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",
"typescript.preferences.importModuleSpecifierEnding": "minimal",
"javascript.updateImportsOnFileMove.enabled": "always",
"typescript.updateImportsOnFileMove.enabled": "always"
"typescript.updateImportsOnFileMove.enabled": "always",
"typescript.preferences.autoImportFileExcludePatterns": [
"react-aria",
"react-aria-components",
"@react-aria/*",
"@react-types/*"
]
}

View File

@ -123,7 +123,7 @@ const RESTRICTED_SYNTAXES = [
},
{
// Matches non-functions.
selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:matches(:has(ArrowFunctionExpression), :has(CallExpression[callee.object.name=newtype][callee.property.name=newtypeConstructor])))`,
selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:matches([init.callee.object.name=React][init.callee.property.name=forwardRef], :has(ArrowFunctionExpression), :has(CallExpression[callee.object.name=newtype][callee.property.name=newtypeConstructor])))`,
message: 'Use `CONSTANT_CASE` for top-level constants that are not functions',
},
{
@ -229,6 +229,27 @@ const RESTRICTED_SYNTAXES = [
)`,
message: 'Use a `getText()` from `useText` instead of a literal string',
},
{
selector: 'JSXOpeningElement[name.name=button] > JSXIdentifier',
message: 'Use `Button` or `UnstyledButton` instead of `button`',
},
{
selector: 'JSXOpeningElement[name.name=label] > JSXIdentifier',
message: 'Use `aria.Label` instead of `label`',
},
{
selector: 'JSXOpeningElement[name.name=input] > JSXIdentifier',
message: 'Use `aria.Input` instead of `input`',
},
{
selector: 'JSXOpeningElement[name.name=span] > JSXIdentifier',
message: 'Use `aria.Text` instead of `span`',
},
{
selector: 'JSXOpeningElement[name.name=/^h[123456]$/] > JSXIdentifier',
message: 'Use `aria.Heading` instead of `h1`-`h6`',
},
// We may want to consider also preferring `aria.Form` in favor of `form` in the future.
]
// ============================
@ -273,6 +294,8 @@ export default [
...tsEslint.configs.strict?.rules,
...react.configs['jsx-runtime'].rules,
eqeqeq: ['error', 'always', { null: 'never' }],
// Any extra semicolons that exist, are required by Prettier.
'no-extra-semi': 'off',
'jsdoc/require-jsdoc': [
'error',
{

View File

@ -66,7 +66,7 @@ export function locateNewLabelModalColorButtons(page: test.Page) {
locateNewLabelModal(page)
.filter({ has: page.getByText('Color') })
// The `radio` inputs are invisible, so they cannot be used in the locator.
.getByRole('button')
.locator('label[data-rac]')
)
}
@ -229,17 +229,17 @@ export function locateDocsColumnToggle(page: test.Locator | test.Page) {
/** Find a button for the "Recent" category (if any) on the current page. */
export function locateRecentCategory(page: test.Locator | test.Page) {
return page.getByTitle('Go To Recent')
return page.getByLabel('Go To Recent category')
}
/** Find a button for the "Home" category (if any) on the current page. */
export function locateHomeCategory(page: test.Locator | test.Page) {
return page.getByTitle('Go To Home')
return page.getByLabel('Go To Home category')
}
/** Find a button for the "Trash" category (if any) on the current page. */
export function locateTrashCategory(page: test.Locator | test.Page) {
return page.getByTitle('Go To Trash')
return page.getByLabel('Go To Trash category')
}
// === Context menu buttons ===
@ -443,14 +443,14 @@ export function locateSettingsPageIcon(page: test.Locator | test.Page) {
/** Find a "name" column heading (if any) on the current page. */
export function locateNameColumnHeading(page: test.Locator | test.Page) {
return page.getByTitle('Sort by name').or(page.getByTitle('Stop sorting by name'))
return page.getByLabel('Sort by name').or(page.getByLabel('Stop sorting by name'))
}
/** Find a "modified" column heading (if any) on the current page. */
export function locateModifiedColumnHeading(page: test.Locator | test.Page) {
return page
.getByTitle('Sort by modification date')
.or(page.getByTitle('Stop sorting by modification date'))
.getByLabel('Sort by modification date')
.or(page.getByLabel('Stop sorting by modification date'))
}
// === Container locators ===
@ -670,10 +670,28 @@ export async function expectTrashPlaceholderRow(page: test.Page) {
// === Mouse utilities ===
// =======================
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
/** Click an asset row. The center must not be clicked as that is the button for adding a label. */
export async function clickAssetRow(assetRow: test.Locator) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
await assetRow.click({ position: { x: 300, y: 16 } })
await assetRow.click({ position: ASSET_ROW_SAFE_POSITION })
}
/** Drag an asset row. The center must not be clicked as that is the button for adding a label. */
export async function dragAssetRowToAssetRow(from: test.Locator, to: test.Locator) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
await from.dragTo(to, {
sourcePosition: ASSET_ROW_SAFE_POSITION,
targetPosition: ASSET_ROW_SAFE_POSITION,
})
}
/** Drag an asset row. The center must not be clicked as that is the button for adding a label. */
export async function dragAssetRow(from: test.Locator, to: test.Locator) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
await from.dragTo(to, { sourcePosition: ASSET_ROW_SAFE_POSITION })
}
// ==========================

View File

@ -92,7 +92,7 @@ test.test('suggestions (keyboard)', async ({ page }) => {
for (const suggestion of await suggestions.all()) {
const name = (await suggestion.textContent()) ?? ''
test.expect(name.length).toBeGreaterThan(0)
await page.press('body', 'Tab')
await page.press('body', 'ArrowDown')
await test.expect(searchBarInput).toHaveValue('name:' + name)
}
})
@ -108,11 +108,11 @@ test.test('complex flows', async ({ page }) => {
await actions.login({ page })
await searchBarInput.click()
await page.press('body', 'Tab')
await page.press('body', 'ArrowDown')
await test.expect(searchBarInput).toHaveValue('name:' + firstName)
await searchBarInput.selectText()
await searchBarInput.press('Backspace')
await test.expect(searchBarInput).toHaveValue('')
await page.press('body', 'Tab')
await page.press('body', 'ArrowDown')
await test.expect(searchBarInput).toHaveValue('name:' + firstName)
})

View File

@ -81,7 +81,7 @@ test.test('move (drag)', async ({ page }) => {
// Assets: [0: Folder 1]
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 2, 1: Folder 1]
await assetRows.nth(0).dragTo(assetRows.nth(1))
await actions.dragAssetRowToAssetRow(assetRows.nth(0), assetRows.nth(1))
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
await test.expect(assetRows).toHaveCount(2)
await test.expect(assetRows.nth(1)).toBeVisible()
@ -99,8 +99,10 @@ test.test('move to trash', async ({ page }) => {
await page.keyboard.down(await actions.modModifier(page))
await actions.clickAssetRow(assetRows.nth(0))
await actions.clickAssetRow(assetRows.nth(1))
await assetRows.nth(0).dragTo(actions.locateTrashCategory(page))
// NOTE: For some reason, `react-aria-components` causes drag-n-drop to break if `Mod` is still
// held.
await page.keyboard.up(await actions.modModifier(page))
await actions.dragAssetRow(assetRows.nth(0), actions.locateTrashCategory(page))
await actions.expectPlaceholderRow(page)
await actions.locateTrashCategory(page).click()
await test.expect(assetRows).toHaveCount(2)

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -34,23 +34,25 @@
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@monaco-editor/react": "4.6.0",
"@sentry/react": "^7.74.0",
"@tanstack/react-query": "^5.27.5",
"ajv": "^8.12.0",
"clsx": "^1.1.1",
"enso-common": "^1.0.0",
"is-network-error": "^1.0.1",
"monaco-editor": "0.47.0",
"react": "^18.2.0",
"react-aria": "^3.32.1",
"react-aria-components": "^1.1.1",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.1",
"react-stately": "^3.30.1",
"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",
"react-aria-components": "^1.1.1"
"tiny-invariant": "^1.3.3",
"ts-results": "^3.3.0",
"validator": "^13.11.0"
},
"devDependencies": {
"@babel/plugin-syntax-import-assertions": "^7.23.3",
@ -60,6 +62,7 @@
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@playwright/experimental-ct-react": "^1.40.0",
"@playwright/test": "^1.40.0",
"@react-types/shared": "^3.22.1",
"@types/node": "^20.11.21",
"@types/react": "^18.0.27",
"@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 type * as loggerProvider from '#/providers/LoggerProvider'
import ModalProvider from '#/providers/ModalProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
import SessionProvider from '#/providers/SessionProvider'
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
@ -186,6 +187,7 @@ function AppRouter(props: AppProps) {
// eslint-disable-next-line no-restricted-properties
const navigate = router.useNavigate()
const { localStorage } = localStorageProvider.useLocalStorage()
const navigator2D = navigator2DProvider.useNavigator2D()
if (detect.IS_DEV_MODE) {
// @ts-expect-error This is used exclusively for debugging.
window.navigate = navigate
@ -276,6 +278,14 @@ function AppRouter(props: AppProps) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
null!
React.useEffect(() => {
const onKeyDown = navigator2D.onKeyDown.bind(navigator2D)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
}
}, [navigator2D])
React.useEffect(() => {
let isClick = false
const onMouseDown = () => {

View File

@ -1,29 +1,27 @@
/**
* @file Button.tsx
*
* Button component
*/
/** @file A styled button. */
import * as React from 'react'
import clsx from 'clsx'
import * as reactAriaComponents from 'react-aria-components'
import * as tailwindMerge from 'tailwind-merge'
import * as focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria'
import Spinner, * as spinnerModule from '#/components/Spinner'
import SvgMask from '#/components/SvgMask'
/**
* Props for the Button component
*/
export interface ButtonProps extends reactAriaComponents.ButtonProps {
// ==============
// === Button ===
// ==============
/** Props for a {@link Button}. */
export interface ButtonProps extends Readonly<aria.ButtonProps> {
readonly loading?: boolean
readonly variant: 'cancel' | 'delete' | 'icon' | 'submit'
readonly icon?: string
/**
* FIXME: This is not yet implemented
/** FIXME: This is not yet implemented
* The position of the icon in the button
* @default 'start'
*/
* @default 'start' */
readonly iconPosition?: 'end' | 'start'
}
@ -39,18 +37,24 @@ const EXTRA_CLICK_ZONE_CLASSES = 'flex relative before:inset-[-12px] before:abso
const DISABLED_CLASSES = 'disabled:opacity-50 disabled:cursor-not-allowed'
const SIZE_CLASSES = 'px-2 py-1'
/**
* A button allows a user to perform an action, with mouse, touch, and keyboard interactions.
*/
export function Button(props: ButtonProps): React.JSX.Element {
const CLASSES_FOR_VARIANT: Record<ButtonProps['variant'], string> = {
cancel: CANCEL_CLASSES,
delete: DELETE_CLASSES,
icon: ICON_CLASSES,
submit: SUBMIT_CLASSES,
}
/** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */
export function Button(props: ButtonProps) {
const { className, children, variant, icon, loading = false, ...ariaButtonProps } = props
const focusChildProps = focusHooks.useFocusChild()
const classes = clsx(
DEFAULT_CLASSES,
DISABLED_CLASSES,
FOCUS_CLASSES,
SIZE_CLASSES,
VARIANT_TO_CLASSES[variant]
CLASSES_FOR_VARIANT[variant]
)
const childrenFactory = (): React.ReactNode => {
@ -58,11 +62,9 @@ export function Button(props: ButtonProps): React.JSX.Element {
return <Spinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
} else if (variant === 'icon' && icon != null) {
return (
<>
<div className={EXTRA_CLICK_ZONE_CLASSES}>
<SvgMask src={icon} />
</div>
</>
)
} else {
return <>{children}</>
@ -70,23 +72,16 @@ export function Button(props: ButtonProps): React.JSX.Element {
}
return (
<reactAriaComponents.Button
className={values =>
<aria.Button
{...aria.mergeProps<aria.ButtonProps>()(ariaButtonProps, focusChildProps, {
className: values =>
tailwindMerge.twMerge(
classes,
typeof className === 'function' ? className(values) : className
)
}
{...ariaButtonProps}
),
})}
>
{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 reactAriaComponents from 'react-aria-components'
import * as tailwindMerge from 'tailwind-merge'
import Dismiss from 'enso-assets/dismiss.svg'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as portal from '#/components/Portal'
@ -50,21 +50,23 @@ export function Dialog(props: types.DialogProps) {
const root = portal.useStrictPortalContext()
return (
<reactAriaComponents.Modal
<aria.Modal
className={tailwindMerge.twMerge(MODAL_CLASSES, [MODAL_CLASSES_BY_TYPE[type]])}
isDismissable={isDismissible}
isKeyboardDismissDisabled={isKeyboardDismissDisabled}
UNSTABLE_portalContainer={root.current}
>
<reactAriaComponents.Dialog
<aria.Dialog
className={tailwindMerge.twMerge(DIALOG_CLASSES, [DIALOG_CLASSES_BY_TYPE[type]], className)}
{...ariaDialogProps}
>
{opts => (
<>
{typeof title === 'string' && (
<reactAriaComponents.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.Header className="center sticky flex flex-none border-b px-3.5 py-2.5 text-primary shadow">
<aria.Heading level={2} className="text-l my-0 font-semibold leading-6">
{title}
</aria.Heading>
<ariaComponents.Button
variant="icon"
@ -72,7 +74,7 @@ export function Dialog(props: types.DialogProps) {
onPress={opts.close}
icon={Dismiss}
/>
</reactAriaComponents.Header>
</aria.Header>
)}
<div className="flex-1 shrink-0">
@ -80,7 +82,7 @@ export function Dialog(props: types.DialogProps) {
</div>
</>
)}
</reactAriaComponents.Dialog>
</reactAriaComponents.Modal>
</aria.Dialog>
</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 reactAriaComponents from 'react-aria-components'
import * as modalProvider from '#/providers/ModalProvider'
import * as aria from '#/components/aria'
import type * as types from './types'
const PLACEHOLDER = <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) {
const { children, onOpenChange, ...triggerProps } = props
@ -24,7 +18,8 @@ export function DialogTrigger(props: types.DialogTriggerProps) {
const onOpenChangeInternal = React.useCallback(
(isOpened: boolean) => {
if (isOpened) {
// we're using a placeholder here just to let the rest of the code know that the modal is open
// We're using a placeholder here just to let the rest of the code know that the modal
// is open.
setModal(PLACEHOLDER)
} else {
unsetModal()
@ -36,10 +31,6 @@ export function DialogTrigger(props: types.DialogTriggerProps) {
)
return (
<reactAriaComponents.DialogTrigger
children={children}
onOpenChange={onOpenChangeInternal}
{...triggerProps}
/>
<aria.DialogTrigger children={children} onOpenChange={onOpenChangeInternal} {...triggerProps} />
)
}

View File

@ -1,22 +1,13 @@
/**
* @file
* Contains the types for the Dialog component.
*/
import type * as reactAriaComponents from 'react-aria-components'
/** @file Types for the Dialog component. */
import type * as aria from '#/components/aria'
/**
*
*/
/** The type of Dialog. */
export type DialogType = 'fullscreen' | 'modal' | 'popover'
/**
* The props for the Dialog component.
*/
export interface DialogProps extends reactAriaComponents.DialogProps {
/**
* The type of dialog to render.
* @default 'modal'
*/
/** Props for the Dialog component. */
export interface DialogProps extends aria.DialogProps {
/** The type of dialog to render.
* @default 'modal' */
readonly type?: DialogType
readonly title?: string
readonly isDismissible?: boolean
@ -24,7 +15,5 @@ export interface DialogProps extends reactAriaComponents.DialogProps {
readonly isKeyboardDismissDisabled?: boolean
}
/**
* The props for the DialogTrigger component.
*/
export interface DialogTriggerProps extends reactAriaComponents.DialogTriggerProps {}
/** The props for the DialogTrigger component. */
export interface DialogTriggerProps extends aria.DialogTriggerProps {}

View File

@ -1,27 +1,19 @@
/**
* @file
*
* A tooltip displays a description of an element on hover or focus.
*/
import * as reactAriaComponents from 'react-aria-components'
/** @file Displays the description of an element on hover or focus. */
import * as tailwindMerge from 'tailwind-merge'
import * as aria from '#/components/aria'
import * as portal from '#/components/Portal'
/**
*
*/
/** Props for a {@link Tooltip}. */
export interface TooltipProps
extends Omit<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_CONTAINER_PADDING = 4
const DEFAULT_OFFSET = 4
/**
* A tooltip displays a description of an element on hover or focus.
*/
/** Displays the description of an element on hover or focus. */
export function Tooltip(props: TooltipProps) {
const { className, containerPadding = DEFAULT_CONTAINER_PADDING, ...ariaTooltipProps } = props
@ -30,7 +22,7 @@ export function Tooltip(props: TooltipProps) {
const classes = tailwindMerge.twJoin(DEFAULT_CLASSES)
return (
<reactAriaComponents.Tooltip
<aria.Tooltip
offset={DEFAULT_OFFSET}
containerPadding={containerPadding}
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
export { TooltipTrigger } from 'react-aria-components'
export const TooltipTrigger = aria.TooltipTrigger

View File

@ -1,6 +1,9 @@
/** @file A select menu with a dropdown. */
import * as React from 'react'
import FocusRing from '#/components/styled/FocusRing'
import Input from '#/components/styled/Input'
// =================
// === Constants ===
// =================
@ -175,16 +178,17 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
return (
<div onKeyDown={onKeyDown} className="grow">
<div className="flex flex-1">
<FocusRing within>
<div className="flex flex-1 rounded-full">
{canEditText ? (
<input
<Input
type={type}
ref={inputRef}
autoFocus={autoFocus}
size={1}
value={text ?? ''}
placeholder={placeholder}
className="text grow bg-transparent px-button-x"
className="text grow rounded-full bg-transparent px-button-x"
onFocus={() => {
setIsDropdownVisible(true)
}}
@ -216,6 +220,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
</div>
)}
</div>
</FocusRing>
<div className="h">
<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 ${

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. */
import * as React from 'react'
import * as focusHooks from '#/hooks/focusHooks'
import * as focusClassProvider from '#/providers/FocusClassProvider'
import * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
import * as aria from '#/components/aria'
import FocusRing from '#/components/styled/FocusRing'
import RadioGroup from '#/components/styled/RadioGroup'
import * as backend from '#/services/Backend'
/** Props for a {@link ColorPickerItem}. */
export interface InternalColorPickerItemProps {
readonly color: backend.LChColor
}
/** An input in a {@link ColorPicker}. */
function ColorPickerItem(props: InternalColorPickerItemProps) {
const { color } = props
const { focusChildClass } = focusClassProvider.useFocusClasses()
const focusDirection = focusDirectionProvider.useFocusDirection()
const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection)
const cssColor = backend.lChColorToCssColor(color)
return (
<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}. */
export interface ColorPickerProps {
export interface ColorPickerProps extends Readonly<aria.RadioGroupProps> {
readonly children?: React.ReactNode
readonly pickerClassName?: string
readonly setColor: (color: backend.LChColor) => void
}
/** A color picker to select from a predetermined list of colors. */
export default function ColorPicker(props: ColorPickerProps) {
const { setColor } = props
function ColorPicker(props: ColorPickerProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { pickerClassName = '', children, setColor, ...radioGroupProps } = props
return (
<div className="flex items-center gap-colors">
{backend.COLORS.map((currentColor, i) => (
<label
key={i}
className="flex size-radio-button cursor-pointer rounded-full"
onClick={event => {
event.stopPropagation()
setColor(currentColor)
<RadioGroup
ref={ref}
{...radioGroupProps}
orientation="horizontal"
onChange={value => {
const color = backend.COLOR_STRING_TO_COLOR.get(value)
if (color != null) {
setColor(color)
}
}}
>
<input type="radio" name="new-label-color" className="peer hidden" />
<button
type="button"
className="group pointer-events-none size-radio-button rounded-full p-radio-button-dot"
style={{ backgroundColor: backend.lChColorToCssColor(currentColor) }}
>
<div className="hidden size-radio-button-dot rounded-full bg-selected-frame peer-checked:group-[]:block" />
</button>
</label>
{children}
<div className={`flex items-center gap-colors ${pickerClassName}`}>
{backend.COLORS.map((currentColor, i) => (
<ColorPickerItem key={i} color={currentColor} />
))}
</div>
</RadioGroup>
)
}
/** 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 FocusArea from '#/components/styled/FocusArea'
// ===================
// === ContextMenu ===
// ===================
/** Props for a {@link ContextMenu}. */
export interface ContextMenuProps extends Readonly<React.PropsWithChildren> {
// eslint-disable-next-line @typescript-eslint/naming-convention
readonly 'aria-label': string
readonly hidden?: boolean
}
@ -17,19 +21,24 @@ export default function ContextMenu(props: ContextMenuProps) {
const { hidden = false, children } = props
return hidden ? (
<>{children}</>
children
) : (
<div className="pointer-events-auto relative rounded-default before:absolute before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default">
<FocusArea direction="vertical">
{innerProps => (
<div
className="pointer-events-auto relative rounded-default before:absolute before:h-full before:w-full before:rounded-default before:bg-selected-frame before:backdrop-blur-default"
{...innerProps}
>
<div
aria-label={props['aria-label']}
className={`relative flex flex-col rounded-default ${
detect.isOnMacOS() ? 'w-context-menu-macos' : 'w-context-menu'
} p-context-menu`}
onClick={clickEvent => {
clickEvent.stopPropagation()
}}
>
{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. */
import * as React from 'react'
import * as focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria'
import FocusRing from '#/components/styled/FocusRing'
// =================
// === Constants ===
// =================
@ -13,8 +18,7 @@ const DEBOUNCE_MS = 1000
// =======================
/** Props for a {@link ControlledInput}. */
export interface ControlledInputProps
extends Readonly<React.InputHTMLAttributes<HTMLInputElement>> {
export interface ControlledInputProps extends Readonly<aria.InputProps> {
readonly value: string
readonly error?: string
readonly validate?: boolean
@ -29,17 +33,28 @@ export default function ControlledInput(props: ControlledInputProps) {
error,
validate = false,
shouldReportValidityRef,
onKeyDown,
onChange,
onBlur,
...passThrough
...inputProps
} = props
const [reportTimeoutHandle, setReportTimeoutHandle] = React.useState<number | null>(null)
const [hasReportedValidity, setHasReportedValidity] = React.useState(false)
const [wasJustBlurred, setWasJustBlurred] = React.useState(false)
const focusChildProps = focusHooks.useFocusChild()
return (
<input
{...passThrough}
onChange={event => {
<FocusRing>
<aria.Input
{...aria.mergeProps<aria.InputProps>()(inputProps, focusChildProps, {
className:
'w-full rounded-full border py-auth-input-y pl-auth-icon-container-w pr-auth-input-r text-sm placeholder-gray-500 transition-all duration-auth hover:bg-gray-100 focus:bg-gray-100',
onKeyDown: event => {
if (!event.isPropagationStopped()) {
onKeyDown?.(event)
}
},
onChange: event => {
onChange?.(event)
setValue(event.target.value)
setWasJustBlurred(false)
@ -63,16 +78,18 @@ export default function ControlledInput(props: ControlledInputProps) {
} else {
setReportTimeoutHandle(
window.setTimeout(() => {
if (shouldReportValidityRef?.current !== false && !currentTarget.reportValidity()) {
if (
shouldReportValidityRef?.current !== false &&
!currentTarget.reportValidity()
) {
setHasReportedValidity(true)
}
}, DEBOUNCE_MS)
)
}
}
}}
onBlur={
validate
},
onBlur: validate
? event => {
onBlur?.(event)
if (wasJustBlurred) {
@ -87,9 +104,9 @@ export default function ControlledInput(props: ControlledInputProps) {
setWasJustBlurred(true)
}
}
: onBlur
}
className="w-full rounded-full border py-auth-input-y pl-auth-icon-container-w pr-auth-input-r text-sm placeholder-gray-500 transition-all duration-auth hover:bg-gray-100 focus:bg-gray-100"
: onBlur,
})}
/>
</FocusRing>
)
}

View File

@ -5,9 +5,14 @@ import CrossIcon from 'enso-assets/cross.svg'
import FolderArrowDoubleIcon from 'enso-assets/folder_arrow_double.svg'
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
import * as focusHooks from '#/hooks/focusHooks'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import UnstyledButton from '#/components/UnstyledButton'
import * as dateTime from '#/utilities/dateTime'
@ -44,6 +49,7 @@ export interface DateInputProps {
export default function DateInput(props: DateInputProps) {
const { date, onInput } = props
const { getText } = textProvider.useText()
const focusChildProps = focusHooks.useFocusChild()
const year = date?.getFullYear() ?? new Date().getFullYear()
const monthIndex = date?.getMonth() ?? new Date().getMonth()
const [isPickerVisible, setIsPickerVisible] = React.useState(false)
@ -94,44 +100,55 @@ export default function DateInput(props: DateInputProps) {
event.stopPropagation()
}}
>
<FocusRing>
<div
role="button"
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={() => {
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(focusChildProps, {
role: 'button',
tabIndex: 0,
className: `flex h-text w-date-picker items-center rounded-full border border-primary/10 px-date-input transition-colors hover:[&:not(:has(button:hover))]:bg-hover-bg ${date == null ? 'placeholder' : ''}`,
onClick: event => {
event.stopPropagation()
setIsPickerVisible(!isPickerVisible)
}}
},
onKeyDown: event => {
if (event.key === 'Enter' || event.key === 'Space') {
event.stopPropagation()
setIsPickerVisible(!isPickerVisible)
}
},
})}
>
<div className="flex grow flex-col items-center">
{date != null ? dateTime.formatDate(date) : 'No date selected'}
{date != null ? dateTime.formatDate(date) : getText('noDateSelected')}
</div>
{date != null && (
<button
<UnstyledButton
className="flex rounded-full transition-colors hover:bg-hover-bg"
onClick={() => {
onPress={() => {
onInput(null)
}}
>
<SvgMask src={CrossIcon} className="size-icon" />
</button>
</UnstyledButton>
)}
</div>
</FocusRing>
{isPickerVisible && (
<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">
<table className="relative w-full">
<caption className="mb-date-input-gap caption-top">
<div className="relative mb-date-input-gap">
<div className="flex items-center">
<button
<UnstyledButton
className="inline-flex rounded-small-rectangle-button hover:bg-hover-bg"
onClick={() => {
onPress={() => {
setSelectedYear(selectedYear - 1)
}}
>
<SvgMask src={FolderArrowDoubleIcon} className="rotate-180" />
</button>
<button
</UnstyledButton>
<UnstyledButton
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
onClick={() => {
onPress={() => {
if (selectedMonthIndex === 0) {
setSelectedYear(selectedYear - 1)
setSelectedMonthIndex(LAST_MONTH_INDEX)
@ -141,13 +158,13 @@ export default function DateInput(props: DateInputProps) {
}}
>
<SvgMask src={FolderArrowIcon} className="rotate-180" />
</button>
<span className="grow">
</UnstyledButton>
<aria.Text className="grow text-center">
{dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear}
</span>
<button
</aria.Text>
<UnstyledButton
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
onClick={() => {
onPress={() => {
if (selectedMonthIndex === LAST_MONTH_INDEX) {
setSelectedYear(selectedYear + 1)
setSelectedMonthIndex(0)
@ -157,17 +174,18 @@ export default function DateInput(props: DateInputProps) {
}}
>
<SvgMask src={FolderArrowIcon} />
</button>
<button
</UnstyledButton>
<UnstyledButton
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
onClick={() => {
onPress={() => {
setSelectedYear(selectedYear + 1)
}}
>
<SvgMask src={FolderArrowDoubleIcon} />
</button>
</UnstyledButton>
</div>
</caption>
</div>
<table className="relative w-full">
<thead>
<tr>
<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.getDate() === date.getDate()
return (
<td
key={j}
className="text-tight p"
onClick={() => {
<td key={j} className="text-tight p">
<UnstyledButton
isDisabled={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'}`}
onPress={() => {
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'}`}
>
{day.date}
</button>
</UnstyledButton>
</td>
)
})}

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import CheckMarkIcon from 'enso-assets/check_mark.svg'
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
// ================
@ -49,12 +50,13 @@ interface InternalMultipleDropdownProps<T> extends InternalBaseDropdownProps<T>
export type DropdownProps<T> = InternalMultipleDropdownProps<T> | InternalSingleDropdownProps<T>
/** 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 [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
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 justBlurredRef = React.useRef(false)
const isMouseDown = React.useRef(false)
const multiple = props.multiple === true
const selectedIndex = 'selectedIndex' in props ? props.selectedIndex : null
@ -79,6 +81,7 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
React.useEffect(() => {
const onDocumentClick = () => {
setIsDropdownVisible(false)
justBlurredRef.current = true
}
document.addEventListener('click', onDocumentClick)
return () => {
@ -97,6 +100,9 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
case 'Enter':
case 'Tab': {
event.stopPropagation()
if (event.key === 'Enter') {
setIsDropdownVisible(true)
}
if (tempSelectedIndex != null) {
const item = items[tempSelectedIndex]
if (item != null) {
@ -116,12 +122,14 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
}
}
}
if (event.key !== 'Enter' || !multiple) {
if (isDropdownVisible && (event.key !== 'Enter' || !multiple)) {
setIsDropdownVisible(false)
justBlurredRef.current = true
}
break
}
case 'ArrowUp': {
if (!isDropdownVisible) break
event.preventDefault()
setTempSelectedIndex(
tempSelectedIndex == null ||
@ -133,6 +141,7 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
break
}
case 'ArrowDown': {
if (!isDropdownVisible) break
event.preventDefault()
setTempSelectedIndex(
tempSelectedIndex == null || tempSelectedIndex >= items.length - 1
@ -146,22 +155,31 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
}
return (
<FocusRing placement="outset">
<div
ref={rootRef}
ref={element => {
if (typeof ref === 'function') {
ref(element)
} else if (ref != null) {
ref.current = element
}
rootRef.current = element
}}
tabIndex={0}
className={`group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy ${
className={`focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy ${
className ?? ''
}`}
onFocus={event => {
if (!readOnly && event.target === event.currentTarget) {
if (!justBlurredRef.current && !readOnly && event.target === event.currentTarget) {
setIsDropdownVisible(true)
justFocusedRef.current = true
}
justBlurredRef.current = false
}}
onBlur={event => {
// TODO: should not blur when `multiple` and clicking on option
if (!readOnly && event.target === event.currentTarget) {
setIsDropdownVisible(false)
justBlurredRef.current = true
}
}}
onKeyDown={onKeyDown}
@ -179,7 +197,7 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
className={`relative before:absolute before:top before:w-full before:rounded-input before:border before:border-primary/10 before:backdrop-blur-default before:transition-colors ${
isDropdownVisible
? 'before:h-full before:shadow-soft'
: 'before:h-text group-hover:before:bg-frame'
: 'before:h-text group-hover:before:bg-hover-bg'
}`}
>
{/* Spacing. */}
@ -206,8 +224,8 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
multiple ? 'hover:font-semibold' : ''
} ${
i === visuallySelectedIndex
? `cursor-default bg-frame font-bold`
: 'hover:bg-primary/10'
? `cursor-default bg-frame font-bold focus-ring`
: 'hover:bg-hover-bg'
}`}
key={i}
onMouseDown={event => {
@ -234,6 +252,7 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
} else {
setIsDropdownVisible(false)
props.onClick(item, i)
justBlurredRef.current = true
}
}
}}
@ -267,6 +286,7 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
event.stopPropagation()
if (!justFocusedRef.current && !readOnly) {
setIsDropdownVisible(false)
justBlurredRef.current = true
}
justFocusedRef.current = false
}}
@ -284,15 +304,20 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
* 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' : ''}`}
>
<div key={i} className="flex gap-dropdown-arrow px-input-x font-bold">
<SvgMask src={CheckMarkIcon} />
<Child item={item} />
</div>
))}
</div>
</div>
</FocusRing>
)
}
/** A styled dropdown. */
// This is REQUIRED, as `React.forwardRef` does not preserve types of generic functions.
// eslint-disable-next-line no-restricted-syntax
export default React.forwardRef(Dropdown) as <T>(
props: DropdownProps<T> & React.RefAttributes<HTMLDivElement>
) => JSX.Element

View File

@ -9,8 +9,12 @@ import * as eventCalback from '#/hooks/eventCallbackHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import UnstyledButton from '#/components/UnstyledButton'
import * as eventModule from '#/utilities/event'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
// ====================
@ -34,30 +38,32 @@ export interface EditableSpanProps {
/** A `<span>` that can turn into an `<input type="text">`. */
export default function EditableSpan(props: EditableSpanProps) {
const { 'data-testid': dataTestId, className, editable = false, children } = props
const { className, editable = false, children } = props
const { checkSubmittable, onSubmit, onCancel, inputPattern, inputTitle } = props
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const [isSubmittable, setIsSubmittable] = React.useState(true)
const [isSubmittable, setIsSubmittable] = React.useState(false)
const inputRef = React.useRef<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.
// to prevent the effect from re-running.
const onCancelEventCallback = eventCalback.useEventCallback(onCancel)
React.useEffect(() => {
setIsSubmittable(checkSubmittable?.(inputRef.current?.value ?? '') ?? true)
// This effect MUST only run on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (editable) {
setIsSubmittable(checkSubmittableRef.current?.(inputRef.current?.value ?? '') ?? true)
}
}, [editable])
React.useEffect(() => {
if (editable) {
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
cancelEditName: () => {
onCancelEventCallback()
cancelled.current = true
cancelledRef.current = true
inputRef.current?.blur()
},
})
@ -67,7 +73,7 @@ export default function EditableSpan(props: EditableSpanProps) {
}, [editable, /* should never change */ inputBindings, onCancelEventCallback])
React.useEffect(() => {
cancelled.current = false
cancelledRef.current = false
}, [editable])
if (editable) {
@ -83,25 +89,28 @@ export default function EditableSpan(props: EditableSpanProps) {
}
}}
>
<input
data-testid={dataTestId}
className={className}
<aria.Input
data-testid={props['data-testid']}
className={className ?? ''}
ref={inputRef}
autoFocus
type="text"
size={1}
defaultValue={children}
onBlur={event => {
if (!cancelled.current) {
event.currentTarget.form?.requestSubmit()
const currentTarget = event.currentTarget
// This must run AFTER the cancel button's event handler runs.
setTimeout(() => {
if (!cancelledRef.current) {
currentTarget.form?.requestSubmit()
}
})
}}
onContextMenu={event => {
event.stopPropagation()
}}
onKeyDown={event => {
if (event.key !== 'Escape') {
// The input may handle the event.
event.stopPropagation()
}
}}
@ -116,36 +125,34 @@ export default function EditableSpan(props: EditableSpanProps) {
})}
/>
{isSubmittable && (
<button
type="submit"
<UnstyledButton
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" />
</button>
</UnstyledButton>
)}
<button
type="button"
<FocusRing>
<UnstyledButton
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
onMouseDown={() => {
cancelled.current = true
}}
onClick={event => {
event.stopPropagation()
onPress={() => {
cancelledRef.current = true
onCancel()
window.setTimeout(() => {
cancelled.current = false
cancelledRef.current = false
})
}}
>
<SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-icon" />
</button>
</UnstyledButton>
</FocusRing>
</form>
)
} else {
return (
<span data-testid={dataTestId} className={className}>
<aria.Text data-testid={props['data-testid']} className={className}>
{children}
</span>
</aria.Text>
)
}
}

View File

@ -4,8 +4,13 @@ import * as React from 'react'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import Autocomplete from '#/components/Autocomplete'
import Dropdown from '#/components/Dropdown'
import Checkbox from '#/components/styled/Checkbox'
import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing'
import UnstyledButton from '#/components/UnstyledButton'
import * as jsonSchema from '#/utilities/jsonSchema'
import * as object from '#/utilities/object'
@ -95,12 +100,15 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
)
} else {
children.push(
<input
<FocusArea direction="horizontal">
{innerProps => (
<FocusRing>
<aria.Input
type="text"
readOnly={readOnly}
value={typeof value === 'string' ? value : ''}
size={1}
className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
}`}
placeholder={getText('enterText')}
@ -108,19 +116,26 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
const newValue: string = event.currentTarget.value
setValue(newValue)
}}
{...innerProps}
/>
</FocusRing>
)}
</FocusArea>
)
}
break
}
case 'number': {
children.push(
<input
<FocusArea direction="horizontal">
{innerProps => (
<FocusRing>
<aria.Input
type="number"
readOnly={readOnly}
value={typeof value === 'number' ? value : ''}
size={1}
className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
}`}
placeholder={getText('enterNumber')}
@ -130,18 +145,25 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
setValue(newValue)
}
}}
{...innerProps}
/>
</FocusRing>
)}
</FocusArea>
)
break
}
case 'integer': {
children.push(
<input
<FocusArea direction="horizontal">
{innerProps => (
<FocusRing>
<aria.Input
type="number"
readOnly={readOnly}
value={typeof value === 'number' ? value : ''}
size={1}
className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
className={`focus-child w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
}`}
placeholder={getText('enterInteger')}
@ -149,20 +171,20 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
setValue(newValue)
}}
{...innerProps}
/>
</FocusRing>
)}
</FocusArea>
)
break
}
case 'boolean': {
children.push(
<input
type="checkbox"
readOnly={readOnly}
checked={typeof value === 'boolean' && value}
onChange={event => {
const newValue: boolean = event.currentTarget.checked
setValue(newValue)
}}
<Checkbox
isReadOnly={readOnly}
isSelected={typeof value === 'boolean' && value}
onChange={setValue}
/>
)
break
@ -194,15 +216,14 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
? { title: String(childSchema.description) }
: {})}
>
<button
type="button"
disabled={!isOptional}
className={`text selectable ${
value != null && key in value ? 'active' : ''
} inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left ${
<FocusArea active={isOptional} direction="horizontal">
{innerProps => (
<UnstyledButton
isDisabled={!isOptional}
className={`text inline-block w-json-schema-object-key whitespace-nowrap rounded-full px-button-x text-left ${
isOptional ? 'hover:bg-hover-bg' : ''
}`}
onClick={() => {
onPress={() => {
if (isOptional) {
setValue(oldValue => {
if (oldValue != null && key in oldValue) {
@ -223,9 +244,18 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
})
}
}}
{...innerProps}
>
<aria.Text
className={`selectable ${
value != null && key in value ? 'active' : ''
}`}
>
{'title' in childSchema ? String(childSchema.title) : key}
</button>
</aria.Text>
</UnstyledButton>
)}
</FocusArea>
{value != null && key in value && (
<JSONSchemaInput
readOnly={readOnly}
@ -293,6 +323,8 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
}
}
const dropdown = (
<FocusArea direction="horizontal">
{innerProps => (
<Dropdown
readOnly={readOnly}
items={childSchemas}
@ -304,7 +336,10 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
const newConstantValue = jsonSchema.constantValue(defs, childSchema, true)
setValue(newConstantValue[0] ?? null)
}}
{...innerProps}
/>
)}
</FocusArea>
)
children.push(
<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 focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
// ============
@ -19,13 +23,20 @@ export interface LinkProps {
/** A styled colored link with an icon. */
export default function Link(props: LinkProps) {
const { to, icon, text } = props
const focusChildProps = focusHooks.useFocusChild()
return (
<FocusRing>
<router.Link
to={to}
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,
className:
'flex items-center gap-auth-link rounded-full px-auth-link-x py-auth-link-y text-center text-xs font-bold text-blue-500 transition-all duration-auth hover:text-blue-700 focus:text-blue-700',
})}
>
<SvgMask src={icon} />
{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 textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
import SvgMask from '#/components/SvgMask'
import UnstyledButton from '#/components/UnstyledButton'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
@ -69,7 +71,7 @@ export interface MenuEntryProps {
/** Overrides the text for the menu entry. */
readonly label?: string
/** When true, the button is not clickable. */
readonly disabled?: boolean
readonly isDisabled?: boolean
readonly title?: string
readonly isContextMenuEntry?: boolean
readonly doAction: () => void
@ -77,48 +79,40 @@ export interface MenuEntryProps {
/** An item in a menu. */
export default function MenuEntry(props: MenuEntryProps) {
const {
hidden = false,
action,
label,
disabled = false,
title,
isContextMenuEntry = false,
} = props
const { doAction } = props
const { hidden = false, action, label, isDisabled = false, title } = props
const { isContextMenuEntry = false, doAction } = props
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const info = inputBindings.metadata[action]
React.useEffect(() => {
// This is slower (but more convenient) than registering every shortcut in the context menu
// at once.
if (disabled) {
if (isDisabled) {
return
} else {
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
[action]: doAction,
})
}
}, [disabled, inputBindings, action, doAction])
}, [isDisabled, inputBindings, action, doAction])
return hidden ? null : (
<button
disabled={disabled}
title={title}
className={`items -center flex h-row
place-content-between rounded-menu-entry p-menu-entry text-left selectable enabled:active hover:bg-hover-bg disabled:bg-transparent ${
<UnstyledButton
isDisabled={isDisabled}
className="group flex w-full rounded-menu-entry"
onPress={doAction}
>
<div
className={`flex h-row grow place-content-between items-center rounded-inherit p-menu-entry text-left selectable group-enabled:active hover:bg-hover-bg disabled:bg-transparent ${
isContextMenuEntry ? 'px-context-menu-entry-x' : ''
}`}
onClick={event => {
event.stopPropagation()
doAction()
}}
>
<div className="flex items-center gap-menu-entry whitespace-nowrap">
<div title={title} className="flex items-center gap-menu-entry whitespace-nowrap">
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" />
{label ?? getText(ACTION_TO_TEXT_ID[action])}
<aria.Text slot="label">{label ?? getText(ACTION_TO_TEXT_ID[action])}</aria.Text>
</div>
<KeyboardShortcut action={action} />
</button>
</div>
</UnstyledButton>
)
}

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import * as modalProvider from '#/providers/ModalProvider'
import FocusRoot from '#/components/styled/FocusRoot'
// =================
// === Component ===
// =================
@ -29,6 +31,8 @@ export default function Modal(props: ModalProps) {
const { unsetModal } = modalProvider.useSetModal()
return (
<FocusRoot active={!hidden}>
{innerProps => (
<div
// The name comes from a third-party API and cannot be changed.
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -47,8 +51,17 @@ export default function Modal(props: ModalProps) {
})
}
onContextMenu={onContextMenu}
{...innerProps}
onKeyDown={event => {
innerProps.onKeyDown?.(event)
if (event.key !== 'Escape') {
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 reactAriaComponents from 'react-aria-components'
import * as aria from '#/components/aria'
import * as portal from '#/components/Portal'
/**
* Props for the root component
*/
/** Props for {@link Root}. */
export interface RootProps extends React.PropsWithChildren {
readonly rootRef: React.RefObject<HTMLElement>
readonly navigate: (path: string) => void
readonly locale?: string
}
/**
* The root component with required providers
*/
/** The root component with required providers. */
export function Root(props: RootProps) {
const { children, rootRef, navigate, locale = 'en-US' } = props
return (
<portal.PortalProvider value={rootRef}>
<reactAriaComponents.RouterProvider navigate={navigate}>
<reactAriaComponents.I18nProvider locale={locale}>
{children}
</reactAriaComponents.I18nProvider>
</reactAriaComponents.RouterProvider>
<aria.RouterProvider navigate={navigate}>
<aria.I18nProvider locale={locale}>{children}</aria.I18nProvider>
</aria.RouterProvider>
</portal.PortalProvider>
)
}

View File

@ -116,13 +116,13 @@ export default function SelectionBrush(props: SelectionBrushProps) {
document.addEventListener('mouseup', onMouseUp)
document.addEventListener('dragstart', onDragStart, { capture: true })
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('click', onClick)
document.addEventListener('click', onClick, { capture: true })
return () => {
document.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mouseup', onMouseUp)
document.removeEventListener('dragstart', onDragStart, { capture: true })
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('click', onClick)
document.removeEventListener('click', onClick, { capture: true })
}
}, [/* should never change */ modalRef])

View File

@ -1,7 +1,9 @@
/** @file A styled submit button. */
import * as React from 'react'
import type * as aria from '#/components/aria'
import SvgMask from '#/components/SvgMask'
import UnstyledButton from '#/components/UnstyledButton'
// ====================
// === SubmitButton ===
@ -9,22 +11,24 @@ import SvgMask from '#/components/SvgMask'
/** Props for a {@link SubmitButton}. */
export interface SubmitButtonProps {
readonly disabled?: boolean
readonly isDisabled?: boolean
readonly text: string
readonly icon: string
readonly onPress: (event: aria.PressEvent) => void
}
/** A styled submit button. */
export default function SubmitButton(props: SubmitButtonProps) {
const { disabled = false, text, icon } = props
const { isDisabled = false, text, icon, onPress } = props
return (
<button
disabled={disabled}
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 focus:outline-none`}
<UnstyledButton
isDisabled={isDisabled}
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`}
onPress={onPress}
>
{text}
<SvgMask src={icon} />
</button>
</UnstyledButton>
)
}

View File

@ -7,6 +7,7 @@ import * as React from 'react'
/** Props for a {@link SvgMask}. */
export interface SvgMaskProps {
readonly invert?: boolean
readonly alt?: string
/** The URL of the SVG to use as the mask. */
readonly src: string
@ -24,8 +25,9 @@ export interface SvgMaskProps {
/** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */
export default function SvgMask(props: SvgMaskProps) {
const { alt, src, title, style, color, className, onClick } = props
const { invert = false, alt, src, title, style, color, className, onClick } = props
const urlSrc = `url(${JSON.stringify(src)})`
const mask = invert ? `${urlSrc}, linear-gradient(white 0 0)` : urlSrc
return (
<div
@ -34,16 +36,18 @@ export default function SvgMask(props: SvgMaskProps) {
style={{
...(style ?? {}),
backgroundColor: color ?? 'currentcolor',
mask: urlSrc,
mask,
maskPosition: 'center',
maskRepeat: 'no-repeat',
maskSize: 'contain',
...(invert ? { maskComposite: 'exclude, exclude' } : {}),
// The names come from a third-party API and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
WebkitMask: urlSrc,
WebkitMask: mask,
WebkitMaskPosition: 'center',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskSize: 'contain',
...(invert ? { WebkitMaskComposite: 'exclude, exclude' } : {}),
/* eslint-enable @typescript-eslint/naming-convention */
}}
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 textProvider from '#/providers/TextProvider'
import Button from '#/components/Button'
import Button from '#/components/styled/Button'
import FocusArea from '#/components/styled/FocusArea'
import * as backendModule from '#/services/Backend'
/** Props for an {@link AssetInfoBar}. */
export interface AssetInfoBarProps {
/** When `true`, the element occupies space in the layout but is not visible.
* Defaults to `false`. */
readonly invisible?: boolean
readonly isAssetPanelEnabled: boolean
readonly setIsAssetPanelEnabled: React.Dispatch<React.SetStateAction<boolean>>
}
@ -20,30 +24,30 @@ export interface AssetInfoBarProps {
// This parameter will be used in the future.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function AssetInfoBar(props: AssetInfoBarProps) {
const {
isAssetPanelEnabled: isAssetPanelVisible,
setIsAssetPanelEnabled: setIsAssetPanelVisible,
} = props
const { invisible = false, isAssetPanelEnabled, setIsAssetPanelEnabled } = props
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
return (
<FocusArea active={!invisible} direction="horizontal">
{innerProps => (
<div
className={`pointer-events-auto flex h-row shrink-0 cursor-default items-center gap-icons rounded-full bg-frame px-icons-x ${
backend.type === backendModule.BackendType.remote ? '' : 'invisible'
}`}
onClick={event => {
event.stopPropagation()
}}
{...innerProps}
>
<Button
alt={isAssetPanelVisible ? getText('closeAssetPanel') : getText('openAssetPanel')}
active={isAssetPanelVisible}
alt={isAssetPanelEnabled ? getText('closeAssetPanel') : getText('openAssetPanel')}
active={isAssetPanelEnabled}
image={SettingsIcon}
error={getText('multipleAssetsSettingsError')}
onClick={() => {
setIsAssetPanelVisible(visible => !visible)
onPress={() => {
setIsAssetPanelEnabled(visible => !visible)
}}
/>
</div>
)}
</FocusArea>
)
}

View File

@ -18,10 +18,12 @@ import AssetListEventType from '#/events/AssetListEventType'
import AssetContextMenu from '#/layouts/AssetContextMenu'
import type * as assetsTable from '#/layouts/AssetsTable'
import * as aria from '#/components/aria'
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
import * as columnModule from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import FocusRing from '#/components/styled/FocusRing'
import EditAssetDescriptionModal from '#/modals/EditAssetDescriptionModal'
@ -76,6 +78,7 @@ export interface AssetRowProps
readonly setSelected: (selected: boolean) => void
readonly isSoleSelected: boolean
readonly isKeyboardSelected: boolean
readonly grabKeyboardFocus: () => void
readonly allowContextMenu: boolean
readonly onClick: (props: AssetRowInnerProps, event: React.MouseEvent) => void
readonly onContextMenu?: (
@ -88,6 +91,7 @@ export interface AssetRowProps
export default function AssetRow(props: AssetRowProps) {
const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelected, isKeyboardSelected } = props
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
const { grabKeyboardFocus } = props
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent, nodeMap } = state
const { setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef } = state
@ -99,7 +103,10 @@ export default function AssetRow(props: AssetRowProps) {
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const [item, setItem] = React.useState(rawItem)
const rootRef = React.useRef<HTMLElement | null>(null)
const dragOverTimeoutHandle = React.useRef<number | null>(null)
const grabKeyboardFocusRef = React.useRef(grabKeyboardFocus)
grabKeyboardFocusRef.current = grabKeyboardFocus
const asset = item.item
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
@ -130,6 +137,13 @@ export default function AssetRow(props: AssetRowProps) {
}
}, [selected, insertionVisibility, /* should never change */ setSelected])
React.useEffect(() => {
if (isKeyboardSelected) {
rootRef.current?.focus()
grabKeyboardFocusRef.current()
}
}, [isKeyboardSelected])
const doCopyOnBackend = React.useCallback(
async (newParentId: backendModule.DirectoryId | null) => {
try {
@ -661,10 +675,12 @@ export default function AssetRow(props: AssetRowProps) {
return (
<>
{!hidden && (
<FocusRing>
<tr
draggable
tabIndex={-1}
tabIndex={0}
ref={element => {
rootRef.current = element
if (isSoleSelected && element != null && scrollContainerRef.current != null) {
const rect = element.getBoundingClientRect()
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
@ -677,10 +693,11 @@ export default function AssetRow(props: AssetRowProps) {
})
}
}
if (isKeyboardSelected && element?.contains(document.activeElement) === false) {
element.focus()
}
}}
className={`h-row rounded-full outline-2 -outline-offset-2 outline-primary ease-in-out ${visibility} ${
isKeyboardSelected ? 'outline' : ''
} ${isDraggedOver || selected ? 'selected' : ''}`}
className={`h-row rounded-full transition-all ease-in-out ${visibility} ${isDraggedOver || selected ? 'selected' : ''}`}
onClick={event => {
unsetModal()
onClick(innerProps, event)
@ -772,7 +789,10 @@ export default function AssetRow(props: AssetRowProps) {
? [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)) {
if (
payload != null &&
payload.every(innerItem => innerItem.key !== directoryKey)
) {
event.preventDefault()
event.stopPropagation()
unsetModal()
@ -823,6 +843,7 @@ export default function AssetRow(props: AssetRowProps) {
)
})}
</tr>
</FocusRing>
)}
{selected && allowContextMenu && !hidden && (
// This is a copy of the context menu, since the context menu registers keyboard
@ -873,7 +894,9 @@ export default function AssetRow(props: AssetRowProps) {
className={`flex h-row items-center rounded-full ${indent.indentClass(item.depth)}`}
>
<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>
</td>
</tr>

View File

@ -5,6 +5,7 @@ import BreadcrumbArrowIcon from 'enso-assets/breadcrumb_arrow.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import AssetIcon from '#/components/dashboard/AssetIcon'
import type * as backend from '#/services/Backend'
@ -32,7 +33,7 @@ export default function AssetSummary(props: AssetSummaryProps) {
<AssetIcon asset={asset} />
</div>
<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}
{newName != null && (
<>
@ -40,13 +41,13 @@ export default function AssetSummary(props: AssetSummaryProps) {
{newName}
</>
)}
</span>
</aria.Text>
{!isNew && (
<span>
<aria.Text>
{getText('lastModifiedOn', dateTime.formatDateTime(new Date(asset.modifiedAt)))}
</span>
</aria.Text>
)}
<span>{asset.labels}</span>
<aria.Text>{asset.labels}</aria.Text>
</div>
</div>
)

View File

@ -166,6 +166,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
}`}
checkSubmittable={newTitle =>
newTitle !== item.item.title &&
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
child =>
// All siblings,

View File

@ -143,6 +143,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
editable={false}
className="text grow bg-transparent"
checkSubmittable={newTitle =>
newTitle !== item.item.title &&
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
child =>
// All siblings,

View File

@ -15,6 +15,7 @@ import type * as dashboardInputBindings from '#/configurations/inputBindings'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import SvgMask from '#/components/SvgMask'
import * as inputBindingsModule from '#/utilities/inputBindings'
@ -55,18 +56,18 @@ const MODIFIER_JSX: Readonly<
},
[detect.Platform.linux]: {
Meta: props => (
<span key="Meta" className="text">
<aria.Text key="Meta" className="text">
{props.getText('superModifier')}
</span>
</aria.Text>
),
},
[detect.Platform.unknown]: {
// Assume the system is Unix-like and calls the key that triggers `event.metaKey`
// the "Super" key.
Meta: props => (
<span key="Meta" className="text">
<aria.Text key="Meta" className="text">
{props.getText('superModifier')}
</span>
</aria.Text>
),
},
/* eslint-enable @typescript-eslint/naming-convention */
@ -119,7 +120,7 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
.sort(inputBindingsModule.compareModifiers)
.map(inputBindingsModule.toModifierKey)
return (
<div
<aria.Keyboard
className={`flex h-text items-center ${
detect.isOnMacOS() ? 'gap-modifiers-macos' : 'gap-modifiers'
}`}
@ -127,15 +128,15 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
{modifiers.map(
modifier =>
MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? (
<span key={modifier} className="text">
<aria.Text key={modifier} className="text">
{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}
</span>
</div>
</aria.Text>
</aria.Keyboard>
)
}
}

View File

@ -1,6 +1,13 @@
/** @file An label that can be applied to an asset. */
import * as React from 'react'
import * as focusHooks from '#/hooks/focusHooks'
import * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
import type * as aria from '#/components/aria'
import FocusRing from '#/components/styled/FocusRing'
import * as backend from '#/services/Backend'
// =============
@ -8,10 +15,7 @@ import * as backend from '#/services/Backend'
// =============
/** Props for a {@link Label}. */
interface InternalLabelProps
extends Readonly<React.PropsWithChildren>,
Readonly<Omit<JSX.IntrinsicElements['button'], 'color' | 'onClick'>>,
Readonly<Required<Pick<JSX.IntrinsicElements['button'], 'onClick'>>> {
interface InternalLabelProps extends Readonly<React.PropsWithChildren> {
// This matches the capitalization of `data-` attributes in React.
// eslint-disable-next-line @typescript-eslint/naming-convention
readonly 'data-testid'?: string
@ -21,43 +25,60 @@ interface InternalLabelProps
* or that it is excluded from search. */
readonly negated?: boolean
/** When true, the button cannot be clicked. */
readonly disabled?: boolean
readonly isDisabled?: boolean
readonly draggable?: boolean
readonly color: backend.LChColor
readonly title?: string
readonly className?: string
readonly onPress: (event: aria.PressEvent | React.MouseEvent<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. */
export default function Label(props: InternalLabelProps) {
const {
'data-testid': dataTestId,
active = false,
disabled = false,
color,
negated = false,
className = 'text-tag-text',
children,
...passthrough
} = props
const textColorClassName = /\btext-/.test(className)
const { active = false, isDisabled = false, color, negated = false, draggable, title } = props
const { className = 'text-tag-text', children, onPress, onDragStart, onContextMenu } = props
const focusDirection = focusDirectionProvider.useFocusDirection()
const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection)
const textClass = /\btext-/.test(className)
? '' // eslint-disable-next-line @typescript-eslint/no-magic-numbers
: color.lightness <= 50
? 'text-tag-text'
: active
? 'text-primary'
: 'text-not-selected'
: 'text-primary'
return (
<FocusRing within placement="after">
<div
className={`relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-inherit ${negated ? 'after:!outline-offset-0' : ''}`}
>
{/* An `aria.Button` MUST NOT be used here, as it breaks dragging. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<button
data-testid={dataTestId}
disabled={disabled}
className={`selectable ${
type="button"
data-testid={props['data-testid']}
draggable={draggable}
title={title}
disabled={isDisabled}
className={`focus-child selectable ${
active ? 'active' : ''
} relative flex h-text items-center whitespace-nowrap rounded-full px-label-x transition-all before:absolute before:inset before:rounded-full ${
negated ? 'before:border-2 before:border-delete' : ''
} ${className} ${textColorClassName}`}
} 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) }}
{...passthrough}
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. */
import * as React from 'react'
import type * as aria from '#/components/aria'
import UnstyledButton from '#/components/UnstyledButton'
import * as permissionsModule from '#/utilities/permissions'
// =================
@ -11,14 +14,12 @@ import * as permissionsModule from '#/utilities/permissions'
export interface PermissionDisplayProps extends Readonly<React.PropsWithChildren> {
readonly action: permissionsModule.PermissionAction
readonly className?: string
readonly onClick?: React.MouseEventHandler<HTMLButtonElement>
readonly onMouseEnter?: React.MouseEventHandler<HTMLButtonElement>
readonly onMouseLeave?: React.MouseEventHandler<HTMLButtonElement>
readonly onPress?: (event: aria.PressEvent) => void
}
/** Colored border around icons and text indicating permissions. */
export default function PermissionDisplay(props: PermissionDisplayProps) {
const { action, className, onClick, onMouseEnter, onMouseLeave, children } = props
const { action, className, onPress: onPress, children } = props
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
switch (permission.type) {
@ -26,29 +27,25 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
case permissionsModule.Permission.admin:
case permissionsModule.Permission.edit: {
return (
<button
disabled={!onClick}
<UnstyledButton
isDisabled={!onPress}
className={`${
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
} inline-block h-text whitespace-nowrap rounded-full px-permission-mini-button-x py-permission-mini-button-y ${
className ?? ''
}`}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onPress={onPress ?? (() => {})}
>
{children}
</button>
</UnstyledButton>
)
}
case permissionsModule.Permission.read:
case permissionsModule.Permission.view: {
return (
<button
<UnstyledButton
className={`relative inline-block whitespace-nowrap rounded-full ${className ?? ''}`}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onPress={onPress ?? (() => {})}
>
{permission.docs && (
<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}
</div>
</button>
</UnstyledButton>
)
}
}

View File

@ -3,8 +3,10 @@ import * as React from 'react'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import PermissionTypeSelector from '#/components/dashboard/PermissionTypeSelector'
import Modal from '#/components/Modal'
import UnstyledButton from '#/components/UnstyledButton'
import type * as backend from '#/services/Backend'
@ -35,7 +37,7 @@ const LABEL_STRAIGHT_WIDTH_PX = 97
export interface PermissionSelectorProps {
readonly showDelete?: boolean
/** When `true`, the button is not clickable. */
readonly disabled?: boolean
readonly isDisabled?: boolean
/** When `true`, the button has lowered opacity when it is disabled. */
readonly input?: boolean
/** Overrides the vertical offset of the {@link PermissionTypeSelector}. */
@ -52,12 +54,13 @@ export interface PermissionSelectorProps {
/** A horizontal selector for all possible permissions. */
export default function PermissionSelector(props: PermissionSelectorProps) {
const { showDelete = false, disabled = false, input = false, typeSelectorYOffsetPx } = props
const { showDelete = false, isDisabled = false, input = false, typeSelectorYOffsetPx } = props
const { error, selfPermission, action: actionRaw, assetType, className } = props
const { onChange, doDelete } = props
const { getText } = textProvider.useText()
const [action, setActionRaw] = React.useState(actionRaw)
const [TheChild, setTheChild] = React.useState<(() => JSX.Element) | null>()
const permissionSelectorButtonRef = React.useRef<HTMLButtonElement>(null)
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
const setAction = (newAction: permissions.PermissionAction) => {
@ -65,8 +68,9 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
onChange(newAction)
}
const doShowPermissionTypeSelector = (event: React.SyntheticEvent<HTMLElement>) => {
const position = event.currentTarget.getBoundingClientRect()
const doShowPermissionTypeSelector = () => {
if (permissionSelectorButtonRef.current != null) {
const position = permissionSelectorButtonRef.current.getBoundingClientRect()
const originalLeft = position.left + window.scrollX
const originalTop = position.top + window.scrollY
const left = originalLeft + TYPE_SELECTOR_X_OFFSET_PX
@ -124,6 +128,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
}
)
}
}
let permissionDisplay: JSX.Element
@ -132,26 +137,23 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
case permissionsModule.Permission.view: {
permissionDisplay = (
<div className="flex w-permission-display gap-px">
<button
type="button"
disabled={disabled}
{...(disabled && error != null ? { title: error } : {})}
className={`selectable ${!disabled || !input ? 'active' : ''} ${
<UnstyledButton
ref={permissionSelectorButtonRef}
isDisabled={isDisabled}
{...(isDisabled && error != null ? { title: error } : {})}
className={`selectable ${!isDisabled || !input ? 'active' : ''} ${
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
} 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])}
</button>
<button
type="button"
disabled={disabled}
{...(disabled && error != null ? { title: error } : {})}
className={`selectable ${permission.docs && (!disabled || !input) ? 'active' : ''} ${
permissionsModule.DOCS_CLASS_NAME
} h-text grow px-permission-mini-button-x py-permission-mini-button-y`}
onClick={event => {
event.stopPropagation()
<aria.Text>{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}</aria.Text>
</UnstyledButton>
<UnstyledButton
isDisabled={isDisabled}
focusRingPlacement="after"
{...(isDisabled && error != null ? { title: error } : {})}
className="relative h-text grow after:absolute after:inset"
onPress={() => {
setAction(
permissionsModule.toPermissionAction({
type: permission.type,
@ -160,18 +162,21 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
})
)
}}
>
<aria.Text
className={`selectable ${permission.docs && (!isDisabled || !input) ? 'active' : ''} ${
permissionsModule.DOCS_CLASS_NAME
} h-text grow px-permission-mini-button-x py-permission-mini-button-y`}
>
{getText('docsPermissionModifier')}
</button>
<button
type="button"
disabled={disabled}
{...(disabled && error != null ? { title: error } : {})}
className={`selectable ${permission.execute && (!disabled || !input) ? 'active' : ''} ${
permissionsModule.EXEC_CLASS_NAME
} h-text grow rounded-r-full px-permission-mini-button-x py-permission-mini-button-y`}
onClick={event => {
event.stopPropagation()
</aria.Text>
</UnstyledButton>
<UnstyledButton
isDisabled={isDisabled}
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(
permissionsModule.toPermissionAction({
type: permission.type,
@ -180,26 +185,32 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
})
)
}}
>
<aria.Text
className={`selectable ${permission.execute && (!isDisabled || !input) ? 'active' : ''} ${
permissionsModule.EXEC_CLASS_NAME
} rounded-r-full px-permission-mini-button-x py-permission-mini-button-y`}
>
{getText('execPermissionModifier')}
</button>
</aria.Text>
</UnstyledButton>
</div>
)
break
}
default: {
permissionDisplay = (
<button
type="button"
disabled={disabled}
{...(disabled && error != null ? { title: error } : {})}
className={`selectable ${!disabled || !input ? 'active' : ''} ${
<UnstyledButton
ref={permissionSelectorButtonRef}
isDisabled={isDisabled}
{...(isDisabled && error != null ? { title: error } : {})}
className={`selectable ${!isDisabled || !input ? 'active' : ''} ${
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
} h-text w-permission-display rounded-full`}
onClick={doShowPermissionTypeSelector}
onPress={doShowPermissionTypeSelector}
>
{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}
</button>
</UnstyledButton>
)
break
}

View File

@ -1,6 +1,10 @@
/** @file A selector for all possible permission types. */
import * as React from 'react'
import * as aria from '#/components/aria'
import FocusArea from '#/components/styled/FocusArea'
import UnstyledButton from '#/components/UnstyledButton'
import * as backend from '#/services/Backend'
import * as permissions from '#/utilities/permissions'
@ -83,12 +87,15 @@ export interface PermissionTypeSelectorProps {
export default function PermissionTypeSelector(props: PermissionTypeSelectorProps) {
const { showDelete = false, selfPermission, type, assetType, style, onChange } = props
return (
<FocusArea direction="vertical">
{innerProps => (
<div
style={style}
className="pointer-events-auto sticky w-min rounded-permission-type-selector before:absolute before:h-full before:w-full before:rounded-permission-type-selector before:bg-selected-frame before:backdrop-blur-default"
onClick={event => {
event.stopPropagation()
}}
{...innerProps}
>
<div className="group relative flex w-permission-type-selector flex-col p-permission-type-selector">
{PERMISSION_TYPE_DATA.filter(
@ -98,13 +105,14 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
? true
: data.type !== permissions.Permission.owner)
).map(data => (
<button
<UnstyledButton
key={data.type}
type="button"
className={`flex h-row items-start gap-permission-type-button rounded-full p-permission-type-button hover:bg-black/5 ${
type === data.type ? 'bg-black/5 hover:!bg-black/5 group-hover:bg-transparent' : ''
type === data.type
? 'bg-black/5 hover:!bg-black/5 group-hover:bg-transparent'
: ''
}`}
onClick={() => {
onPress={() => {
onChange(data.type)
}}
>
@ -118,7 +126,7 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
{/* This is a symbol that should never need to be localized, since it is effectively
* an icon. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<span className="text font-normal">=</span>
<aria.Text className="text font-normal">=</aria.Text>
{data.previous != null && (
<>
<div
@ -131,13 +139,15 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
{/* This is a symbol that should never need to be localized, since it is effectively
* an icon. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<span className="text font-normal">+</span>
<aria.Text className="text font-normal">+</aria.Text>
</>
)}
<span className="text">{data.description(assetType)}</span>
</button>
<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 SvgMask from '#/components/SvgMask'
import UnstyledButton from '#/components/UnstyledButton'
import * as backendModule from '#/services/Backend'
import * as remoteBackend from '#/services/RemoteBackend'
@ -349,30 +350,28 @@ export default function ProjectIcon(props: ProjectIconProps) {
case backendModule.ProjectState.closing:
case backendModule.ProjectState.closed:
return (
<button
className="size-project-icon"
onClick={clickEvent => {
clickEvent.stopPropagation()
<UnstyledButton
className="size-project-icon rounded-full"
onPress={() => {
unsetModal()
doOpenManually(item.id)
}}
>
<SvgMask alt={getText('openInEditor')} src={PlayIcon} className="size-project-icon" />
</button>
</UnstyledButton>
)
case backendModule.ProjectState.openInProgress:
case backendModule.ProjectState.scheduled:
case backendModule.ProjectState.provisioned:
case backendModule.ProjectState.placeholder:
return (
<button
disabled={isOtherUserUsingProject}
<UnstyledButton
isDisabled={isOtherUserUsingProject}
{...(isOtherUserUsingProject ? { title: 'Someone else is using this project.' } : {})}
className="size-project-icon selectable enabled:active"
onClick={async clickEvent => {
clickEvent.stopPropagation()
className="size-project-icon rounded-full selectable enabled:active"
onPress={() => {
unsetModal()
await closeProject(!isRunningInBackground)
void closeProject(!isRunningInBackground)
}}
>
<div className={`relative h ${isRunningInBackground ? 'text-green' : ''}`}>
@ -383,19 +382,18 @@ export default function ProjectIcon(props: ProjectIconProps) {
src={StopIcon}
className={`size-project-icon ${isRunningInBackground ? 'text-green' : ''}`}
/>
</button>
</UnstyledButton>
)
case backendModule.ProjectState.opened:
return (
<div>
<button
disabled={isOtherUserUsingProject}
<UnstyledButton
isDisabled={isOtherUserUsingProject}
{...(isOtherUserUsingProject ? { title: 'Someone else has this project open.' } : {})}
className="size-project-icon selectable enabled:active"
onClick={async clickEvent => {
clickEvent.stopPropagation()
className="size-project-icon rounded-full selectable enabled:active"
onPress={() => {
unsetModal()
await closeProject(!isRunningInBackground)
void closeProject(!isRunningInBackground)
}}
>
<div className={`relative h ${isRunningInBackground ? 'text-green' : ''}`}>
@ -406,12 +404,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
src={StopIcon}
className={`size-project-icon ${isRunningInBackground ? 'text-green' : ''}`}
/>
</button>
</UnstyledButton>
{!isOtherUserUsingProject && !isRunningInBackground && (
<button
className="size-project-icon"
onClick={clickEvent => {
clickEvent.stopPropagation()
<UnstyledButton
className="size-project-icon rounded-full"
onPress={() => {
unsetModal()
doOpenEditor(true)
}}
@ -421,7 +418,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
src={ArrowUpIcon}
className="size-project-icon"
/>
</button>
</UnstyledButton>
)}
</div>
)

View File

@ -310,6 +310,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
: ''
}`}
checkSubmittable={newTitle =>
newTitle !== item.item.title &&
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
child =>
// All siblings,

View File

@ -14,6 +14,7 @@ import * as modalProvider from '#/providers/ModalProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import SvgMask from '#/components/SvgMask'
@ -148,9 +149,9 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
>
<SvgMask src={KeyIcon} className="m-name-column-icon size-icon" />
{/* 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}
</span>
</aria.Text>
</div>
)
}

View File

@ -8,7 +8,9 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import PermissionSelector from '#/components/dashboard/PermissionSelector'
import FocusArea from '#/components/styled/FocusArea'
import * as backendModule from '#/services/Backend'
@ -50,6 +52,7 @@ export default function UserPermission(props: UserPermissionProps) {
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [userPermission, setUserPermission] = React.useState(initialUserPermission)
const isDisabled = isOnlyOwner && userPermission.user.userId === self.user.userId
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
React.useEffect(() => {
@ -73,10 +76,12 @@ export default function UserPermission(props: UserPermissionProps) {
}
return (
<div className="flex items-center gap-user-permission">
<FocusArea active={!isDisabled} direction="horizontal">
{innerProps => (
<div className="flex items-center gap-user-permission" {...innerProps}>
<PermissionSelector
showDelete
disabled={isOnlyOwner && userPermission.user.userId === self.user.userId}
isDisabled={isDisabled}
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
selfPermission={self.permission}
action={userPermission.permission}
@ -88,7 +93,9 @@ export default function UserPermission(props: UserPermissionProps) {
doDelete(userPermission.user)
}}
/>
<span className="text">{userPermission.user.name}</span>
<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 * as labelUtils from '#/components/dashboard/Label/labelUtils'
import MenuEntry from '#/components/MenuEntry'
import UnstyledButton from '#/components/UnstyledButton'
import ManageLabelsModal from '#/modals/ManageLabelsModal'
@ -42,6 +43,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const self = asset.permissions?.find(
permission => permission.user.userId === session.user?.userId
)
@ -52,7 +54,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
setItem(oldItem =>
object.merge(oldItem, {
oldItem.with({
item:
typeof valueOrUpdater !== 'function' ? valueOrUpdater : valueOrUpdater(oldItem.item),
})
@ -60,6 +62,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
},
[/* should never change */ setItem]
)
return (
<div className="group flex items-center gap-column-items">
{(asset.labels ?? [])
@ -71,7 +74,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
title={getText('rightClickToRemoveLabel')}
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
active={!temporarilyRemovedLabels.has(label)}
disabled={temporarilyRemovedLabels.has(label)}
isDisabled={temporarilyRemovedLabels.has(label)}
negated={temporarilyRemovedLabels.has(label)}
className={
temporarilyRemovedLabels.has(label)
@ -102,15 +105,17 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
}
setModal(
<ContextMenus key={`label-${label}`} event={event}>
<ContextMenu>
<MenuEntry action="delete" doAction={doDelete} />
<ContextMenu aria-label={getText('labelContextMenuLabel')}>
<MenuEntry
action="delete"
label={getText('deleteLabelShortcut')}
doAction={doDelete}
/>
</ContextMenu>
</ContextMenus>
)
}}
onClick={event => {
event.preventDefault()
event.stopPropagation()
onPress={event => {
setQuery(oldQuery =>
oldQuery.withToggled('labels', 'negativeLabels', label, event.shiftKey)
)
@ -123,20 +128,20 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
.filter(label => asset.labels?.includes(label) !== true)
.map(label => (
<Label
disabled
isDisabled
key={label}
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
className="pointer-events-none"
onClick={() => {}}
onPress={() => {}}
>
{label}
</Label>
))}
{managesThisAsset && (
<button
className="invisible shrink-0 group-hover:visible"
onClick={event => {
event.stopPropagation()
<UnstyledButton
ref={plusButtonRef}
className="shrink-0 rounded-full transparent group-hover:opacity-100 focus-visible:opacity-100"
onPress={() => {
setModal(
<ManageLabelsModal
key={uniqueString.uniqueString()}
@ -144,13 +149,13 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
setItem={setAsset}
allLabels={labels}
doCreateLabel={doCreateLabel}
eventTarget={event.currentTarget}
eventTarget={plusButtonRef.current}
/>
)
}}
>
<img className="size-plus-icon" src={Plus2Icon} />
</button>
</UnstyledButton>
)}
</div>
)

View File

@ -12,12 +12,12 @@ import Category from '#/layouts/CategorySwitcher/Category'
import type * as column from '#/components/dashboard/column'
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
import UnstyledButton from '#/components/UnstyledButton'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import type * as backendModule from '#/services/Backend'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
import * as uniqueString from '#/utilities/uniqueString'
@ -41,6 +41,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const asset = item.item
const { user } = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal()
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
const self = asset.permissions?.find(permission => permission.user.userId === user?.userId)
const managesThisAsset =
category !== Category.trash &&
@ -49,7 +50,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
setItem(oldItem =>
object.merge(oldItem, {
oldItem.with({
item:
typeof valueOrUpdater !== 'function' ? valueOrUpdater : valueOrUpdater(oldItem.item),
})
@ -57,13 +58,14 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
},
[/* should never change */ setItem]
)
return (
<div className="group flex items-center gap-column-items">
{(asset.permissions ?? []).map(otherUser => (
<PermissionDisplay
key={otherUser.user.userId}
action={otherUser.permission}
onClick={event => {
onPress={event => {
setQuery(oldQuery =>
oldQuery.withToggled('owners', 'negativeOwners', otherUser.user.name, event.shiftKey)
)
@ -73,17 +75,17 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
</PermissionDisplay>
))}
{managesThisAsset && (
<button
className="invisible shrink-0 group-hover:visible"
onClick={event => {
event.stopPropagation()
<UnstyledButton
ref={plusButtonRef}
className="shrink-0 rounded-full transparent group-hover:opacity-100 focus-visible:opacity-100"
onPress={() => {
setModal(
<ManagePermissionsModal
key={uniqueString.uniqueString()}
item={asset}
setItem={setAsset}
self={self}
eventTarget={event.currentTarget}
eventTarget={plusButtonRef.current}
doRemoveSelf={() => {
dispatchAssetEvent({
type: AssetEventType.removeSelf,
@ -95,7 +97,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
}}
>
<img className="size-plus-icon" src={Plus2Icon} />
</button>
</UnstyledButton>
)}
</div>
)

View File

@ -5,6 +5,7 @@ import AccessedByProjectsIcon from 'enso-assets/accessed_by_projects.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -26,7 +27,7 @@ export default function AccessedByProjectsColumnHeading(props: column.AssetColum
hideColumn(columnUtils.Column.accessedByProjects)
}}
/>
<span className="text-header">{getText('accessedByProjectsColumnName')}</span>
<aria.Text className="text-header">{getText('accessedByProjectsColumnName')}</aria.Text>
</div>
)
}

View File

@ -5,6 +5,7 @@ import AccessedDataIcon from 'enso-assets/accessed_data.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -26,7 +27,7 @@ export default function AccessedDataColumnHeading(props: column.AssetColumnHeadi
hideColumn(columnUtils.Column.accessedData)
}}
/>
<span className="text-header">{getText('accessedDataColumnName')}</span>
<aria.Text className="text-header">{getText('accessedDataColumnName')}</aria.Text>
</div>
)
}

View File

@ -5,6 +5,7 @@ import DocsIcon from 'enso-assets/docs.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -26,7 +27,7 @@ export default function DocsColumnHeading(props: column.AssetColumnHeadingProps)
hideColumn(columnUtils.Column.docs)
}}
/>
<span className="text-header">{getText('docsColumnName')}</span>
<aria.Text className="text-header">{getText('docsColumnName')}</aria.Text>
</div>
)
}

View File

@ -5,6 +5,7 @@ import TagIcon from 'enso-assets/tag.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -26,7 +27,7 @@ export default function LabelsColumnHeading(props: column.AssetColumnHeadingProp
hideColumn(columnUtils.Column.labels)
}}
/>
<span className="text-header">{getText('labelsColumnName')}</span>
<aria.Text className="text-header">{getText('labelsColumnName')}</aria.Text>
</div>
)
}

View File

@ -6,9 +6,11 @@ import TimeIcon from 'enso-assets/time.svg'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
import UnstyledButton from '#/components/UnstyledButton'
import * as sorting from '#/utilities/sorting'
@ -21,8 +23,8 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
return (
<button
title={
<UnstyledButton
aria-label={
!isSortActive
? getText('sortByModificationDate')
: isDescending
@ -30,8 +32,7 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
: getText('sortByModificationDateDescending')
}
className="group flex h-drive-table-heading w-full cursor-pointer items-center gap-icon-with-text"
onClick={event => {
event.stopPropagation()
onPress={() => {
const nextDirection = isSortActive
? sorting.nextSortDirection(sortInfo.direction)
: sorting.SortDirection.ascending
@ -51,7 +52,7 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
hideColumn(columnUtils.Column.modified)
}}
/>
<span className="text-header">{getText('modifiedColumnName')}</span>
<aria.Text className="text-header">{getText('modifiedColumnName')}</aria.Text>
<img
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
@ -59,6 +60,6 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'
} ${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 aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import UnstyledButton from '#/components/UnstyledButton'
import * as sorting from '#/utilities/sorting'
@ -19,8 +21,8 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
return (
<button
title={
<UnstyledButton
aria-label={
!isSortActive
? getText('sortByName')
: isDescending
@ -28,8 +30,7 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
: getText('sortByNameDescending')
}
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
onClick={event => {
event.stopPropagation()
onPress={() => {
const nextDirection = isSortActive
? sorting.nextSortDirection(sortInfo.direction)
: 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
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
@ -48,6 +49,6 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'
} ${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 aria from '#/components/aria'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -26,7 +27,7 @@ export default function SharedWithColumnHeading(props: column.AssetColumnHeading
hideColumn(columnUtils.Column.sharedWith)
}}
/>
<span className="text-header">{getText('sharedWithColumnName')}</span>
<aria.Text className="text-header">{getText('sharedWithColumnName')}</aria.Text>
</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 AssetTreeNode from '#/utilities/AssetTreeNode'
import AssetTreeNode from '#/utilities/AssetTreeNode'
// ===================
// === useSetAsset ===
@ -28,7 +28,13 @@ export function useSetAsset<T extends backend.AnyAsset>(
// eslint-disable-next-line no-restricted-syntax
valueOrUpdater(oldNode.item as T)
: valueOrUpdater
return oldNode.with({ item })
const ret = oldNode.with({ item })
if (!(ret instanceof AssetTreeNode)) {
// eslint-disable-next-line no-restricted-properties
console.trace('Error: The new value of an `AssetTreeNode` should be an `AssetTreeNode`.')
Object.setPrototypeOf(ret, AssetTreeNode.prototype)
}
return ret
})
},
[/* should never change */ setNode]

View File

@ -3,6 +3,7 @@ import * as React from 'react'
import * as toast from 'react-toastify'
import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -20,8 +21,8 @@ import GlobalContextMenu from '#/layouts/GlobalContextMenu'
import ContextMenu from '#/components/ContextMenu'
import ContextMenuEntry from '#/components/ContextMenuEntry'
import ContextMenus from '#/components/ContextMenus'
import ContextMenuSeparator from '#/components/ContextMenuSeparator'
import type * as assetRow from '#/components/dashboard/AssetRow'
import Separator from '#/components/styled/Separator'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import ManageLabelsModal from '#/modals/ManageLabelsModal'
@ -97,25 +98,12 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
backendModule.assetIsProject(asset) &&
asset.projectState.opened_by != null &&
asset.projectState.opened_by !== user?.email
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<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]
)
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
return category === Category.trash ? (
!ownsThisAsset ? null : (
<ContextMenus hidden={hidden} key={asset.id} event={event}>
<ContextMenu hidden={hidden}>
<ContextMenu aria-label={getText('assetContextMenuLabel')} hidden={hidden}>
<ContextMenuEntry
hidden={hidden}
action="undelete"
@ -146,7 +134,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
)
) : (
<ContextMenus hidden={hidden} key={asset.id} event={event}>
<ContextMenu hidden={hidden}>
<ContextMenu aria-label={getText('assetContextMenuLabel')} hidden={hidden}>
{asset.type === backendModule.AssetType.project &&
canExecute &&
!isRunningProject &&
@ -235,7 +223,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
{canExecute && !isRunningProject && !isOtherUserUsingProject && (
<ContextMenuEntry
hidden={hidden}
disabled={
isDisabled={
asset.type !== backendModule.AssetType.project &&
asset.type !== backendModule.AssetType.directory
}
@ -280,7 +268,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
{isCloud && (
<ContextMenuEntry
hidden={hidden}
disabled
isDisabled
action="snapshot"
doAction={() => {
// 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 && (
<ContextMenuEntry
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
hidden={hidden}
disabled={!isCloud}
isDisabled={!isCloud}
action="duplicate"
doAction={() => {
unsetModal()
@ -372,7 +360,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
)}
<ContextMenuEntry
hidden={hidden}
disabled={
isDisabled={
isCloud &&
asset.type !== backendModule.AssetType.file &&
asset.type !== backendModule.AssetType.dataLink &&
@ -404,7 +392,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
{category === Category.home && (
<GlobalContextMenu
hidden={hidden}
hasCopyData={hasPasteData}
hasPasteData={hasPasteData}
directoryKey={
// This is SAFE, as both branches are guaranteed to be `DirectoryId`s
// 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 type Category from '#/layouts/CategorySwitcher/Category'
import UnstyledButton from '#/components/UnstyledButton'
import * as backend from '#/services/Backend'
import * as array from '#/utilities/array'
@ -98,7 +100,7 @@ export default function AssetPanel(props: AssetPanelProps) {
return (
<div
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 => {
event.stopPropagation()
}}
@ -107,11 +109,11 @@ export default function AssetPanel(props: AssetPanelProps) {
{item != null &&
item.item.type !== backend.AssetType.secret &&
item.item.type !== backend.AssetType.directory && (
<button
className={`button select-none bg-frame px-button-x leading-cozy transition-colors hover:bg-selected-frame ${
<UnstyledButton
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'
}`}
onClick={() => {
onPress={() => {
setTab(oldTab =>
oldTab === AssetPanelTab.versions
? AssetPanelTab.properties
@ -120,7 +122,7 @@ export default function AssetPanel(props: AssetPanelProps) {
}}
>
{getText('versions')}
</button>
</UnstyledButton>
)}
{/* Spacing. The top right asset and user bars overlap this area. */}
<div className="grow" />

View File

@ -15,11 +15,13 @@ import type * as assetEvent from '#/events/assetEvent'
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 DataLinkInput from '#/components/dashboard/DataLinkInput'
import Label from '#/components/dashboard/Label'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import Button from '#/components/styled/Button'
import UnstyledButton from '#/components/UnstyledButton'
import * as backendModule from '#/services/Backend'
@ -114,9 +116,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
} catch (error) {
toastAndLog('editDescriptionError')
setItem(oldItem =>
oldItem.with({
item: object.merge(oldItem.item, { description: oldDescription }),
})
oldItem.with({ item: object.merge(oldItem.item, { description: oldDescription }) })
)
}
}
@ -124,25 +124,28 @@ export default function AssetProperties(props: AssetPropertiesProps) {
return (
<>
<div className="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">
<div className="pointer-events-auto flex flex-col items-start gap-side-panel">
<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')}
{ownsThisAsset && !isEditingDescription && (
<Button
image={PenIcon}
onClick={() => {
onPress={() => {
setIsEditingDescription(true)
setQueuedDescripion(item.item.description)
}}
/>
)}
</span>
</aria.Heading>
<div
data-testid="asset-panel-description"
className="self-stretch py-side-panel-description-y"
>
{!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}>
<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"
/>
<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')}
</button>
</UnstyledButton>
</div>
</form>
)}
</div>
</div>
<div className="flex flex-col items-start gap-side-panel-section">
<h2 className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug">
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section">
<aria.Heading
level={2}
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
>
{getText('settings')}
</h2>
</aria.Heading>
<table>
<tbody>
<tr data-testid="asset-panel-permissions" className="h-row">
<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 className="w-full p">
<SharedWithColumn
@ -203,13 +212,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
</tr>
<tr data-testid="asset-panel-labels" className="h-row">
<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 className="w-full p">
{item.item.labels?.map(value => {
const label = labels.find(otherLabel => otherLabel.value === value)
return label == null ? null : (
<Label key={value} active disabled color={label.color} onClick={() => {}}>
<Label key={value} active isDisabled color={label.color} onPress={() => {}}>
{value}
</Label>
)
@ -220,10 +229,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
</table>
</div>
{isDataLink && (
<div className="flex flex-col items-start gap-side-panel-section">
<h2 className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug">
<div className="pointer-events-auto flex flex-col items-start gap-side-panel-section">
<aria.Heading
level={2}
className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug"
>
{getText('dataLink')}
</h2>
</aria.Heading>
{!isDataLinkFetched ? (
<div className="grid place-items-center self-stretch">
<StatelessSpinner size={48} state={statelessSpinner.SpinnerState.loadingMedium} />
@ -238,14 +250,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
/>
{canEditThisAsset && (
<div className="flex gap-buttons">
<button
type="button"
disabled={isDataLinkDisabled}
<UnstyledButton
isDisabled={isDataLinkDisabled}
{...(isDataLinkDisabled
? { title: 'Edit the Data Link before updating it.' }
: {})}
className="button bg-invite text-white enabled:active"
onClick={() => {
onPress={() => {
void (async () => {
if (item.item.type === backendModule.AssetType.dataLink) {
const oldDataLinkValue = dataLinkValue
@ -267,17 +278,16 @@ export default function AssetProperties(props: AssetPropertiesProps) {
}}
>
{getText('update')}
</button>
<button
type="button"
disabled={isDataLinkDisabled}
</UnstyledButton>
<UnstyledButton
isDisabled={isDataLinkDisabled}
className="button bg-selected-frame enabled:active"
onClick={() => {
onPress={() => {
setEditedDataLinkValue(dataLinkValue)
}}
>
{getText('cancel')}
</button>
</UnstyledButton>
</div>
)}
</>

View File

@ -7,7 +7,10 @@ import * as detect from 'enso-common/src/detect'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
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'
@ -40,6 +43,65 @@ export interface Suggestion {
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 ===
// ======================
@ -69,16 +131,12 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
const [areSuggestionsVisible, setAreSuggestionsVisible] = React.useState(false)
const areSuggestionsVisibleRef = React.useRef(areSuggestionsVisible)
const querySource = React.useRef(QuerySource.external)
const [isShiftPressed, setIsShiftPressed] = React.useState(false)
const rootRef = React.useRef<HTMLLabelElement>(null)
const searchRef = React.useRef<HTMLInputElement>(null)
React.useEffect(() => {
const rootRef = React.useRef<HTMLLabelElement | null>(null)
const searchRef = React.useRef<HTMLInputElement | null>(null)
areSuggestionsVisibleRef.current = areSuggestionsVisible
}, [areSuggestionsVisible])
React.useEffect(() => {
if (querySource.current !== QuerySource.tabbing && !isShiftPressed) {
if (querySource.current !== QuerySource.tabbing) {
baseQuery.current = query
}
// This effect MUST only run when `query` changes.
@ -100,11 +158,11 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
}, [query])
React.useEffect(() => {
if (querySource.current !== QuerySource.tabbing && !isShiftPressed) {
if (querySource.current !== QuerySource.tabbing) {
setSuggestions(rawSuggestions)
suggestionsRef.current = rawSuggestions
}
}, [isShiftPressed, rawSuggestions])
}, [rawSuggestions])
React.useEffect(() => {
if (
@ -129,14 +187,13 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
}, [selectedIndex])
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
setIsShiftPressed(event.shiftKey)
const onSearchKeyDown = (event: KeyboardEvent) => {
if (areSuggestionsVisibleRef.current) {
if (event.key === 'Tab' || event.key === 'ArrowUp' || event.key === 'ArrowDown') {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
event.stopImmediatePropagation()
querySource.current = QuerySource.tabbing
const reverse = (event.key === 'Tab' && event.shiftKey) || event.key === 'ArrowUp'
const reverse = event.key === 'ArrowUp'
setSelectedIndex(oldIndex => {
const length = Math.max(1, suggestionsRef.current.length)
if (reverse) {
@ -160,7 +217,6 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
if (event.key === 'Enter') {
setAreSuggestionsVisible(false)
}
}
if (event.key === 'Escape') {
if (querySource.current === QuerySource.tabbing) {
querySource.current = QuerySource.external
@ -170,6 +226,9 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
searchRef.current?.blur()
}
}
}
}
const onKeyDown = (event: KeyboardEvent) => {
// Allow `alt` key to be pressed in case it is being used to enter special characters.
if (
!eventModule.isElementTextInput(event.target) &&
@ -189,14 +248,12 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
searchRef.current?.focus()
}
}
const onKeyUp = (event: KeyboardEvent) => {
setIsShiftPressed(event.shiftKey)
}
const root = rootRef.current
root?.addEventListener('keydown', onSearchKeyDown)
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
return () => {
root?.removeEventListener('keydown', onSearchKeyDown)
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp)
}
}, [setQuery, /* should never change */ modalRef])
@ -212,80 +269,39 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
}, [query, /* should never change */ setQuery])
return (
<label
ref={rootRef}
<FocusArea direction="horizontal">
{innerProps => (
<aria.Label
data-testid="asset-search-bar"
tabIndex={-1}
onFocus={() => {
{...aria.mergeProps<aria.LabelProps>()(innerProps, {
className:
'search-bar group relative flex h-row max-w-asset-search-bar grow items-center gap-asset-search-bar rounded-full px-input-x text-primary xl:max-w-asset-search-bar-wide',
ref: rootRef,
onFocus: () => {
setAreSuggestionsVisible(true)
}}
onBlur={event => {
},
onBlur: event => {
if (!event.currentTarget.contains(event.relatedTarget)) {
if (querySource.current === QuerySource.tabbing) {
querySource.current = QuerySource.external
}
setAreSuggestionsVisible(false)
}
}}
className="search-bar group relative flex h-row max-w-asset-search-bar grow items-center gap-asset-search-bar rounded-full px-input-x text-primary xl:max-w-asset-search-bar-wide"
},
})}
>
<img src={FindIcon} className="relative z-1 placeholder" />
<input
ref={searchRef}
type="search"
size={1}
placeholder={
isCloud
? getText('remoteBackendSearchPlaceholder')
: getText('localBackendSearchPlaceholder')
}
className="peer text relative z-1 grow bg-transparent placeholder:text-center"
onChange={event => {
if (querySource.current !== QuerySource.internal) {
querySource.current = QuerySource.typing
setQuery(AssetQuery.fromString(event.target.value))
}
}}
onKeyDown={event => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey
) {
// Clone the query to refresh results.
setQuery(query.clone())
}
}}
/>
<div className="pointer-events-none absolute left top flex w-full flex-col overflow-hidden rounded-default before:absolute before:inset before:bg-frame before:backdrop-blur-default">
<div className="padding relative h-row" />
{areSuggestionsVisible && (
<div className="relative flex flex-col gap-search-suggestions">
{/* Tags (`name:`, `modified:`, etc.) */}
<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('-')
? []
: [
<button
key={key}
className="h-text rounded-full bg-frame px-button-x transition-all hover:bg-selected-frame"
onClick={() => {
querySource.current = QuerySource.internal
setQuery(query.add({ [key]: [[]] }))
}}
>
{`${tag}:`}
</button>,
]
})}
</div>
<Tags
isCloud={isCloud}
querySource={querySource}
query={query}
setQuery={setQuery}
/>
{/* Asset labels */}
{isCloud && labels.length !== 0 && (
<div
@ -307,7 +323,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
query.labels.some(term => array.shallowEqual(term, [label.value]))
}
negated={negated}
onClick={event => {
onPress={event => {
querySource.current = QuerySource.internal
setQuery(oldQuery => {
const newQuery = oldQuery.withToggled(
@ -332,7 +348,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
{suggestions.map((suggestion, index) => (
// This should not be a `<button>`, since `render()` may output a
// tree containing a button.
<div
<aria.Button
data-testid="asset-search-suggestion"
key={index}
ref={el => {
@ -340,7 +356,6 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
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'
@ -348,7 +363,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
? 'bg-frame'
: ''
}`}
onClick={event => {
onPress={event => {
querySource.current = QuerySource.internal
setQuery(
selectedIndices.has(index)
@ -369,12 +384,54 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
}}
>
{suggestion.render()}
</div>
</aria.Button>
))}
</div>
</div>
)}
</div>
</label>
<FocusRing placement="before">
<aria.SearchField
aria-label={getText('assetSearchFieldLabel')}
className="relative grow before:text before:absolute before:inset-x-button-focus-ring-inset before:my-auto before:rounded-full before:transition-all"
value={query.query}
onKeyDown={event => {
event.continuePropagation()
}}
>
<aria.Input
type="search"
ref={searchRef}
size={1}
placeholder={
isCloud
? getText('remoteBackendSearchPlaceholder')
: getText('localBackendSearchPlaceholder')
}
className="focus-child peer text relative z-1 w-full bg-transparent placeholder:text-center"
onChange={event => {
if (querySource.current !== QuerySource.internal) {
querySource.current = QuerySource.typing
setQuery(AssetQuery.fromString(event.target.value))
}
}}
onKeyDown={event => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.altKey &&
!event.metaKey &&
!event.ctrlKey
) {
// Clone the query to refresh results.
setQuery(query.clone())
}
}}
/>
</aria.SearchField>
</FocusRing>
</aria.Label>
)}
</FocusArea>
)
}

View File

@ -39,7 +39,7 @@ export default function AssetVersion(props: AssetVersionProps) {
{getText('versionX', number)} {version.isLatest && getText('latestIndicator')}
</div>
<time className="text-xs text-not-selected">
<time className="text-not-selected text-xs">
{getText('onDateX', dateTime.formatDateTime(new Date(version.lastModified)))}
</time>
</div>

View File

@ -48,7 +48,7 @@ export default function AssetVersions(props: AssetVersionsProps) {
const latestVersion = versions?.find(version => version.isLatest)
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) {
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 eventHooks from '#/hooks/eventHooks'
import * as scrollHooks from '#/hooks/scrollHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -12,6 +13,7 @@ import * as backendProvider from '#/providers/BackendProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
@ -24,7 +26,7 @@ import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
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 AssetRow from '#/components/dashboard/AssetRow'
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 SelectionBrush from '#/components/SelectionBrush'
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 DuplicateAssetsModal from '#/modals/DuplicateAssetsModal'
@ -377,6 +381,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const navigator2D = navigator2DProvider.useNavigator2D()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [initialized, setInitialized] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(true)
@ -408,7 +413,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const isCloud = backend.type === backendModule.BackendType.remote
/** Events sent when the asset list was still loading. */
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 assetTreeRef = React.useRef<AssetTreeNode>(assetTree)
const pasteDataRef = React.useRef<pasteDataModule.PasteData<
@ -786,7 +791,7 @@ export default function AssetsTable(props: AssetsTableProps) {
allLabels.values(),
(label): assetSearchBar.Suggestion => ({
render: () => (
<Label active color={label.color} onClick={() => {}}>
<Label active color={label.color} onPress={() => {}}>
{label.value}
</Label>
),
@ -1030,44 +1035,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [/* 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(() => {
if (initialized) {
localStorage.set('enabledColumns', [...enabledColumns])
@ -1197,7 +1164,7 @@ export default function AssetsTable(props: AssetsTableProps) {
)
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 selectionStartIndexRef = React.useRef<number | null>(null)
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
@ -1205,15 +1172,27 @@ export default function AssetsTable(props: AssetsTableProps) {
const setMostRecentlySelectedIndex = React.useCallback(
(index: number | null, isKeyboard = false) => {
mostRecentlySelectedIndexRef.current = index
setKeyboardSelectedIndexRaw(isKeyboard ? index : null)
setKeyboardSelectedIndex(isKeyboard ? index : null)
},
[]
)
React.useEffect(() => {
const body = bodyRef.current
if (body == null) {
return
} else {
return navigator2D.register(body, {
focusPrimaryChild: () => {
setMostRecentlySelectedIndex(0, true)
},
})
}
}, [navigator2D, setMostRecentlySelectedIndex])
// This is not a React component, even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax
const onKeyDown = (event: KeyboardEvent) => {
const onKeyDown = (event: React.KeyboardEvent) => {
const prevIndex = mostRecentlySelectedIndexRef.current
const item = prevIndex == null ? null : visibleItems[prevIndex]
if (selectedKeysRef.current.size === 1 && item != null) {
@ -1275,15 +1254,34 @@ export default function AssetsTable(props: AssetsTableProps) {
break
}
case 'ArrowLeft': {
if (item.item.type === backendModule.AssetType.directory && item.children != null) {
if (item.item.type === backendModule.AssetType.directory) {
if (item.children != null) {
// The folder is expanded; collapse it.
event.preventDefault()
event.stopPropagation()
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': {
if (item.item.type === backendModule.AssetType.directory && item.children == null) {
// The folder is collapsed; expand it.
event.preventDefault()
event.stopPropagation()
doToggleDirectoryExpansion(item.item.id, item.key, null, true)
@ -1308,19 +1306,36 @@ export default function AssetsTable(props: AssetsTableProps) {
}
case 'ArrowUp':
case 'ArrowDown': {
event.preventDefault()
event.stopPropagation()
if (!event.shiftKey) {
selectionStartIndexRef.current = null
}
const index =
prevIndex == null
? 0
: event.key === 'ArrowUp'
? Math.max(0, prevIndex - 1)
: Math.min(visibleItems.length - 1, prevIndex + 1)
let index = prevIndex ?? 0
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.stopPropagation()
// On Windows, Ctrl+Shift+Arrow behaves the same as Shift+Arrow.
if (selectionStartIndexRef.current == null) {
selectionStartIndexRef.current = prevIndex ?? 0
@ -1330,33 +1345,38 @@ export default function AssetsTable(props: AssetsTableProps) {
const selection = visibleItems.slice(startIndex, endIndex)
setSelectedKeys(new Set(selection.map(newItem => newItem.key)))
} else if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
selectionStartIndexRef.current = null
} else {
} else if (index !== prevIndex) {
event.preventDefault()
event.stopPropagation()
const newItem = visibleItems[index]
if (newItem != null) {
setSelectedKeys(new Set([newItem.key]))
}
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,
backend,
doToggleDirectoryExpansion,
/* should never change */ toastAndLog,
/* should never change */ setModal,
/* should never change */ setMostRecentlySelectedIndex,
/* should never change */ setSelectedKeys,
/* should never change */ setIsAssetPanelTemporarilyVisible,
/* should never change */ dispatchAssetEvent,
])
document.addEventListener('click', onClick, { capture: true })
return () => {
document.removeEventListener('click', onClick, { capture: true })
}
}, [setMostRecentlySelectedIndex])
const getNewProjectName = React.useCallback(
(templateName: string | null, parentKey: backendModule.DirectoryId | null) => {
@ -1863,7 +1883,7 @@ export default function AssetsTable(props: AssetsTableProps) {
(): AssetsTableState => ({
visibilities,
selectedKeys: selectedKeysRef,
scrollContainerRef,
scrollContainerRef: rootRef,
category,
labels: allLabels,
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
// 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 = scrollHooks.useOnScroll(() => {
if (bodyRef.current != null && rootRef.current != null) {
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
}
const onScroll = () => {
if (!isClipPathUpdateQueued) {
isClipPathUpdateQueued = true
requestAnimationFrame(updateClipPath)
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%)`
}
}
updateClipPath()
scrollContainer.addEventListener('scroll', onScroll)
return () => {
scrollContainer.removeEventListener('scroll', onScroll)
}
} else {
return
}
}, [/* should never change */ scrollContainerRef])
}, [enabledColumns.size])
React.useEffect(
() =>
@ -2032,10 +2044,10 @@ export default function AssetsTable(props: AssetsTableProps) {
const onSelectionDrag = React.useCallback(
(rectangle: geometry.DetailedRectangle, event: MouseEvent) => {
if (mostRecentlySelectedIndexRef.current != null) {
setKeyboardSelectedIndexRaw(null)
setKeyboardSelectedIndex(null)
}
cancelAnimationFrame(dragSelectionChangeLoopHandle.current)
const scrollContainer = scrollContainerRef.current
const scrollContainer = rootRef.current
if (scrollContainer != null) {
const rect = scrollContainer.getBoundingClientRect()
if (rectangle.signedHeight <= 0 && scrollContainer.scrollTop > 0) {
@ -2175,7 +2187,7 @@ export default function AssetsTable(props: AssetsTableProps) {
</td>
</tr>
) : (
displayItems.map(item => {
displayItems.map((item, i) => {
const key = AssetTreeNode.getKey(item)
const isSelected = (visuallySelectedKeysOverride ?? selectedKeys).has(key)
const isSoleSelected = selectedKeys.size === 1 && isSelected
@ -2194,6 +2206,10 @@ export default function AssetsTable(props: AssetsTableProps) {
isKeyboardSelected={
keyboardSelectedIndex != null && item === visibleItems[keyboardSelectedIndex]
}
grabKeyboardFocus={() => {
setSelectedKeys(new Set([key]))
setMostRecentlySelectedIndex(i, true)
}}
allowContextMenu={selectedKeysRef.current.size === 0 || !isSelected || isSoleSelected}
onClick={onRowClick}
onContextMenu={(_innerProps, event) => {
@ -2220,6 +2236,10 @@ export default function AssetsTable(props: AssetsTableProps) {
key: node.key,
asset: node.item,
}))
event.dataTransfer.setData(
'application/vnd.enso.assets+json',
JSON.stringify(nodes.map(node => node.key))
)
drag.setDragImageToBlank(event)
drag.ASSET_ROWS.bind(event, payload)
setModal(
@ -2383,13 +2403,15 @@ export default function AssetsTable(props: AssetsTableProps) {
<tr className="hidden h-row first:table-row">
<td colSpan={columns.length} className="bg-transparent">
{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 !== '' ? (
<span className="px-cell-x placeholder">
<aria.Text className="px-cell-x placeholder">
{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>
</tr>
@ -2432,7 +2454,24 @@ export default function AssetsTable(props: AssetsTableProps) {
)
return (
<div ref={scrollContainerRef} className="flex-1 overflow-auto container-size">
<FocusArea direction="vertical">
{innerProps => (
<div
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
ref: rootRef,
className: 'flex-1 overflow-auto container-size',
onKeyDown,
onScroll,
onBlur: event => {
if (
event.relatedTarget instanceof HTMLElement &&
!event.currentTarget.contains(event.relatedTarget)
) {
setKeyboardSelectedIndex(null)
}
},
})}
>
{!hidden && hiddenContextMenu}
{!hidden && (
<SelectionBrush
@ -2448,16 +2487,25 @@ export default function AssetsTable(props: AssetsTableProps) {
data-testid="extra-columns"
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 => (
<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])}
onClick={event => {
event.stopPropagation()
onPress={() => {
const newExtraColumns = new Set(enabledColumns)
if (enabledColumns.has(column)) {
newExtraColumns.delete(column)
@ -2467,14 +2515,17 @@ export default function AssetsTable(props: AssetsTableProps) {
setEnabledColumns(newExtraColumns)
}}
/>
)
)}
))}
</div>
)}
</FocusArea>
</div>
</div>
)}
<div className="flex h-full w-min min-w-full grow flex-col">{table}</div>
</div>
</div>
)}
</FocusArea>
)
}

View File

@ -110,7 +110,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
<></>
) : (
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
<ContextMenu hidden={hidden}>
<ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}>
<ContextMenuEntry
hidden={hidden}
action="undelete"
@ -156,7 +156,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
return (
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
{selectedKeys.size !== 0 && (
<ContextMenu hidden={hidden}>
<ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}>
{ownsAllSelectedAssets && (
<ContextMenuEntry
hidden={hidden}
@ -204,7 +204,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
)}
<GlobalContextMenu
hidden={hidden}
hasCopyData={pasteData != null}
hasPasteData={pasteData != null}
directoryKey={null}
directoryId={null}
dispatchAssetListEvent={dispatchAssetListEvent}

View File

@ -7,7 +7,10 @@ import NotCloudIcon from 'enso-assets/not_cloud.svg'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask'
import UnstyledButton from '#/components/UnstyledButton'
import * as backendModule from '#/services/Backend'
@ -28,31 +31,35 @@ export default function BackendSwitcher(props: BackendSwitcherProps) {
const isCloud = backend.type === backendModule.BackendType.remote
return (
<div className="flex shrink-0 gap-px">
<button
disabled={isCloud}
<FocusArea direction="horizontal">
{innerProps => (
<div className="flex shrink-0 gap-px" {...innerProps}>
<UnstyledButton
isDisabled={isCloud}
className="flex w-backend-switcher-option flex-col items-start bg-selected-frame px-selector-x py-selector-y text-primary selectable first:rounded-l-full last:rounded-r-full disabled:text-cloud disabled:active"
onClick={() => {
onPress={() => {
setBackendType(backendModule.BackendType.remote)
}}
>
<div className="flex items-center gap-icon-with-text">
<SvgMask src={CloudIcon} />
<span className="text">{getText('cloud')}</span>
<aria.Label className="text">{getText('cloud')}</aria.Label>
</div>
</button>
<button
disabled={!isCloud}
</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"
onClick={() => {
onPress={() => {
setBackendType(backendModule.BackendType.local)
}}
>
<div className="flex items-center gap-icon-with-text">
<SvgMask src={NotCloudIcon} />
<span className="text">{getText('local')}</span>
<aria.Label className="text">{getText('local')}</aria.Label>
</div>
</button>
</UnstyledButton>
</div>
)}
</FocusArea>
)
}

View File

@ -7,6 +7,7 @@ import Trash2Icon from 'enso-assets/trash2.svg'
import type * as text from '#/text'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
@ -15,27 +16,53 @@ import AssetEventType from '#/events/AssetEventType'
import Category from '#/layouts/CategorySwitcher/Category'
import * as aria from '#/components/aria'
import FocusArea from '#/components/styled/FocusArea'
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 ===
// =================
const CATEGORIES = Object.values(Category)
const CATEGORY_ICONS: Readonly<Record<Category, string>> = {
[Category.recent]: RecentIcon,
[Category.home]: Home2Icon,
[Category.trash]: Trash2Icon,
}
const CATEGORY_TO_TEXT_ID: Readonly<Record<Category, text.TextId>> = {
[Category.recent]: 'recentCategory',
[Category.home]: 'homeCategory',
[Category.trash]: 'trashCategory',
} satisfies { [C in Category]: `${C}Category` }
const CATEGORY_DATA: CategoryMetadata[] = [
{
category: Category.recent,
icon: RecentIcon,
textId: 'recentCategory',
buttonTextId: 'recentCategoryButtonLabel',
dropZoneTextId: 'recentCategoryDropZoneLabel',
},
{
category: Category.home,
icon: Home2Icon,
textId: 'homeCategory',
buttonTextId: 'homeCategoryButtonLabel',
dropZoneTextId: 'homeCategoryDropZoneLabel',
},
{
category: Category.trash,
icon: Trash2Icon,
textId: 'trashCategory',
buttonTextId: 'trashCategoryButtonLabel',
dropZoneTextId: 'trashCategoryDropZoneLabel',
},
]
// ============================
// === CategorySwitcherItem ===
@ -43,44 +70,54 @@ const CATEGORY_TO_TEXT_ID: Readonly<Record<Category, text.TextId>> = {
/** Props for a {@link CategorySwitcherItem}. */
interface InternalCategorySwitcherItemProps {
readonly category: Category
readonly id: string
readonly data: CategoryMetadata
readonly isCurrent: boolean
readonly onClick: () => void
readonly onDragOver: (event: React.DragEvent) => void
readonly onDrop: (event: React.DragEvent) => void
readonly onPress: (event: aria.PressEvent) => void
readonly acceptedDragTypes: string[]
readonly onDrop: (event: aria.DropEvent) => void
}
/** An entry in a {@link CategorySwitcher}. */
function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
const { category, isCurrent, onClick } = props
const { onDragOver, onDrop } = props
const { data, isCurrent, onPress, acceptedDragTypes, onDrop } = props
const { category, icon, textId, buttonTextId, dropZoneTextId } = data
const { getText } = textProvider.useText()
return (
<button
disabled={isCurrent}
title={`Go To ${category}`}
className={`selectable ${
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`}
onClick={onClick}
// Required because `dragover` does not fire on `mouseenter`.
onDragEnter={onDragOver}
onDragOver={onDragOver}
<aria.DropZone
aria-label={getText(dropZoneTextId)}
getDropOperation={types =>
acceptedDragTypes.some(type => types.has(type)) ? 'move' : 'cancel'
}
className="group relative flex items-center rounded-full drop-target-after"
onDrop={onDrop}
>
<UnstyledButton
aria-label={getText(buttonTextId)}
className={`rounded-inherit ${isCurrent ? 'focus-default' : ''}`}
onPress={onPress}
>
<div
className={`selectable ${
isCurrent ? 'disabled bg-selected-frame active' : ''
} group flex h-row items-center gap-icon-with-text rounded-inherit px-button-x hover:bg-selected-frame`}
>
<SvgMask
src={CATEGORY_ICONS[category]}
className={`group-hover:text-icon-selected ${
isCurrent ? 'text-icon-selected' : 'text-icon-not-selected'
} ${
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' : ''
}`}
}
/>
<span>{getText(CATEGORY_TO_TEXT_ID[category])}</span>
</button>
<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) {
const { category, setCategory, dispatchAssetEvent } = props
const { unsetModal } = modalProvider.useSetModal()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
React.useEffect(() => {
localStorage.set('driveCategory', category)
}, [category, /* should never change */ localStorage])
return (
<div className="flex w-full flex-col gap-sidebar-section-heading">
<div className="text-header px-sidebar-section-heading-x text-sm font-bold">
<FocusArea direction="vertical">
{innerProps => (
<div className="flex w-full flex-col" {...innerProps}>
<aria.Header
id="header"
className="text-header mb-sidebar-section-heading-b px-sidebar-section-heading-x text-sm font-bold"
>
{getText('category')}
</div>
<div className="flex flex-col items-start">
{CATEGORIES.map(currentCategory => (
</aria.Header>
<div
aria-label={getText('categorySwitcherMenuLabel')}
role="grid"
className="flex flex-col items-start"
>
{CATEGORY_DATA.map(data => (
<CategorySwitcherItem
key={currentCategory}
category={currentCategory}
isCurrent={category === currentCategory}
onClick={() => {
setCategory(currentCategory)
key={data.category}
id={data.category}
data={data}
isCurrent={category === data.category}
onPress={() => {
setCategory(data.category)
}}
onDragOver={event => {
if (
(category === Category.trash && currentCategory === Category.home) ||
(category !== Category.trash && currentCategory === Category.trash)
) {
event.preventDefault()
acceptedDragTypes={
(category === Category.trash && data.category === Category.home) ||
(category !== Category.trash && data.category === Category.trash)
? ['application/vnd.enso.assets+json']
: []
}
}}
onDrop={event => {
if (
(category === Category.trash && currentCategory === Category.home) ||
(category !== Category.trash && currentCategory === Category.trash)
) {
event.preventDefault()
event.stopPropagation()
unsetModal()
const payload = drag.ASSET_ROWS.lookup(event)
if (payload != null) {
void Promise.all(
event.items.flatMap(async item => {
if (item.kind === 'text') {
const text = await item.getText('application/vnd.enso.assets+json')
const payload: unknown = JSON.parse(text)
return Array.isArray(payload)
? payload.flatMap(key =>
// This is SAFE, assuming only this app creates payloads with
// the specific mimetype above.
// eslint-disable-next-line no-restricted-syntax
typeof key === 'string' ? [key as backend.AssetId] : []
)
: []
} else {
return []
}
})
).then(keys => {
dispatchAssetEvent({
type:
category === Category.trash ? AssetEventType.restore : AssetEventType.delete,
ids: new Set(payload.map(item => item.key)),
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 textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import SvgMask from '#/components/SvgMask'
import Twemoji from '#/components/Twemoji'
import UnstyledButton from '#/components/UnstyledButton'
import * as dateTime from '#/utilities/dateTime'
import * as newtype from '#/utilities/newtype'
@ -110,9 +112,9 @@ function ReactionBar(props: ReactionBarProps) {
return (
<div className={`m-chat-reaction-bar inline-block rounded-full bg-frame ${className ?? ''}`}>
{REACTION_EMOJIS.map(emoji => (
<button
<UnstyledButton
key={emoji}
onClick={() => {
onPress={() => {
if (selectedReactions.has(emoji)) {
doRemoveReaction(emoji)
} else {
@ -124,7 +126,7 @@ function ReactionBar(props: ReactionBarProps) {
}`}
>
<Twemoji key={emoji} emoji={emoji} size={REACTION_BUTTON_SIZE} />
</button>
</UnstyledButton>
))}
</div>
)
@ -263,17 +265,14 @@ function ChatHeader(props: InternalChatHeaderProps) {
}
}, [gtagEvent])
const toggleThreadListVisibility = React.useCallback((event: React.SyntheticEvent) => {
event.stopPropagation()
setIsThreadListVisible(visible => !visible)
}, [])
return (
<>
<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"
onClick={toggleThreadListVisibility}
onPress={() => {
setIsThreadListVisible(visible => !visible)
}}
>
<SvgMask
className={`shrink-0 transition-transform duration-arrow ${
@ -282,7 +281,7 @@ function ChatHeader(props: InternalChatHeaderProps) {
src={FolderArrowIcon}
/>
<div className="grow">
<input
<aria.Input
type="text"
ref={titleInputRef}
defaultValue={threadTitle}
@ -320,10 +319,10 @@ function ChatHeader(props: InternalChatHeaderProps) {
}}
/>
</div>
</button>
<button className="mx-close-icon" onClick={doClose}>
</UnstyledButton>
<UnstyledButton className="mx-close-icon" onPress={doClose}>
<img src={CloseLargeIcon} />
</button>
</UnstyledButton>
</div>
<div className="relative text-sm font-semibold">
<div
@ -380,6 +379,21 @@ export default function Chat(props: ChatProps) {
const { getText } = textProvider.useText()
const logger = loggerProvider.useLogger()
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.
* See `dashboard.tsx` for its sole usage. */
@ -592,8 +606,7 @@ export default function Chat(props: ChatProps) {
)
const sendCurrentMessage = React.useCallback(
(event: React.SyntheticEvent, createNewThread?: boolean) => {
event.preventDefault()
(createNewThread?: boolean) => {
const element = messageInputRef.current
if (element != null) {
const content = element.value
@ -663,6 +676,7 @@ export default function Chat(props: ChatProps) {
return reactDom.createPortal(
<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'}`}
{...focusWithinProps}
>
<ChatHeader
threads={threads}
@ -743,7 +757,9 @@ export default function Chat(props: ChatProps) {
</div>
<form
className="mx-chat-form-x my-chat-form-y rounded-default bg-frame p-chat-form"
onSubmit={sendCurrentMessage}
onSubmit={() => {
sendCurrentMessage()
}}
>
<textarea
ref={messageInputRef}
@ -776,36 +792,37 @@ export default function Chat(props: ChatProps) {
}}
/>
<div className="flex gap-chat-buttons">
<button
type="button"
disabled={!isReplyEnabled}
<UnstyledButton
isDisabled={!isReplyEnabled}
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'
}`}
onClick={event => {
sendCurrentMessage(event, true)
onPress={() => {
sendCurrentMessage(true)
}}
>
{getText('clickForNewQuestion')}
</button>
<button
type="submit"
disabled={!isReplyEnabled}
</UnstyledButton>
<UnstyledButton
isDisabled={!isReplyEnabled}
className="rounded-full bg-blue-600/90 px-chat-button-x py-chat-button-y text-white selectable enabled:active"
onPress={() => {
sendCurrentMessage()
}}
>
{getText('replyExclamation')}
</button>
</UnstyledButton>
</div>
</form>
{!isPaidUser && (
<button
<UnstyledButton
// This UI element does not appear anywhere else.
// 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"
onClick={upgradeToPro}
onPress={upgradeToPro}
>
{getText('upgradeToProNag')}
</button>
</UnstyledButton>
)}
</div>,
container

View File

@ -14,6 +14,8 @@ import * as textProvider from '#/providers/TextProvider'
import * as chat from '#/layouts/Chat'
import UnstyledButton from '#/components/UnstyledButton'
/** Props for a {@link ChatPlaceholder}. */
export interface ChatPlaceholderProps {
/** 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="grow" />
<button className="mx-close-icon" onClick={doClose}>
<UnstyledButton className="mx-close-icon" onPress={doClose}>
<img src={CloseLargeIcon} />
</button>
</UnstyledButton>
</div>
<div className="grid grow place-items-center">
<div className="flex flex-col gap-status-page text-center text-base">
<div className="px-missing-functionality-text-x">
{getText('placeholderChatPrompt')}
</div>
<button
<UnstyledButton
className="button self-center bg-help text-white"
onClick={() => {
onPress={() => {
navigate(appUtils.LOGIN_PATH)
}}
>
{getText('login')}
</button>
<button
</UnstyledButton>
<UnstyledButton
className="button self-center bg-help text-white"
onClick={() => {
onPress={() => {
navigate(appUtils.REGISTRATION_PATH)
}}
>
{getText('register')}
</button>
</UnstyledButton>
</div>
</div>
</div>,

View File

@ -26,7 +26,9 @@ import Category from '#/layouts/CategorySwitcher/Category'
import DriveBar from '#/layouts/DriveBar'
import Labels from '#/layouts/Labels'
import * as aria from '#/components/aria'
import type * as spinner from '#/components/Spinner'
import UnstyledButton from '#/components/UnstyledButton'
import * as backendModule from '#/services/Backend'
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="flex flex-col gap-status-page text-center text-base">
<div>{getText('youAreNotLoggedIn')}</div>
<button
<UnstyledButton
className="button self-center bg-help text-white"
onClick={() => {
onPress={() => {
navigate(appUtils.LOGIN_PATH)
}}
>
{getText('login')}
</button>
</UnstyledButton>
</div>
</div>
)
@ -350,9 +352,9 @@ export default function Drive(props: DriveProps) {
{getText('upgrade')}
</a>
{!supportsLocalBackend && (
<button
<UnstyledButton
className="button self-center bg-help text-white"
onClick={async () => {
onPress={async () => {
const downloadUrl = await github.getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('noAppDownloadError')
@ -362,7 +364,7 @@ export default function Drive(props: DriveProps) {
}}
>
{getText('downloadFreeEdition')}
</button>
</UnstyledButton>
)}
</div>
</div>
@ -376,10 +378,13 @@ export default function Drive(props: DriveProps) {
hidden ? 'hidden' : ''
}`}
>
<div className="flex flex-col gap-icons self-start">
<h1 className="h-heading px-heading-x py-heading-y text-xl font-bold leading-snug">
<div className="flex flex-col items-start gap-icons self-start">
<aria.Heading
level={1}
className="h-heading px-heading-x py-heading-y text-xl font-bold leading-snug"
>
{isCloud ? getText('cloudDrive') : getText('localDrive')}
</h1>
</aria.Heading>
<DriveBar
category={category}
canDownload={canDownload}

View File

@ -18,7 +18,10 @@ import AssetEventType from '#/events/AssetEventType'
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 UpsertDataLinkModal from '#/modals/UpsertDataLinkModal'
@ -82,18 +85,17 @@ export default function DriveBar(props: DriveBarProps) {
// in the given directory, to avoid name collisions.
return (
<div className="flex h-row py-drive-bar-y">
<div className="flex gap-drive-bar" />
<HorizontalMenuBar />
</div>
)
}
case Category.trash: {
return (
<div className="flex h-row py-drive-bar-y">
<div className="flex gap-drive-bar">
<button
<HorizontalMenuBar>
<UnstyledButton
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
onClick={event => {
event.stopPropagation()
onPress={() => {
setModal(
<ConfirmDeleteModal
actionText={getText('allTrashedItemsForever')}
@ -102,42 +104,47 @@ export default function DriveBar(props: DriveBarProps) {
)
}}
>
<span className="text whitespace-nowrap font-semibold">{getText('clearTrash')}</span>
</button>
</div>
<aria.Text className="text whitespace-nowrap font-semibold">
{getText('clearTrash')}
</aria.Text>
</UnstyledButton>
</HorizontalMenuBar>
</div>
)
}
case Category.home: {
return (
<div className="flex h-row py-drive-bar-y">
<div className="flex gap-drive-bar">
<button
<HorizontalMenuBar>
<UnstyledButton
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
onClick={() => {
onPress={() => {
unsetModal()
doCreateProject()
}}
>
<span className="text whitespace-nowrap font-semibold">{getText('newProject')}</span>
</button>
<aria.Text className="text whitespace-nowrap font-semibold">
{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">
{isCloud && (
<Button
active
image={AddFolderIcon}
alt={getText('newFolder')}
onClick={() => {
onPress={() => {
unsetModal()
doCreateDirectory()
}}
/>
)}
{isCloud && (
<Button
active
image={AddKeyIcon}
alt={getText('newSecret')}
onClick={event => {
event.stopPropagation()
onPress={() => {
setModal(<UpsertSecretModal id={null} name={null} doCreate={doCreateSecret} />)
}}
/>
@ -147,18 +154,18 @@ export default function DriveBar(props: DriveBarProps) {
active
image={AddConnectorIcon}
alt={getText('newDataLink')}
onClick={event => {
event.stopPropagation()
onPress={() => {
setModal(<UpsertDataLinkModal doCreate={doCreateDataLink} />)
}}
/>
)}
<input
<aria.Input
ref={uploadFilesRef}
type="file"
multiple
id="upload_files_input"
name="upload_files_input"
{...(isCloud ? {} : { accept: '.enso-project' })}
className="hidden"
onInput={event => {
if (event.currentTarget.files != null) {
@ -173,27 +180,26 @@ export default function DriveBar(props: DriveBarProps) {
active
image={DataUploadIcon}
alt={getText('uploadFiles')}
onClick={() => {
onPress={() => {
unsetModal()
uploadFilesRef.current?.click()
}}
/>
<Button
active={canDownload}
disabled={!canDownload}
isDisabled={!canDownload}
image={DataDownloadIcon}
alt={getText('downloadFiles')}
error={
isCloud ? getText('canOnlyDownloadFilesError') : getText('noProjectSelectedError')
}
onClick={event => {
event.stopPropagation()
onPress={() => {
unsetModal()
dispatchAssetEvent({ type: AssetEventType.downloadSelected })
}}
/>
</div>
</div>
</HorizontalMenuBar>
</div>
)
}

View File

@ -4,10 +4,12 @@ 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 type * as assetListEventModule from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType'
import * as aria from '#/components/aria'
import ContextMenu from '#/components/ContextMenu'
import ContextMenuEntry from '#/components/ContextMenuEntry'
@ -19,7 +21,7 @@ import * as backendModule from '#/services/Backend'
/** Props for a {@link GlobalContextMenu}. */
export interface GlobalContextMenuProps {
readonly hidden?: boolean
readonly hasCopyData: boolean
readonly hasPasteData: boolean
readonly directoryKey: backendModule.DirectoryId | null
readonly directoryId: backendModule.DirectoryId | null
readonly dispatchAssetListEvent: (event: assetListEventModule.AssetListEvent) => void
@ -31,26 +33,29 @@ export interface GlobalContextMenuProps {
/** A context menu available everywhere in the directory. */
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 { user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const rootDirectoryId = React.useMemo(
() => user?.rootDirectoryId ?? backendModule.DirectoryId(''),
[user]
)
const filesInputRef = React.useRef<HTMLInputElement>(null)
const isCloud = backend.type === backendModule.BackendType.remote
return (
<ContextMenu hidden={hidden}>
<ContextMenu aria-label={getText('globalContextMenuLabel')} hidden={hidden}>
{!hidden && (
<input
<aria.Input
ref={filesInputRef}
multiple
type="file"
id="context_menu_file_input"
className="hidden"
{...(backend.type !== backendModule.BackendType.local ? {} : { accept: '.enso-project' })}
onInput={event => {
if (event.currentTarget.files != null) {
dispatchAssetListEvent({
@ -162,7 +167,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
}}
/>
)}
{isCloud && directoryKey == null && hasCopyData && (
{isCloud && directoryKey == null && hasPasteData && (
<ContextMenuEntry
hidden={hidden}
action="paste"

View File

@ -6,6 +6,7 @@ import * as textProvider from '#/providers/TextProvider'
import Samples from '#/layouts/Samples'
import WhatsNew from '#/layouts/WhatsNew'
import * as aria from '#/components/aria'
import type * as spinner from '#/components/Spinner'
// ============
@ -36,12 +37,15 @@ export default function Home(props: HomeProps) {
<div />
{/* Header */}
<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')}
</h1>
<h2 className="self-center py-banner-item text-center text-xl font-normal leading-snug">
</aria.Heading>
<aria.Text className="self-center py-banner-item text-center text-xl font-normal leading-snug">
{getText('welcomeSubtitle')}
</h2>
</aria.Text>
</div>
<WhatsNew />
<Samples createProject={createProject} />

View File

@ -7,8 +7,11 @@ import Trash2Icon from 'enso-assets/trash2.svg'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import Label from '#/components/dashboard/Label'
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 ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
@ -45,25 +48,36 @@ export default function Labels(props: LabelsProps) {
const currentNegativeLabels = query.negativeLabels
const { setModal } = modalProvider.useSetModal()
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 (
<FocusArea direction="vertical">
{innerProps => (
<div
data-testid="labels"
className="flex w-full flex-col items-start gap-sidebar-section-heading"
className="gap-sidebar-section-heading flex w-full flex-col items-start"
{...innerProps}
>
<div className="text-header px-sidebar-section-heading-x text-sm font-bold">
{getText('labels')}
</div>
<ul data-testid="labels-list" className="flex flex-col items-start gap-labels">
{labels
.filter(label => !deletedLabelNames.has(label.value))
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
.map(label => {
<div
data-testid="labels-list"
aria-label={getText('labelsListLabel')}
className="flex flex-col items-start gap-labels"
>
{displayLabels.map(label => {
const negated = currentNegativeLabels.some(term =>
array.shallowEqual(term, [label.value])
)
return (
<li key={label.id} className="group flex items-center gap-label-icons">
<div key={label.id} className="group relative flex items-center gap-label-icons">
<Label
draggable
color={label.color}
@ -71,10 +85,15 @@ export default function Labels(props: LabelsProps) {
negated || currentLabels.some(term => array.shallowEqual(term, [label.value]))
}
negated={negated}
disabled={newLabelNames.has(label.value)}
onClick={event => {
isDisabled={newLabelNames.has(label.value)}
onPress={event => {
setQuery(oldQuery =>
oldQuery.withToggled('labels', 'negativeLabels', label.value, event.shiftKey)
oldQuery.withToggled(
'labels',
'negativeLabels',
label.value,
event.shiftKey
)
)
}}
onDragStart={event => {
@ -88,7 +107,7 @@ export default function Labels(props: LabelsProps) {
drag.LABELS.unbind(payload)
}}
>
<Label active color={label.color} onClick={() => {}}>
<Label active color={label.color} onPress={() => {}}>
{label.value}
</Label>
</DragModal>
@ -98,10 +117,10 @@ export default function Labels(props: LabelsProps) {
{label.value}
</Label>
{!newLabelNames.has(label.value) && (
<button
className="flex"
onClick={event => {
event.stopPropagation()
<FocusRing placement="after">
<aria.Button
className="relative flex after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring"
onPress={() => {
setModal(
<ConfirmDeleteModal
actionText={getText('deleteLabelActionText', label.value)}
@ -115,36 +134,37 @@ export default function Labels(props: LabelsProps) {
<SvgMask
src={Trash2Icon}
alt={getText('delete')}
className="size-icon text-delete transition-all transparent group-hover:active"
className="size-icon text-delete transition-all transparent group-has-[[data-focus-visible]]:active group-hover:active"
/>
</button>
</aria.Button>
</FocusRing>
)}
</li>
</div>
)
})}
<li>
<Label
active
color={labelUtils.DEFAULT_LABEL_COLOR}
className="bg-frame text-not-selected"
onClick={event => {
event.stopPropagation()
className="bg-selected-frame"
onPress={event => {
if (event.target instanceof HTMLElement) {
setModal(
<NewLabelModal
labels={labels}
eventTarget={event.currentTarget}
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]" />
<span className="text-header">{getText('newLabelButtonLabel')}</span>
<aria.Text className="text-header">{getText('newLabelButtonLabel')}</aria.Text>
</Label>
</li>
</ul>
</div>
</div>
)}
</FocusArea>
)
}

View File

@ -9,7 +9,8 @@ import type * as text from '#/text'
import * as textProvider from '#/providers/TextProvider'
import Button from '#/components/Button'
import Button from '#/components/styled/Button'
import FocusArea from '#/components/styled/FocusArea'
// ====================
// === PageSwitcher ===
@ -31,30 +32,23 @@ const ERRORS = {
[Page.settings]: 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. */
interface PageUIData {
readonly page: Page
readonly icon: string
readonly altId: Extract<text.TextId, `${Page}PageAltText`>
readonly tooltipId: Extract<text.TextId, `${Page}PageTooltip`>
}
const PAGE_DATA: PageUIData[] = [
{ page: Page.home, icon: HomeIcon },
{ page: Page.drive, icon: DriveIcon },
{ page: Page.editor, icon: NetworkIcon },
{ page: Page.home, icon: HomeIcon, altId: 'homePageAltText', tooltipId: 'homePageTooltip' },
{ page: Page.drive, icon: DriveIcon, altId: 'drivePageAltText', tooltipId: 'drivePageTooltip' },
{
page: Page.editor,
icon: NetworkIcon,
altId: 'editorPageAltText',
tooltipId: 'editorPageTooltip',
},
]
/** Props for a {@link PageSwitcher}. */
@ -68,31 +62,49 @@ export interface PageSwitcherProps {
export default function PageSwitcher(props: PageSwitcherProps) {
const { page, setPage, isEditorDisabled } = props
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 (
<FocusArea direction="horizontal">
{innerProps => (
<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' : ''
}`}
{...innerProps}
>
{PAGE_DATA.map(pageData => {
const isDisabled =
pageData.page === page || (pageData.page === Page.editor && isEditorDisabled)
const errorId = ERRORS[pageData.page]
return (
<Button
key={pageData.page}
aria-label={getText(pageData.tooltipId)}
alt={getText(pageData.altId)}
image={pageData.icon}
active={page === pageData.page}
alt={getText(PAGE_TO_ALT_TEXT_ID[pageData.page])}
title={getText(PAGE_TO_TOOLTIP_ID[pageData.page])}
disabled={isDisabled}
error={errorId == null ? null : getText(errorId)}
onClick={() => {
softDisabled={page === pageData.page}
isDisabled={pageData.page === Page.editor && isEditorDisabled}
error={ERRORS[pageData.page]}
onPress={() => {
setPage(pageData.page)
}}
/>
)
})}
</div>
)}
</FocusArea>
)
}

View File

@ -11,7 +11,10 @@ import VisualizeImage from 'enso-assets/visualize.png'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import Spinner, * as spinner from '#/components/Spinner'
import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
// =================
@ -81,12 +84,12 @@ export const SAMPLES: Sample[] = [
},
]
// =====================
// === ProjectsEntry ===
// =====================
// ========================
// === BlankProjectTile ===
// ========================
/** Props for an {@link ProjectsEntry}. */
interface InternalProjectsEntryProps {
/** Props for an {@link BlankProjectTile}. */
interface InternalBlankProjectTileProps {
readonly createProject: (
templateId: null,
templateName: null,
@ -95,12 +98,12 @@ interface InternalProjectsEntryProps {
}
/** A button that, when clicked, creates and opens a new blank project. */
function ProjectsEntry(props: InternalProjectsEntryProps) {
function BlankProjectTile(props: InternalBlankProjectTileProps) {
const { createProject } = props
const { getText } = textProvider.useText()
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
const onClick = () => {
const onPress = () => {
setSpinnerState(spinner.SpinnerState.initial)
createProject(null, null, newSpinnerState => {
setSpinnerState(newSpinnerState)
@ -114,11 +117,13 @@ function ProjectsEntry(props: InternalProjectsEntryProps) {
return (
<div className="flex flex-col gap-sample">
<button
// This UI element does not appear anywhere else.
// eslint-disable-next-line no-restricted-syntax
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"
onClick={onClick}
<FocusArea direction="horizontal">
{innerProps => (
<FocusRing placement="after">
<aria.Button
className="focus-child relative h-sample cursor-pointer before:absolute before:inset before:h-full before:w-full before:rounded-default before:bg-frame before:opacity-60 after:pointer-events-none after:absolute after:inset after:rounded-default"
onPress={onPress}
{...innerProps}
>
<div className="relative flex size-full rounded-default">
<div className="m-auto flex flex-col items-center gap-new-empty-project text-center">
@ -130,7 +135,10 @@ function ProjectsEntry(props: InternalProjectsEntryProps) {
<p className="text-sm font-semibold">{getText('newEmptyProject')}</p>
</div>
</div>
</button>
</aria.Button>
</FocusRing>
)}
</FocusArea>
<div className="h-sample-info" />
</div>
)
@ -169,17 +177,21 @@ function ProjectTile(props: InternalProjectTileProps) {
}
}, [])
const onClick = () => {
const onPress = () => {
setSpinnerState(spinner.SpinnerState.initial)
createProject(id, title, onSpinnerStateChange)
}
return (
<div className="flex flex-col gap-sample">
<button
<FocusArea direction="horizontal">
{innerProps => (
<FocusRing placement="after">
<aria.Button
key={title}
className="relative flex h-sample grow cursor-pointer flex-col text-left"
onClick={onClick}
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"
onPress={onPress}
{...innerProps}
>
<div
style={{ background }}
@ -188,7 +200,7 @@ function ProjectTile(props: InternalProjectTileProps) {
}`}
/>
<div className="w-full grow rounded-b-default bg-frame px-sample-description-x pb-sample-description-b pt-sample-description-t backdrop-blur">
<h2 className="text-header text-sm font-bold">{title}</h2>
<aria.Heading className="text-header text-sm font-bold">{title}</aria.Heading>
<div className="text-ellipsis text-xs leading-snug">{description}</div>
</div>
{spinnerState != null && (
@ -196,24 +208,27 @@ function ProjectTile(props: InternalProjectTileProps) {
<Spinner size={SPINNER_SIZE_PX} state={spinnerState} />
</div>
)}
</button>
</aria.Button>
</FocusRing>
)}
</FocusArea>
{/* Although this component is instantiated multiple times, it has a unique role and hence
* its own opacity. */}
{/* 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 gap-samples-icon-with-text">
<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>
{/* Normally `flex` */}
<div className="hidden gap-icons">
<div title={getText('views')} className="flex gap-samples-icon-with-text">
<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 title={getText('likes')} className="flex gap-samples-icon-with-text">
<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>
@ -238,11 +253,14 @@ export interface SamplesProps {
export default function Samples(props: SamplesProps) {
const { createProject } = props
const { getText } = textProvider.useText()
return (
<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">
<ProjectsEntry createProject={createProject} />
<BlankProjectTile createProject={createProject} />
{SAMPLES.map(sample => (
<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 SettingsSidebar from '#/layouts/SettingsSidebar'
import * as aria from '#/components/aria'
import * as backendModule from '#/services/Backend'
import * as array from '#/utilities/array'
@ -89,8 +91,8 @@ export default function Settings() {
return (
<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">
<span className="py-heading-y">{getText('settingsFor')}</span>
<aria.Heading level={1} className="flex h-heading px-heading-x text-xl font-bold">
<aria.Text className="py-heading-y">{getText('settingsFor')}</aria.Text>
{/* This UI element does not appear anywhere else. */}
{/* 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">
@ -98,7 +100,7 @@ export default function Settings() {
? user?.name ?? 'your account'
: organization.name ?? 'your organization'}
</div>
</div>
</aria.Heading>
<div className="flex flex-1 gap-settings overflow-hidden">
<SettingsSidebar settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
{content}

View File

@ -1,105 +1,12 @@
/** @file Settings tab for viewing and editing account information. */
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 backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import SvgMask from '#/components/SvgMask'
import ConfirmDeleteUserModal from '#/modals/ConfirmDeleteUserModal'
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>
)
}
import ChangePasswordSettingsSection from '#/layouts/Settings/ChangePasswordSettingsSection'
import DeleteUserAccountSettingsSection from '#/layouts/Settings/DeleteUserAccountSettingsSection'
import ProfilePictureSettingsSection from '#/layouts/Settings/ProfilePictureSettingsSection'
import UserAccountSettingsSection from '#/layouts/Settings/UserAccountSettingsSection'
// ==========================
// === AccountSettingsTab ===
@ -107,16 +14,7 @@ function Input(props: InternalInputProps) {
/** Settings tab for viewing and editing account information. */
export default function AccountSettingsTab() {
const toastAndLog = toastAndLogHooks.useToastAndLog()
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('')
const { accessToken } = authProvider.useNonPartialUserSession()
// The shape of the JWT payload is statically known.
// 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
accessToken != null ? JSON.parse(atob(accessToken.split('.')[1]!)).username : null
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 (
<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 flex-col gap-settings-section-header">
<h3 className="settings-subheading">{getText('userAccount')}</h3>
<div className="flex flex-col">
<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>
<UserAccountSettingsSection />
{canChangePassword && <ChangePasswordSettingsSection />}
<DeleteUserAccountSettingsSection />
</div>
<ProfilePictureSettingsSection />
</div>
)
}

View File

@ -12,10 +12,15 @@ import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import DateInput from '#/components/DateInput'
import Dropdown from '#/components/Dropdown'
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 UnstyledButton from '#/components/UnstyledButton'
import * as backendModule from '#/services/Backend'
@ -117,10 +122,11 @@ export default function ActivityLogSettingsTab() {
const isLoading = sortedLogs == null
return (
<div className="flex flex-col gap-settings-subsection">
<div className="flex flex-col gap-settings-section-header">
<h3 className="settings-subheading">{getText('activityLog')}</h3>
<div className="flex gap-activity-log-filters">
<SettingsPage>
<SettingsSection noFocusArea title={getText('activityLog')}>
<FocusArea direction="horizontal">
{innerProps => (
<div className="flex gap-activity-log-filters" {...innerProps}>
<div className="flex items-center gap-activity-log-filter">
{getText('startDate')}
<DateInput date={startDate} onInput={setStartDate} />
@ -137,7 +143,8 @@ export default function ActivityLogSettingsTab() {
selectedIndices={typeIndices}
render={props => EVENT_TYPE_NAME[props.item]}
renderMultiple={props =>
props.items.length === 0 || props.items.length === backendModule.EVENT_TYPES.length
props.items.length === 0 ||
props.items.length === backendModule.EVENT_TYPES.length
? 'All'
: (props.items[0] != null ? EVENT_TYPE_NAME[props.items[0]] : '') +
(props.items.length <= 1 ? '' : ` (+${props.items.length - 1})`)
@ -168,13 +175,15 @@ export default function ActivityLogSettingsTab() {
/>
</div>
</div>
)}
</FocusArea>
<table className="table-fixed self-start rounded-rows">
<thead>
<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-type-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
<button
title={
<UnstyledButton
aria-label={
sortInfo?.field !== ActivityLogSortableColumn.type
? getText('sortByName')
: isDescending
@ -182,8 +191,7 @@ export default function ActivityLogSettingsTab() {
: getText('sortByNameDescending')
}
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
onClick={event => {
event.stopPropagation()
onPress={() => {
const nextDirection =
sortInfo?.field === ActivityLogSortableColumn.type
? 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
alt={
sortInfo?.field === ActivityLogSortableColumn.type && isDescending
@ -216,11 +224,11 @@ export default function ActivityLogSettingsTab() {
: ''
}`}
/>
</button>
</UnstyledButton>
</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">
<button
title={
<UnstyledButton
aria-label={
sortInfo?.field !== ActivityLogSortableColumn.email
? getText('sortByEmail')
: isDescending
@ -228,8 +236,7 @@ export default function ActivityLogSettingsTab() {
: getText('sortByEmailDescending')
}
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
onClick={event => {
event.stopPropagation()
onPress={() => {
const nextDirection =
sortInfo?.field === ActivityLogSortableColumn.email
? 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
alt={
sortInfo?.field === ActivityLogSortableColumn.email && isDescending
@ -262,11 +269,11 @@ export default function ActivityLogSettingsTab() {
: ''
}`}
/>
</button>
</UnstyledButton>
</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">
<button
title={
<UnstyledButton
aria-label={
sortInfo?.field !== ActivityLogSortableColumn.timestamp
? getText('sortByTimestamp')
: isDescending
@ -274,8 +281,7 @@ export default function ActivityLogSettingsTab() {
: getText('sortByTimestampDescending')
}
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
onClick={event => {
event.stopPropagation()
onPress={() => {
const nextDirection =
sortInfo?.field === ActivityLogSortableColumn.timestamp
? 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
alt={
sortInfo?.field === ActivityLogSortableColumn.timestamp && isDescending
@ -308,7 +314,7 @@ export default function ActivityLogSettingsTab() {
: ''
}`}
/>
</button>
</UnstyledButton>
</th>
</tr>
</thead>
@ -346,7 +352,7 @@ export default function ActivityLogSettingsTab() {
)}
</tbody>
</table>
</div>
</div>
</SettingsSection>
</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 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 inputBindingsManager from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
import SvgMask from '#/components/SvgMask'
import KeyboardShortcutsSettingsTabBar from '#/layouts/Settings/KeyboardShortcutsSettingsTabBar'
import KeyboardShortcutsTable from '#/layouts/Settings/KeyboardShortcutsTable'
import CaptureKeyboardShortcutModal from '#/modals/CaptureKeyboardShortcutModal'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import * as object from '#/utilities/object'
import SettingsSection from '#/components/styled/settings/SettingsSection'
// ====================================
// === KeyboardShortcutsSettingsTab ===
// ====================================
/** Settings tab for viewing and editing account information. */
/** Settings tab for viewing and editing keyboard shortcuts. */
export default function KeyboardShortcutsSettingsTab() {
const inputBindings = inputBindingsManager.useInputBindings()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
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 (
<div className="flex w-full flex-1 flex-col gap-settings-section-header">
<h3 className="settings-subheading">{getText('keyboardShortcuts')}</h3>
<div className="flex gap-drive-bar">
<button
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>
<SettingsSection noFocusArea title={getText('keyboardShortcuts')} className="w-full flex-1">
<KeyboardShortcutsSettingsTabBar doRefresh={doRefresh} />
<KeyboardShortcutsTable refresh={refresh} doRefresh={doRefresh} />
</SettingsSection>
)
}

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