Add tooltips (#9899)

- Dashboard side of #9828
- Add tooltips for elements rendered by the Dashboard

# Important Notes
- The UI for the tooltips can be changed at any time.
This commit is contained in:
somebody1234 2024-05-10 18:17:06 +10:00 committed by GitHub
parent 720d32cbe3
commit 35571f64ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 214 additions and 165 deletions

View File

@ -194,52 +194,52 @@ export function locateAssetLabels(page: test.Locator | test.Page) {
/** Find a toggle for the "Name" column (if any) on the current page. */ /** Find a toggle for the "Name" column (if any) on the current page. */
export function locateNameColumnToggle(page: test.Locator | test.Page) { export function locateNameColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText(/^(?:Show|Hide) Name$/) return page.getByAltText('Name')
} }
/** Find a toggle for the "Modified" column (if any) on the current page. */ /** Find a toggle for the "Modified" column (if any) on the current page. */
export function locateModifiedColumnToggle(page: test.Locator | test.Page) { export function locateModifiedColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText(/^(?:Show|Hide) Modified date column$/) return page.getByAltText('Modified')
} }
/** Find a toggle for the "Shared with" column (if any) on the current page. */ /** Find a toggle for the "Shared with" column (if any) on the current page. */
export function locateSharedWithColumnToggle(page: test.Locator | test.Page) { export function locateSharedWithColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText(/^(?:Show|Hide) Shared with column$/) return page.getByAltText('Shared With')
} }
/** Find a toggle for the "Labels" column (if any) on the current page. */ /** Find a toggle for the "Labels" column (if any) on the current page. */
export function locateLabelsColumnToggle(page: test.Locator | test.Page) { export function locateLabelsColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText(/^(?:Show|Hide) Labels column$/) return page.getByAltText('Labels')
} }
/** Find a toggle for the "Accessed by projects" column (if any) on the current page. */ /** Find a toggle for the "Accessed by projects" column (if any) on the current page. */
export function locateAccessedByProjectsColumnToggle(page: test.Locator | test.Page) { export function locateAccessedByProjectsColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText(/^(?:Show|Hide) Accessed by projects column$/) return page.getByAltText('Accessed By Projects')
} }
/** Find a toggle for the "Accessed data" column (if any) on the current page. */ /** Find a toggle for the "Accessed data" column (if any) on the current page. */
export function locateAccessedDataColumnToggle(page: test.Locator | test.Page) { export function locateAccessedDataColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText(/^(?:Show|Hide) Accessed data column$/) return page.getByAltText('Accessed Data')
} }
/** Find a toggle for the "Docs" column (if any) on the current page. */ /** Find a toggle for the "Docs" column (if any) on the current page. */
export function locateDocsColumnToggle(page: test.Locator | test.Page) { export function locateDocsColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText(/^(?:Show|Hide) Docs column$/) return page.getByAltText('Docs')
} }
/** Find a button for the "Recent" category (if any) on the current page. */ /** Find a button for the "Recent" category (if any) on the current page. */
export function locateRecentCategory(page: test.Locator | test.Page) { export function locateRecentCategory(page: test.Locator | test.Page) {
return page.getByLabel('Go To Recent category') return page.getByLabel('Recent').locator('visible=true')
} }
/** Find a button for the "Home" category (if any) on the current page. */ /** Find a button for the "Home" category (if any) on the current page. */
export function locateHomeCategory(page: test.Locator | test.Page) { export function locateHomeCategory(page: test.Locator | test.Page) {
return page.getByLabel('Go To Home category') return page.getByLabel('Home').locator('visible=true')
} }
/** Find a button for the "Trash" category (if any) on the current page. */ /** Find a button for the "Trash" category (if any) on the current page. */
export function locateTrashCategory(page: test.Locator | test.Page) { export function locateTrashCategory(page: test.Locator | test.Page) {
return page.getByLabel('Go To Trash category') return page.getByLabel('Trash').locator('visible=true')
} }
// === Context menu buttons === // === Context menu buttons ===
@ -363,30 +363,27 @@ export function locateUpgradeButton(page: test.Locator | test.Page) {
/** Find a "new folder" icon (if any) on the current page. */ /** Find a "new folder" icon (if any) on the current page. */
export function locateNewFolderIcon(page: test.Locator | test.Page) { export function locateNewFolderIcon(page: test.Locator | test.Page) {
return page.getByAltText('New Folder') return page.getByRole('button').filter({ has: page.getByAltText('New Folder') })
} }
/** Find a "new secret" icon (if any) on the current page. */ /** Find a "new secret" icon (if any) on the current page. */
export function locateNewSecretIcon(page: test.Locator | test.Page) { export function locateNewSecretIcon(page: test.Locator | test.Page) {
return page.getByAltText('New Secret') return page.getByRole('button').filter({ has: page.getByAltText('New Secret') })
} }
/** Find a "upload files" icon (if any) on the current page. */ /** Find an "upload files" icon (if any) on the current page. */
export function locateUploadFilesIcon(page: test.Locator | test.Page) { export function locateUploadFilesIcon(page: test.Locator | test.Page) {
return page.getByAltText('Upload Files') return page.getByRole('button').filter({ has: page.getByAltText('Import') })
} }
/** Find a "download files" icon (if any) on the current page. */ /** Find a "download files" icon (if any) on the current page. */
export function locateDownloadFilesIcon(page: test.Locator | test.Page) { export function locateDownloadFilesIcon(page: test.Locator | test.Page) {
return page.getByAltText('Download Files') return page.getByRole('button').filter({ has: page.getByAltText('Export') })
} }
/** Find an icon to open or close the asset panel (if any) on the current page. */ /** Find an icon to open or close the asset panel (if any) on the current page. */
export function locateAssetPanelIcon(page: test.Locator | test.Page) { export function locateAssetPanelIcon(page: test.Locator | test.Page) {
return page return page.getByAltText('Asset Panel').locator('visible=true')
.getByAltText('Open Asset Panel')
.or(page.getByAltText('Close Asset Panel'))
.locator('visible=true')
} }
/** Find a list of tags in the search bar (if any) on the current page. */ /** Find a list of tags in the search bar (if any) on the current page. */
@ -423,22 +420,22 @@ export function locateSortDescendingIcon(page: test.Locator | test.Page) {
/** Find a "home page" icon (if any) on the current page. */ /** Find a "home page" icon (if any) on the current page. */
export function locateHomePageIcon(page: test.Locator | test.Page) { export function locateHomePageIcon(page: test.Locator | test.Page) {
return page.getByAltText('Home tab') return page.getByRole('button').filter({ has: page.getByAltText('Home') })
} }
/** Find a "drive page" icon (if any) on the current page. */ /** Find a "drive page" icon (if any) on the current page. */
export function locateDrivePageIcon(page: test.Locator | test.Page) { export function locateDrivePageIcon(page: test.Locator | test.Page) {
return page.getByAltText('Drive tab') return page.getByRole('button').filter({ has: page.getByAltText('Catalog') })
} }
/** Find an "editor page" icon (if any) on the current page. */ /** Find an "editor page" icon (if any) on the current page. */
export function locateEditorPageIcon(page: test.Locator | test.Page) { export function locateEditorPageIcon(page: test.Locator | test.Page) {
return page.getByAltText('Project tab') return page.getByRole('button').filter({ has: page.getByAltText('Graph Editor') })
} }
/** Find a "settings page" icon (if any) on the current page. */ /** Find a "settings page" icon (if any) on the current page. */
export function locateSettingsPageIcon(page: test.Locator | test.Page) { export function locateSettingsPageIcon(page: test.Locator | test.Page) {
return page.getByAltText('Settings tab') return page.getByRole('button').filter({ has: page.getByAltText('Settings') })
} }
/** Find a "name" column heading (if any) on the current page. */ /** Find a "name" column heading (if any) on the current page. */

View File

@ -7,6 +7,7 @@ import * as tailwindMerge from 'tailwind-merge'
import * as focusHooks from '#/hooks/focusHooks' import * as focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import Spinner, * as spinnerModule from '#/components/Spinner' import Spinner, * as spinnerModule from '#/components/Spinner'
import SvgMask from '#/components/SvgMask' import SvgMask from '#/components/SvgMask'
@ -16,6 +17,8 @@ import SvgMask from '#/components/SvgMask'
/** Props for a {@link Button}. */ /** Props for a {@link Button}. */
export interface ButtonProps extends Readonly<aria.ButtonProps> { export interface ButtonProps extends Readonly<aria.ButtonProps> {
/** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */
readonly tooltip?: React.ReactNode
readonly loading?: boolean readonly loading?: boolean
readonly variant: 'cancel' | 'delete' | 'icon' | 'submit' readonly variant: 'cancel' | 'delete' | 'icon' | 'submit'
readonly icon?: string readonly icon?: string
@ -46,9 +49,11 @@ const CLASSES_FOR_VARIANT: Record<ButtonProps['variant'], string> = {
/** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */ /** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */
export function Button(props: ButtonProps) { export function Button(props: ButtonProps) {
const { className, children, variant, icon, loading = false, ...ariaButtonProps } = props const { tooltip, className, children, variant, icon, loading = false, ...ariaButtonProps } = props
const focusChildProps = focusHooks.useFocusChild() const focusChildProps = focusHooks.useFocusChild()
const tooltipElement = tooltip === false ? null : tooltip ?? ariaButtonProps['aria-label']
const classes = clsx( const classes = clsx(
DEFAULT_CLASSES, DEFAULT_CLASSES,
DISABLED_CLASSES, DISABLED_CLASSES,
@ -71,7 +76,7 @@ export function Button(props: ButtonProps) {
} }
} }
return ( const button = (
<aria.Button <aria.Button
{...aria.mergeProps<aria.ButtonProps>()(ariaButtonProps, focusChildProps, { {...aria.mergeProps<aria.ButtonProps>()(ariaButtonProps, focusChildProps, {
className: values => className: values =>
@ -84,4 +89,13 @@ export function Button(props: ButtonProps) {
{childrenFactory()} {childrenFactory()}
</aria.Button> </aria.Button>
) )
return tooltipElement == null ? (
button
) : (
<ariaComponents.TooltipTrigger>
{button}
<ariaComponents.Tooltip>{tooltipElement}</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
)
} }

View File

@ -9,7 +9,7 @@ import * as portal from '#/components/Portal'
// ================= // =================
const DEFAULT_CLASSES = const DEFAULT_CLASSES =
'flex bg-frame backdrop-blur-default text-primary p-2 rounded-default shadow-soft text-xs' 'flex bg-frame outline outline-2 outline-primary backdrop-blur-default text-primary px-2 leading-cozy min-h-6 rounded-default shadow-soft text-xs'
const DEFAULT_CONTAINER_PADDING = 4 const DEFAULT_CONTAINER_PADDING = 4
const DEFAULT_OFFSET = 4 const DEFAULT_OFFSET = 4

View File

@ -1,6 +1,8 @@
/** @file File containing SVG icon definitions. */ /** @file File containing SVG icon definitions. */
import * as React from 'react' import * as React from 'react'
import * as tailwindMerge from 'tailwind-merge'
// =============== // ===============
// === SvgMask === // === SvgMask ===
// =============== // ===============
@ -20,19 +22,16 @@ export interface SvgMaskProps {
// underlying `div`. // underlying `div`.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
readonly className?: string | undefined readonly className?: string | undefined
readonly onClick?: (event: React.MouseEvent) => void
} }
/** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */ /** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */
export default function SvgMask(props: SvgMaskProps) { export default function SvgMask(props: SvgMaskProps) {
const { invert = false, alt, src, title, style, color, className, onClick } = props const { invert = false, alt, src, style, color, className } = props
const urlSrc = `url(${JSON.stringify(src)})` const urlSrc = `url(${JSON.stringify(src)})`
const mask = invert ? `${urlSrc}, linear-gradient(white 0 0)` : urlSrc const mask = invert ? `${urlSrc}, linear-gradient(white 0 0)` : urlSrc
return ( return (
<div <div
{...(onClick == null ? {} : { role: 'button' })}
title={title}
style={{ style={{
...(style ?? {}), ...(style ?? {}),
backgroundColor: color ?? 'currentcolor', backgroundColor: color ?? 'currentcolor',
@ -50,10 +49,7 @@ export default function SvgMask(props: SvgMaskProps) {
...(invert ? { WebkitMaskComposite: 'exclude, exclude' } : {}), ...(invert ? { WebkitMaskComposite: 'exclude, exclude' } : {}),
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
}} }}
className={`inline-block ${onClick != null ? 'cursor-pointer' : ''} ${ className={tailwindMerge.twMerge('inline-block h-max w-max', className)}
className ?? 'h-max w-max'
}`}
onClick={onClick}
> >
{/* This is required for this component to have the right size. */} {/* This is required for this component to have the right size. */}
<img alt={alt} src={src} className="transparent" draggable={false} /> <img alt={alt} src={src} className="transparent" draggable={false} />

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import * as focusHooks from '#/hooks/focusHooks' import * as focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import type * as focusRing from '#/components/styled/FocusRing' import type * as focusRing from '#/components/styled/FocusRing'
import FocusRing from '#/components/styled/FocusRing' import FocusRing from '#/components/styled/FocusRing'
@ -15,6 +16,8 @@ import FocusRing from '#/components/styled/FocusRing'
export interface UnstyledButtonProps extends Readonly<React.PropsWithChildren> { export interface UnstyledButtonProps extends Readonly<React.PropsWithChildren> {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
readonly 'aria-label'?: string readonly 'aria-label'?: string
/** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */
readonly tooltip?: React.ReactNode
readonly focusRingPlacement?: focusRing.FocusRingPlacement readonly focusRingPlacement?: focusRing.FocusRingPlacement
readonly autoFocus?: boolean readonly autoFocus?: boolean
/** When `true`, the button is not clickable. */ /** When `true`, the button is not clickable. */
@ -26,10 +29,12 @@ export interface UnstyledButtonProps extends Readonly<React.PropsWithChildren> {
/** An unstyled button with a focus ring and focus movement behavior. */ /** An unstyled button with a focus ring and focus movement behavior. */
function UnstyledButton(props: UnstyledButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) { function UnstyledButton(props: UnstyledButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
const { focusRingPlacement, children, ...buttonProps } = props const { tooltip, focusRingPlacement, children, ...buttonProps } = props
const focusChildProps = focusHooks.useFocusChild() const focusChildProps = focusHooks.useFocusChild()
return ( const tooltipElement = tooltip === false ? null : tooltip ?? buttonProps['aria-label']
const button = (
<FocusRing {...(focusRingPlacement == null ? {} : { placement: focusRingPlacement })}> <FocusRing {...(focusRingPlacement == null ? {} : { placement: focusRingPlacement })}>
<aria.Button <aria.Button
{...aria.mergeProps<aria.ButtonProps & React.RefAttributes<HTMLButtonElement>>()( {...aria.mergeProps<aria.ButtonProps & React.RefAttributes<HTMLButtonElement>>()(
@ -42,6 +47,15 @@ function UnstyledButton(props: UnstyledButtonProps, ref: React.ForwardedRef<HTML
</aria.Button> </aria.Button>
</FocusRing> </FocusRing>
) )
return tooltipElement == null ? (
button
) : (
<ariaComponents.TooltipTrigger>
{button}
<ariaComponents.Tooltip>{tooltipElement}</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
)
} }
export default React.forwardRef(UnstyledButton) export default React.forwardRef(UnstyledButton)

View File

@ -17,6 +17,7 @@ import AssetListEventType from '#/events/AssetListEventType'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan' import EditableSpan from '#/components/EditableSpan'
import Button from '#/components/styled/Button'
import SvgMask from '#/components/SvgMask' import SvgMask from '#/components/SvgMask'
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
@ -158,14 +159,13 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
} }
}} }}
> >
<SvgMask <Button
src={FolderArrowIcon} image={FolderArrowIcon}
alt={item.children == null ? getText('expand') : getText('collapse')} alt={item.children == null ? getText('expand') : getText('collapse')}
className={`m-name-column-icon hidden size-icon cursor-pointer transition-transform duration-arrow group-hover:inline-block ${ className={`m-name-column-icon hidden size-icon cursor-pointer transition-transform duration-arrow group-hover:inline-block ${
item.children != null ? 'rotate-90' : '' item.children != null ? 'rotate-90' : ''
}`} }`}
onClick={event => { onPress={() => {
event.stopPropagation()
doToggleDirectoryExpansion(asset.id, item.key, asset.title) doToggleDirectoryExpansion(asset.id, item.key, asset.title)
}} }}
/> />

View File

@ -8,7 +8,7 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils' import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask' import Button from '#/components/styled/Button'
/** A heading for the "Accessed by projects" column. */ /** A heading for the "Accessed by projects" column. */
export default function AccessedByProjectsColumnHeading(props: column.AssetColumnHeadingProps) { export default function AccessedByProjectsColumnHeading(props: column.AssetColumnHeadingProps) {
@ -18,12 +18,12 @@ export default function AccessedByProjectsColumnHeading(props: column.AssetColum
return ( return (
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text"> <div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
<SvgMask <Button
src={AccessedByProjectsIcon} active
image={AccessedByProjectsIcon}
className="size-icon" className="size-icon"
title={getText('accessedByProjectsColumnHide')} alt={getText('accessedByProjectsColumnHide')}
onClick={event => { onPress={() => {
event.stopPropagation()
hideColumn(columnUtils.Column.accessedByProjects) hideColumn(columnUtils.Column.accessedByProjects)
}} }}
/> />

View File

@ -8,7 +8,7 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils' import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask' import Button from '#/components/styled/Button'
/** A heading for the "Accessed data" column. */ /** A heading for the "Accessed data" column. */
export default function AccessedDataColumnHeading(props: column.AssetColumnHeadingProps) { export default function AccessedDataColumnHeading(props: column.AssetColumnHeadingProps) {
@ -18,12 +18,12 @@ export default function AccessedDataColumnHeading(props: column.AssetColumnHeadi
return ( return (
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text"> <div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
<SvgMask <Button
src={AccessedDataIcon} active
image={AccessedDataIcon}
className="size-icon" className="size-icon"
title={getText('accessedDataColumnHide')} alt={getText('accessedDataColumnHide')}
onClick={event => { onPress={() => {
event.stopPropagation()
hideColumn(columnUtils.Column.accessedData) hideColumn(columnUtils.Column.accessedData)
}} }}
/> />

View File

@ -8,7 +8,7 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils' import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask' import Button from '#/components/styled/Button'
/** A heading for the "Docs" column. */ /** A heading for the "Docs" column. */
export default function DocsColumnHeading(props: column.AssetColumnHeadingProps) { export default function DocsColumnHeading(props: column.AssetColumnHeadingProps) {
@ -18,12 +18,12 @@ export default function DocsColumnHeading(props: column.AssetColumnHeadingProps)
return ( return (
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text"> <div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
<SvgMask <Button
src={DocsIcon} active
image={DocsIcon}
className="size-icon" className="size-icon"
title={getText('docsColumnHide')} alt={getText('docsColumnHide')}
onClick={event => { onPress={() => {
event.stopPropagation()
hideColumn(columnUtils.Column.docs) hideColumn(columnUtils.Column.docs)
}} }}
/> />

View File

@ -8,7 +8,7 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils' import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask' import Button from '#/components/styled/Button'
/** A heading for the "Labels" column. */ /** A heading for the "Labels" column. */
export default function LabelsColumnHeading(props: column.AssetColumnHeadingProps) { export default function LabelsColumnHeading(props: column.AssetColumnHeadingProps) {
@ -18,12 +18,12 @@ export default function LabelsColumnHeading(props: column.AssetColumnHeadingProp
return ( return (
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text"> <div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
<SvgMask <Button
src={TagIcon} active
image={TagIcon}
className="size-icon" className="size-icon"
title={getText('labelsColumnHide')} alt={getText('labelsColumnHide')}
onClick={event => { onPress={() => {
event.stopPropagation()
hideColumn(columnUtils.Column.labels) hideColumn(columnUtils.Column.labels)
}} }}
/> />

View File

@ -9,7 +9,7 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils' import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask' import Button from '#/components/styled/Button'
import UnstyledButton from '#/components/UnstyledButton' import UnstyledButton from '#/components/UnstyledButton'
import * as sorting from '#/utilities/sorting' import * as sorting from '#/utilities/sorting'
@ -23,7 +23,7 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
const isDescending = sortInfo?.direction === sorting.SortDirection.descending const isDescending = sortInfo?.direction === sorting.SortDirection.descending
return ( return (
<UnstyledButton <div
aria-label={ aria-label={
!isSortActive !isSortActive
? getText('sortByModificationDate') ? getText('sortByModificationDate')
@ -32,34 +32,38 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
: getText('sortByModificationDateDescending') : getText('sortByModificationDateDescending')
} }
className="group flex h-drive-table-heading w-full cursor-pointer items-center gap-icon-with-text" className="group flex h-drive-table-heading w-full cursor-pointer items-center gap-icon-with-text"
onPress={() => {
const nextDirection = isSortActive
? sorting.nextSortDirection(sortInfo.direction)
: sorting.SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: columnUtils.Column.modified, direction: nextDirection })
}
}}
> >
<SvgMask <Button
src={TimeIcon} active
image={TimeIcon}
className="size-icon" className="size-icon"
title={getText('modifiedColumnHide')} alt={getText('modifiedColumnHide')}
onClick={event => { onPress={() => {
event.stopPropagation()
hideColumn(columnUtils.Column.modified) hideColumn(columnUtils.Column.modified)
}} }}
/> />
<aria.Text className="text-header">{getText('modifiedColumnName')}</aria.Text> <UnstyledButton
<img className="flex grow gap-icon-with-text"
alt={isDescending ? getText('sortDescending') : getText('sortAscending')} onPress={() => {
src={SortAscendingIcon} const nextDirection = isSortActive
className={`transition-all duration-arrow ${ ? sorting.nextSortDirection(sortInfo.direction)
isSortActive ? 'selectable active' : 'transparent group-hover:selectable' : sorting.SortDirection.ascending
} ${isDescending ? 'rotate-180' : ''}`} if (nextDirection == null) {
/> setSortInfo(null)
</UnstyledButton> } else {
setSortInfo({ field: columnUtils.Column.modified, direction: nextDirection })
}
}}
>
<aria.Text className="text-header">{getText('modifiedColumnName')}</aria.Text>
<img
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
className={`transition-all duration-arrow ${
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'
} ${isDescending ? 'rotate-180' : ''}`}
/>
</UnstyledButton>
</div>
) )
} }

View File

@ -8,7 +8,7 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import type * as column from '#/components/dashboard/column' import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils' import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask' import Button from '#/components/styled/Button'
/** A heading for the "Shared with" column. */ /** A heading for the "Shared with" column. */
export default function SharedWithColumnHeading(props: column.AssetColumnHeadingProps) { export default function SharedWithColumnHeading(props: column.AssetColumnHeadingProps) {
@ -18,12 +18,12 @@ export default function SharedWithColumnHeading(props: column.AssetColumnHeading
return ( return (
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text"> <div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
<SvgMask <Button
src={PeopleIcon} active
image={PeopleIcon}
className="size-icon" className="size-icon"
title={getText('sharedWithColumnHide')} alt={getText('sharedWithColumnHide')}
onClick={event => { onPress={() => {
event.stopPropagation()
hideColumn(columnUtils.Column.sharedWith) hideColumn(columnUtils.Column.sharedWith)
}} }}
/> />

View File

@ -1,9 +1,12 @@
/** @file A styled button. */ /** @file A styled button. */
import * as React from 'react' import * as React from 'react'
import * as tailwindMerge from 'tailwind-merge'
import * as focusHooks from '#/hooks/focusHooks' import * as focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing' import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask' import SvgMask from '#/components/SvgMask'
@ -13,6 +16,8 @@ import SvgMask from '#/components/SvgMask'
/** Props for a {@link Button}. */ /** Props for a {@link Button}. */
export interface ButtonProps { export interface ButtonProps {
/** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */
readonly tooltip?: React.ReactNode
readonly autoFocus?: boolean readonly autoFocus?: boolean
/** When `true`, the button is not faded out even when not hovered. */ /** When `true`, the button is not faded out even when not hovered. */
readonly active?: boolean readonly active?: boolean
@ -25,7 +30,10 @@ export interface ButtonProps {
readonly alt?: string readonly alt?: string
/** A title that is only shown when `disabled` is `true`. */ /** A title that is only shown when `disabled` is `true`. */
readonly error?: string | null readonly error?: string | null
/** Class names for the icon itself. */
readonly className?: string readonly className?: string
/** Extra class names for the `button` element wrapping the icon.
* This is useful for things like positioning the entire button (e.g. `absolute`). */
readonly buttonClassName?: string readonly buttonClassName?: string
readonly onPress: (event: aria.PressEvent) => void readonly onPress: (event: aria.PressEvent) => void
} }
@ -33,31 +41,31 @@ export interface ButtonProps {
/** A styled button. */ /** A styled button. */
function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) { function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
const { const {
tooltip,
active = false, active = false,
softDisabled = false, softDisabled = false,
image, image,
error, error,
alt, alt,
className, className,
buttonClassName = '', buttonClassName,
...buttonProps ...buttonProps
} = props } = props
const { isDisabled = false } = buttonProps const { isDisabled = false } = buttonProps
const focusChildProps = focusHooks.useFocusChild() const focusChildProps = focusHooks.useFocusChild()
return ( const tooltipElement = tooltip === false ? null : tooltip ?? alt
const button = (
<FocusRing placement="after"> <FocusRing placement="after">
<aria.Button <aria.Button
{...aria.mergeProps<aria.ButtonProps>()( {...aria.mergeProps<aria.ButtonProps>()(buttonProps, focusChildProps, {
buttonProps, ref,
focusChildProps, className: tailwindMerge.twMerge(
{ 'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring',
ref, buttonClassName
className: ),
'relative after:pointer-events-none after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring', })}
},
{ className: buttonClassName }
)}
> >
<div <div
className={`group flex selectable ${isDisabled || softDisabled ? 'disabled' : ''} ${active ? 'active' : ''}`} className={`group flex selectable ${isDisabled || softDisabled ? 'disabled' : ''} ${active ? 'active' : ''}`}
@ -72,6 +80,15 @@ function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>)
</aria.Button> </aria.Button>
</FocusRing> </FocusRing>
) )
return tooltipElement == null ? (
button
) : (
<ariaComponents.TooltipTrigger>
{button}
<ariaComponents.Tooltip>{tooltipElement}</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
)
} }
export default React.forwardRef(Button) export default React.forwardRef(Button)

View File

@ -6,9 +6,11 @@ import EyeIcon from 'enso-assets/eye.svg'
import * as focusHooks from '#/hooks/focusHooks' import * as focusHooks from '#/hooks/focusHooks'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import Button from '#/components/styled/Button'
import FocusRing from '#/components/styled/FocusRing' import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
// ===================== // =====================
// === SettingsInput === // === SettingsInput ===
@ -27,6 +29,7 @@ export interface SettingsInputProps {
function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLInputElement>) { function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLInputElement>) {
const { type, placeholder, autoComplete, onChange, onSubmit } = props const { type, placeholder, autoComplete, onChange, onSubmit } = props
const focusChildProps = focusHooks.useFocusChild() const focusChildProps = focusHooks.useFocusChild()
const { getText } = textProvider.useText()
// This is SAFE. The value of this context is never a `SlottedContext`. // This is SAFE. The value of this context is never a `SlottedContext`.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const inputProps = (React.useContext(aria.InputContext) ?? null) as aria.InputProps | null const inputProps = (React.useContext(aria.InputContext) ?? null) as aria.InputProps | null
@ -86,10 +89,12 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
)} )}
/> />
{type === 'password' && ( {type === 'password' && (
<SvgMask <Button
src={isShowingPassword ? EyeIcon : EyeCrossedIcon} active
className="absolute right-2 top-1 cursor-pointer rounded-full" image={isShowingPassword ? EyeIcon : EyeCrossedIcon}
onClick={() => { alt={isShowingPassword ? getText('hidePassword') : getText('showPassword')}
buttonClassName="absolute right-2 top-1 cursor-pointer rounded-full size-icon"
onPress={() => {
setIsShowingPassword(show => !show) setIsShowingPassword(show => !show)
}} }}
/> />

View File

@ -96,8 +96,9 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
onDrop={onDrop} onDrop={onDrop}
> >
<UnstyledButton <UnstyledButton
aria-label={getText(buttonTextId)} tooltip={false}
className={`rounded-inherit ${isCurrent ? 'focus-default' : ''}`} className={`rounded-inherit ${isCurrent ? 'focus-default' : ''}`}
aria-label={getText(buttonTextId)}
onPress={onPress} onPress={onPress}
> >
<div <div

View File

@ -10,9 +10,9 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import Label from '#/components/dashboard/Label' import Label from '#/components/dashboard/Label'
import * as labelUtils from '#/components/dashboard/Label/labelUtils' import * as labelUtils from '#/components/dashboard/Label/labelUtils'
import Button from '#/components/styled/Button'
import FocusArea from '#/components/styled/FocusArea' import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing' import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal' import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import DragModal from '#/modals/DragModal' import DragModal from '#/modals/DragModal'
@ -127,8 +127,11 @@ export default function Labels(props: LabelsProps) {
</Label> </Label>
{!newLabelNames.has(label.value) && ( {!newLabelNames.has(label.value) && (
<FocusRing placement="after"> <FocusRing placement="after">
<aria.Button <Button
className="relative flex after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring" active
image={Trash2Icon}
alt={getText('delete')}
className="relative flex size-icon text-delete transition-all transparent after:absolute after:inset-button-focus-ring-inset after:rounded-button-focus-ring group-has-[[data-focus-visible]]:active group-hover:active"
onPress={() => { onPress={() => {
setModal( setModal(
<ConfirmDeleteModal <ConfirmDeleteModal
@ -139,13 +142,7 @@ export default function Labels(props: LabelsProps) {
/> />
) )
}} }}
> />
<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> </FocusRing>
)} )}
</div> </div>

View File

@ -37,17 +37,15 @@ interface PageUIData {
readonly page: Page readonly page: Page
readonly icon: string readonly icon: string
readonly altId: Extract<text.TextId, `${Page}PageAltText`> readonly altId: Extract<text.TextId, `${Page}PageAltText`>
readonly tooltipId: Extract<text.TextId, `${Page}PageTooltip`>
} }
const PAGE_DATA: PageUIData[] = [ const PAGE_DATA: PageUIData[] = [
{ page: Page.home, icon: HomeIcon, altId: 'homePageAltText', tooltipId: 'homePageTooltip' }, { page: Page.home, icon: HomeIcon, altId: 'homePageAltText' },
{ page: Page.drive, icon: DriveIcon, altId: 'drivePageAltText', tooltipId: 'drivePageTooltip' }, { page: Page.drive, icon: DriveIcon, altId: 'drivePageAltText' },
{ {
page: Page.editor, page: Page.editor,
icon: NetworkIcon, icon: NetworkIcon,
altId: 'editorPageAltText', altId: 'editorPageAltText',
tooltipId: 'editorPageTooltip',
}, },
] ]
@ -87,16 +85,16 @@ export default function PageSwitcher(props: PageSwitcherProps) {
{...innerProps} {...innerProps}
> >
{PAGE_DATA.map(pageData => { {PAGE_DATA.map(pageData => {
const error = ERRORS[pageData.page]
return ( return (
<Button <Button
key={pageData.page} key={pageData.page}
aria-label={getText(pageData.tooltipId)}
alt={getText(pageData.altId)} alt={getText(pageData.altId)}
image={pageData.icon} image={pageData.icon}
active={page === pageData.page} active={page === pageData.page}
softDisabled={page === pageData.page} softDisabled={page === pageData.page}
isDisabled={pageData.page === Page.editor && isEditorDisabled} isDisabled={pageData.page === Page.editor && isEditorDisabled}
error={ERRORS[pageData.page]} error={error == null ? null : getText(error)}
onPress={() => { onPress={() => {
setPage(pageData.page) setPage(pageData.page)
}} }}

View File

@ -75,6 +75,7 @@ export default function UserBar(props: UserBarProps) {
<Button <Button
active={isHelpChatOpen} active={isHelpChatOpen}
image={ChatIcon} image={ChatIcon}
alt={getText('chatButtonAltText')}
onPress={() => { onPress={() => {
setIsHelpChatOpen(!isHelpChatOpen) setIsHelpChatOpen(!isHelpChatOpen)
}} }}
@ -82,6 +83,7 @@ export default function UserBar(props: UserBarProps) {
{shouldShowInviteButton && ( {shouldShowInviteButton && (
<UnstyledButton <UnstyledButton
className="text my-auto rounded-full bg-share px-button-x text-inversed" className="text my-auto rounded-full bg-share px-button-x text-inversed"
aria-label={getText('inviteButtonAltText')}
onPress={() => { onPress={() => {
setModal(<InviteUsersModal />) setModal(<InviteUsersModal />)
}} }}
@ -92,6 +94,7 @@ export default function UserBar(props: UserBarProps) {
{shouldShowShareButton && ( {shouldShowShareButton && (
<UnstyledButton <UnstyledButton
className="text my-auto rounded-full bg-share px-button-x text-inversed" className="text my-auto rounded-full bg-share px-button-x text-inversed"
aria-label={getText('shareButtonAltText')}
onPress={() => { onPress={() => {
setModal( setModal(
<ManagePermissionsModal <ManagePermissionsModal
@ -109,6 +112,7 @@ export default function UserBar(props: UserBarProps) {
)} )}
<UnstyledButton <UnstyledButton
className="flex size-profile-picture select-none items-center overflow-clip rounded-full" className="flex size-profile-picture select-none items-center overflow-clip rounded-full"
aria-label={getText('userMenuAltText')}
onPress={() => { onPress={() => {
updateModal(oldModal => updateModal(oldModal =>
oldModal?.type === UserMenu ? null : ( oldModal?.type === UserMenu ? null : (

View File

@ -11,10 +11,10 @@ import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import Modal from '#/components/Modal' import Modal from '#/components/Modal'
import Button from '#/components/styled/Button'
import ButtonRow from '#/components/styled/ButtonRow' import ButtonRow from '#/components/styled/ButtonRow'
import FocusArea from '#/components/styled/FocusArea' import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing' import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import UnstyledButton from '#/components/UnstyledButton' import UnstyledButton from '#/components/UnstyledButton'
import type * as backend from '#/services/Backend' import type * as backend from '#/services/Backend'
@ -110,10 +110,10 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
}} }}
/> />
</FocusRing> </FocusRing>
<SvgMask <Button
src={isShowingValue ? EyeIcon : EyeCrossedIcon} image={isShowingValue ? EyeIcon : EyeCrossedIcon}
className="absolute right-2 top-1 cursor-pointer rounded-full" className="absolute right-2 top-1 cursor-pointer rounded-full"
onClick={() => { onPress={() => {
setIsShowingValue(show => !show) setIsShowingValue(show => !show)
}} }}
/> />

View File

@ -222,11 +222,11 @@
"homeCategory": "Home", "homeCategory": "Home",
"rootCategory": "Root", "rootCategory": "Root",
"trashCategory": "Trash", "trashCategory": "Trash",
"recentCategoryButtonLabel": "Go to Recent category", "recentCategoryButtonLabel": "Recent",
"draftsCategoryButtonLabel": "Go to Drafts category", "draftsCategoryButtonLabel": "Drafts",
"homeCategoryButtonLabel": "Go to Home category", "homeCategoryButtonLabel": "Home",
"rootCategoryButtonLabel": "Go to Root category", "rootCategoryButtonLabel": "Root",
"trashCategoryButtonLabel": "Go to Trash category", "trashCategoryButtonLabel": "Trash",
"recentCategoryDropZoneLabel": "Move to Recent category", "recentCategoryDropZoneLabel": "Move to Recent category",
"draftsCategoryDropZoneLabel": "Move to Drafts category", "draftsCategoryDropZoneLabel": "Move to Drafts category",
"homeCategoryDropZoneLabel": "Move to Home category", "homeCategoryDropZoneLabel": "Move to Home category",
@ -235,8 +235,8 @@
"newFolder": "New Folder", "newFolder": "New Folder",
"newProject": "New Project", "newProject": "New Project",
"uploadFiles": "Upload Files", "uploadFiles": "Import",
"downloadFiles": "Download Files", "downloadFiles": "Export",
"newDataLink": "New Data Link", "newDataLink": "New Data Link",
"newSecret": "New Secret", "newSecret": "New Secret",
"newLabel": "New Label", "newLabel": "New Label",
@ -260,8 +260,8 @@
"sortByTimestamp": "Sort by timestamp", "sortByTimestamp": "Sort by timestamp",
"sortByTimestampDescending": "Sort by timestamp descending", "sortByTimestampDescending": "Sort by timestamp descending",
"stopSortingByTimestamp": "Stop sorting by timestamp", "stopSortingByTimestamp": "Stop sorting by timestamp",
"closeAssetPanel": "Close Asset Panel", "closeAssetPanel": "Asset Panel",
"openAssetPanel": "Open Asset Panel", "openAssetPanel": "Asset Panel",
"confirmEdit": "Confirm Edit", "confirmEdit": "Confirm Edit",
"cancelEdit": "Cancel Edit", "cancelEdit": "Cancel Edit",
"loadingAppMessage": "Logging in to Enso...", "loadingAppMessage": "Logging in to Enso...",
@ -320,6 +320,8 @@
"noVersionsFound": "No versions found", "noVersionsFound": "No versions found",
"latestIndicator": "(Latest)", "latestIndicator": "(Latest)",
"noDateSelected": "No date selected", "noDateSelected": "No date selected",
"hidePassword": "Hide password",
"showPassword": "Show password",
"deleteLabelActionText": "delete the label '$0'", "deleteLabelActionText": "delete the label '$0'",
"deleteSelectedAssetActionText": "delete '$0'", "deleteSelectedAssetActionText": "delete '$0'",
@ -392,14 +394,14 @@
"newsItemCommunityServer": "Join our community server", "newsItemCommunityServer": "Join our community server",
"newsItemCommunityServerDescription": "Chat with our team and other Enso users.", "newsItemCommunityServerDescription": "Chat with our team and other Enso users.",
"homePageAltText": "Home tab", "chatButtonAltText": "Chat",
"drivePageAltText": "Drive tab", "inviteButtonAltText": "Invite others to try Enso",
"editorPageAltText": "Project tab", "shareButtonAltText": "Share",
"settingsPageAltText": "Settings tab", "userMenuAltText": "User Settings",
"homePageTooltip": "Go to homepage", "homePageAltText": "Home",
"drivePageTooltip": "Go to drive", "drivePageAltText": "Catalog",
"editorPageTooltip": "Go to project", "editorPageAltText": "Graph Editor",
"settingsPageTooltip": "Go to settings", "settingsPageAltText": "Settings",
"soloPlanName": "Solo", "soloPlanName": "Solo",
"teamPlanName": "Team", "teamPlanName": "Team",
@ -476,20 +478,20 @@
"pasteAllShortcut": "Paste All", "pasteAllShortcut": "Paste All",
"deleteLabelShortcut": "Delete Label", "deleteLabelShortcut": "Delete Label",
"nameColumnShow": "Show Name column", "nameColumnShow": "Name",
"nameColumnHide": "Hide Name column", "nameColumnHide": "Name",
"modifiedColumnShow": "Show Modified date column", "modifiedColumnShow": "Modified",
"modifiedColumnHide": "Hide Modified date column", "modifiedColumnHide": "Modified",
"sharedWithColumnShow": "Show Shared with column", "sharedWithColumnShow": "Shared With",
"sharedWithColumnHide": "Hide Shared with column", "sharedWithColumnHide": "Shared With",
"labelsColumnShow": "Show Labels column", "labelsColumnShow": "Labels",
"labelsColumnHide": "Hide Labels column", "labelsColumnHide": "Labels",
"accessedByProjectsColumnShow": "Show Accessed by projects column", "accessedByProjectsColumnShow": "Accessed By Projects",
"accessedByProjectsColumnHide": "Hide Accessed by projects column", "accessedByProjectsColumnHide": "Accessed By Projects",
"accessedDataColumnShow": "Show Accessed data column", "accessedDataColumnShow": "Accessed Data",
"accessedDataColumnHide": "Hide Accessed data column", "accessedDataColumnHide": "Accessed Data",
"docsColumnShow": "Show Docs column", "docsColumnShow": "Docs",
"docsColumnHide": "Hide Doc columns", "docsColumnHide": "Docs",
"activityLog": "Activity Log", "activityLog": "Activity Log",
"startDate": "Start Date", "startDate": "Start Date",