diff --git a/CHANGELOG.md b/CHANGELOG.md index 099bda81269a..99cfeee6c379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,19 +9,24 @@ - [Quick Fix Import Button][12051]. - [Fixed nodes being selected after deleting other nodes or connections.][11902] - [Redo stack is no longer lost when interacting with text literals][11908]. +- [Fixed bug when clicking header in Table Editor Widget didn't start editing + it][12064] [11889]: https://github.com/enso-org/enso/pull/11889 [11836]: https://github.com/enso-org/enso/pull/11836 [12051]: https://github.com/enso-org/enso/pull/12051 [11902]: https://github.com/enso-org/enso/pull/11902 [11908]: https://github.com/enso-org/enso/pull/11908 +[12064]: https://github.com/enso-org/enso/pull/12064 #### Enso Standard Library - [Allow using `/` to access files inside a directory reached through a data link.][11926] +- [Added Table.Offset][12071] [11926]: https://github.com/enso-org/enso/pull/11926 +[12071]: https://github.com/enso-org/enso/pull/12071 #### Enso Language & Runtime diff --git a/app/common/src/utilities/data/object.ts b/app/common/src/utilities/data/object.ts index a60e6051050b..44279a04d2b3 100644 --- a/app/common/src/utilities/data/object.ts +++ b/app/common/src/utilities/data/object.ts @@ -193,27 +193,6 @@ export type ExtractKeys = { /** An instance method of the given type. */ export type MethodOf = (this: T, ...args: never) => unknown -// =================== -// === useObjectId === -// =================== - -/** Composable providing support for managing object identities. */ -export function useObjectId() { - let lastId = 0 - const idNumbers = new WeakMap() - /** @returns A value that can be used to compare object identity. */ - function objectId(o: object): number { - const id = idNumbers.get(o) - if (id == null) { - lastId += 1 - idNumbers.set(o, lastId) - return lastId - } - return id - } - return { objectId } -} - /** * Returns the union of `A` and `B`, with a type-level assertion that `A` and `B` don't have any keys in common; this * can be used to splice together objects without the risk of collisions. diff --git a/app/gui/package.json b/app/gui/package.json index 91c5d079f488..9d05fceebc50 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -112,6 +112,7 @@ "react-aria": "3.34.3", "react-aria-components": "1.3.3", "react-compiler-runtime": "19.0.0-beta-a7bf2bd-20241110", + "react-keyed-flatten-children": "3.0.2", "react-dom": "^18.3.1", "react-error-boundary": "4.0.13", "react-hook-form": "^7.54.2", diff --git a/app/gui/src/dashboard/assets/expand_arrow_down.svg b/app/gui/src/dashboard/assets/expand_arrow_down.svg new file mode 100644 index 000000000000..378801805e0c --- /dev/null +++ b/app/gui/src/dashboard/assets/expand_arrow_down.svg @@ -0,0 +1,5 @@ + + + diff --git a/app/gui/src/dashboard/assets/expand_arrow_right.svg b/app/gui/src/dashboard/assets/expand_arrow_right.svg new file mode 100644 index 000000000000..301d2f8fd0e8 --- /dev/null +++ b/app/gui/src/dashboard/assets/expand_arrow_right.svg @@ -0,0 +1,4 @@ + + + diff --git a/app/gui/src/dashboard/assets/expand_arrow_up.svg b/app/gui/src/dashboard/assets/expand_arrow_up.svg new file mode 100644 index 000000000000..952f9e404b02 --- /dev/null +++ b/app/gui/src/dashboard/assets/expand_arrow_up.svg @@ -0,0 +1,4 @@ + + + diff --git a/app/gui/src/dashboard/assets/sort_ascending.svg b/app/gui/src/dashboard/assets/sort_ascending.svg index 3abb118e2c66..3a0705229b7b 100644 --- a/app/gui/src/dashboard/assets/sort_ascending.svg +++ b/app/gui/src/dashboard/assets/sort_ascending.svg @@ -1,3 +1,3 @@ - \ No newline at end of file + diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.stories.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.stories.tsx index e643addfc517..d7b15e57492f 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.stories.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.stories.tsx @@ -1,11 +1,13 @@ import Enso from '#/assets/enso_logo.svg' +import ArrowDownIcon from '#/assets/expand_arrow_down.svg' +import Plus from '#/assets/plus.svg' + import type * as aria from '#/components/aria' -import { Text } from '#/components/AriaComponents' +import { Popover, Separator, Text } from '#/components/AriaComponents' import type { Meta, StoryObj } from '@storybook/react' import { expect, userEvent, within } from '@storybook/test' +import { Button, type BaseButtonProps } from '.' import { Badge } from '../../Badge' -import type { BaseButtonProps } from './Button' -import { Button } from './Button' type Story = StoryObj> @@ -18,6 +20,7 @@ const variants = [ 'link', 'submit', 'outline', + 'icon', ] as const const sizes = ['hero', 'large', 'medium', 'small', 'xsmall', 'xxsmall'] as const @@ -43,22 +46,15 @@ export const Variants: Story = { render: () => (
Variants -
- {variants.map((variant) => ( - - ))} -
- - Sizes -
- {sizes.map((size) => ( - - ))} -
+ {variants.map((variant) => ( +
+ {sizes.map((size) => ( + + ))} +
+ ))} Icons
@@ -167,3 +163,227 @@ export const Addons: Story = { ), } + +export const ButtonGroup: Story = { + render: () => ( +
+
+ Separate + + + + + +
+ +
+ Joined + + {variants.map((variant) => ( + + + + + + +
+ + {/* Column */} +
+ Column + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {/* End Column */} + + {/* Row */} +
+ Row + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {/* End Row */} + +
+ Button Styles + + + + + +
+
+ ), +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx index 1a78a8f99a5a..3cc7f599ac7d 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx @@ -13,516 +13,259 @@ import * as aria from '#/components/aria' import { StatelessSpinner } from '#/components/StatelessSpinner' import SvgMask from '#/components/SvgMask' -import { TEXT_STYLE, useVisualTooltip } from '#/components/AriaComponents/Text' +import { useVisualTooltip } from '#/components/AriaComponents/Text' import { Tooltip, TooltipTrigger } from '#/components/AriaComponents/Tooltip' import { useEventCallback } from '#/hooks/eventCallbackHooks' import { forwardRef } from '#/utilities/react' -import type { ExtractFunction, VariantProps } from '#/utilities/tailwindVariants' -import { tv } from '#/utilities/tailwindVariants' - -// ============== -// === Button === -// ============== - -/** Props for a {@link Button}. */ -export type ButtonProps = - | (BaseButtonProps & Omit & PropsWithoutHref) - | (BaseButtonProps & Omit & PropsWithHref) - -/** Props for a button with an href. */ -interface PropsWithHref { - readonly href: string -} - -/** Props for a button without an href. */ -interface PropsWithoutHref { - readonly href?: never -} - -/** Base props for a button. */ -export interface BaseButtonProps - extends Omit, 'iconOnly'> { - /** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */ - readonly tooltip?: ReactElement | string | false | null - readonly tooltipPlacement?: aria.Placement - /** The icon to display in the button */ - readonly icon?: - | ReactElement - | string - | ((render: Render) => ReactElement | string | null) - | null - | undefined - /** When `true`, icon will be shown only when hovered. */ - readonly showIconOnHover?: boolean - /** - * Handler that is called when the press is released over the target. - * If the handler returns a promise, the button will be in a loading state until the promise resolves. - */ - readonly onPress?: ((event: aria.PressEvent) => Promise | void) | null | undefined - readonly contentClassName?: string - readonly testId?: string - readonly isDisabled?: boolean - readonly formnovalidate?: boolean - /** - * 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. - */ - readonly loaderPosition?: 'full' | 'icon' - readonly styles?: ExtractFunction | undefined - - readonly addonStart?: - | ReactElement - | string - | false - | ((render: Render) => ReactElement | string | null) - | null - | undefined - readonly addonEnd?: - | ReactElement - | string - | false - | ((render: Render) => ReactElement | string | null) - | null - | undefined -} - -export const BUTTON_STYLES = tv({ - base: [ - 'group', - // we need to set the height to max-content to prevent the button from growing in flex containers - 'h-[max-content]', - // basic outline - 'outline-offset-[1px] outline-transparent', - // buttons always have borders - // so keep them in mind when setting paddings - 'border-0.5 border-transparent', - // button reset styles - 'whitespace-nowrap cursor-pointer select-none appearance-none', - // Align the content by the center - 'text-center items-center justify-center', - // animations - 'transition-[opacity,outline-offset,background,border-color] duration-150 ease-in-out', - ], - variants: { - isDisabled: { - true: 'opacity-50 cursor-not-allowed', - }, - isFocused: { - true: 'focus:outline-none focus-visible:outline-2 focus-visible:outline-black focus-visible:outline-offset-[-2px]', - }, - isActive: { - none: '', - false: - 'disabled:opacity-30 [&.disabled]:opacity-30 disabled:cursor-not-allowed [&.disabled]:cursor-not-allowed opacity-50 hover:opacity-75', - true: 'opacity-100 disabled:opacity-100 [&.disabled]:opacity-100 hover:opacity-100 disabled:cursor-default [&.disabled]:cursor-default', - }, - loading: { true: { base: 'cursor-wait' } }, - fullWidth: { true: 'w-full' }, - size: { - custom: { base: '', extraClickZone: '', icon: 'h-full w-unset min-w-[1.906cap]' }, - hero: { - base: TEXT_STYLE({ - variant: 'subtitle', - color: 'custom', - weight: 'semibold', - className: 'flex px-[24px] py-5', - }), - text: 'mx-[1.5em]', - }, - large: { - base: TEXT_STYLE({ - variant: 'body', - color: 'custom', - weight: 'semibold', - className: 'flex px-[11px] py-[5.5px]', - }), - content: 'gap-2', - icon: '-mb-0.5 h-4 w-4', - extraClickZone: 'after:inset-[-6px]', - }, - medium: { - base: TEXT_STYLE({ - variant: 'body', - color: 'custom', - weight: 'semibold', - className: 'flex px-[7px] py-[3.5px]', - }), - icon: '-mb-0.5 h-4 w-4', - content: 'gap-2', - extraClickZone: 'after:inset-[-8px]', - }, - small: { - base: TEXT_STYLE({ - variant: 'body', - color: 'custom', - weight: 'medium', - className: 'flex px-[5px] py-[1.5px]', - }), - icon: '-mb-0.5 h-3.5 w-3.5', - content: 'gap-1', - extraClickZone: 'after:inset-[-10px]', - }, - xsmall: { - base: TEXT_STYLE({ - variant: 'body', - color: 'custom', - weight: 'medium', - disableLineHeightCompensation: true, - className: 'flex px-[5px] pt-[1px] pb-[2px]', - }), - icon: '-mb-0.5 h-3 w-3', - content: 'gap-1', - extraClickZone: 'after:inset-[-12px]', - }, - xxsmall: { - base: TEXT_STYLE({ - variant: 'body', - color: 'custom', - className: 'flex px-[3px] pt-[0.5px] pb-[2.5px] leading-[16px]', - // we need to disable line height compensation for this size - // because otherwise the text will be too high in the button - disableLineHeightCompensation: true, - }), - content: 'gap-0.5', - icon: 'mb-[-0.1cap]', - extraClickZone: 'after:inset-[-12px]', - }, - }, - iconOnly: { - true: { - base: TEXT_STYLE({ - disableLineHeightCompensation: true, - className: 'border-0 outline-offset-[5px]', - }), - icon: 'mb-[unset]', - }, - }, - rounded: { - full: 'rounded-full', - large: 'rounded-lg', - medium: 'rounded-md', - none: 'rounded-none', - small: 'rounded-sm', - xlarge: 'rounded-xl', - xxlarge: 'rounded-2xl', - xxxlarge: 'rounded-3xl', - }, - variant: { - custom: '', - link: { - base: 'inline-block px-0 py-0 rounded-sm text-primary/50 underline hover:text-primary border-0', - content: 'gap-1.5', - icon: 'h-[1.25cap] w-[1.25cap] mt-[0.25cap]', - }, - primary: 'bg-primary text-white hover:bg-primary/70', - accent: 'bg-accent text-white hover:bg-accent-dark', - delete: - 'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger', - icon: { - base: 'text-primary opacity-80 hover:opacity-100 focus-visible:opacity-100', - wrapper: 'w-full h-full', - content: 'w-full h-full', - extraClickZone: 'w-full h-full', - }, - ghost: - 'text-primary hover:text-primary/80 hover:bg-white focus-visible:text-primary/80 focus-visible:bg-white', - // eslint-disable-next-line @typescript-eslint/naming-convention - 'ghost-fading': - 'text-primary opacity-80 hover:opacity-100 hover:bg-white focus-visible:bg-white', - submit: 'bg-invite text-white opacity-80 hover:opacity-100', - outline: 'border-primary/20 text-primary hover:border-primary hover:bg-primary/5', - }, - iconPosition: { - start: { content: '' }, - end: { content: 'flex-row-reverse' }, - }, - showIconOnHover: { - true: { - icon: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 disabled:opacity-0 aria-disabled:opacity-0 disabled:group-hover:opacity-50 aria-disabled:group-hover:opacity-50', - }, - }, - extraClickZone: { - true: { - extraClickZone: - 'flex relative after:absolute after:cursor-pointer group-disabled:after:cursor-not-allowed', - }, - false: { - extraClickZone: 'after:inset-0', - }, - xxsmall: { - extraClickZone: 'after:inset-[-2px]', - }, - xsmall: { - extraClickZone: 'after:inset-[-4px]', - }, - 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: { - extraClickZone: - 'flex relative after:absolute after:cursor-pointer group-disabled:after:cursor-not-allowed', - wrapper: 'relative block', - loader: 'absolute inset-0 flex items-center justify-center', - content: 'flex items-center', - text: 'inline-flex items-center justify-center gap-1 w-full', - icon: 'h-[1.906cap] w-[1.906cap] flex-none aspect-square flex items-center justify-center', - addonStart: 'flex items-center justify-center macos:-mb-0.5', - addonEnd: 'flex items-center justify-center macos:-mb-0.5', - }, - defaultVariants: { - isActive: 'none', - loading: false, - fullWidth: false, - size: 'medium', - rounded: 'full', - variant: 'primary', - iconPosition: 'start', - showIconOnHover: false, - isDisabled: false, - extraClickZone: true, - }, - compoundVariants: [ - { isFocused: true, iconOnly: true, class: 'focus-visible:outline-offset-[3px]' }, - - { size: 'custom', iconOnly: true, class: { icon: 'w-full h-full' } }, - { size: 'xxsmall', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-2.5 h-2.5' } }, - { size: 'xsmall', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-3 h-3' } }, - { size: 'small', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-3.5 h-3.5' } }, - { size: 'medium', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-4 h-4' } }, - { size: 'large', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-5 h-5' } }, - { size: 'hero', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-12 h-12' } }, - - { size: 'xsmall', class: { addonStart: '-ml-[3.5px]', addonEnd: '-mr-[3.5px]' } }, - { size: 'xxsmall', class: { addonStart: '-ml-[2.5px]', addonEnd: '-mr-[2.5px]' } }, - - { variant: 'icon', class: { base: 'flex-none' } }, - { variant: 'icon', isDisabled: true, class: { base: 'opacity-50 cursor-not-allowed' } }, - - { variant: 'link', isFocused: true, class: 'focus-visible:outline-offset-1' }, - { variant: 'link', size: 'xxsmall', class: 'font-medium' }, - { variant: 'link', size: 'xsmall', class: 'font-medium' }, - { variant: 'link', size: 'small', class: 'font-medium' }, - { variant: 'link', size: 'medium', class: 'font-medium' }, - { variant: 'link', size: 'large', class: 'font-medium' }, - { variant: 'link', size: 'hero', class: 'font-medium' }, - - { variant: 'icon', isDisabled: true, class: 'opacity-50' }, - ], -}) +import { ButtonGroup, ButtonGroupJoin } from './ButtonGroup' +import { useJoinedButtonPrivateContext, useMergedButtonStyles } from './shared' +import type { ButtonProps } from './types' +import { BUTTON_STYLES } from './variants' const ICON_LOADER_DELAY = 150 /** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */ // Manually casting types to make TS infer the final type correctly (e.g. RenderProps in icon) -export const Button: (props: ButtonProps & { ref?: ForwardedRef }) => ReactNode = - memo( - forwardRef(function Button(props: ButtonProps, ref: ForwardedRef) { - const { - className, - contentClassName, - children, - variant, - icon, - loading = false, - isActive, - showIconOnHover, - iconPosition, - size, - fullWidth, - rounded, - tooltip, - tooltipPlacement, - testId, - loaderPosition = 'full', - extraClickZone: extraClickZoneProp, - onPress = () => {}, - variants = BUTTON_STYLES, - addonStart, - addonEnd, - ...ariaProps - } = props - - const [implicitlyLoading, setImplicitlyLoading] = useState(false) - - const contentRef = useRef(null) - const loaderRef = useRef(null) - - const isLink = ariaProps.href != null - - const Tag = isLink ? aria.Link : aria.Button - - const goodDefaults = { - ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), - 'data-testid': testId, - } +// eslint-disable-next-line no-restricted-syntax +export const Button = memo( + forwardRef(function Button(props: ButtonProps, ref: ForwardedRef) { + props = useMergedButtonStyles(props) + const { + className, + contentClassName, + children, + variant, + icon, + loading, + isActive, + showIconOnHover, + iconPosition, + size, + fullWidth, + rounded, + tooltip, + tooltipPlacement, + testId, + loaderPosition = 'full', + extraClickZone: extraClickZoneProp, + onPress = () => {}, + variants = BUTTON_STYLES, + addonStart, + addonEnd, + hideLoader = false, + ...ariaProps + } = props + + const { position, isJoined } = useJoinedButtonPrivateContext() + + const [implicitlyLoading, setImplicitlyLoading] = useState(false) + + const contentRef = useRef(null) + const loaderRef = useRef(null) + + const isLink = ariaProps.href != null + + const Tag = isLink ? aria.Link : aria.Button + + const goodDefaults = { + ...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }), + 'data-testid': testId, + } - const isIconOnly = (children == null || children === '' || children === false) && icon != null + const isIconOnly = (children == null || children === '' || children === false) && icon != null - const shouldShowTooltip = (() => { - if (tooltip === false) { - return false - } else if (isIconOnly) { - return true - } else { - return tooltip != null - } - })() - - const tooltipElement = shouldShowTooltip ? (tooltip ?? ariaProps['aria-label']) : null + const shouldShowTooltip = (() => { + if (tooltip === false) { + return false + } else if (isIconOnly) { + return true + } else { + return tooltip != null + } + })() - const isLoading = loading || implicitlyLoading - const isDisabled = props.isDisabled ?? isLoading - const shouldUseVisualTooltip = shouldShowTooltip && isDisabled - const extraClickZone = extraClickZoneProp ?? variant === 'icon' + const tooltipElement = shouldShowTooltip ? (tooltip ?? ariaProps['aria-label']) : null - useLayoutEffect(() => { - const delay = ICON_LOADER_DELAY + const isLoading = (() => { + if (typeof loading === 'boolean') { + return loading + } - if (isLoading) { - const loaderAnimation = loaderRef.current?.animate( - [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], - { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, + return implicitlyLoading + })() + + const isDisabled = props.isDisabled ?? isLoading + const shouldUseVisualTooltip = shouldShowTooltip && isDisabled + const extraClickZone = extraClickZoneProp ?? variant === 'icon' + + useLayoutEffect(() => { + const delay = ICON_LOADER_DELAY + + if (isLoading) { + const loaderAnimation = loaderRef.current?.animate( + [{ opacity: 0 }, { opacity: 0, offset: 1 }, { opacity: 1 }], + { duration: delay, easing: 'linear', delay: 0, fill: 'forwards' }, + ) + const contentAnimation = + loaderPosition !== 'full' ? null : ( + contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 0, + easing: 'linear', + delay, + fill: 'forwards', + }) ) - const contentAnimation = - loaderPosition !== 'full' ? null : ( - contentRef.current?.animate([{ opacity: 1 }, { opacity: 0 }], { - duration: 0, - easing: 'linear', - delay, - fill: 'forwards', - }) - ) - - return () => { - loaderAnimation?.cancel() - contentAnimation?.cancel() - } - } else { - return () => {} + + return () => { + loaderAnimation?.cancel() + contentAnimation?.cancel() } - }, [isLoading, loaderPosition]) + } else { + return () => {} + } + }, [isLoading, loaderPosition]) - const handlePress = useEventCallback((event: aria.PressEvent): void => { - if (!isDisabled) { - const result = onPress?.(event) + const handlePress = useEventCallback((event: aria.PressEvent): void => { + if (!isDisabled) { + const result = onPress?.(event) - if (result instanceof Promise) { - setImplicitlyLoading(true) + if (result instanceof Promise) { + setImplicitlyLoading(true) - void result.finally(() => { - setImplicitlyLoading(false) - }) - } + void result.finally(() => { + setImplicitlyLoading(false) + }) } - }) - - const styles = variants({ - isDisabled, - isActive, - loading: isLoading, - fullWidth, - size, - rounded, - variant, - iconPosition, - showIconOnHover, - extraClickZone, - iconOnly: isIconOnly, - }) - - const { tooltip: visualTooltip, targetProps } = useVisualTooltip({ - targetRef: contentRef, - children: tooltipElement, - isDisabled: !shouldUseVisualTooltip, - ...(tooltipPlacement && { overlayPositionProps: { placement: tooltipPlacement } }), - }) - - const button = ( - ()(goodDefaults, ariaProps, { - isDisabled, - // 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: (e) => { - if (!isDisabled) { - handlePress(e) - } - }, - className: aria.composeRenderProps(className, (classNames, states) => - styles.base({ className: classNames, ...states }), - ), - })} - > - {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( - - - ()(goodDefaults, ariaProps, { + isPending: isLoading, + isDisabled, + // 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: (e) => { + if (!isDisabled) { + handlePress(e) + } + }, + className: aria.composeRenderProps(className, (classNames, states) => + styles.base({ className: classNames, ...states }), + ), + })} + > + {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => { + const shouldShowOverlayLoader = () => { + if (hideLoader) { + return false + } + + return isLoading && loaderPosition === 'full' + } + + return ( + <> + + - {/* @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} - + + {/* @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} + + + + {shouldShowOverlayLoader() && ( + + + + )} + + {shouldShowTooltip && visualTooltip} - {isLoading && loaderPosition === 'full' && ( - - - - )} + {shouldDisplayBorder &&
} + + ) + }} + + ) - {shouldShowTooltip && visualTooltip} - - )} - - ) + if (tooltipElement == null) { + return button + } - if (tooltipElement == null) { - return button - } + return ( + + {button} - return ( - - {button} + + {tooltipElement} + + + ) + }), +) as unknown as ((props: ButtonProps & { ref?: ForwardedRef }) => ReactNode) & { + // eslint-disable-next-line @typescript-eslint/naming-convention + Group: typeof ButtonGroup + // eslint-disable-next-line @typescript-eslint/naming-convention + GroupJoin: typeof ButtonGroupJoin +} - - {tooltipElement} - - - ) - }), - ) +Button.Group = ButtonGroup +Button.GroupJoin = ButtonGroupJoin /** * Props for {@link ButtonContent}. */ interface ButtonContentProps { + readonly hideLoader: boolean readonly isIconOnly: boolean readonly isLoading: boolean readonly loaderPosition: 'full' | 'icon' @@ -545,15 +288,30 @@ function hasAddon(addon: ButtonContentProps['addonEnd']): boolean { */ // eslint-disable-next-line no-restricted-syntax const ButtonContent = memo(function ButtonContent(props: ButtonContentProps) { - const { isIconOnly, isLoading, loaderPosition, icon, styles, children, addonStart, addonEnd } = - props + const { + isIconOnly, + isLoading, + loaderPosition, + icon, + styles, + children, + addonStart, + addonEnd, + hideLoader, + } = props // Icon only button if (isIconOnly) { return ( {hasAddon(addonStart) &&
{addonStart}
} - + {hasAddon(addonEnd) &&
{addonEnd}
}
) @@ -563,7 +321,13 @@ const ButtonContent = memo(function ButtonContent(props: ButtonContentProps) { return ( <> {hasAddon(addonStart) &&
{addonStart}
} - + {children} {hasAddon(addonEnd) &&
{addonEnd}
} @@ -578,13 +342,14 @@ interface IconProps { readonly loaderPosition: 'full' | 'icon' readonly icon: ReactElement | string | null | undefined readonly styles: ReturnType + readonly hideLoader: boolean } /** * Renders an icon for a button. */ const Icon = memo(function Icon(props: IconProps) { - const { isLoading, loaderPosition, icon, styles } = props + const { isLoading, loaderPosition, icon, styles, hideLoader } = props const [loaderIsVisible, setLoaderIsVisible] = useState(false) @@ -602,7 +367,13 @@ const Icon = memo(function Icon(props: IconProps) { } }, [isLoading, loaderPosition]) - const shouldShowLoader = isLoading && loaderPosition === 'icon' && loaderIsVisible + const shouldShowLoader = (() => { + if (hideLoader) { + return false + } + + return isLoading && loaderPosition === 'icon' && loaderIsVisible + })() if (icon == null && !shouldShowLoader) { return null diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx index 11bb5d822aca..04e9b71f690e 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx @@ -1,31 +1,32 @@ /** @file A group of buttons. */ -import * as React from 'react' +import { forwardRef, type PropsWithChildren } from 'react' +import flattenChildren from 'react-keyed-flatten-children' -import * as twv from '#/utilities/tailwindVariants' +import { tv, type VariantProps } from '#/utilities/tailwindVariants' +import { IS_DEV_MODE } from 'enso-common/src/detect' +import invariant from 'tiny-invariant' +import type { TestIdProps } from '../types' +import { + ButtonGroupProvider, + JoinedButtonPrivateContextProvider, + ResetButtonGroupContext, +} from './shared' +import type { ButtonGroupSharedButtonProps, PrivateJoinedButtonPosition } from './types' -// ================= -// === Constants === -// ================= - -const STYLES = twv.tv({ +const STYLES = tv({ base: 'flex flex-1 shrink-0 max-h-max', variants: { wrap: { true: 'flex-wrap' }, direction: { column: 'flex-col', row: 'flex-row' }, - width: { - auto: 'w-auto', - full: 'w-full', - min: 'w-min', - max: 'w-max', - }, + width: { auto: 'w-auto', full: 'w-full', min: 'w-min', max: 'w-max' }, gap: { custom: '', + joined: 'gap-0', large: 'gap-3.5', medium: 'gap-2', small: 'gap-1.5', xsmall: 'gap-1', xxsmall: 'gap-0.5', - none: 'gap-0', }, align: { start: 'justify-start', @@ -41,6 +42,12 @@ const STYLES = twv.tv({ end: 'items-end', }, }, + defaultVariants: { + direction: 'row', + gap: 'medium', + wrap: false, + width: 'full', + }, compoundVariants: [ { direction: 'column', align: 'start', class: 'items-start' }, { direction: 'column', align: 'center', class: 'items-center' }, @@ -56,43 +63,96 @@ const STYLES = twv.tv({ // =================== /** Props for a {@link ButtonGroup}. */ -interface ButtonGroupProps extends React.PropsWithChildren, twv.VariantProps { +interface ButtonGroupProps + extends React.PropsWithChildren, + VariantProps, + TestIdProps { readonly className?: string | undefined + readonly buttonVariants?: ButtonGroupSharedButtonProps } /** A group of buttons. */ -export const ButtonGroup = React.forwardRef(function ButtonGroup( +// eslint-disable-next-line no-restricted-syntax +export const ButtonGroup = forwardRef(function ButtonGroup( props: ButtonGroupProps, ref: React.ForwardedRef, ) { const { children, className, - gap = 'medium', - wrap = false, - direction = 'row', - width = 'full', + gap, + wrap, + direction, + width, align, variants = STYLES, verticalAlign, + buttonVariants = {}, + testId = 'button-group', ...passthrough } = props + const isJoin = gap === 'joined' + + if (IS_DEV_MODE) { + const isColumnAndJoined = direction === 'column' && isJoin + invariant( + !isColumnAndJoined, + 'ButtonGroup: Joined mode is only supported for row direction, please implement column joined mode', + ) + } + return (
- {children} + + + {isJoin ? + {children} + : children} + +
) }) + +/** + * A wrapper for a button group that joins the buttons together. + * Adds custom styles to the buttons. + */ +function JoinedButtons(props: PropsWithChildren) { + const { children } = props + + return flattenChildren(children).map((child, index, array) => { + if (array.length === 1) { + return <>{child} + } + + let position: PrivateJoinedButtonPosition = 'middle' + + if (index === 0) { + position = 'first' + } + + if (index === array.length - 1) { + position = 'last' + } + + return ( + + {child} + + ) + }) +} + +/** + * A button group that joins the buttons together. + */ +export function ButtonGroupJoin(props: ButtonGroupProps) { + return +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx index 4e3258921ae6..120d13d2dbe5 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/CloseButton.tsx @@ -1,14 +1,11 @@ /** @file A button for closing a modal. */ import DismissIcon from '#/assets/dismiss.svg' -import { Button, type ButtonProps } from '#/components/AriaComponents/Button' import { useText } from '#/providers/TextProvider' import { twMerge } from '#/utilities/tailwindMerge' import { isOnMacOS } from 'enso-common/src/detect' import { memo } from 'react' - -// =================== -// === CloseButton === -// =================== +import { Button } from './Button' +import type { ButtonProps } from './types' /** Props for a {@link CloseButton}. */ export type CloseButtonProps = Omit diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/CopyButton.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/CopyButton.tsx index 5a35c0032923..e2567b6837a7 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/CopyButton.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/CopyButton.tsx @@ -9,14 +9,15 @@ import * as copyHook from '#/hooks/copyHooks' import * as textProvider from '#/providers/TextProvider' -import * as button from './Button' +import { Button } from './Button' +import type { ButtonProps } from './types' // ================== // === CopyButton === // ================== /** Props for a {@link CopyButton}. */ -export interface CopyButtonProps extends Omit { +export interface CopyButtonProps extends Omit { /** The text to copy to the clipboard. */ readonly copyText: string /** @@ -58,7 +59,7 @@ export function CopyButton(props: CopyButtonProps) { : null return ( - ({}) + +/** + * Provider for a button group context + */ +export function ButtonGroupProvider(props: ButtonGroupContextType & PropsWithChildren) { + const { children, ...rest } = props + + return {children} +} + +const EMPTY_CONTEXT: ButtonGroupContextType = {} + +/** + * A wrapper that resets the button group context + */ +export function ResetButtonGroupContext(props: PropsWithChildren) { + const { children } = props + + return ( + + + {children} + + + ) +} + +/** + * Hook to use the button group context + */ +export function useButtonGroupContext() { + return useContext(ButtonGroupContext) +} + +/** + * Hook to merge button styles with the button group context + */ +export function useMergedButtonStyles(props: Props) { + const context = useButtonGroupContext() + + return { ...context, ...props } +} + +const JoinedButtonPrivateContext = createContext({ + isJoined: false, + position: undefined, +}) + +/** + * A provider for the joined button private context + */ +export function JoinedButtonPrivateContextProvider( + props: PrivateJoinedButtonProps & PropsWithChildren, +) { + const { children, isJoined, position } = props + + return ( + + {children} + + ) +} + +/** + * Hook to get the joined button private context + */ +export function useJoinedButtonPrivateContext() { + return useContext(JoinedButtonPrivateContext) +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/types.ts b/app/gui/src/dashboard/components/AriaComponents/Button/types.ts new file mode 100644 index 000000000000..8488cef0038c --- /dev/null +++ b/app/gui/src/dashboard/components/AriaComponents/Button/types.ts @@ -0,0 +1,120 @@ +/** + * @file + * + * Reusable types for the Button component + */ +import type * as aria from '#/components/aria' +import type { ExtractFunction } from '#/utilities/tailwindVariants' +import type { ReactElement, ReactNode } from 'react' +import type { BUTTON_STYLES, ButtonVariants } from './variants' + +/** + * Position of a joined button + */ +export type PrivateJoinedButtonPosition = ButtonVariants['position'] + +/** + * Whether the button is joined + */ +export type PrivateJoinedButton = ButtonVariants['isJoined'] + +/** + * Props for a joined button unlike other button props, + */ +export interface PrivateJoinedButtonProps { + readonly position: PrivateJoinedButtonPosition + readonly isJoined: NonNullable +} + +/** + * Render props for a button. + */ +export interface ButtonRenderProps extends aria.ButtonRenderProps { + readonly isLoading: boolean +} + +/** + * Render props for a link. + */ +export interface LinkRenderProps extends aria.LinkRenderProps { + readonly isLoading: boolean +} + +/** Props for a Button. */ +export type ButtonProps = + | (BaseButtonProps & + Omit & + PropsWithoutHref) + | (BaseButtonProps & + Omit & + PropsWithHref) + +/** Props for a button with an href. */ +interface PropsWithHref { + readonly href: string +} + +/** Props for a button without an href. */ +interface PropsWithoutHref { + readonly href?: never +} + +/** Base props for a button. */ +export interface BaseButtonProps + extends Omit { + /** If `true`, the loader will not be shown. */ + readonly hideLoader?: boolean + /** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */ + readonly tooltip?: ReactElement | string | false | null + readonly tooltipPlacement?: aria.Placement + /** The icon to display in the button */ + readonly icon?: + | ReactElement + | string + | ((render: Render) => ReactElement | string | null) + | null + | undefined + /** When `true`, icon will be shown only when hovered. */ + readonly showIconOnHover?: boolean + /** + * Handler that is called when the press is released over the target. + * If the handler returns a promise, the button will be in a loading state until the promise resolves. + */ + readonly onPress?: ((event: aria.PressEvent) => Promise | void) | null | undefined + readonly contentClassName?: string + readonly testId?: string + readonly isDisabled?: boolean + readonly formnovalidate?: boolean + /** + * 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. + */ + readonly loaderPosition?: 'full' | 'icon' + readonly styles?: ExtractFunction | undefined + + readonly children?: ReactNode | ((render: Render) => ReactNode) + + readonly addonStart?: + | ReactElement + | string + | false + | ((render: Render) => ReactElement | string | null) + | null + | undefined + readonly addonEnd?: + | ReactElement + | string + | false + | ((render: Render) => ReactElement | string | null) + | null + | undefined +} + +/** + * Props that are shared between buttons in a button group. + */ +export interface ButtonGroupSharedButtonProps extends ButtonVariants { + readonly isDisabled?: boolean + readonly isLoading?: boolean + readonly loaderPosition?: 'full' | 'icon' +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/variants.ts b/app/gui/src/dashboard/components/AriaComponents/Button/variants.ts new file mode 100644 index 000000000000..543b6e00fa3f --- /dev/null +++ b/app/gui/src/dashboard/components/AriaComponents/Button/variants.ts @@ -0,0 +1,404 @@ +/** + * @file + * + * Variants for a button + */ +import { tv, type VariantProps } from '#/utilities/tailwindVariants' +import { TEXT_STYLE } from '../Text' + +/** + * Variants for a button + */ +export type ButtonVariants = VariantProps + +export const BUTTON_STYLES = tv({ + base: [ + 'group', + 'relative', + // basic outline + 'outline-offset-[1px] outline-transparent', + // buttons always have borders + // so keep them in mind when setting paddings + 'border-0.5 border-transparent', + // button reset styles + 'whitespace-nowrap cursor-pointer select-none appearance-none', + // Align the content by the center + 'text-center items-center justify-center', + // animations + 'transition-[opacity,outline-offset,background,border-color] duration-150 ease-in-out', + ], + variants: { + isDisabled: { + true: 'opacity-50 cursor-not-allowed', + }, + isFocused: { + true: 'focus:outline-none focus-visible:outline-2 focus-visible:outline-black focus-visible:outline-offset-[-2px]', + }, + isActive: { + none: '', + false: + 'disabled:opacity-30 [&.disabled]:opacity-30 disabled:cursor-not-allowed [&.disabled]:cursor-not-allowed opacity-50 hover:opacity-75', + true: 'opacity-100 disabled:opacity-100 [&.disabled]:opacity-100 hover:opacity-100 disabled:cursor-default [&.disabled]:cursor-default', + }, + isPressed: { + true: '', + }, + isJoined: { + // Mostly defined in the compoundVariants + true: '', + false: '', + }, + position: { + // Mostly defined in the compoundVariants + first: '', + last: '', + middle: '', + }, + loading: { true: { base: 'cursor-wait' } }, + fullWidth: { true: 'w-full' }, + size: { + custom: { base: '', extraClickZone: '', icon: 'h-full w-unset min-w-[1.906cap]' }, + hero: { + base: TEXT_STYLE({ + variant: 'subtitle', + color: 'custom', + weight: 'semibold', + className: 'flex h-16 px-[24px]', + }), + text: 'mx-[1.5em]', + }, + large: { + base: TEXT_STYLE({ + variant: 'body', + color: 'custom', + weight: 'semibold', + className: 'flex h-9 px-[11px]', + }), + content: 'gap-2', + icon: '-mb-0.5 h-4 w-4', + extraClickZone: 'after:inset-[-6px]', + }, + medium: { + base: TEXT_STYLE({ + variant: 'body', + color: 'custom', + weight: 'semibold', + className: 'flex h-8 px-[7px]', + }), + icon: '-mb-0.5 h-4 w-4', + content: 'gap-2', + extraClickZone: 'after:inset-[-8px]', + }, + small: { + base: TEXT_STYLE({ + variant: 'body', + color: 'custom', + weight: 'medium', + className: 'flex h-7 px-[5px]', + }), + icon: '-mb-0.5 h-3.5 w-3.5', + content: 'gap-1', + extraClickZone: 'after:inset-[-10px]', + }, + xsmall: { + base: TEXT_STYLE({ + variant: 'body', + color: 'custom', + weight: 'medium', + className: 'flex h-6 px-[5px]', + }), + icon: '-mb-0.5 h-3 w-3', + content: 'gap-1', + extraClickZone: 'after:inset-[-12px]', + }, + xxsmall: { + base: TEXT_STYLE({ + variant: 'body', + color: 'custom', + className: 'flex h-5 px-[3px] leading-[16px]', + }), + content: 'gap-0.5', + icon: 'mb-[-0.1cap]', + extraClickZone: 'after:inset-[-12px]', + }, + }, + iconOnly: { + // Specified in the compoundVariants + true: '', + }, + rounded: { + full: 'rounded-full', + large: 'rounded-lg', + medium: 'rounded-md', + none: 'rounded-none', + small: 'rounded-sm', + xlarge: 'rounded-xl', + xxlarge: 'rounded-2xl', + xxxlarge: 'rounded-3xl', + }, + variant: { + custom: '', + link: { + base: 'inline-block px-0 py-0 rounded-sm text-primary/50 underline hover:text-primary border-0', + content: 'gap-1.5', + icon: 'h-[1.25cap] w-[1.25cap] mt-[0.25cap]', + }, + primary: 'bg-primary text-white hover:bg-primary/70', + accent: 'bg-accent text-white hover:bg-accent-dark', + delete: + 'bg-danger/80 hover:bg-danger text-white focus-visible:outline-danger focus-visible:bg-danger', + icon: { + base: 'text-primary opacity-80 hover:opacity-100 focus-visible:opacity-100', + wrapper: 'w-full h-full', + content: 'w-full h-full', + extraClickZone: 'w-full h-full', + }, + ghost: + 'text-primary hover:text-primary/80 hover:bg-white focus-visible:text-primary/80 focus-visible:bg-white', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'ghost-fading': + 'text-primary opacity-80 hover:opacity-100 hover:bg-white focus-visible:bg-white', + submit: 'bg-invite text-white opacity-80 hover:opacity-100', + outline: 'border-primary/20 text-primary hover:border-primary hover:bg-primary/5', + }, + iconPosition: { + start: { content: '' }, + end: { content: 'flex-row-reverse' }, + }, + showIconOnHover: { + true: { + icon: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 disabled:opacity-0 aria-disabled:opacity-0 disabled:group-hover:opacity-50 aria-disabled:group-hover:opacity-50', + }, + }, + extraClickZone: { + true: { + extraClickZone: + 'flex relative after:absolute after:cursor-pointer group-disabled:after:cursor-not-allowed', + }, + false: { + extraClickZone: 'after:inset-0', + }, + xxsmall: { + extraClickZone: 'after:inset-[-2px]', + }, + xsmall: { + extraClickZone: 'after:inset-[-4px]', + }, + 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: { + extraClickZone: + 'flex relative after:absolute after:cursor-pointer group-disabled:after:cursor-not-allowed', + wrapper: 'relative block', + loader: 'absolute inset-0 flex items-center justify-center', + content: 'flex items-center', + text: 'inline-flex items-center justify-center gap-1 w-full', + icon: 'h-[1.906cap] w-[1.906cap] flex-none aspect-square flex items-center justify-center', + addonStart: 'flex items-center justify-center macos:-mb-0.5', + addonEnd: 'flex items-center justify-center macos:-mb-0.5', + joinSeparator: 'absolute z-1 -right-[0.5px] h-[80%] w-[0.5px] bg-current rounded-full', + }, + defaultVariants: { + isActive: 'none', + loading: false, + fullWidth: false, + size: 'medium', + rounded: 'full', + variant: 'primary', + iconPosition: 'start', + showIconOnHover: false, + isDisabled: false, + extraClickZone: true, + }, + compoundVariants: [ + { isFocused: true, iconOnly: true, class: 'focus-visible:outline-offset-[3px]' }, + + { + size: 'custom', + iconOnly: true, + isJoined: false, + class: { + base: TEXT_STYLE({ + disableLineHeightCompensation: true, + className: 'border-0 outline-offset-[5px] p-0 rounded-full', + }), + icon: 'w-full h-full mb-[unset]', + }, + }, + { + size: 'xxsmall', + iconOnly: true, + isJoined: false, + class: { + base: TEXT_STYLE({ + disableLineHeightCompensation: true, + className: 'border-0 outline-offset-[5px] p-0 rounded-full w-2.5 h-2.5', + }), + icon: 'w-[unset] h-[unset] mb-[unset]', + }, + }, + { + size: 'xsmall', + iconOnly: true, + isJoined: false, + class: { + base: TEXT_STYLE({ + disableLineHeightCompensation: true, + className: 'border-0 outline-offset-[5px] p-0 rounded-full w-3 h-3', + }), + icon: 'w-[unset] h-[unset] mb-[unset]', + }, + }, + { + size: 'small', + iconOnly: true, + isJoined: false, + class: { + base: TEXT_STYLE({ + disableLineHeightCompensation: true, + className: 'border-0 outline-offset-[4px] p-0 rounded-full w-3.5 h-3.5', + }), + icon: 'w-[unset] h-[unset] mb-[unset]', + }, + }, + { + size: 'medium', + iconOnly: true, + isJoined: false, + class: { + base: TEXT_STYLE({ + disableLineHeightCompensation: true, + className: 'border-0 outline-offset-[4px] p-0 rounded-full w-4 h-4', + }), + icon: 'w-[unset] h-[unset] mb-[unset]', + }, + }, + { + size: 'large', + iconOnly: true, + isJoined: false, + class: { + base: TEXT_STYLE({ + disableLineHeightCompensation: true, + className: 'border-0 outline-offset-[4px] p-0 rounded-full w-5 h-5', + }), + icon: 'w-[unset] h-[unset] mb-[unset]', + }, + }, + { + size: 'hero', + iconOnly: true, + isJoined: false, + class: { + base: TEXT_STYLE({ + disableLineHeightCompensation: true, + className: 'border-0 outline-offset-[5px] p-0 rounded-full w-12 h-12', + }), + icon: 'w-[unset] h-[unset] mb-[unset]', + }, + }, + + { size: 'xsmall', class: { addonStart: '-ml-[3.5px]', addonEnd: '-mr-[3.5px]' } }, + { size: 'xxsmall', class: { addonStart: '-ml-[2.5px]', addonEnd: '-mr-[2.5px]' } }, + + { variant: 'icon', class: { base: 'flex-none' } }, + { variant: 'icon', isDisabled: true, class: { base: 'opacity-50 cursor-not-allowed' } }, + + { variant: 'link', isFocused: true, class: 'focus-visible:outline-offset-1' }, + { variant: 'link', size: 'xxsmall', class: 'font-medium' }, + { variant: 'link', size: 'xsmall', class: 'font-medium' }, + { variant: 'link', size: 'small', class: 'font-medium' }, + { variant: 'link', size: 'medium', class: 'font-medium' }, + { variant: 'link', size: 'large', class: 'font-medium' }, + { variant: 'link', size: 'hero', class: 'font-medium' }, + + { variant: 'icon', isDisabled: true, class: 'opacity-50' }, + + { isJoined: true, position: 'first', class: { base: 'rounded-r-none' } }, + { isJoined: true, position: 'last', class: { base: 'rounded-l-none' } }, + { isJoined: true, position: 'middle', class: { base: 'rounded-none' } }, + + { isJoined: true, variant: 'link', class: { joinSeparator: 'hidden' } }, + + { isJoined: true, variant: 'icon', class: { extraClickZone: 'items-center' } }, + + { + isJoined: true, + position: ['first', 'middle'], + variant: 'primary', + class: { + joinSeparator: 'text-background', + }, + }, + { + isJoined: true, + position: ['first', 'middle'], + variant: 'accent', + class: { + joinSeparator: 'text-background', + }, + }, + + { + isJoined: true, + position: ['first', 'middle'], + variant: 'delete', + class: { + joinSeparator: 'text-background', + }, + }, + + { + isJoined: true, + position: ['first', 'middle'], + variant: 'ghost', + class: { + joinSeparator: 'text-primary/20', + }, + }, + { + isJoined: true, + position: ['first', 'middle'], + variant: 'ghost-fading', + class: { + joinSeparator: 'text-primary/20', + }, + }, + + { + isJoined: true, + position: ['first', 'middle'], + variant: 'outline', + class: { + base: 'mr-[-0.5px] border-r-primary/10', + joinSeparator: 'hidden', + }, + }, + { + isJoined: true, + position: ['first', 'middle'], + variant: 'submit', + class: { + joinSeparator: 'text-background', + }, + }, + { + isJoined: true, + position: ['first', 'middle'], + variant: 'icon', + class: { joinSeparator: 'text-primary/20 h-[50%]' }, + }, + ], +}) diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogDismiss.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogDismiss.tsx index a96480a6f6c8..4de948a203c5 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogDismiss.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/DialogDismiss.tsx @@ -1,8 +1,9 @@ /** @file A button to close a dialog without submitting it. */ import type { JSX } from 'react' -import { Button, useDialogContext, type ButtonProps } from '#/components/AriaComponents' import { useText } from '#/providers/TextProvider' +import { Button, type ButtonProps } from '../Button' +import { useDialogContext } from './DialogProvider' /** Additional props for the Cancel component. */ interface DialogDismissBaseProps { diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx index 1a4abbfc9d04..2d2efc28cf87 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Popover.tsx @@ -13,6 +13,7 @@ import * as suspense from '#/components/Suspense' import * as twv from '#/utilities/tailwindVariants' import { useEventCallback } from '#/hooks/eventCallbackHooks' +import { ResetButtonGroupContext } from '../Button' import * as dialogProvider from './DialogProvider' import * as dialogStackProvider from './DialogStackProvider' import { DialogTrigger } from './DialogTrigger' @@ -34,8 +35,8 @@ export const POPOVER_STYLES = twv.tv({ variants: { variant: { custom: { dialog: '' }, - primary: { dialog: variants.DIALOG_BACKGROUND({ variant: 'light' }) }, - inverted: { dialog: variants.DIALOG_BACKGROUND({ variant: 'dark' }) }, + light: { base: variants.DIALOG_BACKGROUND({ variant: 'light' }) }, + dark: { base: variants.DIALOG_BACKGROUND({ variant: 'dark' }) }, }, isEntering: { true: 'animate-in fade-in placement-bottom:slide-in-from-top-1 placement-top:slide-in-from-bottom-1 placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 ease-out duration-200', @@ -44,13 +45,16 @@ export const POPOVER_STYLES = twv.tv({ true: 'animate-out fade-out placement-bottom:slide-out-to-top-1 placement-top:slide-out-to-bottom-1 placement-left:slide-out-to-right-1 placement-right:slide-out-to-left-1 ease-in duration-150', }, size: { - auto: { base: 'w-[unset]', dialog: 'p-2.5 px-0' }, - xxsmall: { base: 'max-w-[206px]', dialog: 'p-2 px-0' }, - xsmall: { base: 'max-w-xs', dialog: 'p-2.5 px-0' }, - small: { base: 'max-w-sm', dialog: 'py-3 px-2' }, - medium: { base: 'max-w-md', dialog: 'p-3.5 px-2.5' }, - large: { base: 'max-w-lg', dialog: 'px-4 py-3' }, - hero: { base: 'max-w-xl', dialog: 'px-6 py-5' }, + custom: { base: '', dialog: '' }, + auto: { base: 'w-[unset]', dialog: 'p-2.5' }, + xxsmall: { base: 'max-w-[206px]', dialog: 'p-1.5' }, + xsmall: { base: 'max-w-xs', dialog: 'p-3' }, + small: { base: 'max-w-sm', dialog: 'px-4 p-3' }, + medium: { base: 'max-w-md', dialog: 'px-5 p-3.5' }, + large: { base: 'max-w-lg', dialog: 'p-4' }, + xlarge: { base: 'max-w-xl', dialog: 'p-6' }, + xxlarge: { base: 'max-w-2xl', dialog: 'px-8 py-7' }, + xxxlarge: { base: 'max-w-3xl', dialog: 'px-10 py-9' }, }, rounded: { none: { base: 'rounded-none', dialog: 'rounded-none' }, @@ -64,9 +68,9 @@ export const POPOVER_STYLES = twv.tv({ }, }, slots: { - dialog: 'flex-auto overflow-y-auto [scrollbar-gutter:stable_both-edges] max-h-[inherit]', + dialog: 'flex-auto overflow-y-auto max-h-[inherit]', }, - defaultVariants: { rounded: 'xxxlarge', size: 'small', variant: 'primary' }, + defaultVariants: { rounded: 'xxlarge', size: 'small', variant: 'light' }, }) const SUSPENSE_LOADER_PROPS = { minHeight: 'h32' } as const @@ -149,12 +153,10 @@ function PopoverContent(props: PopoverContentProps) { const dialogRef = React.useRef(null) const dialogId = aria.useId() - // We use as here to make the types more accurate // eslint-disable-next-line no-restricted-syntax const contextState = React.useContext( aria.OverlayTriggerStateContext, ) as aria.OverlayTriggerState | null - const dialogContext = React.useContext(aria.DialogContext) // This is safe, because the labelledBy provided by DialogTrigger is always @@ -182,7 +184,7 @@ function PopoverContent(props: PopoverContentProps) { }) return ( - <> +
- +
) } diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx index a139f664e7f2..3d7975c8732a 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/Reset.tsx @@ -1,14 +1,13 @@ /** @file Reset button for forms. */ import * as React from 'react' -import * as ariaComponents from '#/components/AriaComponents' - import { useText } from '#/providers/TextProvider' +import { Button, type ButtonProps } from '../../Button' import * as formContext from './FormProvider' import type * as types from './types' /** Props for the Reset component. */ -export interface ResetProps extends Omit { +export interface ResetProps extends Omit { /** * Connects the reset button to a form. * If not provided, the button will use the nearest form context. @@ -29,14 +28,15 @@ export function Reset(props: ResetProps): React.JSX.Element { testId = 'form-reset-button', children = getText('reset'), onPress, + form, ...buttonProps } = props - const form = formContext.useFormContext(props.form) - const { formState } = form + const formInstance = formContext.useFormContext(form) + const { formState } = formInstance return ( - { // `type="reset"` triggers native HTML reset, which does not work here as it clears inputs // rather than resetting them to default values. - form.reset() + formInstance.reset() return onPress?.(event) }} - {...buttonProps} + /* This is safe because we are passing all props to the button */ + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-explicit-any + {...(buttonProps as any)} /> ) } diff --git a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx index f900bc5cb7b0..ec0d8f313c86 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Tooltip/Tooltip.tsx @@ -3,6 +3,7 @@ import * as aria from '#/components/aria' import { useStrictPortalContext } from '#/components/Portal' import { tv, type VariantProps } from '#/utilities/tailwindVariants' +import { ResetButtonGroupContext } from '../Button' import { DIALOG_BACKGROUND } from '../Dialog' import { TEXT_STYLE } from '../Text' @@ -83,16 +84,18 @@ export function Tooltip(props: TooltipProps) { const root = useStrictPortalContext() return ( - - TOOLTIP_STYLES({ className: classNames, variant, size, rounded, ...values }), - )} - data-ignore-click-outside - {...ariaTooltipProps} - /> + + + TOOLTIP_STYLES({ className: classNames, variant, size, rounded, ...values }), + )} + data-ignore-click-outside + {...ariaTooltipProps} + /> + ) } diff --git a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx index a44d01585d29..484062aa157e 100644 --- a/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/PathColumn.tsx @@ -175,7 +175,7 @@ export default function PathColumn(props: AssetColumnProps) { size="auto" placement="bottom end" crossOffset={14} - variant="primary" + variant="light" className="max-w-lg" >
diff --git a/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue b/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue index 25f3451aafb5..7f0e28d22ba9 100644 --- a/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue +++ b/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue @@ -4,7 +4,7 @@ import { ensoSyntax } from '@/components/CodeEditor/ensoSyntax' import { useEnsoSourceSync } from '@/components/CodeEditor/sync' import { ensoHoverTooltip } from '@/components/CodeEditor/tooltips' import CodeMirrorRoot from '@/components/CodeMirrorRoot.vue' -import VueComponentHost from '@/components/VueComponentHost.vue' +import VueHostRender, { VueHost } from '@/components/VueHostRender.vue' import { useGraphStore } from '@/stores/graph' import { useProjectStore } from '@/stores/project' import { useSuggestionDbStore } from '@/stores/suggestionDatabase' @@ -31,8 +31,6 @@ const projectStore = useProjectStore() const graphStore = useGraphStore() const suggestionDbStore = useSuggestionDbStore() -const vueComponentHost = - useTemplateRef>('vueComponentHost') const editorRoot = useTemplateRef>('editorRoot') const rootElement = computed(() => editorRoot.value?.rootElement) useAutoBlur(rootElement) @@ -42,7 +40,7 @@ const autoindentOnEnter = { run: insertNewlineKeepIndent, } -const vueHost = computed(() => vueComponentHost.value || undefined) +const vueHost = new VueHost() const { editorView, setExtraExtensions } = useCodeMirror(editorRoot, { extensions: [ keymap.of([indentWithTab, autoindentOnEnter]), @@ -55,7 +53,7 @@ const { editorView, setExtraExtensions } = useCodeMirror(editorRoot, { highlightStyle(useCssModule()), ensoHoverTooltip(graphStore, suggestionDbStore, vueHost), ], - vueHost, + vueHost: () => vueHost, }) ;(window as any).__codeEditorApi = testSupport(editorView) const { updateListener, connectModuleListener } = useEnsoSourceSync( @@ -74,7 +72,7 @@ onMounted(() => {