mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 08:11:30 +03:00
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 - ⚠️ 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.
This commit is contained in:
parent
973d2c6aea
commit
9cf4847a34
8
app/ide-desktop/.vscode/settings.json
vendored
8
app/ide-desktop/.vscode/settings.json
vendored
@ -4,5 +4,11 @@
|
|||||||
"javascript.preferences.importModuleSpecifierEnding": "minimal",
|
"javascript.preferences.importModuleSpecifierEnding": "minimal",
|
||||||
"typescript.preferences.importModuleSpecifierEnding": "minimal",
|
"typescript.preferences.importModuleSpecifierEnding": "minimal",
|
||||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||||
"typescript.updateImportsOnFileMove.enabled": "always"
|
"typescript.updateImportsOnFileMove.enabled": "always",
|
||||||
|
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||||
|
"react-aria",
|
||||||
|
"react-aria-components",
|
||||||
|
"@react-aria/*",
|
||||||
|
"@react-types/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -123,7 +123,7 @@ const RESTRICTED_SYNTAXES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Matches non-functions.
|
// 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',
|
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',
|
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,
|
...tsEslint.configs.strict?.rules,
|
||||||
...react.configs['jsx-runtime'].rules,
|
...react.configs['jsx-runtime'].rules,
|
||||||
eqeqeq: ['error', 'always', { null: 'never' }],
|
eqeqeq: ['error', 'always', { null: 'never' }],
|
||||||
|
// Any extra semicolons that exist, are required by Prettier.
|
||||||
|
'no-extra-semi': 'off',
|
||||||
'jsdoc/require-jsdoc': [
|
'jsdoc/require-jsdoc': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
|
@ -66,7 +66,7 @@ export function locateNewLabelModalColorButtons(page: test.Page) {
|
|||||||
locateNewLabelModal(page)
|
locateNewLabelModal(page)
|
||||||
.filter({ has: page.getByText('Color') })
|
.filter({ has: page.getByText('Color') })
|
||||||
// The `radio` inputs are invisible, so they cannot be used in the locator.
|
// 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. */
|
/** Find a button for the "Recent" category (if any) on the current page. */
|
||||||
export function locateRecentCategory(page: test.Locator | test.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. */
|
/** Find a button for the "Home" category (if any) on the current page. */
|
||||||
export function locateHomeCategory(page: test.Locator | test.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. */
|
/** Find a button for the "Trash" category (if any) on the current page. */
|
||||||
export function locateTrashCategory(page: test.Locator | test.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 ===
|
// === 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. */
|
/** Find a "name" column heading (if any) on the current page. */
|
||||||
export function locateNameColumnHeading(page: test.Locator | test.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. */
|
/** Find a "modified" column heading (if any) on the current page. */
|
||||||
export function locateModifiedColumnHeading(page: test.Locator | test.Page) {
|
export function locateModifiedColumnHeading(page: test.Locator | test.Page) {
|
||||||
return page
|
return page
|
||||||
.getByTitle('Sort by modification date')
|
.getByLabel('Sort by modification date')
|
||||||
.or(page.getByTitle('Stop sorting by modification date'))
|
.or(page.getByLabel('Stop sorting by modification date'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Container locators ===
|
// === Container locators ===
|
||||||
@ -670,10 +670,28 @@ export async function expectTrashPlaceholderRow(page: test.Page) {
|
|||||||
// === Mouse utilities ===
|
// === 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. */
|
/** 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) {
|
export async function clickAssetRow(assetRow: test.Locator) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
// 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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================
|
// ==========================
|
||||||
|
@ -92,7 +92,7 @@ test.test('suggestions (keyboard)', async ({ page }) => {
|
|||||||
for (const suggestion of await suggestions.all()) {
|
for (const suggestion of await suggestions.all()) {
|
||||||
const name = (await suggestion.textContent()) ?? ''
|
const name = (await suggestion.textContent()) ?? ''
|
||||||
test.expect(name.length).toBeGreaterThan(0)
|
test.expect(name.length).toBeGreaterThan(0)
|
||||||
await page.press('body', 'Tab')
|
await page.press('body', 'ArrowDown')
|
||||||
await test.expect(searchBarInput).toHaveValue('name:' + name)
|
await test.expect(searchBarInput).toHaveValue('name:' + name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -108,11 +108,11 @@ test.test('complex flows', async ({ page }) => {
|
|||||||
await actions.login({ page })
|
await actions.login({ page })
|
||||||
|
|
||||||
await searchBarInput.click()
|
await searchBarInput.click()
|
||||||
await page.press('body', 'Tab')
|
await page.press('body', 'ArrowDown')
|
||||||
await test.expect(searchBarInput).toHaveValue('name:' + firstName)
|
await test.expect(searchBarInput).toHaveValue('name:' + firstName)
|
||||||
await searchBarInput.selectText()
|
await searchBarInput.selectText()
|
||||||
await searchBarInput.press('Backspace')
|
await searchBarInput.press('Backspace')
|
||||||
await test.expect(searchBarInput).toHaveValue('')
|
await test.expect(searchBarInput).toHaveValue('')
|
||||||
await page.press('body', 'Tab')
|
await page.press('body', 'ArrowDown')
|
||||||
await test.expect(searchBarInput).toHaveValue('name:' + firstName)
|
await test.expect(searchBarInput).toHaveValue('name:' + firstName)
|
||||||
})
|
})
|
||||||
|
@ -81,7 +81,7 @@ test.test('move (drag)', async ({ page }) => {
|
|||||||
// Assets: [0: Folder 1]
|
// Assets: [0: Folder 1]
|
||||||
await actions.locateNewFolderIcon(page).click()
|
await actions.locateNewFolderIcon(page).click()
|
||||||
// Assets: [0: Folder 2, 1: Folder 1]
|
// 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 <child { depth=1 }>]
|
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
|
||||||
await test.expect(assetRows).toHaveCount(2)
|
await test.expect(assetRows).toHaveCount(2)
|
||||||
await test.expect(assetRows.nth(1)).toBeVisible()
|
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 page.keyboard.down(await actions.modModifier(page))
|
||||||
await actions.clickAssetRow(assetRows.nth(0))
|
await actions.clickAssetRow(assetRows.nth(0))
|
||||||
await actions.clickAssetRow(assetRows.nth(1))
|
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 page.keyboard.up(await actions.modModifier(page))
|
||||||
|
await actions.dragAssetRow(assetRows.nth(0), actions.locateTrashCategory(page))
|
||||||
await actions.expectPlaceholderRow(page)
|
await actions.expectPlaceholderRow(page)
|
||||||
await actions.locateTrashCategory(page).click()
|
await actions.locateTrashCategory(page).click()
|
||||||
await test.expect(assetRows).toHaveCount(2)
|
await test.expect(assetRows).toHaveCount(2)
|
||||||
|
BIN
app/ide-desktop/lib/dashboard/favicon.ico
Normal file
BIN
app/ide-desktop/lib/dashboard/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
@ -34,23 +34,25 @@
|
|||||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
|
"@monaco-editor/react": "4.6.0",
|
||||||
"@sentry/react": "^7.74.0",
|
"@sentry/react": "^7.74.0",
|
||||||
|
"@tanstack/react-query": "^5.27.5",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
|
"clsx": "^1.1.1",
|
||||||
"enso-common": "^1.0.0",
|
"enso-common": "^1.0.0",
|
||||||
"is-network-error": "^1.0.1",
|
"is-network-error": "^1.0.1",
|
||||||
|
"monaco-editor": "0.47.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-aria": "^3.32.1",
|
||||||
|
"react-aria-components": "^1.1.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-router-dom": "^6.8.1",
|
||||||
|
"react-stately": "^3.30.1",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
"ts-results": "^3.3.0",
|
|
||||||
"validator": "^13.11.0",
|
|
||||||
"monaco-editor": "0.47.0",
|
|
||||||
"@monaco-editor/react": "4.6.0",
|
|
||||||
"@tanstack/react-query": "^5.27.5",
|
|
||||||
"clsx": "^1.1.1",
|
|
||||||
"tiny-invariant": "^1.3.3",
|
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
"react-aria-components": "^1.1.1"
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"ts-results": "^3.3.0",
|
||||||
|
"validator": "^13.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-syntax-import-assertions": "^7.23.3",
|
"@babel/plugin-syntax-import-assertions": "^7.23.3",
|
||||||
@ -60,6 +62,7 @@
|
|||||||
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
||||||
"@playwright/experimental-ct-react": "^1.40.0",
|
"@playwright/experimental-ct-react": "^1.40.0",
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
|
"@react-types/shared": "^3.22.1",
|
||||||
"@types/node": "^20.11.21",
|
"@types/node": "^20.11.21",
|
||||||
"@types/react": "^18.0.27",
|
"@types/react": "^18.0.27",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
|
@ -53,6 +53,7 @@ import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalSt
|
|||||||
import LoggerProvider from '#/providers/LoggerProvider'
|
import LoggerProvider from '#/providers/LoggerProvider'
|
||||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||||
import ModalProvider from '#/providers/ModalProvider'
|
import ModalProvider from '#/providers/ModalProvider'
|
||||||
|
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
|
||||||
import SessionProvider from '#/providers/SessionProvider'
|
import SessionProvider from '#/providers/SessionProvider'
|
||||||
|
|
||||||
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
|
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
|
||||||
@ -186,6 +187,7 @@ function AppRouter(props: AppProps) {
|
|||||||
// eslint-disable-next-line no-restricted-properties
|
// eslint-disable-next-line no-restricted-properties
|
||||||
const navigate = router.useNavigate()
|
const navigate = router.useNavigate()
|
||||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||||
|
const navigator2D = navigator2DProvider.useNavigator2D()
|
||||||
if (detect.IS_DEV_MODE) {
|
if (detect.IS_DEV_MODE) {
|
||||||
// @ts-expect-error This is used exclusively for debugging.
|
// @ts-expect-error This is used exclusively for debugging.
|
||||||
window.navigate = navigate
|
window.navigate = navigate
|
||||||
@ -276,6 +278,14 @@ function AppRouter(props: AppProps) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
null!
|
null!
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKeyDown = navigator2D.onKeyDown.bind(navigator2D)
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
}
|
||||||
|
}, [navigator2D])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let isClick = false
|
let isClick = false
|
||||||
const onMouseDown = () => {
|
const onMouseDown = () => {
|
||||||
|
@ -1,29 +1,27 @@
|
|||||||
/**
|
/** @file A styled button. */
|
||||||
* @file Button.tsx
|
|
||||||
*
|
|
||||||
* Button component
|
|
||||||
*/
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import * as reactAriaComponents from 'react-aria-components'
|
|
||||||
import * as tailwindMerge from 'tailwind-merge'
|
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 Spinner, * as spinnerModule from '#/components/Spinner'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
/**
|
// ==============
|
||||||
* Props for the Button component
|
// === Button ===
|
||||||
*/
|
// ==============
|
||||||
export interface ButtonProps extends reactAriaComponents.ButtonProps {
|
|
||||||
|
/** Props for a {@link Button}. */
|
||||||
|
export interface ButtonProps extends Readonly<aria.ButtonProps> {
|
||||||
readonly loading?: boolean
|
readonly loading?: boolean
|
||||||
readonly variant: 'cancel' | 'delete' | 'icon' | 'submit'
|
readonly variant: 'cancel' | 'delete' | 'icon' | 'submit'
|
||||||
readonly icon?: string
|
readonly icon?: string
|
||||||
/**
|
/** FIXME: This is not yet implemented
|
||||||
* FIXME: This is not yet implemented
|
|
||||||
* The position of the icon in the button
|
* The position of the icon in the button
|
||||||
* @default 'start'
|
* @default 'start' */
|
||||||
*/
|
|
||||||
readonly iconPosition?: 'end' | '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 DISABLED_CLASSES = 'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
const SIZE_CLASSES = 'px-2 py-1'
|
const SIZE_CLASSES = 'px-2 py-1'
|
||||||
|
|
||||||
/**
|
const CLASSES_FOR_VARIANT: Record<ButtonProps['variant'], string> = {
|
||||||
* A button allows a user to perform an action, with mouse, touch, and keyboard interactions.
|
cancel: CANCEL_CLASSES,
|
||||||
*/
|
delete: DELETE_CLASSES,
|
||||||
export function Button(props: ButtonProps): React.JSX.Element {
|
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 { className, children, variant, icon, loading = false, ...ariaButtonProps } = props
|
||||||
|
const focusChildProps = focusHooks.useFocusChild()
|
||||||
|
|
||||||
const classes = clsx(
|
const classes = clsx(
|
||||||
DEFAULT_CLASSES,
|
DEFAULT_CLASSES,
|
||||||
DISABLED_CLASSES,
|
DISABLED_CLASSES,
|
||||||
FOCUS_CLASSES,
|
FOCUS_CLASSES,
|
||||||
SIZE_CLASSES,
|
SIZE_CLASSES,
|
||||||
VARIANT_TO_CLASSES[variant]
|
CLASSES_FOR_VARIANT[variant]
|
||||||
)
|
)
|
||||||
|
|
||||||
const childrenFactory = (): React.ReactNode => {
|
const childrenFactory = (): React.ReactNode => {
|
||||||
@ -58,11 +62,9 @@ export function Button(props: ButtonProps): React.JSX.Element {
|
|||||||
return <Spinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
|
return <Spinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
|
||||||
} else if (variant === 'icon' && icon != null) {
|
} else if (variant === 'icon' && icon != null) {
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className={EXTRA_CLICK_ZONE_CLASSES}>
|
<div className={EXTRA_CLICK_ZONE_CLASSES}>
|
||||||
<SvgMask src={icon} />
|
<SvgMask src={icon} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
@ -70,23 +72,16 @@ export function Button(props: ButtonProps): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<reactAriaComponents.Button
|
<aria.Button
|
||||||
className={values =>
|
{...aria.mergeProps<aria.ButtonProps>()(ariaButtonProps, focusChildProps, {
|
||||||
|
className: values =>
|
||||||
tailwindMerge.twMerge(
|
tailwindMerge.twMerge(
|
||||||
classes,
|
classes,
|
||||||
typeof className === 'function' ? className(values) : className
|
typeof className === 'function' ? className(values) : className
|
||||||
)
|
),
|
||||||
}
|
})}
|
||||||
{...ariaButtonProps}
|
|
||||||
>
|
>
|
||||||
{childrenFactory()}
|
{childrenFactory()}
|
||||||
</reactAriaComponents.Button>
|
</aria.Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_TO_CLASSES: Record<ButtonProps['variant'], string> = {
|
|
||||||
cancel: CANCEL_CLASSES,
|
|
||||||
delete: DELETE_CLASSES,
|
|
||||||
icon: ICON_CLASSES,
|
|
||||||
submit: SUBMIT_CLASSES,
|
|
||||||
}
|
|
||||||
|
@ -5,11 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as reactAriaComponents from 'react-aria-components'
|
|
||||||
import * as tailwindMerge from 'tailwind-merge'
|
import * as tailwindMerge from 'tailwind-merge'
|
||||||
|
|
||||||
import Dismiss from 'enso-assets/dismiss.svg'
|
import Dismiss from 'enso-assets/dismiss.svg'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import * as ariaComponents from '#/components/AriaComponents'
|
import * as ariaComponents from '#/components/AriaComponents'
|
||||||
import * as portal from '#/components/Portal'
|
import * as portal from '#/components/Portal'
|
||||||
|
|
||||||
@ -50,21 +50,23 @@ export function Dialog(props: types.DialogProps) {
|
|||||||
const root = portal.useStrictPortalContext()
|
const root = portal.useStrictPortalContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<reactAriaComponents.Modal
|
<aria.Modal
|
||||||
className={tailwindMerge.twMerge(MODAL_CLASSES, [MODAL_CLASSES_BY_TYPE[type]])}
|
className={tailwindMerge.twMerge(MODAL_CLASSES, [MODAL_CLASSES_BY_TYPE[type]])}
|
||||||
isDismissable={isDismissible}
|
isDismissable={isDismissible}
|
||||||
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
|
||||||
UNSTABLE_portalContainer={root.current}
|
UNSTABLE_portalContainer={root.current}
|
||||||
>
|
>
|
||||||
<reactAriaComponents.Dialog
|
<aria.Dialog
|
||||||
className={tailwindMerge.twMerge(DIALOG_CLASSES, [DIALOG_CLASSES_BY_TYPE[type]], className)}
|
className={tailwindMerge.twMerge(DIALOG_CLASSES, [DIALOG_CLASSES_BY_TYPE[type]], className)}
|
||||||
{...ariaDialogProps}
|
{...ariaDialogProps}
|
||||||
>
|
>
|
||||||
{opts => (
|
{opts => (
|
||||||
<>
|
<>
|
||||||
{typeof title === 'string' && (
|
{typeof title === 'string' && (
|
||||||
<reactAriaComponents.Header className="center sticky flex flex-none border-b px-3.5 py-2.5 text-primary shadow">
|
<aria.Header className="center sticky flex flex-none border-b px-3.5 py-2.5 text-primary shadow">
|
||||||
<h2 className="text-l my-0 font-semibold leading-6">{title}</h2>
|
<aria.Heading level={2} className="text-l my-0 font-semibold leading-6">
|
||||||
|
{title}
|
||||||
|
</aria.Heading>
|
||||||
|
|
||||||
<ariaComponents.Button
|
<ariaComponents.Button
|
||||||
variant="icon"
|
variant="icon"
|
||||||
@ -72,7 +74,7 @@ export function Dialog(props: types.DialogProps) {
|
|||||||
onPress={opts.close}
|
onPress={opts.close}
|
||||||
icon={Dismiss}
|
icon={Dismiss}
|
||||||
/>
|
/>
|
||||||
</reactAriaComponents.Header>
|
</aria.Header>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 shrink-0">
|
<div className="flex-1 shrink-0">
|
||||||
@ -80,7 +82,7 @@ export function Dialog(props: types.DialogProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</reactAriaComponents.Dialog>
|
</aria.Dialog>
|
||||||
</reactAriaComponents.Modal>
|
</aria.Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 React from 'react'
|
||||||
|
|
||||||
import * as reactAriaComponents from 'react-aria-components'
|
|
||||||
|
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
|
||||||
import type * as types from './types'
|
import type * as types from './types'
|
||||||
|
|
||||||
const PLACEHOLDER = <div />
|
const PLACEHOLDER = <div />
|
||||||
|
|
||||||
/**
|
/** 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) {
|
export function DialogTrigger(props: types.DialogTriggerProps) {
|
||||||
const { children, onOpenChange, ...triggerProps } = props
|
const { children, onOpenChange, ...triggerProps } = props
|
||||||
|
|
||||||
@ -24,7 +18,8 @@ export function DialogTrigger(props: types.DialogTriggerProps) {
|
|||||||
const onOpenChangeInternal = React.useCallback(
|
const onOpenChangeInternal = React.useCallback(
|
||||||
(isOpened: boolean) => {
|
(isOpened: boolean) => {
|
||||||
if (isOpened) {
|
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)
|
setModal(PLACEHOLDER)
|
||||||
} else {
|
} else {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
@ -36,10 +31,6 @@ export function DialogTrigger(props: types.DialogTriggerProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<reactAriaComponents.DialogTrigger
|
<aria.DialogTrigger children={children} onOpenChange={onOpenChangeInternal} {...triggerProps} />
|
||||||
children={children}
|
|
||||||
onOpenChange={onOpenChangeInternal}
|
|
||||||
{...triggerProps}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,22 +1,13 @@
|
|||||||
/**
|
/** @file Types for the Dialog component. */
|
||||||
* @file
|
import type * as aria from '#/components/aria'
|
||||||
* Contains the types for the Dialog component.
|
|
||||||
*/
|
|
||||||
import type * as reactAriaComponents from 'react-aria-components'
|
|
||||||
|
|
||||||
/**
|
/** The type of Dialog. */
|
||||||
*
|
|
||||||
*/
|
|
||||||
export type DialogType = 'fullscreen' | 'modal' | 'popover'
|
export type DialogType = 'fullscreen' | 'modal' | 'popover'
|
||||||
|
|
||||||
/**
|
/** Props for the Dialog component. */
|
||||||
* The props for the Dialog component.
|
export interface DialogProps extends aria.DialogProps {
|
||||||
*/
|
/** The type of dialog to render.
|
||||||
export interface DialogProps extends reactAriaComponents.DialogProps {
|
* @default 'modal' */
|
||||||
/**
|
|
||||||
* The type of dialog to render.
|
|
||||||
* @default 'modal'
|
|
||||||
*/
|
|
||||||
readonly type?: DialogType
|
readonly type?: DialogType
|
||||||
readonly title?: string
|
readonly title?: string
|
||||||
readonly isDismissible?: boolean
|
readonly isDismissible?: boolean
|
||||||
@ -24,7 +15,5 @@ export interface DialogProps extends reactAriaComponents.DialogProps {
|
|||||||
readonly isKeyboardDismissDisabled?: boolean
|
readonly isKeyboardDismissDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The props for the DialogTrigger component. */
|
||||||
* The props for the DialogTrigger component.
|
export interface DialogTriggerProps extends aria.DialogTriggerProps {}
|
||||||
*/
|
|
||||||
export interface DialogTriggerProps extends reactAriaComponents.DialogTriggerProps {}
|
|
||||||
|
@ -1,27 +1,19 @@
|
|||||||
/**
|
/** @file Displays the description of an element on hover or focus. */
|
||||||
* @file
|
|
||||||
*
|
|
||||||
* A tooltip displays a description of an element on hover or focus.
|
|
||||||
*/
|
|
||||||
import * as reactAriaComponents from 'react-aria-components'
|
|
||||||
import * as tailwindMerge from 'tailwind-merge'
|
import * as tailwindMerge from 'tailwind-merge'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import * as portal from '#/components/Portal'
|
import * as portal from '#/components/Portal'
|
||||||
|
|
||||||
/**
|
/** Props for a {@link Tooltip}. */
|
||||||
*
|
|
||||||
*/
|
|
||||||
export interface TooltipProps
|
export interface TooltipProps
|
||||||
extends Omit<reactAriaComponents.TooltipProps, 'offset' | 'UNSTABLE_portalContainer'> {}
|
extends Omit<Readonly<aria.TooltipProps>, 'offset' | 'UNSTABLE_portalContainer'> {}
|
||||||
|
|
||||||
const DEFAULT_CLASSES = 'z-1 flex bg-neutral-800 text-white p-2 rounded-md shadow-lg text-xs'
|
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_CONTAINER_PADDING = 4
|
||||||
const DEFAULT_OFFSET = 4
|
const DEFAULT_OFFSET = 4
|
||||||
|
|
||||||
/**
|
/** Displays the description of an element on hover or focus. */
|
||||||
* A tooltip displays a description of an element on hover or focus.
|
|
||||||
*/
|
|
||||||
export function Tooltip(props: TooltipProps) {
|
export function Tooltip(props: TooltipProps) {
|
||||||
const { className, containerPadding = DEFAULT_CONTAINER_PADDING, ...ariaTooltipProps } = props
|
const { className, containerPadding = DEFAULT_CONTAINER_PADDING, ...ariaTooltipProps } = props
|
||||||
|
|
||||||
@ -30,7 +22,7 @@ export function Tooltip(props: TooltipProps) {
|
|||||||
const classes = tailwindMerge.twJoin(DEFAULT_CLASSES)
|
const classes = tailwindMerge.twJoin(DEFAULT_CLASSES)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<reactAriaComponents.Tooltip
|
<aria.Tooltip
|
||||||
offset={DEFAULT_OFFSET}
|
offset={DEFAULT_OFFSET}
|
||||||
containerPadding={containerPadding}
|
containerPadding={containerPadding}
|
||||||
UNSTABLE_portalContainer={root.current}
|
UNSTABLE_portalContainer={root.current}
|
||||||
@ -45,6 +37,6 @@ export function Tooltip(props: TooltipProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export the TooltipTrigger component from react-aria-components
|
// Re-export the TooltipTrigger component from `react-aria-components`
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
export { TooltipTrigger } from 'react-aria-components'
|
export const TooltipTrigger = aria.TooltipTrigger
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
/** @file A select menu with a dropdown. */
|
/** @file A select menu with a dropdown. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
|
import Input from '#/components/styled/Input'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
@ -175,16 +178,17 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div onKeyDown={onKeyDown} className="grow">
|
<div onKeyDown={onKeyDown} className="grow">
|
||||||
<div className="flex flex-1">
|
<FocusRing within>
|
||||||
|
<div className="flex flex-1 rounded-full">
|
||||||
{canEditText ? (
|
{canEditText ? (
|
||||||
<input
|
<Input
|
||||||
type={type}
|
type={type}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
size={1}
|
size={1}
|
||||||
value={text ?? ''}
|
value={text ?? ''}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="text grow bg-transparent px-button-x"
|
className="text grow rounded-full bg-transparent px-button-x"
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setIsDropdownVisible(true)
|
setIsDropdownVisible(true)
|
||||||
}}
|
}}
|
||||||
@ -216,6 +220,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</FocusRing>
|
||||||
<div className="h">
|
<div className="h">
|
||||||
<div
|
<div
|
||||||
className={`relative top-2 z-1 h-max w-full rounded-default shadow-soft before:absolute before:top before:h-full before:w-full before:rounded-default before:bg-frame before:backdrop-blur-default ${
|
className={`relative top-2 z-1 h-max w-full rounded-default shadow-soft before:absolute before:top before:h-full before:w-full before:rounded-default before:bg-frame before:backdrop-blur-default ${
|
||||||
|
@ -1,45 +0,0 @@
|
|||||||
/** @file A styled button. */
|
|
||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
import SvgMask from '#/components/SvgMask'
|
|
||||||
|
|
||||||
/** Props for a {@link Button}. */
|
|
||||||
export interface ButtonProps {
|
|
||||||
/** When `true`, the button is not faded out even when not hovered. */
|
|
||||||
readonly active?: boolean
|
|
||||||
/** When `true`, the button is not clickable. */
|
|
||||||
readonly disabled?: boolean
|
|
||||||
readonly image: string
|
|
||||||
readonly alt?: string
|
|
||||||
/** A title that is only shown when `disabled` is `true`. */
|
|
||||||
readonly error?: string | null
|
|
||||||
/** The default title. */
|
|
||||||
readonly title?: string
|
|
||||||
readonly className?: string
|
|
||||||
readonly onClick: (event: React.MouseEvent) => 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 (
|
|
||||||
<button
|
|
||||||
disabled={disabled}
|
|
||||||
className={`group flex selectable ${active ? 'active' : ''}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<SvgMask
|
|
||||||
src={image}
|
|
||||||
{...(!active && disabled && error != null
|
|
||||||
? { title: error }
|
|
||||||
: title != null
|
|
||||||
? { title }
|
|
||||||
: {})}
|
|
||||||
{...(alt != null ? { alt } : {})}
|
|
||||||
className={className}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,37 +1,82 @@
|
|||||||
/** @file A color picker to select from a predetermined list of colors. */
|
/** @file A color picker to select from a predetermined list of colors. */
|
||||||
import * as React from 'react'
|
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'
|
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 (
|
||||||
|
<FocusRing within>
|
||||||
|
<aria.Radio
|
||||||
|
ref={element => {
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<div className="hidden size-radio-button-dot rounded-full bg-selected-frame group-selected:block" />
|
||||||
|
</aria.Radio>
|
||||||
|
</FocusRing>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================
|
||||||
|
// === ColorPicker ===
|
||||||
|
// ===================
|
||||||
|
|
||||||
/** Props for a {@link ColorPicker}. */
|
/** Props for a {@link ColorPicker}. */
|
||||||
export interface ColorPickerProps {
|
export interface ColorPickerProps extends Readonly<aria.RadioGroupProps> {
|
||||||
|
readonly children?: React.ReactNode
|
||||||
|
readonly pickerClassName?: string
|
||||||
readonly setColor: (color: backend.LChColor) => void
|
readonly setColor: (color: backend.LChColor) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A color picker to select from a predetermined list of colors. */
|
/** A color picker to select from a predetermined list of colors. */
|
||||||
export default function ColorPicker(props: ColorPickerProps) {
|
function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||||
const { setColor } = props
|
const { pickerClassName = '', children, setColor, ...radioGroupProps } = props
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-colors">
|
<RadioGroup
|
||||||
{backend.COLORS.map((currentColor, i) => (
|
ref={ref}
|
||||||
<label
|
{...radioGroupProps}
|
||||||
key={i}
|
orientation="horizontal"
|
||||||
className="flex size-radio-button cursor-pointer rounded-full"
|
onChange={value => {
|
||||||
onClick={event => {
|
const color = backend.COLOR_STRING_TO_COLOR.get(value)
|
||||||
event.stopPropagation()
|
if (color != null) {
|
||||||
setColor(currentColor)
|
setColor(color)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="radio" name="new-label-color" className="peer hidden" />
|
{children}
|
||||||
<button
|
<div className={`flex items-center gap-colors ${pickerClassName}`}>
|
||||||
type="button"
|
{backend.COLORS.map((currentColor, i) => (
|
||||||
className="group pointer-events-none size-radio-button rounded-full p-radio-button-dot"
|
<ColorPickerItem key={i} color={currentColor} />
|
||||||
style={{ backgroundColor: backend.lChColorToCssColor(currentColor) }}
|
|
||||||
>
|
|
||||||
<div className="hidden size-radio-button-dot rounded-full bg-selected-frame peer-checked:group-[]:block" />
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A color picker to select from a predetermined list of colors. */
|
||||||
|
export default React.forwardRef(ColorPicker)
|
||||||
|
@ -3,12 +3,16 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import * as detect from 'enso-common/src/detect'
|
import * as detect from 'enso-common/src/detect'
|
||||||
|
|
||||||
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
|
|
||||||
// ===================
|
// ===================
|
||||||
// === ContextMenu ===
|
// === ContextMenu ===
|
||||||
// ===================
|
// ===================
|
||||||
|
|
||||||
/** Props for a {@link ContextMenu}. */
|
/** Props for a {@link ContextMenu}. */
|
||||||
export interface ContextMenuProps extends Readonly<React.PropsWithChildren> {
|
export interface ContextMenuProps extends Readonly<React.PropsWithChildren> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
readonly 'aria-label': string
|
||||||
readonly hidden?: boolean
|
readonly hidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,19 +21,24 @@ export default function ContextMenu(props: ContextMenuProps) {
|
|||||||
const { hidden = false, children } = props
|
const { hidden = false, children } = props
|
||||||
|
|
||||||
return hidden ? (
|
return hidden ? (
|
||||||
<>{children}</>
|
children
|
||||||
) : (
|
) : (
|
||||||
<div className="pointer-events-auto relative rounded-default before:absolute before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default">
|
<FocusArea direction="vertical">
|
||||||
|
{innerProps => (
|
||||||
<div
|
<div
|
||||||
|
className="pointer-events-auto relative rounded-default before:absolute before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
|
||||||
|
{...innerProps}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-label={props['aria-label']}
|
||||||
className={`relative flex flex-col rounded-default ${
|
className={`relative flex flex-col rounded-default ${
|
||||||
detect.isOnMacOS() ? 'w-context-menu-macos' : 'w-context-menu'
|
detect.isOnMacOS() ? 'w-context-menu-macos' : 'w-context-menu'
|
||||||
} p-context-menu`}
|
} p-context-menu`}
|
||||||
onClick={clickEvent => {
|
|
||||||
clickEvent.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 : (
|
|
||||||
<div className="py-context-menu-separator-y">
|
|
||||||
<div className="border-t-[0.5px] border-black/[0.16]" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,6 +1,11 @@
|
|||||||
/** @file Styled input element. */
|
/** @file Styled input element. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as focusHooks from '#/hooks/focusHooks'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
@ -13,8 +18,7 @@ const DEBOUNCE_MS = 1000
|
|||||||
// =======================
|
// =======================
|
||||||
|
|
||||||
/** Props for a {@link ControlledInput}. */
|
/** Props for a {@link ControlledInput}. */
|
||||||
export interface ControlledInputProps
|
export interface ControlledInputProps extends Readonly<aria.InputProps> {
|
||||||
extends Readonly<React.InputHTMLAttributes<HTMLInputElement>> {
|
|
||||||
readonly value: string
|
readonly value: string
|
||||||
readonly error?: string
|
readonly error?: string
|
||||||
readonly validate?: boolean
|
readonly validate?: boolean
|
||||||
@ -29,17 +33,28 @@ export default function ControlledInput(props: ControlledInputProps) {
|
|||||||
error,
|
error,
|
||||||
validate = false,
|
validate = false,
|
||||||
shouldReportValidityRef,
|
shouldReportValidityRef,
|
||||||
|
onKeyDown,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
...passThrough
|
...inputProps
|
||||||
} = props
|
} = props
|
||||||
const [reportTimeoutHandle, setReportTimeoutHandle] = React.useState<number | null>(null)
|
const [reportTimeoutHandle, setReportTimeoutHandle] = React.useState<number | null>(null)
|
||||||
const [hasReportedValidity, setHasReportedValidity] = React.useState(false)
|
const [hasReportedValidity, setHasReportedValidity] = React.useState(false)
|
||||||
const [wasJustBlurred, setWasJustBlurred] = React.useState(false)
|
const [wasJustBlurred, setWasJustBlurred] = React.useState(false)
|
||||||
|
const focusChildProps = focusHooks.useFocusChild()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<FocusRing>
|
||||||
{...passThrough}
|
<aria.Input
|
||||||
onChange={event => {
|
{...aria.mergeProps<aria.InputProps>()(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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChange: event => {
|
||||||
onChange?.(event)
|
onChange?.(event)
|
||||||
setValue(event.target.value)
|
setValue(event.target.value)
|
||||||
setWasJustBlurred(false)
|
setWasJustBlurred(false)
|
||||||
@ -63,16 +78,18 @@ export default function ControlledInput(props: ControlledInputProps) {
|
|||||||
} else {
|
} else {
|
||||||
setReportTimeoutHandle(
|
setReportTimeoutHandle(
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (shouldReportValidityRef?.current !== false && !currentTarget.reportValidity()) {
|
if (
|
||||||
|
shouldReportValidityRef?.current !== false &&
|
||||||
|
!currentTarget.reportValidity()
|
||||||
|
) {
|
||||||
setHasReportedValidity(true)
|
setHasReportedValidity(true)
|
||||||
}
|
}
|
||||||
}, DEBOUNCE_MS)
|
}, DEBOUNCE_MS)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
},
|
||||||
onBlur={
|
onBlur: validate
|
||||||
validate
|
|
||||||
? event => {
|
? event => {
|
||||||
onBlur?.(event)
|
onBlur?.(event)
|
||||||
if (wasJustBlurred) {
|
if (wasJustBlurred) {
|
||||||
@ -87,9 +104,9 @@ export default function ControlledInput(props: ControlledInputProps) {
|
|||||||
setWasJustBlurred(true)
|
setWasJustBlurred(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: onBlur
|
: 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"
|
|
||||||
/>
|
/>
|
||||||
|
</FocusRing>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -5,9 +5,14 @@ import CrossIcon from 'enso-assets/cross.svg'
|
|||||||
import FolderArrowDoubleIcon from 'enso-assets/folder_arrow_double.svg'
|
import FolderArrowDoubleIcon from 'enso-assets/folder_arrow_double.svg'
|
||||||
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
|
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
|
||||||
|
|
||||||
|
import * as focusHooks from '#/hooks/focusHooks'
|
||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as dateTime from '#/utilities/dateTime'
|
import * as dateTime from '#/utilities/dateTime'
|
||||||
|
|
||||||
@ -44,6 +49,7 @@ export interface DateInputProps {
|
|||||||
export default function DateInput(props: DateInputProps) {
|
export default function DateInput(props: DateInputProps) {
|
||||||
const { date, onInput } = props
|
const { date, onInput } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
const focusChildProps = focusHooks.useFocusChild()
|
||||||
const year = date?.getFullYear() ?? new Date().getFullYear()
|
const year = date?.getFullYear() ?? new Date().getFullYear()
|
||||||
const monthIndex = date?.getMonth() ?? new Date().getMonth()
|
const monthIndex = date?.getMonth() ?? new Date().getMonth()
|
||||||
const [isPickerVisible, setIsPickerVisible] = React.useState(false)
|
const [isPickerVisible, setIsPickerVisible] = React.useState(false)
|
||||||
@ -94,44 +100,55 @@ export default function DateInput(props: DateInputProps) {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<FocusRing>
|
||||||
<div
|
<div
|
||||||
role="button"
|
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(focusChildProps, {
|
||||||
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' : ''}`}
|
role: 'button',
|
||||||
onClick={() => {
|
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)
|
setIsPickerVisible(!isPickerVisible)
|
||||||
}}
|
},
|
||||||
|
onKeyDown: event => {
|
||||||
|
if (event.key === 'Enter' || event.key === 'Space') {
|
||||||
|
event.stopPropagation()
|
||||||
|
setIsPickerVisible(!isPickerVisible)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<div className="flex grow flex-col items-center">
|
<div className="flex grow flex-col items-center">
|
||||||
{date != null ? dateTime.formatDate(date) : 'No date selected'}
|
{date != null ? dateTime.formatDate(date) : getText('noDateSelected')}
|
||||||
</div>
|
</div>
|
||||||
{date != null && (
|
{date != null && (
|
||||||
<button
|
<UnstyledButton
|
||||||
className="flex rounded-full transition-colors hover:bg-hover-bg"
|
className="flex rounded-full transition-colors hover:bg-hover-bg"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
onInput(null)
|
onInput(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SvgMask src={CrossIcon} className="size-icon" />
|
<SvgMask src={CrossIcon} className="size-icon" />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</FocusRing>
|
||||||
{isPickerVisible && (
|
{isPickerVisible && (
|
||||||
<div className="absolute left-1/2 top-text-h mt-date-input-gap">
|
<div className="absolute left-1/2 top-text-h mt-date-input-gap">
|
||||||
<div className="relative -translate-x-1/2 rounded-2xl border border-primary/10 p-date-input shadow-soft before:absolute before:inset-0 before:rounded-2xl before:backdrop-blur-3xl">
|
<div className="relative -translate-x-1/2 rounded-2xl border border-primary/10 p-date-input shadow-soft before:absolute before:inset-0 before:rounded-2xl before:backdrop-blur-3xl">
|
||||||
<table className="relative w-full">
|
<div className="relative mb-date-input-gap">
|
||||||
<caption className="mb-date-input-gap caption-top">
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<UnstyledButton
|
||||||
className="inline-flex rounded-small-rectangle-button hover:bg-hover-bg"
|
className="inline-flex rounded-small-rectangle-button hover:bg-hover-bg"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
setSelectedYear(selectedYear - 1)
|
setSelectedYear(selectedYear - 1)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SvgMask src={FolderArrowDoubleIcon} className="rotate-180" />
|
<SvgMask src={FolderArrowDoubleIcon} className="rotate-180" />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
<button
|
<UnstyledButton
|
||||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
if (selectedMonthIndex === 0) {
|
if (selectedMonthIndex === 0) {
|
||||||
setSelectedYear(selectedYear - 1)
|
setSelectedYear(selectedYear - 1)
|
||||||
setSelectedMonthIndex(LAST_MONTH_INDEX)
|
setSelectedMonthIndex(LAST_MONTH_INDEX)
|
||||||
@ -141,13 +158,13 @@ export default function DateInput(props: DateInputProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SvgMask src={FolderArrowIcon} className="rotate-180" />
|
<SvgMask src={FolderArrowIcon} className="rotate-180" />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
<span className="grow">
|
<aria.Text className="grow text-center">
|
||||||
{dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear}
|
{dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear}
|
||||||
</span>
|
</aria.Text>
|
||||||
<button
|
<UnstyledButton
|
||||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
if (selectedMonthIndex === LAST_MONTH_INDEX) {
|
if (selectedMonthIndex === LAST_MONTH_INDEX) {
|
||||||
setSelectedYear(selectedYear + 1)
|
setSelectedYear(selectedYear + 1)
|
||||||
setSelectedMonthIndex(0)
|
setSelectedMonthIndex(0)
|
||||||
@ -157,17 +174,18 @@ export default function DateInput(props: DateInputProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SvgMask src={FolderArrowIcon} />
|
<SvgMask src={FolderArrowIcon} />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
<button
|
<UnstyledButton
|
||||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
setSelectedYear(selectedYear + 1)
|
setSelectedYear(selectedYear + 1)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SvgMask src={FolderArrowDoubleIcon} />
|
<SvgMask src={FolderArrowDoubleIcon} />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</div>
|
</div>
|
||||||
</caption>
|
</div>
|
||||||
|
<table className="relative w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-tight min-w-date-cell p">{getText('mondayAbbr')}</th>
|
<th className="text-tight min-w-date-cell p">{getText('mondayAbbr')}</th>
|
||||||
@ -194,20 +212,17 @@ export default function DateInput(props: DateInputProps) {
|
|||||||
currentDate.getMonth() === monthIndex &&
|
currentDate.getMonth() === monthIndex &&
|
||||||
currentDate.getDate() === date.getDate()
|
currentDate.getDate() === date.getDate()
|
||||||
return (
|
return (
|
||||||
<td
|
<td key={j} className="text-tight p">
|
||||||
key={j}
|
<UnstyledButton
|
||||||
className="text-tight p"
|
isDisabled={isSelectedDate}
|
||||||
onClick={() => {
|
className={`w-full rounded-small-rectangle-button text-center hover:bg-primary/10 disabled:bg-frame disabled:font-bold ${day.monthOffset === 0 ? '' : 'opacity-unimportant'}`}
|
||||||
|
onPress={() => {
|
||||||
setIsPickerVisible(false)
|
setIsPickerVisible(false)
|
||||||
onInput(currentDate)
|
onInput(currentDate)
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<button
|
|
||||||
disabled={isSelectedDate}
|
|
||||||
className={`w-full rounded-small-rectangle-button text-center hover:bg-primary/10 disabled:bg-frame disabled:font-bold ${day.monthOffset === 0 ? '' : 'opacity-unimportant'}`}
|
|
||||||
>
|
>
|
||||||
{day.date}
|
{day.date}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
@ -4,6 +4,7 @@ import * as React from 'react'
|
|||||||
import CheckMarkIcon from 'enso-assets/check_mark.svg'
|
import CheckMarkIcon from 'enso-assets/check_mark.svg'
|
||||||
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
|
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
|
||||||
|
|
||||||
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
// ================
|
// ================
|
||||||
@ -49,12 +50,13 @@ interface InternalMultipleDropdownProps<T> extends InternalBaseDropdownProps<T>
|
|||||||
export type DropdownProps<T> = InternalMultipleDropdownProps<T> | InternalSingleDropdownProps<T>
|
export type DropdownProps<T> = InternalMultipleDropdownProps<T> | InternalSingleDropdownProps<T>
|
||||||
|
|
||||||
/** A styled dropdown. */
|
/** A styled dropdown. */
|
||||||
export default function Dropdown<T>(props: DropdownProps<T>) {
|
function Dropdown<T>(props: DropdownProps<T>, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||||
const { readOnly = false, className, items, render: Child } = props
|
const { readOnly = false, className, items, render: Child } = props
|
||||||
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
|
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
|
||||||
const [tempSelectedIndex, setTempSelectedIndex] = React.useState<number | null>(null)
|
const [tempSelectedIndex, setTempSelectedIndex] = React.useState<number | null>(null)
|
||||||
const rootRef = React.useRef<HTMLDivElement>(null)
|
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
const justFocusedRef = React.useRef(false)
|
const justFocusedRef = React.useRef(false)
|
||||||
|
const justBlurredRef = React.useRef(false)
|
||||||
const isMouseDown = React.useRef(false)
|
const isMouseDown = React.useRef(false)
|
||||||
const multiple = props.multiple === true
|
const multiple = props.multiple === true
|
||||||
const selectedIndex = 'selectedIndex' in props ? props.selectedIndex : null
|
const selectedIndex = 'selectedIndex' in props ? props.selectedIndex : null
|
||||||
@ -79,6 +81,7 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onDocumentClick = () => {
|
const onDocumentClick = () => {
|
||||||
setIsDropdownVisible(false)
|
setIsDropdownVisible(false)
|
||||||
|
justBlurredRef.current = true
|
||||||
}
|
}
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
return () => {
|
return () => {
|
||||||
@ -97,6 +100,9 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
|||||||
case 'Enter':
|
case 'Enter':
|
||||||
case 'Tab': {
|
case 'Tab': {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
setIsDropdownVisible(true)
|
||||||
|
}
|
||||||
if (tempSelectedIndex != null) {
|
if (tempSelectedIndex != null) {
|
||||||
const item = items[tempSelectedIndex]
|
const item = items[tempSelectedIndex]
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
@ -116,12 +122,14 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event.key !== 'Enter' || !multiple) {
|
if (isDropdownVisible && (event.key !== 'Enter' || !multiple)) {
|
||||||
setIsDropdownVisible(false)
|
setIsDropdownVisible(false)
|
||||||
|
justBlurredRef.current = true
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'ArrowUp': {
|
case 'ArrowUp': {
|
||||||
|
if (!isDropdownVisible) break
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setTempSelectedIndex(
|
setTempSelectedIndex(
|
||||||
tempSelectedIndex == null ||
|
tempSelectedIndex == null ||
|
||||||
@ -133,6 +141,7 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'ArrowDown': {
|
case 'ArrowDown': {
|
||||||
|
if (!isDropdownVisible) break
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setTempSelectedIndex(
|
setTempSelectedIndex(
|
||||||
tempSelectedIndex == null || tempSelectedIndex >= items.length - 1
|
tempSelectedIndex == null || tempSelectedIndex >= items.length - 1
|
||||||
@ -146,22 +155,31 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FocusRing placement="outset">
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={element => {
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(element)
|
||||||
|
} else if (ref != null) {
|
||||||
|
ref.current = element
|
||||||
|
}
|
||||||
|
rootRef.current = element
|
||||||
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={`group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy ${
|
className={`focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy ${
|
||||||
className ?? ''
|
className ?? ''
|
||||||
}`}
|
}`}
|
||||||
onFocus={event => {
|
onFocus={event => {
|
||||||
if (!readOnly && event.target === event.currentTarget) {
|
if (!justBlurredRef.current && !readOnly && event.target === event.currentTarget) {
|
||||||
setIsDropdownVisible(true)
|
setIsDropdownVisible(true)
|
||||||
justFocusedRef.current = true
|
justFocusedRef.current = true
|
||||||
}
|
}
|
||||||
|
justBlurredRef.current = false
|
||||||
}}
|
}}
|
||||||
onBlur={event => {
|
onBlur={event => {
|
||||||
// TODO: should not blur when `multiple` and clicking on option
|
|
||||||
if (!readOnly && event.target === event.currentTarget) {
|
if (!readOnly && event.target === event.currentTarget) {
|
||||||
setIsDropdownVisible(false)
|
setIsDropdownVisible(false)
|
||||||
|
justBlurredRef.current = true
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
@ -179,7 +197,7 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
|||||||
className={`relative before:absolute before:top before:w-full before:rounded-input before:border before:border-primary/10 before:backdrop-blur-default before:transition-colors ${
|
className={`relative before:absolute before:top before:w-full before:rounded-input before:border before:border-primary/10 before:backdrop-blur-default before:transition-colors ${
|
||||||
isDropdownVisible
|
isDropdownVisible
|
||||||
? 'before:h-full before:shadow-soft'
|
? 'before:h-full before:shadow-soft'
|
||||||
: 'before:h-text group-hover:before:bg-frame'
|
: 'before:h-text group-hover:before:bg-hover-bg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Spacing. */}
|
{/* Spacing. */}
|
||||||
@ -206,8 +224,8 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
|||||||
multiple ? 'hover:font-semibold' : ''
|
multiple ? 'hover:font-semibold' : ''
|
||||||
} ${
|
} ${
|
||||||
i === visuallySelectedIndex
|
i === visuallySelectedIndex
|
||||||
? `cursor-default bg-frame font-bold`
|
? `cursor-default bg-frame font-bold focus-ring`
|
||||||
: 'hover:bg-primary/10'
|
: 'hover:bg-hover-bg'
|
||||||
}`}
|
}`}
|
||||||
key={i}
|
key={i}
|
||||||
onMouseDown={event => {
|
onMouseDown={event => {
|
||||||
@ -234,6 +252,7 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
|||||||
} else {
|
} else {
|
||||||
setIsDropdownVisible(false)
|
setIsDropdownVisible(false)
|
||||||
props.onClick(item, i)
|
props.onClick(item, i)
|
||||||
|
justBlurredRef.current = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -267,6 +286,7 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (!justFocusedRef.current && !readOnly) {
|
if (!justFocusedRef.current && !readOnly) {
|
||||||
setIsDropdownVisible(false)
|
setIsDropdownVisible(false)
|
||||||
|
justBlurredRef.current = true
|
||||||
}
|
}
|
||||||
justFocusedRef.current = false
|
justFocusedRef.current = false
|
||||||
}}
|
}}
|
||||||
@ -284,15 +304,20 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
|||||||
* Classes that do not affect width have been removed. */}
|
* Classes that do not affect width have been removed. */}
|
||||||
<div className="flex h flex-col overflow-hidden">
|
<div className="flex h flex-col overflow-hidden">
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<div
|
<div key={i} className="flex gap-dropdown-arrow px-input-x font-bold">
|
||||||
key={i}
|
|
||||||
className={`flex gap-dropdown-arrow px-input-x ${i === visuallySelectedIndex ? 'font-bold' : ''}`}
|
|
||||||
>
|
|
||||||
<SvgMask src={CheckMarkIcon} />
|
<SvgMask src={CheckMarkIcon} />
|
||||||
<Child item={item} />
|
<Child item={item} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FocusRing>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 <T>(
|
||||||
|
props: DropdownProps<T> & React.RefAttributes<HTMLDivElement>
|
||||||
|
) => JSX.Element
|
||||||
|
@ -9,8 +9,12 @@ import * as eventCalback from '#/hooks/eventCallbackHooks'
|
|||||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
|
import * as eventModule from '#/utilities/event'
|
||||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
@ -34,30 +38,32 @@ export interface EditableSpanProps {
|
|||||||
|
|
||||||
/** A `<span>` that can turn into an `<input type="text">`. */
|
/** A `<span>` that can turn into an `<input type="text">`. */
|
||||||
export default function EditableSpan(props: EditableSpanProps) {
|
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 { checkSubmittable, onSubmit, onCancel, inputPattern, inputTitle } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||||
const [isSubmittable, setIsSubmittable] = React.useState(true)
|
const [isSubmittable, setIsSubmittable] = React.useState(false)
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
const inputRef = React.useRef<HTMLInputElement>(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.
|
// Making sure that the event callback is stable.
|
||||||
// to prevent the effect from re-running.
|
// to prevent the effect from re-running.
|
||||||
const onCancelEventCallback = eventCalback.useEventCallback(onCancel)
|
const onCancelEventCallback = eventCalback.useEventCallback(onCancel)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsSubmittable(checkSubmittable?.(inputRef.current?.value ?? '') ?? true)
|
if (editable) {
|
||||||
// This effect MUST only run on mount.
|
setIsSubmittable(checkSubmittableRef.current?.(inputRef.current?.value ?? '') ?? true)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}
|
||||||
}, [])
|
}, [editable])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (editable) {
|
if (editable) {
|
||||||
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
||||||
cancelEditName: () => {
|
cancelEditName: () => {
|
||||||
onCancelEventCallback()
|
onCancelEventCallback()
|
||||||
cancelled.current = true
|
cancelledRef.current = true
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -67,7 +73,7 @@ export default function EditableSpan(props: EditableSpanProps) {
|
|||||||
}, [editable, /* should never change */ inputBindings, onCancelEventCallback])
|
}, [editable, /* should never change */ inputBindings, onCancelEventCallback])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
cancelled.current = false
|
cancelledRef.current = false
|
||||||
}, [editable])
|
}, [editable])
|
||||||
|
|
||||||
if (editable) {
|
if (editable) {
|
||||||
@ -83,25 +89,28 @@ export default function EditableSpan(props: EditableSpanProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<aria.Input
|
||||||
data-testid={dataTestId}
|
data-testid={props['data-testid']}
|
||||||
className={className}
|
className={className ?? ''}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
size={1}
|
size={1}
|
||||||
defaultValue={children}
|
defaultValue={children}
|
||||||
onBlur={event => {
|
onBlur={event => {
|
||||||
if (!cancelled.current) {
|
const currentTarget = event.currentTarget
|
||||||
event.currentTarget.form?.requestSubmit()
|
// This must run AFTER the cancel button's event handler runs.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!cancelledRef.current) {
|
||||||
|
currentTarget.form?.requestSubmit()
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
onContextMenu={event => {
|
onContextMenu={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
if (event.key !== 'Escape') {
|
if (event.key !== 'Escape') {
|
||||||
// The input may handle the event.
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -116,36 +125,34 @@ export default function EditableSpan(props: EditableSpanProps) {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{isSubmittable && (
|
{isSubmittable && (
|
||||||
<button
|
<UnstyledButton
|
||||||
type="submit"
|
|
||||||
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
|
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
|
||||||
|
onPress={eventModule.submitForm}
|
||||||
>
|
>
|
||||||
<SvgMask src={TickIcon} alt={getText('confirmEdit')} className="size-icon" />
|
<SvgMask src={TickIcon} alt={getText('confirmEdit')} className="size-icon" />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)}
|
)}
|
||||||
<button
|
<FocusRing>
|
||||||
type="button"
|
<UnstyledButton
|
||||||
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
|
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
|
||||||
onMouseDown={() => {
|
onPress={() => {
|
||||||
cancelled.current = true
|
cancelledRef.current = true
|
||||||
}}
|
|
||||||
onClick={event => {
|
|
||||||
event.stopPropagation()
|
|
||||||
onCancel()
|
onCancel()
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
cancelled.current = false
|
cancelledRef.current = false
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-icon" />
|
<SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-icon" />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
|
</FocusRing>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span data-testid={dataTestId} className={className}>
|
<aria.Text data-testid={props['data-testid']} className={className}>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</aria.Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,8 +4,13 @@ import * as React from 'react'
|
|||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import Autocomplete from '#/components/Autocomplete'
|
import Autocomplete from '#/components/Autocomplete'
|
||||||
import Dropdown from '#/components/Dropdown'
|
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 jsonSchema from '#/utilities/jsonSchema'
|
||||||
import * as object from '#/utilities/object'
|
import * as object from '#/utilities/object'
|
||||||
@ -95,12 +100,15 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
children.push(
|
children.push(
|
||||||
<input
|
<FocusArea direction="horizontal">
|
||||||
|
{innerProps => (
|
||||||
|
<FocusRing>
|
||||||
|
<aria.Input
|
||||||
type="text"
|
type="text"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
value={typeof value === 'string' ? value : ''}
|
value={typeof value === 'string' ? value : ''}
|
||||||
size={1}
|
size={1}
|
||||||
className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
|
className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
|
||||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
||||||
}`}
|
}`}
|
||||||
placeholder={getText('enterText')}
|
placeholder={getText('enterText')}
|
||||||
@ -108,19 +116,26 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
const newValue: string = event.currentTarget.value
|
const newValue: string = event.currentTarget.value
|
||||||
setValue(newValue)
|
setValue(newValue)
|
||||||
}}
|
}}
|
||||||
|
{...innerProps}
|
||||||
/>
|
/>
|
||||||
|
</FocusRing>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'number': {
|
case 'number': {
|
||||||
children.push(
|
children.push(
|
||||||
<input
|
<FocusArea direction="horizontal">
|
||||||
|
{innerProps => (
|
||||||
|
<FocusRing>
|
||||||
|
<aria.Input
|
||||||
type="number"
|
type="number"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
value={typeof value === 'number' ? value : ''}
|
value={typeof value === 'number' ? value : ''}
|
||||||
size={1}
|
size={1}
|
||||||
className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
|
className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
|
||||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
||||||
}`}
|
}`}
|
||||||
placeholder={getText('enterNumber')}
|
placeholder={getText('enterNumber')}
|
||||||
@ -130,18 +145,25 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
setValue(newValue)
|
setValue(newValue)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
{...innerProps}
|
||||||
/>
|
/>
|
||||||
|
</FocusRing>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'integer': {
|
case 'integer': {
|
||||||
children.push(
|
children.push(
|
||||||
<input
|
<FocusArea direction="horizontal">
|
||||||
|
{innerProps => (
|
||||||
|
<FocusRing>
|
||||||
|
<aria.Input
|
||||||
type="number"
|
type="number"
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
value={typeof value === 'number' ? value : ''}
|
value={typeof value === 'number' ? value : ''}
|
||||||
size={1}
|
size={1}
|
||||||
className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
|
className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
|
||||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
||||||
}`}
|
}`}
|
||||||
placeholder={getText('enterInteger')}
|
placeholder={getText('enterInteger')}
|
||||||
@ -149,20 +171,20 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
|
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
|
||||||
setValue(newValue)
|
setValue(newValue)
|
||||||
}}
|
}}
|
||||||
|
{...innerProps}
|
||||||
/>
|
/>
|
||||||
|
</FocusRing>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'boolean': {
|
case 'boolean': {
|
||||||
children.push(
|
children.push(
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
isReadOnly={readOnly}
|
||||||
readOnly={readOnly}
|
isSelected={typeof value === 'boolean' && value}
|
||||||
checked={typeof value === 'boolean' && value}
|
onChange={setValue}
|
||||||
onChange={event => {
|
|
||||||
const newValue: boolean = event.currentTarget.checked
|
|
||||||
setValue(newValue)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
@ -194,15 +216,14 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
? { title: String(childSchema.description) }
|
? { title: String(childSchema.description) }
|
||||||
: {})}
|
: {})}
|
||||||
>
|
>
|
||||||
<button
|
<FocusArea active={isOptional} direction="horizontal">
|
||||||
type="button"
|
{innerProps => (
|
||||||
disabled={!isOptional}
|
<UnstyledButton
|
||||||
className={`text selectable ${
|
isDisabled={!isOptional}
|
||||||
value != null && key in value ? 'active' : ''
|
className={`text inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left ${
|
||||||
} inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left ${
|
|
||||||
isOptional ? 'hover:bg-hover-bg' : ''
|
isOptional ? 'hover:bg-hover-bg' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
if (isOptional) {
|
if (isOptional) {
|
||||||
setValue(oldValue => {
|
setValue(oldValue => {
|
||||||
if (oldValue != null && key in oldValue) {
|
if (oldValue != null && key in oldValue) {
|
||||||
@ -223,9 +244,18 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
{...innerProps}
|
||||||
|
>
|
||||||
|
<aria.Text
|
||||||
|
className={`selectable ${
|
||||||
|
value != null && key in value ? 'active' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{'title' in childSchema ? String(childSchema.title) : key}
|
{'title' in childSchema ? String(childSchema.title) : key}
|
||||||
</button>
|
</aria.Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
{value != null && key in value && (
|
{value != null && key in value && (
|
||||||
<JSONSchemaInput
|
<JSONSchemaInput
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
@ -293,6 +323,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const dropdown = (
|
const dropdown = (
|
||||||
|
<FocusArea direction="horizontal">
|
||||||
|
{innerProps => (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
items={childSchemas}
|
items={childSchemas}
|
||||||
@ -304,7 +336,10 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
|||||||
const newConstantValue = jsonSchema.constantValue(defs, childSchema, true)
|
const newConstantValue = jsonSchema.constantValue(defs, childSchema, true)
|
||||||
setValue(newConstantValue[0] ?? null)
|
setValue(newConstantValue[0] ?? null)
|
||||||
}}
|
}}
|
||||||
|
{...innerProps}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
children.push(
|
children.push(
|
||||||
<div className={`flex flex-col gap-json-schema ${childValue.length === 0 ? 'w-full' : ''}`}>
|
<div className={`flex flex-col gap-json-schema ${childValue.length === 0 ? 'w-full' : ''}`}>
|
||||||
|
@ -3,6 +3,10 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import * as router from 'react-router-dom'
|
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'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
@ -19,13 +23,20 @@ export interface LinkProps {
|
|||||||
/** A styled colored link with an icon. */
|
/** A styled colored link with an icon. */
|
||||||
export default function Link(props: LinkProps) {
|
export default function Link(props: LinkProps) {
|
||||||
const { to, icon, text } = props
|
const { to, icon, text } = props
|
||||||
|
const focusChildProps = focusHooks.useFocusChild()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FocusRing>
|
||||||
<router.Link
|
<router.Link
|
||||||
to={to}
|
{...aria.mergeProps<router.LinkProps>()(focusChildProps, {
|
||||||
className="flex items-center gap-auth-link text-center text-xs font-bold text-blue-500 transition-all duration-auth hover:text-blue-700 focus:text-blue-700"
|
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',
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<SvgMask src={icon} />
|
<SvgMask src={icon} />
|
||||||
{text}
|
{text}
|
||||||
</router.Link>
|
</router.Link>
|
||||||
|
</FocusRing>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,10 @@ import type * as inputBindings from '#/configurations/inputBindings'
|
|||||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
|
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||||
|
|
||||||
@ -69,7 +71,7 @@ export interface MenuEntryProps {
|
|||||||
/** Overrides the text for the menu entry. */
|
/** Overrides the text for the menu entry. */
|
||||||
readonly label?: string
|
readonly label?: string
|
||||||
/** When true, the button is not clickable. */
|
/** When true, the button is not clickable. */
|
||||||
readonly disabled?: boolean
|
readonly isDisabled?: boolean
|
||||||
readonly title?: string
|
readonly title?: string
|
||||||
readonly isContextMenuEntry?: boolean
|
readonly isContextMenuEntry?: boolean
|
||||||
readonly doAction: () => void
|
readonly doAction: () => void
|
||||||
@ -77,48 +79,40 @@ export interface MenuEntryProps {
|
|||||||
|
|
||||||
/** An item in a menu. */
|
/** An item in a menu. */
|
||||||
export default function MenuEntry(props: MenuEntryProps) {
|
export default function MenuEntry(props: MenuEntryProps) {
|
||||||
const {
|
const { hidden = false, action, label, isDisabled = false, title } = props
|
||||||
hidden = false,
|
const { isContextMenuEntry = false, doAction } = props
|
||||||
action,
|
|
||||||
label,
|
|
||||||
disabled = false,
|
|
||||||
title,
|
|
||||||
isContextMenuEntry = false,
|
|
||||||
} = props
|
|
||||||
const { doAction } = props
|
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||||
const info = inputBindings.metadata[action]
|
const info = inputBindings.metadata[action]
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// This is slower (but more convenient) than registering every shortcut in the context menu
|
// This is slower (but more convenient) than registering every shortcut in the context menu
|
||||||
// at once.
|
// at once.
|
||||||
if (disabled) {
|
if (isDisabled) {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
||||||
[action]: doAction,
|
[action]: doAction,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [disabled, inputBindings, action, doAction])
|
}, [isDisabled, inputBindings, action, doAction])
|
||||||
|
|
||||||
return hidden ? null : (
|
return hidden ? null : (
|
||||||
<button
|
<UnstyledButton
|
||||||
disabled={disabled}
|
isDisabled={isDisabled}
|
||||||
title={title}
|
className="group flex w-full rounded-menu-entry"
|
||||||
className={`items -center flex h-row
|
onPress={doAction}
|
||||||
place-content-between rounded-menu-entry p-menu-entry text-left selectable enabled:active hover:bg-hover-bg disabled:bg-transparent ${
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-row grow place-content-between items-center rounded-inherit p-menu-entry text-left selectable group-enabled:active hover:bg-hover-bg disabled:bg-transparent ${
|
||||||
isContextMenuEntry ? 'px-context-menu-entry-x' : ''
|
isContextMenuEntry ? 'px-context-menu-entry-x' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={event => {
|
|
||||||
event.stopPropagation()
|
|
||||||
doAction()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-menu-entry whitespace-nowrap">
|
<div title={title} className="flex items-center gap-menu-entry whitespace-nowrap">
|
||||||
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" />
|
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" />
|
||||||
{label ?? getText(ACTION_TO_TEXT_ID[action])}
|
<aria.Text slot="label">{label ?? getText(ACTION_TO_TEXT_ID[action])}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
<KeyboardShortcut action={action} />
|
<KeyboardShortcut action={action} />
|
||||||
</button>
|
</div>
|
||||||
|
</UnstyledButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,8 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
|
||||||
|
import FocusRoot from '#/components/styled/FocusRoot'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Component ===
|
// === Component ===
|
||||||
// =================
|
// =================
|
||||||
@ -29,6 +31,8 @@ export default function Modal(props: ModalProps) {
|
|||||||
const { unsetModal } = modalProvider.useSetModal()
|
const { unsetModal } = modalProvider.useSetModal()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FocusRoot active={!hidden}>
|
||||||
|
{innerProps => (
|
||||||
<div
|
<div
|
||||||
// The name comes from a third-party API and cannot be changed.
|
// The name comes from a third-party API and cannot be changed.
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
@ -47,8 +51,17 @@ export default function Modal(props: ModalProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
|
{...innerProps}
|
||||||
|
onKeyDown={event => {
|
||||||
|
innerProps.onKeyDown?.(event)
|
||||||
|
if (event.key !== 'Escape') {
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusRoot>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 React from 'react'
|
||||||
|
|
||||||
import * as reactAriaComponents from 'react-aria-components'
|
import * as aria from '#/components/aria'
|
||||||
|
|
||||||
import * as portal from '#/components/Portal'
|
import * as portal from '#/components/Portal'
|
||||||
|
|
||||||
/**
|
/** Props for {@link Root}. */
|
||||||
* Props for the root component
|
|
||||||
*/
|
|
||||||
export interface RootProps extends React.PropsWithChildren {
|
export interface RootProps extends React.PropsWithChildren {
|
||||||
readonly rootRef: React.RefObject<HTMLElement>
|
readonly rootRef: React.RefObject<HTMLElement>
|
||||||
readonly navigate: (path: string) => void
|
readonly navigate: (path: string) => void
|
||||||
readonly locale?: string
|
readonly locale?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The root component with required providers. */
|
||||||
* The root component with required providers
|
|
||||||
*/
|
|
||||||
export function Root(props: RootProps) {
|
export function Root(props: RootProps) {
|
||||||
const { children, rootRef, navigate, locale = 'en-US' } = props
|
const { children, rootRef, navigate, locale = 'en-US' } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<portal.PortalProvider value={rootRef}>
|
<portal.PortalProvider value={rootRef}>
|
||||||
<reactAriaComponents.RouterProvider navigate={navigate}>
|
<aria.RouterProvider navigate={navigate}>
|
||||||
<reactAriaComponents.I18nProvider locale={locale}>
|
<aria.I18nProvider locale={locale}>{children}</aria.I18nProvider>
|
||||||
{children}
|
</aria.RouterProvider>
|
||||||
</reactAriaComponents.I18nProvider>
|
|
||||||
</reactAriaComponents.RouterProvider>
|
|
||||||
</portal.PortalProvider>
|
</portal.PortalProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -116,13 +116,13 @@ export default function SelectionBrush(props: SelectionBrushProps) {
|
|||||||
document.addEventListener('mouseup', onMouseUp)
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
document.addEventListener('dragstart', onDragStart, { capture: true })
|
document.addEventListener('dragstart', onDragStart, { capture: true })
|
||||||
document.addEventListener('mousemove', onMouseMove)
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
document.addEventListener('click', onClick)
|
document.addEventListener('click', onClick, { capture: true })
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', onMouseDown)
|
document.removeEventListener('mousedown', onMouseDown)
|
||||||
document.removeEventListener('mouseup', onMouseUp)
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
document.removeEventListener('dragstart', onDragStart, { capture: true })
|
document.removeEventListener('dragstart', onDragStart, { capture: true })
|
||||||
document.removeEventListener('mousemove', onMouseMove)
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
document.removeEventListener('click', onClick)
|
document.removeEventListener('click', onClick, { capture: true })
|
||||||
}
|
}
|
||||||
}, [/* should never change */ modalRef])
|
}, [/* should never change */ modalRef])
|
||||||
|
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
/** @file A styled submit button. */
|
/** @file A styled submit button. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import type * as aria from '#/components/aria'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
// === SubmitButton ===
|
// === SubmitButton ===
|
||||||
@ -9,22 +11,24 @@ import SvgMask from '#/components/SvgMask'
|
|||||||
|
|
||||||
/** Props for a {@link SubmitButton}. */
|
/** Props for a {@link SubmitButton}. */
|
||||||
export interface SubmitButtonProps {
|
export interface SubmitButtonProps {
|
||||||
readonly disabled?: boolean
|
readonly isDisabled?: boolean
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly icon: string
|
readonly icon: string
|
||||||
|
readonly onPress: (event: aria.PressEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A styled submit button. */
|
/** A styled submit button. */
|
||||||
export default function SubmitButton(props: SubmitButtonProps) {
|
export default function SubmitButton(props: SubmitButtonProps) {
|
||||||
const { disabled = false, text, icon } = props
|
const { isDisabled = false, text, icon, onPress } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<UnstyledButton
|
||||||
disabled={disabled}
|
isDisabled={isDisabled}
|
||||||
type="submit"
|
className={`flex items-center justify-center gap-icon-with-text rounded-full bg-blue-600 py-auth-input-y text-white transition-all duration-auth selectable enabled:active hover:bg-blue-700 focus:bg-blue-700`}
|
||||||
className={`flex items-center justify-center gap-icon-with-text rounded-full bg-blue-600 py-auth-input-y text-white transition-all duration-auth selectable enabled:active hover:bg-blue-700 focus:bg-blue-700 focus:outline-none`}
|
onPress={onPress}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
<SvgMask src={icon} />
|
<SvgMask src={icon} />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import * as React from 'react'
|
|||||||
|
|
||||||
/** Props for a {@link SvgMask}. */
|
/** Props for a {@link SvgMask}. */
|
||||||
export interface SvgMaskProps {
|
export interface SvgMaskProps {
|
||||||
|
readonly invert?: boolean
|
||||||
readonly alt?: string
|
readonly alt?: string
|
||||||
/** The URL of the SVG to use as the mask. */
|
/** The URL of the SVG to use as the mask. */
|
||||||
readonly src: string
|
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`). */
|
/** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */
|
||||||
export default function SvgMask(props: SvgMaskProps) {
|
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 urlSrc = `url(${JSON.stringify(src)})`
|
||||||
|
const mask = invert ? `${urlSrc}, linear-gradient(white 0 0)` : urlSrc
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -34,16 +36,18 @@ export default function SvgMask(props: SvgMaskProps) {
|
|||||||
style={{
|
style={{
|
||||||
...(style ?? {}),
|
...(style ?? {}),
|
||||||
backgroundColor: color ?? 'currentcolor',
|
backgroundColor: color ?? 'currentcolor',
|
||||||
mask: urlSrc,
|
mask,
|
||||||
maskPosition: 'center',
|
maskPosition: 'center',
|
||||||
maskRepeat: 'no-repeat',
|
maskRepeat: 'no-repeat',
|
||||||
maskSize: 'contain',
|
maskSize: 'contain',
|
||||||
|
...(invert ? { maskComposite: 'exclude, exclude' } : {}),
|
||||||
// The names come from a third-party API and cannot be changed.
|
// The names come from a third-party API and cannot be changed.
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
WebkitMask: urlSrc,
|
WebkitMask: mask,
|
||||||
WebkitMaskPosition: 'center',
|
WebkitMaskPosition: 'center',
|
||||||
WebkitMaskRepeat: 'no-repeat',
|
WebkitMaskRepeat: 'no-repeat',
|
||||||
WebkitMaskSize: 'contain',
|
WebkitMaskSize: 'contain',
|
||||||
|
...(invert ? { WebkitMaskComposite: 'exclude, exclude' } : {}),
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
}}
|
}}
|
||||||
className={`inline-block ${onClick != null ? 'cursor-pointer' : ''} ${
|
className={`inline-block ${onClick != null ? 'cursor-pointer' : ''} ${
|
||||||
|
37
app/ide-desktop/lib/dashboard/src/components/TextLink.tsx
Normal file
37
app/ide-desktop/lib/dashboard/src/components/TextLink.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/** @file A link without an icon. */
|
||||||
|
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'
|
||||||
|
|
||||||
|
// ================
|
||||||
|
// === TextLink ===
|
||||||
|
// ================
|
||||||
|
|
||||||
|
/** Props for a {@link TextLink}. */
|
||||||
|
export interface TextLinkProps {
|
||||||
|
readonly to: string
|
||||||
|
readonly text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A link without an icon. */
|
||||||
|
export default function TextLink(props: TextLinkProps) {
|
||||||
|
const { to, text } = props
|
||||||
|
const focusChildProps = focusHooks.useFocusChild()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusRing>
|
||||||
|
<router.Link
|
||||||
|
{...aria.mergeProps<router.LinkProps>()(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}
|
||||||
|
</router.Link>
|
||||||
|
</FocusRing>
|
||||||
|
)
|
||||||
|
}
|
@ -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<React.PropsWithChildren> {
|
||||||
|
// 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<HTMLButtonElement>) {
|
||||||
|
const { focusRingPlacement, children, ...buttonProps } = props
|
||||||
|
const focusChildProps = focusHooks.useFocusChild()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusRing {...(focusRingPlacement == null ? {} : { placement: focusRingPlacement })}>
|
||||||
|
<aria.Button
|
||||||
|
{...aria.mergeProps<aria.ButtonProps & React.RefAttributes<HTMLButtonElement>>()(
|
||||||
|
buttonProps,
|
||||||
|
focusChildProps,
|
||||||
|
{ ref }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</aria.Button>
|
||||||
|
</FocusRing>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.forwardRef(UnstyledButton)
|
19
app/ide-desktop/lib/dashboard/src/components/aria.tsx
Normal file
19
app/ide-desktop/lib/dashboard/src/components/aria.tsx
Normal file
@ -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<Constraint extends object = never>() {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
return <T extends (Partial<Constraint> | null | undefined)[]>(...args: T) =>
|
||||||
|
aria.mergeProps(...args)
|
||||||
|
}
|
@ -6,12 +6,16 @@ import SettingsIcon from 'enso-assets/settings.svg'
|
|||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
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'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
/** Props for an {@link AssetInfoBar}. */
|
/** Props for an {@link AssetInfoBar}. */
|
||||||
export interface AssetInfoBarProps {
|
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 isAssetPanelEnabled: boolean
|
||||||
readonly setIsAssetPanelEnabled: React.Dispatch<React.SetStateAction<boolean>>
|
readonly setIsAssetPanelEnabled: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
}
|
}
|
||||||
@ -20,30 +24,30 @@ export interface AssetInfoBarProps {
|
|||||||
// This parameter will be used in the future.
|
// This parameter will be used in the future.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
export default function AssetInfoBar(props: AssetInfoBarProps) {
|
export default function AssetInfoBar(props: AssetInfoBarProps) {
|
||||||
const {
|
const { invisible = false, isAssetPanelEnabled, setIsAssetPanelEnabled } = props
|
||||||
isAssetPanelEnabled: isAssetPanelVisible,
|
|
||||||
setIsAssetPanelEnabled: setIsAssetPanelVisible,
|
|
||||||
} = props
|
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FocusArea active={!invisible} direction="horizontal">
|
||||||
|
{innerProps => (
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-auto flex h-row shrink-0 cursor-default items-center gap-icons rounded-full bg-frame px-icons-x ${
|
className={`pointer-events-auto flex h-row shrink-0 cursor-default items-center gap-icons rounded-full bg-frame px-icons-x ${
|
||||||
backend.type === backendModule.BackendType.remote ? '' : 'invisible'
|
backend.type === backendModule.BackendType.remote ? '' : 'invisible'
|
||||||
}`}
|
}`}
|
||||||
onClick={event => {
|
{...innerProps}
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
alt={isAssetPanelVisible ? getText('closeAssetPanel') : getText('openAssetPanel')}
|
alt={isAssetPanelEnabled ? getText('closeAssetPanel') : getText('openAssetPanel')}
|
||||||
active={isAssetPanelVisible}
|
active={isAssetPanelEnabled}
|
||||||
image={SettingsIcon}
|
image={SettingsIcon}
|
||||||
error={getText('multipleAssetsSettingsError')}
|
error={getText('multipleAssetsSettingsError')}
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
setIsAssetPanelVisible(visible => !visible)
|
setIsAssetPanelEnabled(visible => !visible)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -18,10 +18,12 @@ import AssetListEventType from '#/events/AssetListEventType'
|
|||||||
import AssetContextMenu from '#/layouts/AssetContextMenu'
|
import AssetContextMenu from '#/layouts/AssetContextMenu'
|
||||||
import type * as assetsTable from '#/layouts/AssetsTable'
|
import type * as assetsTable from '#/layouts/AssetsTable'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
|
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
|
||||||
import * as columnModule from '#/components/dashboard/column'
|
import * as columnModule from '#/components/dashboard/column'
|
||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||||
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
|
|
||||||
import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal'
|
import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal'
|
||||||
|
|
||||||
@ -76,6 +78,7 @@ export interface AssetRowProps
|
|||||||
readonly setSelected: (selected: boolean) => void
|
readonly setSelected: (selected: boolean) => void
|
||||||
readonly isSoleSelected: boolean
|
readonly isSoleSelected: boolean
|
||||||
readonly isKeyboardSelected: boolean
|
readonly isKeyboardSelected: boolean
|
||||||
|
readonly grabKeyboardFocus: () => void
|
||||||
readonly allowContextMenu: boolean
|
readonly allowContextMenu: boolean
|
||||||
readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void
|
readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void
|
||||||
readonly onContextMenu?: (
|
readonly onContextMenu?: (
|
||||||
@ -88,6 +91,7 @@ export interface AssetRowProps
|
|||||||
export default function AssetRow(props: AssetRowProps) {
|
export default function AssetRow(props: AssetRowProps) {
|
||||||
const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelected, isKeyboardSelected } = props
|
const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelected, isKeyboardSelected } = props
|
||||||
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
|
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
|
||||||
|
const { grabKeyboardFocus } = props
|
||||||
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent, nodeMap } = state
|
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent, nodeMap } = state
|
||||||
const { setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
const { setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
||||||
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef } = state
|
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef } = state
|
||||||
@ -99,7 +103,10 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
|
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
|
||||||
const [item, setItem] = React.useState(rawItem)
|
const [item, setItem] = React.useState(rawItem)
|
||||||
|
const rootRef = React.useRef<HTMLElement | null>(null)
|
||||||
const dragOverTimeoutHandle = React.useRef<number | null>(null)
|
const dragOverTimeoutHandle = React.useRef<number | null>(null)
|
||||||
|
const grabKeyboardFocusRef = React.useRef(grabKeyboardFocus)
|
||||||
|
grabKeyboardFocusRef.current = grabKeyboardFocus
|
||||||
const asset = item.item
|
const asset = item.item
|
||||||
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
|
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
|
||||||
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
|
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
|
||||||
@ -130,6 +137,13 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
}
|
}
|
||||||
}, [selected, insertionVisibility, /* should never change */ setSelected])
|
}, [selected, insertionVisibility, /* should never change */ setSelected])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isKeyboardSelected) {
|
||||||
|
rootRef.current?.focus()
|
||||||
|
grabKeyboardFocusRef.current()
|
||||||
|
}
|
||||||
|
}, [isKeyboardSelected])
|
||||||
|
|
||||||
const doCopyOnBackend = React.useCallback(
|
const doCopyOnBackend = React.useCallback(
|
||||||
async (newParentId: backendModule.DirectoryId | null) => {
|
async (newParentId: backendModule.DirectoryId | null) => {
|
||||||
try {
|
try {
|
||||||
@ -661,10 +675,12 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!hidden && (
|
{!hidden && (
|
||||||
|
<FocusRing>
|
||||||
<tr
|
<tr
|
||||||
draggable
|
draggable
|
||||||
tabIndex={-1}
|
tabIndex={0}
|
||||||
ref={element => {
|
ref={element => {
|
||||||
|
rootRef.current = element
|
||||||
if (isSoleSelected && element != null && scrollContainerRef.current != null) {
|
if (isSoleSelected && element != null && scrollContainerRef.current != null) {
|
||||||
const rect = element.getBoundingClientRect()
|
const rect = element.getBoundingClientRect()
|
||||||
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
|
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
|
||||||
@ -677,10 +693,11 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isKeyboardSelected && element?.contains(document.activeElement) === false) {
|
||||||
|
element.focus()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className={`h-row rounded-full outline-2 -outline-offset-2 outline-primary ease-in-out ${visibility} ${
|
className={`h-row rounded-full transition-all ease-in-out ${visibility} ${isDraggedOver || selected ? 'selected' : ''}`}
|
||||||
isKeyboardSelected ? 'outline' : ''
|
|
||||||
} ${isDraggedOver || selected ? 'selected' : ''}`}
|
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
onClick(innerProps, event)
|
onClick(innerProps, event)
|
||||||
@ -772,7 +789,10 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
? [item.key, item.item.id, asset.title]
|
? [item.key, item.item.id, asset.title]
|
||||||
: [item.directoryKey, item.directoryId, null]
|
: [item.directoryKey, item.directoryId, null]
|
||||||
const payload = drag.ASSET_ROWS.lookup(event)
|
const payload = drag.ASSET_ROWS.lookup(event)
|
||||||
if (payload != null && payload.every(innerItem => innerItem.key !== directoryKey)) {
|
if (
|
||||||
|
payload != null &&
|
||||||
|
payload.every(innerItem => innerItem.key !== directoryKey)
|
||||||
|
) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
unsetModal()
|
unsetModal()
|
||||||
@ -823,6 +843,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
|
</FocusRing>
|
||||||
)}
|
)}
|
||||||
{selected && allowContextMenu && !hidden && (
|
{selected && allowContextMenu && !hidden && (
|
||||||
// This is a copy of the context menu, since the context menu registers keyboard
|
// 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)}`}
|
className={`flex h-row items-center rounded-full ${indent.indentClass(item.depth)}`}
|
||||||
>
|
>
|
||||||
<img src={BlankIcon} />
|
<img src={BlankIcon} />
|
||||||
<span className="px-name-column-x placeholder">{getText('thisFolderIsEmpty')}</span>
|
<aria.Text className="px-name-column-x placeholder">
|
||||||
|
{getText('thisFolderIsEmpty')}
|
||||||
|
</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -5,6 +5,7 @@ import BreadcrumbArrowIcon from 'enso-assets/breadcrumb_arrow.svg'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import AssetIcon from '#/components/dashboard/AssetIcon'
|
import AssetIcon from '#/components/dashboard/AssetIcon'
|
||||||
|
|
||||||
import type * as backend from '#/services/Backend'
|
import type * as backend from '#/services/Backend'
|
||||||
@ -32,7 +33,7 @@ export default function AssetSummary(props: AssetSummaryProps) {
|
|||||||
<AssetIcon asset={asset} />
|
<AssetIcon asset={asset} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="flex items-center gap-icon-with-text font-semibold">
|
<aria.Text className="flex items-center gap-icon-with-text font-semibold">
|
||||||
{asset.title}
|
{asset.title}
|
||||||
{newName != null && (
|
{newName != null && (
|
||||||
<>
|
<>
|
||||||
@ -40,13 +41,13 @@ export default function AssetSummary(props: AssetSummaryProps) {
|
|||||||
{newName}
|
{newName}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</aria.Text>
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
<span>
|
<aria.Text>
|
||||||
{getText('lastModifiedOn', dateTime.formatDateTime(new Date(asset.modifiedAt)))}
|
{getText('lastModifiedOn', dateTime.formatDateTime(new Date(asset.modifiedAt)))}
|
||||||
</span>
|
</aria.Text>
|
||||||
)}
|
)}
|
||||||
<span>{asset.labels}</span>
|
<aria.Text>{asset.labels}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -166,6 +166,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
|||||||
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
|
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
checkSubmittable={newTitle =>
|
checkSubmittable={newTitle =>
|
||||||
|
newTitle !== item.item.title &&
|
||||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||||
child =>
|
child =>
|
||||||
// All siblings,
|
// All siblings,
|
||||||
|
@ -143,6 +143,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
|||||||
editable={false}
|
editable={false}
|
||||||
className="text grow bg-transparent"
|
className="text grow bg-transparent"
|
||||||
checkSubmittable={newTitle =>
|
checkSubmittable={newTitle =>
|
||||||
|
newTitle !== item.item.title &&
|
||||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||||
child =>
|
child =>
|
||||||
// All siblings,
|
// All siblings,
|
||||||
|
@ -15,6 +15,7 @@ import type * as dashboardInputBindings from '#/configurations/inputBindings'
|
|||||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
import * as inputBindingsModule from '#/utilities/inputBindings'
|
import * as inputBindingsModule from '#/utilities/inputBindings'
|
||||||
@ -55,18 +56,18 @@ const MODIFIER_JSX: Readonly<
|
|||||||
},
|
},
|
||||||
[detect.Platform.linux]: {
|
[detect.Platform.linux]: {
|
||||||
Meta: props => (
|
Meta: props => (
|
||||||
<span key="Meta" className="text">
|
<aria.Text key="Meta" className="text">
|
||||||
{props.getText('superModifier')}
|
{props.getText('superModifier')}
|
||||||
</span>
|
</aria.Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
[detect.Platform.unknown]: {
|
[detect.Platform.unknown]: {
|
||||||
// Assume the system is Unix-like and calls the key that triggers `event.metaKey`
|
// Assume the system is Unix-like and calls the key that triggers `event.metaKey`
|
||||||
// the "Super" key.
|
// the "Super" key.
|
||||||
Meta: props => (
|
Meta: props => (
|
||||||
<span key="Meta" className="text">
|
<aria.Text key="Meta" className="text">
|
||||||
{props.getText('superModifier')}
|
{props.getText('superModifier')}
|
||||||
</span>
|
</aria.Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
/* eslint-enable @typescript-eslint/naming-convention */
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
@ -119,7 +120,7 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
|
|||||||
.sort(inputBindingsModule.compareModifiers)
|
.sort(inputBindingsModule.compareModifiers)
|
||||||
.map(inputBindingsModule.toModifierKey)
|
.map(inputBindingsModule.toModifierKey)
|
||||||
return (
|
return (
|
||||||
<div
|
<aria.Keyboard
|
||||||
className={`flex h-text items-center ${
|
className={`flex h-text items-center ${
|
||||||
detect.isOnMacOS() ? 'gap-modifiers-macos' : 'gap-modifiers'
|
detect.isOnMacOS() ? 'gap-modifiers-macos' : 'gap-modifiers'
|
||||||
}`}
|
}`}
|
||||||
@ -127,15 +128,15 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
|
|||||||
{modifiers.map(
|
{modifiers.map(
|
||||||
modifier =>
|
modifier =>
|
||||||
MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? (
|
MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? (
|
||||||
<span key={modifier} className="text">
|
<aria.Text key={modifier} className="text">
|
||||||
{getText(MODIFIER_TO_TEXT_ID[modifier])}
|
{getText(MODIFIER_TO_TEXT_ID[modifier])}
|
||||||
</span>
|
</aria.Text>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
<span className="text">
|
<aria.Text className="text">
|
||||||
{shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key}
|
{shortcut.key === ' ' ? 'Space' : KEY_CHARACTER[shortcut.key] ?? shortcut.key}
|
||||||
</span>
|
</aria.Text>
|
||||||
</div>
|
</aria.Keyboard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
/** @file An label that can be applied to an asset. */
|
/** @file An label that can be applied to an asset. */
|
||||||
import * as React from 'react'
|
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'
|
import * as backend from '#/services/Backend'
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
@ -8,10 +15,7 @@ import * as backend from '#/services/Backend'
|
|||||||
// =============
|
// =============
|
||||||
|
|
||||||
/** Props for a {@link Label}. */
|
/** Props for a {@link Label}. */
|
||||||
interface InternalLabelProps
|
interface InternalLabelProps extends Readonly<React.PropsWithChildren> {
|
||||||
extends Readonly<React.PropsWithChildren>,
|
|
||||||
Readonly<Omit<JSX.IntrinsicElements['button'], 'color' | 'onClick'>>,
|
|
||||||
Readonly<Required<Pick<JSX.IntrinsicElements['button'], 'onClick'>>> {
|
|
||||||
// This matches the capitalization of `data-` attributes in React.
|
// This matches the capitalization of `data-` attributes in React.
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
readonly 'data-testid'?: string
|
readonly 'data-testid'?: string
|
||||||
@ -21,43 +25,60 @@ interface InternalLabelProps
|
|||||||
* or that it is excluded from search. */
|
* or that it is excluded from search. */
|
||||||
readonly negated?: boolean
|
readonly negated?: boolean
|
||||||
/** When true, the button cannot be clicked. */
|
/** When true, the button cannot be clicked. */
|
||||||
readonly disabled?: boolean
|
readonly isDisabled?: boolean
|
||||||
|
readonly draggable?: boolean
|
||||||
readonly color: backend.LChColor
|
readonly color: backend.LChColor
|
||||||
|
readonly title?: string
|
||||||
readonly className?: string
|
readonly className?: string
|
||||||
|
readonly onPress: (event: aria.PressEvent | React.MouseEvent<HTMLButtonElement>) => void
|
||||||
|
readonly onContextMenu?: (event: React.MouseEvent<HTMLElement>) => void
|
||||||
|
readonly onDragStart?: (event: React.DragEvent<HTMLElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An label that can be applied to an asset. */
|
/** An label that can be applied to an asset. */
|
||||||
export default function Label(props: InternalLabelProps) {
|
export default function Label(props: InternalLabelProps) {
|
||||||
const {
|
const { active = false, isDisabled = false, color, negated = false, draggable, title } = props
|
||||||
'data-testid': dataTestId,
|
const { className = 'text-tag-text', children, onPress, onDragStart, onContextMenu } = props
|
||||||
active = false,
|
const focusDirection = focusDirectionProvider.useFocusDirection()
|
||||||
disabled = false,
|
const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection)
|
||||||
color,
|
const textClass = /\btext-/.test(className)
|
||||||
negated = false,
|
|
||||||
className = 'text-tag-text',
|
|
||||||
children,
|
|
||||||
...passthrough
|
|
||||||
} = props
|
|
||||||
const textColorClassName = /\btext-/.test(className)
|
|
||||||
? '' // eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
? '' // eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||||
: color.lightness <= 50
|
: color.lightness <= 50
|
||||||
? 'text-tag-text'
|
? 'text-tag-text'
|
||||||
: active
|
: 'text-primary'
|
||||||
? 'text-primary'
|
|
||||||
: 'text-not-selected'
|
|
||||||
return (
|
return (
|
||||||
|
<FocusRing within placement="after">
|
||||||
|
<div
|
||||||
|
className={`relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-inherit ${negated ? 'after:!outline-offset-0' : ''}`}
|
||||||
|
>
|
||||||
|
{/* An `aria.Button` MUST NOT be used here, as it breaks dragging. */}
|
||||||
|
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||||
<button
|
<button
|
||||||
data-testid={dataTestId}
|
type="button"
|
||||||
disabled={disabled}
|
data-testid={props['data-testid']}
|
||||||
className={`selectable ${
|
draggable={draggable}
|
||||||
|
title={title}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`focus-child selectable ${
|
||||||
active ? 'active' : ''
|
active ? 'active' : ''
|
||||||
} relative flex h-text items-center whitespace-nowrap rounded-full px-label-x transition-all before:absolute before:inset before:rounded-full ${
|
} relative flex h-text items-center whitespace-nowrap rounded-inherit px-label-x transition-all after:pointer-events-none after:absolute after:inset after:rounded-full ${
|
||||||
negated ? 'before:border-2 before:border-delete' : ''
|
negated ? 'after:border-2 after:border-delete' : ''
|
||||||
} ${className} ${textColorClassName}`}
|
} ${className} ${textClass}`}
|
||||||
style={{ backgroundColor: backend.lChColorToCssColor(color) }}
|
style={{ backgroundColor: backend.lChColorToCssColor(color) }}
|
||||||
{...passthrough}
|
onClick={event => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onPress(event)
|
||||||
|
}}
|
||||||
|
onDragStart={e => {
|
||||||
|
onDragStart?.(e)
|
||||||
|
}}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onKeyDown={handleFocusMove}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</FocusRing>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
/** @file Colored border around icons and text indicating permissions. */
|
/** @file Colored border around icons and text indicating permissions. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import type * as aria from '#/components/aria'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as permissionsModule from '#/utilities/permissions'
|
import * as permissionsModule from '#/utilities/permissions'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
@ -11,14 +14,12 @@ import * as permissionsModule from '#/utilities/permissions'
|
|||||||
export interface PermissionDisplayProps extends Readonly<React.PropsWithChildren> {
|
export interface PermissionDisplayProps extends Readonly<React.PropsWithChildren> {
|
||||||
readonly action: permissionsModule.PermissionAction
|
readonly action: permissionsModule.PermissionAction
|
||||||
readonly className?: string
|
readonly className?: string
|
||||||
readonly onClick?: React.MouseEventHandler<HTMLButtonElement>
|
readonly onPress?: (event: aria.PressEvent) => void
|
||||||
readonly onMouseEnter?: React.MouseEventHandler<HTMLButtonElement>
|
|
||||||
readonly onMouseLeave?: React.MouseEventHandler<HTMLButtonElement>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Colored border around icons and text indicating permissions. */
|
/** Colored border around icons and text indicating permissions. */
|
||||||
export default function PermissionDisplay(props: PermissionDisplayProps) {
|
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]
|
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
|
||||||
|
|
||||||
switch (permission.type) {
|
switch (permission.type) {
|
||||||
@ -26,29 +27,25 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
|
|||||||
case permissionsModule.Permission.admin:
|
case permissionsModule.Permission.admin:
|
||||||
case permissionsModule.Permission.edit: {
|
case permissionsModule.Permission.edit: {
|
||||||
return (
|
return (
|
||||||
<button
|
<UnstyledButton
|
||||||
disabled={!onClick}
|
isDisabled={!onPress}
|
||||||
className={`${
|
className={`${
|
||||||
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
||||||
} inline-block h-text whitespace-nowrap rounded-full px-permission-mini-button-x py-permission-mini-button-y ${
|
} inline-block h-text whitespace-nowrap rounded-full px-permission-mini-button-x py-permission-mini-button-y ${
|
||||||
className ?? ''
|
className ?? ''
|
||||||
}`}
|
}`}
|
||||||
onClick={onClick}
|
onPress={onPress ?? (() => {})}
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case permissionsModule.Permission.read:
|
case permissionsModule.Permission.read:
|
||||||
case permissionsModule.Permission.view: {
|
case permissionsModule.Permission.view: {
|
||||||
return (
|
return (
|
||||||
<button
|
<UnstyledButton
|
||||||
className={`relative inline-block whitespace-nowrap rounded-full ${className ?? ''}`}
|
className={`relative inline-block whitespace-nowrap rounded-full ${className ?? ''}`}
|
||||||
onClick={onClick}
|
onPress={onPress ?? (() => {})}
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
>
|
>
|
||||||
{permission.docs && (
|
{permission.docs && (
|
||||||
<div className="absolute size-full rounded-full border-2 border-permission-docs clip-path-top" />
|
<div className="absolute size-full rounded-full border-2 border-permission-docs clip-path-top" />
|
||||||
@ -63,7 +60,7 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,10 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import PermissionTypeSelector from '#/components/dashboard/PermissionTypeSelector'
|
import PermissionTypeSelector from '#/components/dashboard/PermissionTypeSelector'
|
||||||
import Modal from '#/components/Modal'
|
import Modal from '#/components/Modal'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import type * as backend from '#/services/Backend'
|
import type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
@ -35,7 +37,7 @@ const LABEL_STRAIGHT_WIDTH_PX = 97
|
|||||||
export interface PermissionSelectorProps {
|
export interface PermissionSelectorProps {
|
||||||
readonly showDelete?: boolean
|
readonly showDelete?: boolean
|
||||||
/** When `true`, the button is not clickable. */
|
/** When `true`, the button is not clickable. */
|
||||||
readonly disabled?: boolean
|
readonly isDisabled?: boolean
|
||||||
/** When `true`, the button has lowered opacity when it is disabled. */
|
/** When `true`, the button has lowered opacity when it is disabled. */
|
||||||
readonly input?: boolean
|
readonly input?: boolean
|
||||||
/** Overrides the vertical offset of the {@link PermissionTypeSelector}. */
|
/** Overrides the vertical offset of the {@link PermissionTypeSelector}. */
|
||||||
@ -52,12 +54,13 @@ export interface PermissionSelectorProps {
|
|||||||
|
|
||||||
/** A horizontal selector for all possible permissions. */
|
/** A horizontal selector for all possible permissions. */
|
||||||
export default function PermissionSelector(props: PermissionSelectorProps) {
|
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 { error, selfPermission, action: actionRaw, assetType, className } = props
|
||||||
const { onChange, doDelete } = props
|
const { onChange, doDelete } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const [action, setActionRaw] = React.useState(actionRaw)
|
const [action, setActionRaw] = React.useState(actionRaw)
|
||||||
const [TheChild, setTheChild] = React.useState<(() => JSX.Element) | null>()
|
const [TheChild, setTheChild] = React.useState<(() => JSX.Element) | null>()
|
||||||
|
const permissionSelectorButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
|
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
|
||||||
|
|
||||||
const setAction = (newAction: permissions.PermissionAction) => {
|
const setAction = (newAction: permissions.PermissionAction) => {
|
||||||
@ -65,8 +68,9 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
|||||||
onChange(newAction)
|
onChange(newAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
const doShowPermissionTypeSelector = (event: React.SyntheticEvent<HTMLElement>) => {
|
const doShowPermissionTypeSelector = () => {
|
||||||
const position = event.currentTarget.getBoundingClientRect()
|
if (permissionSelectorButtonRef.current != null) {
|
||||||
|
const position = permissionSelectorButtonRef.current.getBoundingClientRect()
|
||||||
const originalLeft = position.left + window.scrollX
|
const originalLeft = position.left + window.scrollX
|
||||||
const originalTop = position.top + window.scrollY
|
const originalTop = position.top + window.scrollY
|
||||||
const left = originalLeft + TYPE_SELECTOR_X_OFFSET_PX
|
const left = originalLeft + TYPE_SELECTOR_X_OFFSET_PX
|
||||||
@ -124,6 +128,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let permissionDisplay: JSX.Element
|
let permissionDisplay: JSX.Element
|
||||||
|
|
||||||
@ -132,26 +137,23 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
|||||||
case permissionsModule.Permission.view: {
|
case permissionsModule.Permission.view: {
|
||||||
permissionDisplay = (
|
permissionDisplay = (
|
||||||
<div className="flex w-permission-display gap-px">
|
<div className="flex w-permission-display gap-px">
|
||||||
<button
|
<UnstyledButton
|
||||||
type="button"
|
ref={permissionSelectorButtonRef}
|
||||||
disabled={disabled}
|
isDisabled={isDisabled}
|
||||||
{...(disabled && error != null ? { title: error } : {})}
|
{...(isDisabled && error != null ? { title: error } : {})}
|
||||||
className={`selectable ${!disabled || !input ? 'active' : ''} ${
|
className={`selectable ${!isDisabled || !input ? 'active' : ''} ${
|
||||||
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
||||||
} h-text grow rounded-l-full px-permission-mini-button-x py-permission-mini-button-y`}
|
} h-text grow rounded-l-full px-permission-mini-button-x py-permission-mini-button-y`}
|
||||||
onClick={doShowPermissionTypeSelector}
|
onPress={doShowPermissionTypeSelector}
|
||||||
>
|
>
|
||||||
{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}
|
<aria.Text>{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}</aria.Text>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
<button
|
<UnstyledButton
|
||||||
type="button"
|
isDisabled={isDisabled}
|
||||||
disabled={disabled}
|
focusRingPlacement="after"
|
||||||
{...(disabled && error != null ? { title: error } : {})}
|
{...(isDisabled && error != null ? { title: error } : {})}
|
||||||
className={`selectable ${permission.docs && (!disabled || !input) ? 'active' : ''} ${
|
className="relative h-text grow after:absolute after:inset"
|
||||||
permissionsModule.DOCS_CLASS_NAME
|
onPress={() => {
|
||||||
} h-text grow px-permission-mini-button-x py-permission-mini-button-y`}
|
|
||||||
onClick={event => {
|
|
||||||
event.stopPropagation()
|
|
||||||
setAction(
|
setAction(
|
||||||
permissionsModule.toPermissionAction({
|
permissionsModule.toPermissionAction({
|
||||||
type: permission.type,
|
type: permission.type,
|
||||||
@ -160,18 +162,21 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<aria.Text
|
||||||
|
className={`selectable ${permission.docs && (!isDisabled || !input) ? 'active' : ''} ${
|
||||||
|
permissionsModule.DOCS_CLASS_NAME
|
||||||
|
} h-text grow px-permission-mini-button-x py-permission-mini-button-y`}
|
||||||
>
|
>
|
||||||
{getText('docsPermissionModifier')}
|
{getText('docsPermissionModifier')}
|
||||||
</button>
|
</aria.Text>
|
||||||
<button
|
</UnstyledButton>
|
||||||
type="button"
|
<UnstyledButton
|
||||||
disabled={disabled}
|
isDisabled={isDisabled}
|
||||||
{...(disabled && error != null ? { title: error } : {})}
|
focusRingPlacement="after"
|
||||||
className={`selectable ${permission.execute && (!disabled || !input) ? 'active' : ''} ${
|
{...(isDisabled && error != null ? { title: error } : {})}
|
||||||
permissionsModule.EXEC_CLASS_NAME
|
className="relative h-text grow rounded-r-full after:absolute after:inset after:rounded-r-full"
|
||||||
} h-text grow rounded-r-full px-permission-mini-button-x py-permission-mini-button-y`}
|
onPress={() => {
|
||||||
onClick={event => {
|
|
||||||
event.stopPropagation()
|
|
||||||
setAction(
|
setAction(
|
||||||
permissionsModule.toPermissionAction({
|
permissionsModule.toPermissionAction({
|
||||||
type: permission.type,
|
type: permission.type,
|
||||||
@ -180,26 +185,32 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<aria.Text
|
||||||
|
className={`selectable ${permission.execute && (!isDisabled || !input) ? 'active' : ''} ${
|
||||||
|
permissionsModule.EXEC_CLASS_NAME
|
||||||
|
} rounded-r-full px-permission-mini-button-x py-permission-mini-button-y`}
|
||||||
>
|
>
|
||||||
{getText('execPermissionModifier')}
|
{getText('execPermissionModifier')}
|
||||||
</button>
|
</aria.Text>
|
||||||
|
</UnstyledButton>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
permissionDisplay = (
|
permissionDisplay = (
|
||||||
<button
|
<UnstyledButton
|
||||||
type="button"
|
ref={permissionSelectorButtonRef}
|
||||||
disabled={disabled}
|
isDisabled={isDisabled}
|
||||||
{...(disabled && error != null ? { title: error } : {})}
|
{...(isDisabled && error != null ? { title: error } : {})}
|
||||||
className={`selectable ${!disabled || !input ? 'active' : ''} ${
|
className={`selectable ${!isDisabled || !input ? 'active' : ''} ${
|
||||||
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
||||||
} h-text w-permission-display rounded-full`}
|
} h-text w-permission-display rounded-full`}
|
||||||
onClick={doShowPermissionTypeSelector}
|
onPress={doShowPermissionTypeSelector}
|
||||||
>
|
>
|
||||||
{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}
|
{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
/** @file A selector for all possible permission types. */
|
/** @file A selector for all possible permission types. */
|
||||||
import * as React from 'react'
|
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 backend from '#/services/Backend'
|
||||||
|
|
||||||
import * as permissions from '#/utilities/permissions'
|
import * as permissions from '#/utilities/permissions'
|
||||||
@ -83,12 +87,15 @@ export interface PermissionTypeSelectorProps {
|
|||||||
export default function PermissionTypeSelector(props: PermissionTypeSelectorProps) {
|
export default function PermissionTypeSelector(props: PermissionTypeSelectorProps) {
|
||||||
const { showDelete = false, selfPermission, type, assetType, style, onChange } = props
|
const { showDelete = false, selfPermission, type, assetType, style, onChange } = props
|
||||||
return (
|
return (
|
||||||
|
<FocusArea direction="vertical">
|
||||||
|
{innerProps => (
|
||||||
<div
|
<div
|
||||||
style={style}
|
style={style}
|
||||||
className="pointer-events-auto sticky w-min rounded-permission-type-selector before:absolute before:h-full before:w-full before:rounded-permission-type-selector before:bg-selected-frame before:backdrop-blur-default"
|
className="pointer-events-auto sticky w-min rounded-permission-type-selector before:absolute before:h-full before:w-full before:rounded-permission-type-selector before:bg-selected-frame before:backdrop-blur-default"
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
|
{...innerProps}
|
||||||
>
|
>
|
||||||
<div className="group relative flex w-permission-type-selector flex-col p-permission-type-selector">
|
<div className="group relative flex w-permission-type-selector flex-col p-permission-type-selector">
|
||||||
{PERMISSION_TYPE_DATA.filter(
|
{PERMISSION_TYPE_DATA.filter(
|
||||||
@ -98,13 +105,14 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
|
|||||||
? true
|
? true
|
||||||
: data.type !== permissions.Permission.owner)
|
: data.type !== permissions.Permission.owner)
|
||||||
).map(data => (
|
).map(data => (
|
||||||
<button
|
<UnstyledButton
|
||||||
key={data.type}
|
key={data.type}
|
||||||
type="button"
|
|
||||||
className={`flex h-row items-start gap-permission-type-button rounded-full p-permission-type-button hover:bg-black/5 ${
|
className={`flex h-row items-start gap-permission-type-button rounded-full p-permission-type-button hover:bg-black/5 ${
|
||||||
type === data.type ? 'bg-black/5 hover:!bg-black/5 group-hover:bg-transparent' : ''
|
type === data.type
|
||||||
|
? 'bg-black/5 hover:!bg-black/5 group-hover:bg-transparent'
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
onChange(data.type)
|
onChange(data.type)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -118,7 +126,7 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
|
|||||||
{/* This is a symbol that should never need to be localized, since it is effectively
|
{/* This is a symbol that should never need to be localized, since it is effectively
|
||||||
* an icon. */}
|
* an icon. */}
|
||||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||||
<span className="text font-normal">=</span>
|
<aria.Text className="text font-normal">=</aria.Text>
|
||||||
{data.previous != null && (
|
{data.previous != null && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -131,13 +139,15 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
|
|||||||
{/* This is a symbol that should never need to be localized, since it is effectively
|
{/* This is a symbol that should never need to be localized, since it is effectively
|
||||||
* an icon. */}
|
* an icon. */}
|
||||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||||
<span className="text font-normal">+</span>
|
<aria.Text className="text font-normal">+</aria.Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="text">{data.description(assetType)}</span>
|
<aria.Label className="text">{data.description(assetType)}</aria.Label>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ import AssetEventType from '#/events/AssetEventType'
|
|||||||
|
|
||||||
import Spinner, * as spinner from '#/components/Spinner'
|
import Spinner, * as spinner from '#/components/Spinner'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
import * as remoteBackend from '#/services/RemoteBackend'
|
import * as remoteBackend from '#/services/RemoteBackend'
|
||||||
@ -349,30 +350,28 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
|||||||
case backendModule.ProjectState.closing:
|
case backendModule.ProjectState.closing:
|
||||||
case backendModule.ProjectState.closed:
|
case backendModule.ProjectState.closed:
|
||||||
return (
|
return (
|
||||||
<button
|
<UnstyledButton
|
||||||
className="size-project-icon"
|
className="size-project-icon rounded-full"
|
||||||
onClick={clickEvent => {
|
onPress={() => {
|
||||||
clickEvent.stopPropagation()
|
|
||||||
unsetModal()
|
unsetModal()
|
||||||
doOpenManually(item.id)
|
doOpenManually(item.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SvgMask alt={getText('openInEditor')} src={PlayIcon} className="size-project-icon" />
|
<SvgMask alt={getText('openInEditor')} src={PlayIcon} className="size-project-icon" />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)
|
)
|
||||||
case backendModule.ProjectState.openInProgress:
|
case backendModule.ProjectState.openInProgress:
|
||||||
case backendModule.ProjectState.scheduled:
|
case backendModule.ProjectState.scheduled:
|
||||||
case backendModule.ProjectState.provisioned:
|
case backendModule.ProjectState.provisioned:
|
||||||
case backendModule.ProjectState.placeholder:
|
case backendModule.ProjectState.placeholder:
|
||||||
return (
|
return (
|
||||||
<button
|
<UnstyledButton
|
||||||
disabled={isOtherUserUsingProject}
|
isDisabled={isOtherUserUsingProject}
|
||||||
{...(isOtherUserUsingProject ? { title: 'Someone else is using this project.' } : {})}
|
{...(isOtherUserUsingProject ? { title: 'Someone else is using this project.' } : {})}
|
||||||
className="size-project-icon selectable enabled:active"
|
className="size-project-icon rounded-full selectable enabled:active"
|
||||||
onClick={async clickEvent => {
|
onPress={() => {
|
||||||
clickEvent.stopPropagation()
|
|
||||||
unsetModal()
|
unsetModal()
|
||||||
await closeProject(!isRunningInBackground)
|
void closeProject(!isRunningInBackground)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={`relative h ${isRunningInBackground ? 'text-green' : ''}`}>
|
<div className={`relative h ${isRunningInBackground ? 'text-green' : ''}`}>
|
||||||
@ -383,19 +382,18 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
|||||||
src={StopIcon}
|
src={StopIcon}
|
||||||
className={`size-project-icon ${isRunningInBackground ? 'text-green' : ''}`}
|
className={`size-project-icon ${isRunningInBackground ? 'text-green' : ''}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)
|
)
|
||||||
case backendModule.ProjectState.opened:
|
case backendModule.ProjectState.opened:
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<UnstyledButton
|
||||||
disabled={isOtherUserUsingProject}
|
isDisabled={isOtherUserUsingProject}
|
||||||
{...(isOtherUserUsingProject ? { title: 'Someone else has this project open.' } : {})}
|
{...(isOtherUserUsingProject ? { title: 'Someone else has this project open.' } : {})}
|
||||||
className="size-project-icon selectable enabled:active"
|
className="size-project-icon rounded-full selectable enabled:active"
|
||||||
onClick={async clickEvent => {
|
onPress={() => {
|
||||||
clickEvent.stopPropagation()
|
|
||||||
unsetModal()
|
unsetModal()
|
||||||
await closeProject(!isRunningInBackground)
|
void closeProject(!isRunningInBackground)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={`relative h ${isRunningInBackground ? 'text-green' : ''}`}>
|
<div className={`relative h ${isRunningInBackground ? 'text-green' : ''}`}>
|
||||||
@ -406,12 +404,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
|||||||
src={StopIcon}
|
src={StopIcon}
|
||||||
className={`size-project-icon ${isRunningInBackground ? 'text-green' : ''}`}
|
className={`size-project-icon ${isRunningInBackground ? 'text-green' : ''}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
{!isOtherUserUsingProject && !isRunningInBackground && (
|
{!isOtherUserUsingProject && !isRunningInBackground && (
|
||||||
<button
|
<UnstyledButton
|
||||||
className="size-project-icon"
|
className="size-project-icon rounded-full"
|
||||||
onClick={clickEvent => {
|
onPress={() => {
|
||||||
clickEvent.stopPropagation()
|
|
||||||
unsetModal()
|
unsetModal()
|
||||||
doOpenEditor(true)
|
doOpenEditor(true)
|
||||||
}}
|
}}
|
||||||
@ -421,7 +418,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
|||||||
src={ArrowUpIcon}
|
src={ArrowUpIcon}
|
||||||
className="size-project-icon"
|
className="size-project-icon"
|
||||||
/>
|
/>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -310,6 +310,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
|||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
checkSubmittable={newTitle =>
|
checkSubmittable={newTitle =>
|
||||||
|
newTitle !== item.item.title &&
|
||||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||||
child =>
|
child =>
|
||||||
// All siblings,
|
// All siblings,
|
||||||
|
@ -14,6 +14,7 @@ import * as modalProvider from '#/providers/ModalProvider'
|
|||||||
import AssetEventType from '#/events/AssetEventType'
|
import AssetEventType from '#/events/AssetEventType'
|
||||||
import AssetListEventType from '#/events/AssetListEventType'
|
import AssetListEventType from '#/events/AssetListEventType'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
@ -148,9 +149,9 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
|||||||
>
|
>
|
||||||
<SvgMask src={KeyIcon} className="m-name-column-icon size-icon" />
|
<SvgMask src={KeyIcon} className="m-name-column-icon size-icon" />
|
||||||
{/* Secrets cannot be renamed. */}
|
{/* Secrets cannot be renamed. */}
|
||||||
<span data-testid="asset-row-name" className="text grow bg-transparent">
|
<aria.Text data-testid="asset-row-name" className="text grow bg-transparent">
|
||||||
{asset.title}
|
{asset.title}
|
||||||
</span>
|
</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,9 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
|||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||||
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
@ -50,6 +52,7 @@ export default function UserPermission(props: UserPermissionProps) {
|
|||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const [userPermission, setUserPermission] = React.useState(initialUserPermission)
|
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])
|
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -73,10 +76,12 @@ export default function UserPermission(props: UserPermissionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-user-permission">
|
<FocusArea active={!isDisabled} direction="horizontal">
|
||||||
|
{innerProps => (
|
||||||
|
<div className="flex items-center gap-user-permission" {...innerProps}>
|
||||||
<PermissionSelector
|
<PermissionSelector
|
||||||
showDelete
|
showDelete
|
||||||
disabled={isOnlyOwner && userPermission.user.userId === self.user.userId}
|
isDisabled={isDisabled}
|
||||||
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
|
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
|
||||||
selfPermission={self.permission}
|
selfPermission={self.permission}
|
||||||
action={userPermission.permission}
|
action={userPermission.permission}
|
||||||
@ -88,7 +93,9 @@ export default function UserPermission(props: UserPermissionProps) {
|
|||||||
doDelete(userPermission.user)
|
doDelete(userPermission.user)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text">{userPermission.user.name}</span>
|
<aria.Text className="text">{userPermission.user.name}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ import type * as column from '#/components/dashboard/column'
|
|||||||
import Label from '#/components/dashboard/Label'
|
import Label from '#/components/dashboard/Label'
|
||||||
import * as labelUtils from '#/components/dashboard/Label/labelUtils'
|
import * as labelUtils from '#/components/dashboard/Label/labelUtils'
|
||||||
import MenuEntry from '#/components/MenuEntry'
|
import MenuEntry from '#/components/MenuEntry'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import ManageLabelsModal from '#/modals/ManageLabelsModal'
|
import ManageLabelsModal from '#/modals/ManageLabelsModal'
|
||||||
|
|
||||||
@ -42,6 +43,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
|||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
|
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
const self = asset.permissions?.find(
|
const self = asset.permissions?.find(
|
||||||
permission => permission.user.userId === session.user?.userId
|
permission => permission.user.userId === session.user?.userId
|
||||||
)
|
)
|
||||||
@ -52,7 +54,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
|||||||
const setAsset = React.useCallback(
|
const setAsset = React.useCallback(
|
||||||
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
|
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
|
||||||
setItem(oldItem =>
|
setItem(oldItem =>
|
||||||
object.merge(oldItem, {
|
oldItem.with({
|
||||||
item:
|
item:
|
||||||
typeof valueOrUpdater !== 'function' ? valueOrUpdater : valueOrUpdater(oldItem.item),
|
typeof valueOrUpdater !== 'function' ? valueOrUpdater : valueOrUpdater(oldItem.item),
|
||||||
})
|
})
|
||||||
@ -60,6 +62,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
|||||||
},
|
},
|
||||||
[/* should never change */ setItem]
|
[/* should never change */ setItem]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center gap-column-items">
|
<div className="group flex items-center gap-column-items">
|
||||||
{(asset.labels ?? [])
|
{(asset.labels ?? [])
|
||||||
@ -71,7 +74,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
|||||||
title={getText('rightClickToRemoveLabel')}
|
title={getText('rightClickToRemoveLabel')}
|
||||||
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
||||||
active={!temporarilyRemovedLabels.has(label)}
|
active={!temporarilyRemovedLabels.has(label)}
|
||||||
disabled={temporarilyRemovedLabels.has(label)}
|
isDisabled={temporarilyRemovedLabels.has(label)}
|
||||||
negated={temporarilyRemovedLabels.has(label)}
|
negated={temporarilyRemovedLabels.has(label)}
|
||||||
className={
|
className={
|
||||||
temporarilyRemovedLabels.has(label)
|
temporarilyRemovedLabels.has(label)
|
||||||
@ -102,15 +105,17 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
|||||||
}
|
}
|
||||||
setModal(
|
setModal(
|
||||||
<ContextMenus key={`label-${label}`} event={event}>
|
<ContextMenus key={`label-${label}`} event={event}>
|
||||||
<ContextMenu>
|
<ContextMenu aria-label={getText('labelContextMenuLabel')}>
|
||||||
<MenuEntry action="delete" doAction={doDelete} />
|
<MenuEntry
|
||||||
|
action="delete"
|
||||||
|
label={getText('deleteLabelShortcut')}
|
||||||
|
doAction={doDelete}
|
||||||
|
/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</ContextMenus>
|
</ContextMenus>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onClick={event => {
|
onPress={event => {
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
setQuery(oldQuery =>
|
setQuery(oldQuery =>
|
||||||
oldQuery.withToggled('labels', 'negativeLabels', label, event.shiftKey)
|
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)
|
.filter(label => asset.labels?.includes(label) !== true)
|
||||||
.map(label => (
|
.map(label => (
|
||||||
<Label
|
<Label
|
||||||
disabled
|
isDisabled
|
||||||
key={label}
|
key={label}
|
||||||
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
||||||
className="pointer-events-none"
|
className="pointer-events-none"
|
||||||
onClick={() => {}}
|
onPress={() => {}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
))}
|
))}
|
||||||
{managesThisAsset && (
|
{managesThisAsset && (
|
||||||
<button
|
<UnstyledButton
|
||||||
className="invisible shrink-0 group-hover:visible"
|
ref={plusButtonRef}
|
||||||
onClick={event => {
|
className="shrink-0 rounded-full transparent group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
event.stopPropagation()
|
onPress={() => {
|
||||||
setModal(
|
setModal(
|
||||||
<ManageLabelsModal
|
<ManageLabelsModal
|
||||||
key={uniqueString.uniqueString()}
|
key={uniqueString.uniqueString()}
|
||||||
@ -144,13 +149,13 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
|||||||
setItem={setAsset}
|
setItem={setAsset}
|
||||||
allLabels={labels}
|
allLabels={labels}
|
||||||
doCreateLabel={doCreateLabel}
|
doCreateLabel={doCreateLabel}
|
||||||
eventTarget={event.currentTarget}
|
eventTarget={plusButtonRef.current}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img className="size-plus-icon" src={Plus2Icon} />
|
<img className="size-plus-icon" src={Plus2Icon} />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -12,12 +12,12 @@ import Category from '#/layouts/CategorySwitcher/Category'
|
|||||||
|
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
|
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
||||||
|
|
||||||
import type * as backendModule from '#/services/Backend'
|
import type * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
import * as object from '#/utilities/object'
|
|
||||||
import * as permissions from '#/utilities/permissions'
|
import * as permissions from '#/utilities/permissions'
|
||||||
import * as uniqueString from '#/utilities/uniqueString'
|
import * as uniqueString from '#/utilities/uniqueString'
|
||||||
|
|
||||||
@ -41,6 +41,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
|||||||
const asset = item.item
|
const asset = item.item
|
||||||
const { user } = authProvider.useNonPartialUserSession()
|
const { user } = authProvider.useNonPartialUserSession()
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
|
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
const self = asset.permissions?.find(permission => permission.user.userId === user?.userId)
|
const self = asset.permissions?.find(permission => permission.user.userId === user?.userId)
|
||||||
const managesThisAsset =
|
const managesThisAsset =
|
||||||
category !== Category.trash &&
|
category !== Category.trash &&
|
||||||
@ -49,7 +50,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
|||||||
const setAsset = React.useCallback(
|
const setAsset = React.useCallback(
|
||||||
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
|
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
|
||||||
setItem(oldItem =>
|
setItem(oldItem =>
|
||||||
object.merge(oldItem, {
|
oldItem.with({
|
||||||
item:
|
item:
|
||||||
typeof valueOrUpdater !== 'function' ? valueOrUpdater : valueOrUpdater(oldItem.item),
|
typeof valueOrUpdater !== 'function' ? valueOrUpdater : valueOrUpdater(oldItem.item),
|
||||||
})
|
})
|
||||||
@ -57,13 +58,14 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
|||||||
},
|
},
|
||||||
[/* should never change */ setItem]
|
[/* should never change */ setItem]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center gap-column-items">
|
<div className="group flex items-center gap-column-items">
|
||||||
{(asset.permissions ?? []).map(otherUser => (
|
{(asset.permissions ?? []).map(otherUser => (
|
||||||
<PermissionDisplay
|
<PermissionDisplay
|
||||||
key={otherUser.user.userId}
|
key={otherUser.user.userId}
|
||||||
action={otherUser.permission}
|
action={otherUser.permission}
|
||||||
onClick={event => {
|
onPress={event => {
|
||||||
setQuery(oldQuery =>
|
setQuery(oldQuery =>
|
||||||
oldQuery.withToggled('owners', 'negativeOwners', otherUser.user.name, event.shiftKey)
|
oldQuery.withToggled('owners', 'negativeOwners', otherUser.user.name, event.shiftKey)
|
||||||
)
|
)
|
||||||
@ -73,17 +75,17 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
|||||||
</PermissionDisplay>
|
</PermissionDisplay>
|
||||||
))}
|
))}
|
||||||
{managesThisAsset && (
|
{managesThisAsset && (
|
||||||
<button
|
<UnstyledButton
|
||||||
className="invisible shrink-0 group-hover:visible"
|
ref={plusButtonRef}
|
||||||
onClick={event => {
|
className="shrink-0 rounded-full transparent group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
event.stopPropagation()
|
onPress={() => {
|
||||||
setModal(
|
setModal(
|
||||||
<ManagePermissionsModal
|
<ManagePermissionsModal
|
||||||
key={uniqueString.uniqueString()}
|
key={uniqueString.uniqueString()}
|
||||||
item={asset}
|
item={asset}
|
||||||
setItem={setAsset}
|
setItem={setAsset}
|
||||||
self={self}
|
self={self}
|
||||||
eventTarget={event.currentTarget}
|
eventTarget={plusButtonRef.current}
|
||||||
doRemoveSelf={() => {
|
doRemoveSelf={() => {
|
||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
type: AssetEventType.removeSelf,
|
type: AssetEventType.removeSelf,
|
||||||
@ -95,7 +97,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img className="size-plus-icon" src={Plus2Icon} />
|
<img className="size-plus-icon" src={Plus2Icon} />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -5,6 +5,7 @@ import AccessedByProjectsIcon from 'enso-assets/accessed_by_projects.svg'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
@ -26,7 +27,7 @@ export default function AccessedByProjectsColumnHeading(props: column.AssetColum
|
|||||||
hideColumn(columnUtils.Column.accessedByProjects)
|
hideColumn(columnUtils.Column.accessedByProjects)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-header">{getText('accessedByProjectsColumnName')}</span>
|
<aria.Text className="text-header">{getText('accessedByProjectsColumnName')}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import AccessedDataIcon from 'enso-assets/accessed_data.svg'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
@ -26,7 +27,7 @@ export default function AccessedDataColumnHeading(props: column.AssetColumnHeadi
|
|||||||
hideColumn(columnUtils.Column.accessedData)
|
hideColumn(columnUtils.Column.accessedData)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-header">{getText('accessedDataColumnName')}</span>
|
<aria.Text className="text-header">{getText('accessedDataColumnName')}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import DocsIcon from 'enso-assets/docs.svg'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
@ -26,7 +27,7 @@ export default function DocsColumnHeading(props: column.AssetColumnHeadingProps)
|
|||||||
hideColumn(columnUtils.Column.docs)
|
hideColumn(columnUtils.Column.docs)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-header">{getText('docsColumnName')}</span>
|
<aria.Text className="text-header">{getText('docsColumnName')}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import TagIcon from 'enso-assets/tag.svg'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
@ -26,7 +27,7 @@ export default function LabelsColumnHeading(props: column.AssetColumnHeadingProp
|
|||||||
hideColumn(columnUtils.Column.labels)
|
hideColumn(columnUtils.Column.labels)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-header">{getText('labelsColumnName')}</span>
|
<aria.Text className="text-header">{getText('labelsColumnName')}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -6,9 +6,11 @@ import TimeIcon from 'enso-assets/time.svg'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as sorting from '#/utilities/sorting'
|
import * as sorting from '#/utilities/sorting'
|
||||||
|
|
||||||
@ -21,8 +23,8 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
|||||||
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
|
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<UnstyledButton
|
||||||
title={
|
aria-label={
|
||||||
!isSortActive
|
!isSortActive
|
||||||
? getText('sortByModificationDate')
|
? getText('sortByModificationDate')
|
||||||
: isDescending
|
: isDescending
|
||||||
@ -30,8 +32,7 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
|||||||
: getText('sortByModificationDateDescending')
|
: getText('sortByModificationDateDescending')
|
||||||
}
|
}
|
||||||
className="group flex h-drive-table-heading w-full cursor-pointer items-center gap-icon-with-text"
|
className="group flex h-drive-table-heading w-full cursor-pointer items-center gap-icon-with-text"
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
event.stopPropagation()
|
|
||||||
const nextDirection = isSortActive
|
const nextDirection = isSortActive
|
||||||
? sorting.nextSortDirection(sortInfo.direction)
|
? sorting.nextSortDirection(sortInfo.direction)
|
||||||
: sorting.SortDirection.ascending
|
: sorting.SortDirection.ascending
|
||||||
@ -51,7 +52,7 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
|||||||
hideColumn(columnUtils.Column.modified)
|
hideColumn(columnUtils.Column.modified)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-header">{getText('modifiedColumnName')}</span>
|
<aria.Text className="text-header">{getText('modifiedColumnName')}</aria.Text>
|
||||||
<img
|
<img
|
||||||
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
|
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
|
||||||
src={SortAscendingIcon}
|
src={SortAscendingIcon}
|
||||||
@ -59,6 +60,6 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
|||||||
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'
|
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'
|
||||||
} ${isDescending ? 'rotate-180' : ''}`}
|
} ${isDescending ? 'rotate-180' : ''}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,10 @@ import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as sorting from '#/utilities/sorting'
|
import * as sorting from '#/utilities/sorting'
|
||||||
|
|
||||||
@ -19,8 +21,8 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
|||||||
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
|
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<UnstyledButton
|
||||||
title={
|
aria-label={
|
||||||
!isSortActive
|
!isSortActive
|
||||||
? getText('sortByName')
|
? getText('sortByName')
|
||||||
: isDescending
|
: isDescending
|
||||||
@ -28,8 +30,7 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
|||||||
: getText('sortByNameDescending')
|
: getText('sortByNameDescending')
|
||||||
}
|
}
|
||||||
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
|
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
event.stopPropagation()
|
|
||||||
const nextDirection = isSortActive
|
const nextDirection = isSortActive
|
||||||
? sorting.nextSortDirection(sortInfo.direction)
|
? sorting.nextSortDirection(sortInfo.direction)
|
||||||
: sorting.SortDirection.ascending
|
: sorting.SortDirection.ascending
|
||||||
@ -40,7 +41,7 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-header">{getText('nameColumnName')}</span>
|
<aria.Text className="text-header">{getText('nameColumnName')}</aria.Text>
|
||||||
<img
|
<img
|
||||||
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
|
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
|
||||||
src={SortAscendingIcon}
|
src={SortAscendingIcon}
|
||||||
@ -48,6 +49,6 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
|||||||
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'
|
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'
|
||||||
} ${isDescending ? 'rotate-180' : ''}`}
|
} ${isDescending ? 'rotate-180' : ''}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import PeopleIcon from 'enso-assets/people.svg'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
@ -26,7 +27,7 @@ export default function SharedWithColumnHeading(props: column.AssetColumnHeading
|
|||||||
hideColumn(columnUtils.Column.sharedWith)
|
hideColumn(columnUtils.Column.sharedWith)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-header">{getText('sharedWithColumnName')}</span>
|
<aria.Text className="text-header">{getText('sharedWithColumnName')}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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<HTMLButtonElement>) {
|
||||||
|
const {
|
||||||
|
active = false,
|
||||||
|
softDisabled = false,
|
||||||
|
image,
|
||||||
|
error,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
...buttonProps
|
||||||
|
} = props
|
||||||
|
const { isDisabled = false } = buttonProps
|
||||||
|
const focusChildProps = focusHooks.useFocusChild()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusRing placement="after">
|
||||||
|
<aria.Button
|
||||||
|
{...aria.mergeProps<aria.ButtonProps>()(buttonProps, focusChildProps, {
|
||||||
|
ref,
|
||||||
|
className:
|
||||||
|
'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`group flex selectable ${isDisabled || softDisabled ? 'disabled' : ''} ${active ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<SvgMask
|
||||||
|
src={image}
|
||||||
|
{...(!active && isDisabled && error != null ? { title: error } : {})}
|
||||||
|
{...(alt != null ? { alt } : {})}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aria.Button>
|
||||||
|
</FocusRing>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.forwardRef(Button)
|
@ -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<React.PropsWithChildren> {
|
||||||
|
/** 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 (
|
||||||
|
<FocusArea direction="horizontal">
|
||||||
|
{innerProps => (
|
||||||
|
<div className={`relative flex gap-buttons self-start ${positionClass}`} {...innerProps}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
|
)
|
||||||
|
}
|
@ -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<Readonly<aria.CheckboxProps>, 'className'> {}
|
||||||
|
|
||||||
|
/** A styled checkbox. */
|
||||||
|
export default function Checkbox(props: CheckboxProps) {
|
||||||
|
return (
|
||||||
|
<FocusRing>
|
||||||
|
<aria.Checkbox
|
||||||
|
className="group flex size-3 cursor-pointer overflow-clip rounded-sm text-cloud outline outline-1 outline-primary checkbox"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SvgMask
|
||||||
|
invert
|
||||||
|
src={CheckMarkIcon}
|
||||||
|
className="-m-0.5 size-icon transition-all duration-75 transparent group-selected:opacity-100"
|
||||||
|
/>
|
||||||
|
</aria.Checkbox>
|
||||||
|
</FocusRing>
|
||||||
|
)
|
||||||
|
}
|
@ -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<HTMLElement | SVGElement | null>
|
||||||
|
readonly onFocus: NonNullable<aria.DOMAttributes<Element>['onFocus']>
|
||||||
|
readonly onBlur: NonNullable<aria.DOMAttributes<Element>['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<HTMLElement | SVGElement | null>(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 = (
|
||||||
|
<FocusDirectionProvider direction={direction}>
|
||||||
|
<AreaFocusProvider areaFocus={areaFocus}>{cachedChildren}</AreaFocusProvider>
|
||||||
|
</FocusDirectionProvider>
|
||||||
|
)
|
||||||
|
return focusChildClass === outerFocusChildClass ? (
|
||||||
|
result
|
||||||
|
) : (
|
||||||
|
<FocusClassesProvider focusChildClass={focusChildClass}>{result}</FocusClassesProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An area that can be focused within. */
|
||||||
|
export default withFocusScope.withFocusScope(FocusArea)
|
@ -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<Pick<aria.FocusRingProps, 'children'>> {
|
||||||
|
/** 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 (
|
||||||
|
<aria.FocusRing within={within} focusRingClass={focusClass}>
|
||||||
|
{children}
|
||||||
|
</aria.FocusRing>
|
||||||
|
)
|
||||||
|
}
|
@ -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<HTMLElement | SVGElement | null>
|
||||||
|
readonly onKeyDown?: React.KeyboardEventHandler<HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
) : (
|
||||||
|
<aria.FocusScope contain restoreFocus autoFocus>
|
||||||
|
{cachedChildren}
|
||||||
|
</aria.FocusScope>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An area that can be focused within. */
|
||||||
|
export default withFocusScope.withFocusScope(FocusRoot)
|
@ -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<React.PropsWithChildren> {}
|
||||||
|
|
||||||
|
/** A styled horizontal menu bar. */
|
||||||
|
export default function HorizontalMenuBar(props: HorizontalMenuBarProps) {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusArea direction="horizontal">
|
||||||
|
{innerProps => (
|
||||||
|
<div className="flex h-row gap-drive-bar" {...innerProps}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
|
)
|
||||||
|
}
|
@ -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<aria.InputProps> {}
|
||||||
|
|
||||||
|
/** An input that handles focus movement. */
|
||||||
|
function Input(props: InputProps, ref: React.ForwardedRef<HTMLInputElement>) {
|
||||||
|
const focusDirection = focusDirectionProvider.useFocusDirection()
|
||||||
|
const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aria.Input
|
||||||
|
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(props, {
|
||||||
|
ref,
|
||||||
|
className: 'focus-child',
|
||||||
|
onKeyDown: handleFocusMove,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.forwardRef(Input)
|
@ -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<T> 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<T>(props: RenderPropsHookOptions<T>) {
|
||||||
|
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<Element>, 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<HTMLDivElement>) {
|
||||||
|
;[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 (
|
||||||
|
<div
|
||||||
|
{...radioGroupProps}
|
||||||
|
{...renderProps}
|
||||||
|
ref={ref}
|
||||||
|
slot={(props.slot ?? '') || UNDEFINED}
|
||||||
|
data-orientation={props.orientation || 'vertical'}
|
||||||
|
data-invalid={state.isInvalid || UNDEFINED}
|
||||||
|
data-disabled={state.isDisabled || UNDEFINED}
|
||||||
|
data-readonly={state.isReadOnly || UNDEFINED}
|
||||||
|
data-required={state.isRequired || UNDEFINED}
|
||||||
|
>
|
||||||
|
<aria.Provider
|
||||||
|
values={[
|
||||||
|
[aria.RadioGroupStateContext, state],
|
||||||
|
[aria.LabelContext, { ...labelProps, ref: labelRef, elementType: 'span' }],
|
||||||
|
[
|
||||||
|
aria.TextContext,
|
||||||
|
{
|
||||||
|
slots: {
|
||||||
|
description: descriptionProps,
|
||||||
|
errorMessage: errorMessageProps,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[aria.FieldErrorContext, validation],
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{renderProps.children}
|
||||||
|
</aria.Provider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A radio group allows a user to select a single item from a list of mutually exclusive options. */
|
||||||
|
export default React.forwardRef(RadioGroup)
|
@ -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 && (
|
||||||
|
<aria.Separator className="mx-context-menu-entry-px my-separator-y border-t-[0.5px] border-black/[0.16]" />
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
@ -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 (
|
||||||
|
<UnstyledButton
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
onPress={onPress}
|
||||||
|
className={`rounded-full ${active ? 'focus-default' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`button icon-with-text h-row px-button-x transition-colors selectable hover:bg-selected-frame ${active ? 'disabled bg-selected-frame active' : ''}`}
|
||||||
|
>
|
||||||
|
<SvgMask src={icon} />
|
||||||
|
<aria.Text className="text">{label}</aria.Text>
|
||||||
|
</div>
|
||||||
|
</UnstyledButton>
|
||||||
|
)
|
||||||
|
}
|
@ -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<HTMLInputElement>
|
||||||
|
readonly onSubmit?: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A styled input specific to settings pages. */
|
||||||
|
function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLInputElement>) {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="text my-auto grow font-bold">
|
||||||
|
<FocusRing within placement="after">
|
||||||
|
<aria.Group className="relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-full">
|
||||||
|
<aria.Input
|
||||||
|
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(
|
||||||
|
{
|
||||||
|
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' && (
|
||||||
|
<SvgMask
|
||||||
|
src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
|
||||||
|
className="absolute right-2 top-1 cursor-pointer rounded-full"
|
||||||
|
onClick={() => {
|
||||||
|
setIsShowingPassword(show => !show)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</aria.Group>
|
||||||
|
</FocusRing>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.forwardRef(SettingsInput)
|
@ -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<React.PropsWithChildren> {}
|
||||||
|
|
||||||
|
/** Styled content of a settings tab. */
|
||||||
|
export default function SettingsPage(props: SettingsPageProps) {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <div className="flex flex-col gap-settings-subsection">{children}</div>
|
||||||
|
}
|
@ -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<React.PropsWithChildren> {
|
||||||
|
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 = (
|
||||||
|
<aria.Heading level={2} className="h-[2.375rem] py-0.5 text-xl font-bold">
|
||||||
|
{title}
|
||||||
|
</aria.Heading>
|
||||||
|
)
|
||||||
|
|
||||||
|
return noFocusArea ? (
|
||||||
|
<div className={`flex flex-col gap-settings-section-header ${className}`}>
|
||||||
|
{heading}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FocusArea direction="vertical">
|
||||||
|
{innerProps => (
|
||||||
|
<div className={`flex flex-col gap-settings-section-header ${className}`} {...innerProps}>
|
||||||
|
{heading}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
|
)
|
||||||
|
}
|
@ -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<ComponentType extends (props: any) => 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 (
|
||||||
|
<aria.FocusScope>
|
||||||
|
{/* eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any */}
|
||||||
|
<Child {...(props as any)} />
|
||||||
|
</aria.FocusScope>
|
||||||
|
)
|
||||||
|
// This type assertion is REQUIRED in order to preserve generics.
|
||||||
|
} as unknown as ComponentType
|
||||||
|
}
|
93
app/ide-desktop/lib/dashboard/src/hooks/focusHooks.ts
Normal file
93
app/ide-desktop/lib/dashboard/src/hooks/focusHooks.ts
Normal file
@ -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<NonNullable<aria.KeyboardEvents['onKeyUp']>>[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<Element>
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// === 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<Element>
|
||||||
|
}
|
43
app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts
Normal file
43
app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts
Normal file
@ -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
|
||||||
|
}
|
@ -4,7 +4,7 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import type * as backend from '#/services/Backend'
|
import type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
import AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||||
|
|
||||||
// ===================
|
// ===================
|
||||||
// === useSetAsset ===
|
// === useSetAsset ===
|
||||||
@ -28,7 +28,13 @@ export function useSetAsset<T extends backend.AnyAsset>(
|
|||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
valueOrUpdater(oldNode.item as T)
|
valueOrUpdater(oldNode.item as T)
|
||||||
: valueOrUpdater
|
: 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]
|
[/* should never change */ setNode]
|
||||||
|
@ -3,6 +3,7 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import * as toast from 'react-toastify'
|
import * as toast from 'react-toastify'
|
||||||
|
|
||||||
|
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
@ -20,8 +21,8 @@ import GlobalContextMenu from '#/layouts/GlobalContextMenu'
|
|||||||
import ContextMenu from '#/components/ContextMenu'
|
import ContextMenu from '#/components/ContextMenu'
|
||||||
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||||
import ContextMenus from '#/components/ContextMenus'
|
import ContextMenus from '#/components/ContextMenus'
|
||||||
import ContextMenuSeparator from '#/components/ContextMenuSeparator'
|
|
||||||
import type * as assetRow from '#/components/dashboard/AssetRow'
|
import type * as assetRow from '#/components/dashboard/AssetRow'
|
||||||
|
import Separator from '#/components/styled/Separator'
|
||||||
|
|
||||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||||
import ManageLabelsModal from '#/modals/ManageLabelsModal'
|
import ManageLabelsModal from '#/modals/ManageLabelsModal'
|
||||||
@ -97,25 +98,12 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
backendModule.assetIsProject(asset) &&
|
backendModule.assetIsProject(asset) &&
|
||||||
asset.projectState.opened_by != null &&
|
asset.projectState.opened_by != null &&
|
||||||
asset.projectState.opened_by !== user?.email
|
asset.projectState.opened_by !== user?.email
|
||||||
const setAsset = React.useCallback(
|
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||||
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
|
|
||||||
if (typeof valueOrUpdater === 'function') {
|
|
||||||
setItem(oldItem =>
|
|
||||||
oldItem.with({
|
|
||||||
item: valueOrUpdater(oldItem.item),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setItem(oldItem => oldItem.with({ item: valueOrUpdater }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[/* should never change */ setItem]
|
|
||||||
)
|
|
||||||
|
|
||||||
return category === Category.trash ? (
|
return category === Category.trash ? (
|
||||||
!ownsThisAsset ? null : (
|
!ownsThisAsset ? null : (
|
||||||
<ContextMenus hidden={hidden} key={asset.id} event={event}>
|
<ContextMenus hidden={hidden} key={asset.id} event={event}>
|
||||||
<ContextMenu hidden={hidden}>
|
<ContextMenu aria-label={getText('assetContextMenuLabel')} hidden={hidden}>
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action="undelete"
|
action="undelete"
|
||||||
@ -146,7 +134,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<ContextMenus hidden={hidden} key={asset.id} event={event}>
|
<ContextMenus hidden={hidden} key={asset.id} event={event}>
|
||||||
<ContextMenu hidden={hidden}>
|
<ContextMenu aria-label={getText('assetContextMenuLabel')} hidden={hidden}>
|
||||||
{asset.type === backendModule.AssetType.project &&
|
{asset.type === backendModule.AssetType.project &&
|
||||||
canExecute &&
|
canExecute &&
|
||||||
!isRunningProject &&
|
!isRunningProject &&
|
||||||
@ -235,7 +223,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
{canExecute && !isRunningProject && !isOtherUserUsingProject && (
|
{canExecute && !isRunningProject && !isOtherUserUsingProject && (
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
disabled={
|
isDisabled={
|
||||||
asset.type !== backendModule.AssetType.project &&
|
asset.type !== backendModule.AssetType.project &&
|
||||||
asset.type !== backendModule.AssetType.directory
|
asset.type !== backendModule.AssetType.directory
|
||||||
}
|
}
|
||||||
@ -280,7 +268,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
{isCloud && (
|
{isCloud && (
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
disabled
|
isDisabled
|
||||||
action="snapshot"
|
action="snapshot"
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
// No backend support yet.
|
// No backend support yet.
|
||||||
@ -311,7 +299,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCloud && <ContextMenuSeparator hidden={hidden} />}
|
{isCloud && <Separator hidden={hidden} />}
|
||||||
{isCloud && managesThisAsset && self != null && (
|
{isCloud && managesThisAsset && self != null && (
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
@ -351,10 +339,10 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCloud && managesThisAsset && self != null && <ContextMenuSeparator hidden={hidden} />}
|
{isCloud && managesThisAsset && self != null && <Separator hidden={hidden} />}
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
disabled={!isCloud}
|
isDisabled={!isCloud}
|
||||||
action="duplicate"
|
action="duplicate"
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
@ -372,7 +360,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
)}
|
)}
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
disabled={
|
isDisabled={
|
||||||
isCloud &&
|
isCloud &&
|
||||||
asset.type !== backendModule.AssetType.file &&
|
asset.type !== backendModule.AssetType.file &&
|
||||||
asset.type !== backendModule.AssetType.dataLink &&
|
asset.type !== backendModule.AssetType.dataLink &&
|
||||||
@ -404,7 +392,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
{category === Category.home && (
|
{category === Category.home && (
|
||||||
<GlobalContextMenu
|
<GlobalContextMenu
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
hasCopyData={hasPasteData}
|
hasPasteData={hasPasteData}
|
||||||
directoryKey={
|
directoryKey={
|
||||||
// This is SAFE, as both branches are guaranteed to be `DirectoryId`s
|
// This is SAFE, as both branches are guaranteed to be `DirectoryId`s
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
@ -10,6 +10,8 @@ import AssetProperties from '#/layouts/AssetProperties'
|
|||||||
import AssetVersions from '#/layouts/AssetVersions/AssetVersions'
|
import AssetVersions from '#/layouts/AssetVersions/AssetVersions'
|
||||||
import type Category from '#/layouts/CategorySwitcher/Category'
|
import type Category from '#/layouts/CategorySwitcher/Category'
|
||||||
|
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as backend from '#/services/Backend'
|
import * as backend from '#/services/Backend'
|
||||||
|
|
||||||
import * as array from '#/utilities/array'
|
import * as array from '#/utilities/array'
|
||||||
@ -98,7 +100,7 @@ export default function AssetPanel(props: AssetPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="asset-panel"
|
data-testid="asset-panel"
|
||||||
className="absolute flex h-full w-asset-panel flex-col gap-asset-panel border-l-2 border-black/[0.12] p-top-bar-margin pl-asset-panel-l"
|
className="pointer-events-none absolute flex h-full w-asset-panel flex-col gap-asset-panel border-l-2 border-black/[0.12] p-top-bar-margin pl-asset-panel-l"
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
@ -107,11 +109,11 @@ export default function AssetPanel(props: AssetPanelProps) {
|
|||||||
{item != null &&
|
{item != null &&
|
||||||
item.item.type !== backend.AssetType.secret &&
|
item.item.type !== backend.AssetType.secret &&
|
||||||
item.item.type !== backend.AssetType.directory && (
|
item.item.type !== backend.AssetType.directory && (
|
||||||
<button
|
<UnstyledButton
|
||||||
className={`button select-none bg-frame px-button-x leading-cozy transition-colors hover:bg-selected-frame ${
|
className={`button pointer-events-auto select-none bg-frame px-button-x leading-cozy transition-colors hover:bg-selected-frame ${
|
||||||
tab !== AssetPanelTab.versions ? '' : 'bg-selected-frame active'
|
tab !== AssetPanelTab.versions ? '' : 'bg-selected-frame active'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
setTab(oldTab =>
|
setTab(oldTab =>
|
||||||
oldTab === AssetPanelTab.versions
|
oldTab === AssetPanelTab.versions
|
||||||
? AssetPanelTab.properties
|
? AssetPanelTab.properties
|
||||||
@ -120,7 +122,7 @@ export default function AssetPanel(props: AssetPanelProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getText('versions')}
|
{getText('versions')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)}
|
)}
|
||||||
{/* Spacing. The top right asset and user bars overlap this area. */}
|
{/* Spacing. The top right asset and user bars overlap this area. */}
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
|
@ -15,11 +15,13 @@ import type * as assetEvent from '#/events/assetEvent'
|
|||||||
|
|
||||||
import type Category from '#/layouts/CategorySwitcher/Category'
|
import type Category from '#/layouts/CategorySwitcher/Category'
|
||||||
|
|
||||||
import Button from '#/components/Button'
|
import * as aria from '#/components/aria'
|
||||||
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
||||||
import DataLinkInput from '#/components/dashboard/DataLinkInput'
|
import DataLinkInput from '#/components/dashboard/DataLinkInput'
|
||||||
import Label from '#/components/dashboard/Label'
|
import Label from '#/components/dashboard/Label'
|
||||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||||
|
import Button from '#/components/styled/Button'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
@ -114,9 +116,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastAndLog('editDescriptionError')
|
toastAndLog('editDescriptionError')
|
||||||
setItem(oldItem =>
|
setItem(oldItem =>
|
||||||
oldItem.with({
|
oldItem.with({ item: object.merge(oldItem.item, { description: oldDescription }) })
|
||||||
item: object.merge(oldItem.item, { description: oldDescription }),
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,25 +124,28 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-start gap-side-panel">
|
<div className="pointer-events-auto flex flex-col items-start gap-side-panel">
|
||||||
<span className="flex h-side-panel-heading items-center gap-side-panel-section py-side-panel-heading-y text-lg leading-snug">
|
<aria.Heading
|
||||||
|
level={2}
|
||||||
|
className="flex h-side-panel-heading items-center gap-side-panel-section py-side-panel-heading-y text-lg leading-snug"
|
||||||
|
>
|
||||||
{getText('description')}
|
{getText('description')}
|
||||||
{ownsThisAsset && !isEditingDescription && (
|
{ownsThisAsset && !isEditingDescription && (
|
||||||
<Button
|
<Button
|
||||||
image={PenIcon}
|
image={PenIcon}
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
setIsEditingDescription(true)
|
setIsEditingDescription(true)
|
||||||
setQueuedDescripion(item.item.description)
|
setQueuedDescripion(item.item.description)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</aria.Heading>
|
||||||
<div
|
<div
|
||||||
data-testid="asset-panel-description"
|
data-testid="asset-panel-description"
|
||||||
className="self-stretch py-side-panel-description-y"
|
className="self-stretch py-side-panel-description-y"
|
||||||
>
|
>
|
||||||
{!isEditingDescription ? (
|
{!isEditingDescription ? (
|
||||||
<span className="text">{item.item.description}</span>
|
<aria.Text className="text">{item.item.description}</aria.Text>
|
||||||
) : (
|
) : (
|
||||||
<form className="flex flex-col gap-modal" onSubmit={doEditDescription}>
|
<form className="flex flex-col gap-modal" onSubmit={doEditDescription}>
|
||||||
<textarea
|
<textarea
|
||||||
@ -175,23 +178,29 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
|||||||
className="-m-multiline-input-p w-full resize-none rounded-input bg-frame p-multiline-input"
|
className="-m-multiline-input-p w-full resize-none rounded-input bg-frame p-multiline-input"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-buttons">
|
<div className="flex gap-buttons">
|
||||||
<button type="submit" className="button self-start bg-selected-frame">
|
<UnstyledButton
|
||||||
|
className="button self-start bg-selected-frame"
|
||||||
|
onPress={doEditDescription}
|
||||||
|
>
|
||||||
{getText('update')}
|
{getText('update')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start gap-side-panel-section">
|
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section">
|
||||||
<h2 className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug">
|
<aria.Heading
|
||||||
|
level={2}
|
||||||
|
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
|
||||||
|
>
|
||||||
{getText('settings')}
|
{getText('settings')}
|
||||||
</h2>
|
</aria.Heading>
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr data-testid="asset-panel-permissions" className="h-row">
|
<tr data-testid="asset-panel-permissions" className="h-row">
|
||||||
<td className="text my-auto min-w-side-panel-label p">
|
<td className="text my-auto min-w-side-panel-label p">
|
||||||
<span className="text inline-block">{getText('sharedWith')}</span>
|
<aria.Label className="text inline-block">{getText('sharedWith')}</aria.Label>
|
||||||
</td>
|
</td>
|
||||||
<td className="w-full p">
|
<td className="w-full p">
|
||||||
<SharedWithColumn
|
<SharedWithColumn
|
||||||
@ -203,13 +212,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr data-testid="asset-panel-labels" className="h-row">
|
<tr data-testid="asset-panel-labels" className="h-row">
|
||||||
<td className="text my-auto min-w-side-panel-label p">
|
<td className="text my-auto min-w-side-panel-label p">
|
||||||
<span className="text inline-block">{getText('labels')}</span>
|
<aria.Label className="text inline-block">{getText('labels')}</aria.Label>
|
||||||
</td>
|
</td>
|
||||||
<td className="w-full p">
|
<td className="w-full p">
|
||||||
{item.item.labels?.map(value => {
|
{item.item.labels?.map(value => {
|
||||||
const label = labels.find(otherLabel => otherLabel.value === value)
|
const label = labels.find(otherLabel => otherLabel.value === value)
|
||||||
return label == null ? null : (
|
return label == null ? null : (
|
||||||
<Label key={value} active disabled color={label.color} onClick={() => {}}>
|
<Label key={value} active isDisabled color={label.color} onPress={() => {}}>
|
||||||
{value}
|
{value}
|
||||||
</Label>
|
</Label>
|
||||||
)
|
)
|
||||||
@ -220,10 +229,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{isDataLink && (
|
{isDataLink && (
|
||||||
<div className="flex flex-col items-start gap-side-panel-section">
|
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section">
|
||||||
<h2 className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug">
|
<aria.Heading
|
||||||
|
level={2}
|
||||||
|
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
|
||||||
|
>
|
||||||
{getText('dataLink')}
|
{getText('dataLink')}
|
||||||
</h2>
|
</aria.Heading>
|
||||||
{!isDataLinkFetched ? (
|
{!isDataLinkFetched ? (
|
||||||
<div className="grid place-items-center self-stretch">
|
<div className="grid place-items-center self-stretch">
|
||||||
<StatelessSpinner size={48} state={statelessSpinner.SpinnerState.loadingMedium} />
|
<StatelessSpinner size={48} state={statelessSpinner.SpinnerState.loadingMedium} />
|
||||||
@ -238,14 +250,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
|||||||
/>
|
/>
|
||||||
{canEditThisAsset && (
|
{canEditThisAsset && (
|
||||||
<div className="flex gap-buttons">
|
<div className="flex gap-buttons">
|
||||||
<button
|
<UnstyledButton
|
||||||
type="button"
|
isDisabled={isDataLinkDisabled}
|
||||||
disabled={isDataLinkDisabled}
|
|
||||||
{...(isDataLinkDisabled
|
{...(isDataLinkDisabled
|
||||||
? { title: 'Edit the Data Link before updating it.' }
|
? { title: 'Edit the Data Link before updating it.' }
|
||||||
: {})}
|
: {})}
|
||||||
className="button bg-invite text-white enabled:active"
|
className="button bg-invite text-white enabled:active"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (item.item.type === backendModule.AssetType.dataLink) {
|
if (item.item.type === backendModule.AssetType.dataLink) {
|
||||||
const oldDataLinkValue = dataLinkValue
|
const oldDataLinkValue = dataLinkValue
|
||||||
@ -267,17 +278,16 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getText('update')}
|
{getText('update')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
<button
|
<UnstyledButton
|
||||||
type="button"
|
isDisabled={isDataLinkDisabled}
|
||||||
disabled={isDataLinkDisabled}
|
|
||||||
className="button bg-selected-frame enabled:active"
|
className="button bg-selected-frame enabled:active"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
setEditedDataLinkValue(dataLinkValue)
|
setEditedDataLinkValue(dataLinkValue)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getText('cancel')}
|
{getText('cancel')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@ -7,7 +7,10 @@ import * as detect from 'enso-common/src/detect'
|
|||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import Label from '#/components/dashboard/Label'
|
import Label from '#/components/dashboard/Label'
|
||||||
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
|
|
||||||
import type * as backend from '#/services/Backend'
|
import type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
@ -40,6 +43,65 @@ export interface Suggestion {
|
|||||||
readonly deleteFromQuery: (query: AssetQuery) => AssetQuery
|
readonly deleteFromQuery: (query: AssetQuery) => AssetQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============
|
||||||
|
// === Tags ===
|
||||||
|
// ============
|
||||||
|
|
||||||
|
/** Props for a {@link Tags}. */
|
||||||
|
interface InternalTagsProps {
|
||||||
|
readonly isCloud: boolean
|
||||||
|
readonly querySource: React.MutableRefObject<QuerySource>
|
||||||
|
readonly query: AssetQuery
|
||||||
|
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tags (`name:`, `modified:`, etc.) */
|
||||||
|
function Tags(props: InternalTagsProps) {
|
||||||
|
const { isCloud, querySource, query, setQuery } = props
|
||||||
|
const [isShiftPressed, setIsShiftPressed] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
setIsShiftPressed(event.shiftKey)
|
||||||
|
}
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
setIsShiftPressed(event.shiftKey)
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKeyDown)
|
||||||
|
document.addEventListener('keyup', onKeyUp)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.removeEventListener('keyup', onKeyUp)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="asset-search-tag-names"
|
||||||
|
className="pointer-events-auto flex flex-wrap gap-buttons whitespace-nowrap px-search-suggestions"
|
||||||
|
>
|
||||||
|
{(isCloud ? AssetQuery.tagNames : AssetQuery.localTagNames).flatMap(entry => {
|
||||||
|
const [key, tag] = entry
|
||||||
|
return tag == null || isShiftPressed !== tag.startsWith('-')
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
<FocusRing key={key}>
|
||||||
|
<aria.Button
|
||||||
|
className="h-text rounded-full bg-frame px-button-x transition-all hover:bg-selected-frame"
|
||||||
|
onPress={() => {
|
||||||
|
querySource.current = QuerySource.internal
|
||||||
|
setQuery(query.add({ [key]: [[]] }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag + ':'}
|
||||||
|
</aria.Button>
|
||||||
|
</FocusRing>,
|
||||||
|
]
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ======================
|
// ======================
|
||||||
// === AssetSearchBar ===
|
// === AssetSearchBar ===
|
||||||
// ======================
|
// ======================
|
||||||
@ -69,16 +131,12 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
const [areSuggestionsVisible, setAreSuggestionsVisible] = React.useState(false)
|
const [areSuggestionsVisible, setAreSuggestionsVisible] = React.useState(false)
|
||||||
const areSuggestionsVisibleRef = React.useRef(areSuggestionsVisible)
|
const areSuggestionsVisibleRef = React.useRef(areSuggestionsVisible)
|
||||||
const querySource = React.useRef(QuerySource.external)
|
const querySource = React.useRef(QuerySource.external)
|
||||||
const [isShiftPressed, setIsShiftPressed] = React.useState(false)
|
const rootRef = React.useRef<HTMLLabelElement | null>(null)
|
||||||
const rootRef = React.useRef<HTMLLabelElement>(null)
|
const searchRef = React.useRef<HTMLInputElement | null>(null)
|
||||||
const searchRef = React.useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
areSuggestionsVisibleRef.current = areSuggestionsVisible
|
areSuggestionsVisibleRef.current = areSuggestionsVisible
|
||||||
}, [areSuggestionsVisible])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (querySource.current !== QuerySource.tabbing && !isShiftPressed) {
|
if (querySource.current !== QuerySource.tabbing) {
|
||||||
baseQuery.current = query
|
baseQuery.current = query
|
||||||
}
|
}
|
||||||
// This effect MUST only run when `query` changes.
|
// This effect MUST only run when `query` changes.
|
||||||
@ -100,11 +158,11 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (querySource.current !== QuerySource.tabbing && !isShiftPressed) {
|
if (querySource.current !== QuerySource.tabbing) {
|
||||||
setSuggestions(rawSuggestions)
|
setSuggestions(rawSuggestions)
|
||||||
suggestionsRef.current = rawSuggestions
|
suggestionsRef.current = rawSuggestions
|
||||||
}
|
}
|
||||||
}, [isShiftPressed, rawSuggestions])
|
}, [rawSuggestions])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -129,14 +187,13 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
}, [selectedIndex])
|
}, [selectedIndex])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onSearchKeyDown = (event: KeyboardEvent) => {
|
||||||
setIsShiftPressed(event.shiftKey)
|
|
||||||
if (areSuggestionsVisibleRef.current) {
|
if (areSuggestionsVisibleRef.current) {
|
||||||
if (event.key === 'Tab' || event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopImmediatePropagation()
|
event.stopImmediatePropagation()
|
||||||
querySource.current = QuerySource.tabbing
|
querySource.current = QuerySource.tabbing
|
||||||
const reverse = (event.key === 'Tab' && event.shiftKey) || event.key === 'ArrowUp'
|
const reverse = event.key === 'ArrowUp'
|
||||||
setSelectedIndex(oldIndex => {
|
setSelectedIndex(oldIndex => {
|
||||||
const length = Math.max(1, suggestionsRef.current.length)
|
const length = Math.max(1, suggestionsRef.current.length)
|
||||||
if (reverse) {
|
if (reverse) {
|
||||||
@ -160,7 +217,6 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
setAreSuggestionsVisible(false)
|
setAreSuggestionsVisible(false)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
if (querySource.current === QuerySource.tabbing) {
|
if (querySource.current === QuerySource.tabbing) {
|
||||||
querySource.current = QuerySource.external
|
querySource.current = QuerySource.external
|
||||||
@ -170,6 +226,9 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
searchRef.current?.blur()
|
searchRef.current?.blur()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
// Allow `alt` key to be pressed in case it is being used to enter special characters.
|
// Allow `alt` key to be pressed in case it is being used to enter special characters.
|
||||||
if (
|
if (
|
||||||
!eventModule.isElementTextInput(event.target) &&
|
!eventModule.isElementTextInput(event.target) &&
|
||||||
@ -189,14 +248,12 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
searchRef.current?.focus()
|
searchRef.current?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onKeyUp = (event: KeyboardEvent) => {
|
const root = rootRef.current
|
||||||
setIsShiftPressed(event.shiftKey)
|
root?.addEventListener('keydown', onSearchKeyDown)
|
||||||
}
|
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
document.addEventListener('keyup', onKeyUp)
|
|
||||||
return () => {
|
return () => {
|
||||||
|
root?.removeEventListener('keydown', onSearchKeyDown)
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
document.removeEventListener('keyup', onKeyUp)
|
|
||||||
}
|
}
|
||||||
}, [setQuery, /* should never change */ modalRef])
|
}, [setQuery, /* should never change */ modalRef])
|
||||||
|
|
||||||
@ -212,80 +269,39 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
}, [query, /* should never change */ setQuery])
|
}, [query, /* should never change */ setQuery])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<FocusArea direction="horizontal">
|
||||||
ref={rootRef}
|
{innerProps => (
|
||||||
|
<aria.Label
|
||||||
data-testid="asset-search-bar"
|
data-testid="asset-search-bar"
|
||||||
tabIndex={-1}
|
{...aria.mergeProps<aria.LabelProps>()(innerProps, {
|
||||||
onFocus={() => {
|
className:
|
||||||
|
'search-bar group relative flex h-row max-w-asset-search-bar grow items-center gap-asset-search-bar rounded-full px-input-x text-primary xl:max-w-asset-search-bar-wide',
|
||||||
|
ref: rootRef,
|
||||||
|
onFocus: () => {
|
||||||
setAreSuggestionsVisible(true)
|
setAreSuggestionsVisible(true)
|
||||||
}}
|
},
|
||||||
onBlur={event => {
|
onBlur: event => {
|
||||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
if (querySource.current === QuerySource.tabbing) {
|
if (querySource.current === QuerySource.tabbing) {
|
||||||
querySource.current = QuerySource.external
|
querySource.current = QuerySource.external
|
||||||
}
|
}
|
||||||
setAreSuggestionsVisible(false)
|
setAreSuggestionsVisible(false)
|
||||||
}
|
}
|
||||||
}}
|
},
|
||||||
className="search-bar group relative flex h-row max-w-asset-search-bar grow items-center gap-asset-search-bar rounded-full px-input-x text-primary xl:max-w-asset-search-bar-wide"
|
})}
|
||||||
>
|
>
|
||||||
<img src={FindIcon} className="relative z-1 placeholder" />
|
<img src={FindIcon} className="relative z-1 placeholder" />
|
||||||
<input
|
|
||||||
ref={searchRef}
|
|
||||||
type="search"
|
|
||||||
size={1}
|
|
||||||
placeholder={
|
|
||||||
isCloud
|
|
||||||
? getText('remoteBackendSearchPlaceholder')
|
|
||||||
: getText('localBackendSearchPlaceholder')
|
|
||||||
}
|
|
||||||
className="peer text relative z-1 grow bg-transparent placeholder:text-center"
|
|
||||||
onChange={event => {
|
|
||||||
if (querySource.current !== QuerySource.internal) {
|
|
||||||
querySource.current = QuerySource.typing
|
|
||||||
setQuery(AssetQuery.fromString(event.target.value))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={event => {
|
|
||||||
if (
|
|
||||||
event.key === 'Enter' &&
|
|
||||||
!event.shiftKey &&
|
|
||||||
!event.altKey &&
|
|
||||||
!event.metaKey &&
|
|
||||||
!event.ctrlKey
|
|
||||||
) {
|
|
||||||
// Clone the query to refresh results.
|
|
||||||
setQuery(query.clone())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="pointer-events-none absolute left top flex w-full flex-col overflow-hidden rounded-default before:absolute before:inset before:bg-frame before:backdrop-blur-default">
|
<div className="pointer-events-none absolute left top flex w-full flex-col overflow-hidden rounded-default before:absolute before:inset before:bg-frame before:backdrop-blur-default">
|
||||||
<div className="padding relative h-row" />
|
<div className="padding relative h-row" />
|
||||||
{areSuggestionsVisible && (
|
{areSuggestionsVisible && (
|
||||||
<div className="relative flex flex-col gap-search-suggestions">
|
<div className="relative flex flex-col gap-search-suggestions">
|
||||||
{/* Tags (`name:`, `modified:`, etc.) */}
|
{/* Tags (`name:`, `modified:`, etc.) */}
|
||||||
<div
|
<Tags
|
||||||
data-testid="asset-search-tag-names"
|
isCloud={isCloud}
|
||||||
className="pointer-events-auto flex flex-wrap gap-buttons whitespace-nowrap px-search-suggestions"
|
querySource={querySource}
|
||||||
>
|
query={query}
|
||||||
{(isCloud ? AssetQuery.tagNames : AssetQuery.localTagNames).flatMap(entry => {
|
setQuery={setQuery}
|
||||||
const [key, tag] = entry
|
/>
|
||||||
return tag == null || isShiftPressed !== tag.startsWith('-')
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
className="h-text rounded-full bg-frame px-button-x transition-all hover:bg-selected-frame"
|
|
||||||
onClick={() => {
|
|
||||||
querySource.current = QuerySource.internal
|
|
||||||
setQuery(query.add({ [key]: [[]] }))
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{`${tag}:`}
|
|
||||||
</button>,
|
|
||||||
]
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* Asset labels */}
|
{/* Asset labels */}
|
||||||
{isCloud && labels.length !== 0 && (
|
{isCloud && labels.length !== 0 && (
|
||||||
<div
|
<div
|
||||||
@ -307,7 +323,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
query.labels.some(term => array.shallowEqual(term, [label.value]))
|
query.labels.some(term => array.shallowEqual(term, [label.value]))
|
||||||
}
|
}
|
||||||
negated={negated}
|
negated={negated}
|
||||||
onClick={event => {
|
onPress={event => {
|
||||||
querySource.current = QuerySource.internal
|
querySource.current = QuerySource.internal
|
||||||
setQuery(oldQuery => {
|
setQuery(oldQuery => {
|
||||||
const newQuery = oldQuery.withToggled(
|
const newQuery = oldQuery.withToggled(
|
||||||
@ -332,7 +348,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
{suggestions.map((suggestion, index) => (
|
{suggestions.map((suggestion, index) => (
|
||||||
// This should not be a `<button>`, since `render()` may output a
|
// This should not be a `<button>`, since `render()` may output a
|
||||||
// tree containing a button.
|
// tree containing a button.
|
||||||
<div
|
<aria.Button
|
||||||
data-testid="asset-search-suggestion"
|
data-testid="asset-search-suggestion"
|
||||||
key={index}
|
key={index}
|
||||||
ref={el => {
|
ref={el => {
|
||||||
@ -340,7 +356,6 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
el?.focus()
|
el?.focus()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={-1}
|
|
||||||
className={`pointer-events-auto mx-search-suggestion cursor-pointer rounded-default px-search-suggestions py-search-suggestion-y text-left transition-colors last:mb-search-suggestion hover:bg-selected-frame ${
|
className={`pointer-events-auto mx-search-suggestion cursor-pointer rounded-default px-search-suggestions py-search-suggestion-y text-left transition-colors last:mb-search-suggestion hover:bg-selected-frame ${
|
||||||
index === selectedIndex
|
index === selectedIndex
|
||||||
? 'bg-selected-frame'
|
? 'bg-selected-frame'
|
||||||
@ -348,7 +363,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
? 'bg-frame'
|
? 'bg-frame'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
onClick={event => {
|
onPress={event => {
|
||||||
querySource.current = QuerySource.internal
|
querySource.current = QuerySource.internal
|
||||||
setQuery(
|
setQuery(
|
||||||
selectedIndices.has(index)
|
selectedIndices.has(index)
|
||||||
@ -369,12 +384,54 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{suggestion.render()}
|
{suggestion.render()}
|
||||||
</div>
|
</aria.Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
<FocusRing placement="before">
|
||||||
|
<aria.SearchField
|
||||||
|
aria-label={getText('assetSearchFieldLabel')}
|
||||||
|
className="relative grow before:text before:absolute before:inset-x-button-focus-ring-inset before:my-auto before:rounded-full before:transition-all"
|
||||||
|
value={query.query}
|
||||||
|
onKeyDown={event => {
|
||||||
|
event.continuePropagation()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aria.Input
|
||||||
|
type="search"
|
||||||
|
ref={searchRef}
|
||||||
|
size={1}
|
||||||
|
placeholder={
|
||||||
|
isCloud
|
||||||
|
? getText('remoteBackendSearchPlaceholder')
|
||||||
|
: getText('localBackendSearchPlaceholder')
|
||||||
|
}
|
||||||
|
className="focus-child peer text relative z-1 w-full bg-transparent placeholder:text-center"
|
||||||
|
onChange={event => {
|
||||||
|
if (querySource.current !== QuerySource.internal) {
|
||||||
|
querySource.current = QuerySource.typing
|
||||||
|
setQuery(AssetQuery.fromString(event.target.value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (
|
||||||
|
event.key === 'Enter' &&
|
||||||
|
!event.shiftKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.ctrlKey
|
||||||
|
) {
|
||||||
|
// Clone the query to refresh results.
|
||||||
|
setQuery(query.clone())
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</aria.SearchField>
|
||||||
|
</FocusRing>
|
||||||
|
</aria.Label>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
|||||||
{getText('versionX', number)} {version.isLatest && getText('latestIndicator')}
|
{getText('versionX', number)} {version.isLatest && getText('latestIndicator')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<time className="text-xs text-not-selected">
|
<time className="text-not-selected text-xs">
|
||||||
{getText('onDateX', dateTime.formatDateTime(new Date(version.lastModified)))}
|
{getText('onDateX', dateTime.formatDateTime(new Date(version.lastModified)))}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
|
@ -48,7 +48,7 @@ export default function AssetVersions(props: AssetVersionsProps) {
|
|||||||
const latestVersion = versions?.find(version => version.isLatest)
|
const latestVersion = versions?.find(version => version.isLatest)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 shrink-0 flex-col items-center overflow-y-auto overflow-x-hidden">
|
<div className="pointer-events-auto flex flex-1 shrink-0 flex-col items-center overflow-y-auto overflow-x-hidden">
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!isCloud) {
|
if (!isCloud) {
|
||||||
return <div>{getText('localAssetsDoNotHaveVersions')}</div>
|
return <div>{getText('localAssetsDoNotHaveVersions')}</div>
|
||||||
|
@ -5,6 +5,7 @@ import * as toast from 'react-toastify'
|
|||||||
|
|
||||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||||
import * as eventHooks from '#/hooks/eventHooks'
|
import * as eventHooks from '#/hooks/eventHooks'
|
||||||
|
import * as scrollHooks from '#/hooks/scrollHooks'
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
@ -12,6 +13,7 @@ import * as backendProvider from '#/providers/BackendProvider'
|
|||||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
import type * as assetEvent from '#/events/assetEvent'
|
import type * as assetEvent from '#/events/assetEvent'
|
||||||
@ -24,7 +26,7 @@ import type * as assetSearchBar from '#/layouts/AssetSearchBar'
|
|||||||
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
|
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
|
||||||
import Category from '#/layouts/CategorySwitcher/Category'
|
import Category from '#/layouts/CategorySwitcher/Category'
|
||||||
|
|
||||||
import Button from '#/components/Button'
|
import * as aria from '#/components/aria'
|
||||||
import type * as assetRow from '#/components/dashboard/AssetRow'
|
import type * as assetRow from '#/components/dashboard/AssetRow'
|
||||||
import AssetRow from '#/components/dashboard/AssetRow'
|
import AssetRow from '#/components/dashboard/AssetRow'
|
||||||
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
|
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
|
||||||
@ -34,6 +36,8 @@ import * as columnHeading from '#/components/dashboard/columnHeading'
|
|||||||
import Label from '#/components/dashboard/Label'
|
import Label from '#/components/dashboard/Label'
|
||||||
import SelectionBrush from '#/components/SelectionBrush'
|
import SelectionBrush from '#/components/SelectionBrush'
|
||||||
import Spinner, * as spinner from '#/components/Spinner'
|
import Spinner, * as spinner from '#/components/Spinner'
|
||||||
|
import Button from '#/components/styled/Button'
|
||||||
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
|
|
||||||
import DragModal from '#/modals/DragModal'
|
import DragModal from '#/modals/DragModal'
|
||||||
import DuplicateAssetsModal from '#/modals/DuplicateAssetsModal'
|
import DuplicateAssetsModal from '#/modals/DuplicateAssetsModal'
|
||||||
@ -377,6 +381,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||||
|
const navigator2D = navigator2DProvider.useNavigator2D()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const [initialized, setInitialized] = React.useState(false)
|
const [initialized, setInitialized] = React.useState(false)
|
||||||
const [isLoading, setIsLoading] = React.useState(true)
|
const [isLoading, setIsLoading] = React.useState(true)
|
||||||
@ -408,7 +413,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
const isCloud = backend.type === backendModule.BackendType.remote
|
const isCloud = backend.type === backendModule.BackendType.remote
|
||||||
/** Events sent when the asset list was still loading. */
|
/** Events sent when the asset list was still loading. */
|
||||||
const queuedAssetListEventsRef = React.useRef<assetListEvent.AssetListEvent[]>([])
|
const queuedAssetListEventsRef = React.useRef<assetListEvent.AssetListEvent[]>([])
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
const headerRowRef = React.useRef<HTMLTableRowElement>(null)
|
const headerRowRef = React.useRef<HTMLTableRowElement>(null)
|
||||||
const assetTreeRef = React.useRef<AssetTreeNode>(assetTree)
|
const assetTreeRef = React.useRef<AssetTreeNode>(assetTree)
|
||||||
const pasteDataRef = React.useRef<pasteDataModule.PasteData<
|
const pasteDataRef = React.useRef<pasteDataModule.PasteData<
|
||||||
@ -786,7 +791,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
allLabels.values(),
|
allLabels.values(),
|
||||||
(label): assetSearchBar.Suggestion => ({
|
(label): assetSearchBar.Suggestion => ({
|
||||||
render: () => (
|
render: () => (
|
||||||
<Label active color={label.color} onClick={() => {}}>
|
<Label active color={label.color} onPress={() => {}}>
|
||||||
{label.value}
|
{label.value}
|
||||||
</Label>
|
</Label>
|
||||||
),
|
),
|
||||||
@ -1030,44 +1035,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
}
|
}
|
||||||
}, [/* should never change */ localStorage])
|
}, [/* should never change */ localStorage])
|
||||||
|
|
||||||
// Clip the header bar so that the background behind the extra colums selector is visible.
|
|
||||||
React.useEffect(() => {
|
|
||||||
const headerRow = headerRowRef.current
|
|
||||||
const scrollContainer = scrollContainerRef.current
|
|
||||||
if (
|
|
||||||
backend.type === backendModule.BackendType.remote &&
|
|
||||||
headerRow != null &&
|
|
||||||
scrollContainer != null
|
|
||||||
) {
|
|
||||||
let isClipPathUpdateQueued = false
|
|
||||||
const updateClipPath = () => {
|
|
||||||
isClipPathUpdateQueued = false
|
|
||||||
const hiddenColumnsCount = columnUtils.CLOUD_COLUMNS.length - enabledColumns.size
|
|
||||||
const shrinkBy =
|
|
||||||
COLUMNS_SELECTOR_BASE_WIDTH_PX + COLUMNS_SELECTOR_ICON_WIDTH_PX * hiddenColumnsCount
|
|
||||||
const rightOffset = scrollContainer.clientWidth + scrollContainer.scrollLeft - shrinkBy
|
|
||||||
|
|
||||||
headerRow.style.clipPath = `polygon(0 0, ${rightOffset}px 0, ${rightOffset}px 100%, 0 100%)`
|
|
||||||
}
|
|
||||||
const onScroll = () => {
|
|
||||||
if (!isClipPathUpdateQueued) {
|
|
||||||
isClipPathUpdateQueued = true
|
|
||||||
requestAnimationFrame(updateClipPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateClipPath()
|
|
||||||
const observer = new ResizeObserver(onScroll)
|
|
||||||
observer.observe(scrollContainer)
|
|
||||||
scrollContainer.addEventListener('scroll', onScroll)
|
|
||||||
return () => {
|
|
||||||
observer.unobserve(scrollContainer)
|
|
||||||
scrollContainer.removeEventListener('scroll', onScroll)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}, [enabledColumns.size, backend.type])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
localStorage.set('enabledColumns', [...enabledColumns])
|
localStorage.set('enabledColumns', [...enabledColumns])
|
||||||
@ -1197,7 +1164,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial)
|
const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial)
|
||||||
const [keyboardSelectedIndex, setKeyboardSelectedIndexRaw] = React.useState<number | null>(null)
|
const [keyboardSelectedIndex, setKeyboardSelectedIndex] = React.useState<number | null>(null)
|
||||||
const mostRecentlySelectedIndexRef = React.useRef<number | null>(null)
|
const mostRecentlySelectedIndexRef = React.useRef<number | null>(null)
|
||||||
const selectionStartIndexRef = React.useRef<number | null>(null)
|
const selectionStartIndexRef = React.useRef<number | null>(null)
|
||||||
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
||||||
@ -1205,15 +1172,27 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
const setMostRecentlySelectedIndex = React.useCallback(
|
const setMostRecentlySelectedIndex = React.useCallback(
|
||||||
(index: number | null, isKeyboard = false) => {
|
(index: number | null, isKeyboard = false) => {
|
||||||
mostRecentlySelectedIndexRef.current = index
|
mostRecentlySelectedIndexRef.current = index
|
||||||
setKeyboardSelectedIndexRaw(isKeyboard ? index : null)
|
setKeyboardSelectedIndex(isKeyboard ? index : null)
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
const body = bodyRef.current
|
||||||
|
if (body == null) {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
return navigator2D.register(body, {
|
||||||
|
focusPrimaryChild: () => {
|
||||||
|
setMostRecentlySelectedIndex(0, true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [navigator2D, setMostRecentlySelectedIndex])
|
||||||
|
|
||||||
// This is not a React component, even though it contains JSX.
|
// This is not a React component, even though it contains JSX.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||||
const prevIndex = mostRecentlySelectedIndexRef.current
|
const prevIndex = mostRecentlySelectedIndexRef.current
|
||||||
const item = prevIndex == null ? null : visibleItems[prevIndex]
|
const item = prevIndex == null ? null : visibleItems[prevIndex]
|
||||||
if (selectedKeysRef.current.size === 1 && item != null) {
|
if (selectedKeysRef.current.size === 1 && item != null) {
|
||||||
@ -1275,15 +1254,34 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'ArrowLeft': {
|
case 'ArrowLeft': {
|
||||||
if (item.item.type === backendModule.AssetType.directory && item.children != null) {
|
if (item.item.type === backendModule.AssetType.directory) {
|
||||||
|
if (item.children != null) {
|
||||||
|
// The folder is expanded; collapse it.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
doToggleDirectoryExpansion(item.item.id, item.key, null, false)
|
doToggleDirectoryExpansion(item.item.id, item.key, null, false)
|
||||||
|
} else if (prevIndex != null) {
|
||||||
|
// Focus parent if there is one.
|
||||||
|
let index = prevIndex - 1
|
||||||
|
let possibleParent = visibleItems[index]
|
||||||
|
while (possibleParent != null && index >= 0) {
|
||||||
|
if (possibleParent.depth < item.depth) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
setSelectedKeys(new Set([possibleParent.key]))
|
||||||
|
setMostRecentlySelectedIndex(index, true)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
index -= 1
|
||||||
|
possibleParent = visibleItems[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'ArrowRight': {
|
case 'ArrowRight': {
|
||||||
if (item.item.type === backendModule.AssetType.directory && item.children == null) {
|
if (item.item.type === backendModule.AssetType.directory && item.children == null) {
|
||||||
|
// The folder is collapsed; expand it.
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
doToggleDirectoryExpansion(item.item.id, item.key, null, true)
|
doToggleDirectoryExpansion(item.item.id, item.key, null, true)
|
||||||
@ -1308,19 +1306,36 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
}
|
}
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
case 'ArrowDown': {
|
case 'ArrowDown': {
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
if (!event.shiftKey) {
|
if (!event.shiftKey) {
|
||||||
selectionStartIndexRef.current = null
|
selectionStartIndexRef.current = null
|
||||||
}
|
}
|
||||||
const index =
|
let index = prevIndex ?? 0
|
||||||
prevIndex == null
|
let oldIndex = index
|
||||||
? 0
|
if (prevIndex != null) {
|
||||||
: event.key === 'ArrowUp'
|
let itemType = visibleItems[index]?.item.type
|
||||||
? Math.max(0, prevIndex - 1)
|
do {
|
||||||
: Math.min(visibleItems.length - 1, prevIndex + 1)
|
oldIndex = index
|
||||||
|
index =
|
||||||
|
event.key === 'ArrowUp'
|
||||||
|
? Math.max(0, index - 1)
|
||||||
|
: Math.min(visibleItems.length - 1, index + 1)
|
||||||
|
itemType = visibleItems[index]?.item.type
|
||||||
|
} while (
|
||||||
|
index !== oldIndex &&
|
||||||
|
(itemType === backendModule.AssetType.specialEmpty ||
|
||||||
|
itemType === backendModule.AssetType.specialLoading)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
itemType === backendModule.AssetType.specialEmpty ||
|
||||||
|
itemType === backendModule.AssetType.specialLoading
|
||||||
|
) {
|
||||||
|
index = prevIndex
|
||||||
|
}
|
||||||
|
}
|
||||||
setMostRecentlySelectedIndex(index, true)
|
setMostRecentlySelectedIndex(index, true)
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
// On Windows, Ctrl+Shift+Arrow behaves the same as Shift+Arrow.
|
// On Windows, Ctrl+Shift+Arrow behaves the same as Shift+Arrow.
|
||||||
if (selectionStartIndexRef.current == null) {
|
if (selectionStartIndexRef.current == null) {
|
||||||
selectionStartIndexRef.current = prevIndex ?? 0
|
selectionStartIndexRef.current = prevIndex ?? 0
|
||||||
@ -1330,33 +1345,38 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
const selection = visibleItems.slice(startIndex, endIndex)
|
const selection = visibleItems.slice(startIndex, endIndex)
|
||||||
setSelectedKeys(new Set(selection.map(newItem => newItem.key)))
|
setSelectedKeys(new Set(selection.map(newItem => newItem.key)))
|
||||||
} else if (event.ctrlKey) {
|
} else if (event.ctrlKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
selectionStartIndexRef.current = null
|
selectionStartIndexRef.current = null
|
||||||
} else {
|
} else if (index !== prevIndex) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
const newItem = visibleItems[index]
|
const newItem = visibleItems[index]
|
||||||
if (newItem != null) {
|
if (newItem != null) {
|
||||||
setSelectedKeys(new Set([newItem.key]))
|
setSelectedKeys(new Set([newItem.key]))
|
||||||
}
|
}
|
||||||
selectionStartIndexRef.current = null
|
selectionStartIndexRef.current = null
|
||||||
|
} else {
|
||||||
|
// The arrow key will escape this container. In that case, do not stop propagation
|
||||||
|
// and let `navigator2D` navigate to a different container.
|
||||||
|
setSelectedKeys(new Set())
|
||||||
|
selectionStartIndexRef.current = null
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => {
|
React.useEffect(() => {
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
const onClick = () => {
|
||||||
|
setKeyboardSelectedIndex(null)
|
||||||
}
|
}
|
||||||
}, [
|
|
||||||
visibleItems,
|
document.addEventListener('click', onClick, { capture: true })
|
||||||
backend,
|
return () => {
|
||||||
doToggleDirectoryExpansion,
|
document.removeEventListener('click', onClick, { capture: true })
|
||||||
/* should never change */ toastAndLog,
|
}
|
||||||
/* should never change */ setModal,
|
}, [setMostRecentlySelectedIndex])
|
||||||
/* should never change */ setMostRecentlySelectedIndex,
|
|
||||||
/* should never change */ setSelectedKeys,
|
|
||||||
/* should never change */ setIsAssetPanelTemporarilyVisible,
|
|
||||||
/* should never change */ dispatchAssetEvent,
|
|
||||||
])
|
|
||||||
|
|
||||||
const getNewProjectName = React.useCallback(
|
const getNewProjectName = React.useCallback(
|
||||||
(templateName: string | null, parentKey: backendModule.DirectoryId | null) => {
|
(templateName: string | null, parentKey: backendModule.DirectoryId | null) => {
|
||||||
@ -1863,7 +1883,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
(): AssetsTableState => ({
|
(): AssetsTableState => ({
|
||||||
visibilities,
|
visibilities,
|
||||||
selectedKeys: selectedKeysRef,
|
selectedKeys: selectedKeysRef,
|
||||||
scrollContainerRef,
|
scrollContainerRef: rootRef,
|
||||||
category,
|
category,
|
||||||
labels: allLabels,
|
labels: allLabels,
|
||||||
deletedLabelNames,
|
deletedLabelNames,
|
||||||
@ -1917,30 +1937,22 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
|
|
||||||
// This is required to prevent the table body from overlapping the table header, because
|
// This is required to prevent the table body from overlapping the table header, because
|
||||||
// the table header is transparent.
|
// the table header is transparent.
|
||||||
React.useEffect(() => {
|
const onScroll = scrollHooks.useOnScroll(() => {
|
||||||
const body = bodyRef.current
|
if (bodyRef.current != null && rootRef.current != null) {
|
||||||
const scrollContainer = scrollContainerRef.current
|
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
|
||||||
if (body != null && scrollContainer != null) {
|
|
||||||
let isClipPathUpdateQueued = false
|
|
||||||
const updateClipPath = () => {
|
|
||||||
isClipPathUpdateQueued = false
|
|
||||||
body.style.clipPath = `inset(${scrollContainer.scrollTop}px 0 0 0)`
|
|
||||||
}
|
}
|
||||||
const onScroll = () => {
|
if (
|
||||||
if (!isClipPathUpdateQueued) {
|
backend.type === backendModule.BackendType.remote &&
|
||||||
isClipPathUpdateQueued = true
|
rootRef.current != null &&
|
||||||
requestAnimationFrame(updateClipPath)
|
headerRowRef.current != null
|
||||||
|
) {
|
||||||
|
const hiddenColumnsCount = columnUtils.CLOUD_COLUMNS.length - enabledColumns.size
|
||||||
|
const shrinkBy =
|
||||||
|
COLUMNS_SELECTOR_BASE_WIDTH_PX + COLUMNS_SELECTOR_ICON_WIDTH_PX * hiddenColumnsCount
|
||||||
|
const rightOffset = rootRef.current.clientWidth + rootRef.current.scrollLeft - shrinkBy
|
||||||
|
headerRowRef.current.style.clipPath = `polygon(0 0, ${rightOffset}px 0, ${rightOffset}px 100%, 0 100%)`
|
||||||
}
|
}
|
||||||
}
|
}, [enabledColumns.size])
|
||||||
updateClipPath()
|
|
||||||
scrollContainer.addEventListener('scroll', onScroll)
|
|
||||||
return () => {
|
|
||||||
scrollContainer.removeEventListener('scroll', onScroll)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}, [/* should never change */ scrollContainerRef])
|
|
||||||
|
|
||||||
React.useEffect(
|
React.useEffect(
|
||||||
() =>
|
() =>
|
||||||
@ -2032,10 +2044,10 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
const onSelectionDrag = React.useCallback(
|
const onSelectionDrag = React.useCallback(
|
||||||
(rectangle: geometry.DetailedRectangle, event: MouseEvent) => {
|
(rectangle: geometry.DetailedRectangle, event: MouseEvent) => {
|
||||||
if (mostRecentlySelectedIndexRef.current != null) {
|
if (mostRecentlySelectedIndexRef.current != null) {
|
||||||
setKeyboardSelectedIndexRaw(null)
|
setKeyboardSelectedIndex(null)
|
||||||
}
|
}
|
||||||
cancelAnimationFrame(dragSelectionChangeLoopHandle.current)
|
cancelAnimationFrame(dragSelectionChangeLoopHandle.current)
|
||||||
const scrollContainer = scrollContainerRef.current
|
const scrollContainer = rootRef.current
|
||||||
if (scrollContainer != null) {
|
if (scrollContainer != null) {
|
||||||
const rect = scrollContainer.getBoundingClientRect()
|
const rect = scrollContainer.getBoundingClientRect()
|
||||||
if (rectangle.signedHeight <= 0 && scrollContainer.scrollTop > 0) {
|
if (rectangle.signedHeight <= 0 && scrollContainer.scrollTop > 0) {
|
||||||
@ -2175,7 +2187,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
displayItems.map(item => {
|
displayItems.map((item, i) => {
|
||||||
const key = AssetTreeNode.getKey(item)
|
const key = AssetTreeNode.getKey(item)
|
||||||
const isSelected = (visuallySelectedKeysOverride ?? selectedKeys).has(key)
|
const isSelected = (visuallySelectedKeysOverride ?? selectedKeys).has(key)
|
||||||
const isSoleSelected = selectedKeys.size === 1 && isSelected
|
const isSoleSelected = selectedKeys.size === 1 && isSelected
|
||||||
@ -2194,6 +2206,10 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
isKeyboardSelected={
|
isKeyboardSelected={
|
||||||
keyboardSelectedIndex != null && item === visibleItems[keyboardSelectedIndex]
|
keyboardSelectedIndex != null && item === visibleItems[keyboardSelectedIndex]
|
||||||
}
|
}
|
||||||
|
grabKeyboardFocus={() => {
|
||||||
|
setSelectedKeys(new Set([key]))
|
||||||
|
setMostRecentlySelectedIndex(i, true)
|
||||||
|
}}
|
||||||
allowContextMenu={selectedKeysRef.current.size === 0 || !isSelected || isSoleSelected}
|
allowContextMenu={selectedKeysRef.current.size === 0 || !isSelected || isSoleSelected}
|
||||||
onClick={onRowClick}
|
onClick={onRowClick}
|
||||||
onContextMenu={(_innerProps, event) => {
|
onContextMenu={(_innerProps, event) => {
|
||||||
@ -2220,6 +2236,10 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
key: node.key,
|
key: node.key,
|
||||||
asset: node.item,
|
asset: node.item,
|
||||||
}))
|
}))
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
'application/vnd.enso.assets+json',
|
||||||
|
JSON.stringify(nodes.map(node => node.key))
|
||||||
|
)
|
||||||
drag.setDragImageToBlank(event)
|
drag.setDragImageToBlank(event)
|
||||||
drag.ASSET_ROWS.bind(event, payload)
|
drag.ASSET_ROWS.bind(event, payload)
|
||||||
setModal(
|
setModal(
|
||||||
@ -2383,13 +2403,15 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
<tr className="hidden h-row first:table-row">
|
<tr className="hidden h-row first:table-row">
|
||||||
<td colSpan={columns.length} className="bg-transparent">
|
<td colSpan={columns.length} className="bg-transparent">
|
||||||
{category === Category.trash ? (
|
{category === Category.trash ? (
|
||||||
<span className="px-cell-x placeholder">{getText('yourTrashIsEmpty')}</span>
|
<aria.Text className="px-cell-x placeholder">
|
||||||
|
{getText('yourTrashIsEmpty')}
|
||||||
|
</aria.Text>
|
||||||
) : query.query !== '' ? (
|
) : query.query !== '' ? (
|
||||||
<span className="px-cell-x placeholder">
|
<aria.Text className="px-cell-x placeholder">
|
||||||
{getText('noFilesMatchTheCurrentFilters')}
|
{getText('noFilesMatchTheCurrentFilters')}
|
||||||
</span>
|
</aria.Text>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-cell-x placeholder">{getText('youHaveNoFiles')}</span>
|
<aria.Text className="px-cell-x placeholder">{getText('youHaveNoFiles')}</aria.Text>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -2432,7 +2454,24 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-auto container-size">
|
<FocusArea direction="vertical">
|
||||||
|
{innerProps => (
|
||||||
|
<div
|
||||||
|
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
|
||||||
|
ref: rootRef,
|
||||||
|
className: 'flex-1 overflow-auto container-size',
|
||||||
|
onKeyDown,
|
||||||
|
onScroll,
|
||||||
|
onBlur: event => {
|
||||||
|
if (
|
||||||
|
event.relatedTarget instanceof HTMLElement &&
|
||||||
|
!event.currentTarget.contains(event.relatedTarget)
|
||||||
|
) {
|
||||||
|
setKeyboardSelectedIndex(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
{!hidden && hiddenContextMenu}
|
{!hidden && hiddenContextMenu}
|
||||||
{!hidden && (
|
{!hidden && (
|
||||||
<SelectionBrush
|
<SelectionBrush
|
||||||
@ -2448,16 +2487,25 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
data-testid="extra-columns"
|
data-testid="extra-columns"
|
||||||
className="sticky right flex self-end px-extra-columns-panel-x py-extra-columns-panel-y"
|
className="sticky right flex self-end px-extra-columns-panel-x py-extra-columns-panel-y"
|
||||||
>
|
>
|
||||||
<div className="inline-flex gap-icons">
|
<FocusArea direction="horizontal">
|
||||||
{columnUtils.CLOUD_COLUMNS.filter(column => !enabledColumns.has(column)).map(
|
{columnsBarProps => (
|
||||||
column => (
|
<div
|
||||||
|
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(columnsBarProps, {
|
||||||
|
className: 'inline-flex gap-icons',
|
||||||
|
onFocus: () => {
|
||||||
|
setKeyboardSelectedIndex(null)
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{columnUtils.CLOUD_COLUMNS.filter(
|
||||||
|
column => !enabledColumns.has(column)
|
||||||
|
).map(column => (
|
||||||
<Button
|
<Button
|
||||||
key={column}
|
key={column}
|
||||||
active
|
active
|
||||||
image={columnUtils.COLUMN_ICONS[column]}
|
image={columnUtils.COLUMN_ICONS[column]}
|
||||||
alt={getText(columnUtils.COLUMN_SHOW_TEXT_ID[column])}
|
alt={getText(columnUtils.COLUMN_SHOW_TEXT_ID[column])}
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
event.stopPropagation()
|
|
||||||
const newExtraColumns = new Set(enabledColumns)
|
const newExtraColumns = new Set(enabledColumns)
|
||||||
if (enabledColumns.has(column)) {
|
if (enabledColumns.has(column)) {
|
||||||
newExtraColumns.delete(column)
|
newExtraColumns.delete(column)
|
||||||
@ -2467,14 +2515,17 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
setEnabledColumns(newExtraColumns)
|
setEnabledColumns(newExtraColumns)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex h-full w-min min-w-full grow flex-col">{table}</div>
|
<div className="flex h-full w-min min-w-full grow flex-col">{table}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -110,7 +110,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
|||||||
<></>
|
<></>
|
||||||
) : (
|
) : (
|
||||||
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
|
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
|
||||||
<ContextMenu hidden={hidden}>
|
<ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}>
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action="undelete"
|
action="undelete"
|
||||||
@ -156,7 +156,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
|||||||
return (
|
return (
|
||||||
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
|
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
|
||||||
{selectedKeys.size !== 0 && (
|
{selectedKeys.size !== 0 && (
|
||||||
<ContextMenu hidden={hidden}>
|
<ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}>
|
||||||
{ownsAllSelectedAssets && (
|
{ownsAllSelectedAssets && (
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
@ -204,7 +204,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
|||||||
)}
|
)}
|
||||||
<GlobalContextMenu
|
<GlobalContextMenu
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
hasCopyData={pasteData != null}
|
hasPasteData={pasteData != null}
|
||||||
directoryKey={null}
|
directoryKey={null}
|
||||||
directoryId={null}
|
directoryId={null}
|
||||||
dispatchAssetListEvent={dispatchAssetListEvent}
|
dispatchAssetListEvent={dispatchAssetListEvent}
|
||||||
|
@ -7,7 +7,10 @@ import NotCloudIcon from 'enso-assets/not_cloud.svg'
|
|||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
@ -28,31 +31,35 @@ export default function BackendSwitcher(props: BackendSwitcherProps) {
|
|||||||
const isCloud = backend.type === backendModule.BackendType.remote
|
const isCloud = backend.type === backendModule.BackendType.remote
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex shrink-0 gap-px">
|
<FocusArea direction="horizontal">
|
||||||
<button
|
{innerProps => (
|
||||||
disabled={isCloud}
|
<div className="flex shrink-0 gap-px" {...innerProps}>
|
||||||
|
<UnstyledButton
|
||||||
|
isDisabled={isCloud}
|
||||||
className="flex w-backend-switcher-option flex-col items-start bg-selected-frame px-selector-x py-selector-y text-primary selectable first:rounded-l-full last:rounded-r-full disabled:text-cloud disabled:active"
|
className="flex w-backend-switcher-option flex-col items-start bg-selected-frame px-selector-x py-selector-y text-primary selectable first:rounded-l-full last:rounded-r-full disabled:text-cloud disabled:active"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
setBackendType(backendModule.BackendType.remote)
|
setBackendType(backendModule.BackendType.remote)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-icon-with-text">
|
<div className="flex items-center gap-icon-with-text">
|
||||||
<SvgMask src={CloudIcon} />
|
<SvgMask src={CloudIcon} />
|
||||||
<span className="text">{getText('cloud')}</span>
|
<aria.Label className="text">{getText('cloud')}</aria.Label>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
<button
|
<UnstyledButton
|
||||||
disabled={!isCloud}
|
isDisabled={!isCloud}
|
||||||
className="flex w-backend-switcher-option flex-col items-start bg-selected-frame px-selector-x py-selector-y text-primary selectable first:rounded-l-full last:rounded-r-full disabled:text-cloud disabled:active"
|
className="flex w-backend-switcher-option flex-col items-start bg-selected-frame px-selector-x py-selector-y text-primary selectable first:rounded-l-full last:rounded-r-full disabled:text-cloud disabled:active"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
setBackendType(backendModule.BackendType.local)
|
setBackendType(backendModule.BackendType.local)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-icon-with-text">
|
<div className="flex items-center gap-icon-with-text">
|
||||||
<SvgMask src={NotCloudIcon} />
|
<SvgMask src={NotCloudIcon} />
|
||||||
<span className="text">{getText('local')}</span>
|
<aria.Label className="text">{getText('local')}</aria.Label>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import Trash2Icon from 'enso-assets/trash2.svg'
|
|||||||
|
|
||||||
import type * as text from '#/text'
|
import type * as text from '#/text'
|
||||||
|
|
||||||
|
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
@ -15,27 +16,53 @@ import AssetEventType from '#/events/AssetEventType'
|
|||||||
|
|
||||||
import Category from '#/layouts/CategorySwitcher/Category'
|
import Category from '#/layouts/CategorySwitcher/Category'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as drag from '#/utilities/drag'
|
import type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
|
// =============
|
||||||
|
// === Types ===
|
||||||
|
// =============
|
||||||
|
|
||||||
|
/** Metadata for a category. */
|
||||||
|
interface CategoryMetadata {
|
||||||
|
readonly category: Category
|
||||||
|
readonly icon: string
|
||||||
|
readonly textId: Extract<text.TextId, `${Category}Category`>
|
||||||
|
readonly buttonTextId: Extract<text.TextId, `${Category}CategoryButtonLabel`>
|
||||||
|
readonly dropZoneTextId: Extract<text.TextId, `${Category}CategoryDropZoneLabel`>
|
||||||
|
}
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
|
|
||||||
const CATEGORIES = Object.values(Category)
|
const CATEGORY_DATA: CategoryMetadata[] = [
|
||||||
|
{
|
||||||
const CATEGORY_ICONS: Readonly<Record<Category, string>> = {
|
category: Category.recent,
|
||||||
[Category.recent]: RecentIcon,
|
icon: RecentIcon,
|
||||||
[Category.home]: Home2Icon,
|
textId: 'recentCategory',
|
||||||
[Category.trash]: Trash2Icon,
|
buttonTextId: 'recentCategoryButtonLabel',
|
||||||
}
|
dropZoneTextId: 'recentCategoryDropZoneLabel',
|
||||||
|
},
|
||||||
const CATEGORY_TO_TEXT_ID: Readonly<Record<Category, text.TextId>> = {
|
{
|
||||||
[Category.recent]: 'recentCategory',
|
category: Category.home,
|
||||||
[Category.home]: 'homeCategory',
|
icon: Home2Icon,
|
||||||
[Category.trash]: 'trashCategory',
|
textId: 'homeCategory',
|
||||||
} satisfies { [C in Category]: `${C}Category` }
|
buttonTextId: 'homeCategoryButtonLabel',
|
||||||
|
dropZoneTextId: 'homeCategoryDropZoneLabel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: Category.trash,
|
||||||
|
icon: Trash2Icon,
|
||||||
|
textId: 'trashCategory',
|
||||||
|
buttonTextId: 'trashCategoryButtonLabel',
|
||||||
|
dropZoneTextId: 'trashCategoryDropZoneLabel',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// === CategorySwitcherItem ===
|
// === CategorySwitcherItem ===
|
||||||
@ -43,44 +70,54 @@ const CATEGORY_TO_TEXT_ID: Readonly<Record<Category, text.TextId>> = {
|
|||||||
|
|
||||||
/** Props for a {@link CategorySwitcherItem}. */
|
/** Props for a {@link CategorySwitcherItem}. */
|
||||||
interface InternalCategorySwitcherItemProps {
|
interface InternalCategorySwitcherItemProps {
|
||||||
readonly category: Category
|
readonly id: string
|
||||||
|
readonly data: CategoryMetadata
|
||||||
readonly isCurrent: boolean
|
readonly isCurrent: boolean
|
||||||
readonly onClick: () => void
|
readonly onPress: (event: aria.PressEvent) => void
|
||||||
readonly onDragOver: (event: React.DragEvent) => void
|
readonly acceptedDragTypes: string[]
|
||||||
readonly onDrop: (event: React.DragEvent) => void
|
readonly onDrop: (event: aria.DropEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An entry in a {@link CategorySwitcher}. */
|
/** An entry in a {@link CategorySwitcher}. */
|
||||||
function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
|
function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
|
||||||
const { category, isCurrent, onClick } = props
|
const { data, isCurrent, onPress, acceptedDragTypes, onDrop } = props
|
||||||
const { onDragOver, onDrop } = props
|
const { category, icon, textId, buttonTextId, dropZoneTextId } = data
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<aria.DropZone
|
||||||
disabled={isCurrent}
|
aria-label={getText(dropZoneTextId)}
|
||||||
title={`Go To ${category}`}
|
getDropOperation={types =>
|
||||||
className={`selectable ${
|
acceptedDragTypes.some(type => types.has(type)) ? 'move' : 'cancel'
|
||||||
isCurrent ? 'bg-selected-frame active' : ''
|
}
|
||||||
} group flex h-row items-center gap-icon-with-text rounded-full px-button-x transition-colors hover:bg-selected-frame`}
|
className="group relative flex items-center rounded-full drop-target-after"
|
||||||
onClick={onClick}
|
|
||||||
// Required because `dragover` does not fire on `mouseenter`.
|
|
||||||
onDragEnter={onDragOver}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
|
>
|
||||||
|
<UnstyledButton
|
||||||
|
aria-label={getText(buttonTextId)}
|
||||||
|
className={`rounded-inherit ${isCurrent ? 'focus-default' : ''}`}
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`selectable ${
|
||||||
|
isCurrent ? 'disabled bg-selected-frame active' : ''
|
||||||
|
} group flex h-row items-center gap-icon-with-text rounded-inherit px-button-x hover:bg-selected-frame`}
|
||||||
>
|
>
|
||||||
<SvgMask
|
<SvgMask
|
||||||
src={CATEGORY_ICONS[category]}
|
src={icon}
|
||||||
className={`group-hover:text-icon-selected ${
|
className={
|
||||||
isCurrent ? 'text-icon-selected' : 'text-icon-not-selected'
|
|
||||||
} ${
|
|
||||||
// This explicit class is a special-case due to the unusual shape of the "Recent" icon.
|
// This explicit class is a special-case due to the unusual shape of the "Recent" icon.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
category === Category.recent ? '-ml-0.5' : ''
|
category === Category.recent ? '-ml-0.5' : ''
|
||||||
}`}
|
}
|
||||||
/>
|
/>
|
||||||
<span>{getText(CATEGORY_TO_TEXT_ID[category])}</span>
|
<aria.Text slot="description">{getText(textId)}</aria.Text>
|
||||||
</button>
|
</div>
|
||||||
|
</UnstyledButton>
|
||||||
|
<div className="absolute left-full ml-2 hidden group-focus-visible:block">
|
||||||
|
{getText('drop')}
|
||||||
|
</div>
|
||||||
|
</aria.DropZone>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,51 +136,77 @@ export interface CategorySwitcherProps {
|
|||||||
export default function CategorySwitcher(props: CategorySwitcherProps) {
|
export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||||
const { category, setCategory, dispatchAssetEvent } = props
|
const { category, setCategory, dispatchAssetEvent } = props
|
||||||
const { unsetModal } = modalProvider.useSetModal()
|
const { unsetModal } = modalProvider.useSetModal()
|
||||||
|
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
localStorage.set('driveCategory', category)
|
||||||
|
}, [category, /* should never change */ localStorage])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-sidebar-section-heading">
|
<FocusArea direction="vertical">
|
||||||
<div className="text-header px-sidebar-section-heading-x text-sm font-bold">
|
{innerProps => (
|
||||||
|
<div className="flex w-full flex-col" {...innerProps}>
|
||||||
|
<aria.Header
|
||||||
|
id="header"
|
||||||
|
className="text-header mb-sidebar-section-heading-b px-sidebar-section-heading-x text-sm font-bold"
|
||||||
|
>
|
||||||
{getText('category')}
|
{getText('category')}
|
||||||
</div>
|
</aria.Header>
|
||||||
<div className="flex flex-col items-start">
|
<div
|
||||||
{CATEGORIES.map(currentCategory => (
|
aria-label={getText('categorySwitcherMenuLabel')}
|
||||||
|
role="grid"
|
||||||
|
className="flex flex-col items-start"
|
||||||
|
>
|
||||||
|
{CATEGORY_DATA.map(data => (
|
||||||
<CategorySwitcherItem
|
<CategorySwitcherItem
|
||||||
key={currentCategory}
|
key={data.category}
|
||||||
category={currentCategory}
|
id={data.category}
|
||||||
isCurrent={category === currentCategory}
|
data={data}
|
||||||
onClick={() => {
|
isCurrent={category === data.category}
|
||||||
setCategory(currentCategory)
|
onPress={() => {
|
||||||
|
setCategory(data.category)
|
||||||
}}
|
}}
|
||||||
onDragOver={event => {
|
acceptedDragTypes={
|
||||||
if (
|
(category === Category.trash && data.category === Category.home) ||
|
||||||
(category === Category.trash && currentCategory === Category.home) ||
|
(category !== Category.trash && data.category === Category.trash)
|
||||||
(category !== Category.trash && currentCategory === Category.trash)
|
? ['application/vnd.enso.assets+json']
|
||||||
) {
|
: []
|
||||||
event.preventDefault()
|
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
onDrop={event => {
|
onDrop={event => {
|
||||||
if (
|
|
||||||
(category === Category.trash && currentCategory === Category.home) ||
|
|
||||||
(category !== Category.trash && currentCategory === Category.trash)
|
|
||||||
) {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
unsetModal()
|
unsetModal()
|
||||||
const payload = drag.ASSET_ROWS.lookup(event)
|
void Promise.all(
|
||||||
if (payload != null) {
|
event.items.flatMap(async item => {
|
||||||
|
if (item.kind === 'text') {
|
||||||
|
const text = await item.getText('application/vnd.enso.assets+json')
|
||||||
|
const payload: unknown = JSON.parse(text)
|
||||||
|
return Array.isArray(payload)
|
||||||
|
? payload.flatMap(key =>
|
||||||
|
// This is SAFE, assuming only this app creates payloads with
|
||||||
|
// the specific mimetype above.
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
typeof key === 'string' ? [key as backend.AssetId] : []
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).then(keys => {
|
||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
type:
|
type:
|
||||||
category === Category.trash ? AssetEventType.restore : AssetEventType.delete,
|
category === Category.trash
|
||||||
ids: new Set(payload.map(item => item.key)),
|
? AssetEventType.restore
|
||||||
|
: AssetEventType.delete,
|
||||||
|
ids: new Set(keys.flat(1)),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -15,8 +15,10 @@ import * as authProvider from '#/providers/AuthProvider'
|
|||||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
import Twemoji from '#/components/Twemoji'
|
import Twemoji from '#/components/Twemoji'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as dateTime from '#/utilities/dateTime'
|
import * as dateTime from '#/utilities/dateTime'
|
||||||
import * as newtype from '#/utilities/newtype'
|
import * as newtype from '#/utilities/newtype'
|
||||||
@ -110,9 +112,9 @@ function ReactionBar(props: ReactionBarProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={`m-chat-reaction-bar inline-block rounded-full bg-frame ${className ?? ''}`}>
|
<div className={`m-chat-reaction-bar inline-block rounded-full bg-frame ${className ?? ''}`}>
|
||||||
{REACTION_EMOJIS.map(emoji => (
|
{REACTION_EMOJIS.map(emoji => (
|
||||||
<button
|
<UnstyledButton
|
||||||
key={emoji}
|
key={emoji}
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
if (selectedReactions.has(emoji)) {
|
if (selectedReactions.has(emoji)) {
|
||||||
doRemoveReaction(emoji)
|
doRemoveReaction(emoji)
|
||||||
} else {
|
} else {
|
||||||
@ -124,7 +126,7 @@ function ReactionBar(props: ReactionBarProps) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Twemoji key={emoji} emoji={emoji} size={REACTION_BUTTON_SIZE} />
|
<Twemoji key={emoji} emoji={emoji} size={REACTION_BUTTON_SIZE} />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -263,17 +265,14 @@ function ChatHeader(props: InternalChatHeaderProps) {
|
|||||||
}
|
}
|
||||||
}, [gtagEvent])
|
}, [gtagEvent])
|
||||||
|
|
||||||
const toggleThreadListVisibility = React.useCallback((event: React.SyntheticEvent) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
setIsThreadListVisible(visible => !visible)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-chat-header-x mt-chat-header-t flex text-sm font-semibold">
|
<div className="mx-chat-header-x mt-chat-header-t flex text-sm font-semibold">
|
||||||
<button
|
<UnstyledButton
|
||||||
className="flex grow items-center gap-icon-with-text"
|
className="flex grow items-center gap-icon-with-text"
|
||||||
onClick={toggleThreadListVisibility}
|
onPress={() => {
|
||||||
|
setIsThreadListVisible(visible => !visible)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SvgMask
|
<SvgMask
|
||||||
className={`shrink-0 transition-transform duration-arrow ${
|
className={`shrink-0 transition-transform duration-arrow ${
|
||||||
@ -282,7 +281,7 @@ function ChatHeader(props: InternalChatHeaderProps) {
|
|||||||
src={FolderArrowIcon}
|
src={FolderArrowIcon}
|
||||||
/>
|
/>
|
||||||
<div className="grow">
|
<div className="grow">
|
||||||
<input
|
<aria.Input
|
||||||
type="text"
|
type="text"
|
||||||
ref={titleInputRef}
|
ref={titleInputRef}
|
||||||
defaultValue={threadTitle}
|
defaultValue={threadTitle}
|
||||||
@ -320,10 +319,10 @@ function ChatHeader(props: InternalChatHeaderProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
<button className="mx-close-icon" onClick={doClose}>
|
<UnstyledButton className="mx-close-icon" onPress={doClose}>
|
||||||
<img src={CloseLargeIcon} />
|
<img src={CloseLargeIcon} />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative text-sm font-semibold">
|
<div className="relative text-sm font-semibold">
|
||||||
<div
|
<div
|
||||||
@ -380,6 +379,21 @@ export default function Chat(props: ChatProps) {
|
|||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const logger = loggerProvider.useLogger()
|
const logger = loggerProvider.useLogger()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
|
const { isFocusVisible } = aria.useFocusVisible()
|
||||||
|
const { focusWithinProps } = aria.useFocusWithin({
|
||||||
|
onFocusWithin: event => {
|
||||||
|
if (
|
||||||
|
isFocusVisible &&
|
||||||
|
!isOpen &&
|
||||||
|
(event.relatedTarget instanceof HTMLElement || event.relatedTarget instanceof SVGElement)
|
||||||
|
) {
|
||||||
|
const relatedTarget = event.relatedTarget
|
||||||
|
setTimeout(() => {
|
||||||
|
relatedTarget.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
/** This is SAFE, because this component is only rendered when `accessToken` is present.
|
/** This is SAFE, because this component is only rendered when `accessToken` is present.
|
||||||
* See `dashboard.tsx` for its sole usage. */
|
* See `dashboard.tsx` for its sole usage. */
|
||||||
@ -592,8 +606,7 @@ export default function Chat(props: ChatProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const sendCurrentMessage = React.useCallback(
|
const sendCurrentMessage = React.useCallback(
|
||||||
(event: React.SyntheticEvent, createNewThread?: boolean) => {
|
(createNewThread?: boolean) => {
|
||||||
event.preventDefault()
|
|
||||||
const element = messageInputRef.current
|
const element = messageInputRef.current
|
||||||
if (element != null) {
|
if (element != null) {
|
||||||
const content = element.value
|
const content = element.value
|
||||||
@ -663,6 +676,7 @@ export default function Chat(props: ChatProps) {
|
|||||||
return reactDom.createPortal(
|
return reactDom.createPortal(
|
||||||
<div
|
<div
|
||||||
className={`fixed right top z-1 flex h-screen w-chat flex-col py-chat-y text-xs text-primary shadow-soft backdrop-blur-default transition-transform ${isOpen ? '' : 'translate-x-full'}`}
|
className={`fixed right top z-1 flex h-screen w-chat flex-col py-chat-y text-xs text-primary shadow-soft backdrop-blur-default transition-transform ${isOpen ? '' : 'translate-x-full'}`}
|
||||||
|
{...focusWithinProps}
|
||||||
>
|
>
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
threads={threads}
|
threads={threads}
|
||||||
@ -743,7 +757,9 @@ export default function Chat(props: ChatProps) {
|
|||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
className="mx-chat-form-x my-chat-form-y rounded-default bg-frame p-chat-form"
|
className="mx-chat-form-x my-chat-form-y rounded-default bg-frame p-chat-form"
|
||||||
onSubmit={sendCurrentMessage}
|
onSubmit={() => {
|
||||||
|
sendCurrentMessage()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
@ -776,36 +792,37 @@ export default function Chat(props: ChatProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-chat-buttons">
|
<div className="flex gap-chat-buttons">
|
||||||
<button
|
<UnstyledButton
|
||||||
type="button"
|
isDisabled={!isReplyEnabled}
|
||||||
disabled={!isReplyEnabled}
|
|
||||||
className={`text-xxs grow rounded-full px-chat-button-x py-chat-button-y text-left text-white ${
|
className={`text-xxs grow rounded-full px-chat-button-x py-chat-button-y text-left text-white ${
|
||||||
isReplyEnabled ? 'bg-gray-400' : 'bg-gray-300'
|
isReplyEnabled ? 'bg-gray-400' : 'bg-gray-300'
|
||||||
}`}
|
}`}
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
sendCurrentMessage(event, true)
|
sendCurrentMessage(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getText('clickForNewQuestion')}
|
{getText('clickForNewQuestion')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
<button
|
<UnstyledButton
|
||||||
type="submit"
|
isDisabled={!isReplyEnabled}
|
||||||
disabled={!isReplyEnabled}
|
|
||||||
className="rounded-full bg-blue-600/90 px-chat-button-x py-chat-button-y text-white selectable enabled:active"
|
className="rounded-full bg-blue-600/90 px-chat-button-x py-chat-button-y text-white selectable enabled:active"
|
||||||
|
onPress={() => {
|
||||||
|
sendCurrentMessage()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{getText('replyExclamation')}
|
{getText('replyExclamation')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{!isPaidUser && (
|
{!isPaidUser && (
|
||||||
<button
|
<UnstyledButton
|
||||||
// This UI element does not appear anywhere else.
|
// This UI element does not appear anywhere else.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
className="mx-2 my-1 rounded-default bg-call-to-action/90 p-2 text-center leading-cozy text-white"
|
className="mx-2 my-1 rounded-default bg-call-to-action/90 p-2 text-center leading-cozy text-white"
|
||||||
onClick={upgradeToPro}
|
onPress={upgradeToPro}
|
||||||
>
|
>
|
||||||
{getText('upgradeToProNag')}
|
{getText('upgradeToProNag')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
container
|
container
|
||||||
|
@ -14,6 +14,8 @@ import * as textProvider from '#/providers/TextProvider'
|
|||||||
|
|
||||||
import * as chat from '#/layouts/Chat'
|
import * as chat from '#/layouts/Chat'
|
||||||
|
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
/** Props for a {@link ChatPlaceholder}. */
|
/** Props for a {@link ChatPlaceholder}. */
|
||||||
export interface ChatPlaceholderProps {
|
export interface ChatPlaceholderProps {
|
||||||
/** This should only be false when the panel is closing. */
|
/** This should only be false when the panel is closing. */
|
||||||
@ -40,31 +42,31 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
|
|||||||
>
|
>
|
||||||
<div className="mx-chat-header-x mt-chat-header-t flex text-sm font-semibold">
|
<div className="mx-chat-header-x mt-chat-header-t flex text-sm font-semibold">
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
<button className="mx-close-icon" onClick={doClose}>
|
<UnstyledButton className="mx-close-icon" onPress={doClose}>
|
||||||
<img src={CloseLargeIcon} />
|
<img src={CloseLargeIcon} />
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grow place-items-center">
|
<div className="grid grow place-items-center">
|
||||||
<div className="flex flex-col gap-status-page text-center text-base">
|
<div className="flex flex-col gap-status-page text-center text-base">
|
||||||
<div className="px-missing-functionality-text-x">
|
<div className="px-missing-functionality-text-x">
|
||||||
{getText('placeholderChatPrompt')}
|
{getText('placeholderChatPrompt')}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<UnstyledButton
|
||||||
className="button self-center bg-help text-white"
|
className="button self-center bg-help text-white"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
navigate(appUtils.LOGIN_PATH)
|
navigate(appUtils.LOGIN_PATH)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getText('login')}
|
{getText('login')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
<button
|
<UnstyledButton
|
||||||
className="button self-center bg-help text-white"
|
className="button self-center bg-help text-white"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
navigate(appUtils.REGISTRATION_PATH)
|
navigate(appUtils.REGISTRATION_PATH)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getText('register')}
|
{getText('register')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
@ -26,7 +26,9 @@ import Category from '#/layouts/CategorySwitcher/Category'
|
|||||||
import DriveBar from '#/layouts/DriveBar'
|
import DriveBar from '#/layouts/DriveBar'
|
||||||
import Labels from '#/layouts/Labels'
|
import Labels from '#/layouts/Labels'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import type * as spinner from '#/components/Spinner'
|
import type * as spinner from '#/components/Spinner'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
import * as projectManager from '#/services/ProjectManager'
|
import * as projectManager from '#/services/ProjectManager'
|
||||||
@ -320,14 +322,14 @@ export default function Drive(props: DriveProps) {
|
|||||||
<div className={`grid grow place-items-center ${hidden ? 'hidden' : ''}`}>
|
<div className={`grid grow place-items-center ${hidden ? 'hidden' : ''}`}>
|
||||||
<div className="flex flex-col gap-status-page text-center text-base">
|
<div className="flex flex-col gap-status-page text-center text-base">
|
||||||
<div>{getText('youAreNotLoggedIn')}</div>
|
<div>{getText('youAreNotLoggedIn')}</div>
|
||||||
<button
|
<UnstyledButton
|
||||||
className="button self-center bg-help text-white"
|
className="button self-center bg-help text-white"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
navigate(appUtils.LOGIN_PATH)
|
navigate(appUtils.LOGIN_PATH)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getText('login')}
|
{getText('login')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -350,9 +352,9 @@ export default function Drive(props: DriveProps) {
|
|||||||
{getText('upgrade')}
|
{getText('upgrade')}
|
||||||
</a>
|
</a>
|
||||||
{!supportsLocalBackend && (
|
{!supportsLocalBackend && (
|
||||||
<button
|
<UnstyledButton
|
||||||
className="button self-center bg-help text-white"
|
className="button self-center bg-help text-white"
|
||||||
onClick={async () => {
|
onPress={async () => {
|
||||||
const downloadUrl = await github.getDownloadUrl()
|
const downloadUrl = await github.getDownloadUrl()
|
||||||
if (downloadUrl == null) {
|
if (downloadUrl == null) {
|
||||||
toastAndLog('noAppDownloadError')
|
toastAndLog('noAppDownloadError')
|
||||||
@ -362,7 +364,7 @@ export default function Drive(props: DriveProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getText('downloadFreeEdition')}
|
{getText('downloadFreeEdition')}
|
||||||
</button>
|
</UnstyledButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -376,10 +378,13 @@ export default function Drive(props: DriveProps) {
|
|||||||
hidden ? 'hidden' : ''
|
hidden ? 'hidden' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-icons self-start">
|
<div className="flex flex-col items-start gap-icons self-start">
|
||||||
<h1 className="h-heading px-heading-x py-heading-y text-xl font-bold leading-snug">
|
<aria.Heading
|
||||||
|
level={1}
|
||||||
|
className="h-heading px-heading-x py-heading-y text-xl font-bold leading-snug"
|
||||||
|
>
|
||||||
{isCloud ? getText('cloudDrive') : getText('localDrive')}
|
{isCloud ? getText('cloudDrive') : getText('localDrive')}
|
||||||
</h1>
|
</aria.Heading>
|
||||||
<DriveBar
|
<DriveBar
|
||||||
category={category}
|
category={category}
|
||||||
canDownload={canDownload}
|
canDownload={canDownload}
|
||||||
|
@ -18,7 +18,10 @@ import AssetEventType from '#/events/AssetEventType'
|
|||||||
|
|
||||||
import Category from '#/layouts/CategorySwitcher/Category'
|
import Category from '#/layouts/CategorySwitcher/Category'
|
||||||
|
|
||||||
import Button from '#/components/Button'
|
import * as aria from '#/components/aria'
|
||||||
|
import Button from '#/components/styled/Button'
|
||||||
|
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||||
import UpsertDataLinkModal from '#/modals/UpsertDataLinkModal'
|
import UpsertDataLinkModal from '#/modals/UpsertDataLinkModal'
|
||||||
@ -82,18 +85,17 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
// in the given directory, to avoid name collisions.
|
// in the given directory, to avoid name collisions.
|
||||||
return (
|
return (
|
||||||
<div className="flex h-row py-drive-bar-y">
|
<div className="flex h-row py-drive-bar-y">
|
||||||
<div className="flex gap-drive-bar" />
|
<HorizontalMenuBar />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case Category.trash: {
|
case Category.trash: {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-row py-drive-bar-y">
|
<div className="flex h-row py-drive-bar-y">
|
||||||
<div className="flex gap-drive-bar">
|
<HorizontalMenuBar>
|
||||||
<button
|
<UnstyledButton
|
||||||
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
event.stopPropagation()
|
|
||||||
setModal(
|
setModal(
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
actionText={getText('allTrashedItemsForever')}
|
actionText={getText('allTrashedItemsForever')}
|
||||||
@ -102,42 +104,47 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text whitespace-nowrap font-semibold">{getText('clearTrash')}</span>
|
<aria.Text className="text whitespace-nowrap font-semibold">
|
||||||
</button>
|
{getText('clearTrash')}
|
||||||
</div>
|
</aria.Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</HorizontalMenuBar>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case Category.home: {
|
case Category.home: {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-row py-drive-bar-y">
|
<div className="flex h-row py-drive-bar-y">
|
||||||
<div className="flex gap-drive-bar">
|
<HorizontalMenuBar>
|
||||||
<button
|
<UnstyledButton
|
||||||
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
doCreateProject()
|
doCreateProject()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text whitespace-nowrap font-semibold">{getText('newProject')}</span>
|
<aria.Text className="text whitespace-nowrap font-semibold">
|
||||||
</button>
|
{getText('newProject')}
|
||||||
|
</aria.Text>
|
||||||
|
</UnstyledButton>
|
||||||
<div className="flex h-row items-center gap-icons rounded-full bg-frame px-drive-bar-icons-x text-black/50">
|
<div className="flex h-row items-center gap-icons rounded-full bg-frame px-drive-bar-icons-x text-black/50">
|
||||||
|
{isCloud && (
|
||||||
<Button
|
<Button
|
||||||
active
|
active
|
||||||
image={AddFolderIcon}
|
image={AddFolderIcon}
|
||||||
alt={getText('newFolder')}
|
alt={getText('newFolder')}
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
doCreateDirectory()
|
doCreateDirectory()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{isCloud && (
|
{isCloud && (
|
||||||
<Button
|
<Button
|
||||||
active
|
active
|
||||||
image={AddKeyIcon}
|
image={AddKeyIcon}
|
||||||
alt={getText('newSecret')}
|
alt={getText('newSecret')}
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
event.stopPropagation()
|
|
||||||
setModal(<UpsertSecretModal id={null} name={null} doCreate={doCreateSecret} />)
|
setModal(<UpsertSecretModal id={null} name={null} doCreate={doCreateSecret} />)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -147,18 +154,18 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
active
|
active
|
||||||
image={AddConnectorIcon}
|
image={AddConnectorIcon}
|
||||||
alt={getText('newDataLink')}
|
alt={getText('newDataLink')}
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
event.stopPropagation()
|
|
||||||
setModal(<UpsertDataLinkModal doCreate={doCreateDataLink} />)
|
setModal(<UpsertDataLinkModal doCreate={doCreateDataLink} />)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<input
|
<aria.Input
|
||||||
ref={uploadFilesRef}
|
ref={uploadFilesRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
id="upload_files_input"
|
id="upload_files_input"
|
||||||
name="upload_files_input"
|
name="upload_files_input"
|
||||||
|
{...(isCloud ? {} : { accept: '.enso-project' })}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onInput={event => {
|
onInput={event => {
|
||||||
if (event.currentTarget.files != null) {
|
if (event.currentTarget.files != null) {
|
||||||
@ -173,27 +180,26 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
active
|
active
|
||||||
image={DataUploadIcon}
|
image={DataUploadIcon}
|
||||||
alt={getText('uploadFiles')}
|
alt={getText('uploadFiles')}
|
||||||
onClick={() => {
|
onPress={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
uploadFilesRef.current?.click()
|
uploadFilesRef.current?.click()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
active={canDownload}
|
active={canDownload}
|
||||||
disabled={!canDownload}
|
isDisabled={!canDownload}
|
||||||
image={DataDownloadIcon}
|
image={DataDownloadIcon}
|
||||||
alt={getText('downloadFiles')}
|
alt={getText('downloadFiles')}
|
||||||
error={
|
error={
|
||||||
isCloud ? getText('canOnlyDownloadFilesError') : getText('noProjectSelectedError')
|
isCloud ? getText('canOnlyDownloadFilesError') : getText('noProjectSelectedError')
|
||||||
}
|
}
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
event.stopPropagation()
|
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetEvent({ type: AssetEventType.downloadSelected })
|
dispatchAssetEvent({ type: AssetEventType.downloadSelected })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</HorizontalMenuBar>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,12 @@ import * as React from 'react'
|
|||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
import type * as assetListEventModule from '#/events/assetListEvent'
|
import type * as assetListEventModule from '#/events/assetListEvent'
|
||||||
import AssetListEventType from '#/events/AssetListEventType'
|
import AssetListEventType from '#/events/AssetListEventType'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import ContextMenu from '#/components/ContextMenu'
|
import ContextMenu from '#/components/ContextMenu'
|
||||||
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
import ContextMenuEntry from '#/components/ContextMenuEntry'
|
||||||
|
|
||||||
@ -19,7 +21,7 @@ import * as backendModule from '#/services/Backend'
|
|||||||
/** Props for a {@link GlobalContextMenu}. */
|
/** Props for a {@link GlobalContextMenu}. */
|
||||||
export interface GlobalContextMenuProps {
|
export interface GlobalContextMenuProps {
|
||||||
readonly hidden?: boolean
|
readonly hidden?: boolean
|
||||||
readonly hasCopyData: boolean
|
readonly hasPasteData: boolean
|
||||||
readonly directoryKey: backendModule.DirectoryId | null
|
readonly directoryKey: backendModule.DirectoryId | null
|
||||||
readonly directoryId: backendModule.DirectoryId | null
|
readonly directoryId: backendModule.DirectoryId | null
|
||||||
readonly dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
|
readonly dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
|
||||||
@ -31,26 +33,29 @@ export interface GlobalContextMenuProps {
|
|||||||
|
|
||||||
/** A context menu available everywhere in the directory. */
|
/** A context menu available everywhere in the directory. */
|
||||||
export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
||||||
const { hidden = false, hasCopyData, directoryKey, directoryId, dispatchAssetListEvent } = props
|
const { hidden = false, hasPasteData, directoryKey, directoryId, dispatchAssetListEvent } = props
|
||||||
const { doPaste } = props
|
const { doPaste } = props
|
||||||
const { user } = authProvider.useNonPartialUserSession()
|
const { user } = authProvider.useNonPartialUserSession()
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
const rootDirectoryId = React.useMemo(
|
const rootDirectoryId = React.useMemo(
|
||||||
() => user?.rootDirectoryId ?? backendModule.DirectoryId(''),
|
() => user?.rootDirectoryId ?? backendModule.DirectoryId(''),
|
||||||
[user]
|
[user]
|
||||||
)
|
)
|
||||||
const filesInputRef = React.useRef<HTMLInputElement>(null)
|
const filesInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const isCloud = backend.type === backendModule.BackendType.remote
|
const isCloud = backend.type === backendModule.BackendType.remote
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu hidden={hidden}>
|
<ContextMenu aria-label={getText('globalContextMenuLabel')} hidden={hidden}>
|
||||||
{!hidden && (
|
{!hidden && (
|
||||||
<input
|
<aria.Input
|
||||||
ref={filesInputRef}
|
ref={filesInputRef}
|
||||||
multiple
|
multiple
|
||||||
type="file"
|
type="file"
|
||||||
id="context_menu_file_input"
|
id="context_menu_file_input"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
{...(backend.type !== backendModule.BackendType.local ? {} : { accept: '.enso-project' })}
|
||||||
onInput={event => {
|
onInput={event => {
|
||||||
if (event.currentTarget.files != null) {
|
if (event.currentTarget.files != null) {
|
||||||
dispatchAssetListEvent({
|
dispatchAssetListEvent({
|
||||||
@ -162,7 +167,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCloud && directoryKey == null && hasCopyData && (
|
{isCloud && directoryKey == null && hasPasteData && (
|
||||||
<ContextMenuEntry
|
<ContextMenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action="paste"
|
action="paste"
|
||||||
|
@ -6,6 +6,7 @@ import * as textProvider from '#/providers/TextProvider'
|
|||||||
import Samples from '#/layouts/Samples'
|
import Samples from '#/layouts/Samples'
|
||||||
import WhatsNew from '#/layouts/WhatsNew'
|
import WhatsNew from '#/layouts/WhatsNew'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import type * as spinner from '#/components/Spinner'
|
import type * as spinner from '#/components/Spinner'
|
||||||
|
|
||||||
// ============
|
// ============
|
||||||
@ -36,12 +37,15 @@ export default function Home(props: HomeProps) {
|
|||||||
<div />
|
<div />
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-banner px-banner-x py-banner-y">
|
<div className="flex flex-col gap-banner px-banner-x py-banner-y">
|
||||||
<h1 className="self-center py-banner-item text-center text-4xl leading-snug">
|
<aria.Heading
|
||||||
|
level={1}
|
||||||
|
className="self-center py-banner-item text-center text-4xl leading-snug"
|
||||||
|
>
|
||||||
{getText('welcomeMessage')}
|
{getText('welcomeMessage')}
|
||||||
</h1>
|
</aria.Heading>
|
||||||
<h2 className="self-center py-banner-item text-center text-xl font-normal leading-snug">
|
<aria.Text className="self-center py-banner-item text-center text-xl font-normal leading-snug">
|
||||||
{getText('welcomeSubtitle')}
|
{getText('welcomeSubtitle')}
|
||||||
</h2>
|
</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
<WhatsNew />
|
<WhatsNew />
|
||||||
<Samples createProject={createProject} />
|
<Samples createProject={createProject} />
|
||||||
|
@ -7,8 +7,11 @@ import Trash2Icon from 'enso-assets/trash2.svg'
|
|||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import Label from '#/components/dashboard/Label'
|
import Label from '#/components/dashboard/Label'
|
||||||
import * as labelUtils from '#/components/dashboard/Label/labelUtils'
|
import * as labelUtils from '#/components/dashboard/Label/labelUtils'
|
||||||
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||||
@ -45,25 +48,36 @@ export default function Labels(props: LabelsProps) {
|
|||||||
const currentNegativeLabels = query.negativeLabels
|
const currentNegativeLabels = query.negativeLabels
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
const displayLabels = React.useMemo(
|
||||||
|
() =>
|
||||||
|
labels
|
||||||
|
.filter(label => !deletedLabelNames.has(label.value))
|
||||||
|
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value)),
|
||||||
|
[deletedLabelNames, labels]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FocusArea direction="vertical">
|
||||||
|
{innerProps => (
|
||||||
<div
|
<div
|
||||||
data-testid="labels"
|
data-testid="labels"
|
||||||
className="flex w-full flex-col items-start gap-sidebar-section-heading"
|
className="gap-sidebar-section-heading flex w-full flex-col items-start"
|
||||||
|
{...innerProps}
|
||||||
>
|
>
|
||||||
<div className="text-header px-sidebar-section-heading-x text-sm font-bold">
|
<div className="text-header px-sidebar-section-heading-x text-sm font-bold">
|
||||||
{getText('labels')}
|
{getText('labels')}
|
||||||
</div>
|
</div>
|
||||||
<ul data-testid="labels-list" className="flex flex-col items-start gap-labels">
|
<div
|
||||||
{labels
|
data-testid="labels-list"
|
||||||
.filter(label => !deletedLabelNames.has(label.value))
|
aria-label={getText('labelsListLabel')}
|
||||||
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
|
className="flex flex-col items-start gap-labels"
|
||||||
.map(label => {
|
>
|
||||||
|
{displayLabels.map(label => {
|
||||||
const negated = currentNegativeLabels.some(term =>
|
const negated = currentNegativeLabels.some(term =>
|
||||||
array.shallowEqual(term, [label.value])
|
array.shallowEqual(term, [label.value])
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<li key={label.id} className="group flex items-center gap-label-icons">
|
<div key={label.id} className="group relative flex items-center gap-label-icons">
|
||||||
<Label
|
<Label
|
||||||
draggable
|
draggable
|
||||||
color={label.color}
|
color={label.color}
|
||||||
@ -71,10 +85,15 @@ export default function Labels(props: LabelsProps) {
|
|||||||
negated || currentLabels.some(term => array.shallowEqual(term, [label.value]))
|
negated || currentLabels.some(term => array.shallowEqual(term, [label.value]))
|
||||||
}
|
}
|
||||||
negated={negated}
|
negated={negated}
|
||||||
disabled={newLabelNames.has(label.value)}
|
isDisabled={newLabelNames.has(label.value)}
|
||||||
onClick={event => {
|
onPress={event => {
|
||||||
setQuery(oldQuery =>
|
setQuery(oldQuery =>
|
||||||
oldQuery.withToggled('labels', 'negativeLabels', label.value, event.shiftKey)
|
oldQuery.withToggled(
|
||||||
|
'labels',
|
||||||
|
'negativeLabels',
|
||||||
|
label.value,
|
||||||
|
event.shiftKey
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onDragStart={event => {
|
onDragStart={event => {
|
||||||
@ -88,7 +107,7 @@ export default function Labels(props: LabelsProps) {
|
|||||||
drag.LABELS.unbind(payload)
|
drag.LABELS.unbind(payload)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Label active color={label.color} onClick={() => {}}>
|
<Label active color={label.color} onPress={() => {}}>
|
||||||
{label.value}
|
{label.value}
|
||||||
</Label>
|
</Label>
|
||||||
</DragModal>
|
</DragModal>
|
||||||
@ -98,10 +117,10 @@ export default function Labels(props: LabelsProps) {
|
|||||||
{label.value}
|
{label.value}
|
||||||
</Label>
|
</Label>
|
||||||
{!newLabelNames.has(label.value) && (
|
{!newLabelNames.has(label.value) && (
|
||||||
<button
|
<FocusRing placement="after">
|
||||||
className="flex"
|
<aria.Button
|
||||||
onClick={event => {
|
className="relative flex after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring"
|
||||||
event.stopPropagation()
|
onPress={() => {
|
||||||
setModal(
|
setModal(
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
actionText={getText('deleteLabelActionText', label.value)}
|
actionText={getText('deleteLabelActionText', label.value)}
|
||||||
@ -115,36 +134,37 @@ export default function Labels(props: LabelsProps) {
|
|||||||
<SvgMask
|
<SvgMask
|
||||||
src={Trash2Icon}
|
src={Trash2Icon}
|
||||||
alt={getText('delete')}
|
alt={getText('delete')}
|
||||||
className="size-icon text-delete transition-all transparent group-hover:active"
|
className="size-icon text-delete transition-all transparent group-has-[[data-focus-visible]]:active group-hover:active"
|
||||||
/>
|
/>
|
||||||
</button>
|
</aria.Button>
|
||||||
|
</FocusRing>
|
||||||
)}
|
)}
|
||||||
</li>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<li>
|
|
||||||
<Label
|
<Label
|
||||||
active
|
|
||||||
color={labelUtils.DEFAULT_LABEL_COLOR}
|
color={labelUtils.DEFAULT_LABEL_COLOR}
|
||||||
className="bg-frame text-not-selected"
|
className="bg-selected-frame"
|
||||||
onClick={event => {
|
onPress={event => {
|
||||||
event.stopPropagation()
|
if (event.target instanceof HTMLElement) {
|
||||||
setModal(
|
setModal(
|
||||||
<NewLabelModal
|
<NewLabelModal
|
||||||
labels={labels}
|
labels={labels}
|
||||||
eventTarget={event.currentTarget}
|
eventTarget={event.target}
|
||||||
doCreate={doCreateLabel}
|
doCreate={doCreateLabel}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* This is a non-standard-sized icon. */}
|
{/* This is a non-standard-sized icon. */}
|
||||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||||
<img src={PlusIcon} className="mr-[6px] size-[6px]" />
|
<img src={PlusIcon} className="mr-[6px] size-[6px]" />
|
||||||
<span className="text-header">{getText('newLabelButtonLabel')}</span>
|
<aria.Text className="text-header">{getText('newLabelButtonLabel')}</aria.Text>
|
||||||
</Label>
|
</Label>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,8 @@ import type * as text from '#/text'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
import Button from '#/components/Button'
|
import Button from '#/components/styled/Button'
|
||||||
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
// === PageSwitcher ===
|
// === PageSwitcher ===
|
||||||
@ -31,30 +32,23 @@ const ERRORS = {
|
|||||||
[Page.settings]: null,
|
[Page.settings]: null,
|
||||||
} as const satisfies Record<Page, text.TextId | null>
|
} as const satisfies Record<Page, text.TextId | null>
|
||||||
|
|
||||||
const PAGE_TO_ALT_TEXT_ID: Readonly<Record<Page, text.TextId>> = {
|
|
||||||
home: 'homePageAltText',
|
|
||||||
drive: 'drivePageAltText',
|
|
||||||
editor: 'editorPageAltText',
|
|
||||||
settings: 'settingsPageAltText',
|
|
||||||
} satisfies { [P in Page]: `${P}PageAltText` }
|
|
||||||
|
|
||||||
const PAGE_TO_TOOLTIP_ID: Readonly<Record<Page, text.TextId>> = {
|
|
||||||
home: 'homePageTooltip',
|
|
||||||
drive: 'drivePageTooltip',
|
|
||||||
editor: 'editorPageTooltip',
|
|
||||||
settings: 'settingsPageTooltip',
|
|
||||||
} satisfies { [P in Page]: `${P}PageTooltip` }
|
|
||||||
|
|
||||||
/** Data describing how to display a button for a page. */
|
/** Data describing how to display a button for a page. */
|
||||||
interface PageUIData {
|
interface PageUIData {
|
||||||
readonly page: Page
|
readonly page: Page
|
||||||
readonly icon: string
|
readonly icon: string
|
||||||
|
readonly altId: Extract<text.TextId, `${Page}PageAltText`>
|
||||||
|
readonly tooltipId: Extract<text.TextId, `${Page}PageTooltip`>
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_DATA: PageUIData[] = [
|
const PAGE_DATA: PageUIData[] = [
|
||||||
{ page: Page.home, icon: HomeIcon },
|
{ page: Page.home, icon: HomeIcon, altId: 'homePageAltText', tooltipId: 'homePageTooltip' },
|
||||||
{ page: Page.drive, icon: DriveIcon },
|
{ page: Page.drive, icon: DriveIcon, altId: 'drivePageAltText', tooltipId: 'drivePageTooltip' },
|
||||||
{ page: Page.editor, icon: NetworkIcon },
|
{
|
||||||
|
page: Page.editor,
|
||||||
|
icon: NetworkIcon,
|
||||||
|
altId: 'editorPageAltText',
|
||||||
|
tooltipId: 'editorPageTooltip',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Props for a {@link PageSwitcher}. */
|
/** Props for a {@link PageSwitcher}. */
|
||||||
@ -68,31 +62,49 @@ export interface PageSwitcherProps {
|
|||||||
export default function PageSwitcher(props: PageSwitcherProps) {
|
export default function PageSwitcher(props: PageSwitcherProps) {
|
||||||
const { page, setPage, isEditorDisabled } = props
|
const { page, setPage, isEditorDisabled } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
const selectedChildIndexRef = React.useRef(0)
|
||||||
|
const lastChildIndexRef = React.useRef(0)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
selectedChildIndexRef.current = PAGE_DATA.findIndex(data => data.page === page)
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isEditorDisabled) {
|
||||||
|
lastChildIndexRef.current = PAGE_DATA.length - 2
|
||||||
|
} else {
|
||||||
|
lastChildIndexRef.current = PAGE_DATA.length - 1
|
||||||
|
}
|
||||||
|
}, [isEditorDisabled])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<FocusArea direction="horizontal">
|
||||||
|
{innerProps => (
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-auto flex shrink-0 cursor-default items-center gap-pages rounded-full px-page-switcher-x ${
|
className={`pointer-events-auto flex shrink-0 cursor-default items-center gap-pages rounded-full px-page-switcher-x ${
|
||||||
page === Page.editor ? 'bg-frame backdrop-blur-default' : ''
|
page === Page.editor ? 'bg-frame backdrop-blur-default' : ''
|
||||||
}`}
|
}`}
|
||||||
|
{...innerProps}
|
||||||
>
|
>
|
||||||
{PAGE_DATA.map(pageData => {
|
{PAGE_DATA.map(pageData => {
|
||||||
const isDisabled =
|
|
||||||
pageData.page === page || (pageData.page === Page.editor && isEditorDisabled)
|
|
||||||
const errorId = ERRORS[pageData.page]
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={pageData.page}
|
key={pageData.page}
|
||||||
|
aria-label={getText(pageData.tooltipId)}
|
||||||
|
alt={getText(pageData.altId)}
|
||||||
image={pageData.icon}
|
image={pageData.icon}
|
||||||
active={page === pageData.page}
|
active={page === pageData.page}
|
||||||
alt={getText(PAGE_TO_ALT_TEXT_ID[pageData.page])}
|
softDisabled={page === pageData.page}
|
||||||
title={getText(PAGE_TO_TOOLTIP_ID[pageData.page])}
|
isDisabled={pageData.page === Page.editor && isEditorDisabled}
|
||||||
disabled={isDisabled}
|
error={ERRORS[pageData.page]}
|
||||||
error={errorId == null ? null : getText(errorId)}
|
onPress={() => {
|
||||||
onClick={() => {
|
|
||||||
setPage(pageData.page)
|
setPage(pageData.page)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,10 @@ import VisualizeImage from 'enso-assets/visualize.png'
|
|||||||
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import Spinner, * as spinner from '#/components/Spinner'
|
import Spinner, * as spinner from '#/components/Spinner'
|
||||||
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
|
import FocusRing from '#/components/styled/FocusRing'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
@ -81,12 +84,12 @@ export const SAMPLES: Sample[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// =====================
|
// ========================
|
||||||
// === ProjectsEntry ===
|
// === BlankProjectTile ===
|
||||||
// =====================
|
// ========================
|
||||||
|
|
||||||
/** Props for an {@link ProjectsEntry}. */
|
/** Props for an {@link BlankProjectTile}. */
|
||||||
interface InternalProjectsEntryProps {
|
interface InternalBlankProjectTileProps {
|
||||||
readonly createProject: (
|
readonly createProject: (
|
||||||
templateId: null,
|
templateId: null,
|
||||||
templateName: null,
|
templateName: null,
|
||||||
@ -95,12 +98,12 @@ interface InternalProjectsEntryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A button that, when clicked, creates and opens a new blank project. */
|
/** A button that, when clicked, creates and opens a new blank project. */
|
||||||
function ProjectsEntry(props: InternalProjectsEntryProps) {
|
function BlankProjectTile(props: InternalBlankProjectTileProps) {
|
||||||
const { createProject } = props
|
const { createProject } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
|
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
|
||||||
|
|
||||||
const onClick = () => {
|
const onPress = () => {
|
||||||
setSpinnerState(spinner.SpinnerState.initial)
|
setSpinnerState(spinner.SpinnerState.initial)
|
||||||
createProject(null, null, newSpinnerState => {
|
createProject(null, null, newSpinnerState => {
|
||||||
setSpinnerState(newSpinnerState)
|
setSpinnerState(newSpinnerState)
|
||||||
@ -114,11 +117,13 @@ function ProjectsEntry(props: InternalProjectsEntryProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-sample">
|
<div className="flex flex-col gap-sample">
|
||||||
<button
|
<FocusArea direction="horizontal">
|
||||||
// This UI element does not appear anywhere else.
|
{innerProps => (
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
<FocusRing placement="after">
|
||||||
className="relative h-sample cursor-pointer before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-frame before:opacity-60"
|
<aria.Button
|
||||||
onClick={onClick}
|
className="focus-child relative h-sample cursor-pointer before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-frame before:opacity-60 after:pointer-events-none after:absolute after:inset after:rounded-default"
|
||||||
|
onPress={onPress}
|
||||||
|
{...innerProps}
|
||||||
>
|
>
|
||||||
<div className="relative flex size-full rounded-default">
|
<div className="relative flex size-full rounded-default">
|
||||||
<div className="m-auto flex flex-col items-center gap-new-empty-project text-center">
|
<div className="m-auto flex flex-col items-center gap-new-empty-project text-center">
|
||||||
@ -130,7 +135,10 @@ function ProjectsEntry(props: InternalProjectsEntryProps) {
|
|||||||
<p className="text-sm font-semibold">{getText('newEmptyProject')}</p>
|
<p className="text-sm font-semibold">{getText('newEmptyProject')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</aria.Button>
|
||||||
|
</FocusRing>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
<div className="h-sample-info" />
|
<div className="h-sample-info" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -169,17 +177,21 @@ function ProjectTile(props: InternalProjectTileProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onClick = () => {
|
const onPress = () => {
|
||||||
setSpinnerState(spinner.SpinnerState.initial)
|
setSpinnerState(spinner.SpinnerState.initial)
|
||||||
createProject(id, title, onSpinnerStateChange)
|
createProject(id, title, onSpinnerStateChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-sample">
|
<div className="flex flex-col gap-sample">
|
||||||
<button
|
<FocusArea direction="horizontal">
|
||||||
|
{innerProps => (
|
||||||
|
<FocusRing placement="after">
|
||||||
|
<aria.Button
|
||||||
key={title}
|
key={title}
|
||||||
className="relative flex h-sample grow cursor-pointer flex-col text-left"
|
className="focus-child relative flex h-sample grow cursor-pointer flex-col text-left after:pointer-events-none after:absolute after:inset after:rounded-default"
|
||||||
onClick={onClick}
|
onPress={onPress}
|
||||||
|
{...innerProps}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{ background }}
|
style={{ background }}
|
||||||
@ -188,7 +200,7 @@ function ProjectTile(props: InternalProjectTileProps) {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div className="w-full grow rounded-b-default bg-frame px-sample-description-x pb-sample-description-b pt-sample-description-t backdrop-blur">
|
<div className="w-full grow rounded-b-default bg-frame px-sample-description-x pb-sample-description-b pt-sample-description-t backdrop-blur">
|
||||||
<h2 className="text-header text-sm font-bold">{title}</h2>
|
<aria.Heading className="text-header text-sm font-bold">{title}</aria.Heading>
|
||||||
<div className="text-ellipsis text-xs leading-snug">{description}</div>
|
<div className="text-ellipsis text-xs leading-snug">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
{spinnerState != null && (
|
{spinnerState != null && (
|
||||||
@ -196,24 +208,27 @@ function ProjectTile(props: InternalProjectTileProps) {
|
|||||||
<Spinner size={SPINNER_SIZE_PX} state={spinnerState} />
|
<Spinner size={SPINNER_SIZE_PX} state={spinnerState} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</aria.Button>
|
||||||
|
</FocusRing>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
{/* Although this component is instantiated multiple times, it has a unique role and hence
|
{/* Although this component is instantiated multiple times, it has a unique role and hence
|
||||||
* its own opacity. */}
|
* its own opacity. */}
|
||||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||||
<div className="flex h-sample-info justify-between px-sample-description-x text-primary opacity-70">
|
<div className="flex h-sample-info justify-between px-sample-description-x text-primary opacity-70">
|
||||||
<div className="flex gap-samples-icon-with-text">
|
<div className="flex gap-samples-icon-with-text">
|
||||||
<SvgMask src={Logo} className="size-icon self-end" />
|
<SvgMask src={Logo} className="size-icon self-end" />
|
||||||
<span className="self-start font-bold leading-snug">{author}</span>
|
<aria.Text className="self-start font-bold leading-snug">{author}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
{/* Normally `flex` */}
|
{/* Normally `flex` */}
|
||||||
<div className="hidden gap-icons">
|
<div className="hidden gap-icons">
|
||||||
<div title={getText('views')} className="flex gap-samples-icon-with-text">
|
<div title={getText('views')} className="flex gap-samples-icon-with-text">
|
||||||
<SvgMask alt={getText('views')} src={OpenCountIcon} className="size-icon self-end" />
|
<SvgMask alt={getText('views')} src={OpenCountIcon} className="size-icon self-end" />
|
||||||
<span className="self-start font-bold leading-snug">{opens}</span>
|
<aria.Text className="self-start font-bold leading-snug">{opens}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
<div title={getText('likes')} className="flex gap-samples-icon-with-text">
|
<div title={getText('likes')} className="flex gap-samples-icon-with-text">
|
||||||
<SvgMask alt={getText('likes')} src={HeartIcon} className="size-icon self-end" />
|
<SvgMask alt={getText('likes')} src={HeartIcon} className="size-icon self-end" />
|
||||||
<span className="self-start font-bold leading-snug">{likes}</span>
|
<aria.Text className="self-start font-bold leading-snug">{likes}</aria.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -238,11 +253,14 @@ export interface SamplesProps {
|
|||||||
export default function Samples(props: SamplesProps) {
|
export default function Samples(props: SamplesProps) {
|
||||||
const { createProject } = props
|
const { createProject } = props
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="samples" className="flex flex-col gap-subheading px-home-section-x">
|
<div data-testid="samples" className="flex flex-col gap-subheading px-home-section-x">
|
||||||
<h2 className="text-subheading">{getText('sampleAndCommunityProjects')}</h2>
|
<aria.Heading level={2} className="text-subheading">
|
||||||
|
{getText('sampleAndCommunityProjects')}
|
||||||
|
</aria.Heading>
|
||||||
<div className="grid grid-cols-fill-samples gap-samples">
|
<div className="grid grid-cols-fill-samples gap-samples">
|
||||||
<ProjectsEntry createProject={createProject} />
|
<BlankProjectTile createProject={createProject} />
|
||||||
{SAMPLES.map(sample => (
|
{SAMPLES.map(sample => (
|
||||||
<ProjectTile key={sample.id} sample={sample} createProject={createProject} />
|
<ProjectTile key={sample.id} sample={sample} createProject={createProject} />
|
||||||
))}
|
))}
|
||||||
|
@ -15,6 +15,8 @@ import OrganizationSettingsTab from '#/layouts/Settings/OrganizationSettingsTab'
|
|||||||
import SettingsTab from '#/layouts/Settings/SettingsTab'
|
import SettingsTab from '#/layouts/Settings/SettingsTab'
|
||||||
import SettingsSidebar from '#/layouts/SettingsSidebar'
|
import SettingsSidebar from '#/layouts/SettingsSidebar'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
import * as array from '#/utilities/array'
|
import * as array from '#/utilities/array'
|
||||||
@ -89,8 +91,8 @@ export default function Settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col gap-settings-header overflow-hidden px-page-x">
|
<div className="flex flex-1 flex-col gap-settings-header overflow-hidden px-page-x">
|
||||||
<div className="flex h-heading px-heading-x text-xl font-bold">
|
<aria.Heading level={1} className="flex h-heading px-heading-x text-xl font-bold">
|
||||||
<span className="py-heading-y">{getText('settingsFor')}</span>
|
<aria.Text className="py-heading-y">{getText('settingsFor')}</aria.Text>
|
||||||
{/* This UI element does not appear anywhere else. */}
|
{/* This UI element does not appear anywhere else. */}
|
||||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||||
<div className="ml-[0.625rem] h-[2.25rem] rounded-full bg-frame px-[0.5625rem] pb-[0.3125rem] pt-[0.125rem] leading-snug">
|
<div className="ml-[0.625rem] h-[2.25rem] rounded-full bg-frame px-[0.5625rem] pb-[0.3125rem] pt-[0.125rem] leading-snug">
|
||||||
@ -98,7 +100,7 @@ export default function Settings() {
|
|||||||
? user?.name ?? 'your account'
|
? user?.name ?? 'your account'
|
||||||
: organization.name ?? 'your organization'}
|
: organization.name ?? 'your organization'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aria.Heading>
|
||||||
<div className="flex flex-1 gap-settings overflow-hidden">
|
<div className="flex flex-1 gap-settings overflow-hidden">
|
||||||
<SettingsSidebar settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
|
<SettingsSidebar settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
|
||||||
{content}
|
{content}
|
||||||
|
@ -1,105 +1,12 @@
|
|||||||
/** @file Settings tab for viewing and editing account information. */
|
/** @file Settings tab for viewing and editing account information. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import DefaultUserIcon from 'enso-assets/default_user.svg'
|
|
||||||
import EyeCrossedIcon from 'enso-assets/eye_crossed.svg'
|
|
||||||
import EyeIcon from 'enso-assets/eye.svg'
|
|
||||||
|
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
|
||||||
|
|
||||||
import SvgMask from '#/components/SvgMask'
|
import ChangePasswordSettingsSection from '#/layouts/Settings/ChangePasswordSettingsSection'
|
||||||
|
import DeleteUserAccountSettingsSection from '#/layouts/Settings/DeleteUserAccountSettingsSection'
|
||||||
import ConfirmDeleteUserModal from '#/modals/ConfirmDeleteUserModal'
|
import ProfilePictureSettingsSection from '#/layouts/Settings/ProfilePictureSettingsSection'
|
||||||
|
import UserAccountSettingsSection from '#/layouts/Settings/UserAccountSettingsSection'
|
||||||
import * as object from '#/utilities/object'
|
|
||||||
import * as uniqueString from '#/utilities/uniqueString'
|
|
||||||
import * as validation from '#/utilities/validation'
|
|
||||||
|
|
||||||
// =============
|
|
||||||
// === Input ===
|
|
||||||
// =============
|
|
||||||
|
|
||||||
/** Props for an {@link Input}. */
|
|
||||||
interface InternalInputProps {
|
|
||||||
readonly originalValue: string
|
|
||||||
readonly type?: string
|
|
||||||
readonly placeholder?: string
|
|
||||||
readonly onChange?: React.ChangeEventHandler<HTMLInputElement>
|
|
||||||
readonly onSubmit?: (value: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A styled input. */
|
|
||||||
function Input(props: InternalInputProps) {
|
|
||||||
const { originalValue, type, placeholder, onChange, onSubmit } = props
|
|
||||||
const [isShowingPassword, setIsShowingPassword] = React.useState(false)
|
|
||||||
const cancelled = React.useRef(false)
|
|
||||||
|
|
||||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Escape': {
|
|
||||||
cancelled.current = true
|
|
||||||
event.stopPropagation()
|
|
||||||
event.currentTarget.value = originalValue
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = (
|
|
||||||
<input
|
|
||||||
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={isShowingPassword ? 'text' : type}
|
|
||||||
size={1}
|
|
||||||
defaultValue={originalValue}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onChange={onChange}
|
|
||||||
onBlur={event => {
|
|
||||||
if (!cancelled.current) {
|
|
||||||
onSubmit?.(event.currentTarget.value)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return type !== 'password' ? (
|
|
||||||
input
|
|
||||||
) : (
|
|
||||||
<div className="relative">
|
|
||||||
{input}
|
|
||||||
{
|
|
||||||
<SvgMask
|
|
||||||
src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
|
|
||||||
className="absolute right-2 top-1 cursor-pointer rounded-full"
|
|
||||||
onClick={() => {
|
|
||||||
setIsShowingPassword(show => !show)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================
|
// ==========================
|
||||||
// === AccountSettingsTab ===
|
// === AccountSettingsTab ===
|
||||||
@ -107,16 +14,7 @@ function Input(props: InternalInputProps) {
|
|||||||
|
|
||||||
/** Settings tab for viewing and editing account information. */
|
/** Settings tab for viewing and editing account information. */
|
||||||
export default function AccountSettingsTab() {
|
export default function AccountSettingsTab() {
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const { accessToken } = authProvider.useNonPartialUserSession()
|
||||||
const { setUser, changePassword, signOut } = authProvider.useAuth()
|
|
||||||
const { setModal } = modalProvider.useSetModal()
|
|
||||||
const { backend } = backendProvider.useBackend()
|
|
||||||
const { user, accessToken } = authProvider.useNonPartialUserSession()
|
|
||||||
const { getText } = textProvider.useText()
|
|
||||||
const [passwordFormKey, setPasswordFormKey] = React.useState('')
|
|
||||||
const [currentPassword, setCurrentPassword] = React.useState('')
|
|
||||||
const [newPassword, setNewPassword] = React.useState('')
|
|
||||||
const [confirmNewPassword, setConfirmNewPassword] = React.useState('')
|
|
||||||
|
|
||||||
// The shape of the JWT payload is statically known.
|
// The shape of the JWT payload is statically known.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
@ -124,192 +22,15 @@ export default function AccountSettingsTab() {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion
|
||||||
accessToken != null ? JSON.parse(atob(accessToken.split('.')[1]!)).username : null
|
accessToken != null ? JSON.parse(atob(accessToken.split('.')[1]!)).username : null
|
||||||
const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false
|
const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false
|
||||||
const canSubmitPassword =
|
|
||||||
currentPassword !== '' &&
|
|
||||||
newPassword !== '' &&
|
|
||||||
confirmNewPassword !== '' &&
|
|
||||||
newPassword === confirmNewPassword &&
|
|
||||||
validation.PASSWORD_REGEX.test(newPassword)
|
|
||||||
|
|
||||||
const doUpdateName = async (newName: string) => {
|
|
||||||
const oldName = user?.name ?? ''
|
|
||||||
if (newName === oldName) {
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await backend.updateUser({ username: newName })
|
|
||||||
setUser(object.merger({ name: newName }))
|
|
||||||
} catch (error) {
|
|
||||||
toastAndLog(null, error)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const doUploadUserPicture = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const image = event.target.files?.[0]
|
|
||||||
if (image == null) {
|
|
||||||
toastAndLog('noNewProfilePictureError')
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const newUser = await backend.uploadUserPicture({ fileName: image.name }, image)
|
|
||||||
setUser(newUser)
|
|
||||||
} catch (error) {
|
|
||||||
toastAndLog(null, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Reset selected files, otherwise the file input will do nothing if the same file is
|
|
||||||
// selected again. While technically not undesired behavior, it is unintuitive for the user.
|
|
||||||
event.target.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h flex-col gap-settings-section lg:h-auto lg:flex-row">
|
<div className="flex h flex-col gap-settings-section lg:h-auto lg:flex-row">
|
||||||
<div className="flex w-settings-main-section flex-col gap-settings-subsection">
|
<div className="flex w-settings-main-section flex-col gap-settings-subsection">
|
||||||
<div className="flex flex-col gap-settings-section-header">
|
<UserAccountSettingsSection />
|
||||||
<h3 className="settings-subheading">{getText('userAccount')}</h3>
|
{canChangePassword && <ChangePasswordSettingsSection />}
|
||||||
<div className="flex flex-col">
|
<DeleteUserAccountSettingsSection />
|
||||||
<div className="flex h-row gap-settings-entry">
|
|
||||||
<span className="text my-auto w-user-account-settings-label">{getText('name')}</span>
|
|
||||||
<span className="text my-auto grow font-bold">
|
|
||||||
<Input originalValue={user?.name ?? ''} onSubmit={doUpdateName} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-row gap-settings-entry">
|
|
||||||
<span className="text my-auto w-user-account-settings-label">{getText('email')}</span>
|
|
||||||
<span className="settings-value my-auto grow font-bold">{user?.email ?? ''}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{canChangePassword && (
|
|
||||||
<div key={passwordFormKey}>
|
|
||||||
<h3 className="settings-subheading">{getText('changePassword')}</h3>
|
|
||||||
<div className="flex h-row gap-settings-entry">
|
|
||||||
<span className="text my-auto w-change-password-settings-label">
|
|
||||||
{getText('currentPasswordLabel')}
|
|
||||||
</span>
|
|
||||||
<span className="text my-auto grow font-bold">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
originalValue=""
|
|
||||||
placeholder={getText('currentPasswordPlaceholder')}
|
|
||||||
onChange={event => {
|
|
||||||
setCurrentPassword(event.currentTarget.value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-row gap-settings-entry">
|
|
||||||
<span className="text my-auto w-change-password-settings-label">
|
|
||||||
{getText('newPasswordLabel')}
|
|
||||||
</span>
|
|
||||||
<span className="text my-auto grow font-bold">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
originalValue=""
|
|
||||||
placeholder={getText('newPasswordPlaceholder')}
|
|
||||||
onChange={event => {
|
|
||||||
const newValue = event.currentTarget.value
|
|
||||||
setNewPassword(newValue)
|
|
||||||
event.currentTarget.setCustomValidity(
|
|
||||||
newValue === '' || validation.PASSWORD_REGEX.test(newValue)
|
|
||||||
? ''
|
|
||||||
: getText('passwordValidationError')
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-row gap-settings-entry">
|
|
||||||
<span className="text my-auto w-change-password-settings-label">
|
|
||||||
{getText('confirmNewPasswordLabel')}
|
|
||||||
</span>
|
|
||||||
<span className="text my-auto grow font-bold">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
originalValue=""
|
|
||||||
placeholder={getText('confirmNewPasswordPlaceholder')}
|
|
||||||
onChange={event => {
|
|
||||||
const newValue = event.currentTarget.value
|
|
||||||
setConfirmNewPassword(newValue)
|
|
||||||
event.currentTarget.setCustomValidity(
|
|
||||||
newValue === '' || newValue === newPassword ? '' : 'Passwords must match.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-row items-center gap-buttons">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!canSubmitPassword}
|
|
||||||
className={`settings-value rounded-full bg-invite font-medium text-white selectable enabled:active`}
|
|
||||||
onClick={() => {
|
|
||||||
setPasswordFormKey(uniqueString.uniqueString())
|
|
||||||
setCurrentPassword('')
|
|
||||||
setNewPassword('')
|
|
||||||
setConfirmNewPassword('')
|
|
||||||
void changePassword(currentPassword, newPassword)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getText('change')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!canSubmitPassword}
|
|
||||||
className="settings-value rounded-full bg-selected-frame font-medium selectable enabled:active"
|
|
||||||
onClick={() => {
|
|
||||||
setPasswordFormKey(uniqueString.uniqueString())
|
|
||||||
setCurrentPassword('')
|
|
||||||
setNewPassword('')
|
|
||||||
setConfirmNewPassword('')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getText('cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* This UI element does not appear anywhere else. */}
|
|
||||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
|
||||||
<div className="flex flex-col items-start gap-settings-section-header rounded-2.5xl border-2 border-danger px-[1rem] pb-[0.9375rem] pt-[0.5625rem]">
|
|
||||||
<h3 className="settings-subheading text-danger">{getText('dangerZone')}</h3>
|
|
||||||
<div className="flex gap-buttons">
|
|
||||||
<button
|
|
||||||
className="button bg-danger px-delete-user-account-button-x text-inversed opacity-full hover:opacity-full"
|
|
||||||
onClick={event => {
|
|
||||||
event.stopPropagation()
|
|
||||||
setModal(
|
|
||||||
<ConfirmDeleteUserModal
|
|
||||||
doDelete={async () => {
|
|
||||||
await backend.deleteUser()
|
|
||||||
await signOut()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text inline-block">{getText('deleteUserAccountButtonLabel')}</span>
|
|
||||||
</button>
|
|
||||||
<span className="text my-auto">{getText('deleteUserAccountWarning')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-settings-section-header">
|
|
||||||
<h3 className="settings-subheading">{getText('profilePicture')}</h3>
|
|
||||||
<label className="flex h-profile-picture-large w-profile-picture-large cursor-pointer items-center overflow-clip rounded-full transition-colors hover:bg-frame">
|
|
||||||
<input type="file" className="hidden" accept="image/*" onChange={doUploadUserPicture} />
|
|
||||||
<img
|
|
||||||
src={user?.profilePicture ?? DefaultUserIcon}
|
|
||||||
width={128}
|
|
||||||
height={128}
|
|
||||||
className="pointer-events-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<span className="w-profile-picture-caption py-profile-picture-caption-y">
|
|
||||||
{getText('profilePictureWarning')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ProfilePictureSettingsSection />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -12,10 +12,15 @@ import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
|||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
import DateInput from '#/components/DateInput'
|
import DateInput from '#/components/DateInput'
|
||||||
import Dropdown from '#/components/Dropdown'
|
import Dropdown from '#/components/Dropdown'
|
||||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||||
|
import FocusArea from '#/components/styled/FocusArea'
|
||||||
|
import SettingsPage from '#/components/styled/settings/SettingsPage'
|
||||||
|
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
@ -117,10 +122,11 @@ export default function ActivityLogSettingsTab() {
|
|||||||
const isLoading = sortedLogs == null
|
const isLoading = sortedLogs == null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-settings-subsection">
|
<SettingsPage>
|
||||||
<div className="flex flex-col gap-settings-section-header">
|
<SettingsSection noFocusArea title={getText('activityLog')}>
|
||||||
<h3 className="settings-subheading">{getText('activityLog')}</h3>
|
<FocusArea direction="horizontal">
|
||||||
<div className="flex gap-activity-log-filters">
|
{innerProps => (
|
||||||
|
<div className="flex gap-activity-log-filters" {...innerProps}>
|
||||||
<div className="flex items-center gap-activity-log-filter">
|
<div className="flex items-center gap-activity-log-filter">
|
||||||
{getText('startDate')}
|
{getText('startDate')}
|
||||||
<DateInput date={startDate} onInput={setStartDate} />
|
<DateInput date={startDate} onInput={setStartDate} />
|
||||||
@ -137,7 +143,8 @@ export default function ActivityLogSettingsTab() {
|
|||||||
selectedIndices={typeIndices}
|
selectedIndices={typeIndices}
|
||||||
render={props => EVENT_TYPE_NAME[props.item]}
|
render={props => EVENT_TYPE_NAME[props.item]}
|
||||||
renderMultiple={props =>
|
renderMultiple={props =>
|
||||||
props.items.length === 0 || props.items.length === backendModule.EVENT_TYPES.length
|
props.items.length === 0 ||
|
||||||
|
props.items.length === backendModule.EVENT_TYPES.length
|
||||||
? 'All'
|
? 'All'
|
||||||
: (props.items[0] != null ? EVENT_TYPE_NAME[props.items[0]] : '') +
|
: (props.items[0] != null ? EVENT_TYPE_NAME[props.items[0]] : '') +
|
||||||
(props.items.length <= 1 ? '' : ` (+${props.items.length - 1})`)
|
(props.items.length <= 1 ? '' : ` (+${props.items.length - 1})`)
|
||||||
@ -168,13 +175,15 @@ export default function ActivityLogSettingsTab() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</FocusArea>
|
||||||
<table className="table-fixed self-start rounded-rows">
|
<table className="table-fixed self-start rounded-rows">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="h-row">
|
<tr className="h-row">
|
||||||
<th className="w-activity-log-icon-column border-x-2 border-transparent bg-clip-padding pl-cell-x pr-icon-column-r text-left text-sm font-semibold last:border-r-0" />
|
<th className="w-activity-log-icon-column border-x-2 border-transparent bg-clip-padding pl-cell-x pr-icon-column-r text-left text-sm font-semibold last:border-r-0" />
|
||||||
<th className="w-activity-log-type-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
<th className="w-activity-log-type-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||||
<button
|
<UnstyledButton
|
||||||
title={
|
aria-label={
|
||||||
sortInfo?.field !== ActivityLogSortableColumn.type
|
sortInfo?.field !== ActivityLogSortableColumn.type
|
||||||
? getText('sortByName')
|
? getText('sortByName')
|
||||||
: isDescending
|
: isDescending
|
||||||
@ -182,8 +191,7 @@ export default function ActivityLogSettingsTab() {
|
|||||||
: getText('sortByNameDescending')
|
: getText('sortByNameDescending')
|
||||||
}
|
}
|
||||||
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
|
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
event.stopPropagation()
|
|
||||||
const nextDirection =
|
const nextDirection =
|
||||||
sortInfo?.field === ActivityLogSortableColumn.type
|
sortInfo?.field === ActivityLogSortableColumn.type
|
||||||
? sorting.nextSortDirection(sortInfo.direction)
|
? sorting.nextSortDirection(sortInfo.direction)
|
||||||
@ -198,7 +206,7 @@ export default function ActivityLogSettingsTab() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-header">{getText('type')}</span>
|
<aria.Text className="text-header">{getText('type')}</aria.Text>
|
||||||
<img
|
<img
|
||||||
alt={
|
alt={
|
||||||
sortInfo?.field === ActivityLogSortableColumn.type && isDescending
|
sortInfo?.field === ActivityLogSortableColumn.type && isDescending
|
||||||
@ -216,11 +224,11 @@ export default function ActivityLogSettingsTab() {
|
|||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</th>
|
</th>
|
||||||
<th className="w-activity-log-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
<th className="w-activity-log-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||||
<button
|
<UnstyledButton
|
||||||
title={
|
aria-label={
|
||||||
sortInfo?.field !== ActivityLogSortableColumn.email
|
sortInfo?.field !== ActivityLogSortableColumn.email
|
||||||
? getText('sortByEmail')
|
? getText('sortByEmail')
|
||||||
: isDescending
|
: isDescending
|
||||||
@ -228,8 +236,7 @@ export default function ActivityLogSettingsTab() {
|
|||||||
: getText('sortByEmailDescending')
|
: getText('sortByEmailDescending')
|
||||||
}
|
}
|
||||||
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
|
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
event.stopPropagation()
|
|
||||||
const nextDirection =
|
const nextDirection =
|
||||||
sortInfo?.field === ActivityLogSortableColumn.email
|
sortInfo?.field === ActivityLogSortableColumn.email
|
||||||
? sorting.nextSortDirection(sortInfo.direction)
|
? sorting.nextSortDirection(sortInfo.direction)
|
||||||
@ -244,7 +251,7 @@ export default function ActivityLogSettingsTab() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-header">{getText('email')}</span>
|
<aria.Text className="text-header">{getText('email')}</aria.Text>
|
||||||
<img
|
<img
|
||||||
alt={
|
alt={
|
||||||
sortInfo?.field === ActivityLogSortableColumn.email && isDescending
|
sortInfo?.field === ActivityLogSortableColumn.email && isDescending
|
||||||
@ -262,11 +269,11 @@ export default function ActivityLogSettingsTab() {
|
|||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</th>
|
</th>
|
||||||
<th className="w-activity-log-timestamp-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
<th className="w-activity-log-timestamp-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||||
<button
|
<UnstyledButton
|
||||||
title={
|
aria-label={
|
||||||
sortInfo?.field !== ActivityLogSortableColumn.timestamp
|
sortInfo?.field !== ActivityLogSortableColumn.timestamp
|
||||||
? getText('sortByTimestamp')
|
? getText('sortByTimestamp')
|
||||||
: isDescending
|
: isDescending
|
||||||
@ -274,8 +281,7 @@ export default function ActivityLogSettingsTab() {
|
|||||||
: getText('sortByTimestampDescending')
|
: getText('sortByTimestampDescending')
|
||||||
}
|
}
|
||||||
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
|
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
|
||||||
onClick={event => {
|
onPress={() => {
|
||||||
event.stopPropagation()
|
|
||||||
const nextDirection =
|
const nextDirection =
|
||||||
sortInfo?.field === ActivityLogSortableColumn.timestamp
|
sortInfo?.field === ActivityLogSortableColumn.timestamp
|
||||||
? sorting.nextSortDirection(sortInfo.direction)
|
? sorting.nextSortDirection(sortInfo.direction)
|
||||||
@ -290,7 +296,7 @@ export default function ActivityLogSettingsTab() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-header">{getText('timestamp')}</span>
|
<aria.Text className="text-header">{getText('timestamp')}</aria.Text>
|
||||||
<img
|
<img
|
||||||
alt={
|
alt={
|
||||||
sortInfo?.field === ActivityLogSortableColumn.timestamp && isDescending
|
sortInfo?.field === ActivityLogSortableColumn.timestamp && isDescending
|
||||||
@ -308,7 +314,7 @@ export default function ActivityLogSettingsTab() {
|
|||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</UnstyledButton>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -346,7 +352,7 @@ export default function ActivityLogSettingsTab() {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</SettingsSection>
|
||||||
</div>
|
</SettingsPage>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,121 @@
|
|||||||
|
/** @file Settings section for changing password. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import ButtonRow from '#/components/styled/ButtonRow'
|
||||||
|
import SettingsInput from '#/components/styled/settings/SettingsInput'
|
||||||
|
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
|
import * as eventModule from '#/utilities/event'
|
||||||
|
import * as uniqueString from '#/utilities/uniqueString'
|
||||||
|
import * as validation from '#/utilities/validation'
|
||||||
|
|
||||||
|
// =====================================
|
||||||
|
// === ChangePasswordSettingsSection ===
|
||||||
|
// =====================================
|
||||||
|
|
||||||
|
/** Settings section for changing password. */
|
||||||
|
export default function ChangePasswordSettingsSection() {
|
||||||
|
const { user } = authProvider.useNonPartialUserSession()
|
||||||
|
const { changePassword } = authProvider.useAuth()
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
const [key, setKey] = React.useState('')
|
||||||
|
const [currentPassword, setCurrentPassword] = React.useState('')
|
||||||
|
const [newPassword, setNewPassword] = React.useState('')
|
||||||
|
const [confirmNewPassword, setConfirmNewPassword] = React.useState('')
|
||||||
|
|
||||||
|
const canSubmitPassword =
|
||||||
|
currentPassword !== '' &&
|
||||||
|
newPassword !== '' &&
|
||||||
|
confirmNewPassword !== '' &&
|
||||||
|
newPassword === confirmNewPassword &&
|
||||||
|
validation.PASSWORD_REGEX.test(newPassword)
|
||||||
|
const canCancel = currentPassword !== '' || newPassword !== '' || confirmNewPassword !== ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection title={getText('changePassword')}>
|
||||||
|
<aria.Form
|
||||||
|
key={key}
|
||||||
|
onSubmit={event => {
|
||||||
|
event.preventDefault()
|
||||||
|
setKey(uniqueString.uniqueString())
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmNewPassword('')
|
||||||
|
void changePassword(currentPassword, newPassword)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aria.Input hidden autoComplete="username" value={user?.email} readOnly />
|
||||||
|
<aria.TextField className="flex h-row gap-settings-entry" onChange={setCurrentPassword}>
|
||||||
|
<aria.Label className="text my-auto w-change-password-settings-label">
|
||||||
|
{getText('currentPasswordLabel')}
|
||||||
|
</aria.Label>
|
||||||
|
<SettingsInput
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder={getText('currentPasswordPlaceholder')}
|
||||||
|
/>
|
||||||
|
</aria.TextField>
|
||||||
|
<aria.TextField
|
||||||
|
className="flex h-row gap-settings-entry"
|
||||||
|
onChange={setNewPassword}
|
||||||
|
validate={value =>
|
||||||
|
value === '' || validation.PASSWORD_REGEX.test(value)
|
||||||
|
? ''
|
||||||
|
: getText('passwordValidationError')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<aria.Label className="text my-auto w-change-password-settings-label">
|
||||||
|
{getText('newPasswordLabel')}
|
||||||
|
</aria.Label>
|
||||||
|
<SettingsInput
|
||||||
|
type="password"
|
||||||
|
placeholder={getText('newPasswordPlaceholder')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</aria.TextField>
|
||||||
|
<aria.TextField
|
||||||
|
className="flex h-row gap-settings-entry"
|
||||||
|
onChange={setConfirmNewPassword}
|
||||||
|
validate={newValue =>
|
||||||
|
newValue === '' || newValue === newPassword ? '' : getText('passwordMismatchError')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<aria.Label className="text my-auto w-change-password-settings-label">
|
||||||
|
{getText('confirmNewPasswordLabel')}
|
||||||
|
</aria.Label>
|
||||||
|
<SettingsInput
|
||||||
|
type="password"
|
||||||
|
placeholder={getText('confirmNewPasswordPlaceholder')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</aria.TextField>
|
||||||
|
<ButtonRow>
|
||||||
|
<UnstyledButton
|
||||||
|
isDisabled={!canSubmitPassword}
|
||||||
|
className={`settings-value rounded-full bg-invite font-medium text-white selectable enabled:active`}
|
||||||
|
onPress={eventModule.submitForm}
|
||||||
|
>
|
||||||
|
{getText('change')}
|
||||||
|
</UnstyledButton>
|
||||||
|
<UnstyledButton
|
||||||
|
isDisabled={!canCancel}
|
||||||
|
className="settings-value rounded-full bg-selected-frame font-medium selectable enabled:active"
|
||||||
|
onPress={() => {
|
||||||
|
setKey(uniqueString.uniqueString())
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmNewPassword('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getText('cancel')}
|
||||||
|
</UnstyledButton>
|
||||||
|
</ButtonRow>
|
||||||
|
</aria.Form>
|
||||||
|
</SettingsSection>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
/** @file Settings tab for deleting the current user. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
|
import ConfirmDeleteUserModal from '#/modals/ConfirmDeleteUserModal'
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// === DeleteUserAccountSettingsSection ===
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/** Settings tab for deleting the current user. */
|
||||||
|
export default function DeleteUserAccountSettingsSection() {
|
||||||
|
const { signOut } = authProvider.useAuth()
|
||||||
|
const { setModal } = modalProvider.useSetModal()
|
||||||
|
const { backend } = backendProvider.useBackend()
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
title={<aria.Text className="text-danger">{getText('dangerZone')}</aria.Text>}
|
||||||
|
// This UI element does not appear anywhere else.
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
className="flex flex-col items-start gap-settings-section-header rounded-2.5xl border-2 border-danger px-[1rem] pb-[0.9375rem] pt-[0.5625rem]"
|
||||||
|
>
|
||||||
|
<div className="flex gap-buttons">
|
||||||
|
<UnstyledButton
|
||||||
|
className="button bg-danger px-delete-user-account-button-x text-inversed opacity-full hover:opacity-full"
|
||||||
|
onPress={() => {
|
||||||
|
setModal(
|
||||||
|
<ConfirmDeleteUserModal
|
||||||
|
doDelete={async () => {
|
||||||
|
await backend.deleteUser()
|
||||||
|
await signOut()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aria.Text className="text inline-block">
|
||||||
|
{getText('deleteUserAccountButtonLabel')}
|
||||||
|
</aria.Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
<aria.Text className="text my-auto">{getText('deleteUserAccountWarning')}</aria.Text>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
)
|
||||||
|
}
|
@ -1,193 +1,28 @@
|
|||||||
/** @file Settings tab for editing keyboard shortcuts. */
|
/** @file Settings tab for viewing and editing keyboard shortcuts. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import BlankIcon from 'enso-assets/blank.svg'
|
|
||||||
import CrossIcon from 'enso-assets/cross.svg'
|
|
||||||
import Plus2Icon from 'enso-assets/plus2.svg'
|
|
||||||
import ReloadInCircleIcon from 'enso-assets/reload_in_circle.svg'
|
|
||||||
|
|
||||||
import type * as inputBindingsModule from '#/configurations/inputBindings'
|
|
||||||
|
|
||||||
import * as refreshHooks from '#/hooks/refreshHooks'
|
import * as refreshHooks from '#/hooks/refreshHooks'
|
||||||
|
|
||||||
import * as inputBindingsManager from '#/providers/InputBindingsProvider'
|
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
|
||||||
import * as textProvider from '#/providers/TextProvider'
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
|
import KeyboardShortcutsSettingsTabBar from '#/layouts/Settings/KeyboardShortcutsSettingsTabBar'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import KeyboardShortcutsTable from '#/layouts/Settings/KeyboardShortcutsTable'
|
||||||
|
|
||||||
import CaptureKeyboardShortcutModal from '#/modals/CaptureKeyboardShortcutModal'
|
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
|
||||||
|
|
||||||
import * as object from '#/utilities/object'
|
|
||||||
|
|
||||||
// ====================================
|
// ====================================
|
||||||
// === KeyboardShortcutsSettingsTab ===
|
// === KeyboardShortcutsSettingsTab ===
|
||||||
// ====================================
|
// ====================================
|
||||||
|
|
||||||
/** Settings tab for viewing and editing account information. */
|
/** Settings tab for viewing and editing keyboard shortcuts. */
|
||||||
export default function KeyboardShortcutsSettingsTab() {
|
export default function KeyboardShortcutsSettingsTab() {
|
||||||
const inputBindings = inputBindingsManager.useInputBindings()
|
|
||||||
const { setModal } = modalProvider.useSetModal()
|
|
||||||
const { getText } = textProvider.useText()
|
const { getText } = textProvider.useText()
|
||||||
const [refresh, doRefresh] = refreshHooks.useRefresh()
|
const [refresh, doRefresh] = refreshHooks.useRefresh()
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
|
||||||
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
|
|
||||||
const allShortcuts = React.useMemo(() => {
|
|
||||||
// This is REQUIRED, in order to avoid disabling the `react-hooks/exhaustive-deps` lint.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
||||||
refresh
|
|
||||||
return new Set(Object.values(inputBindings.metadata).flatMap(value => value.bindings))
|
|
||||||
}, [inputBindings.metadata, refresh])
|
|
||||||
|
|
||||||
// This is required to prevent the table body from overlapping the table header, because
|
|
||||||
// the table header is transparent.
|
|
||||||
React.useEffect(() => {
|
|
||||||
const body = bodyRef.current
|
|
||||||
const scrollContainer = scrollContainerRef.current
|
|
||||||
if (body != null && scrollContainer != null) {
|
|
||||||
let isClipPathUpdateQueued = false
|
|
||||||
const updateClipPath = () => {
|
|
||||||
isClipPathUpdateQueued = false
|
|
||||||
body.style.clipPath = `inset(${scrollContainer.scrollTop}px 0 0 0)`
|
|
||||||
}
|
|
||||||
const onScroll = () => {
|
|
||||||
if (!isClipPathUpdateQueued) {
|
|
||||||
isClipPathUpdateQueued = true
|
|
||||||
requestAnimationFrame(updateClipPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateClipPath()
|
|
||||||
scrollContainer.addEventListener('scroll', onScroll)
|
|
||||||
return () => {
|
|
||||||
scrollContainer.removeEventListener('scroll', onScroll)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}, [/* should never change */ scrollContainerRef])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-1 flex-col gap-settings-section-header">
|
<SettingsSection noFocusArea title={getText('keyboardShortcuts')} className="w-full flex-1">
|
||||||
<h3 className="settings-subheading">{getText('keyboardShortcuts')}</h3>
|
<KeyboardShortcutsSettingsTabBar doRefresh={doRefresh} />
|
||||||
<div className="flex gap-drive-bar">
|
<KeyboardShortcutsTable refresh={refresh} doRefresh={doRefresh} />
|
||||||
<button
|
</SettingsSection>
|
||||||
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
|
||||||
onClick={event => {
|
|
||||||
event.stopPropagation()
|
|
||||||
setModal(
|
|
||||||
<ConfirmDeleteModal
|
|
||||||
actionText={getText('resetAllKeyboardShortcuts')}
|
|
||||||
actionButtonLabel={getText('resetAll')}
|
|
||||||
doDelete={() => {
|
|
||||||
for (const k in inputBindings.metadata) {
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
inputBindings.reset(k as inputBindingsModule.DashboardBindingKey)
|
|
||||||
}
|
|
||||||
doRefresh()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text whitespace-nowrap font-semibold">{getText('resetAll')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/* There is a horizontal scrollbar for some reason without `px-px`. */}
|
|
||||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
|
||||||
<div ref={scrollContainerRef} className="overflow-auto px-px">
|
|
||||||
<table className="table-fixed border-collapse rounded-rows">
|
|
||||||
<thead className="sticky top-0">
|
|
||||||
<tr className="h-row text-left text-sm font-semibold">
|
|
||||||
<th className="pr-keyboard-shortcuts-icon-column-r min-w-keyboard-shortcuts-icon-column pl-cell-x">
|
|
||||||
{/* Icon */}
|
|
||||||
</th>
|
|
||||||
<th className="min-w-keyboard-shortcuts-name-column px-cell-x">{getText('name')}</th>
|
|
||||||
<th className="px-cell-x">{getText('shortcuts')}</th>
|
|
||||||
<th className="w-full px-cell-x">{getText('description')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody ref={bodyRef}>
|
|
||||||
{object
|
|
||||||
.unsafeEntries(inputBindings.metadata)
|
|
||||||
.filter(kv => kv[1].rebindable !== false)
|
|
||||||
.map(kv => {
|
|
||||||
const [action, info] = kv
|
|
||||||
return (
|
|
||||||
<tr key={action}>
|
|
||||||
<td className="flex h-row items-center rounded-l-full bg-clip-padding pl-cell-x pr-icon-column-r">
|
|
||||||
<SvgMask
|
|
||||||
src={info.icon ?? BlankIcon}
|
|
||||||
color={info.color}
|
|
||||||
className="size-icon"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="border-l-2 border-r-2 border-transparent bg-clip-padding px-cell-x">
|
|
||||||
{info.name}
|
|
||||||
</td>
|
|
||||||
<td className="group min-w-max border-l-2 border-r-2 border-transparent bg-clip-padding px-cell-x">
|
|
||||||
{/* I don't know why this padding is needed,
|
|
||||||
* given that this is a flex container. */}
|
|
||||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
|
||||||
<div className="flex gap-buttons pr-4">
|
|
||||||
{info.bindings.map((binding, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="inline-flex shrink-0 items-center gap-keyboard-shortcuts-button"
|
|
||||||
>
|
|
||||||
<KeyboardShortcut shortcut={binding} />
|
|
||||||
<button
|
|
||||||
className="invisible group-hover:visible"
|
|
||||||
onClick={() => {
|
|
||||||
inputBindings.delete(action, binding)
|
|
||||||
doRefresh()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img src={CrossIcon} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="gap-keyboard-shortcuts-buttons flex shrink-0">
|
|
||||||
<button
|
|
||||||
className="invisible align-middle group-hover:visible"
|
|
||||||
onClick={event => {
|
|
||||||
event.stopPropagation()
|
|
||||||
setModal(
|
|
||||||
<CaptureKeyboardShortcutModal
|
|
||||||
description={`'${info.name}'`}
|
|
||||||
existingShortcuts={allShortcuts}
|
|
||||||
onSubmit={shortcut => {
|
|
||||||
inputBindings.add(action, shortcut)
|
|
||||||
doRefresh()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img className="size-plus-icon" src={Plus2Icon} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="invisible align-middle group-hover:visible"
|
|
||||||
onClick={() => {
|
|
||||||
inputBindings.reset(action)
|
|
||||||
doRefresh()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img className="size-plus-icon" src={ReloadInCircleIcon} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="cell-x rounded-r-full border-l-2 border-r-2 border-transparent bg-clip-padding">
|
|
||||||
{info.description}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,60 @@
|
|||||||
|
/** @file Button bar for managing keyboard shortcuts. */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import type * as inputBindingsModule from '#/configurations/inputBindings'
|
||||||
|
|
||||||
|
import * as inputBindingsManager from '#/providers/InputBindingsProvider'
|
||||||
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
import * as textProvider from '#/providers/TextProvider'
|
||||||
|
|
||||||
|
import * as aria from '#/components/aria'
|
||||||
|
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||||
|
import UnstyledButton from '#/components/UnstyledButton'
|
||||||
|
|
||||||
|
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||||
|
|
||||||
|
// =======================================
|
||||||
|
// === KeyboardShortcutsSettingsTabBar ===
|
||||||
|
// =======================================
|
||||||
|
|
||||||
|
/** Props for a {@link KeyboardShortcutsSettingsTabBar}. */
|
||||||
|
export interface KeyboardShortcutsSettingsTabBarProps {
|
||||||
|
readonly doRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button bar for managing keyboard shortcuts. */
|
||||||
|
export default function KeyboardShortcutsSettingsTabBar(
|
||||||
|
props: KeyboardShortcutsSettingsTabBarProps
|
||||||
|
) {
|
||||||
|
const { doRefresh } = props
|
||||||
|
const inputBindings = inputBindingsManager.useInputBindings()
|
||||||
|
const { setModal } = modalProvider.useSetModal()
|
||||||
|
const { getText } = textProvider.useText()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HorizontalMenuBar>
|
||||||
|
<UnstyledButton
|
||||||
|
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
||||||
|
onPress={() => {
|
||||||
|
setModal(
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
actionText={getText('resetAllKeyboardShortcuts')}
|
||||||
|
actionButtonLabel={getText('resetAll')}
|
||||||
|
doDelete={() => {
|
||||||
|
for (const k in inputBindings.metadata) {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
inputBindings.reset(k as inputBindingsModule.DashboardBindingKey)
|
||||||
|
}
|
||||||
|
doRefresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<aria.Text className="text whitespace-nowrap font-semibold">
|
||||||
|
{getText('resetAll')}
|
||||||
|
</aria.Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</HorizontalMenuBar>
|
||||||
|
)
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user