mirror of
https://github.com/enso-org/enso.git
synced 2024-11-26 08:52:58 +03:00
Fix close button being shown off the tab (#10375)
This commit is contained in:
parent
2dbd8a2e71
commit
2fde378b15
@ -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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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 */
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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={() => {
|
||||||
|
Loading…
Reference in New Issue
Block a user