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:
somebody1234 2024-04-05 17:21:02 +10:00 committed by GitHub
parent 973d2c6aea
commit 9cf4847a34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
146 changed files with 6692 additions and 3913 deletions

View File

@ -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/*"
]
} }

View File

@ -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',
{ {

View File

@ -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 })
} }
// ========================== // ==========================

View File

@ -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)
}) })

View File

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -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",

View File

@ -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 = () => {

View File

@ -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, {
tailwindMerge.twMerge( className: values =>
classes, tailwindMerge.twMerge(
typeof className === 'function' ? className(values) : className classes,
) 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,
}

View File

@ -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>
) )
} }

View File

@ -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}
/>
) )
} }

View File

@ -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 {}

View File

@ -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

View File

@ -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,47 +178,49 @@ 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>
{canEditText ? ( <div className="flex flex-1 rounded-full">
<input {canEditText ? (
type={type} <Input
ref={inputRef} type={type}
autoFocus={autoFocus} ref={inputRef}
size={1} autoFocus={autoFocus}
value={text ?? ''} size={1}
placeholder={placeholder} value={text ?? ''}
className="text grow bg-transparent px-button-x" placeholder={placeholder}
onFocus={() => { className="text grow rounded-full bg-transparent px-button-x"
setIsDropdownVisible(true) onFocus={() => {
}} setIsDropdownVisible(true)
onBlur={() => { }}
window.setTimeout(() => { onBlur={() => {
setIsDropdownVisible(false) window.setTimeout(() => {
}) setIsDropdownVisible(false)
}} })
onChange={event => { }}
setIsDropdownVisible(true) onChange={event => {
setText(event.currentTarget.value === '' ? null : event.currentTarget.value) setIsDropdownVisible(true)
}} setText(event.currentTarget.value === '' ? null : event.currentTarget.value)
/> }}
) : ( />
<div ) : (
ref={element => element?.focus()} <div
tabIndex={-1} ref={element => element?.focus()}
className="text grow cursor-pointer bg-transparent px-button-x" tabIndex={-1}
onClick={() => { className="text grow cursor-pointer bg-transparent px-button-x"
setIsDropdownVisible(true) onClick={() => {
}} setIsDropdownVisible(true)
onBlur={() => { }}
requestAnimationFrame(() => { onBlur={() => {
setIsDropdownVisible(false) requestAnimationFrame(() => {
}) setIsDropdownVisible(false)
}} })
> }}
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)} >
</div> {itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
)} </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 ${

View File

@ -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>
)
}

View File

@ -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" /> >
<button {children}
type="button" <div className={`flex items-center gap-colors ${pickerClassName}`}>
className="group pointer-events-none size-radio-button rounded-full p-radio-button-dot" {backend.COLORS.map((currentColor, i) => (
style={{ backgroundColor: backend.lChColorToCssColor(currentColor) }} <ColorPickerItem key={i} color={currentColor} />
> ))}
<div className="hidden size-radio-button-dot rounded-full bg-selected-frame peer-checked:group-[]:block" /> </div>
</button> </RadioGroup>
</label>
))}
</div>
) )
} }
/** A color picker to select from a predetermined list of colors. */
export default React.forwardRef(ColorPicker)

View File

@ -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">
<div {innerProps => (
className={`relative flex flex-col rounded-default ${ <div
detect.isOnMacOS() ? 'w-context-menu-macos' : 'w-context-menu' 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"
} p-context-menu`} {...innerProps}
onClick={clickEvent => { >
clickEvent.stopPropagation() <div
}} aria-label={props['aria-label']}
> className={`relative flex flex-col rounded-default ${
{children} detect.isOnMacOS() ? 'w-context-menu-macos' : 'w-context-menu'
</div> } p-context-menu`}
</div> >
{children}
</div>
</div>
)}
</FocusArea>
) )
} }

View File

@ -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>
)
}

View File

@ -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,67 +33,80 @@ 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, {
onChange?.(event) className:
setValue(event.target.value) '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',
setWasJustBlurred(false) onKeyDown: event => {
if (validate) { if (!event.isPropagationStopped()) {
if (reportTimeoutHandle != null) { onKeyDown?.(event)
window.clearTimeout(reportTimeoutHandle)
}
const currentTarget = event.currentTarget
if (error != null) {
currentTarget.setCustomValidity('')
currentTarget.setCustomValidity(
currentTarget.checkValidity() || shouldReportValidityRef?.current === false
? ''
: error
)
}
if (hasReportedValidity) {
if (shouldReportValidityRef?.current === false || currentTarget.checkValidity()) {
setHasReportedValidity(false)
} }
} else { },
setReportTimeoutHandle( onChange: event => {
window.setTimeout(() => { onChange?.(event)
if (shouldReportValidityRef?.current !== false && !currentTarget.reportValidity()) { setValue(event.target.value)
setHasReportedValidity(true) setWasJustBlurred(false)
if (validate) {
if (reportTimeoutHandle != null) {
window.clearTimeout(reportTimeoutHandle)
}
const currentTarget = event.currentTarget
if (error != null) {
currentTarget.setCustomValidity('')
currentTarget.setCustomValidity(
currentTarget.checkValidity() || shouldReportValidityRef?.current === false
? ''
: error
)
}
if (hasReportedValidity) {
if (shouldReportValidityRef?.current === false || currentTarget.checkValidity()) {
setHasReportedValidity(false)
} }
}, DEBOUNCE_MS)
)
}
}
}}
onBlur={
validate
? event => {
onBlur?.(event)
if (wasJustBlurred) {
setHasReportedValidity(false)
} else { } else {
const currentTarget = event.currentTarget setReportTimeoutHandle(
if (shouldReportValidityRef?.current !== false) { window.setTimeout(() => {
if (!currentTarget.reportValidity()) { if (
event.preventDefault() shouldReportValidityRef?.current !== false &&
} !currentTarget.reportValidity()
} ) {
setWasJustBlurred(true) setHasReportedValidity(true)
}
}, DEBOUNCE_MS)
)
} }
} }
: onBlur },
} onBlur: validate
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" ? event => {
/> onBlur?.(event)
if (wasJustBlurred) {
setHasReportedValidity(false)
} else {
const currentTarget = event.currentTarget
if (shouldReportValidityRef?.current !== false) {
if (!currentTarget.reportValidity()) {
event.preventDefault()
}
}
setWasJustBlurred(true)
}
}
: onBlur,
})}
/>
</FocusRing>
) )
} }

View File

@ -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,80 +100,92 @@ export default function DateInput(props: DateInputProps) {
event.stopPropagation() event.stopPropagation()
}} }}
> >
<div <FocusRing>
role="button" <div
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' : ''}`} {...aria.mergeProps<JSX.IntrinsicElements['div']>()(focusChildProps, {
onClick={() => { role: 'button',
setIsPickerVisible(!isPickerVisible) 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 => {
<div className="flex grow flex-col items-center"> event.stopPropagation()
{date != null ? dateTime.formatDate(date) : 'No date selected'} setIsPickerVisible(!isPickerVisible)
},
onKeyDown: event => {
if (event.key === 'Enter' || event.key === 'Space') {
event.stopPropagation()
setIsPickerVisible(!isPickerVisible)
}
},
})}
>
<div className="flex grow flex-col items-center">
{date != null ? dateTime.formatDate(date) : getText('noDateSelected')}
</div>
{date != null && (
<UnstyledButton
className="flex rounded-full transition-colors hover:bg-hover-bg"
onPress={() => {
onInput(null)
}}
>
<SvgMask src={CrossIcon} className="size-icon" />
</UnstyledButton>
)}
</div> </div>
{date != null && ( </FocusRing>
<button
className="flex rounded-full transition-colors hover:bg-hover-bg"
onClick={() => {
onInput(null)
}}
>
<SvgMask src={CrossIcon} className="size-icon" />
</button>
)}
</div>
{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"> <UnstyledButton
<button className="inline-flex rounded-small-rectangle-button hover:bg-hover-bg"
className="inline-flex rounded-small-rectangle-button hover:bg-hover-bg" onPress={() => {
onClick={() => { setSelectedYear(selectedYear - 1)
}}
>
<SvgMask src={FolderArrowDoubleIcon} className="rotate-180" />
</UnstyledButton>
<UnstyledButton
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
onPress={() => {
if (selectedMonthIndex === 0) {
setSelectedYear(selectedYear - 1) setSelectedYear(selectedYear - 1)
}} setSelectedMonthIndex(LAST_MONTH_INDEX)
> } else {
<SvgMask src={FolderArrowDoubleIcon} className="rotate-180" /> setSelectedMonthIndex(selectedMonthIndex - 1)
</button> }
<button }}
className="inline-flex rounded-small-rectangle-button hover:bg-black/10" >
onClick={() => { <SvgMask src={FolderArrowIcon} className="rotate-180" />
if (selectedMonthIndex === 0) { </UnstyledButton>
setSelectedYear(selectedYear - 1) <aria.Text className="grow text-center">
setSelectedMonthIndex(LAST_MONTH_INDEX) {dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear}
} else { </aria.Text>
setSelectedMonthIndex(selectedMonthIndex - 1) <UnstyledButton
} className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
}} onPress={() => {
> if (selectedMonthIndex === LAST_MONTH_INDEX) {
<SvgMask src={FolderArrowIcon} className="rotate-180" />
</button>
<span className="grow">
{dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear}
</span>
<button
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
onClick={() => {
if (selectedMonthIndex === LAST_MONTH_INDEX) {
setSelectedYear(selectedYear + 1)
setSelectedMonthIndex(0)
} else {
setSelectedMonthIndex(selectedMonthIndex + 1)
}
}}
>
<SvgMask src={FolderArrowIcon} />
</button>
<button
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
onClick={() => {
setSelectedYear(selectedYear + 1) setSelectedYear(selectedYear + 1)
}} setSelectedMonthIndex(0)
> } else {
<SvgMask src={FolderArrowDoubleIcon} /> setSelectedMonthIndex(selectedMonthIndex + 1)
</button> }
</div> }}
</caption> >
<SvgMask src={FolderArrowIcon} />
</UnstyledButton>
<UnstyledButton
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
onPress={() => {
setSelectedYear(selectedYear + 1)
}}
>
<SvgMask src={FolderArrowDoubleIcon} />
</UnstyledButton>
</div>
</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={() => {
setIsPickerVisible(false)
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'}`} 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)
onInput(currentDate)
}}
> >
{day.date} {day.date}
</button> </UnstyledButton>
</td> </td>
) )
})} })}

View File

@ -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,153 +155,169 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
} }
return ( return (
<div <FocusRing placement="outset">
ref={rootRef}
tabIndex={0}
className={`group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy ${
className ?? ''
}`}
onFocus={event => {
if (!readOnly && event.target === event.currentTarget) {
setIsDropdownVisible(true)
justFocusedRef.current = true
}
}}
onBlur={event => {
// TODO: should not blur when `multiple` and clicking on option
if (!readOnly && event.target === event.currentTarget) {
setIsDropdownVisible(false)
}
}}
onKeyDown={onKeyDown}
onKeyUp={() => {
justFocusedRef.current = false
}}
onClick={event => {
event.stopPropagation()
}}
>
<div <div
className={`absolute left-0 h-full w-full min-w-max ${isDropdownVisible ? 'z-1' : 'overflow-hidden'}`} ref={element => {
if (typeof ref === 'function') {
ref(element)
} else if (ref != null) {
ref.current = element
}
rootRef.current = element
}}
tabIndex={0}
className={`focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy ${
className ?? ''
}`}
onFocus={event => {
if (!justBlurredRef.current && !readOnly && event.target === event.currentTarget) {
setIsDropdownVisible(true)
justFocusedRef.current = true
}
justBlurredRef.current = false
}}
onBlur={event => {
if (!readOnly && event.target === event.currentTarget) {
setIsDropdownVisible(false)
justBlurredRef.current = true
}
}}
onKeyDown={onKeyDown}
onKeyUp={() => {
justFocusedRef.current = false
}}
onClick={event => {
event.stopPropagation()
}}
> >
<div <div
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={`absolute left-0 h-full w-full min-w-max ${isDropdownVisible ? 'z-1' : 'overflow-hidden'}`}
isDropdownVisible
? 'before:h-full before:shadow-soft'
: 'before:h-text group-hover:before:bg-frame'
}`}
> >
{/* Spacing. */}
<div <div
className="padding relative h-text" 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 ${
onClick={event => { isDropdownVisible
event.stopPropagation() ? 'before:h-full before:shadow-soft'
if (!justFocusedRef.current && !readOnly) { : 'before:h-text group-hover:before:bg-hover-bg'
setIsDropdownVisible(false)
}
justFocusedRef.current = false
}}
/>
<div
className={`relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows ${
isDropdownVisible ? 'grid-rows-1fr' : 'grid-rows-0fr'
}`} }`}
> >
<div className="overflow-hidden"> {/* Spacing. */}
{items.map((item, i) => ( <div
<div className="padding relative h-text"
tabIndex={-1} onClick={event => {
className={`flex h-text items-center gap-dropdown-arrow rounded-input px-input-x transition-colors ${ event.stopPropagation()
multiple ? 'hover:font-semibold' : '' if (!justFocusedRef.current && !readOnly) {
} ${ setIsDropdownVisible(false)
i === visuallySelectedIndex }
? `cursor-default bg-frame font-bold` justFocusedRef.current = false
: 'hover:bg-primary/10' }}
}`} />
key={i} <div
onMouseDown={event => { className={`relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows ${
event.preventDefault() isDropdownVisible ? 'grid-rows-1fr' : 'grid-rows-0fr'
isMouseDown.current = true }`}
}} >
onMouseUp={() => { <div className="overflow-hidden">
isMouseDown.current = false {items.map((item, i) => (
}} <div
onClick={() => { tabIndex={-1}
if (i !== visuallySelectedIndex) { className={`flex h-text items-center gap-dropdown-arrow rounded-input px-input-x transition-colors ${
if (multiple) { multiple ? 'hover:font-semibold' : ''
const newIndices = selectedIndices.includes(i) } ${
? selectedIndices.filter(index => index !== i) i === visuallySelectedIndex
: [...selectedIndices, i] ? `cursor-default bg-frame font-bold focus-ring`
props.onClick( : 'hover:bg-hover-bg'
newIndices.flatMap(index => { }`}
const otherItem = items[index] key={i}
return otherItem != null ? [otherItem] : [] onMouseDown={event => {
}), event.preventDefault()
newIndices isMouseDown.current = true
) }}
rootRef.current?.focus() onMouseUp={() => {
} else { isMouseDown.current = false
setIsDropdownVisible(false) }}
props.onClick(item, i) onClick={() => {
if (i !== visuallySelectedIndex) {
if (multiple) {
const newIndices = selectedIndices.includes(i)
? selectedIndices.filter(index => index !== i)
: [...selectedIndices, i]
props.onClick(
newIndices.flatMap(index => {
const otherItem = items[index]
return otherItem != null ? [otherItem] : []
}),
newIndices
)
rootRef.current?.focus()
} else {
setIsDropdownVisible(false)
props.onClick(item, i)
justBlurredRef.current = true
}
} }
} }}
}} onFocus={() => {
onFocus={() => { if (!isMouseDown.current) {
if (!isMouseDown.current) { // This is from keyboard navigation.
// This is from keyboard navigation. if (multiple) {
if (multiple) { props.onClick([item], [i])
props.onClick([item], [i]) } else {
} else { props.onClick(item, i)
props.onClick(item, i) }
} }
} }}
}} >
> <SvgMask
<SvgMask src={CheckMarkIcon}
src={CheckMarkIcon} className={selectedIndices.includes(i) ? '' : 'invisible'}
className={selectedIndices.includes(i) ? '' : 'invisible'} />
/> <Child item={item} />
<Child item={item} /> </div>
</div> ))}
))} </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div
<div className={`relative flex h-text items-center gap-dropdown-arrow px-input-x ${isDropdownVisible ? 'z-1' : ''} ${
className={`relative flex h-text items-center gap-dropdown-arrow px-input-x ${isDropdownVisible ? 'z-1' : ''} ${ readOnly ? 'read-only' : ''
readOnly ? 'read-only' : '' }`}
}`} onClick={event => {
onClick={event => { 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
}} }}
> >
<SvgMask src={FolderArrowIcon} className="rotate-90" /> <SvgMask src={FolderArrowIcon} className="rotate-90" />
<div className="grow"> <div className="grow">
{visuallySelectedItem != null ? ( {visuallySelectedItem != null ? (
<Child item={visuallySelectedItem} /> <Child item={visuallySelectedItem} />
) : ( ) : (
multiple && <props.renderMultiple items={selectedItems} render={Child} /> multiple && <props.renderMultiple items={selectedItems} render={Child} />
)} )}
</div>
</div>
{/* Hidden, but required to exist for the width of the parent element to be correct.
* Classes that do not affect width have been removed. */}
<div className="flex h flex-col overflow-hidden">
{items.map((item, i) => (
<div key={i} className="flex gap-dropdown-arrow px-input-x font-bold">
<SvgMask src={CheckMarkIcon} />
<Child item={item} />
</div>
))}
</div> </div>
</div> </div>
{/* Hidden, but required to exist for the width of the parent element to be correct. </FocusRing>
* Classes that do not affect width have been removed. */}
<div className="flex h flex-col overflow-hidden">
{items.map((item, i) => (
<div
key={i}
className={`flex gap-dropdown-arrow px-input-x ${i === visuallySelectedIndex ? 'font-bold' : ''}`}
>
<SvgMask src={CheckMarkIcon} />
<Child item={item} />
</div>
))}
</div>
</div>
) )
} }
/** 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

View File

@ -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
}} onCancel()
onClick={event => { window.setTimeout(() => {
event.stopPropagation() cancelledRef.current = false
onCancel() })
window.setTimeout(() => { }}
cancelled.current = false >
}) <SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-icon" />
}} </UnstyledButton>
> </FocusRing>
<SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-icon" />
</button>
</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>
) )
} }
} }

View File

@ -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,74 +100,91 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
) )
} else { } else {
children.push( children.push(
<input <FocusArea direction="horizontal">
type="text" {innerProps => (
readOnly={readOnly} <FocusRing>
value={typeof value === 'string' ? value : ''} <aria.Input
size={1} type="text"
className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${ readOnly={readOnly}
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60' value={typeof value === 'string' ? value : ''}
}`} size={1}
placeholder={getText('enterText')} className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
onChange={event => { getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
const newValue: string = event.currentTarget.value }`}
setValue(newValue) placeholder={getText('enterText')}
}} onChange={event => {
/> const newValue: string = event.currentTarget.value
setValue(newValue)
}}
{...innerProps}
/>
</FocusRing>
)}
</FocusArea>
) )
} }
break break
} }
case 'number': { case 'number': {
children.push( children.push(
<input <FocusArea direction="horizontal">
type="number" {innerProps => (
readOnly={readOnly} <FocusRing>
value={typeof value === 'number' ? value : ''} <aria.Input
size={1} type="number"
className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${ readOnly={readOnly}
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60' value={typeof value === 'number' ? value : ''}
}`} size={1}
placeholder={getText('enterNumber')} className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
onChange={event => { getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
const newValue: number = event.currentTarget.valueAsNumber }`}
if (Number.isFinite(newValue)) { placeholder={getText('enterNumber')}
setValue(newValue) onChange={event => {
} const newValue: number = event.currentTarget.valueAsNumber
}} if (Number.isFinite(newValue)) {
/> setValue(newValue)
}
}}
{...innerProps}
/>
</FocusRing>
)}
</FocusArea>
) )
break break
} }
case 'integer': { case 'integer': {
children.push( children.push(
<input <FocusArea direction="horizontal">
type="number" {innerProps => (
readOnly={readOnly} <FocusRing>
value={typeof value === 'number' ? value : ''} <aria.Input
size={1} type="number"
className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${ readOnly={readOnly}
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60' value={typeof value === 'number' ? value : ''}
}`} size={1}
placeholder={getText('enterInteger')} className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
onChange={event => { getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
const newValue: number = Math.floor(event.currentTarget.valueAsNumber) }`}
setValue(newValue) placeholder={getText('enterInteger')}
}} onChange={event => {
/> const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
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,38 +216,46 @@ 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' : '' }`}
}`} onPress={() => {
onClick={() => { if (isOptional) {
if (isOptional) { setValue(oldValue => {
setValue(oldValue => { if (oldValue != null && key in oldValue) {
if (oldValue != null && key in oldValue) { // This is SAFE, as `value` is an untyped object.
// This is SAFE, as `value` is an untyped object. // The removed key is intentionally unused.
// The removed key is intentionally unused. // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars const { [key]: removed, ...newValue } = oldValue as Record<
const { [key]: removed, ...newValue } = oldValue as Record< string,
string, NonNullable<unknown> | null
NonNullable<unknown> | null >
> return newValue
return newValue } else {
} else { return {
return { ...oldValue,
...oldValue, [key]: jsonSchema.constantValue(defs, childSchema, true)[0],
[key]: jsonSchema.constantValue(defs, childSchema, true)[0], }
} }
})
} }
}) }}
} {...innerProps}
}} >
> <aria.Text
{'title' in childSchema ? String(childSchema.title) : key} className={`selectable ${
</button> value != null && key in value ? 'active' : ''
}`}
>
{'title' in childSchema ? String(childSchema.title) : key}
</aria.Text>
</UnstyledButton>
)}
</FocusArea>
{value != null && key in value && ( {value != null && key in value && (
<JSONSchemaInput <JSONSchemaInput
readOnly={readOnly} readOnly={readOnly}
@ -293,18 +323,23 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
} }
} }
const dropdown = ( const dropdown = (
<Dropdown <FocusArea direction="horizontal">
readOnly={readOnly} {innerProps => (
items={childSchemas} <Dropdown
selectedIndex={selectedChildIndex} readOnly={readOnly}
render={childProps => jsonSchema.getSchemaName(defs, childProps.item)} items={childSchemas}
className="self-start" selectedIndex={selectedChildIndex}
onClick={(childSchema, index) => { render={childProps => jsonSchema.getSchemaName(defs, childProps.item)}
setSelectedChildIndex(index) className="self-start"
const newConstantValue = jsonSchema.constantValue(defs, childSchema, true) onClick={(childSchema, index) => {
setValue(newConstantValue[0] ?? null) setSelectedChildIndex(index)
}} const newConstantValue = jsonSchema.constantValue(defs, childSchema, true)
/> 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' : ''}`}>

View File

@ -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 (
<router.Link <FocusRing>
to={to} <router.Link
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" {...aria.mergeProps<router.LinkProps>()(focusChildProps, {
> to,
<SvgMask src={icon} /> className:
{text} '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',
</router.Link> })}
>
<SvgMask src={icon} />
{text}
</router.Link>
</FocusRing>
) )
} }

View File

@ -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 ${
isContextMenuEntry ? 'px-context-menu-entry-x' : ''
}`}
onClick={event => {
event.stopPropagation()
doAction()
}}
> >
<div className="flex items-center gap-menu-entry whitespace-nowrap"> <div
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" /> 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 ${
{label ?? getText(ACTION_TO_TEXT_ID[action])} isContextMenuEntry ? 'px-context-menu-entry-x' : ''
}`}
>
<div title={title} className="flex items-center gap-menu-entry whitespace-nowrap">
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" />
<aria.Text slot="label">{label ?? getText(ACTION_TO_TEXT_ID[action])}</aria.Text>
</div>
<KeyboardShortcut action={action} />
</div> </div>
<KeyboardShortcut action={action} /> </UnstyledButton>
</button>
) )
} }

View File

@ -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,26 +31,37 @@ export default function Modal(props: ModalProps) {
const { unsetModal } = modalProvider.useSetModal() const { unsetModal } = modalProvider.useSetModal()
return ( return (
<div <FocusRoot active={!hidden}>
// The name comes from a third-party API and cannot be changed. {innerProps => (
// eslint-disable-next-line @typescript-eslint/naming-convention <div
{...(!hidden ? { 'data-testid': 'modal-background' } : {})} // The name comes from a third-party API and cannot be changed.
style={style} // eslint-disable-next-line @typescript-eslint/naming-convention
className={`inset z-1 ${centered ? 'size-screen fixed grid place-items-center' : ''} ${ {...(!hidden ? { 'data-testid': 'modal-background' } : {})}
className ?? '' style={style}
}`} className={`inset z-1 ${centered ? 'size-screen fixed grid place-items-center' : ''} ${
onClick={ className ?? ''
onClick ?? }`}
(event => { onClick={
if (event.currentTarget === event.target && getSelection()?.type !== 'Range') { onClick ??
event.stopPropagation() (event => {
unsetModal() if (event.currentTarget === event.target && getSelection()?.type !== 'Range') {
event.stopPropagation()
unsetModal()
}
})
} }
}) onContextMenu={onContextMenu}
} {...innerProps}
onContextMenu={onContextMenu} onKeyDown={event => {
> innerProps.onKeyDown?.(event)
{children} if (event.key !== 'Escape') {
</div> event.stopPropagation()
}
}}
>
{children}
</div>
)}
</FocusRoot>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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])

View File

@ -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>
) )
} }

View File

@ -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' : ''} ${

View 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>
)
}

View File

@ -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)

View 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)
}

View File

@ -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 (
<div <FocusArea active={!invisible} direction="horizontal">
className={`pointer-events-auto flex h-row shrink-0 cursor-default items-center gap-icons rounded-full bg-frame px-icons-x ${ {innerProps => (
backend.type === backendModule.BackendType.remote ? '' : 'invisible' <div
}`} className={`pointer-events-auto flex h-row shrink-0 cursor-default items-center gap-icons rounded-full bg-frame px-icons-x ${
onClick={event => { backend.type === backendModule.BackendType.remote ? '' : 'invisible'
event.stopPropagation() }`}
}} {...innerProps}
> >
<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>
) )
} }

View File

@ -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,168 +675,175 @@ export default function AssetRow(props: AssetRowProps) {
return ( return (
<> <>
{!hidden && ( {!hidden && (
<tr <FocusRing>
draggable <tr
tabIndex={-1} draggable
ref={element => { tabIndex={0}
if (isSoleSelected && element != null && scrollContainerRef.current != null) { ref={element => {
const rect = element.getBoundingClientRect() rootRef.current = element
const scrollRect = scrollContainerRef.current.getBoundingClientRect() if (isSoleSelected && element != null && scrollContainerRef.current != null) {
const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX) const rect = element.getBoundingClientRect()
const scrollDown = rect.bottom - scrollRect.bottom const scrollRect = scrollContainerRef.current.getBoundingClientRect()
if (scrollUp < 0 || scrollDown > 0) { const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX)
scrollContainerRef.current.scrollBy({ const scrollDown = rect.bottom - scrollRect.bottom
top: scrollUp < 0 ? scrollUp : scrollDown, if (scrollUp < 0 || scrollDown > 0) {
behavior: 'smooth', scrollContainerRef.current.scrollBy({
top: scrollUp < 0 ? scrollUp : scrollDown,
behavior: 'smooth',
})
}
}
if (isKeyboardSelected && element?.contains(document.activeElement) === false) {
element.focus()
}
}}
className={`h-row rounded-full transition-all ease-in-out ${visibility} ${isDraggedOver || selected ? 'selected' : ''}`}
onClick={event => {
unsetModal()
onClick(innerProps, event)
if (
asset.type === backendModule.AssetType.directory &&
eventModule.isDoubleClick(event) &&
!rowState.isEditingName
) {
// This must be processed on the next tick, otherwise it will be overridden
// by the default click handler.
window.setTimeout(() => {
setSelected(false)
})
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
}
}}
onContextMenu={event => {
if (allowContextMenu) {
event.preventDefault()
event.stopPropagation()
onContextMenu?.(innerProps, event)
setModal(
<AssetContextMenu
innerProps={innerProps}
event={event}
eventTarget={
event.target instanceof HTMLElement ? event.target : event.currentTarget
}
doCopy={doCopy}
doCut={doCut}
doPaste={doPaste}
doDelete={doDelete}
doTriggerDescriptionEdit={doTriggerDescriptionEdit}
/>
)
} else {
onContextMenu?.(innerProps, event)
}
}}
onDragStart={event => {
if (rowState.isEditingName) {
event.preventDefault()
} else {
props.onDragStart?.(event)
}
}}
onDragEnter={event => {
if (dragOverTimeoutHandle.current != null) {
window.clearTimeout(dragOverTimeoutHandle.current)
}
if (backendModule.assetIsDirectory(asset)) {
dragOverTimeoutHandle.current = window.setTimeout(() => {
doToggleDirectoryExpansion(asset.id, item.key, asset.title, true)
}, DRAG_EXPAND_DELAY_MS)
}
// Required because `dragover` does not fire on `mouseenter`.
props.onDragOver?.(event)
onDragOver(event)
}}
onDragOver={event => {
props.onDragOver?.(event)
onDragOver(event)
}}
onDragEnd={event => {
clearDragState()
props.onDragEnd?.(event)
}}
onDragLeave={event => {
if (
dragOverTimeoutHandle.current != null &&
(!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget))
) {
window.clearTimeout(dragOverTimeoutHandle.current)
}
if (
event.relatedTarget instanceof Node &&
!event.currentTarget.contains(event.relatedTarget)
) {
clearDragState()
}
props.onDragLeave?.(event)
}}
onDrop={event => {
props.onDrop?.(event)
clearDragState()
const [directoryKey, directoryId, directoryTitle] =
item.item.type === backendModule.AssetType.directory
? [item.key, item.item.id, asset.title]
: [item.directoryKey, item.directoryId, null]
const payload = drag.ASSET_ROWS.lookup(event)
if (
payload != null &&
payload.every(innerItem => innerItem.key !== directoryKey)
) {
event.preventDefault()
event.stopPropagation()
unsetModal()
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
const ids = payload
.filter(payloadItem => payloadItem.asset.parentId !== directoryId)
.map(dragItem => dragItem.key)
dispatchAssetEvent({
type: AssetEventType.move,
newParentKey: directoryKey,
newParentId: directoryId,
ids: new Set(ids),
})
} else if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
// This is SAFE, as it is guarded by the condition above:
// `item.item.type === backendModule.AssetType.directory`
// eslint-disable-next-line no-restricted-syntax
parentKey: directoryKey as backendModule.DirectoryId,
parentId: directoryId,
files: Array.from(event.dataTransfer.files),
}) })
} }
} }}
}} >
className={`h-row rounded-full outline-2 -outline-offset-2 outline-primary ease-in-out ${visibility} ${ {columns.map(column => {
isKeyboardSelected ? 'outline' : '' // This is a React component even though it does not contain JSX.
} ${isDraggedOver || selected ? 'selected' : ''}`} // eslint-disable-next-line no-restricted-syntax
onClick={event => { const Render = columnModule.COLUMN_RENDERER[column]
unsetModal() return (
onClick(innerProps, event) <td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
if ( <Render
asset.type === backendModule.AssetType.directory && keyProp={key}
eventModule.isDoubleClick(event) && item={item}
!rowState.isEditingName setItem={setItem}
) { selected={selected}
// This must be processed on the next tick, otherwise it will be overridden setSelected={setSelected}
// by the default click handler. isSoleSelected={isSoleSelected}
window.setTimeout(() => { state={state}
setSelected(false) rowState={rowState}
}) setRowState={setRowState}
doToggleDirectoryExpansion(asset.id, item.key, asset.title) />
} </td>
}}
onContextMenu={event => {
if (allowContextMenu) {
event.preventDefault()
event.stopPropagation()
onContextMenu?.(innerProps, event)
setModal(
<AssetContextMenu
innerProps={innerProps}
event={event}
eventTarget={
event.target instanceof HTMLElement ? event.target : event.currentTarget
}
doCopy={doCopy}
doCut={doCut}
doPaste={doPaste}
doDelete={doDelete}
doTriggerDescriptionEdit={doTriggerDescriptionEdit}
/>
) )
} else { })}
onContextMenu?.(innerProps, event) </tr>
} </FocusRing>
}}
onDragStart={event => {
if (rowState.isEditingName) {
event.preventDefault()
} else {
props.onDragStart?.(event)
}
}}
onDragEnter={event => {
if (dragOverTimeoutHandle.current != null) {
window.clearTimeout(dragOverTimeoutHandle.current)
}
if (backendModule.assetIsDirectory(asset)) {
dragOverTimeoutHandle.current = window.setTimeout(() => {
doToggleDirectoryExpansion(asset.id, item.key, asset.title, true)
}, DRAG_EXPAND_DELAY_MS)
}
// Required because `dragover` does not fire on `mouseenter`.
props.onDragOver?.(event)
onDragOver(event)
}}
onDragOver={event => {
props.onDragOver?.(event)
onDragOver(event)
}}
onDragEnd={event => {
clearDragState()
props.onDragEnd?.(event)
}}
onDragLeave={event => {
if (
dragOverTimeoutHandle.current != null &&
(!(event.relatedTarget instanceof Node) ||
!event.currentTarget.contains(event.relatedTarget))
) {
window.clearTimeout(dragOverTimeoutHandle.current)
}
if (
event.relatedTarget instanceof Node &&
!event.currentTarget.contains(event.relatedTarget)
) {
clearDragState()
}
props.onDragLeave?.(event)
}}
onDrop={event => {
props.onDrop?.(event)
clearDragState()
const [directoryKey, directoryId, directoryTitle] =
item.item.type === backendModule.AssetType.directory
? [item.key, item.item.id, asset.title]
: [item.directoryKey, item.directoryId, null]
const payload = drag.ASSET_ROWS.lookup(event)
if (payload != null && payload.every(innerItem => innerItem.key !== directoryKey)) {
event.preventDefault()
event.stopPropagation()
unsetModal()
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
const ids = payload
.filter(payloadItem => payloadItem.asset.parentId !== directoryId)
.map(dragItem => dragItem.key)
dispatchAssetEvent({
type: AssetEventType.move,
newParentKey: directoryKey,
newParentId: directoryId,
ids: new Set(ids),
})
} else if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
// This is SAFE, as it is guarded by the condition above:
// `item.item.type === backendModule.AssetType.directory`
// eslint-disable-next-line no-restricted-syntax
parentKey: directoryKey as backendModule.DirectoryId,
parentId: directoryId,
files: Array.from(event.dataTransfer.files),
})
}
}}
>
{columns.map(column => {
// This is a React component even though it does not contain JSX.
// eslint-disable-next-line no-restricted-syntax
const Render = columnModule.COLUMN_RENDERER[column]
return (
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
<Render
keyProp={key}
item={item}
setItem={setItem}
selected={selected}
setSelected={setSelected}
isSoleSelected={isSoleSelected}
state={state}
rowState={rowState}
setRowState={setRowState}
/>
</td>
)
})}
</tr>
)} )}
{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>

View File

@ -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>
) )

View File

@ -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,

View File

@ -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,

View File

@ -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>
) )
} }
} }

View File

@ -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 (
<button <FocusRing within placement="after">
data-testid={dataTestId} <div
disabled={disabled} className={`relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-inherit ${negated ? 'after:!outline-offset-0' : ''}`}
className={`selectable ${ >
active ? 'active' : '' {/* An `aria.Button` MUST NOT be used here, as it breaks dragging. */}
} relative flex h-text items-center whitespace-nowrap rounded-full px-label-x transition-all before:absolute before:inset before:rounded-full ${ {/* eslint-disable-next-line no-restricted-syntax */}
negated ? 'before:border-2 before:border-delete' : '' <button
} ${className} ${textColorClassName}`} type="button"
style={{ backgroundColor: backend.lChColorToCssColor(color) }} data-testid={props['data-testid']}
{...passthrough} draggable={draggable}
> title={title}
{children} disabled={isDisabled}
</button> className={`focus-child selectable ${
active ? 'active' : ''
} 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 ? 'after:border-2 after:border-delete' : ''
} ${className} ${textClass}`}
style={{ backgroundColor: backend.lChColorToCssColor(color) }}
onClick={event => {
event.stopPropagation()
onPress(event)
}}
onDragStart={e => {
onDragStart?.(e)
}}
onContextMenu={onContextMenu}
onKeyDown={handleFocusMove}
>
{children}
</button>
</div>
</FocusRing>
) )
} }

View File

@ -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>
) )
} }
} }

View File

@ -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,64 +68,66 @@ 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 originalLeft = position.left + window.scrollX const position = permissionSelectorButtonRef.current.getBoundingClientRect()
const originalTop = position.top + window.scrollY const originalLeft = position.left + window.scrollX
const left = originalLeft + TYPE_SELECTOR_X_OFFSET_PX const originalTop = position.top + window.scrollY
const top = originalTop + (typeSelectorYOffsetPx ?? TYPE_SELECTOR_Y_OFFSET_PX) const left = originalLeft + TYPE_SELECTOR_X_OFFSET_PX
// The border radius of the label. This is half of the label's height. const top = originalTop + (typeSelectorYOffsetPx ?? TYPE_SELECTOR_Y_OFFSET_PX)
const r = LABEL_BORDER_RADIUS_PX // The border radius of the label. This is half of the label's height.
const clipPath = const r = LABEL_BORDER_RADIUS_PX
// A rectangle covering the entire screen const clipPath =
'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' + // A rectangle covering the entire screen
// Move to top left of label 'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' +
`M${originalLeft + LABEL_BORDER_RADIUS_PX} ${originalTop + LABEL_CLIP_Y_OFFSET_PX}` + // Move to top left of label
// Top straight edge of label `M${originalLeft + LABEL_BORDER_RADIUS_PX} ${originalTop + LABEL_CLIP_Y_OFFSET_PX}` +
`h${LABEL_STRAIGHT_WIDTH_PX}` + // Top straight edge of label
// Right semicircle of label `h${LABEL_STRAIGHT_WIDTH_PX}` +
`a${r} ${r} 0 0 1 0 ${r * 2}` + // Right semicircle of label
// Bottom straight edge of label `a${r} ${r} 0 0 1 0 ${r * 2}` +
`h-${LABEL_STRAIGHT_WIDTH_PX}` + // Bottom straight edge of label
// Left semicircle of label `h-${LABEL_STRAIGHT_WIDTH_PX}` +
`a${r} ${r} 0 0 1 0 -${r * 2}Z")` // Left semicircle of label
setTheChild(oldTheChild => `a${r} ${r} 0 0 1 0 -${r * 2}Z")`
oldTheChild != null setTheChild(oldTheChild =>
? null oldTheChild != null
: function Child() { ? null
return ( : function Child() {
<Modal return (
className="fixed size-full overflow-auto" <Modal
onClick={() => { className="fixed size-full overflow-auto"
setTheChild(null) onClick={() => {
}}
>
<div style={{ clipPath }} className="absolute size-full bg-dim" />
<PermissionTypeSelector
showDelete={showDelete}
type={permission.type}
assetType={assetType}
selfPermission={selfPermission}
style={{ left, top }}
onChange={type => {
setTheChild(null) setTheChild(null)
if (type === permissionsModule.Permission.delete) {
doDelete?.()
} else {
const newAction = permissionsModule.TYPE_TO_PERMISSION_ACTION[type]
const newPermissions = permissionsModule.FROM_PERMISSION_ACTION[newAction]
if ('docs' in permission && 'docs' in newPermissions) {
setAction(permissionsModule.toPermissionAction({ ...permission, type }))
} else {
setAction(permissionsModule.TYPE_TO_PERMISSION_ACTION[type])
}
}
}} }}
/> >
</Modal> <div style={{ clipPath }} className="absolute size-full bg-dim" />
) <PermissionTypeSelector
} showDelete={showDelete}
) type={permission.type}
assetType={assetType}
selfPermission={selfPermission}
style={{ left, top }}
onChange={type => {
setTheChild(null)
if (type === permissionsModule.Permission.delete) {
doDelete?.()
} else {
const newAction = permissionsModule.TYPE_TO_PERMISSION_ACTION[type]
const newPermissions = permissionsModule.FROM_PERMISSION_ACTION[newAction]
if ('docs' in permission && 'docs' in newPermissions) {
setAction(permissionsModule.toPermissionAction({ ...permission, type }))
} else {
setAction(permissionsModule.TYPE_TO_PERMISSION_ACTION[type])
}
}
}}
/>
</Modal>
)
}
)
}
} }
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,
@ -161,17 +163,20 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
) )
}} }}
> >
{getText('docsPermissionModifier')} <aria.Text
</button> className={`selectable ${permission.docs && (!isDisabled || !input) ? 'active' : ''} ${
<button permissionsModule.DOCS_CLASS_NAME
type="button" } h-text grow px-permission-mini-button-x py-permission-mini-button-y`}
disabled={disabled} >
{...(disabled && error != null ? { title: error } : {})} {getText('docsPermissionModifier')}
className={`selectable ${permission.execute && (!disabled || !input) ? 'active' : ''} ${ </aria.Text>
permissionsModule.EXEC_CLASS_NAME </UnstyledButton>
} h-text grow rounded-r-full px-permission-mini-button-x py-permission-mini-button-y`} <UnstyledButton
onClick={event => { isDisabled={isDisabled}
event.stopPropagation() focusRingPlacement="after"
{...(isDisabled && error != null ? { title: error } : {})}
className="relative h-text grow rounded-r-full after:absolute after:inset after:rounded-r-full"
onPress={() => {
setAction( setAction(
permissionsModule.toPermissionAction({ permissionsModule.toPermissionAction({
type: permission.type, type: permission.type,
@ -181,25 +186,31 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
) )
}} }}
> >
{getText('execPermissionModifier')} <aria.Text
</button> 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')}
</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
} }

View File

@ -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,61 +87,67 @@ 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 (
<div <FocusArea direction="vertical">
style={style} {innerProps => (
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" <div
onClick={event => { style={style}
event.stopPropagation() 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 => {
> event.stopPropagation()
<div className="group relative flex w-permission-type-selector flex-col p-permission-type-selector"> }}
{PERMISSION_TYPE_DATA.filter( {...innerProps}
data => >
(showDelete ? true : data.type !== permissions.Permission.delete) && <div className="group relative flex w-permission-type-selector flex-col p-permission-type-selector">
(selfPermission === permissions.PermissionAction.own {PERMISSION_TYPE_DATA.filter(
? true data =>
: data.type !== permissions.Permission.owner) (showDelete ? true : data.type !== permissions.Permission.delete) &&
).map(data => ( (selfPermission === permissions.PermissionAction.own
<button ? true
key={data.type} : data.type !== permissions.Permission.owner)
type="button" ).map(data => (
className={`flex h-row items-start gap-permission-type-button rounded-full p-permission-type-button hover:bg-black/5 ${ <UnstyledButton
type === data.type ? 'bg-black/5 hover:!bg-black/5 group-hover:bg-transparent' : '' key={data.type}
}`} className={`flex h-row items-start gap-permission-type-button rounded-full p-permission-type-button hover:bg-black/5 ${
onClick={() => { type === data.type
onChange(data.type) ? 'bg-black/5 hover:!bg-black/5 group-hover:bg-transparent'
}} : ''
> }`}
<div onPress={() => {
className={`h-full w-permission-type rounded-full py-permission-type-y ${ onChange(data.type)
permissions.PERMISSION_CLASS_NAME[data.type] }}
}`} >
>
{data.type}
</div>
{/* This is a symbol that should never need to be localized, since it is effectively
* an icon. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<span className="text font-normal">=</span>
{data.previous != null && (
<>
<div <div
className={`h-full w-permission-type rounded-full py-permission-type-y text-center ${ className={`h-full w-permission-type rounded-full py-permission-type-y ${
permissions.PERMISSION_CLASS_NAME[data.previous] permissions.PERMISSION_CLASS_NAME[data.type]
}`} }`}
> >
{data.previous} {data.type}
</div> </div>
{/* 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 && (
)} <>
<span className="text">{data.description(assetType)}</span> <div
</button> className={`h-full w-permission-type rounded-full py-permission-type-y text-center ${
))} permissions.PERMISSION_CLASS_NAME[data.previous]
</div> }`}
</div> >
{data.previous}
</div>
{/* This is a symbol that should never need to be localized, since it is effectively
* an icon. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<aria.Text className="text font-normal">+</aria.Text>
</>
)}
<aria.Label className="text">{data.description(assetType)}</aria.Label>
</UnstyledButton>
))}
</div>
</div>
)}
</FocusArea>
) )
} }

View File

@ -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>
) )

View File

@ -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,

View File

@ -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>
) )
} }

View File

@ -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,22 +76,26 @@ export default function UserPermission(props: UserPermissionProps) {
} }
return ( return (
<div className="flex items-center gap-user-permission"> <FocusArea active={!isDisabled} direction="horizontal">
<PermissionSelector {innerProps => (
showDelete <div className="flex items-center gap-user-permission" {...innerProps}>
disabled={isOnlyOwner && userPermission.user.userId === self.user.userId} <PermissionSelector
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null} showDelete
selfPermission={self.permission} isDisabled={isDisabled}
action={userPermission.permission} error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
assetType={asset.type} selfPermission={self.permission}
onChange={async permissions => { action={userPermission.permission}
await doSetUserPermission(object.merge(userPermission, { permission: permissions })) assetType={asset.type}
}} onChange={async permissions => {
doDelete={() => { await doSetUserPermission(object.merge(userPermission, { permission: permissions }))
doDelete(userPermission.user) }}
}} doDelete={() => {
/> doDelete(userPermission.user)
<span className="text">{userPermission.user.name}</span> }}
</div> />
<aria.Text className="text">{userPermission.user.name}</aria.Text>
</div>
)}
</FocusArea>
) )
} }

View File

@ -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>
) )

View File

@ -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>
) )

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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)

View File

@ -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)

View File

@ -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]" />
)
)
}

View File

@ -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>
)
}

View File

@ -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)

View File

@ -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>
}

View File

@ -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>
)
}

View File

@ -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
}

View 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>
}

View 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
}

View File

@ -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]

View File

@ -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

View File

@ -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" />

View File

@ -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>
)} )}
</> </>

View File

@ -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) areSuggestionsVisibleRef.current = areSuggestionsVisible
React.useEffect(() => { React.useEffect(() => {
areSuggestionsVisibleRef.current = areSuggestionsVisible if (querySource.current !== QuerySource.tabbing) {
}, [areSuggestionsVisible])
React.useEffect(() => {
if (querySource.current !== QuerySource.tabbing && !isShiftPressed) {
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,16 +217,18 @@ 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 setQuery(baseQuery.current)
setQuery(baseQuery.current) setAreSuggestionsVisible(false)
setAreSuggestionsVisible(false) } else {
} else { 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,169 +269,169 @@ 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 => (
data-testid="asset-search-bar" <aria.Label
tabIndex={-1} data-testid="asset-search-bar"
onFocus={() => { {...aria.mergeProps<aria.LabelProps>()(innerProps, {
setAreSuggestionsVisible(true) 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',
onBlur={event => { ref: rootRef,
if (!event.currentTarget.contains(event.relatedTarget)) { onFocus: () => {
if (querySource.current === QuerySource.tabbing) { setAreSuggestionsVisible(true)
querySource.current = QuerySource.external },
} onBlur: event => {
setAreSuggestionsVisible(false) if (!event.currentTarget.contains(event.relatedTarget)) {
} if (querySource.current === QuerySource.tabbing) {
}} querySource.current = QuerySource.external
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" }
> setAreSuggestionsVisible(false)
<img src={FindIcon} className="relative z-1 placeholder" /> }
<input },
ref={searchRef} })}
type="search" >
size={1} <img src={FindIcon} className="relative z-1 placeholder" />
placeholder={ <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">
isCloud <div className="padding relative h-row" />
? getText('remoteBackendSearchPlaceholder') {areSuggestionsVisible && (
: getText('localBackendSearchPlaceholder') <div className="relative flex flex-col gap-search-suggestions">
} {/* Tags (`name:`, `modified:`, etc.) */}
className="peer text relative z-1 grow bg-transparent placeholder:text-center" <Tags
onChange={event => { isCloud={isCloud}
if (querySource.current !== QuerySource.internal) { querySource={querySource}
querySource.current = QuerySource.typing query={query}
setQuery(AssetQuery.fromString(event.target.value)) setQuery={setQuery}
} />
}} {/* Asset labels */}
onKeyDown={event => { {isCloud && labels.length !== 0 && (
if ( <div
event.key === 'Enter' && data-testid="asset-search-labels"
!event.shiftKey && className="pointer-events-auto flex gap-buttons p-search-suggestions"
!event.altKey && >
!event.metaKey && {labels
!event.ctrlKey .sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
) { .map(label => {
// Clone the query to refresh results. const negated = query.negativeLabels.some(term =>
setQuery(query.clone()) array.shallowEqual(term, [label.value])
} )
}} return (
/> <Label
<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"> key={label.id}
<div className="padding relative h-row" /> color={label.color}
{areSuggestionsVisible && ( active={
<div className="relative flex flex-col gap-search-suggestions"> negated ||
{/* Tags (`name:`, `modified:`, etc.) */} query.labels.some(term => array.shallowEqual(term, [label.value]))
<div }
data-testid="asset-search-tag-names" negated={negated}
className="pointer-events-auto flex flex-wrap gap-buttons whitespace-nowrap px-search-suggestions" onPress={event => {
> querySource.current = QuerySource.internal
{(isCloud ? AssetQuery.tagNames : AssetQuery.localTagNames).flatMap(entry => { setQuery(oldQuery => {
const [key, tag] = entry const newQuery = oldQuery.withToggled(
return tag == null || isShiftPressed !== tag.startsWith('-') 'labels',
? [] 'negativeLabels',
: [ label.value,
<button event.shiftKey
key={key} )
className="h-text rounded-full bg-frame px-button-x transition-all hover:bg-selected-frame" baseQuery.current = newQuery
onClick={() => { return newQuery
querySource.current = QuerySource.internal })
setQuery(query.add({ [key]: [[]] })) }}
}} >
> {label.value}
{`${tag}:`} </Label>
</button>, )
] })}
})} </div>
</div> )}
{/* Asset labels */} {/* Suggestions */}
{isCloud && labels.length !== 0 && ( <div className="flex max-h-search-suggestions-list flex-col overflow-y-auto">
<div {suggestions.map((suggestion, index) => (
data-testid="asset-search-labels" // This should not be a `<button>`, since `render()` may output a
className="pointer-events-auto flex gap-buttons p-search-suggestions" // tree containing a button.
> <aria.Button
{labels data-testid="asset-search-suggestion"
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value)) key={index}
.map(label => { ref={el => {
const negated = query.negativeLabels.some(term => if (index === selectedIndex) {
array.shallowEqual(term, [label.value]) el?.focus()
)
return (
<Label
key={label.id}
color={label.color}
active={
negated ||
query.labels.some(term => array.shallowEqual(term, [label.value]))
} }
negated={negated} }}
onClick={event => { 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 ${
querySource.current = QuerySource.internal index === selectedIndex
setQuery(oldQuery => { ? 'bg-selected-frame'
const newQuery = oldQuery.withToggled( : selectedIndices.has(index)
'labels', ? 'bg-frame'
'negativeLabels', : ''
label.value, }`}
event.shiftKey onPress={event => {
querySource.current = QuerySource.internal
setQuery(
selectedIndices.has(index)
? suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current)
)
if (event.shiftKey) {
setSelectedIndices(
new Set(
selectedIndices.has(index)
? [...selectedIndices].filter(otherIndex => otherIndex !== index)
: [...selectedIndices, index]
) )
baseQuery.current = newQuery )
return newQuery } else {
}) setAreSuggestionsVisible(false)
}} }
> }}
{label.value} >
</Label> {suggestion.render()}
) </aria.Button>
})} ))}
</div>
</div> </div>
)} )}
{/* Suggestions */}
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto">
{suggestions.map((suggestion, index) => (
// This should not be a `<button>`, since `render()` may output a
// tree containing a button.
<div
data-testid="asset-search-suggestion"
key={index}
ref={el => {
if (index === selectedIndex) {
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 ${
index === selectedIndex
? 'bg-selected-frame'
: selectedIndices.has(index)
? 'bg-frame'
: ''
}`}
onClick={event => {
querySource.current = QuerySource.internal
setQuery(
selectedIndices.has(index)
? suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current)
)
if (event.shiftKey) {
setSelectedIndices(
new Set(
selectedIndices.has(index)
? [...selectedIndices].filter(otherIndex => otherIndex !== index)
: [...selectedIndices, index]
)
)
} else {
setAreSuggestionsVisible(false)
}
}}
>
{suggestion.render()}
</div>
))}
</div>
</div> </div>
)} <FocusRing placement="before">
</div> <aria.SearchField
</label> 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>
) )
} }

View File

@ -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>

View File

@ -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>

View File

@ -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,158 +1172,211 @@ 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(() => {
// This is not a React component, even though it contains JSX. const body = bodyRef.current
// eslint-disable-next-line no-restricted-syntax if (body == null) {
const onKeyDown = (event: KeyboardEvent) => { return
const prevIndex = mostRecentlySelectedIndexRef.current } else {
const item = prevIndex == null ? null : visibleItems[prevIndex] return navigator2D.register(body, {
if (selectedKeysRef.current.size === 1 && item != null) { focusPrimaryChild: () => {
switch (event.key) { setMostRecentlySelectedIndex(0, true)
case 'Enter': },
case ' ': { })
if (event.key === ' ' && event.ctrlKey) { }
const keys = selectedKeysRef.current }, [navigator2D, setMostRecentlySelectedIndex])
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
} else { // This is not a React component, even though it contains JSX.
switch (item.item.type) { // eslint-disable-next-line no-restricted-syntax
case backendModule.AssetType.directory: { const onKeyDown = (event: React.KeyboardEvent) => {
event.preventDefault() const prevIndex = mostRecentlySelectedIndexRef.current
event.stopPropagation() const item = prevIndex == null ? null : visibleItems[prevIndex]
doToggleDirectoryExpansion(item.item.id, item.key) if (selectedKeysRef.current.size === 1 && item != null) {
break switch (event.key) {
} case 'Enter':
case backendModule.AssetType.project: { case ' ': {
event.preventDefault() if (event.key === ' ' && event.ctrlKey) {
event.stopPropagation() const keys = selectedKeysRef.current
dispatchAssetEvent({ setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
type: AssetEventType.openProject, } else {
id: item.item.id, switch (item.item.type) {
runInBackground: false, case backendModule.AssetType.directory: {
shouldAutomaticallySwitchPage: true, event.preventDefault()
}) event.stopPropagation()
break doToggleDirectoryExpansion(item.item.id, item.key)
} break
case backendModule.AssetType.dataLink: { }
event.preventDefault() case backendModule.AssetType.project: {
event.stopPropagation() event.preventDefault()
setIsAssetPanelTemporarilyVisible(true) event.stopPropagation()
break dispatchAssetEvent({
} type: AssetEventType.openProject,
case backendModule.AssetType.secret: { id: item.item.id,
event.preventDefault() runInBackground: false,
event.stopPropagation() shouldAutomaticallySwitchPage: true,
const id = item.item.id })
setModal( break
<UpsertSecretModal }
id={item.item.id} case backendModule.AssetType.dataLink: {
name={item.item.title} event.preventDefault()
doCreate={async (_name, value) => { event.stopPropagation()
try { setIsAssetPanelTemporarilyVisible(true)
await backend.updateSecret(id, { value }, item.item.title) break
} catch (error) { }
toastAndLog(null, error) case backendModule.AssetType.secret: {
} event.preventDefault()
}} event.stopPropagation()
/> const id = item.item.id
) setModal(
break <UpsertSecretModal
} id={item.item.id}
default: { name={item.item.title}
break doCreate={async (_name, value) => {
} try {
await backend.updateSecret(id, { value }, item.item.title)
} catch (error) {
toastAndLog(null, error)
}
}}
/>
)
break
}
default: {
break
} }
} }
break
} }
case 'ArrowLeft': { break
if (item.item.type === backendModule.AssetType.directory && item.children != null) { }
case 'ArrowLeft': {
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
} }
case 'ArrowRight': { break
if (item.item.type === backendModule.AssetType.directory && item.children == null) { }
event.preventDefault() case 'ArrowRight': {
event.stopPropagation() if (item.item.type === backendModule.AssetType.directory && item.children == null) {
doToggleDirectoryExpansion(item.item.id, item.key, null, true) // The folder is collapsed; expand it.
} event.preventDefault()
break event.stopPropagation()
doToggleDirectoryExpansion(item.item.id, item.key, null, true)
} }
break
} }
} }
switch (event.key) { }
case ' ': { switch (event.key) {
if (event.ctrlKey && item != null) { case ' ': {
const keys = selectedKeysRef.current if (event.ctrlKey && item != null) {
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key))) const keys = selectedKeysRef.current
} setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
break
} }
case 'Escape': { break
setSelectedKeys(new Set()) }
setMostRecentlySelectedIndex(null) case 'Escape': {
setSelectedKeys(new Set())
setMostRecentlySelectedIndex(null)
selectionStartIndexRef.current = null
break
}
case 'ArrowUp':
case 'ArrowDown': {
if (!event.shiftKey) {
selectionStartIndexRef.current = null selectionStartIndexRef.current = null
break
} }
case 'ArrowUp': let index = prevIndex ?? 0
case 'ArrowDown': { let oldIndex = index
if (prevIndex != null) {
let itemType = visibleItems[index]?.item.type
do {
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)
if (event.shiftKey) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!event.shiftKey) { // On Windows, Ctrl+Shift+Arrow behaves the same as Shift+Arrow.
selectionStartIndexRef.current = null if (selectionStartIndexRef.current == null) {
selectionStartIndexRef.current = prevIndex ?? 0
} }
const index = const startIndex = Math.min(index, selectionStartIndexRef.current)
prevIndex == null const endIndex = Math.max(index, selectionStartIndexRef.current) + 1
? 0 const selection = visibleItems.slice(startIndex, endIndex)
: event.key === 'ArrowUp' setSelectedKeys(new Set(selection.map(newItem => newItem.key)))
? Math.max(0, prevIndex - 1) } else if (event.ctrlKey) {
: Math.min(visibleItems.length - 1, prevIndex + 1) event.preventDefault()
setMostRecentlySelectedIndex(index, true) event.stopPropagation()
if (event.shiftKey) { selectionStartIndexRef.current = null
// On Windows, Ctrl+Shift+Arrow behaves the same as Shift+Arrow. } else if (index !== prevIndex) {
if (selectionStartIndexRef.current == null) { event.preventDefault()
selectionStartIndexRef.current = prevIndex ?? 0 event.stopPropagation()
} const newItem = visibleItems[index]
const startIndex = Math.min(index, selectionStartIndexRef.current) if (newItem != null) {
const endIndex = Math.max(index, selectionStartIndexRef.current) + 1 setSelectedKeys(new Set([newItem.key]))
const selection = visibleItems.slice(startIndex, endIndex)
setSelectedKeys(new Set(selection.map(newItem => newItem.key)))
} else if (event.ctrlKey) {
selectionStartIndexRef.current = null
} else {
const newItem = visibleItems[index]
if (newItem != null) {
setSelectedKeys(new Set([newItem.key]))
}
selectionStartIndexRef.current = null
} }
break 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
} }
} }
document.addEventListener('keydown', onKeyDown) }
return () => {
document.removeEventListener('keydown', onKeyDown) React.useEffect(() => {
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 (!isClipPathUpdateQueued) {
isClipPathUpdateQueued = true
requestAnimationFrame(updateClipPath)
}
}
updateClipPath()
scrollContainer.addEventListener('scroll', onScroll)
return () => {
scrollContainer.removeEventListener('scroll', onScroll)
}
} else {
return
} }
}, [/* should never change */ scrollContainerRef]) if (
backend.type === backendModule.BackendType.remote &&
rootRef.current != null &&
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])
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,49 +2454,78 @@ export default function AssetsTable(props: AssetsTableProps) {
) )
return ( return (
<div ref={scrollContainerRef} className="flex-1 overflow-auto container-size"> <FocusArea direction="vertical">
{!hidden && hiddenContextMenu} {innerProps => (
{!hidden && ( <div
<SelectionBrush {...aria.mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
onDrag={onSelectionDrag} ref: rootRef,
onDragEnd={onSelectionDragEnd} className: 'flex-1 overflow-auto container-size',
onDragCancel={onSelectionDragCancel} onKeyDown,
/> onScroll,
)} onBlur: event => {
<div className="flex h-max min-h-full w-max min-w-full flex-col"> if (
{isCloud && ( event.relatedTarget instanceof HTMLElement &&
<div className="flex-0 sticky top flex h flex-col"> !event.currentTarget.contains(event.relatedTarget)
<div ) {
data-testid="extra-columns" setKeyboardSelectedIndex(null)
className="sticky right flex self-end px-extra-columns-panel-x py-extra-columns-panel-y" }
> },
<div className="inline-flex gap-icons"> })}
{columnUtils.CLOUD_COLUMNS.filter(column => !enabledColumns.has(column)).map( >
column => ( {!hidden && hiddenContextMenu}
<Button {!hidden && (
key={column} <SelectionBrush
active onDrag={onSelectionDrag}
image={columnUtils.COLUMN_ICONS[column]} onDragEnd={onSelectionDragEnd}
alt={getText(columnUtils.COLUMN_SHOW_TEXT_ID[column])} onDragCancel={onSelectionDragCancel}
onClick={event => { />
event.stopPropagation() )}
const newExtraColumns = new Set(enabledColumns) <div className="flex h-max min-h-full w-max min-w-full flex-col">
if (enabledColumns.has(column)) { {isCloud && (
newExtraColumns.delete(column) <div className="flex-0 sticky top flex h flex-col">
} else { <div
newExtraColumns.add(column) data-testid="extra-columns"
} className="sticky right flex self-end px-extra-columns-panel-x py-extra-columns-panel-y"
setEnabledColumns(newExtraColumns) >
}} <FocusArea direction="horizontal">
/> {columnsBarProps => (
) <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
key={column}
active
image={columnUtils.COLUMN_ICONS[column]}
alt={getText(columnUtils.COLUMN_SHOW_TEXT_ID[column])}
onPress={() => {
const newExtraColumns = new Set(enabledColumns)
if (enabledColumns.has(column)) {
newExtraColumns.delete(column)
} else {
newExtraColumns.add(column)
}
setEnabledColumns(newExtraColumns)
}}
/>
))}
</div>
)}
</FocusArea>
</div>
</div> </div>
</div> )}
<div className="flex h-full w-min min-w-full grow flex-col">{table}</div>
</div> </div>
)} </div>
<div className="flex h-full w-min min-w-full grow flex-col">{table}</div> )}
</div> </FocusArea>
</div>
) )
} }

View File

@ -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}

View File

@ -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}>
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" <UnstyledButton
onClick={() => { isDisabled={isCloud}
setBackendType(backendModule.BackendType.remote) 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"
}} onPress={() => {
> setBackendType(backendModule.BackendType.remote)
<div className="flex items-center gap-icon-with-text"> }}
<SvgMask src={CloudIcon} /> >
<span className="text">{getText('cloud')}</span> <div className="flex items-center gap-icon-with-text">
<SvgMask src={CloudIcon} />
<aria.Label className="text">{getText('cloud')}</aria.Label>
</div>
</UnstyledButton>
<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"
onPress={() => {
setBackendType(backendModule.BackendType.local)
}}
>
<div className="flex items-center gap-icon-with-text">
<SvgMask src={NotCloudIcon} />
<aria.Label className="text">{getText('local')}</aria.Label>
</div>
</UnstyledButton>
</div> </div>
</button> )}
<button </FocusArea>
disabled={!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"
onClick={() => {
setBackendType(backendModule.BackendType.local)
}}
>
<div className="flex items-center gap-icon-with-text">
<SvgMask src={NotCloudIcon} />
<span className="text">{getText('local')}</span>
</div>
</button>
</div>
) )
} }

View File

@ -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}
> >
<SvgMask <UnstyledButton
src={CATEGORY_ICONS[category]} aria-label={getText(buttonTextId)}
className={`group-hover:text-icon-selected ${ className={`rounded-inherit ${isCurrent ? 'focus-default' : ''}`}
isCurrent ? 'text-icon-selected' : 'text-icon-not-selected' onPress={onPress}
} ${ >
// This explicit class is a special-case due to the unusual shape of the "Recent" icon. <div
// eslint-disable-next-line no-restricted-syntax className={`selectable ${
category === Category.recent ? '-ml-0.5' : '' 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`}
/> >
<span>{getText(CATEGORY_TO_TEXT_ID[category])}</span> <SvgMask
</button> src={icon}
className={
// This explicit class is a special-case due to the unusual shape of the "Recent" icon.
// eslint-disable-next-line no-restricted-syntax
category === Category.recent ? '-ml-0.5' : ''
}
/>
<aria.Text slot="description">{getText(textId)}</aria.Text>
</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 => (
{getText('category')} <div className="flex w-full flex-col" {...innerProps}>
</div> <aria.Header
<div className="flex flex-col items-start"> id="header"
{CATEGORIES.map(currentCategory => ( className="text-header mb-sidebar-section-heading-b px-sidebar-section-heading-x text-sm font-bold"
<CategorySwitcherItem >
key={currentCategory} {getText('category')}
category={currentCategory} </aria.Header>
isCurrent={category === currentCategory} <div
onClick={() => { aria-label={getText('categorySwitcherMenuLabel')}
setCategory(currentCategory) role="grid"
}} className="flex flex-col items-start"
onDragOver={event => { >
if ( {CATEGORY_DATA.map(data => (
(category === Category.trash && currentCategory === Category.home) || <CategorySwitcherItem
(category !== Category.trash && currentCategory === Category.trash) key={data.category}
) { id={data.category}
event.preventDefault() data={data}
} isCurrent={category === data.category}
}} onPress={() => {
onDrop={event => { setCategory(data.category)
if ( }}
(category === Category.trash && currentCategory === Category.home) || acceptedDragTypes={
(category !== Category.trash && currentCategory === Category.trash) (category === Category.trash && data.category === Category.home) ||
) { (category !== Category.trash && data.category === Category.trash)
event.preventDefault() ? ['application/vnd.enso.assets+json']
event.stopPropagation() : []
unsetModal()
const payload = drag.ASSET_ROWS.lookup(event)
if (payload != null) {
dispatchAssetEvent({
type:
category === Category.trash ? AssetEventType.restore : AssetEventType.delete,
ids: new Set(payload.map(item => item.key)),
})
} }
} onDrop={event => {
}} unsetModal()
/> void Promise.all(
))} event.items.flatMap(async item => {
</div> if (item.kind === 'text') {
</div> 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({
type:
category === Category.trash
? AssetEventType.restore
: AssetEventType.delete,
ids: new Set(keys.flat(1)),
})
})
}}
/>
))}
</div>
</div>
)}
</FocusArea>
) )
} }

View File

@ -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

View File

@ -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>,

View File

@ -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}

View File

@ -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">
<Button {isCloud && (
active <Button
image={AddFolderIcon} active
alt={getText('newFolder')} image={AddFolderIcon}
onClick={() => { alt={getText('newFolder')}
unsetModal() onPress={() => {
doCreateDirectory() unsetModal()
}} 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>
) )
} }

View File

@ -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"

View File

@ -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} />

View File

@ -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,106 +48,123 @@ 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 (
<div <FocusArea direction="vertical">
data-testid="labels" {innerProps => (
className="flex w-full flex-col items-start gap-sidebar-section-heading" <div
> data-testid="labels"
<div className="text-header px-sidebar-section-heading-x text-sm font-bold"> className="gap-sidebar-section-heading flex w-full flex-col items-start"
{getText('labels')} {...innerProps}
</div> >
<ul data-testid="labels-list" className="flex flex-col items-start gap-labels"> <div className="text-header px-sidebar-section-heading-x text-sm font-bold">
{labels {getText('labels')}
.filter(label => !deletedLabelNames.has(label.value)) </div>
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value)) <div
.map(label => { data-testid="labels-list"
const negated = currentNegativeLabels.some(term => aria-label={getText('labelsListLabel')}
array.shallowEqual(term, [label.value]) className="flex flex-col items-start gap-labels"
) >
return ( {displayLabels.map(label => {
<li key={label.id} className="group flex items-center gap-label-icons"> const negated = currentNegativeLabels.some(term =>
<Label array.shallowEqual(term, [label.value])
draggable )
color={label.color} return (
active={ <div key={label.id} className="group relative flex items-center gap-label-icons">
negated || currentLabels.some(term => array.shallowEqual(term, [label.value])) <Label
} draggable
negated={negated} color={label.color}
disabled={newLabelNames.has(label.value)} active={
onClick={event => { negated || currentLabels.some(term => array.shallowEqual(term, [label.value]))
setQuery(oldQuery => }
oldQuery.withToggled('labels', 'negativeLabels', label.value, event.shiftKey) negated={negated}
) isDisabled={newLabelNames.has(label.value)}
}} onPress={event => {
onDragStart={event => { setQuery(oldQuery =>
drag.setDragImageToBlank(event) oldQuery.withToggled(
const payload: drag.LabelsDragPayload = new Set([label.value]) 'labels',
drag.LABELS.bind(event, payload) 'negativeLabels',
setModal( label.value,
<DragModal event.shiftKey
event={event} )
doCleanup={() => { )
drag.LABELS.unbind(payload) }}
}} onDragStart={event => {
> drag.setDragImageToBlank(event)
<Label active color={label.color} onClick={() => {}}> const payload: drag.LabelsDragPayload = new Set([label.value])
{label.value} drag.LABELS.bind(event, payload)
</Label>
</DragModal>
)
}}
>
{label.value}
</Label>
{!newLabelNames.has(label.value) && (
<button
className="flex"
onClick={event => {
event.stopPropagation()
setModal( setModal(
<ConfirmDeleteModal <DragModal
actionText={getText('deleteLabelActionText', label.value)} event={event}
doDelete={() => { doCleanup={() => {
doDeleteLabel(label.id, label.value) drag.LABELS.unbind(payload)
}} }}
/> >
<Label active color={label.color} onPress={() => {}}>
{label.value}
</Label>
</DragModal>
) )
}} }}
> >
<SvgMask {label.value}
src={Trash2Icon} </Label>
alt={getText('delete')} {!newLabelNames.has(label.value) && (
className="size-icon text-delete transition-all transparent group-hover:active" <FocusRing placement="after">
/> <aria.Button
</button> className="relative flex after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring"
)} onPress={() => {
</li> setModal(
) <ConfirmDeleteModal
})} actionText={getText('deleteLabelActionText', label.value)}
<li> doDelete={() => {
<Label doDeleteLabel(label.id, label.value)
active }}
color={labelUtils.DEFAULT_LABEL_COLOR} />
className="bg-frame text-not-selected" )
onClick={event => { }}
event.stopPropagation() >
setModal( <SvgMask
<NewLabelModal src={Trash2Icon}
labels={labels} alt={getText('delete')}
eventTarget={event.currentTarget} className="size-icon text-delete transition-all transparent group-has-[[data-focus-visible]]:active group-hover:active"
doCreate={doCreateLabel} />
/> </aria.Button>
</FocusRing>
)}
</div>
) )
}} })}
> <Label
{/* This is a non-standard-sized icon. */} color={labelUtils.DEFAULT_LABEL_COLOR}
{/* eslint-disable-next-line no-restricted-syntax */} className="bg-selected-frame"
<img src={PlusIcon} className="mr-[6px] size-[6px]" /> onPress={event => {
<span className="text-header">{getText('newLabelButtonLabel')}</span> if (event.target instanceof HTMLElement) {
</Label> setModal(
</li> <NewLabelModal
</ul> labels={labels}
</div> eventTarget={event.target}
doCreate={doCreateLabel}
/>
)
}
}}
>
{/* This is a non-standard-sized icon. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<img src={PlusIcon} className="mr-[6px] size-[6px]" />
<aria.Text className="text-header">{getText('newLabelButtonLabel')}</aria.Text>
</Label>
</div>
</div>
)}
</FocusArea>
) )
} }

View File

@ -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 (
<div <FocusArea direction="horizontal">
className={`pointer-events-auto flex shrink-0 cursor-default items-center gap-pages rounded-full px-page-switcher-x ${ {innerProps => (
page === Page.editor ? 'bg-frame backdrop-blur-default' : '' <div
}`} 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_DATA.map(pageData => { }`}
const isDisabled = {...innerProps}
pageData.page === page || (pageData.page === Page.editor && isEditorDisabled) >
const errorId = ERRORS[pageData.page] {PAGE_DATA.map(pageData => {
return ( return (
<Button <Button
key={pageData.page} key={pageData.page}
image={pageData.icon} aria-label={getText(pageData.tooltipId)}
active={page === pageData.page} alt={getText(pageData.altId)}
alt={getText(PAGE_TO_ALT_TEXT_ID[pageData.page])} image={pageData.icon}
title={getText(PAGE_TO_TOOLTIP_ID[pageData.page])} active={page === pageData.page}
disabled={isDisabled} softDisabled={page === pageData.page}
error={errorId == null ? null : getText(errorId)} isDisabled={pageData.page === Page.editor && isEditorDisabled}
onClick={() => { error={ERRORS[pageData.page]}
setPage(pageData.page) onPress={() => {
}} setPage(pageData.page)
/> }}
) />
})} )
</div> })}
</div>
)}
</FocusArea>
) )
} }

View File

@ -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,23 +117,28 @@ 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}
<div className="relative flex size-full rounded-default"> {...innerProps}
<div className="m-auto flex flex-col items-center gap-new-empty-project text-center"> >
{spinnerState != null ? ( <div className="relative flex size-full rounded-default">
<Spinner size={SPINNER_SIZE_PX} padding={2} state={spinnerState} /> <div className="m-auto flex flex-col items-center gap-new-empty-project text-center">
) : ( {spinnerState != null ? (
<img src={ProjectIcon} /> <Spinner size={SPINNER_SIZE_PX} padding={2} state={spinnerState} />
)} ) : (
<p className="text-sm font-semibold">{getText('newEmptyProject')}</p> <img src={ProjectIcon} />
</div> )}
</div> <p className="text-sm font-semibold">{getText('newEmptyProject')}</p>
</button> </div>
</div>
</aria.Button>
</FocusRing>
)}
</FocusArea>
<div className="h-sample-info" /> <div className="h-sample-info" />
</div> </div>
) )
@ -169,51 +177,58 @@ 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">
key={title} {innerProps => (
className="relative flex h-sample grow cursor-pointer flex-col text-left" <FocusRing placement="after">
onClick={onClick} <aria.Button
> key={title}
<div 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"
style={{ background }} onPress={onPress}
className={`h-sample-image w-full rounded-t-default ${ {...innerProps}
background != null ? '' : 'bg-frame' >
}`} <div
/> style={{ background }}
<div className="w-full grow rounded-b-default bg-frame px-sample-description-x pb-sample-description-b pt-sample-description-t backdrop-blur"> className={`h-sample-image w-full rounded-t-default ${
<h2 className="text-header text-sm font-bold">{title}</h2> background != null ? '' : 'bg-frame'
<div className="text-ellipsis text-xs leading-snug">{description}</div> }`}
</div> />
{spinnerState != null && ( <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="absolute grid h-sample-image w-full place-items-center"> <aria.Heading className="text-header text-sm font-bold">{title}</aria.Heading>
<Spinner size={SPINNER_SIZE_PX} state={spinnerState} /> <div className="text-ellipsis text-xs leading-snug">{description}</div>
</div> </div>
{spinnerState != null && (
<div className="absolute grid h-sample-image w-full place-items-center">
<Spinner size={SPINNER_SIZE_PX} state={spinnerState} />
</div>
)}
</aria.Button>
</FocusRing>
)} )}
</button> </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} />
))} ))}

View File

@ -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}

View File

@ -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>
) )
} }

View File

@ -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,64 +122,68 @@ 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 items-center gap-activity-log-filter"> <div className="flex gap-activity-log-filters" {...innerProps}>
{getText('startDate')} <div className="flex items-center gap-activity-log-filter">
<DateInput date={startDate} onInput={setStartDate} /> {getText('startDate')}
</div> <DateInput date={startDate} onInput={setStartDate} />
<div className="flex items-center gap-activity-log-filter"> </div>
{getText('endDate')} <div className="flex items-center gap-activity-log-filter">
<DateInput date={endDate} onInput={setEndDate} /> {getText('endDate')}
</div> <DateInput date={endDate} onInput={setEndDate} />
<div className="flex items-center gap-activity-log-filter"> </div>
{getText('types')} <div className="flex items-center gap-activity-log-filter">
<Dropdown {getText('types')}
multiple <Dropdown
items={backendModule.EVENT_TYPES} multiple
selectedIndices={typeIndices} items={backendModule.EVENT_TYPES}
render={props => EVENT_TYPE_NAME[props.item]} selectedIndices={typeIndices}
renderMultiple={props => render={props => EVENT_TYPE_NAME[props.item]}
props.items.length === 0 || props.items.length === backendModule.EVENT_TYPES.length renderMultiple={props =>
? 'All' props.items.length === 0 ||
: (props.items[0] != null ? EVENT_TYPE_NAME[props.items[0]] : '') + props.items.length === backendModule.EVENT_TYPES.length
(props.items.length <= 1 ? '' : ` (+${props.items.length - 1})`) ? 'All'
} : (props.items[0] != null ? EVENT_TYPE_NAME[props.items[0]] : '') +
onClick={(items, indices) => { (props.items.length <= 1 ? '' : ` (+${props.items.length - 1})`)
setTypes(items) }
setTypeIndices(indices) onClick={(items, indices) => {
}} setTypes(items)
/> setTypeIndices(indices)
</div> }}
<div className="flex items-center gap-activity-log-filter"> />
{getText('users')} </div>
<Dropdown <div className="flex items-center gap-activity-log-filter">
multiple {getText('users')}
items={allEmails} <Dropdown
selectedIndices={emailIndices} multiple
render={props => props.item} items={allEmails}
renderMultiple={props => selectedIndices={emailIndices}
props.items.length === 0 || props.items.length === allEmails.length render={props => props.item}
? 'All' renderMultiple={props =>
: (props.items[0] ?? '') + props.items.length === 0 || props.items.length === allEmails.length
(props.items.length <= 1 ? '' : `(+${props.items.length - 1})`) ? 'All'
} : (props.items[0] ?? '') +
onClick={(items, indices) => { (props.items.length <= 1 ? '' : `(+${props.items.length - 1})`)
setEmails(items) }
setEmailIndices(indices) onClick={(items, indices) => {
}} setEmails(items)
/> setEmailIndices(indices)
</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>
) )
} }

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
) )
} }

View File

@ -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