Fix close button being shown off the tab (#10375)

This commit is contained in:
Sergei Garin 2024-06-27 09:10:19 +03:00 committed by GitHub
parent 2dbd8a2e71
commit 2fde378b15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 124 additions and 62 deletions

View File

@ -18,36 +18,39 @@ import * as text from '../Text'
/** Props for a {@link Button}. */ /** Props for a {@link Button}. */
export type ButtonProps = export type ButtonProps =
| (BaseButtonProps & Omit<aria.ButtonProps, 'children' | 'onPress' | 'type'> & PropsWithoutHref) | (BaseButtonProps<aria.ButtonRenderProps> & Omit<aria.ButtonProps, 'onPress'> & PropsWithoutHref)
| (BaseButtonProps & Omit<aria.LinkProps, 'children' | 'onPress' | 'type'> & PropsWithHref) | (BaseButtonProps<aria.LinkRenderProps> & Omit<aria.LinkProps, 'onPress'> & PropsWithHref)
/** /**
* Props for a button with an href. * Props for a button with an href.
*/ */
interface PropsWithHref { interface PropsWithHref {
readonly href?: string readonly href: string
readonly type?: never
} }
/** /**
* Props for a button without an href. * Props for a button without an href.
*/ */
interface PropsWithoutHref { interface PropsWithoutHref {
readonly type?: 'button' | 'reset' | 'submit'
readonly href?: never readonly href?: never
} }
/** /**
* Base props for a button. * Base props for a button.
*/ */
export interface BaseButtonProps extends Omit<twv.VariantProps<typeof BUTTON_STYLES>, 'iconOnly'> { export interface BaseButtonProps<Render>
extends Omit<twv.VariantProps<typeof BUTTON_STYLES>, 'iconOnly'> {
/** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */ /** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */
readonly tooltip?: React.ReactElement | string | false readonly tooltip?: React.ReactElement | string | false
readonly tooltipPlacement?: aria.Placement readonly tooltipPlacement?: aria.Placement
/** /**
* The icon to display in the button * The icon to display in the button
*/ */
readonly icon?: React.ReactElement | string | null readonly icon?:
| React.ReactElement
| string
| ((render: Render) => React.ReactElement | string | null)
| null
/** /**
* When `true`, icon will be shown only when hovered. * When `true`, icon will be shown only when hovered.
*/ */
@ -58,9 +61,8 @@ export interface BaseButtonProps extends Omit<twv.VariantProps<typeof BUTTON_STY
*/ */
readonly onPress?: (event: aria.PressEvent) => Promise<void> | void readonly onPress?: (event: aria.PressEvent) => Promise<void> | void
readonly contentClassName?: string readonly contentClassName?: string
readonly children?: React.ReactNode
readonly testId?: string readonly testId?: string
readonly isDisabled?: boolean
readonly formnovalidate?: boolean readonly formnovalidate?: boolean
/** Defaults to `full`. When `full`, the entire button will be replaced with the loader. /** Defaults to `full`. When `full`, the entire button will be replaced with the loader.
* When `icon`, only the icon will be replaced with the loader. */ * When `icon`, only the icon will be replaced with the loader. */
@ -201,6 +203,26 @@ export const BUTTON_STYLES = twv.tv({
showIconOnHover: { showIconOnHover: {
true: { icon: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100' }, true: { icon: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100' },
}, },
extraClickZone: {
true: {
extraClickZone: 'flex relative after:absolute after:cursor-pointer',
},
false: {
extraClickZone: '',
},
small: {
extraClickZone: 'after:inset-[-6px]',
},
medium: {
extraClickZone: 'after:inset-[-8px]',
},
large: {
extraClickZone: 'after:inset-[-10px]',
},
custom: {
extraClickZone: 'after:inset-[calc(var(--extra-click-zone-offset, 0) * -1)]',
},
},
}, },
slots: { slots: {
extraClickZone: 'flex relative after:absolute after:cursor-pointer', extraClickZone: 'flex relative after:absolute after:cursor-pointer',
@ -278,7 +300,6 @@ export const Button = React.forwardRef(function Button(
variant, variant,
icon, icon,
loading = false, loading = false,
isDisabled,
isActive, isActive,
showIconOnHover, showIconOnHover,
iconPosition, iconPosition,
@ -290,6 +311,7 @@ export const Button = React.forwardRef(function Button(
tooltipPlacement, tooltipPlacement,
testId, testId,
loaderPosition = 'full', loaderPosition = 'full',
extraClickZone: extraClickZoneProp,
onPress = () => {}, onPress = () => {},
...ariaProps ...ariaProps
} = props } = props
@ -304,10 +326,11 @@ export const Button = React.forwardRef(function Button(
const Tag = isLink ? aria.Link : aria.Button const Tag = isLink ? aria.Link : aria.Button
const goodDefaults = { const goodDefaults = {
...(isLink ? { rel: 'noopener noreferrer' } : {}), ...(isLink ? { rel: 'noopener noreferrer', ref } : {}),
...(isLink ? {} : { type: 'button' as const }), ...(isLink ? {} : { type: 'button' as const }),
'data-testid': testId ?? (isLink ? 'link' : 'button'), 'data-testid': testId ?? (isLink ? 'link' : 'button'),
} }
const isIconOnly = (children == null || children === '' || children === false) && icon != null const isIconOnly = (children == null || children === '' || children === false) && icon != null
const shouldShowTooltip = (() => { const shouldShowTooltip = (() => {
if (tooltip === false) { if (tooltip === false) {
@ -321,6 +344,7 @@ export const Button = React.forwardRef(function Button(
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
const isLoading = loading || implicitlyLoading const isLoading = loading || implicitlyLoading
const isDisabled = props.isDisabled == null ? isLoading : props.isDisabled
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const delay = 350 const delay = 350
@ -350,7 +374,7 @@ export const Button = React.forwardRef(function Button(
}, [isLoading, loaderPosition]) }, [isLoading, loaderPosition])
const handlePress = (event: aria.PressEvent): void => { const handlePress = (event: aria.PressEvent): void => {
if (!isLoading) { if (!isDisabled) {
const result = onPress(event) const result = onPress(event)
if (result instanceof Promise) { if (result instanceof Promise) {
@ -371,7 +395,7 @@ export const Button = React.forwardRef(function Button(
icon: iconClasses, icon: iconClasses,
text: textClasses, text: textClasses,
} = BUTTON_STYLES({ } = BUTTON_STYLES({
isDisabled, isDisabled: isDisabled,
isActive, isActive,
loading: isLoading, loading: isLoading,
fullWidth, fullWidth,
@ -381,10 +405,13 @@ export const Button = React.forwardRef(function Button(
variant, variant,
iconPosition, iconPosition,
showIconOnHover, showIconOnHover,
extraClickZone: extraClickZoneProp,
iconOnly: isIconOnly, iconOnly: isIconOnly,
}) })
const childrenFactory = (): React.ReactNode => { const childrenFactory = (
render: aria.ButtonRenderProps | aria.LinkRenderProps
): React.ReactNode => {
const iconComponent = (() => { const iconComponent = (() => {
if (icon == null) { if (icon == null) {
return null return null
@ -394,10 +421,15 @@ export const Button = React.forwardRef(function Button(
<StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} /> <StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
</span> </span>
) )
} else if (typeof icon === 'string') {
return <SvgMask src={icon} className={iconClasses()} />
} else { } else {
return <span className={iconClasses()}>{icon}</span> /* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */
const actualIcon = typeof icon === 'function' ? icon(render) : icon
if (typeof actualIcon === 'string') {
return <SvgMask src={actualIcon} className={iconClasses()} />
} else {
return <span className={iconClasses()}>{actualIcon}</span>
}
} }
})() })()
// Icon only button // Icon only button
@ -408,7 +440,10 @@ export const Button = React.forwardRef(function Button(
return ( return (
<> <>
{iconComponent} {iconComponent}
<span className={textClasses()}>{children}</span> <span className={textClasses()}>
{/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */}
{typeof children === 'function' ? children(render) : children}
</span>
</> </>
) )
} }
@ -416,34 +451,34 @@ export const Button = React.forwardRef(function Button(
const button = ( const button = (
<Tag <Tag
{...aria.mergeProps<aria.ButtonProps | aria.LinkProps>()( // @ts-expect-error ts errors are expected here because we are merging props with different types
goodDefaults, {...aria.mergeProps<aria.ButtonProps>()(goodDefaults, ariaProps, focusChildProps, {
ariaProps, isDisabled: isDisabled,
focusChildProps, // we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger
{ // onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered
// eslint-disable-next-line no-restricted-syntax onPressEnd: handlePress,
...{ ref: ref as never }, className: aria.composeRenderProps(className, (classNames, states) =>
isDisabled, base({ className: classNames, ...states })
// we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger ),
// onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered })}
onPressEnd: handlePress,
className: aria.composeRenderProps(className, (classNames, states) =>
base({ className: classNames, ...states })
),
}
)}
> >
<span className={wrapper()}> {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */}
<span ref={contentRef} className={content({ className: contentClassName })}> {render => (
{childrenFactory()} <>
</span> <span className={wrapper()}>
<span ref={contentRef} className={content({ className: contentClassName })}>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */}
{childrenFactory(render)}
</span>
{isLoading && loaderPosition === 'full' && ( {isLoading && loaderPosition === 'full' && (
<span ref={loaderRef} className={loader()}> <span ref={loaderRef} className={loader()}>
<StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} /> <StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
</span>
)}
</span> </span>
)} </>
</span> )}
</Tag> </Tag>
) )

View File

@ -34,11 +34,11 @@ export function CloseButton(props: CloseButtonProps) {
return ( return (
<button.Button <button.Button
variant="icon" variant="icon"
// @ts-expect-error ts fails to infer the type of the className prop
className={values => className={values =>
tailwindMerge.twMerge( tailwindMerge.twMerge(
'h-3 w-3 bg-primary/30 hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1', 'h-3 w-3 bg-primary/30 hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1',
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
// @ts-expect-error ts fails to infer the type of the className prop
typeof className === 'function' ? className(values) : className typeof className === 'function' ? className(values) : className
) )
} }
@ -46,6 +46,7 @@ export function CloseButton(props: CloseButtonProps) {
showIconOnHover showIconOnHover
size="custom" size="custom"
rounded="full" rounded="full"
extraClickZone="medium"
icon={icon} icon={icon}
aria-label={ariaLabel} aria-label={ariaLabel}
/* This is safe because we are passing all props to the button */ /* This is safe because we are passing all props to the button */

View File

@ -27,6 +27,7 @@ const TAB_RADIUS_PX = 24
/** Context for a {@link TabBarContext}. */ /** Context for a {@link TabBarContext}. */
interface TabBarContextValue { interface TabBarContextValue {
readonly updateClipPath: (element: HTMLDivElement | null) => void readonly updateClipPath: (element: HTMLDivElement | null) => void
readonly observeElement: (element: HTMLElement) => () => void
} }
const TabBarContext = React.createContext<TabBarContextValue | null>(null) const TabBarContext = React.createContext<TabBarContextValue | null>(null)
@ -103,7 +104,18 @@ export default function TabBar(props: TabBarProps) {
} }
return ( return (
<TabBarContext.Provider value={{ updateClipPath }}> <TabBarContext.Provider
value={{
updateClipPath,
observeElement: element => {
resizeObserver.observe(element)
return () => {
resizeObserver.unobserve(element)
}
},
}}
>
<div className="relative flex grow"> <div className="relative flex grow">
<div <div
ref={element => { ref={element => {
@ -162,10 +174,25 @@ interface InternalTabProps extends Readonly<React.PropsWithChildren> {
/** A tab in a {@link TabBar}. */ /** A tab in a {@link TabBar}. */
export function Tab(props: InternalTabProps) { export function Tab(props: InternalTabProps) {
const { isActive, icon, labelId, loadingPromise, children, onPress, onClose } = props const { isActive, icon, labelId, loadingPromise, children, onPress, onClose } = props
const { updateClipPath } = useTabBarContext() const { updateClipPath, observeElement } = useTabBarContext()
const ref = React.useRef<HTMLDivElement | null>(null)
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const [isLoading, setIsLoading] = React.useState(loadingPromise != null) const [isLoading, setIsLoading] = React.useState(loadingPromise != null)
React.useLayoutEffect(() => {
if (isActive) {
updateClipPath(ref.current)
}
}, [isActive, updateClipPath])
React.useEffect(() => {
if (ref.current) {
return observeElement(ref.current)
} else {
return () => {}
}
}, [observeElement])
React.useEffect(() => { React.useEffect(() => {
if (loadingPromise) { if (loadingPromise) {
setIsLoading(true) setIsLoading(true)
@ -184,7 +211,7 @@ export function Tab(props: InternalTabProps) {
return ( return (
<div <div
ref={isActive ? updateClipPath : null} ref={ref}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
'group relative h-full', 'group relative h-full',
!isActive && 'hover:enabled:bg-frame' !isActive && 'hover:enabled:bg-frame'
@ -194,26 +221,25 @@ export function Tab(props: InternalTabProps) {
size="custom" size="custom"
variant="custom" variant="custom"
loaderPosition="icon" loaderPosition="icon"
icon={icon} icon={({ isFocusVisible, isHovered }) =>
isDisabled={isActive} (isFocusVisible || isHovered) && onClose ? (
<div className="mt-[1px] flex h-4 w-4 items-center justify-center">
<ariaComponents.CloseButton onPress={onClose} />
</div>
) : (
icon
)
}
isDisabled={false}
isActive={isActive} isActive={isActive}
loading={isLoading} loading={isActive ? false : isLoading}
aria-label={getText(labelId)} aria-label={getText(labelId)}
tooltip={false} tooltip={false}
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge('relative flex h-full items-center gap-3 px-4')}
'relative flex h-full items-center gap-3 px-4',
onClose && 'pl-10'
)}
onPress={onPress} onPress={onPress}
> >
{children} {children}
</ariaComponents.Button> </ariaComponents.Button>
{onClose && (
<ariaComponents.CloseButton
className="absolute left-4 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
onPress={onClose}
/>
)}
</div> </div>
) )
} }

View File

@ -3,8 +3,8 @@
import * as React from 'react' import * as React from 'react'
import DriveIcon from 'enso-assets/drive.svg' import DriveIcon from 'enso-assets/drive.svg'
import EditorIcon from 'enso-assets/network.svg'
import SettingsIcon from 'enso-assets/settings.svg' import SettingsIcon from 'enso-assets/settings.svg'
import WorkspaceIcon from 'enso-assets/workspace.svg'
import * as detect from 'enso-common/src/detect' import * as detect from 'enso-common/src/detect'
import * as eventHooks from '#/hooks/eventHooks' import * as eventHooks from '#/hooks/eventHooks'
@ -365,7 +365,7 @@ export default function Dashboard(props: DashboardProps) {
{projectStartupInfo != null && ( {projectStartupInfo != null && (
<tabBar.Tab <tabBar.Tab
isActive={page === TabType.editor} isActive={page === TabType.editor}
icon={WorkspaceIcon} icon={EditorIcon}
labelId="editorPageName" labelId="editorPageName"
loadingPromise={projectStartupInfo.project} loadingPromise={projectStartupInfo.project}
onPress={() => { onPress={() => {