mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 21:41:34 +03:00
Keyboard navigation between components (#9499)
- Close https://github.com/enso-org/cloud-v2/issues/982 - Add keyboard navigation via arrows between different components - This is achieved by a `Navigator2D` class which keeps track of the closest adjacent elements. Other changes: - Switch much of the codebase to use `react-aria-components` - This *should* (but does not necessarily) give us improved accessibility for free. - Refactor various common styles into styled components - `FocusArea` to perform automatic registration with `Navigator2D` - `Button` and `UnstyledButton` to let buttons participate in keyboard navigation - `HorizontalMenuBar` - used for buttons below the titles in the Drive page, Keyboard Shortcuts settings page, and Members List settings page - `SettingsPage` in the settings pages - `SettingsSection` in the settings page to wrap around `FocusArea` and the heading for each section - Add debugging utilities - Add debugging when `body` has the `data-debug` attribute: `document.body.dataset.debug = ''` - This adds rings around elements (all with different colors): - That are `FocusArea`s. `FocusArea` is a wrapper component that makes an element participate in `Navigator2D`. - That are `:focus`ed, and that are `:focus-visible` - That are `.focus-child`. This is because keyboard navigation via arrows ***ignores*** all focusable elements that are not `.focus-child`. - Debug `Navigator2D` neighbors when `body` has the `debug-navigator2d` attribute: `document.body.dataset.debugNavigator2d = ''` - This highlights neighbors of the currently focused element. This is a separate debug option because computing neighbors is potentially quite expensive. # Important Notes - ⚠️ Modals and the authentication flow are not yet fully tested. - Up+Down to navigate through suggestions has been disabled to improve UX when accidentally navigating upwards to the assets search bar. - There are a number of *known* issues with keyboard navigation. For the most part it's because a proper solution will be quite difficult. - Focus is lost when a column (from the extra columns selector) is toggled - because the button stops existing - It's not possible to navigate to the icons on the assets table - so it's current not possible to *hide* columns via the keyboard - Neighbors of the extra columns selector are not ideal (both when it is being navigated from, and when it is being navigated to) - The suggestions in the `AssetSearchBar` aren't *quite* fully integrated with arrow keyboard navigation. - This is *semi*-intentional. I think it makes a lot more sense to integrate them in, *however* it stays like this for now largely because I think pressing `ArrowUp` then `ArrowDown` from the assets table should return to the assets table - Likewise for the assets table. The reason here, however, is because we want multi-select. While `react-aria-components` has lists which support multi-select, it doesn't allow programmatic focus control, making it not particularly ideal, as we want to focus the topmost element when navigating in from above. - Clicking on the "New Folder" icon (and the like) do not focus on the newly created child. This one should be pretty easy to do, but I'm not sure whether it's the right thing to do.
This commit is contained in:
parent
973d2c6aea
commit
9cf4847a34
8
app/ide-desktop/.vscode/settings.json
vendored
8
app/ide-desktop/.vscode/settings.json
vendored
@ -4,5 +4,11 @@
|
||||
"javascript.preferences.importModuleSpecifierEnding": "minimal",
|
||||
"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/*"
|
||||
]
|
||||
}
|
||||
|
@ -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',
|
||||
{
|
||||
|
@ -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 })
|
||||
}
|
||||
|
||||
// ==========================
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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)
|
||||
|
BIN
app/ide-desktop/lib/dashboard/favicon.ico
Normal file
BIN
app/ide-desktop/lib/dashboard/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
@ -34,23 +34,25 @@
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/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",
|
||||
|
@ -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 = () => {
|
||||
|
@ -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>
|
||||
</>
|
||||
<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 =>
|
||||
tailwindMerge.twMerge(
|
||||
classes,
|
||||
typeof className === 'function' ? className(values) : className
|
||||
)
|
||||
}
|
||||
{...ariaButtonProps}
|
||||
<aria.Button
|
||||
{...aria.mergeProps<aria.ButtonProps>()(ariaButtonProps, focusChildProps, {
|
||||
className: values =>
|
||||
tailwindMerge.twMerge(
|
||||
classes,
|
||||
typeof className === 'function' ? className(values) : className
|
||||
),
|
||||
})}
|
||||
>
|
||||
{childrenFactory()}
|
||||
</reactAriaComponents.Button>
|
||||
</aria.Button>
|
||||
)
|
||||
}
|
||||
|
||||
const VARIANT_TO_CLASSES: Record<ButtonProps['variant'], string> = {
|
||||
cancel: CANCEL_CLASSES,
|
||||
delete: DELETE_CLASSES,
|
||||
icon: ICON_CLASSES,
|
||||
submit: SUBMIT_CLASSES,
|
||||
}
|
||||
|
@ -5,11 +5,11 @@
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import * as 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>
|
||||
)
|
||||
}
|
||||
|
@ -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} />
|
||||
)
|
||||
}
|
||||
|
@ -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 {}
|
||||
|
@ -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
|
||||
|
@ -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,47 +178,49 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
|
||||
|
||||
return (
|
||||
<div onKeyDown={onKeyDown} className="grow">
|
||||
<div className="flex flex-1">
|
||||
{canEditText ? (
|
||||
<input
|
||||
type={type}
|
||||
ref={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
size={1}
|
||||
value={text ?? ''}
|
||||
placeholder={placeholder}
|
||||
className="text grow bg-transparent px-button-x"
|
||||
onFocus={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
onChange={event => {
|
||||
setIsDropdownVisible(true)
|
||||
setText(event.currentTarget.value === '' ? null : event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={element => element?.focus()}
|
||||
tabIndex={-1}
|
||||
className="text grow cursor-pointer bg-transparent px-button-x"
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FocusRing within>
|
||||
<div className="flex flex-1 rounded-full">
|
||||
{canEditText ? (
|
||||
<Input
|
||||
type={type}
|
||||
ref={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
size={1}
|
||||
value={text ?? ''}
|
||||
placeholder={placeholder}
|
||||
className="text grow rounded-full bg-transparent px-button-x"
|
||||
onFocus={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
onChange={event => {
|
||||
setIsDropdownVisible(true)
|
||||
setText(event.currentTarget.value === '' ? null : event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={element => element?.focus()}
|
||||
tabIndex={-1}
|
||||
className="text grow cursor-pointer bg-transparent px-button-x"
|
||||
onClick={() => {
|
||||
setIsDropdownVisible(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIsDropdownVisible(false)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{itemsToString?.(values) ?? (values[0] != null ? itemToString(values[0]) : ZWSP)}
|
||||
</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 ${
|
||||
|
@ -1,45 +0,0 @@
|
||||
/** @file A styled button. */
|
||||
import * as React from 'react'
|
||||
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
/** Props for a {@link Button}. */
|
||||
export interface ButtonProps {
|
||||
/** When `true`, the button is not faded out even when not hovered. */
|
||||
readonly active?: boolean
|
||||
/** When `true`, the button is not clickable. */
|
||||
readonly disabled?: boolean
|
||||
readonly image: string
|
||||
readonly alt?: string
|
||||
/** A title that is only shown when `disabled` is `true`. */
|
||||
readonly error?: string | null
|
||||
/** The default title. */
|
||||
readonly title?: string
|
||||
readonly className?: string
|
||||
readonly onClick: (event: React.MouseEvent) => void
|
||||
}
|
||||
|
||||
/** A styled button. */
|
||||
export default function Button(props: ButtonProps) {
|
||||
const { active = false, disabled = false, image, error } = props
|
||||
const { title, alt, className, onClick } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
className={`group flex selectable ${active ? 'active' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<SvgMask
|
||||
src={image}
|
||||
{...(!active && disabled && error != null
|
||||
? { title: error }
|
||||
: title != null
|
||||
? { title }
|
||||
: {})}
|
||||
{...(alt != null ? { alt } : {})}
|
||||
className={className}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
@ -1,37 +1,82 @@
|
||||
/** @file A color picker to select from a predetermined list of colors. */
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
<RadioGroup
|
||||
ref={ref}
|
||||
{...radioGroupProps}
|
||||
orientation="horizontal"
|
||||
onChange={value => {
|
||||
const color = backend.COLOR_STRING_TO_COLOR.get(value)
|
||||
if (color != null) {
|
||||
setColor(color)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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)
|
||||
|
@ -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">
|
||||
<div
|
||||
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 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`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -1,21 +0,0 @@
|
||||
/** @file A horizontal line dividing two sections in the context menu. */
|
||||
import * as React from 'react'
|
||||
|
||||
// ============================
|
||||
// === ContextMenuSeparator ===
|
||||
// ============================
|
||||
|
||||
/** Props for a {@link ContextMenuSeparator}. */
|
||||
export interface ContextMenuSeparatorProps {
|
||||
readonly hidden?: boolean
|
||||
}
|
||||
|
||||
/** A horizontal line dividing two sections in the context menu. */
|
||||
export default function ContextMenuSeparator(props: ContextMenuSeparatorProps) {
|
||||
const { hidden = false } = props
|
||||
return hidden ? null : (
|
||||
<div className="py-context-menu-separator-y">
|
||||
<div className="border-t-[0.5px] border-black/[0.16]" />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,6 +1,11 @@
|
||||
/** @file Styled input element. */
|
||||
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,67 +33,80 @@ 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 => {
|
||||
onChange?.(event)
|
||||
setValue(event.target.value)
|
||||
setWasJustBlurred(false)
|
||||
if (validate) {
|
||||
if (reportTimeoutHandle != null) {
|
||||
window.clearTimeout(reportTimeoutHandle)
|
||||
}
|
||||
const currentTarget = event.currentTarget
|
||||
if (error != null) {
|
||||
currentTarget.setCustomValidity('')
|
||||
currentTarget.setCustomValidity(
|
||||
currentTarget.checkValidity() || shouldReportValidityRef?.current === false
|
||||
? ''
|
||||
: error
|
||||
)
|
||||
}
|
||||
if (hasReportedValidity) {
|
||||
if (shouldReportValidityRef?.current === false || currentTarget.checkValidity()) {
|
||||
setHasReportedValidity(false)
|
||||
<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)
|
||||
}
|
||||
} else {
|
||||
setReportTimeoutHandle(
|
||||
window.setTimeout(() => {
|
||||
if (shouldReportValidityRef?.current !== false && !currentTarget.reportValidity()) {
|
||||
setHasReportedValidity(true)
|
||||
},
|
||||
onChange: event => {
|
||||
onChange?.(event)
|
||||
setValue(event.target.value)
|
||||
setWasJustBlurred(false)
|
||||
if (validate) {
|
||||
if (reportTimeoutHandle != null) {
|
||||
window.clearTimeout(reportTimeoutHandle)
|
||||
}
|
||||
const currentTarget = event.currentTarget
|
||||
if (error != null) {
|
||||
currentTarget.setCustomValidity('')
|
||||
currentTarget.setCustomValidity(
|
||||
currentTarget.checkValidity() || shouldReportValidityRef?.current === false
|
||||
? ''
|
||||
: error
|
||||
)
|
||||
}
|
||||
if (hasReportedValidity) {
|
||||
if (shouldReportValidityRef?.current === false || currentTarget.checkValidity()) {
|
||||
setHasReportedValidity(false)
|
||||
}
|
||||
}, DEBOUNCE_MS)
|
||||
)
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBlur={
|
||||
validate
|
||||
? event => {
|
||||
onBlur?.(event)
|
||||
if (wasJustBlurred) {
|
||||
setHasReportedValidity(false)
|
||||
} else {
|
||||
const currentTarget = event.currentTarget
|
||||
if (shouldReportValidityRef?.current !== false) {
|
||||
if (!currentTarget.reportValidity()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
setWasJustBlurred(true)
|
||||
setReportTimeoutHandle(
|
||||
window.setTimeout(() => {
|
||||
if (
|
||||
shouldReportValidityRef?.current !== false &&
|
||||
!currentTarget.reportValidity()
|
||||
) {
|
||||
setHasReportedValidity(true)
|
||||
}
|
||||
}, DEBOUNCE_MS)
|
||||
)
|
||||
}
|
||||
}
|
||||
: onBlur
|
||||
}
|
||||
className="w-full rounded-full border py-auth-input-y pl-auth-icon-container-w pr-auth-input-r text-sm placeholder-gray-500 transition-all duration-auth hover:bg-gray-100 focus:bg-gray-100"
|
||||
/>
|
||||
},
|
||||
onBlur: validate
|
||||
? event => {
|
||||
onBlur?.(event)
|
||||
if (wasJustBlurred) {
|
||||
setHasReportedValidity(false)
|
||||
} else {
|
||||
const currentTarget = event.currentTarget
|
||||
if (shouldReportValidityRef?.current !== false) {
|
||||
if (!currentTarget.reportValidity()) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
setWasJustBlurred(true)
|
||||
}
|
||||
}
|
||||
: onBlur,
|
||||
})}
|
||||
/>
|
||||
</FocusRing>
|
||||
)
|
||||
}
|
||||
|
@ -5,9 +5,14 @@ import CrossIcon from 'enso-assets/cross.svg'
|
||||
import FolderArrowDoubleIcon from 'enso-assets/folder_arrow_double.svg'
|
||||
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
|
||||
@ -44,6 +49,7 @@ export interface DateInputProps {
|
||||
export default function DateInput(props: DateInputProps) {
|
||||
const { date, onInput } = props
|
||||
const { getText } = textProvider.useText()
|
||||
const focusChildProps = focusHooks.useFocusChild()
|
||||
const year = date?.getFullYear() ?? new Date().getFullYear()
|
||||
const monthIndex = date?.getMonth() ?? new Date().getMonth()
|
||||
const [isPickerVisible, setIsPickerVisible] = React.useState(false)
|
||||
@ -94,80 +100,92 @@ export default function DateInput(props: DateInputProps) {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<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={() => {
|
||||
setIsPickerVisible(!isPickerVisible)
|
||||
}}
|
||||
>
|
||||
<div className="flex grow flex-col items-center">
|
||||
{date != null ? dateTime.formatDate(date) : 'No date selected'}
|
||||
<FocusRing>
|
||||
<div
|
||||
{...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) : getText('noDateSelected')}
|
||||
</div>
|
||||
{date != null && (
|
||||
<UnstyledButton
|
||||
className="flex rounded-full transition-colors hover:bg-hover-bg"
|
||||
onPress={() => {
|
||||
onInput(null)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={CrossIcon} className="size-icon" />
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</div>
|
||||
{date != null && (
|
||||
<button
|
||||
className="flex rounded-full transition-colors hover:bg-hover-bg"
|
||||
onClick={() => {
|
||||
onInput(null)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={CrossIcon} className="size-icon" />
|
||||
</button>
|
||||
)}
|
||||
</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="flex items-center">
|
||||
<button
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-hover-bg"
|
||||
onClick={() => {
|
||||
<div className="relative mb-date-input-gap">
|
||||
<div className="flex items-center">
|
||||
<UnstyledButton
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-hover-bg"
|
||||
onPress={() => {
|
||||
setSelectedYear(selectedYear - 1)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowDoubleIcon} className="rotate-180" />
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onPress={() => {
|
||||
if (selectedMonthIndex === 0) {
|
||||
setSelectedYear(selectedYear - 1)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowDoubleIcon} className="rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onClick={() => {
|
||||
if (selectedMonthIndex === 0) {
|
||||
setSelectedYear(selectedYear - 1)
|
||||
setSelectedMonthIndex(LAST_MONTH_INDEX)
|
||||
} else {
|
||||
setSelectedMonthIndex(selectedMonthIndex - 1)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} className="rotate-180" />
|
||||
</button>
|
||||
<span className="grow">
|
||||
{dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear}
|
||||
</span>
|
||||
<button
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onClick={() => {
|
||||
if (selectedMonthIndex === LAST_MONTH_INDEX) {
|
||||
setSelectedYear(selectedYear + 1)
|
||||
setSelectedMonthIndex(0)
|
||||
} else {
|
||||
setSelectedMonthIndex(selectedMonthIndex + 1)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onClick={() => {
|
||||
setSelectedMonthIndex(LAST_MONTH_INDEX)
|
||||
} else {
|
||||
setSelectedMonthIndex(selectedMonthIndex - 1)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} className="rotate-180" />
|
||||
</UnstyledButton>
|
||||
<aria.Text className="grow text-center">
|
||||
{dateTime.MONTH_NAMES[selectedMonthIndex]} {selectedYear}
|
||||
</aria.Text>
|
||||
<UnstyledButton
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onPress={() => {
|
||||
if (selectedMonthIndex === LAST_MONTH_INDEX) {
|
||||
setSelectedYear(selectedYear + 1)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowDoubleIcon} />
|
||||
</button>
|
||||
</div>
|
||||
</caption>
|
||||
setSelectedMonthIndex(0)
|
||||
} else {
|
||||
setSelectedMonthIndex(selectedMonthIndex + 1)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} />
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
className="inline-flex rounded-small-rectangle-button hover:bg-black/10"
|
||||
onPress={() => {
|
||||
setSelectedYear(selectedYear + 1)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowDoubleIcon} />
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
</div>
|
||||
<table className="relative w-full">
|
||||
<thead>
|
||||
<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={() => {
|
||||
setIsPickerVisible(false)
|
||||
onInput(currentDate)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
disabled={isSelectedDate}
|
||||
<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)
|
||||
}}
|
||||
>
|
||||
{day.date}
|
||||
</button>
|
||||
</UnstyledButton>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
@ -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,153 +155,169 @@ export default function Dropdown<T>(props: DropdownProps<T>) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
tabIndex={0}
|
||||
className={`group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy ${
|
||||
className ?? ''
|
||||
}`}
|
||||
onFocus={event => {
|
||||
if (!readOnly && event.target === event.currentTarget) {
|
||||
setIsDropdownVisible(true)
|
||||
justFocusedRef.current = true
|
||||
}
|
||||
}}
|
||||
onBlur={event => {
|
||||
// TODO: should not blur when `multiple` and clicking on option
|
||||
if (!readOnly && event.target === event.currentTarget) {
|
||||
setIsDropdownVisible(false)
|
||||
}
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={() => {
|
||||
justFocusedRef.current = false
|
||||
}}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<FocusRing placement="outset">
|
||||
<div
|
||||
className={`absolute left-0 h-full w-full min-w-max ${isDropdownVisible ? 'z-1' : 'overflow-hidden'}`}
|
||||
ref={element => {
|
||||
if (typeof ref === 'function') {
|
||||
ref(element)
|
||||
} else if (ref != null) {
|
||||
ref.current = element
|
||||
}
|
||||
rootRef.current = element
|
||||
}}
|
||||
tabIndex={0}
|
||||
className={`focus-child group relative flex w-max cursor-pointer flex-col items-start whitespace-nowrap rounded-input leading-cozy ${
|
||||
className ?? ''
|
||||
}`}
|
||||
onFocus={event => {
|
||||
if (!justBlurredRef.current && !readOnly && event.target === event.currentTarget) {
|
||||
setIsDropdownVisible(true)
|
||||
justFocusedRef.current = true
|
||||
}
|
||||
justBlurredRef.current = false
|
||||
}}
|
||||
onBlur={event => {
|
||||
if (!readOnly && event.target === event.currentTarget) {
|
||||
setIsDropdownVisible(false)
|
||||
justBlurredRef.current = true
|
||||
}
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={() => {
|
||||
justFocusedRef.current = false
|
||||
}}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
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'
|
||||
}`}
|
||||
className={`absolute left-0 h-full w-full min-w-max ${isDropdownVisible ? 'z-1' : 'overflow-hidden'}`}
|
||||
>
|
||||
{/* Spacing. */}
|
||||
<div
|
||||
className="padding relative h-text"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
if (!justFocusedRef.current && !readOnly) {
|
||||
setIsDropdownVisible(false)
|
||||
}
|
||||
justFocusedRef.current = false
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows ${
|
||||
isDropdownVisible ? 'grid-rows-1fr' : 'grid-rows-0fr'
|
||||
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-hover-bg'
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className={`flex h-text items-center gap-dropdown-arrow rounded-input px-input-x transition-colors ${
|
||||
multiple ? 'hover:font-semibold' : ''
|
||||
} ${
|
||||
i === visuallySelectedIndex
|
||||
? `cursor-default bg-frame font-bold`
|
||||
: 'hover:bg-primary/10'
|
||||
}`}
|
||||
key={i}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
isMouseDown.current = true
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
isMouseDown.current = false
|
||||
}}
|
||||
onClick={() => {
|
||||
if (i !== visuallySelectedIndex) {
|
||||
if (multiple) {
|
||||
const newIndices = selectedIndices.includes(i)
|
||||
? selectedIndices.filter(index => index !== i)
|
||||
: [...selectedIndices, i]
|
||||
props.onClick(
|
||||
newIndices.flatMap(index => {
|
||||
const otherItem = items[index]
|
||||
return otherItem != null ? [otherItem] : []
|
||||
}),
|
||||
newIndices
|
||||
)
|
||||
rootRef.current?.focus()
|
||||
} else {
|
||||
setIsDropdownVisible(false)
|
||||
props.onClick(item, i)
|
||||
{/* Spacing. */}
|
||||
<div
|
||||
className="padding relative h-text"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
if (!justFocusedRef.current && !readOnly) {
|
||||
setIsDropdownVisible(false)
|
||||
}
|
||||
justFocusedRef.current = false
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`relative grid max-h-dropdown-items w-full overflow-auto rounded-input transition-grid-template-rows ${
|
||||
isDropdownVisible ? 'grid-rows-1fr' : 'grid-rows-0fr'
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className={`flex h-text items-center gap-dropdown-arrow rounded-input px-input-x transition-colors ${
|
||||
multiple ? 'hover:font-semibold' : ''
|
||||
} ${
|
||||
i === visuallySelectedIndex
|
||||
? `cursor-default bg-frame font-bold focus-ring`
|
||||
: 'hover:bg-hover-bg'
|
||||
}`}
|
||||
key={i}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
isMouseDown.current = true
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
isMouseDown.current = false
|
||||
}}
|
||||
onClick={() => {
|
||||
if (i !== visuallySelectedIndex) {
|
||||
if (multiple) {
|
||||
const newIndices = selectedIndices.includes(i)
|
||||
? selectedIndices.filter(index => index !== i)
|
||||
: [...selectedIndices, i]
|
||||
props.onClick(
|
||||
newIndices.flatMap(index => {
|
||||
const otherItem = items[index]
|
||||
return otherItem != null ? [otherItem] : []
|
||||
}),
|
||||
newIndices
|
||||
)
|
||||
rootRef.current?.focus()
|
||||
} else {
|
||||
setIsDropdownVisible(false)
|
||||
props.onClick(item, i)
|
||||
justBlurredRef.current = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!isMouseDown.current) {
|
||||
// This is from keyboard navigation.
|
||||
if (multiple) {
|
||||
props.onClick([item], [i])
|
||||
} else {
|
||||
props.onClick(item, i)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!isMouseDown.current) {
|
||||
// This is from keyboard navigation.
|
||||
if (multiple) {
|
||||
props.onClick([item], [i])
|
||||
} else {
|
||||
props.onClick(item, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SvgMask
|
||||
src={CheckMarkIcon}
|
||||
className={selectedIndices.includes(i) ? '' : 'invisible'}
|
||||
/>
|
||||
<Child item={item} />
|
||||
</div>
|
||||
))}
|
||||
}}
|
||||
>
|
||||
<SvgMask
|
||||
src={CheckMarkIcon}
|
||||
className={selectedIndices.includes(i) ? '' : 'invisible'}
|
||||
/>
|
||||
<Child item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`relative flex h-text items-center gap-dropdown-arrow px-input-x ${isDropdownVisible ? 'z-1' : ''} ${
|
||||
readOnly ? 'read-only' : ''
|
||||
}`}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
if (!justFocusedRef.current && !readOnly) {
|
||||
setIsDropdownVisible(false)
|
||||
}
|
||||
justFocusedRef.current = false
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} className="rotate-90" />
|
||||
<div className="grow">
|
||||
{visuallySelectedItem != null ? (
|
||||
<Child item={visuallySelectedItem} />
|
||||
) : (
|
||||
multiple && <props.renderMultiple items={selectedItems} render={Child} />
|
||||
)}
|
||||
<div
|
||||
className={`relative flex h-text items-center gap-dropdown-arrow px-input-x ${isDropdownVisible ? 'z-1' : ''} ${
|
||||
readOnly ? 'read-only' : ''
|
||||
}`}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
if (!justFocusedRef.current && !readOnly) {
|
||||
setIsDropdownVisible(false)
|
||||
justBlurredRef.current = true
|
||||
}
|
||||
justFocusedRef.current = false
|
||||
}}
|
||||
>
|
||||
<SvgMask src={FolderArrowIcon} className="rotate-90" />
|
||||
<div className="grow">
|
||||
{visuallySelectedItem != null ? (
|
||||
<Child item={visuallySelectedItem} />
|
||||
) : (
|
||||
multiple && <props.renderMultiple items={selectedItems} render={Child} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Hidden, but required to exist for the width of the parent element to be correct.
|
||||
* Classes that do not affect width have been removed. */}
|
||||
<div className="flex h flex-col overflow-hidden">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex gap-dropdown-arrow px-input-x font-bold">
|
||||
<SvgMask src={CheckMarkIcon} />
|
||||
<Child item={item} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Hidden, but required to exist for the width of the parent element to be correct.
|
||||
* Classes that do not affect width have been removed. */}
|
||||
<div className="flex h flex-col overflow-hidden">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex gap-dropdown-arrow px-input-x ${i === visuallySelectedIndex ? '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
|
||||
|
@ -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"
|
||||
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
|
||||
onMouseDown={() => {
|
||||
cancelled.current = true
|
||||
}}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onCancel()
|
||||
window.setTimeout(() => {
|
||||
cancelled.current = false
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-icon" />
|
||||
</button>
|
||||
<FocusRing>
|
||||
<UnstyledButton
|
||||
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
|
||||
onPress={() => {
|
||||
cancelledRef.current = true
|
||||
onCancel()
|
||||
window.setTimeout(() => {
|
||||
cancelledRef.current = false
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-icon" />
|
||||
</UnstyledButton>
|
||||
</FocusRing>
|
||||
</form>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<span data-testid={dataTestId} className={className}>
|
||||
<aria.Text data-testid={props['data-testid']} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
</aria.Text>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -4,8 +4,13 @@ import * as React from 'react'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import Autocomplete from '#/components/Autocomplete'
|
||||
import Dropdown from '#/components/Dropdown'
|
||||
import Checkbox from '#/components/styled/Checkbox'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as jsonSchema from '#/utilities/jsonSchema'
|
||||
import * as object from '#/utilities/object'
|
||||
@ -95,74 +100,91 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
)
|
||||
} else {
|
||||
children.push(
|
||||
<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 ${
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
||||
}`}
|
||||
placeholder={getText('enterText')}
|
||||
onChange={event => {
|
||||
const newValue: string = event.currentTarget.value
|
||||
setValue(newValue)
|
||||
}}
|
||||
/>
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<FocusRing>
|
||||
<aria.Input
|
||||
type="text"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
size={1}
|
||||
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')}
|
||||
onChange={event => {
|
||||
const newValue: string = event.currentTarget.value
|
||||
setValue(newValue)
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
</FocusRing>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'number': {
|
||||
children.push(
|
||||
<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 ${
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
||||
}`}
|
||||
placeholder={getText('enterNumber')}
|
||||
onChange={event => {
|
||||
const newValue: number = event.currentTarget.valueAsNumber
|
||||
if (Number.isFinite(newValue)) {
|
||||
setValue(newValue)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<FocusRing>
|
||||
<aria.Input
|
||||
type="number"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
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')}
|
||||
onChange={event => {
|
||||
const newValue: number = event.currentTarget.valueAsNumber
|
||||
if (Number.isFinite(newValue)) {
|
||||
setValue(newValue)
|
||||
}
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
</FocusRing>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'integer': {
|
||||
children.push(
|
||||
<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 ${
|
||||
getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60'
|
||||
}`}
|
||||
placeholder={getText('enterInteger')}
|
||||
onChange={event => {
|
||||
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
|
||||
setValue(newValue)
|
||||
}}
|
||||
/>
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<FocusRing>
|
||||
<aria.Input
|
||||
type="number"
|
||||
readOnly={readOnly}
|
||||
value={typeof value === 'number' ? value : ''}
|
||||
size={1}
|
||||
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')}
|
||||
onChange={event => {
|
||||
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,38 +216,46 @@ 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 ${
|
||||
isOptional ? 'hover:bg-hover-bg' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (isOptional) {
|
||||
setValue(oldValue => {
|
||||
if (oldValue != null && key in oldValue) {
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// The removed key is intentionally unused.
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
|
||||
const { [key]: removed, ...newValue } = oldValue as Record<
|
||||
string,
|
||||
NonNullable<unknown> | null
|
||||
>
|
||||
return newValue
|
||||
} else {
|
||||
return {
|
||||
...oldValue,
|
||||
[key]: jsonSchema.constantValue(defs, childSchema, true)[0],
|
||||
}
|
||||
<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' : ''
|
||||
}`}
|
||||
onPress={() => {
|
||||
if (isOptional) {
|
||||
setValue(oldValue => {
|
||||
if (oldValue != null && key in oldValue) {
|
||||
// This is SAFE, as `value` is an untyped object.
|
||||
// The removed key is intentionally unused.
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
|
||||
const { [key]: removed, ...newValue } = oldValue as Record<
|
||||
string,
|
||||
NonNullable<unknown> | null
|
||||
>
|
||||
return newValue
|
||||
} else {
|
||||
return {
|
||||
...oldValue,
|
||||
[key]: jsonSchema.constantValue(defs, childSchema, true)[0],
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{'title' in childSchema ? String(childSchema.title) : key}
|
||||
</button>
|
||||
}}
|
||||
{...innerProps}
|
||||
>
|
||||
<aria.Text
|
||||
className={`selectable ${
|
||||
value != null && key in value ? 'active' : ''
|
||||
}`}
|
||||
>
|
||||
{'title' in childSchema ? String(childSchema.title) : key}
|
||||
</aria.Text>
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</FocusArea>
|
||||
{value != null && key in value && (
|
||||
<JSONSchemaInput
|
||||
readOnly={readOnly}
|
||||
@ -293,18 +323,23 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
}
|
||||
}
|
||||
const dropdown = (
|
||||
<Dropdown
|
||||
readOnly={readOnly}
|
||||
items={childSchemas}
|
||||
selectedIndex={selectedChildIndex}
|
||||
render={childProps => jsonSchema.getSchemaName(defs, childProps.item)}
|
||||
className="self-start"
|
||||
onClick={(childSchema, index) => {
|
||||
setSelectedChildIndex(index)
|
||||
const newConstantValue = jsonSchema.constantValue(defs, childSchema, true)
|
||||
setValue(newConstantValue[0] ?? null)
|
||||
}}
|
||||
/>
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<Dropdown
|
||||
readOnly={readOnly}
|
||||
items={childSchemas}
|
||||
selectedIndex={selectedChildIndex}
|
||||
render={childProps => jsonSchema.getSchemaName(defs, childProps.item)}
|
||||
className="self-start"
|
||||
onClick={(childSchema, index) => {
|
||||
setSelectedChildIndex(index)
|
||||
const newConstantValue = jsonSchema.constantValue(defs, childSchema, true)
|
||||
setValue(newConstantValue[0] ?? null)
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
children.push(
|
||||
<div className={`flex flex-col gap-json-schema ${childValue.length === 0 ? 'w-full' : ''}`}>
|
||||
|
@ -3,6 +3,10 @@ import * as React from 'react'
|
||||
|
||||
import * as router from 'react-router-dom'
|
||||
|
||||
import * as 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 (
|
||||
<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"
|
||||
>
|
||||
<SvgMask src={icon} />
|
||||
{text}
|
||||
</router.Link>
|
||||
<FocusRing>
|
||||
<router.Link
|
||||
{...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>
|
||||
)
|
||||
}
|
||||
|
@ -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 ${
|
||||
isContextMenuEntry ? 'px-context-menu-entry-x' : ''
|
||||
}`}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
doAction()
|
||||
}}
|
||||
<UnstyledButton
|
||||
isDisabled={isDisabled}
|
||||
className="group flex w-full rounded-menu-entry"
|
||||
onPress={doAction}
|
||||
>
|
||||
<div 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])}
|
||||
<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' : ''
|
||||
}`}
|
||||
>
|
||||
<div title={title} className="flex items-center gap-menu-entry whitespace-nowrap">
|
||||
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" />
|
||||
<aria.Text slot="label">{label ?? getText(ACTION_TO_TEXT_ID[action])}</aria.Text>
|
||||
</div>
|
||||
<KeyboardShortcut action={action} />
|
||||
</div>
|
||||
<KeyboardShortcut action={action} />
|
||||
</button>
|
||||
</UnstyledButton>
|
||||
)
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ import * as React from 'react'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import FocusRoot from '#/components/styled/FocusRoot'
|
||||
|
||||
// =================
|
||||
// === Component ===
|
||||
// =================
|
||||
@ -29,26 +31,37 @@ export default function Modal(props: ModalProps) {
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
return (
|
||||
<div
|
||||
// The name comes from a third-party API and cannot be changed.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
{...(!hidden ? { 'data-testid': 'modal-background' } : {})}
|
||||
style={style}
|
||||
className={`inset z-1 ${centered ? 'size-screen fixed grid place-items-center' : ''} ${
|
||||
className ?? ''
|
||||
}`}
|
||||
onClick={
|
||||
onClick ??
|
||||
(event => {
|
||||
if (event.currentTarget === event.target && getSelection()?.type !== 'Range') {
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
<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
|
||||
{...(!hidden ? { 'data-testid': 'modal-background' } : {})}
|
||||
style={style}
|
||||
className={`inset z-1 ${centered ? 'size-screen fixed grid place-items-center' : ''} ${
|
||||
className ?? ''
|
||||
}`}
|
||||
onClick={
|
||||
onClick ??
|
||||
(event => {
|
||||
if (event.currentTarget === event.target && getSelection()?.type !== 'Range') {
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
onContextMenu={onContextMenu}
|
||||
{...innerProps}
|
||||
onKeyDown={event => {
|
||||
innerProps.onKeyDown?.(event)
|
||||
if (event.key !== 'Escape') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</FocusRoot>
|
||||
)
|
||||
}
|
||||
|
@ -1,35 +1,25 @@
|
||||
/**
|
||||
* @file
|
||||
* The root component with required providers
|
||||
*/
|
||||
/** @file The root component with required providers */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as 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>
|
||||
)
|
||||
}
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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' : ''} ${
|
||||
|
37
app/ide-desktop/lib/dashboard/src/components/TextLink.tsx
Normal file
37
app/ide-desktop/lib/dashboard/src/components/TextLink.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
/** @file A link without an icon. */
|
||||
import * as router from 'react-router-dom'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
|
||||
// ================
|
||||
// === TextLink ===
|
||||
// ================
|
||||
|
||||
/** Props for a {@link TextLink}. */
|
||||
export interface TextLinkProps {
|
||||
readonly to: string
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/** A link without an icon. */
|
||||
export default function TextLink(props: TextLinkProps) {
|
||||
const { to, text } = props
|
||||
const focusChildProps = focusHooks.useFocusChild()
|
||||
|
||||
return (
|
||||
<FocusRing>
|
||||
<router.Link
|
||||
{...aria.mergeProps<router.LinkProps>()(focusChildProps, {
|
||||
to,
|
||||
className:
|
||||
'-mx-text-link-px self-end rounded-full px-text-link-x text-end text-xs text-blue-500 transition-all duration-auth hover:text-blue-700 focus:text-blue-700',
|
||||
})}
|
||||
>
|
||||
{text}
|
||||
</router.Link>
|
||||
</FocusRing>
|
||||
)
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
/** @file An unstyled button with a focus ring and focus movement behavior. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import type * as focusRing from '#/components/styled/FocusRing'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
|
||||
// ======================
|
||||
// === UnstyledButton ===
|
||||
// ======================
|
||||
|
||||
/** Props for a {@link UnstyledButton}. */
|
||||
export interface UnstyledButtonProps extends Readonly<React.PropsWithChildren> {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
readonly 'aria-label'?: string
|
||||
readonly focusRingPlacement?: focusRing.FocusRingPlacement
|
||||
readonly autoFocus?: boolean
|
||||
/** When `true`, the button is not clickable. */
|
||||
readonly isDisabled?: boolean
|
||||
readonly className?: string
|
||||
readonly style?: React.CSSProperties
|
||||
readonly onPress: (event: aria.PressEvent) => void
|
||||
}
|
||||
|
||||
/** An unstyled button with a focus ring and focus movement behavior. */
|
||||
function UnstyledButton(props: UnstyledButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
|
||||
const { focusRingPlacement, children, ...buttonProps } = props
|
||||
const focusChildProps = focusHooks.useFocusChild()
|
||||
|
||||
return (
|
||||
<FocusRing {...(focusRingPlacement == null ? {} : { placement: focusRingPlacement })}>
|
||||
<aria.Button
|
||||
{...aria.mergeProps<aria.ButtonProps & React.RefAttributes<HTMLButtonElement>>()(
|
||||
buttonProps,
|
||||
focusChildProps,
|
||||
{ ref }
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</aria.Button>
|
||||
</FocusRing>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(UnstyledButton)
|
19
app/ide-desktop/lib/dashboard/src/components/aria.tsx
Normal file
19
app/ide-desktop/lib/dashboard/src/components/aria.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
/** @file Barrel re-export of `react-aria` and `react-aria-components`. */
|
||||
import * as aria from 'react-aria'
|
||||
|
||||
export type * from '@react-types/shared'
|
||||
export * from 'react-aria'
|
||||
// @ts-expect-error The conflicting exports are props types ONLY.
|
||||
export * from 'react-aria-components'
|
||||
|
||||
/** Merges multiple props objects together.
|
||||
* Event handlers are chained, classNames are combined, and ids are deduplicated -
|
||||
* different ids will trigger a side-effect and re-render components hooked up with `useId`.
|
||||
* For all other props, the last prop object overrides all previous ones.
|
||||
*
|
||||
* The constraint is defaulted to `never` to make an explicit constraint mandatory. */
|
||||
export function mergeProps<Constraint extends object = never>() {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return <T extends (Partial<Constraint> | null | undefined)[]>(...args: T) =>
|
||||
aria.mergeProps(...args)
|
||||
}
|
@ -6,12 +6,16 @@ import SettingsIcon from 'enso-assets/settings.svg'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as 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 (
|
||||
<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()
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
alt={isAssetPanelVisible ? getText('closeAssetPanel') : getText('openAssetPanel')}
|
||||
active={isAssetPanelVisible}
|
||||
image={SettingsIcon}
|
||||
error={getText('multipleAssetsSettingsError')}
|
||||
onClick={() => {
|
||||
setIsAssetPanelVisible(visible => !visible)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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'
|
||||
}`}
|
||||
{...innerProps}
|
||||
>
|
||||
<Button
|
||||
alt={isAssetPanelEnabled ? getText('closeAssetPanel') : getText('openAssetPanel')}
|
||||
active={isAssetPanelEnabled}
|
||||
image={SettingsIcon}
|
||||
error={getText('multipleAssetsSettingsError')}
|
||||
onPress={() => {
|
||||
setIsAssetPanelEnabled(visible => !visible)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -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,168 +675,175 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
return (
|
||||
<>
|
||||
{!hidden && (
|
||||
<tr
|
||||
draggable
|
||||
tabIndex={-1}
|
||||
ref={element => {
|
||||
if (isSoleSelected && element != null && scrollContainerRef.current != null) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
|
||||
const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX)
|
||||
const scrollDown = rect.bottom - scrollRect.bottom
|
||||
if (scrollUp < 0 || scrollDown > 0) {
|
||||
scrollContainerRef.current.scrollBy({
|
||||
top: scrollUp < 0 ? scrollUp : scrollDown,
|
||||
behavior: 'smooth',
|
||||
<FocusRing>
|
||||
<tr
|
||||
draggable
|
||||
tabIndex={0}
|
||||
ref={element => {
|
||||
rootRef.current = element
|
||||
if (isSoleSelected && element != null && scrollContainerRef.current != null) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const scrollRect = scrollContainerRef.current.getBoundingClientRect()
|
||||
const scrollUp = rect.top - (scrollRect.top + HEADER_HEIGHT_PX)
|
||||
const scrollDown = rect.bottom - scrollRect.bottom
|
||||
if (scrollUp < 0 || scrollDown > 0) {
|
||||
scrollContainerRef.current.scrollBy({
|
||||
top: scrollUp < 0 ? scrollUp : scrollDown,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (isKeyboardSelected && element?.contains(document.activeElement) === false) {
|
||||
element.focus()
|
||||
}
|
||||
}}
|
||||
className={`h-row rounded-full transition-all ease-in-out ${visibility} ${isDraggedOver || selected ? 'selected' : ''}`}
|
||||
onClick={event => {
|
||||
unsetModal()
|
||||
onClick(innerProps, event)
|
||||
if (
|
||||
asset.type === backendModule.AssetType.directory &&
|
||||
eventModule.isDoubleClick(event) &&
|
||||
!rowState.isEditingName
|
||||
) {
|
||||
// This must be processed on the next tick, otherwise it will be overridden
|
||||
// by the default click handler.
|
||||
window.setTimeout(() => {
|
||||
setSelected(false)
|
||||
})
|
||||
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
|
||||
}
|
||||
}}
|
||||
onContextMenu={event => {
|
||||
if (allowContextMenu) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onContextMenu?.(innerProps, event)
|
||||
setModal(
|
||||
<AssetContextMenu
|
||||
innerProps={innerProps}
|
||||
event={event}
|
||||
eventTarget={
|
||||
event.target instanceof HTMLElement ? event.target : event.currentTarget
|
||||
}
|
||||
doCopy={doCopy}
|
||||
doCut={doCut}
|
||||
doPaste={doPaste}
|
||||
doDelete={doDelete}
|
||||
doTriggerDescriptionEdit={doTriggerDescriptionEdit}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
onContextMenu?.(innerProps, event)
|
||||
}
|
||||
}}
|
||||
onDragStart={event => {
|
||||
if (rowState.isEditingName) {
|
||||
event.preventDefault()
|
||||
} else {
|
||||
props.onDragStart?.(event)
|
||||
}
|
||||
}}
|
||||
onDragEnter={event => {
|
||||
if (dragOverTimeoutHandle.current != null) {
|
||||
window.clearTimeout(dragOverTimeoutHandle.current)
|
||||
}
|
||||
if (backendModule.assetIsDirectory(asset)) {
|
||||
dragOverTimeoutHandle.current = window.setTimeout(() => {
|
||||
doToggleDirectoryExpansion(asset.id, item.key, asset.title, true)
|
||||
}, DRAG_EXPAND_DELAY_MS)
|
||||
}
|
||||
// Required because `dragover` does not fire on `mouseenter`.
|
||||
props.onDragOver?.(event)
|
||||
onDragOver(event)
|
||||
}}
|
||||
onDragOver={event => {
|
||||
props.onDragOver?.(event)
|
||||
onDragOver(event)
|
||||
}}
|
||||
onDragEnd={event => {
|
||||
clearDragState()
|
||||
props.onDragEnd?.(event)
|
||||
}}
|
||||
onDragLeave={event => {
|
||||
if (
|
||||
dragOverTimeoutHandle.current != null &&
|
||||
(!(event.relatedTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(event.relatedTarget))
|
||||
) {
|
||||
window.clearTimeout(dragOverTimeoutHandle.current)
|
||||
}
|
||||
if (
|
||||
event.relatedTarget instanceof Node &&
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
clearDragState()
|
||||
}
|
||||
props.onDragLeave?.(event)
|
||||
}}
|
||||
onDrop={event => {
|
||||
props.onDrop?.(event)
|
||||
clearDragState()
|
||||
const [directoryKey, directoryId, directoryTitle] =
|
||||
item.item.type === backendModule.AssetType.directory
|
||||
? [item.key, item.item.id, asset.title]
|
||||
: [item.directoryKey, item.directoryId, null]
|
||||
const payload = drag.ASSET_ROWS.lookup(event)
|
||||
if (
|
||||
payload != null &&
|
||||
payload.every(innerItem => innerItem.key !== directoryKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
|
||||
const ids = payload
|
||||
.filter(payloadItem => payloadItem.asset.parentId !== directoryId)
|
||||
.map(dragItem => dragItem.key)
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.move,
|
||||
newParentKey: directoryKey,
|
||||
newParentId: directoryId,
|
||||
ids: new Set(ids),
|
||||
})
|
||||
} else if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
// This is SAFE, as it is guarded by the condition above:
|
||||
// `item.item.type === backendModule.AssetType.directory`
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
parentKey: directoryKey as backendModule.DirectoryId,
|
||||
parentId: directoryId,
|
||||
files: Array.from(event.dataTransfer.files),
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`h-row rounded-full outline-2 -outline-offset-2 outline-primary ease-in-out ${visibility} ${
|
||||
isKeyboardSelected ? 'outline' : ''
|
||||
} ${isDraggedOver || selected ? 'selected' : ''}`}
|
||||
onClick={event => {
|
||||
unsetModal()
|
||||
onClick(innerProps, event)
|
||||
if (
|
||||
asset.type === backendModule.AssetType.directory &&
|
||||
eventModule.isDoubleClick(event) &&
|
||||
!rowState.isEditingName
|
||||
) {
|
||||
// This must be processed on the next tick, otherwise it will be overridden
|
||||
// by the default click handler.
|
||||
window.setTimeout(() => {
|
||||
setSelected(false)
|
||||
})
|
||||
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
|
||||
}
|
||||
}}
|
||||
onContextMenu={event => {
|
||||
if (allowContextMenu) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onContextMenu?.(innerProps, event)
|
||||
setModal(
|
||||
<AssetContextMenu
|
||||
innerProps={innerProps}
|
||||
event={event}
|
||||
eventTarget={
|
||||
event.target instanceof HTMLElement ? event.target : event.currentTarget
|
||||
}
|
||||
doCopy={doCopy}
|
||||
doCut={doCut}
|
||||
doPaste={doPaste}
|
||||
doDelete={doDelete}
|
||||
doTriggerDescriptionEdit={doTriggerDescriptionEdit}
|
||||
/>
|
||||
}}
|
||||
>
|
||||
{columns.map(column => {
|
||||
// This is a React component even though it does not contain JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const Render = columnModule.COLUMN_RENDERER[column]
|
||||
return (
|
||||
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
|
||||
<Render
|
||||
keyProp={key}
|
||||
item={item}
|
||||
setItem={setItem}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
isSoleSelected={isSoleSelected}
|
||||
state={state}
|
||||
rowState={rowState}
|
||||
setRowState={setRowState}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
} else {
|
||||
onContextMenu?.(innerProps, event)
|
||||
}
|
||||
}}
|
||||
onDragStart={event => {
|
||||
if (rowState.isEditingName) {
|
||||
event.preventDefault()
|
||||
} else {
|
||||
props.onDragStart?.(event)
|
||||
}
|
||||
}}
|
||||
onDragEnter={event => {
|
||||
if (dragOverTimeoutHandle.current != null) {
|
||||
window.clearTimeout(dragOverTimeoutHandle.current)
|
||||
}
|
||||
if (backendModule.assetIsDirectory(asset)) {
|
||||
dragOverTimeoutHandle.current = window.setTimeout(() => {
|
||||
doToggleDirectoryExpansion(asset.id, item.key, asset.title, true)
|
||||
}, DRAG_EXPAND_DELAY_MS)
|
||||
}
|
||||
// Required because `dragover` does not fire on `mouseenter`.
|
||||
props.onDragOver?.(event)
|
||||
onDragOver(event)
|
||||
}}
|
||||
onDragOver={event => {
|
||||
props.onDragOver?.(event)
|
||||
onDragOver(event)
|
||||
}}
|
||||
onDragEnd={event => {
|
||||
clearDragState()
|
||||
props.onDragEnd?.(event)
|
||||
}}
|
||||
onDragLeave={event => {
|
||||
if (
|
||||
dragOverTimeoutHandle.current != null &&
|
||||
(!(event.relatedTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(event.relatedTarget))
|
||||
) {
|
||||
window.clearTimeout(dragOverTimeoutHandle.current)
|
||||
}
|
||||
if (
|
||||
event.relatedTarget instanceof Node &&
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
clearDragState()
|
||||
}
|
||||
props.onDragLeave?.(event)
|
||||
}}
|
||||
onDrop={event => {
|
||||
props.onDrop?.(event)
|
||||
clearDragState()
|
||||
const [directoryKey, directoryId, directoryTitle] =
|
||||
item.item.type === backendModule.AssetType.directory
|
||||
? [item.key, item.item.id, asset.title]
|
||||
: [item.directoryKey, item.directoryId, null]
|
||||
const payload = drag.ASSET_ROWS.lookup(event)
|
||||
if (payload != null && payload.every(innerItem => innerItem.key !== directoryKey)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
unsetModal()
|
||||
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
|
||||
const ids = payload
|
||||
.filter(payloadItem => payloadItem.asset.parentId !== directoryId)
|
||||
.map(dragItem => dragItem.key)
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.move,
|
||||
newParentKey: directoryKey,
|
||||
newParentId: directoryId,
|
||||
ids: new Set(ids),
|
||||
})
|
||||
} else if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doToggleDirectoryExpansion(directoryId, directoryKey, directoryTitle, true)
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
// This is SAFE, as it is guarded by the condition above:
|
||||
// `item.item.type === backendModule.AssetType.directory`
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
parentKey: directoryKey as backendModule.DirectoryId,
|
||||
parentId: directoryId,
|
||||
files: Array.from(event.dataTransfer.files),
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columns.map(column => {
|
||||
// This is a React component even though it does not contain JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const Render = columnModule.COLUMN_RENDERER[column]
|
||||
return (
|
||||
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
|
||||
<Render
|
||||
keyProp={key}
|
||||
item={item}
|
||||
setItem={setItem}
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
isSoleSelected={isSoleSelected}
|
||||
state={state}
|
||||
rowState={rowState}
|
||||
setRowState={setRowState}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
})}
|
||||
</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>
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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 (
|
||||
<button
|
||||
data-testid={dataTestId}
|
||||
disabled={disabled}
|
||||
className={`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}`}
|
||||
style={{ backgroundColor: backend.lChColorToCssColor(color) }}
|
||||
{...passthrough}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<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
|
||||
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-inherit px-label-x transition-all after:pointer-events-none after:absolute after:inset after:rounded-full ${
|
||||
negated ? 'after:border-2 after:border-delete' : ''
|
||||
} ${className} ${textClass}`}
|
||||
style={{ backgroundColor: backend.lChColorToCssColor(color) }}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onPress(event)
|
||||
}}
|
||||
onDragStart={e => {
|
||||
onDragStart?.(e)
|
||||
}}
|
||||
onContextMenu={onContextMenu}
|
||||
onKeyDown={handleFocusMove}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</div>
|
||||
</FocusRing>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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,64 +68,66 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
onChange(newAction)
|
||||
}
|
||||
|
||||
const doShowPermissionTypeSelector = (event: React.SyntheticEvent<HTMLElement>) => {
|
||||
const position = event.currentTarget.getBoundingClientRect()
|
||||
const originalLeft = position.left + window.scrollX
|
||||
const originalTop = position.top + window.scrollY
|
||||
const left = originalLeft + TYPE_SELECTOR_X_OFFSET_PX
|
||||
const top = originalTop + (typeSelectorYOffsetPx ?? TYPE_SELECTOR_Y_OFFSET_PX)
|
||||
// The border radius of the label. This is half of the label's height.
|
||||
const r = LABEL_BORDER_RADIUS_PX
|
||||
const clipPath =
|
||||
// A rectangle covering the entire screen
|
||||
'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' +
|
||||
// Move to top left of label
|
||||
`M${originalLeft + LABEL_BORDER_RADIUS_PX} ${originalTop + LABEL_CLIP_Y_OFFSET_PX}` +
|
||||
// Top straight edge of label
|
||||
`h${LABEL_STRAIGHT_WIDTH_PX}` +
|
||||
// Right semicircle of label
|
||||
`a${r} ${r} 0 0 1 0 ${r * 2}` +
|
||||
// Bottom straight edge of label
|
||||
`h-${LABEL_STRAIGHT_WIDTH_PX}` +
|
||||
// Left semicircle of label
|
||||
`a${r} ${r} 0 0 1 0 -${r * 2}Z")`
|
||||
setTheChild(oldTheChild =>
|
||||
oldTheChild != null
|
||||
? null
|
||||
: function Child() {
|
||||
return (
|
||||
<Modal
|
||||
className="fixed size-full overflow-auto"
|
||||
onClick={() => {
|
||||
setTheChild(null)
|
||||
}}
|
||||
>
|
||||
<div style={{ clipPath }} className="absolute size-full bg-dim" />
|
||||
<PermissionTypeSelector
|
||||
showDelete={showDelete}
|
||||
type={permission.type}
|
||||
assetType={assetType}
|
||||
selfPermission={selfPermission}
|
||||
style={{ left, top }}
|
||||
onChange={type => {
|
||||
const doShowPermissionTypeSelector = () => {
|
||||
if (permissionSelectorButtonRef.current != null) {
|
||||
const position = permissionSelectorButtonRef.current.getBoundingClientRect()
|
||||
const originalLeft = position.left + window.scrollX
|
||||
const originalTop = position.top + window.scrollY
|
||||
const left = originalLeft + TYPE_SELECTOR_X_OFFSET_PX
|
||||
const top = originalTop + (typeSelectorYOffsetPx ?? TYPE_SELECTOR_Y_OFFSET_PX)
|
||||
// The border radius of the label. This is half of the label's height.
|
||||
const r = LABEL_BORDER_RADIUS_PX
|
||||
const clipPath =
|
||||
// A rectangle covering the entire screen
|
||||
'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' +
|
||||
// Move to top left of label
|
||||
`M${originalLeft + LABEL_BORDER_RADIUS_PX} ${originalTop + LABEL_CLIP_Y_OFFSET_PX}` +
|
||||
// Top straight edge of label
|
||||
`h${LABEL_STRAIGHT_WIDTH_PX}` +
|
||||
// Right semicircle of label
|
||||
`a${r} ${r} 0 0 1 0 ${r * 2}` +
|
||||
// Bottom straight edge of label
|
||||
`h-${LABEL_STRAIGHT_WIDTH_PX}` +
|
||||
// Left semicircle of label
|
||||
`a${r} ${r} 0 0 1 0 -${r * 2}Z")`
|
||||
setTheChild(oldTheChild =>
|
||||
oldTheChild != null
|
||||
? null
|
||||
: function Child() {
|
||||
return (
|
||||
<Modal
|
||||
className="fixed size-full overflow-auto"
|
||||
onClick={() => {
|
||||
setTheChild(null)
|
||||
if (type === permissionsModule.Permission.delete) {
|
||||
doDelete?.()
|
||||
} else {
|
||||
const newAction = permissionsModule.TYPE_TO_PERMISSION_ACTION[type]
|
||||
const newPermissions = permissionsModule.FROM_PERMISSION_ACTION[newAction]
|
||||
if ('docs' in permission && 'docs' in newPermissions) {
|
||||
setAction(permissionsModule.toPermissionAction({ ...permission, type }))
|
||||
} else {
|
||||
setAction(permissionsModule.TYPE_TO_PERMISSION_ACTION[type])
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
)
|
||||
>
|
||||
<div style={{ clipPath }} className="absolute size-full bg-dim" />
|
||||
<PermissionTypeSelector
|
||||
showDelete={showDelete}
|
||||
type={permission.type}
|
||||
assetType={assetType}
|
||||
selfPermission={selfPermission}
|
||||
style={{ left, top }}
|
||||
onChange={type => {
|
||||
setTheChild(null)
|
||||
if (type === permissionsModule.Permission.delete) {
|
||||
doDelete?.()
|
||||
} else {
|
||||
const newAction = permissionsModule.TYPE_TO_PERMISSION_ACTION[type]
|
||||
const newPermissions = permissionsModule.FROM_PERMISSION_ACTION[newAction]
|
||||
if ('docs' in permission && 'docs' in newPermissions) {
|
||||
setAction(permissionsModule.toPermissionAction({ ...permission, type }))
|
||||
} else {
|
||||
setAction(permissionsModule.TYPE_TO_PERMISSION_ACTION[type])
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let permissionDisplay: JSX.Element
|
||||
@ -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,
|
||||
@ -161,17 +163,20 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
)
|
||||
}}
|
||||
>
|
||||
{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
|
||||
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')}
|
||||
</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,
|
||||
@ -181,25 +186,31 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
|
||||
)
|
||||
}}
|
||||
>
|
||||
{getText('execPermissionModifier')}
|
||||
</button>
|
||||
<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')}
|
||||
</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
|
||||
}
|
||||
|
@ -1,6 +1,10 @@
|
||||
/** @file A selector for all possible permission types. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
@ -83,61 +87,67 @@ export interface PermissionTypeSelectorProps {
|
||||
export default function PermissionTypeSelector(props: PermissionTypeSelectorProps) {
|
||||
const { showDelete = false, selfPermission, type, assetType, style, onChange } = props
|
||||
return (
|
||||
<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()
|
||||
}}
|
||||
>
|
||||
<div className="group relative flex w-permission-type-selector flex-col p-permission-type-selector">
|
||||
{PERMISSION_TYPE_DATA.filter(
|
||||
data =>
|
||||
(showDelete ? true : data.type !== permissions.Permission.delete) &&
|
||||
(selfPermission === permissions.PermissionAction.own
|
||||
? true
|
||||
: data.type !== permissions.Permission.owner)
|
||||
).map(data => (
|
||||
<button
|
||||
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' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
onChange(data.type)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-permission-type rounded-full py-permission-type-y ${
|
||||
permissions.PERMISSION_CLASS_NAME[data.type]
|
||||
}`}
|
||||
>
|
||||
{data.type}
|
||||
</div>
|
||||
{/* This is a symbol that should never need to be localized, since it is effectively
|
||||
* an icon. */}
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
<span className="text font-normal">=</span>
|
||||
{data.previous != null && (
|
||||
<>
|
||||
<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(
|
||||
data =>
|
||||
(showDelete ? true : data.type !== permissions.Permission.delete) &&
|
||||
(selfPermission === permissions.PermissionAction.own
|
||||
? true
|
||||
: data.type !== permissions.Permission.owner)
|
||||
).map(data => (
|
||||
<UnstyledButton
|
||||
key={data.type}
|
||||
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'
|
||||
: ''
|
||||
}`}
|
||||
onPress={() => {
|
||||
onChange(data.type)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-permission-type rounded-full py-permission-type-y text-center ${
|
||||
permissions.PERMISSION_CLASS_NAME[data.previous]
|
||||
className={`h-full w-permission-type rounded-full py-permission-type-y ${
|
||||
permissions.PERMISSION_CLASS_NAME[data.type]
|
||||
}`}
|
||||
>
|
||||
{data.previous}
|
||||
{data.type}
|
||||
</div>
|
||||
{/* This is a symbol that should never need to be localized, since it is effectively
|
||||
* an icon. */}
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
<span className="text font-normal">+</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text">{data.description(assetType)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<aria.Text className="text font-normal">=</aria.Text>
|
||||
{data.previous != null && (
|
||||
<>
|
||||
<div
|
||||
className={`h-full w-permission-type rounded-full py-permission-type-y text-center ${
|
||||
permissions.PERMISSION_CLASS_NAME[data.previous]
|
||||
}`}
|
||||
>
|
||||
{data.previous}
|
||||
</div>
|
||||
{/* This is a symbol that should never need to be localized, since it is effectively
|
||||
* an icon. */}
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
<aria.Text className="text font-normal">+</aria.Text>
|
||||
</>
|
||||
)}
|
||||
<aria.Label className="text">{data.description(assetType)}</aria.Label>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -8,7 +8,9 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
@ -50,6 +52,7 @@ export default function UserPermission(props: UserPermissionProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [userPermission, setUserPermission] = React.useState(initialUserPermission)
|
||||
const isDisabled = isOnlyOwner && userPermission.user.userId === self.user.userId
|
||||
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -73,22 +76,26 @@ export default function UserPermission(props: UserPermissionProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-user-permission">
|
||||
<PermissionSelector
|
||||
showDelete
|
||||
disabled={isOnlyOwner && userPermission.user.userId === self.user.userId}
|
||||
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
|
||||
selfPermission={self.permission}
|
||||
action={userPermission.permission}
|
||||
assetType={asset.type}
|
||||
onChange={async permissions => {
|
||||
await doSetUserPermission(object.merge(userPermission, { permission: permissions }))
|
||||
}}
|
||||
doDelete={() => {
|
||||
doDelete(userPermission.user)
|
||||
}}
|
||||
/>
|
||||
<span className="text">{userPermission.user.name}</span>
|
||||
</div>
|
||||
<FocusArea active={!isDisabled} direction="horizontal">
|
||||
{innerProps => (
|
||||
<div className="flex items-center gap-user-permission" {...innerProps}>
|
||||
<PermissionSelector
|
||||
showDelete
|
||||
isDisabled={isDisabled}
|
||||
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
|
||||
selfPermission={self.permission}
|
||||
action={userPermission.permission}
|
||||
assetType={asset.type}
|
||||
onChange={async permissions => {
|
||||
await doSetUserPermission(object.merge(userPermission, { permission: permissions }))
|
||||
}}
|
||||
doDelete={() => {
|
||||
doDelete(userPermission.user)
|
||||
}}
|
||||
/>
|
||||
<aria.Text className="text">{userPermission.user.name}</aria.Text>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -0,0 +1,70 @@
|
||||
/** @file A styled button. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
// ==============
|
||||
// === Button ===
|
||||
// ==============
|
||||
|
||||
/** Props for a {@link Button}. */
|
||||
export interface ButtonProps {
|
||||
readonly autoFocus?: boolean
|
||||
/** When `true`, the button is not faded out even when not hovered. */
|
||||
readonly active?: boolean
|
||||
/** When `true`, the button is clickable, but displayed as not clickable.
|
||||
* This is mostly useful when letting a button still be keyboard focusable. */
|
||||
readonly softDisabled?: boolean
|
||||
/** When `true`, the button is not clickable. */
|
||||
readonly isDisabled?: boolean
|
||||
readonly image: string
|
||||
readonly alt?: string
|
||||
/** A title that is only shown when `disabled` is `true`. */
|
||||
readonly error?: string | null
|
||||
readonly className?: string
|
||||
readonly onPress: (event: aria.PressEvent) => void
|
||||
}
|
||||
|
||||
/** A styled button. */
|
||||
function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
|
||||
const {
|
||||
active = false,
|
||||
softDisabled = false,
|
||||
image,
|
||||
error,
|
||||
alt,
|
||||
className,
|
||||
...buttonProps
|
||||
} = props
|
||||
const { isDisabled = false } = buttonProps
|
||||
const focusChildProps = focusHooks.useFocusChild()
|
||||
|
||||
return (
|
||||
<FocusRing placement="after">
|
||||
<aria.Button
|
||||
{...aria.mergeProps<aria.ButtonProps>()(buttonProps, focusChildProps, {
|
||||
ref,
|
||||
className:
|
||||
'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={`group flex selectable ${isDisabled || softDisabled ? 'disabled' : ''} ${active ? 'active' : ''}`}
|
||||
>
|
||||
<SvgMask
|
||||
src={image}
|
||||
{...(!active && isDisabled && error != null ? { title: error } : {})}
|
||||
{...(alt != null ? { alt } : {})}
|
||||
className={className}
|
||||
/>
|
||||
</div>
|
||||
</aria.Button>
|
||||
</FocusRing>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(Button)
|
@ -0,0 +1,34 @@
|
||||
/** @file A styled horizontal button row. Does not have padding; does not have a background. */
|
||||
import * as React from 'react'
|
||||
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
// =================
|
||||
// === ButtonRow ===
|
||||
// =================
|
||||
|
||||
/** The flex `align-self` of a {@link ButtonRow}. */
|
||||
export type ButtonRowPosition = 'center' | 'end' | 'start'
|
||||
|
||||
/** Props for a {@link ButtonRow}. */
|
||||
export interface ButtonRowProps extends Readonly<React.PropsWithChildren> {
|
||||
/** The flex `align-self` of this element. Defaults to `start`. */
|
||||
readonly position?: ButtonRowPosition
|
||||
}
|
||||
|
||||
/** A styled horizontal button row. Does not have padding; does not have a background. */
|
||||
export default function ButtonRow(props: ButtonRowProps) {
|
||||
const { children, position = 'start' } = props
|
||||
const positionClass =
|
||||
position === 'start' ? 'self-start' : position === 'center' ? 'self-center' : 'self-end'
|
||||
|
||||
return (
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<div className={`relative flex gap-buttons self-start ${positionClass}`} {...innerProps}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
/** @file A styled checkbox. */
|
||||
import * as React from 'react'
|
||||
|
||||
import CheckMarkIcon from 'enso-assets/check_mark.svg'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
// ================
|
||||
// === Checkbox ===
|
||||
// ================
|
||||
|
||||
/** Props for a {@link Checkbox}. */
|
||||
export interface CheckboxProps extends Omit<Readonly<aria.CheckboxProps>, 'className'> {}
|
||||
|
||||
/** A styled checkbox. */
|
||||
export default function Checkbox(props: CheckboxProps) {
|
||||
return (
|
||||
<FocusRing>
|
||||
<aria.Checkbox
|
||||
className="group flex size-3 cursor-pointer overflow-clip rounded-sm text-cloud outline outline-1 outline-primary checkbox"
|
||||
{...props}
|
||||
>
|
||||
<SvgMask
|
||||
invert
|
||||
src={CheckMarkIcon}
|
||||
className="-m-0.5 size-icon transition-all duration-75 transparent group-selected:opacity-100"
|
||||
/>
|
||||
</aria.Checkbox>
|
||||
</FocusRing>
|
||||
)
|
||||
}
|
@ -0,0 +1,123 @@
|
||||
/** @file An area that contains focusable children. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import AreaFocusProvider from '#/providers/AreaFocusProvider'
|
||||
import FocusClassesProvider, * as focusClassProvider from '#/providers/FocusClassProvider'
|
||||
import type * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
|
||||
import FocusDirectionProvider from '#/providers/FocusDirectionProvider'
|
||||
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as withFocusScope from '#/components/styled/withFocusScope'
|
||||
|
||||
// =================
|
||||
// === FocusArea ===
|
||||
// =================
|
||||
|
||||
/** Props returned by {@link aria.useFocusWithin}. */
|
||||
export interface FocusWithinProps {
|
||||
readonly ref: React.RefCallback<HTMLElement | SVGElement | null>
|
||||
readonly onFocus: NonNullable<aria.DOMAttributes<Element>['onFocus']>
|
||||
readonly onBlur: NonNullable<aria.DOMAttributes<Element>['onBlur']>
|
||||
}
|
||||
|
||||
/** Props for a {@link FocusArea} */
|
||||
export interface FocusAreaProps {
|
||||
/** Should ONLY be passed in exceptional cases. */
|
||||
readonly focusChildClass?: string
|
||||
/** Should ONLY be passed in exceptional cases. */
|
||||
readonly focusDefaultClass?: string
|
||||
readonly active?: boolean
|
||||
readonly direction: focusDirectionProvider.FocusDirection
|
||||
readonly children: (props: FocusWithinProps) => JSX.Element
|
||||
}
|
||||
|
||||
/** An area that can be focused within. */
|
||||
function FocusArea(props: FocusAreaProps) {
|
||||
const { active = true, direction, children } = props
|
||||
const { focusChildClass = 'focus-child', focusDefaultClass = 'focus-default' } = props
|
||||
const { focusChildClass: outerFocusChildClass } = focusClassProvider.useFocusClasses()
|
||||
const [areaFocus, setAreaFocus] = React.useState(false)
|
||||
const { focusWithinProps } = aria.useFocusWithin({ onFocusWithinChange: setAreaFocus })
|
||||
const focusManager = aria.useFocusManager()
|
||||
const navigator2D = navigator2DProvider.useNavigator2D()
|
||||
const rootRef = React.useRef<HTMLElement | SVGElement | null>(null)
|
||||
const cleanupRef = React.useRef(() => {})
|
||||
const focusChildClassRef = React.useRef(focusChildClass)
|
||||
focusChildClassRef.current = focusChildClass
|
||||
const focusDefaultClassRef = React.useRef(focusDefaultClass)
|
||||
focusDefaultClassRef.current = focusDefaultClass
|
||||
|
||||
let isRealRun = !detect.IS_DEV_MODE
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (isRealRun) {
|
||||
cleanupRef.current()
|
||||
}
|
||||
// This is INTENTIONAL. It may not be causing problems now, but is a defensive measure
|
||||
// to make the implementation of this function consistent with the implementation of
|
||||
// `FocusRoot`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
isRealRun = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cachedChildren = React.useMemo(
|
||||
() =>
|
||||
// This is REQUIRED, otherwise `useFocusWithin` does not work with components from
|
||||
// `react-aria-components`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
children({
|
||||
ref: element => {
|
||||
rootRef.current = element
|
||||
cleanupRef.current()
|
||||
if (active && element != null && focusManager != null) {
|
||||
const focusFirst = focusManager.focusFirst.bind(null, {
|
||||
accept: other => other.classList.contains(focusChildClassRef.current),
|
||||
})
|
||||
const focusLast = focusManager.focusLast.bind(null, {
|
||||
accept: other => other.classList.contains(focusChildClassRef.current),
|
||||
})
|
||||
const focusCurrent = () =>
|
||||
focusManager.focusFirst({
|
||||
accept: other => other.classList.contains(focusDefaultClassRef.current),
|
||||
}) ?? focusFirst()
|
||||
cleanupRef.current = navigator2D.register(element, {
|
||||
focusPrimaryChild: focusCurrent,
|
||||
focusWhenPressed:
|
||||
direction === 'horizontal'
|
||||
? { right: focusFirst, left: focusLast }
|
||||
: { down: focusFirst, up: focusLast },
|
||||
})
|
||||
} else {
|
||||
cleanupRef.current = () => {}
|
||||
}
|
||||
if (element != null && detect.IS_DEV_MODE) {
|
||||
if (active) {
|
||||
element.dataset.focusArea = ''
|
||||
} else {
|
||||
delete element.dataset.focusArea
|
||||
}
|
||||
}
|
||||
},
|
||||
...focusWithinProps,
|
||||
} as FocusWithinProps),
|
||||
[active, direction, children, focusManager, focusWithinProps, navigator2D]
|
||||
)
|
||||
|
||||
const result = (
|
||||
<FocusDirectionProvider direction={direction}>
|
||||
<AreaFocusProvider areaFocus={areaFocus}>{cachedChildren}</AreaFocusProvider>
|
||||
</FocusDirectionProvider>
|
||||
)
|
||||
return focusChildClass === outerFocusChildClass ? (
|
||||
result
|
||||
) : (
|
||||
<FocusClassesProvider focusChildClass={focusChildClass}>{result}</FocusClassesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/** An area that can be focused within. */
|
||||
export default withFocusScope.withFocusScope(FocusArea)
|
@ -0,0 +1,39 @@
|
||||
/** @file A styled focus ring. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
// =================
|
||||
// === FocusRing ===
|
||||
// =================
|
||||
|
||||
/** Which pseudo-element to place the focus ring on (if any). */
|
||||
export type FocusRingPlacement = 'after' | 'before' | 'outset'
|
||||
|
||||
/** Props for a {@link FocusRing}. */
|
||||
export interface FocusRingProps extends Readonly<Pick<aria.FocusRingProps, 'children'>> {
|
||||
/** Whether to show the focus ring on `:focus-within` instead of `:focus`. */
|
||||
readonly within?: boolean
|
||||
/** Which pseudo-element to place the focus ring on (if any).
|
||||
* Defaults to placement on the actual element. */
|
||||
readonly placement?: FocusRingPlacement
|
||||
}
|
||||
|
||||
/** A styled focus ring. */
|
||||
export default function FocusRing(props: FocusRingProps) {
|
||||
const { within = false, placement, children } = props
|
||||
const focusClass =
|
||||
placement === 'outset'
|
||||
? 'focus-ring-outset'
|
||||
: placement === 'before'
|
||||
? 'before:focus-ring'
|
||||
: placement === 'after'
|
||||
? 'after:focus-ring'
|
||||
: 'focus-ring'
|
||||
|
||||
return (
|
||||
<aria.FocusRing within={within} focusRingClass={focusClass}>
|
||||
{children}
|
||||
</aria.FocusRing>
|
||||
)
|
||||
}
|
@ -0,0 +1,80 @@
|
||||
/** @file An element that prevents navigation outside of itself. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as withFocusScope from '#/components/styled/withFocusScope'
|
||||
|
||||
// =================
|
||||
// === FocusRoot ===
|
||||
// =================
|
||||
|
||||
/** Props passed to the inner handler of a {@link FocusRoot}. */
|
||||
export interface FocusRootInnerProps {
|
||||
readonly ref: React.RefCallback<HTMLElement | SVGElement | null>
|
||||
readonly onKeyDown?: React.KeyboardEventHandler<HTMLElement>
|
||||
}
|
||||
|
||||
/** Props for a {@link FocusRoot} */
|
||||
export interface FocusRootProps {
|
||||
readonly active?: boolean
|
||||
readonly children: (props: FocusRootInnerProps) => JSX.Element
|
||||
}
|
||||
|
||||
/** An element that prevents navigation outside of itself. */
|
||||
function FocusRoot(props: FocusRootProps) {
|
||||
const { active = true, children } = props
|
||||
const navigator2D = navigator2DProvider.useNavigator2D()
|
||||
const cleanupRef = React.useRef(() => {})
|
||||
|
||||
let isRealRun = !detect.IS_DEV_MODE
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (isRealRun) {
|
||||
cleanupRef.current()
|
||||
}
|
||||
// This is INTENTIONAL. The first time this hook runs, when in Strict Mode, is *after* the ref
|
||||
// has already been set. This makes the focus root immediately unset itself,
|
||||
// which is incorrect behavior.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
isRealRun = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cachedChildren = React.useMemo(
|
||||
() =>
|
||||
children({
|
||||
ref: element => {
|
||||
cleanupRef.current()
|
||||
if (active && element != null) {
|
||||
cleanupRef.current = navigator2D.pushFocusRoot(element)
|
||||
} else {
|
||||
cleanupRef.current = () => {}
|
||||
}
|
||||
if (element != null && detect.IS_DEV_MODE) {
|
||||
if (active) {
|
||||
element.dataset.focusRoot = ''
|
||||
} else {
|
||||
delete element.dataset.focusRoot
|
||||
}
|
||||
}
|
||||
},
|
||||
...(active ? { onKeyDown: navigator2D.onKeyDown.bind(navigator2D) } : {}),
|
||||
}),
|
||||
[active, children, navigator2D]
|
||||
)
|
||||
|
||||
return !active ? (
|
||||
cachedChildren
|
||||
) : (
|
||||
<aria.FocusScope contain restoreFocus autoFocus>
|
||||
{cachedChildren}
|
||||
</aria.FocusScope>
|
||||
)
|
||||
}
|
||||
|
||||
/** An area that can be focused within. */
|
||||
export default withFocusScope.withFocusScope(FocusRoot)
|
@ -0,0 +1,26 @@
|
||||
/** @file A styled horizontal menu bar. */
|
||||
import * as React from 'react'
|
||||
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
// =========================
|
||||
// === HorizontalMenuBar ===
|
||||
// =========================
|
||||
|
||||
/** Props for a {@link HorizontalMenuBar}. */
|
||||
export interface HorizontalMenuBarProps extends Readonly<React.PropsWithChildren> {}
|
||||
|
||||
/** A styled horizontal menu bar. */
|
||||
export default function HorizontalMenuBar(props: HorizontalMenuBarProps) {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<div className="flex h-row gap-drive-bar" {...innerProps}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
/** @file An input that handles focus movement. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
// =============
|
||||
// === Input ===
|
||||
// =============
|
||||
|
||||
/** Props for a {@link Input}. */
|
||||
export interface InputProps extends Readonly<aria.InputProps> {}
|
||||
|
||||
/** An input that handles focus movement. */
|
||||
function Input(props: InputProps, ref: React.ForwardedRef<HTMLInputElement>) {
|
||||
const focusDirection = focusDirectionProvider.useFocusDirection()
|
||||
const handleFocusMove = focusHooks.useHandleFocusMove(focusDirection)
|
||||
|
||||
return (
|
||||
<aria.Input
|
||||
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(props, {
|
||||
ref,
|
||||
className: 'focus-child',
|
||||
onKeyDown: handleFocusMove,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(Input)
|
@ -0,0 +1,173 @@
|
||||
/** @file A copy of `RadioGroup` from `react-aria-components`, with the sole difference being that
|
||||
* `onKeyDown` is omitted from `useRadioGroup`. */
|
||||
// NOTE: Some of `react-aria-components/utils.ts` has also been inlined, in order to avoid needing
|
||||
// to export them, and by extension polluting auto-imports.
|
||||
/*
|
||||
* Copyright 2022 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactStately from 'react-stately'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
/** Options for {@link useRenderProps}. */
|
||||
interface RenderPropsHookOptions<T> extends aria.DOMProps, aria.AriaLabelingProps {
|
||||
/** The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. A function may be provided to compute the class based on component state. */
|
||||
readonly className?: string | ((values: T) => string)
|
||||
/** The inline [style](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style) for the element. A function may be provided to compute the style based on component state. */
|
||||
readonly style?: React.CSSProperties | ((values: T) => React.CSSProperties)
|
||||
/** The children of the component. A function may be provided to alter the children based on component state. */
|
||||
readonly children?: React.ReactNode | ((values: T) => React.ReactNode)
|
||||
readonly values: T
|
||||
readonly defaultChildren?: React.ReactNode
|
||||
readonly defaultClassName?: string
|
||||
}
|
||||
|
||||
/** Run each render prop if if is a function, otherwise return the value itself. */
|
||||
function useRenderProps<T>(props: RenderPropsHookOptions<T>) {
|
||||
const { className, style, children, defaultClassName, defaultChildren, values } = props
|
||||
|
||||
return React.useMemo(() => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
let computedClassName: string | undefined
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
let computedStyle: React.CSSProperties | undefined
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
let computedChildren: React.ReactNode | undefined
|
||||
|
||||
if (typeof className === 'function') {
|
||||
computedClassName = className(values)
|
||||
} else {
|
||||
computedClassName = className
|
||||
}
|
||||
|
||||
if (typeof style === 'function') {
|
||||
computedStyle = style(values)
|
||||
} else {
|
||||
computedStyle = style
|
||||
}
|
||||
|
||||
if (typeof children === 'function') {
|
||||
computedChildren = children(values)
|
||||
} else if (children == null) {
|
||||
computedChildren = defaultChildren
|
||||
} else {
|
||||
computedChildren = children
|
||||
}
|
||||
|
||||
return {
|
||||
className: computedClassName ?? defaultClassName,
|
||||
style: computedStyle,
|
||||
children: computedChildren,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'data-rac': '',
|
||||
}
|
||||
}, [className, style, children, defaultClassName, defaultChildren, values])
|
||||
}
|
||||
|
||||
/** Create a slot. */
|
||||
function useSlot(): [React.RefCallback<Element>, boolean] {
|
||||
// Assume we do have the slot in the initial render.
|
||||
const [hasSlot, setHasSlot] = React.useState(true)
|
||||
const hasRun = React.useRef(false)
|
||||
|
||||
// A callback ref which will run when the slotted element mounts.
|
||||
// This should happen before the useLayoutEffect below.
|
||||
const ref = React.useCallback((el: unknown) => {
|
||||
hasRun.current = true
|
||||
setHasSlot(Boolean(el))
|
||||
}, [])
|
||||
|
||||
// If the callback hasn't been called, then reset to false.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!hasRun.current) {
|
||||
setHasSlot(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return [ref, hasSlot]
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const UNDEFINED = undefined
|
||||
|
||||
/** A radio group allows a user to select a single item from a list of mutually exclusive options. */
|
||||
function RadioGroup(props: aria.RadioGroupProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
;[props, ref] = aria.useContextProps(props, ref, aria.RadioGroupContext)
|
||||
const state = reactStately.useRadioGroupState({
|
||||
...props,
|
||||
validationBehavior: props.validationBehavior ?? 'native',
|
||||
})
|
||||
|
||||
const [labelRef, label] = useSlot()
|
||||
const { radioGroupProps, labelProps, descriptionProps, errorMessageProps, ...validation } =
|
||||
aria.useRadioGroup(
|
||||
{
|
||||
...props,
|
||||
label,
|
||||
validationBehavior: props.validationBehavior ?? 'native',
|
||||
},
|
||||
state
|
||||
)
|
||||
// This single line is the reason this file exists!
|
||||
delete radioGroupProps.onKeyDown
|
||||
|
||||
const renderProps = useRenderProps({
|
||||
...props,
|
||||
values: {
|
||||
orientation: props.orientation || 'vertical',
|
||||
isDisabled: state.isDisabled,
|
||||
isReadOnly: state.isReadOnly,
|
||||
isRequired: state.isRequired,
|
||||
isInvalid: state.isInvalid,
|
||||
state,
|
||||
},
|
||||
defaultClassName: 'react-aria-RadioGroup',
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
{...radioGroupProps}
|
||||
{...renderProps}
|
||||
ref={ref}
|
||||
slot={(props.slot ?? '') || UNDEFINED}
|
||||
data-orientation={props.orientation || 'vertical'}
|
||||
data-invalid={state.isInvalid || UNDEFINED}
|
||||
data-disabled={state.isDisabled || UNDEFINED}
|
||||
data-readonly={state.isReadOnly || UNDEFINED}
|
||||
data-required={state.isRequired || UNDEFINED}
|
||||
>
|
||||
<aria.Provider
|
||||
values={[
|
||||
[aria.RadioGroupStateContext, state],
|
||||
[aria.LabelContext, { ...labelProps, ref: labelRef, elementType: 'span' }],
|
||||
[
|
||||
aria.TextContext,
|
||||
{
|
||||
slots: {
|
||||
description: descriptionProps,
|
||||
errorMessage: errorMessageProps,
|
||||
},
|
||||
},
|
||||
],
|
||||
[aria.FieldErrorContext, validation],
|
||||
]}
|
||||
>
|
||||
{renderProps.children}
|
||||
</aria.Provider>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A radio group allows a user to select a single item from a list of mutually exclusive options. */
|
||||
export default React.forwardRef(RadioGroup)
|
@ -0,0 +1,24 @@
|
||||
/** @file A horizontal line dividing two sections in a menu. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
// =================
|
||||
// === Separator ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link Separator}. */
|
||||
export interface SeparatorProps {
|
||||
readonly hidden?: boolean
|
||||
}
|
||||
|
||||
/** A horizontal line dividing two sections in a menu. */
|
||||
export default function Separator(props: SeparatorProps) {
|
||||
const { hidden = false } = props
|
||||
|
||||
return (
|
||||
!hidden && (
|
||||
<aria.Separator className="mx-context-menu-entry-px my-separator-y border-t-[0.5px] border-black/[0.16]" />
|
||||
)
|
||||
)
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
/** @file A styled button representing a tab on a sidebar. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
// ========================
|
||||
// === SidebarTabButton ===
|
||||
// ========================
|
||||
|
||||
/** Props for a {@link SidebarTabButton}. */
|
||||
export interface SidebarTabButtonProps {
|
||||
readonly id: string
|
||||
readonly autoFocus?: boolean
|
||||
/** When `true`, the button is not faded out even when not hovered. */
|
||||
readonly active?: boolean
|
||||
readonly icon: string
|
||||
readonly label: string
|
||||
readonly onPress: (event: aria.PressEvent) => void
|
||||
}
|
||||
|
||||
/** A styled button representing a tab on a sidebar. */
|
||||
export default function SidebarTabButton(props: SidebarTabButtonProps) {
|
||||
const { autoFocus = false, active = false, icon, label, onPress } = props
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
autoFocus={autoFocus}
|
||||
onPress={onPress}
|
||||
className={`rounded-full ${active ? 'focus-default' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`button icon-with-text h-row px-button-x transition-colors selectable hover:bg-selected-frame ${active ? 'disabled bg-selected-frame active' : ''}`}
|
||||
>
|
||||
<SvgMask src={icon} />
|
||||
<aria.Text className="text">{label}</aria.Text>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
)
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
/** @file A styled input specific to settings pages. */
|
||||
import * as React from 'react'
|
||||
|
||||
import EyeCrossedIcon from 'enso-assets/eye_crossed.svg'
|
||||
import EyeIcon from 'enso-assets/eye.svg'
|
||||
|
||||
import * as focusHooks from '#/hooks/focusHooks'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import FocusRing from '#/components/styled/FocusRing'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
// =====================
|
||||
// === SettingsInput ===
|
||||
// =====================
|
||||
|
||||
/** Props for an {@link SettingsInput}. */
|
||||
export interface SettingsInputProps {
|
||||
readonly type?: string
|
||||
readonly placeholder?: string
|
||||
readonly autoComplete?: React.HTMLInputAutoCompleteAttribute
|
||||
readonly onChange?: React.ChangeEventHandler<HTMLInputElement>
|
||||
readonly onSubmit?: (value: string) => void
|
||||
}
|
||||
|
||||
/** A styled input specific to settings pages. */
|
||||
function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLInputElement>) {
|
||||
const { type, placeholder, autoComplete, onChange, onSubmit } = props
|
||||
const focusChildProps = focusHooks.useFocusChild()
|
||||
// This is SAFE. The value of this context is never a `SlottedContext`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const inputProps = (React.useContext(aria.InputContext) ?? null) as aria.InputProps | null
|
||||
const [isShowingPassword, setIsShowingPassword] = React.useState(false)
|
||||
const cancelled = React.useRef(false)
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (event.key) {
|
||||
case 'Escape': {
|
||||
cancelled.current = true
|
||||
event.stopPropagation()
|
||||
event.currentTarget.value = String(inputProps?.defaultValue ?? '')
|
||||
event.currentTarget.blur()
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
cancelled.current = false
|
||||
event.stopPropagation()
|
||||
event.currentTarget.blur()
|
||||
break
|
||||
}
|
||||
case 'Tab': {
|
||||
cancelled.current = false
|
||||
event.currentTarget.blur()
|
||||
break
|
||||
}
|
||||
default: {
|
||||
cancelled.current = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text my-auto grow font-bold">
|
||||
<FocusRing within placement="after">
|
||||
<aria.Group className="relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-full">
|
||||
<aria.Input
|
||||
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(
|
||||
{
|
||||
ref,
|
||||
className:
|
||||
'settings-value w-full rounded-full bg-transparent font-bold placeholder-black/30 transition-colors invalid:border invalid:border-red-700 hover:bg-selected-frame focus:bg-selected-frame',
|
||||
...(type == null ? {} : { type: isShowingPassword ? 'text' : type }),
|
||||
size: 1,
|
||||
autoComplete,
|
||||
placeholder,
|
||||
onKeyDown,
|
||||
onChange,
|
||||
onBlur: event => {
|
||||
if (!cancelled.current) {
|
||||
onSubmit?.(event.currentTarget.value)
|
||||
}
|
||||
},
|
||||
},
|
||||
focusChildProps
|
||||
)}
|
||||
/>
|
||||
{type === 'password' && (
|
||||
<SvgMask
|
||||
src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
|
||||
className="absolute right-2 top-1 cursor-pointer rounded-full"
|
||||
onClick={() => {
|
||||
setIsShowingPassword(show => !show)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</aria.Group>
|
||||
</FocusRing>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef(SettingsInput)
|
@ -0,0 +1,16 @@
|
||||
/** @file Styled content of a settings tab. */
|
||||
import * as React from 'react'
|
||||
|
||||
// ==========================
|
||||
// === SettingsTabContent ===
|
||||
// ==========================
|
||||
|
||||
/** Props for a {@link SettingsPage}. */
|
||||
export interface SettingsPageProps extends Readonly<React.PropsWithChildren> {}
|
||||
|
||||
/** Styled content of a settings tab. */
|
||||
export default function SettingsPage(props: SettingsPageProps) {
|
||||
const { children } = props
|
||||
|
||||
return <div className="flex flex-col gap-settings-subsection">{children}</div>
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
/** @file A styled settings section. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
// =======================
|
||||
// === SettingsSection ===
|
||||
// =======================
|
||||
|
||||
/** Props for a {@link SettingsSection}. */
|
||||
export interface SettingsSectionProps extends Readonly<React.PropsWithChildren> {
|
||||
readonly title: React.ReactNode
|
||||
/** If `true`, the component is not wrapped in an {@link FocusArea}. */
|
||||
readonly noFocusArea?: boolean
|
||||
readonly className?: string
|
||||
}
|
||||
|
||||
/** A styled settings section. */
|
||||
export default function SettingsSection(props: SettingsSectionProps) {
|
||||
const { title, noFocusArea = false, className, children } = props
|
||||
const heading = (
|
||||
<aria.Heading level={2} className="h-[2.375rem] py-0.5 text-xl font-bold">
|
||||
{title}
|
||||
</aria.Heading>
|
||||
)
|
||||
|
||||
return noFocusArea ? (
|
||||
<div className={`flex flex-col gap-settings-section-header ${className}`}>
|
||||
{heading}
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<FocusArea direction="vertical">
|
||||
{innerProps => (
|
||||
<div className={`flex flex-col gap-settings-section-header ${className}`} {...innerProps}>
|
||||
{heading}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
/** @file A higher order component wrapping the inner component with a {@link aria.FocusScope}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
// ======================
|
||||
// === withFocusScope ===
|
||||
// ======================
|
||||
|
||||
/** Wrap a component in a {@link aria.FocusScope}. This allows {@link aria.useFocusManager} to be
|
||||
* used in the component. */
|
||||
// This is not a React component, even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any
|
||||
export function withFocusScope<ComponentType extends (props: any) => React.ReactNode>(
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Child: ComponentType
|
||||
) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return function WithFocusScope(props: never) {
|
||||
return (
|
||||
<aria.FocusScope>
|
||||
{/* eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any */}
|
||||
<Child {...(props as any)} />
|
||||
</aria.FocusScope>
|
||||
)
|
||||
// This type assertion is REQUIRED in order to preserve generics.
|
||||
} as unknown as ComponentType
|
||||
}
|
93
app/ide-desktop/lib/dashboard/src/hooks/focusHooks.ts
Normal file
93
app/ide-desktop/lib/dashboard/src/hooks/focusHooks.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/** @file Hooks for moving focus. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as focusClassProvider from '#/providers/FocusClassProvider'
|
||||
import * as focusDirectionProvider from '#/providers/FocusDirectionProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
|
||||
// ==========================
|
||||
// === useHandleFocusMove ===
|
||||
// ==========================
|
||||
|
||||
/** The type of `react-aria` keyboard events. It must be extracted out of this type as it is not
|
||||
* exposed from the library itself. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
type AriaKeyboardEvent = Parameters<NonNullable<aria.KeyboardEvents['onKeyUp']>>[0]
|
||||
|
||||
/** Handle arrow keys for moving focus. */
|
||||
export function useHandleFocusMove(direction: 'horizontal' | 'vertical') {
|
||||
const { focusChildClass } = focusClassProvider.useFocusClasses()
|
||||
const focusManager = aria.useFocusManager()
|
||||
const keyPrevious = direction === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
|
||||
const keyNext = direction === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
|
||||
|
||||
return React.useCallback(
|
||||
(event: AriaKeyboardEvent | React.KeyboardEvent) => {
|
||||
const ariaEvent = 'continuePropagation' in event ? event : null
|
||||
const reactEvent = 'continuePropagation' in event ? null : event
|
||||
switch (event.key) {
|
||||
case keyPrevious: {
|
||||
const element = focusManager?.focusPrevious({
|
||||
accept: other => other.classList.contains(focusChildClass),
|
||||
})
|
||||
if (element != null) {
|
||||
reactEvent?.stopPropagation()
|
||||
event.preventDefault()
|
||||
} else {
|
||||
ariaEvent?.continuePropagation()
|
||||
}
|
||||
break
|
||||
}
|
||||
case keyNext: {
|
||||
const element = focusManager?.focusNext({
|
||||
accept: other => other.classList.contains(focusChildClass),
|
||||
})
|
||||
if (element != null) {
|
||||
reactEvent?.stopPropagation()
|
||||
event.preventDefault()
|
||||
} else {
|
||||
ariaEvent?.continuePropagation()
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
ariaEvent?.continuePropagation()
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[keyPrevious, keyNext, focusManager, focusChildClass]
|
||||
)
|
||||
}
|
||||
|
||||
// =========================
|
||||
// === useSoleFocusChild ===
|
||||
// =========================
|
||||
|
||||
/** Return JSX props to make a child focusable by `Navigator2D`. DOES NOT handle arrow keys,
|
||||
* because this hook assumes the child is the only focus child. */
|
||||
export function useSoleFocusChild() {
|
||||
const { focusChildClass } = focusClassProvider.useFocusClasses()
|
||||
|
||||
return {
|
||||
className: focusChildClass,
|
||||
} satisfies React.HTMLAttributes<Element>
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === useFocusChild ===
|
||||
// =====================
|
||||
|
||||
/** Return JSX props to make a child focusable by `Navigator2D`, and make the child handle arrow
|
||||
* keys to navigate to siblings. */
|
||||
export function useFocusChild() {
|
||||
const focusDirection = focusDirectionProvider.useFocusDirection()
|
||||
const handleFocusMove = useHandleFocusMove(focusDirection)
|
||||
const { focusChildClass } = focusClassProvider.useFocusClasses()
|
||||
|
||||
return {
|
||||
className: focusChildClass,
|
||||
onKeyDown: handleFocusMove,
|
||||
} satisfies React.HTMLAttributes<Element>
|
||||
}
|
43
app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts
Normal file
43
app/ide-desktop/lib/dashboard/src/hooks/scrollHooks.ts
Normal file
@ -0,0 +1,43 @@
|
||||
/** @file Execute a function on scroll. */
|
||||
import * as React from 'react'
|
||||
|
||||
// ===================
|
||||
// === useOnScroll ===
|
||||
// ===================
|
||||
|
||||
/** Execute a function on scroll. */
|
||||
export function useOnScroll(callback: () => void, dependencies: React.DependencyList = []) {
|
||||
const callbackRef = React.useRef(callback)
|
||||
callbackRef.current = callback
|
||||
const updateClipPathRef = React.useRef(() => {})
|
||||
|
||||
const onScroll = React.useMemo(() => {
|
||||
let isClipPathUpdateQueued = false
|
||||
const updateClipPath = () => {
|
||||
isClipPathUpdateQueued = false
|
||||
callbackRef.current()
|
||||
}
|
||||
updateClipPathRef.current = updateClipPath
|
||||
updateClipPath()
|
||||
return () => {
|
||||
if (!isClipPathUpdateQueued) {
|
||||
isClipPathUpdateQueued = true
|
||||
requestAnimationFrame(updateClipPath)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateClipPathRef.current()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, dependencies)
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener('resize', onScroll)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onScroll)
|
||||
}
|
||||
}, [onScroll])
|
||||
|
||||
return onScroll
|
||||
}
|
@ -4,7 +4,7 @@ import * as React from 'react'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
import type 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]
|
||||
|
@ -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
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -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)
|
||||
const rootRef = React.useRef<HTMLLabelElement | null>(null)
|
||||
const searchRef = React.useRef<HTMLInputElement | null>(null)
|
||||
areSuggestionsVisibleRef.current = areSuggestionsVisible
|
||||
|
||||
React.useEffect(() => {
|
||||
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,16 +217,18 @@ 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
|
||||
setQuery(baseQuery.current)
|
||||
setAreSuggestionsVisible(false)
|
||||
} else {
|
||||
searchRef.current?.blur()
|
||||
if (event.key === 'Escape') {
|
||||
if (querySource.current === QuerySource.tabbing) {
|
||||
querySource.current = QuerySource.external
|
||||
setQuery(baseQuery.current)
|
||||
setAreSuggestionsVisible(false)
|
||||
} else {
|
||||
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,169 +269,169 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
}, [query, /* should never change */ setQuery])
|
||||
|
||||
return (
|
||||
<label
|
||||
ref={rootRef}
|
||||
data-testid="asset-search-bar"
|
||||
tabIndex={-1}
|
||||
onFocus={() => {
|
||||
setAreSuggestionsVisible(true)
|
||||
}}
|
||||
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>
|
||||
{/* Asset labels */}
|
||||
{isCloud && labels.length !== 0 && (
|
||||
<div
|
||||
data-testid="asset-search-labels"
|
||||
className="pointer-events-auto flex gap-buttons p-search-suggestions"
|
||||
>
|
||||
{labels
|
||||
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
|
||||
.map(label => {
|
||||
const negated = query.negativeLabels.some(term =>
|
||||
array.shallowEqual(term, [label.value])
|
||||
)
|
||||
return (
|
||||
<Label
|
||||
key={label.id}
|
||||
color={label.color}
|
||||
active={
|
||||
negated ||
|
||||
query.labels.some(term => array.shallowEqual(term, [label.value]))
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<aria.Label
|
||||
data-testid="asset-search-bar"
|
||||
{...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 => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
if (querySource.current === QuerySource.tabbing) {
|
||||
querySource.current = QuerySource.external
|
||||
}
|
||||
setAreSuggestionsVisible(false)
|
||||
}
|
||||
},
|
||||
})}
|
||||
>
|
||||
<img src={FindIcon} className="relative z-1 placeholder" />
|
||||
<div className="pointer-events-none absolute left top flex w-full flex-col overflow-hidden rounded-default before:absolute before:inset before:bg-frame before:backdrop-blur-default">
|
||||
<div className="padding relative h-row" />
|
||||
{areSuggestionsVisible && (
|
||||
<div className="relative flex flex-col gap-search-suggestions">
|
||||
{/* Tags (`name:`, `modified:`, etc.) */}
|
||||
<Tags
|
||||
isCloud={isCloud}
|
||||
querySource={querySource}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
/>
|
||||
{/* Asset labels */}
|
||||
{isCloud && labels.length !== 0 && (
|
||||
<div
|
||||
data-testid="asset-search-labels"
|
||||
className="pointer-events-auto flex gap-buttons p-search-suggestions"
|
||||
>
|
||||
{labels
|
||||
.sort((a, b) => string.compareCaseInsensitive(a.value, b.value))
|
||||
.map(label => {
|
||||
const negated = query.negativeLabels.some(term =>
|
||||
array.shallowEqual(term, [label.value])
|
||||
)
|
||||
return (
|
||||
<Label
|
||||
key={label.id}
|
||||
color={label.color}
|
||||
active={
|
||||
negated ||
|
||||
query.labels.some(term => array.shallowEqual(term, [label.value]))
|
||||
}
|
||||
negated={negated}
|
||||
onPress={event => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(oldQuery => {
|
||||
const newQuery = oldQuery.withToggled(
|
||||
'labels',
|
||||
'negativeLabels',
|
||||
label.value,
|
||||
event.shiftKey
|
||||
)
|
||||
baseQuery.current = newQuery
|
||||
return newQuery
|
||||
})
|
||||
}}
|
||||
>
|
||||
{label.value}
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* Suggestions */}
|
||||
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
// This should not be a `<button>`, since `render()` may output a
|
||||
// tree containing a button.
|
||||
<aria.Button
|
||||
data-testid="asset-search-suggestion"
|
||||
key={index}
|
||||
ref={el => {
|
||||
if (index === selectedIndex) {
|
||||
el?.focus()
|
||||
}
|
||||
negated={negated}
|
||||
onClick={event => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(oldQuery => {
|
||||
const newQuery = oldQuery.withToggled(
|
||||
'labels',
|
||||
'negativeLabels',
|
||||
label.value,
|
||||
event.shiftKey
|
||||
}}
|
||||
className={`pointer-events-auto mx-search-suggestion cursor-pointer rounded-default px-search-suggestions py-search-suggestion-y text-left transition-colors last:mb-search-suggestion hover:bg-selected-frame ${
|
||||
index === selectedIndex
|
||||
? 'bg-selected-frame'
|
||||
: selectedIndices.has(index)
|
||||
? 'bg-frame'
|
||||
: ''
|
||||
}`}
|
||||
onPress={event => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(
|
||||
selectedIndices.has(index)
|
||||
? suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
|
||||
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current)
|
||||
)
|
||||
if (event.shiftKey) {
|
||||
setSelectedIndices(
|
||||
new Set(
|
||||
selectedIndices.has(index)
|
||||
? [...selectedIndices].filter(otherIndex => otherIndex !== index)
|
||||
: [...selectedIndices, index]
|
||||
)
|
||||
baseQuery.current = newQuery
|
||||
return newQuery
|
||||
})
|
||||
}}
|
||||
>
|
||||
{label.value}
|
||||
</Label>
|
||||
)
|
||||
})}
|
||||
)
|
||||
} else {
|
||||
setAreSuggestionsVisible(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{suggestion.render()}
|
||||
</aria.Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Suggestions */}
|
||||
<div className="flex max-h-search-suggestions-list flex-col overflow-y-auto">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
// This should not be a `<button>`, since `render()` may output a
|
||||
// tree containing a button.
|
||||
<div
|
||||
data-testid="asset-search-suggestion"
|
||||
key={index}
|
||||
ref={el => {
|
||||
if (index === selectedIndex) {
|
||||
el?.focus()
|
||||
}
|
||||
}}
|
||||
tabIndex={-1}
|
||||
className={`pointer-events-auto mx-search-suggestion cursor-pointer rounded-default px-search-suggestions py-search-suggestion-y text-left transition-colors last:mb-search-suggestion hover:bg-selected-frame ${
|
||||
index === selectedIndex
|
||||
? 'bg-selected-frame'
|
||||
: selectedIndices.has(index)
|
||||
? 'bg-frame'
|
||||
: ''
|
||||
}`}
|
||||
onClick={event => {
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(
|
||||
selectedIndices.has(index)
|
||||
? suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
|
||||
: suggestion.addToQuery(event.shiftKey ? query : baseQuery.current)
|
||||
)
|
||||
if (event.shiftKey) {
|
||||
setSelectedIndices(
|
||||
new Set(
|
||||
selectedIndices.has(index)
|
||||
? [...selectedIndices].filter(otherIndex => otherIndex !== index)
|
||||
: [...selectedIndices, index]
|
||||
)
|
||||
)
|
||||
} else {
|
||||
setAreSuggestionsVisible(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{suggestion.render()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<FocusRing placement="before">
|
||||
<aria.SearchField
|
||||
aria-label={getText('assetSearchFieldLabel')}
|
||||
className="relative grow before:text before:absolute before:inset-x-button-focus-ring-inset before:my-auto before:rounded-full before:transition-all"
|
||||
value={query.query}
|
||||
onKeyDown={event => {
|
||||
event.continuePropagation()
|
||||
}}
|
||||
>
|
||||
<aria.Input
|
||||
type="search"
|
||||
ref={searchRef}
|
||||
size={1}
|
||||
placeholder={
|
||||
isCloud
|
||||
? getText('remoteBackendSearchPlaceholder')
|
||||
: getText('localBackendSearchPlaceholder')
|
||||
}
|
||||
className="focus-child peer text relative z-1 w-full bg-transparent placeholder:text-center"
|
||||
onChange={event => {
|
||||
if (querySource.current !== QuerySource.internal) {
|
||||
querySource.current = QuerySource.typing
|
||||
setQuery(AssetQuery.fromString(event.target.value))
|
||||
}
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey
|
||||
) {
|
||||
// Clone the query to refresh results.
|
||||
setQuery(query.clone())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</aria.SearchField>
|
||||
</FocusRing>
|
||||
</aria.Label>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
{getText('versionX', number)} {version.isLatest && getText('latestIndicator')}
|
||||
</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>
|
||||
|
@ -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>
|
||||
|
@ -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,158 +1172,211 @@ 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(() => {
|
||||
// This is not a React component, even though it contains JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const prevIndex = mostRecentlySelectedIndexRef.current
|
||||
const item = prevIndex == null ? null : visibleItems[prevIndex]
|
||||
if (selectedKeysRef.current.size === 1 && item != null) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
if (event.key === ' ' && event.ctrlKey) {
|
||||
const keys = selectedKeysRef.current
|
||||
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
|
||||
} else {
|
||||
switch (item.item.type) {
|
||||
case backendModule.AssetType.directory: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doToggleDirectoryExpansion(item.item.id, item.key)
|
||||
break
|
||||
}
|
||||
case backendModule.AssetType.project: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.openProject,
|
||||
id: item.item.id,
|
||||
runInBackground: false,
|
||||
shouldAutomaticallySwitchPage: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
case backendModule.AssetType.dataLink: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsAssetPanelTemporarilyVisible(true)
|
||||
break
|
||||
}
|
||||
case backendModule.AssetType.secret: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const id = item.item.id
|
||||
setModal(
|
||||
<UpsertSecretModal
|
||||
id={item.item.id}
|
||||
name={item.item.title}
|
||||
doCreate={async (_name, value) => {
|
||||
try {
|
||||
await backend.updateSecret(id, { value }, item.item.title)
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
break
|
||||
}
|
||||
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: React.KeyboardEvent) => {
|
||||
const prevIndex = mostRecentlySelectedIndexRef.current
|
||||
const item = prevIndex == null ? null : visibleItems[prevIndex]
|
||||
if (selectedKeysRef.current.size === 1 && item != null) {
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
case ' ': {
|
||||
if (event.key === ' ' && event.ctrlKey) {
|
||||
const keys = selectedKeysRef.current
|
||||
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
|
||||
} else {
|
||||
switch (item.item.type) {
|
||||
case backendModule.AssetType.directory: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doToggleDirectoryExpansion(item.item.id, item.key)
|
||||
break
|
||||
}
|
||||
case backendModule.AssetType.project: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.openProject,
|
||||
id: item.item.id,
|
||||
runInBackground: false,
|
||||
shouldAutomaticallySwitchPage: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
case backendModule.AssetType.dataLink: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsAssetPanelTemporarilyVisible(true)
|
||||
break
|
||||
}
|
||||
case backendModule.AssetType.secret: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const id = item.item.id
|
||||
setModal(
|
||||
<UpsertSecretModal
|
||||
id={item.item.id}
|
||||
name={item.item.title}
|
||||
doCreate={async (_name, value) => {
|
||||
try {
|
||||
await backend.updateSecret(id, { value }, item.item.title)
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
if (item.item.type === backendModule.AssetType.directory && item.children != null) {
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
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) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
doToggleDirectoryExpansion(item.item.id, item.key, null, true)
|
||||
}
|
||||
break
|
||||
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)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
switch (event.key) {
|
||||
case ' ': {
|
||||
if (event.ctrlKey && item != null) {
|
||||
const keys = selectedKeysRef.current
|
||||
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
|
||||
}
|
||||
break
|
||||
}
|
||||
switch (event.key) {
|
||||
case ' ': {
|
||||
if (event.ctrlKey && item != null) {
|
||||
const keys = selectedKeysRef.current
|
||||
setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key)))
|
||||
}
|
||||
case 'Escape': {
|
||||
setSelectedKeys(new Set())
|
||||
setMostRecentlySelectedIndex(null)
|
||||
break
|
||||
}
|
||||
case 'Escape': {
|
||||
setSelectedKeys(new Set())
|
||||
setMostRecentlySelectedIndex(null)
|
||||
selectionStartIndexRef.current = null
|
||||
break
|
||||
}
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown': {
|
||||
if (!event.shiftKey) {
|
||||
selectionStartIndexRef.current = null
|
||||
break
|
||||
}
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown': {
|
||||
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()
|
||||
if (!event.shiftKey) {
|
||||
selectionStartIndexRef.current = null
|
||||
// On Windows, Ctrl+Shift+Arrow behaves the same as Shift+Arrow.
|
||||
if (selectionStartIndexRef.current == null) {
|
||||
selectionStartIndexRef.current = prevIndex ?? 0
|
||||
}
|
||||
const index =
|
||||
prevIndex == null
|
||||
? 0
|
||||
: event.key === 'ArrowUp'
|
||||
? Math.max(0, prevIndex - 1)
|
||||
: Math.min(visibleItems.length - 1, prevIndex + 1)
|
||||
setMostRecentlySelectedIndex(index, true)
|
||||
if (event.shiftKey) {
|
||||
// On Windows, Ctrl+Shift+Arrow behaves the same as Shift+Arrow.
|
||||
if (selectionStartIndexRef.current == null) {
|
||||
selectionStartIndexRef.current = prevIndex ?? 0
|
||||
}
|
||||
const startIndex = Math.min(index, selectionStartIndexRef.current)
|
||||
const endIndex = Math.max(index, selectionStartIndexRef.current) + 1
|
||||
const selection = visibleItems.slice(startIndex, endIndex)
|
||||
setSelectedKeys(new Set(selection.map(newItem => newItem.key)))
|
||||
} else if (event.ctrlKey) {
|
||||
selectionStartIndexRef.current = null
|
||||
} else {
|
||||
const newItem = visibleItems[index]
|
||||
if (newItem != null) {
|
||||
setSelectedKeys(new Set([newItem.key]))
|
||||
}
|
||||
selectionStartIndexRef.current = null
|
||||
const startIndex = Math.min(index, selectionStartIndexRef.current)
|
||||
const endIndex = Math.max(index, selectionStartIndexRef.current) + 1
|
||||
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 if (index !== prevIndex) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const newItem = visibleItems[index]
|
||||
if (newItem != null) {
|
||||
setSelectedKeys(new Set([newItem.key]))
|
||||
}
|
||||
break
|
||||
selectionStartIndexRef.current = null
|
||||
} else {
|
||||
// The arrow key will escape this container. In that case, do not stop propagation
|
||||
// and let `navigator2D` navigate to a different container.
|
||||
setSelectedKeys(new Set())
|
||||
selectionStartIndexRef.current = null
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const onClick = () => {
|
||||
setKeyboardSelectedIndex(null)
|
||||
}
|
||||
}, [
|
||||
visibleItems,
|
||||
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 = () => {
|
||||
if (!isClipPathUpdateQueued) {
|
||||
isClipPathUpdateQueued = true
|
||||
requestAnimationFrame(updateClipPath)
|
||||
}
|
||||
}
|
||||
updateClipPath()
|
||||
scrollContainer.addEventListener('scroll', onScroll)
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
} else {
|
||||
return
|
||||
const onScroll = scrollHooks.useOnScroll(() => {
|
||||
if (bodyRef.current != null && rootRef.current != null) {
|
||||
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
|
||||
}
|
||||
}, [/* should never change */ scrollContainerRef])
|
||||
if (
|
||||
backend.type === backendModule.BackendType.remote &&
|
||||
rootRef.current != null &&
|
||||
headerRowRef.current != null
|
||||
) {
|
||||
const hiddenColumnsCount = columnUtils.CLOUD_COLUMNS.length - enabledColumns.size
|
||||
const shrinkBy =
|
||||
COLUMNS_SELECTOR_BASE_WIDTH_PX + COLUMNS_SELECTOR_ICON_WIDTH_PX * hiddenColumnsCount
|
||||
const rightOffset = rootRef.current.clientWidth + rootRef.current.scrollLeft - shrinkBy
|
||||
headerRowRef.current.style.clipPath = `polygon(0 0, ${rightOffset}px 0, ${rightOffset}px 100%, 0 100%)`
|
||||
}
|
||||
}, [enabledColumns.size])
|
||||
|
||||
React.useEffect(
|
||||
() =>
|
||||
@ -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,49 +2454,78 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-auto container-size">
|
||||
{!hidden && hiddenContextMenu}
|
||||
{!hidden && (
|
||||
<SelectionBrush
|
||||
onDrag={onSelectionDrag}
|
||||
onDragEnd={onSelectionDragEnd}
|
||||
onDragCancel={onSelectionDragCancel}
|
||||
/>
|
||||
)}
|
||||
<div className="flex h-max min-h-full w-max min-w-full flex-col">
|
||||
{isCloud && (
|
||||
<div className="flex-0 sticky top flex h flex-col">
|
||||
<div
|
||||
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 => (
|
||||
<Button
|
||||
key={column}
|
||||
active
|
||||
image={columnUtils.COLUMN_ICONS[column]}
|
||||
alt={getText(columnUtils.COLUMN_SHOW_TEXT_ID[column])}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
const newExtraColumns = new Set(enabledColumns)
|
||||
if (enabledColumns.has(column)) {
|
||||
newExtraColumns.delete(column)
|
||||
} else {
|
||||
newExtraColumns.add(column)
|
||||
}
|
||||
setEnabledColumns(newExtraColumns)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<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
|
||||
onDrag={onSelectionDrag}
|
||||
onDragEnd={onSelectionDragEnd}
|
||||
onDragCancel={onSelectionDragCancel}
|
||||
/>
|
||||
)}
|
||||
<div className="flex h-max min-h-full w-max min-w-full flex-col">
|
||||
{isCloud && (
|
||||
<div className="flex-0 sticky top flex h flex-col">
|
||||
<div
|
||||
data-testid="extra-columns"
|
||||
className="sticky right flex self-end px-extra-columns-panel-x py-extra-columns-panel-y"
|
||||
>
|
||||
<FocusArea direction="horizontal">
|
||||
{columnsBarProps => (
|
||||
<div
|
||||
{...aria.mergeProps<JSX.IntrinsicElements['div']>()(columnsBarProps, {
|
||||
className: 'inline-flex gap-icons',
|
||||
onFocus: () => {
|
||||
setKeyboardSelectedIndex(null)
|
||||
},
|
||||
})}
|
||||
>
|
||||
{columnUtils.CLOUD_COLUMNS.filter(
|
||||
column => !enabledColumns.has(column)
|
||||
).map(column => (
|
||||
<Button
|
||||
key={column}
|
||||
active
|
||||
image={columnUtils.COLUMN_ICONS[column]}
|
||||
alt={getText(columnUtils.COLUMN_SHOW_TEXT_ID[column])}
|
||||
onPress={() => {
|
||||
const newExtraColumns = new Set(enabledColumns)
|
||||
if (enabledColumns.has(column)) {
|
||||
newExtraColumns.delete(column)
|
||||
} else {
|
||||
newExtraColumns.add(column)
|
||||
}
|
||||
setEnabledColumns(newExtraColumns)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full w-min min-w-full grow flex-col">{table}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full w-min min-w-full grow flex-col">{table}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
className="flex w-backend-switcher-option flex-col items-start bg-selected-frame px-selector-x py-selector-y text-primary selectable first:rounded-l-full last:rounded-r-full disabled:text-cloud disabled:active"
|
||||
onClick={() => {
|
||||
setBackendType(backendModule.BackendType.remote)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-icon-with-text">
|
||||
<SvgMask src={CloudIcon} />
|
||||
<span className="text">{getText('cloud')}</span>
|
||||
<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"
|
||||
onPress={() => {
|
||||
setBackendType(backendModule.BackendType.remote)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-icon-with-text">
|
||||
<SvgMask src={CloudIcon} />
|
||||
<aria.Label className="text">{getText('cloud')}</aria.Label>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
isDisabled={!isCloud}
|
||||
className="flex w-backend-switcher-option flex-col items-start bg-selected-frame px-selector-x py-selector-y text-primary selectable first:rounded-l-full last:rounded-r-full disabled:text-cloud disabled:active"
|
||||
onPress={() => {
|
||||
setBackendType(backendModule.BackendType.local)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-icon-with-text">
|
||||
<SvgMask src={NotCloudIcon} />
|
||||
<aria.Label className="text">{getText('local')}</aria.Label>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
disabled={!isCloud}
|
||||
className="flex w-backend-switcher-option flex-col items-start bg-selected-frame px-selector-x py-selector-y text-primary selectable first:rounded-l-full last:rounded-r-full disabled:text-cloud disabled:active"
|
||||
onClick={() => {
|
||||
setBackendType(backendModule.BackendType.local)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-icon-with-text">
|
||||
<SvgMask src={NotCloudIcon} />
|
||||
<span className="text">{getText('local')}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -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}
|
||||
>
|
||||
<SvgMask
|
||||
src={CATEGORY_ICONS[category]}
|
||||
className={`group-hover:text-icon-selected ${
|
||||
isCurrent ? 'text-icon-selected' : 'text-icon-not-selected'
|
||||
} ${
|
||||
// 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>
|
||||
<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={icon}
|
||||
className={
|
||||
// This explicit class is a special-case due to the unusual shape of the "Recent" icon.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
category === Category.recent ? '-ml-0.5' : ''
|
||||
}
|
||||
/>
|
||||
<aria.Text slot="description">{getText(textId)}</aria.Text>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
<div className="absolute left-full ml-2 hidden group-focus-visible:block">
|
||||
{getText('drop')}
|
||||
</div>
|
||||
</aria.DropZone>
|
||||
)
|
||||
}
|
||||
|
||||
@ -99,51 +136,77 @@ export interface CategorySwitcherProps {
|
||||
export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||
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">
|
||||
{getText('category')}
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
{CATEGORIES.map(currentCategory => (
|
||||
<CategorySwitcherItem
|
||||
key={currentCategory}
|
||||
category={currentCategory}
|
||||
isCurrent={category === currentCategory}
|
||||
onClick={() => {
|
||||
setCategory(currentCategory)
|
||||
}}
|
||||
onDragOver={event => {
|
||||
if (
|
||||
(category === Category.trash && currentCategory === Category.home) ||
|
||||
(category !== Category.trash && currentCategory === Category.trash)
|
||||
) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
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) {
|
||||
dispatchAssetEvent({
|
||||
type:
|
||||
category === Category.trash ? AssetEventType.restore : AssetEventType.delete,
|
||||
ids: new Set(payload.map(item => item.key)),
|
||||
})
|
||||
<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')}
|
||||
</aria.Header>
|
||||
<div
|
||||
aria-label={getText('categorySwitcherMenuLabel')}
|
||||
role="grid"
|
||||
className="flex flex-col items-start"
|
||||
>
|
||||
{CATEGORY_DATA.map(data => (
|
||||
<CategorySwitcherItem
|
||||
key={data.category}
|
||||
id={data.category}
|
||||
data={data}
|
||||
isCurrent={category === data.category}
|
||||
onPress={() => {
|
||||
setCategory(data.category)
|
||||
}}
|
||||
acceptedDragTypes={
|
||||
(category === Category.trash && data.category === Category.home) ||
|
||||
(category !== Category.trash && data.category === Category.trash)
|
||||
? ['application/vnd.enso.assets+json']
|
||||
: []
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
onDrop={event => {
|
||||
unsetModal()
|
||||
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(keys.flat(1)),
|
||||
})
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>,
|
||||
|
@ -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}
|
||||
|
@ -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">
|
||||
<Button
|
||||
active
|
||||
image={AddFolderIcon}
|
||||
alt={getText('newFolder')}
|
||||
onClick={() => {
|
||||
unsetModal()
|
||||
doCreateDirectory()
|
||||
}}
|
||||
/>
|
||||
{isCloud && (
|
||||
<Button
|
||||
active
|
||||
image={AddFolderIcon}
|
||||
alt={getText('newFolder')}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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} />
|
||||
|
@ -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,106 +48,123 @@ 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 (
|
||||
<div
|
||||
data-testid="labels"
|
||||
className="flex w-full flex-col items-start gap-sidebar-section-heading"
|
||||
>
|
||||
<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 => {
|
||||
const negated = currentNegativeLabels.some(term =>
|
||||
array.shallowEqual(term, [label.value])
|
||||
)
|
||||
return (
|
||||
<li key={label.id} className="group flex items-center gap-label-icons">
|
||||
<Label
|
||||
draggable
|
||||
color={label.color}
|
||||
active={
|
||||
negated || currentLabels.some(term => array.shallowEqual(term, [label.value]))
|
||||
}
|
||||
negated={negated}
|
||||
disabled={newLabelNames.has(label.value)}
|
||||
onClick={event => {
|
||||
setQuery(oldQuery =>
|
||||
oldQuery.withToggled('labels', 'negativeLabels', label.value, event.shiftKey)
|
||||
)
|
||||
}}
|
||||
onDragStart={event => {
|
||||
drag.setDragImageToBlank(event)
|
||||
const payload: drag.LabelsDragPayload = new Set([label.value])
|
||||
drag.LABELS.bind(event, payload)
|
||||
setModal(
|
||||
<DragModal
|
||||
event={event}
|
||||
doCleanup={() => {
|
||||
drag.LABELS.unbind(payload)
|
||||
}}
|
||||
>
|
||||
<Label active color={label.color} onClick={() => {}}>
|
||||
{label.value}
|
||||
</Label>
|
||||
</DragModal>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{label.value}
|
||||
</Label>
|
||||
{!newLabelNames.has(label.value) && (
|
||||
<button
|
||||
className="flex"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
<FocusArea direction="vertical">
|
||||
{innerProps => (
|
||||
<div
|
||||
data-testid="labels"
|
||||
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>
|
||||
<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 (
|
||||
<div key={label.id} className="group relative flex items-center gap-label-icons">
|
||||
<Label
|
||||
draggable
|
||||
color={label.color}
|
||||
active={
|
||||
negated || currentLabels.some(term => array.shallowEqual(term, [label.value]))
|
||||
}
|
||||
negated={negated}
|
||||
isDisabled={newLabelNames.has(label.value)}
|
||||
onPress={event => {
|
||||
setQuery(oldQuery =>
|
||||
oldQuery.withToggled(
|
||||
'labels',
|
||||
'negativeLabels',
|
||||
label.value,
|
||||
event.shiftKey
|
||||
)
|
||||
)
|
||||
}}
|
||||
onDragStart={event => {
|
||||
drag.setDragImageToBlank(event)
|
||||
const payload: drag.LabelsDragPayload = new Set([label.value])
|
||||
drag.LABELS.bind(event, payload)
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
actionText={getText('deleteLabelActionText', label.value)}
|
||||
doDelete={() => {
|
||||
doDeleteLabel(label.id, label.value)
|
||||
<DragModal
|
||||
event={event}
|
||||
doCleanup={() => {
|
||||
drag.LABELS.unbind(payload)
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Label active color={label.color} onPress={() => {}}>
|
||||
{label.value}
|
||||
</Label>
|
||||
</DragModal>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SvgMask
|
||||
src={Trash2Icon}
|
||||
alt={getText('delete')}
|
||||
className="size-icon text-delete transition-all transparent group-hover:active"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
<li>
|
||||
<Label
|
||||
active
|
||||
color={labelUtils.DEFAULT_LABEL_COLOR}
|
||||
className="bg-frame text-not-selected"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<NewLabelModal
|
||||
labels={labels}
|
||||
eventTarget={event.currentTarget}
|
||||
doCreate={doCreateLabel}
|
||||
/>
|
||||
{label.value}
|
||||
</Label>
|
||||
{!newLabelNames.has(label.value) && (
|
||||
<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)}
|
||||
doDelete={() => {
|
||||
doDeleteLabel(label.id, label.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SvgMask
|
||||
src={Trash2Icon}
|
||||
alt={getText('delete')}
|
||||
className="size-icon text-delete transition-all transparent group-has-[[data-focus-visible]]:active group-hover:active"
|
||||
/>
|
||||
</aria.Button>
|
||||
</FocusRing>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
</Label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
})}
|
||||
<Label
|
||||
color={labelUtils.DEFAULT_LABEL_COLOR}
|
||||
className="bg-selected-frame"
|
||||
onPress={event => {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
setModal(
|
||||
<NewLabelModal
|
||||
labels={labels}
|
||||
eventTarget={event.target}
|
||||
doCreate={doCreateLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* This is a non-standard-sized icon. */}
|
||||
{/* eslint-disable-next-line no-restricted-syntax */}
|
||||
<img src={PlusIcon} className="mr-[6px] size-[6px]" />
|
||||
<aria.Text className="text-header">{getText('newLabelButtonLabel')}</aria.Text>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -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 (
|
||||
<div
|
||||
className={`pointer-events-auto flex shrink-0 cursor-default items-center gap-pages rounded-full px-page-switcher-x ${
|
||||
page === Page.editor ? 'bg-frame backdrop-blur-default' : ''
|
||||
}`}
|
||||
>
|
||||
{PAGE_DATA.map(pageData => {
|
||||
const isDisabled =
|
||||
pageData.page === page || (pageData.page === Page.editor && isEditorDisabled)
|
||||
const errorId = ERRORS[pageData.page]
|
||||
return (
|
||||
<Button
|
||||
key={pageData.page}
|
||||
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={() => {
|
||||
setPage(pageData.page)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<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 => {
|
||||
return (
|
||||
<Button
|
||||
key={pageData.page}
|
||||
aria-label={getText(pageData.tooltipId)}
|
||||
alt={getText(pageData.altId)}
|
||||
image={pageData.icon}
|
||||
active={page === pageData.page}
|
||||
softDisabled={page === pageData.page}
|
||||
isDisabled={pageData.page === Page.editor && isEditorDisabled}
|
||||
error={ERRORS[pageData.page]}
|
||||
onPress={() => {
|
||||
setPage(pageData.page)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -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,23 +117,28 @@ 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}
|
||||
>
|
||||
<div className="relative flex size-full rounded-default">
|
||||
<div className="m-auto flex flex-col items-center gap-new-empty-project text-center">
|
||||
{spinnerState != null ? (
|
||||
<Spinner size={SPINNER_SIZE_PX} padding={2} state={spinnerState} />
|
||||
) : (
|
||||
<img src={ProjectIcon} />
|
||||
)}
|
||||
<p className="text-sm font-semibold">{getText('newEmptyProject')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<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">
|
||||
{spinnerState != null ? (
|
||||
<Spinner size={SPINNER_SIZE_PX} padding={2} state={spinnerState} />
|
||||
) : (
|
||||
<img src={ProjectIcon} />
|
||||
)}
|
||||
<p className="text-sm font-semibold">{getText('newEmptyProject')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aria.Button>
|
||||
</FocusRing>
|
||||
)}
|
||||
</FocusArea>
|
||||
<div className="h-sample-info" />
|
||||
</div>
|
||||
)
|
||||
@ -169,51 +177,58 @@ 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
|
||||
key={title}
|
||||
className="relative flex h-sample grow cursor-pointer flex-col text-left"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
style={{ background }}
|
||||
className={`h-sample-image w-full rounded-t-default ${
|
||||
background != null ? '' : 'bg-frame'
|
||||
}`}
|
||||
/>
|
||||
<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>
|
||||
<div className="text-ellipsis text-xs leading-snug">{description}</div>
|
||||
</div>
|
||||
{spinnerState != null && (
|
||||
<div className="absolute grid h-sample-image w-full place-items-center">
|
||||
<Spinner size={SPINNER_SIZE_PX} state={spinnerState} />
|
||||
</div>
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<FocusRing placement="after">
|
||||
<aria.Button
|
||||
key={title}
|
||||
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 }}
|
||||
className={`h-sample-image w-full rounded-t-default ${
|
||||
background != null ? '' : 'bg-frame'
|
||||
}`}
|
||||
/>
|
||||
<div className="w-full grow rounded-b-default bg-frame px-sample-description-x pb-sample-description-b pt-sample-description-t backdrop-blur">
|
||||
<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 && (
|
||||
<div className="absolute grid h-sample-image w-full place-items-center">
|
||||
<Spinner size={SPINNER_SIZE_PX} state={spinnerState} />
|
||||
</div>
|
||||
)}
|
||||
</aria.Button>
|
||||
</FocusRing>
|
||||
)}
|
||||
</button>
|
||||
</FocusArea>
|
||||
{/* Although this component is instantiated multiple times, it has a unique role and hence
|
||||
* 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} />
|
||||
))}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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,64 +122,68 @@ 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">
|
||||
<div className="flex items-center gap-activity-log-filter">
|
||||
{getText('startDate')}
|
||||
<DateInput date={startDate} onInput={setStartDate} />
|
||||
</div>
|
||||
<div className="flex items-center gap-activity-log-filter">
|
||||
{getText('endDate')}
|
||||
<DateInput date={endDate} onInput={setEndDate} />
|
||||
</div>
|
||||
<div className="flex items-center gap-activity-log-filter">
|
||||
{getText('types')}
|
||||
<Dropdown
|
||||
multiple
|
||||
items={backendModule.EVENT_TYPES}
|
||||
selectedIndices={typeIndices}
|
||||
render={props => EVENT_TYPE_NAME[props.item]}
|
||||
renderMultiple={props =>
|
||||
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})`)
|
||||
}
|
||||
onClick={(items, indices) => {
|
||||
setTypes(items)
|
||||
setTypeIndices(indices)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-activity-log-filter">
|
||||
{getText('users')}
|
||||
<Dropdown
|
||||
multiple
|
||||
items={allEmails}
|
||||
selectedIndices={emailIndices}
|
||||
render={props => props.item}
|
||||
renderMultiple={props =>
|
||||
props.items.length === 0 || props.items.length === allEmails.length
|
||||
? 'All'
|
||||
: (props.items[0] ?? '') +
|
||||
(props.items.length <= 1 ? '' : `(+${props.items.length - 1})`)
|
||||
}
|
||||
onClick={(items, indices) => {
|
||||
setEmails(items)
|
||||
setEmailIndices(indices)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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} />
|
||||
</div>
|
||||
<div className="flex items-center gap-activity-log-filter">
|
||||
{getText('endDate')}
|
||||
<DateInput date={endDate} onInput={setEndDate} />
|
||||
</div>
|
||||
<div className="flex items-center gap-activity-log-filter">
|
||||
{getText('types')}
|
||||
<Dropdown
|
||||
multiple
|
||||
items={backendModule.EVENT_TYPES}
|
||||
selectedIndices={typeIndices}
|
||||
render={props => EVENT_TYPE_NAME[props.item]}
|
||||
renderMultiple={props =>
|
||||
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})`)
|
||||
}
|
||||
onClick={(items, indices) => {
|
||||
setTypes(items)
|
||||
setTypeIndices(indices)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-activity-log-filter">
|
||||
{getText('users')}
|
||||
<Dropdown
|
||||
multiple
|
||||
items={allEmails}
|
||||
selectedIndices={emailIndices}
|
||||
render={props => props.item}
|
||||
renderMultiple={props =>
|
||||
props.items.length === 0 || props.items.length === allEmails.length
|
||||
? 'All'
|
||||
: (props.items[0] ?? '') +
|
||||
(props.items.length <= 1 ? '' : `(+${props.items.length - 1})`)
|
||||
}
|
||||
onClick={(items, indices) => {
|
||||
setEmails(items)
|
||||
setEmailIndices(indices)
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
@ -0,0 +1,121 @@
|
||||
/** @file Settings section for changing password. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import ButtonRow from '#/components/styled/ButtonRow'
|
||||
import SettingsInput from '#/components/styled/settings/SettingsInput'
|
||||
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
import * as validation from '#/utilities/validation'
|
||||
|
||||
// =====================================
|
||||
// === ChangePasswordSettingsSection ===
|
||||
// =====================================
|
||||
|
||||
/** Settings section for changing password. */
|
||||
export default function ChangePasswordSettingsSection() {
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { changePassword } = authProvider.useAuth()
|
||||
const { getText } = textProvider.useText()
|
||||
const [key, setKey] = React.useState('')
|
||||
const [currentPassword, setCurrentPassword] = React.useState('')
|
||||
const [newPassword, setNewPassword] = React.useState('')
|
||||
const [confirmNewPassword, setConfirmNewPassword] = React.useState('')
|
||||
|
||||
const canSubmitPassword =
|
||||
currentPassword !== '' &&
|
||||
newPassword !== '' &&
|
||||
confirmNewPassword !== '' &&
|
||||
newPassword === confirmNewPassword &&
|
||||
validation.PASSWORD_REGEX.test(newPassword)
|
||||
const canCancel = currentPassword !== '' || newPassword !== '' || confirmNewPassword !== ''
|
||||
|
||||
return (
|
||||
<SettingsSection title={getText('changePassword')}>
|
||||
<aria.Form
|
||||
key={key}
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
setKey(uniqueString.uniqueString())
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmNewPassword('')
|
||||
void changePassword(currentPassword, newPassword)
|
||||
}}
|
||||
>
|
||||
<aria.Input hidden autoComplete="username" value={user?.email} readOnly />
|
||||
<aria.TextField className="flex h-row gap-settings-entry" onChange={setCurrentPassword}>
|
||||
<aria.Label className="text my-auto w-change-password-settings-label">
|
||||
{getText('currentPasswordLabel')}
|
||||
</aria.Label>
|
||||
<SettingsInput
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder={getText('currentPasswordPlaceholder')}
|
||||
/>
|
||||
</aria.TextField>
|
||||
<aria.TextField
|
||||
className="flex h-row gap-settings-entry"
|
||||
onChange={setNewPassword}
|
||||
validate={value =>
|
||||
value === '' || validation.PASSWORD_REGEX.test(value)
|
||||
? ''
|
||||
: getText('passwordValidationError')
|
||||
}
|
||||
>
|
||||
<aria.Label className="text my-auto w-change-password-settings-label">
|
||||
{getText('newPasswordLabel')}
|
||||
</aria.Label>
|
||||
<SettingsInput
|
||||
type="password"
|
||||
placeholder={getText('newPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</aria.TextField>
|
||||
<aria.TextField
|
||||
className="flex h-row gap-settings-entry"
|
||||
onChange={setConfirmNewPassword}
|
||||
validate={newValue =>
|
||||
newValue === '' || newValue === newPassword ? '' : getText('passwordMismatchError')
|
||||
}
|
||||
>
|
||||
<aria.Label className="text my-auto w-change-password-settings-label">
|
||||
{getText('confirmNewPasswordLabel')}
|
||||
</aria.Label>
|
||||
<SettingsInput
|
||||
type="password"
|
||||
placeholder={getText('confirmNewPasswordPlaceholder')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</aria.TextField>
|
||||
<ButtonRow>
|
||||
<UnstyledButton
|
||||
isDisabled={!canSubmitPassword}
|
||||
className={`settings-value rounded-full bg-invite font-medium text-white selectable enabled:active`}
|
||||
onPress={eventModule.submitForm}
|
||||
>
|
||||
{getText('change')}
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
isDisabled={!canCancel}
|
||||
className="settings-value rounded-full bg-selected-frame font-medium selectable enabled:active"
|
||||
onPress={() => {
|
||||
setKey(uniqueString.uniqueString())
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmNewPassword('')
|
||||
}}
|
||||
>
|
||||
{getText('cancel')}
|
||||
</UnstyledButton>
|
||||
</ButtonRow>
|
||||
</aria.Form>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
/** @file Settings tab for deleting the current user. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import ConfirmDeleteUserModal from '#/modals/ConfirmDeleteUserModal'
|
||||
|
||||
// ========================================
|
||||
// === DeleteUserAccountSettingsSection ===
|
||||
// ========================================
|
||||
|
||||
/** Settings tab for deleting the current user. */
|
||||
export default function DeleteUserAccountSettingsSection() {
|
||||
const { signOut } = authProvider.useAuth()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={<aria.Text className="text-danger">{getText('dangerZone')}</aria.Text>}
|
||||
// This UI element does not appear anywhere else.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
className="flex flex-col items-start gap-settings-section-header rounded-2.5xl border-2 border-danger px-[1rem] pb-[0.9375rem] pt-[0.5625rem]"
|
||||
>
|
||||
<div className="flex gap-buttons">
|
||||
<UnstyledButton
|
||||
className="button bg-danger px-delete-user-account-button-x text-inversed opacity-full hover:opacity-full"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteUserModal
|
||||
doDelete={async () => {
|
||||
await backend.deleteUser()
|
||||
await signOut()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<aria.Text className="text inline-block">
|
||||
{getText('deleteUserAccountButtonLabel')}
|
||||
</aria.Text>
|
||||
</UnstyledButton>
|
||||
<aria.Text className="text my-auto">{getText('deleteUserAccountWarning')}</aria.Text>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
@ -1,193 +1,28 @@
|
||||
/** @file Settings tab for editing keyboard shortcuts. */
|
||||
/** @file Settings tab for viewing and editing keyboard shortcuts. */
|
||||
import * as React from 'react'
|
||||
|
||||
import 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>
|
||||
)
|
||||
}
|
||||
|
@ -0,0 +1,60 @@
|
||||
/** @file Button bar for managing keyboard shortcuts. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as inputBindingsModule from '#/configurations/inputBindings'
|
||||
|
||||
import * as inputBindingsManager from '#/providers/InputBindingsProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import HorizontalMenuBar from '#/components/styled/HorizontalMenuBar'
|
||||
import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
|
||||
|
||||
// =======================================
|
||||
// === KeyboardShortcutsSettingsTabBar ===
|
||||
// =======================================
|
||||
|
||||
/** Props for a {@link KeyboardShortcutsSettingsTabBar}. */
|
||||
export interface KeyboardShortcutsSettingsTabBarProps {
|
||||
readonly doRefresh: () => void
|
||||
}
|
||||
|
||||
/** Button bar for managing keyboard shortcuts. */
|
||||
export default function KeyboardShortcutsSettingsTabBar(
|
||||
props: KeyboardShortcutsSettingsTabBarProps
|
||||
) {
|
||||
const { doRefresh } = props
|
||||
const inputBindings = inputBindingsManager.useInputBindings()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
return (
|
||||
<HorizontalMenuBar>
|
||||
<UnstyledButton
|
||||
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
|
||||
onPress={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
actionText={getText('resetAllKeyboardShortcuts')}
|
||||
actionButtonLabel={getText('resetAll')}
|
||||
doDelete={() => {
|
||||
for (const k in inputBindings.metadata) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
inputBindings.reset(k as inputBindingsModule.DashboardBindingKey)
|
||||
}
|
||||
doRefresh()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<aria.Text className="text whitespace-nowrap font-semibold">
|
||||
{getText('resetAll')}
|
||||
</aria.Text>
|
||||
</UnstyledButton>
|
||||
</HorizontalMenuBar>
|
||||
)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user