diff --git a/.changeset/big-pugs-deny.md b/.changeset/big-pugs-deny.md new file mode 100644 index 000000000..3e6bdae5c --- /dev/null +++ b/.changeset/big-pugs-deny.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Allow to add actions to Item, ItemButton, and ItemBase. diff --git a/.changeset/dry-carrots-double.md b/.changeset/dry-carrots-double.md new file mode 100644 index 000000000..933a09a3f --- /dev/null +++ b/.changeset/dry-carrots-double.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Add ItemBadge component. diff --git a/.changeset/large-spies-decide.md b/.changeset/large-spies-decide.md new file mode 100644 index 000000000..898544abc --- /dev/null +++ b/.changeset/large-spies-decide.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Add isCard flag to ItemBase component. diff --git a/.changeset/sour-toys-accept.md b/.changeset/sour-toys-accept.md new file mode 100644 index 000000000..5886ade46 --- /dev/null +++ b/.changeset/sour-toys-accept.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix popover height limit for Select and ComboBox. diff --git a/src/components/Item.tsx b/src/components/Item.tsx index f63cedbef..4fb2feae8 100644 --- a/src/components/Item.tsx +++ b/src/components/Item.tsx @@ -1,6 +1,8 @@ import { ReactElement } from 'react'; import { Item, ItemProps } from 'react-stately'; +import { ItemAction } from './actions/ItemAction'; +import { ItemBadge } from './content/ItemBadge'; import { CubeItemBaseProps } from './content/ItemBase/ItemBase'; export interface CubeItemProps @@ -11,6 +13,12 @@ export interface CubeItemProps [key: string]: any; } -const _Item = Item as (props: CubeItemProps) => ReactElement; +const _Item = Object.assign( + Item as (props: CubeItemProps) => ReactElement, + { + Action: ItemAction, + Badge: ItemBadge, + }, +); export { _Item as Item }; diff --git a/src/components/actions/CommandMenu/CommandMenu.stories.tsx b/src/components/actions/CommandMenu/CommandMenu.stories.tsx index a29fc6b16..e204cb393 100644 --- a/src/components/actions/CommandMenu/CommandMenu.stories.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.stories.tsx @@ -1,14 +1,19 @@ import { IconArrowBack, IconArrowForward, + IconBook, + IconBulb, IconClipboard, IconCopy, IconCut, + IconPlus, + IconReload, IconSelect, } from '@tabler/icons-react'; import React, { useState } from 'react'; import { expect, findByRole, userEvent, waitFor, within } from 'storybook/test'; +import { ClearIcon, EditIcon } from '../../../icons'; import { tasty } from '../../../tasty'; import { Card } from '../../content/Card/Card'; import { HotKeys } from '../../content/HotKeys'; @@ -23,6 +28,7 @@ import { useDialogContainer, } from '../../overlays/Dialog'; import { Button } from '../Button'; +import { ItemAction } from '../ItemAction'; import { Menu } from '../Menu/Menu'; import { useAnchoredMenu } from '../use-anchored-menu'; import { useContextMenu } from '../use-context-menu'; @@ -376,6 +382,107 @@ WithSections.args = { width: '20x 50x', }; +export const ItemsWithActions = (props) => { + const handleAction = (key) => { + console.log('CommandMenu action:', key); + }; + + const handleItemAction = (itemKey, actionKey) => { + console.log(`Action "${actionKey}" triggered on item "${itemKey}"`); + }; + + return ( + + } + description="PDF document" + actions={ + <> + } + aria-label="Edit" + onPress={() => handleItemAction('file1', 'edit')} + /> + } + aria-label="Delete" + onPress={() => handleItemAction('file1', 'delete')} + /> + + } + > + Document.pdf + + } + description="Backup file" + actions={ + <> + } + aria-label="Edit" + onPress={() => handleItemAction('file2', 'edit')} + /> + } + aria-label="Delete" + onPress={() => handleItemAction('file2', 'delete')} + /> + + } + > + Backup.zip + + } + description="New file" + actions={ + <> + } + aria-label="Edit" + onPress={() => handleItemAction('file3', 'edit')} + /> + } + aria-label="Delete" + onPress={() => handleItemAction('file3', 'delete')} + /> + + } + > + Project.doc + + } + description="No actions" + > + Item without actions + + + ); +}; + +ItemsWithActions.parameters = { + docs: { + description: { + story: + 'Demonstrates CommandMenu.Item with inline actions. Actions are displayed on the right side of each item and inherit the item type through ItemActionProvider context. Search to filter items and hover to see the actions.', + }, + }, +}; + export const WithMenuTrigger: StoryFn> = (args) => ( diff --git a/src/components/actions/ItemAction/ItemAction.tsx b/src/components/actions/ItemAction/ItemAction.tsx index 8f3ce6586..d6cce77af 100644 --- a/src/components/actions/ItemAction/ItemAction.tsx +++ b/src/components/actions/ItemAction/ItemAction.tsx @@ -1,23 +1,253 @@ import { FocusableRef } from '@react-types/shared'; -import { forwardRef } from 'react'; +import { + ComponentProps, + forwardRef, + HTMLAttributes, + ReactNode, + RefObject, + useMemo, +} from 'react'; +import { + DANGER_CLEAR_STYLES, + DANGER_NEUTRAL_STYLES, + DANGER_PRIMARY_STYLES, + DANGER_SECONDARY_STYLES, + DEFAULT_CLEAR_STYLES, + DEFAULT_NEUTRAL_STYLES, + DEFAULT_PRIMARY_STYLES, + DEFAULT_SECONDARY_STYLES, + ITEM_ACTION_BASE_STYLES, + SPECIAL_CLEAR_STYLES, + SPECIAL_NEUTRAL_STYLES, + SPECIAL_PRIMARY_STYLES, + SPECIAL_SECONDARY_STYLES, + SUCCESS_CLEAR_STYLES, + SUCCESS_NEUTRAL_STYLES, + SUCCESS_PRIMARY_STYLES, + SUCCESS_SECONDARY_STYLES, +} from '../../../data/item-themes'; +import { CheckIcon } from '../../../icons/CheckIcon'; +import { LoadingIcon } from '../../../icons/LoadingIcon'; import { tasty } from '../../../tasty'; -import { Button, CubeButtonProps } from '../Button'; +import { mergeProps } from '../../../utils/react'; +import { TooltipProvider } from '../../overlays/Tooltip/TooltipProvider'; +import { useItemActionContext } from '../ItemActionContext'; +import { CubeUseActionProps, useAction } from '../use-action'; -export interface CubeItemActionProps extends CubeButtonProps { - // All props from Button are inherited +export interface CubeItemActionProps + extends Omit { + icon?: ReactNode | 'checkbox'; + children?: ReactNode; + isLoading?: boolean; + isSelected?: boolean; + type?: 'primary' | 'secondary' | 'neutral' | 'clear' | (string & {}); + theme?: 'default' | 'danger' | 'success' | 'special' | (string & {}); + tooltip?: + | string + | (Omit, 'children'> & { + title?: string; + }); } -export const ItemAction = tasty(Button, { - type: 'neutral', +type ItemActionVariant = + | 'default.primary' + | 'default.secondary' + | 'default.neutral' + | 'default.clear' + | 'danger.primary' + | 'danger.secondary' + | 'danger.neutral' + | 'danger.clear' + | 'success.primary' + | 'success.secondary' + | 'success.neutral' + | 'success.clear' + | 'special.primary' + | 'special.secondary' + | 'special.neutral' + | 'special.clear'; + +const ItemActionElement = tasty({ + qa: 'ItemAction', styles: { - height: '($size - 1x)', - width: '($size - 1x)', - margin: { - '': '0 1bw 0 1bw', - ':last-child & !:first-child': '0 (.5x - 1bw) 0 0', - '!:last-child & :first-child': '0 0 0 (.5x - 1bw)', - ':last-child & :first-child': '0 (.5x - 1bw)', - }, + ...ITEM_ACTION_BASE_STYLES, + reset: 'button', + outline: 0, + outlineOffset: 1, + cursor: { '': 'pointer', disabled: 'default' }, + }, + variants: { + // Default theme + 'default.primary': DEFAULT_PRIMARY_STYLES, + 'default.secondary': DEFAULT_SECONDARY_STYLES, + 'default.neutral': DEFAULT_NEUTRAL_STYLES, + 'default.clear': DEFAULT_CLEAR_STYLES, + + // Danger theme + 'danger.primary': DANGER_PRIMARY_STYLES, + 'danger.secondary': DANGER_SECONDARY_STYLES, + 'danger.neutral': DANGER_NEUTRAL_STYLES, + 'danger.clear': DANGER_CLEAR_STYLES, + + // Success theme + 'success.primary': SUCCESS_PRIMARY_STYLES, + 'success.secondary': SUCCESS_SECONDARY_STYLES, + 'success.neutral': SUCCESS_NEUTRAL_STYLES, + 'success.clear': SUCCESS_CLEAR_STYLES, + + // Special theme + 'special.primary': SPECIAL_PRIMARY_STYLES, + 'special.secondary': SPECIAL_SECONDARY_STYLES, + 'special.neutral': SPECIAL_NEUTRAL_STYLES, + 'special.clear': SPECIAL_CLEAR_STYLES, }, }); + +export const ItemAction = forwardRef(function ItemAction( + allProps: CubeItemActionProps, + ref: FocusableRef, +) { + const { type: contextType, theme: contextTheme } = useItemActionContext(); + + const { + type = contextType ?? 'neutral', + theme = contextTheme ?? 'default', + icon, + children, + isLoading = false, + isSelected = false, + tooltip, + mods, + ...rest + } = allProps; + + // Determine if we should show checkbox + const hasCheckbox = icon === 'checkbox'; + + // Determine final icon (loading takes precedence) + const finalIcon = isLoading ? ( + + ) : hasCheckbox ? ( + + ) : ( + icon + ); + + // Build modifiers + const finalMods = useMemo( + () => ({ + checkbox: hasCheckbox, + selected: isSelected, + loading: isLoading, + 'with-label': !!children, + context: !!contextType, + ...mods, + }), + [hasCheckbox, isSelected, isLoading, children, contextType, mods], + ); + + // Extract aria-label from tooltip if needed + const ariaLabel = useMemo(() => { + if (typeof tooltip === 'string') { + return tooltip; + } + if (typeof tooltip === 'object' && tooltip.title) { + return tooltip.title; + } + return rest['aria-label']; + }, [tooltip, rest]); + + // Call useAction hook + const { actionProps } = useAction( + { + ...rest, + 'aria-label': ariaLabel, + mods: finalMods, + htmlType: 'button', + }, + ref, + ); + + // Set tabIndex when in context + const finalTabIndex = contextType ? -1 : undefined; + + // Determine if we should show tooltip (icon-only buttons) + const showTooltip = !children && tooltip; + + // Extract tooltip content and props + const tooltipContent = useMemo(() => { + if (typeof tooltip === 'string') { + return tooltip; + } + if (typeof tooltip === 'object' && tooltip.title) { + return tooltip.title; + } + return undefined; + }, [tooltip]); + + const tooltipProps = useMemo(() => { + if (typeof tooltip === 'object') { + const { title, ...rest } = tooltip; + return rest; + } + return {}; + }, [tooltip]); + + const finalType = useMemo(() => { + return theme !== 'default' && type === 'neutral' ? 'clear' : type; + }, [theme, type]); + + // Render function that accepts tooltip trigger props and ref + const renderButton = ( + tooltipTriggerProps?: HTMLAttributes, + tooltipRef?: RefObject, + ) => { + // Merge tooltip ref with actionProps if provided + const mergedProps = tooltipRef + ? mergeProps(actionProps, tooltipTriggerProps || {}, { + ref: (element: HTMLElement | null) => { + // Set the tooltip ref + if (tooltipRef) { + (tooltipRef as any).current = element; + } + // Set the action ref if it exists in actionProps + const actionRef = (actionProps as any).ref; + if (actionRef) { + if (typeof actionRef === 'function') { + actionRef(element); + } else { + actionRef.current = element; + } + } + }, + }) + : mergeProps(actionProps, tooltipTriggerProps || {}); + + return ( + + {finalIcon &&
{finalIcon}
} + {children} +
+ ); + }; + + // Wrap with tooltip if needed + if (showTooltip && tooltipContent) { + return ( + + {(triggerProps, tooltipRef) => renderButton(triggerProps, tooltipRef)} + + ); + } + + return renderButton(); +}); + +export type { CubeItemActionProps as ItemActionProps }; diff --git a/src/components/actions/ItemActionContext.tsx b/src/components/actions/ItemActionContext.tsx new file mode 100644 index 000000000..6c38dca88 --- /dev/null +++ b/src/components/actions/ItemActionContext.tsx @@ -0,0 +1,44 @@ +import { createContext, ReactNode, useContext } from 'react'; + +import { CubeItemBaseProps } from '../content/ItemBase'; + +interface ItemActionContextValue { + type?: CubeItemBaseProps['type']; + theme?: 'default' | 'danger' | 'success' | 'special' | (string & {}); +} + +const ItemActionContext = createContext( + undefined, +); + +export interface ItemActionProviderProps { + type?: CubeItemBaseProps['type']; + theme?: 'default' | 'danger' | 'success' | 'special' | (string & {}); + children: ReactNode; +} + +export function ItemActionProvider({ + type, + theme, + children, +}: ItemActionProviderProps) { + return ( + + {children} + + ); +} + +export function useItemActionContext(): ItemActionContextValue { + return useContext(ItemActionContext) ?? {}; +} diff --git a/src/components/actions/ItemButton/ItemButton.docs.mdx b/src/components/actions/ItemButton/ItemButton.docs.mdx index 3a7ce91d0..8a028eecf 100644 --- a/src/components/actions/ItemButton/ItemButton.docs.mdx +++ b/src/components/actions/ItemButton/ItemButton.docs.mdx @@ -37,6 +37,12 @@ ItemButton inherits all properties from [ItemBase](/docs/content-itembase--docs) - Interactive properties: `hotkeys`, `tooltip`, `isSelected` - Styling properties: `size`, `type`, `theme`, `styles` +### Content Properties + +#### actions +- **Type**: `ReactNode` +- **Description**: Inline action buttons displayed on the right side of the button. Use `ItemButton.Action` for consistent styling. Actions automatically inherit the parent button's `type` prop and the component reserves space to prevent content overlap. + ### Action Properties #### onPress @@ -161,6 +167,27 @@ Inherits all modifiers from [ItemBase](/docs/content-itembase--docs) plus: ``` +### Button with Actions + +ItemButton supports inline actions that appear on the right side. Use the `ItemButton.Action` compound component for consistent styling: + +```jsx +} + actions={ + <> + } aria-label="Edit" onPress={handleEdit} /> + } aria-label="Delete" onPress={handleDelete} /> + + } + onPress={handleOpen} +> + Document with Actions + +``` + +Actions automatically inherit the parent button's `type` prop and adjust their styling accordingly. The component reserves space for actions to prevent content overlap. + ## Accessibility ### Keyboard Navigation @@ -207,13 +234,46 @@ Inherits all modifiers from [ItemBase](/docs/content-itembase--docs) plus: ``` -4. **Don't**: Use vague or unclear button text +4. **Do**: Use `ItemButton.Action` for inline actions + ```jsx + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + onPress={handleOpen} + > + Open Document + + ``` + +5. **Don't**: Use vague or unclear button text ```jsx {/* Avoid this - unclear what "OK" does */} OK ``` -5. **Don't**: Overcrowd buttons with too many visual elements +6. **Don't**: Add too many actions (limit to 2-3 for clarity) + ```jsx + {/* Avoid this - too many action buttons */} + + } aria-label="Edit" /> + } aria-label="Copy" /> + } aria-label="Share" /> + } aria-label="Delete" /> + + } + > + Button with too many actions + + ``` + +7. **Don't**: Overcrowd buttons with too many visual elements ```jsx {/* Avoid this - too many competing elements */} ``` -6. **Accessibility**: Always ensure buttons have clear, descriptive labels and proper keyboard support +8. **Accessibility**: Always ensure buttons have clear, descriptive labels, proper keyboard support, and provide `aria-label` for action buttons ## Integration with Forms @@ -248,6 +308,7 @@ ItemButton integrates seamlessly with forms when using the `buttonType` prop: ## Related Components - [ItemBase](/docs/content-itembase--docs) - The foundational component that ItemButton extends +- [ItemButton.Action](/docs/actions-itemaction--docs) - Action button component for inline actions (also available as `ItemBase.Action`, `Menu.Item.Action`, etc.) - [Button](/docs/actions-button--docs) - Traditional button component for simpler use cases - [Link](/docs/navigation-link--docs) - Text link component for navigation - [Menu.Item](/docs/actions-menu--docs) - Menu item component that also uses ItemBase \ No newline at end of file diff --git a/src/components/actions/ItemButton/ItemButton.stories.tsx b/src/components/actions/ItemButton/ItemButton.stories.tsx index 5a9b4eca6..256f0c3c8 100644 --- a/src/components/actions/ItemButton/ItemButton.stories.tsx +++ b/src/components/actions/ItemButton/ItemButton.stories.tsx @@ -1,4 +1,13 @@ -import { IconExternalLink, IconFile } from '@tabler/icons-react'; +import { + IconEdit, + IconExternalLink, + IconFile, + IconTrash, +} from '@tabler/icons-react'; +import { userEvent, within } from 'storybook/test'; + +import { timeout } from '../../../utils/promise'; +import { ItemAction } from '../ItemAction'; import { ItemButton } from './ItemButton'; @@ -68,6 +77,11 @@ const meta: Meta = { description: 'Which slot to replace with loading icon (auto intelligently selects)', }, + showActionsOnHover: { + control: 'boolean', + description: + 'When true, actions are hidden by default and fade in on hover', + }, // Icon controls are typically not included in argTypes since they're complex ReactNode objects // prefix and suffix are also ReactNode, so omitted from controls onPress: { @@ -595,3 +609,554 @@ export const AutoTooltipOnOverflow: Story = { }, }, }; + +export const WithActionsLayouts: Story = { + render: (args) => ( +
+
+

Different Sizes

+
+ } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + XSmall Size + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Small Size + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Medium Size + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Large Size + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + XLarge Size + +
+
+ +
+

Only Actions

+
+ + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Actions + + + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Very long item name that should truncate properly with actions + +
+
+ +
+

With Left Icon

+
+ } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Icon + + } + wrapperStyles={{ width: 'max 250px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Very long item name with icon that should truncate properly + +
+
+ +
+

With Prefix

+
+ + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Prefix + + + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Very long item name with prefix that should truncate properly + +
+
+ +
+

With Left Icon and Prefix

+
+ } + prefix="$" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Both + + } + prefix="$" + wrapperStyles={{ width: 'max 250px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Very long item name with icon and prefix that should truncate + +
+
+ +
+

With Inline Description

+
+ + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Inline Description + + + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Very long item name with inline description that should truncate + +
+
+ +
+

With Inline Description and Left Icon

+
+ } + description="Additional info" + descriptionPlacement="inline" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Both + + } + description="Additional info" + descriptionPlacement="inline" + wrapperStyles={{ width: 'max 250px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Very long item name with icon and inline description + +
+
+ +
+

With Block Description

+
+ + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Block Description + + + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Very long item name with block description that should truncate + +
+
+ +
+

With Block Description and Left Icon

+
+ } + description="Additional information" + descriptionPlacement="block" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Both + + } + description="Additional information" + descriptionPlacement="block" + wrapperStyles={{ width: 'max 250px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Very long item name with icon and block description + +
+
+
+ ), + parameters: { + docs: { + description: { + story: + 'Demonstrates ItemButton with actions in various layouts. Each layout shows a regular version and a truncated version (with long text and limited width). The actions are absolutely positioned on the right side, and the button automatically reserves space for them to prevent content overlap.', + }, + }, + }, +}; + +export const WithActionsHoverBehavior: Story = { + render: (args) => ( +
+
+

Only Actions

+

+ Hover over the buttons to see the difference in action visibility +

+
+ + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Long item name always showing actions + + + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Long item name with hover actions + +
+
+ +
+

With Left Icon

+
+ } + showActionsOnHover={false} + wrapperStyles={{ width: 'max 250px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Long item name with icon always showing actions + + } + showActionsOnHover={true} + wrapperStyles={{ width: 'max 250px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Long item name with icon and hover actions + +
+
+ +
+

With Left Icon and Inline Description

+
+ } + description="Additional info" + descriptionPlacement="inline" + showActionsOnHover={false} + wrapperStyles={{ width: 'max 250px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Long item name with icon and inline description + + } + description="Additional info" + descriptionPlacement="inline" + showActionsOnHover={true} + wrapperStyles={{ width: 'max 250px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Long item name with icon and inline description + +
+
+ +
+

With Left Icon and Block Description

+
+ } + description="Additional information" + descriptionPlacement="block" + showActionsOnHover={false} + wrapperStyles={{ width: 'max 250px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Long item name with icon and block description + + } + description="Additional information" + descriptionPlacement="block" + showActionsOnHover={true} + wrapperStyles={{ width: 'max 250px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Long item name with icon and block description + +
+
+
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const buttons = canvas.getAllByRole('button'); + + // Find the first button with showActionsOnHover={true} + // It should be the second button in the first row + if (buttons[3]) { + await userEvent.hover(buttons[3]); + } + }, + parameters: { + docs: { + description: { + story: + 'Demonstrates the `showActionsOnHover` flag behavior. Each row compares two buttons side-by-side: one with actions always visible (left) and one with actions shown only on hover (right). The play function automatically hovers over the first button with `showActionsOnHover={true}` to demonstrate the hover behavior.', + }, + }, + }, +}; diff --git a/src/components/actions/ItemButton/ItemButton.tsx b/src/components/actions/ItemButton/ItemButton.tsx index 865d77ab2..ddaadfe81 100644 --- a/src/components/actions/ItemButton/ItemButton.tsx +++ b/src/components/actions/ItemButton/ItemButton.tsx @@ -1,14 +1,32 @@ import { FocusableRef } from '@react-types/shared'; -import { forwardRef } from 'react'; +import { + CSSProperties, + forwardRef, + ReactNode, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useHover } from 'react-aria'; -import { tasty } from '../../../tasty'; +import { Styles, tasty } from '../../../tasty'; import { mergeProps } from '../../../utils/react'; +import { ItemBadge } from '../../content/ItemBadge'; import { CubeItemBaseProps, ItemBase } from '../../content/ItemBase'; +import { DisplayTransition } from '../../helpers'; +import { CubeItemActionProps, ItemAction } from '../ItemAction'; +import { ItemActionProvider } from '../ItemActionContext'; import { CubeUseActionProps, useAction } from '../use-action'; export interface CubeItemButtonProps - extends CubeItemBaseProps, - Omit {} + extends Omit, + Omit { + actions?: ReactNode; + size?: Omit; + wrapperStyles?: Styles; + showActionsOnHover?: boolean; +} const StyledItemBase = tasty(ItemBase, { as: 'button', @@ -20,28 +38,183 @@ const StyledItemBase = tasty(ItemBase, { }, }); -export const ItemButton = forwardRef(function ItemButton( +const ActionsWrapper = tasty({ + styles: { + display: 'grid', + position: 'relative', + placeContent: 'stretch', + placeItems: 'stretch', + preset: { + '': 't3m', + '[data-size="xsmall"]': 't4', + '[data-size="xlarge"]': 't2m', + }, + + $size: { + '': '$size-md', + '[data-size="xsmall"]': '$size-xs', + '[data-size="small"]': '$size-sm', + '[data-size="medium"]': '$size-md', + '[data-size="large"]': '$size-lg', + '[data-size="xlarge"]': '$size-xl', + }, + + '& > [data-element="Actions"]': { + position: 'absolute', + inset: '1bw 1bw auto auto', + display: 'flex', + gap: '1bw', + placeItems: 'center', + placeContent: 'center end', + pointerEvents: 'auto', + padding: '0 $side-padding', + height: 'min ($size - 2bw)', + opacity: { + '': 1, + 'actions-hidden': 0, + }, + translate: { + '': '0 0', + 'actions-hidden': '.5x 0', + }, + transition: 'theme, translate', + + '$side-padding': 'max(min(.5x, (($size - 3x + 2bw) / 2)), 1bw)', + }, + }, +}); + +const ItemButton = forwardRef(function ItemButton( allProps: CubeItemButtonProps, ref: FocusableRef, ) { - const { mods, to, htmlType, as, type, theme, onPress, ...rest } = - allProps as CubeItemButtonProps & { - as?: 'a' | 'button' | 'div' | 'span'; - }; + const { + mods, + to, + htmlType, + as, + type, + theme, + onPress, + actions, + size = 'medium', + wrapperStyles, + showActionsOnHover = false, + ...rest + } = allProps as CubeItemButtonProps & { + as?: 'a' | 'button' | 'div' | 'span'; + }; + + const actionsRef = useRef(null); + const [actionsWidth, setActionsWidth] = useState(0); + const [areActionsVisible, setAreActionsVisible] = useState(false); + const [areActionsShown, setAreActionsShown] = useState(false); + + useLayoutEffect(() => { + if (actions && actionsRef.current) { + const width = Math.round(actionsRef.current.offsetWidth); + if (width !== actionsWidth) { + setActionsWidth(width); + } + } + }, [actions, areActionsVisible]); + + const { hoverProps, isHovered } = useHover({}); + + const finalWrapperStyles = useMemo(() => { + return wrapperStyles + ? { + ...wrapperStyles, + ...(wrapperStyles?.Actions + ? { + '& > [data-element="Actions"]': wrapperStyles.Actions, + Actions: undefined, + } + : undefined), + } + : undefined; + }, [wrapperStyles]); const { actionProps } = useAction( { ...(allProps as any), htmlType, to, as, mods }, ref, ); - return ( + const button = ( ); + + if (actions) { + return ( + + {button} + + {showActionsOnHover ? ( + { + setAreActionsVisible(phase !== 'unmounted'); + }} + onToggle={(isShown) => { + setAreActionsShown(isShown); + }} + > + {({ ref: transitionRef }) => { + return ( +
{ + actionsRef.current = node; + transitionRef(node); + }} + data-element="Actions" + > + {actions} +
+ ); + }} +
+ ) : ( +
+ {actions} +
+ )} +
+
+ ); + } + + return button; +}); + +const _ItemButton = Object.assign(ItemButton, { + Action: ItemAction, + Badge: ItemBadge, }); -export type { CubeItemButtonProps as ItemButtonProps }; +export { _ItemButton as ItemButton }; +export type { + CubeItemButtonProps as ItemButtonProps, + CubeItemActionProps as ItemActionProps, +}; diff --git a/src/components/actions/Menu/Menu.stories.tsx b/src/components/actions/Menu/Menu.stories.tsx index 09a9188e5..307b77046 100644 --- a/src/components/actions/Menu/Menu.stories.tsx +++ b/src/components/actions/Menu/Menu.stories.tsx @@ -14,6 +14,7 @@ import { expect, findByRole, userEvent, waitFor, within } from 'storybook/test'; import { CheckIcon, + ClearIcon, CloseCircleIcon, CloseIcon, CopyIcon, @@ -46,6 +47,7 @@ import { baseProps } from '../../../stories/lists/baseProps'; import { ComboBox } from '../../fields/ComboBox'; import { FilterPicker } from '../../fields/FilterPicker'; import { Select } from '../../fields/Select'; +import { ItemAction } from '../ItemAction'; export default { title: 'Actions/Menu', @@ -1969,3 +1971,96 @@ export const ComprehensivePopoverSynchronization = () => { ); }; + +export const ItemsWithActions = (props) => { + const handleAction = (key) => { + console.log('Menu action:', key); + }; + + const handleItemAction = (itemKey, actionKey) => { + console.log(`Action "${actionKey}" triggered on item "${itemKey}"`); + }; + + return ( +
+ + } + actions={ + <> + } + tooltip="Edit" + onPress={() => handleItemAction('file1', 'edit')} + /> + } + tooltip="Delete" + onPress={() => handleItemAction('file1', 'delete')} + /> + + } + > + Document.pdf + + } + actions={ + <> + } + tooltip="Edit" + theme="danger" + onPress={() => handleItemAction('file2', 'edit')} + /> + } + tooltip="Delete" + theme="danger" + onPress={() => handleItemAction('file2', 'delete')} + /> + + } + > + Spreadsheet.xlsx + + } + actions={ + <> + } + tooltip="Edit" + theme="success" + onPress={() => handleItemAction('file3', 'edit')} + /> + } + tooltip="Delete" + theme="success" + onPress={() => handleItemAction('file3', 'delete')} + /> + + } + > + Presentation.pptx + + }> + Item without actions + + +
+ ); +}; + +ItemsWithActions.parameters = { + docs: { + description: { + story: + 'Demonstrates Menu.Item with inline actions. Actions are displayed on the right side of each item and inherit the item type through ItemActionProvider context. Hover over items to see the actions.', + }, + }, +}; diff --git a/src/components/actions/Menu/Menu.test.tsx b/src/components/actions/Menu/Menu.test.tsx index 92846c88d..c6625ea81 100644 --- a/src/components/actions/Menu/Menu.test.tsx +++ b/src/components/actions/Menu/Menu.test.tsx @@ -477,7 +477,7 @@ describe('', () => { it('should handle keyboard navigation', async () => { const onAction = jest.fn(); - const { getByRole } = render( + const { getByRole } = renderWithRoot( {basicItems} , @@ -496,7 +496,7 @@ describe('', () => { }); it('should handle keyboard navigation with focus wrapping', async () => { - const { getByRole } = render( + const { getByRole } = renderWithRoot( {basicItems} , @@ -516,7 +516,7 @@ describe('', () => { }); it('should handle keyboard navigation with arrow keys', async () => { - const { getByRole } = render( + const { getByRole } = renderWithRoot( {basicItems} , @@ -549,7 +549,7 @@ describe('', () => { }); it('should handle focus with first strategy', () => { - const { container } = render( + const { container } = renderWithRoot( {basicItems} , @@ -561,7 +561,7 @@ describe('', () => { }); it('should handle focus with last strategy', () => { - const { container } = render( + const { container } = renderWithRoot( {basicItems} , diff --git a/src/components/actions/Menu/MenuItem.tsx b/src/components/actions/Menu/MenuItem.tsx index db7a4d92e..13a005604 100644 --- a/src/components/actions/Menu/MenuItem.tsx +++ b/src/components/actions/Menu/MenuItem.tsx @@ -78,6 +78,7 @@ export function MenuItem(props: MenuItemProps) { rightIcon, prefix, tooltip, + actions, mods: itemMods, qa: itemQa, textValue, @@ -134,6 +135,7 @@ export function MenuItem(props: MenuItemProps) { description={description} hotkeys={hotkeys} tooltip={tooltip} + actions={actions} defaultTooltipPlacement="right" isSelected={isSelectable ? isSelected : undefined} isDisabled={isDisabled} diff --git a/src/components/actions/index.ts b/src/components/actions/index.ts index 946815956..1780c3efb 100644 --- a/src/components/actions/index.ts +++ b/src/components/actions/index.ts @@ -11,6 +11,7 @@ const Button = Object.assign( export * from './Button'; export * from './Action/Action'; export * from './ItemAction'; +export * from './ItemActionContext'; export * from './ItemButton'; export * from './Menu'; export * from './CommandMenu'; diff --git a/src/components/content/ItemBadge/ItemBadge.stories.tsx b/src/components/content/ItemBadge/ItemBadge.stories.tsx new file mode 100644 index 000000000..3f29b4240 --- /dev/null +++ b/src/components/content/ItemBadge/ItemBadge.stories.tsx @@ -0,0 +1,109 @@ +import { Meta, StoryObj } from '@storybook/react-vite'; + +import { CheckIcon } from '../../../icons/CheckIcon'; +import { KeyIcon } from '../../../icons/KeyIcon'; +import { ItemBase } from '../ItemBase'; + +import { ItemBadge } from './ItemBadge'; + +const meta: Meta = { + title: 'Content/ItemBadge', + component: ItemBadge, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + tooltip: 'Information', + }, +}; + +export const WithLabel: Story = { + args: { + icon: , + children: 'Success', + }, +}; + +export const Types: Story = { + render: () => ( +
+ } type="primary" tooltip="Primary" /> + } type="secondary" tooltip="Secondary" /> + } type="neutral" tooltip="Neutral" /> + } type="clear" tooltip="Clear" /> +
+ ), +}; + +export const Themes: Story = { + render: () => ( +
+ } theme="default" tooltip="Default" /> + } theme="danger" tooltip="Danger" /> + } theme="success" tooltip="Success" /> + } theme="special" tooltip="Special" /> +
+ ), +}; + +export const Loading: Story = { + args: { + icon: , + isLoading: true, + tooltip: 'Loading...', + }, +}; + +export const Selected: Story = { + args: { + icon: 'checkbox', + isSelected: true, + tooltip: 'Selected', + }, +}; + +export const WithItemBase: Story = { + render: () => ( + + } tooltip="Primary" /> + } + theme="success" + tooltip="Success" + /> + + } + > + Item with badges + + ), +}; + +export const InContext: Story = { + render: () => ( + + } tooltip="Verified" /> + } tooltip="Primary" /> + + } + > + Item with badges in context + + ), +}; diff --git a/src/components/content/ItemBadge/ItemBadge.tsx b/src/components/content/ItemBadge/ItemBadge.tsx new file mode 100644 index 000000000..44b39303e --- /dev/null +++ b/src/components/content/ItemBadge/ItemBadge.tsx @@ -0,0 +1,227 @@ +import { + ComponentProps, + forwardRef, + HTMLAttributes, + ReactNode, + RefObject, + useMemo, +} from 'react'; + +import { + DANGER_CLEAR_STYLES, + DANGER_NEUTRAL_STYLES, + DANGER_PRIMARY_STYLES, + DANGER_SECONDARY_STYLES, + DEFAULT_CLEAR_STYLES, + DEFAULT_NEUTRAL_STYLES, + DEFAULT_PRIMARY_STYLES, + DEFAULT_SECONDARY_STYLES, + ITEM_ACTION_BASE_STYLES, + SPECIAL_CLEAR_STYLES, + SPECIAL_NEUTRAL_STYLES, + SPECIAL_PRIMARY_STYLES, + SPECIAL_SECONDARY_STYLES, + SUCCESS_CLEAR_STYLES, + SUCCESS_NEUTRAL_STYLES, + SUCCESS_PRIMARY_STYLES, + SUCCESS_SECONDARY_STYLES, +} from '../../../data/item-themes'; +import { CheckIcon } from '../../../icons/CheckIcon'; +import { LoadingIcon } from '../../../icons/LoadingIcon'; +import { BaseProps, tasty } from '../../../tasty'; +import { mergeProps } from '../../../utils/react'; +import { useItemActionContext } from '../../actions/ItemActionContext'; +import { TooltipProvider } from '../../overlays/Tooltip/TooltipProvider'; + +export interface CubeItemBadgeProps extends BaseProps { + icon?: ReactNode | 'checkbox'; + children?: ReactNode; + isLoading?: boolean; + isSelected?: boolean; + type?: 'primary' | 'secondary' | 'neutral' | 'clear' | (string & {}); + theme?: 'default' | 'danger' | 'success' | 'special' | (string & {}); + tooltip?: + | string + | (Omit, 'children'> & { + title?: string; + }); +} + +type ItemBadgeVariant = + | 'default.primary' + | 'default.secondary' + | 'default.neutral' + | 'default.clear' + | 'danger.primary' + | 'danger.secondary' + | 'danger.neutral' + | 'danger.clear' + | 'success.primary' + | 'success.secondary' + | 'success.neutral' + | 'success.clear' + | 'special.primary' + | 'special.secondary' + | 'special.neutral' + | 'special.clear'; + +const ItemBadgeElement = tasty({ + qa: 'ItemBadge', + styles: { + ...ITEM_ACTION_BASE_STYLES, + cursor: 'default', + }, + variants: { + // Default theme + 'default.primary': DEFAULT_PRIMARY_STYLES, + 'default.secondary': DEFAULT_SECONDARY_STYLES, + 'default.neutral': DEFAULT_NEUTRAL_STYLES, + 'default.clear': DEFAULT_CLEAR_STYLES, + + // Danger theme + 'danger.primary': DANGER_PRIMARY_STYLES, + 'danger.secondary': DANGER_SECONDARY_STYLES, + 'danger.neutral': DANGER_NEUTRAL_STYLES, + 'danger.clear': DANGER_CLEAR_STYLES, + + // Success theme + 'success.primary': SUCCESS_PRIMARY_STYLES, + 'success.secondary': SUCCESS_SECONDARY_STYLES, + 'success.neutral': SUCCESS_NEUTRAL_STYLES, + 'success.clear': SUCCESS_CLEAR_STYLES, + + // Special theme + 'special.primary': SPECIAL_PRIMARY_STYLES, + 'special.secondary': SPECIAL_SECONDARY_STYLES, + 'special.neutral': SPECIAL_NEUTRAL_STYLES, + 'special.clear': SPECIAL_CLEAR_STYLES, + }, +}); + +export const ItemBadge = forwardRef( + function ItemBadge(allProps, ref) { + const { type: contextType, theme: contextTheme } = useItemActionContext(); + + const { + type = contextType ?? 'neutral', + theme = contextTheme ?? 'default', + icon, + children, + isLoading = false, + isSelected = false, + tooltip, + mods, + ...rest + } = allProps; + + // Determine if we should show checkbox + const hasCheckbox = icon === 'checkbox'; + + // Determine final icon (loading takes precedence) + const finalIcon = isLoading ? ( + + ) : hasCheckbox ? ( + + ) : ( + icon + ); + + // Build modifiers + const finalMods = useMemo( + () => ({ + checkbox: hasCheckbox, + selected: isSelected, + loading: isLoading, + 'with-label': !!children, + context: !!contextType, + ...mods, + }), + [hasCheckbox, isSelected, isLoading, children, contextType, mods], + ); + + // Extract aria-label from tooltip if needed + const ariaLabel = useMemo(() => { + if (typeof tooltip === 'string') { + return tooltip; + } + if (typeof tooltip === 'object' && tooltip.title) { + return tooltip.title; + } + return rest['aria-label']; + }, [tooltip, rest]); + + // Determine if we should show tooltip (icon-only badges) + const showTooltip = !children && tooltip; + + // Extract tooltip content and props + const tooltipContent = useMemo(() => { + if (typeof tooltip === 'string') { + return tooltip; + } + if (typeof tooltip === 'object' && tooltip.title) { + return tooltip.title; + } + return undefined; + }, [tooltip]); + + const tooltipProps = useMemo(() => { + if (typeof tooltip === 'object') { + const { title, ...rest } = tooltip; + return rest; + } + return {}; + }, [tooltip]); + + const finalType = useMemo(() => { + return theme !== 'default' && type === 'neutral' ? 'clear' : type; + }, [theme, type]); + + // Render function that accepts tooltip trigger props and ref + const renderBadge = ( + tooltipTriggerProps?: HTMLAttributes, + tooltipRef?: RefObject, + ) => { + // Merge tooltip ref with component ref if provided + const handleRef = (element: HTMLDivElement | null) => { + // Set the component ref + if (typeof ref === 'function') { + ref(element); + } else if (ref) { + (ref as any).current = element; + } + // Set the tooltip ref + if (tooltipRef) { + (tooltipRef as any).current = element; + } + }; + + return ( + + {finalIcon &&
{finalIcon}
} + {children} +
+ ); + }; + + // Wrap with tooltip if needed + if (showTooltip && tooltipContent) { + return ( + + {(triggerProps, tooltipRef) => renderBadge(triggerProps, tooltipRef)} + + ); + } + + return renderBadge(); + }, +); + +export type { CubeItemBadgeProps as ItemBadgeProps }; diff --git a/src/components/content/ItemBadge/index.ts b/src/components/content/ItemBadge/index.ts new file mode 100644 index 000000000..31edf5735 --- /dev/null +++ b/src/components/content/ItemBadge/index.ts @@ -0,0 +1,2 @@ +export { ItemBadge } from './ItemBadge'; +export type { CubeItemBadgeProps } from './ItemBadge'; diff --git a/src/components/content/ItemBase/ItemBase.docs.mdx b/src/components/content/ItemBase/ItemBase.docs.mdx index 7ad8ceab1..f461ff945 100644 --- a/src/components/content/ItemBase/ItemBase.docs.mdx +++ b/src/components/content/ItemBase/ItemBase.docs.mdx @@ -25,6 +25,12 @@ A foundational component that provides a standardized layout and styling for ite +### Content Properties + +#### actions +- **Type**: `ReactNode` +- **Description**: Inline action buttons displayed on the right side of the item. Use `ItemBase.Action` for consistent styling. Actions automatically inherit the parent's `type` prop and the component reserves space to prevent content overlap. + ### Base Properties Supports [Base properties](/BaseProperties) @@ -138,6 +144,26 @@ The `mods` property accepts the following modifiers: ``` +### With Actions + +ItemBase supports inline actions that appear on the right side. Use the `ItemBase.Action` compound component for consistent styling: + +```jsx +} + actions={ + <> + } aria-label="Edit" onPress={handleEdit} /> + } aria-label="Delete" onPress={handleDelete} /> + + } +> + Document with Actions + +``` + +Actions automatically inherit the parent's `type` prop and adjust their styling accordingly. The component reserves space for actions to prevent content overlap. + ### With Tooltip By default, ItemBase shows an auto tooltip when content overflows. @@ -227,7 +253,39 @@ By default, ItemBase shows an auto tooltip when content overflows. ``` -4. **Don't**: Overcrowd the component with too many elements +4. **Do**: Use `ItemBase.Action` for inline actions + ```jsx + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + File with Actions + + ``` + +5. **Don't**: Add too many actions (limit to 2-3 for clarity) + ```jsx + {/* Avoid this - too many action buttons */} + + } aria-label="Edit" /> + } aria-label="Copy" /> + } aria-label="Share" /> + } aria-label="Delete" /> + + } + > + Item with too many actions + + ``` + +6. **Don't**: Overcrowd the component with too many elements ```jsx {/* Avoid this - too many elements competing for attention */} ``` -5. **Accessibility**: Always ensure interactive items are focusable and have proper ARIA attributes +7. **Accessibility**: Always ensure interactive items are focusable and have proper ARIA attributes, and provide `aria-label` for action buttons ## Related Components - [ItemButton](/docs/actions-itembutton--docs) - Interactive button built on ItemBase +- [ItemBase.Action](/docs/actions-itemaction--docs) - Action button component for inline actions (also available as `ItemButton.Action`, `Menu.Item.Action`, etc.) - [Select](/docs/fields-select--docs) - Dropdown selection component using ItemBase - [ComboBox](/docs/fields-combobox--docs) - Searchable dropdown component using ItemBase - [ListBox](/docs/fields-listbox--docs) - List selection component using ItemBase diff --git a/src/components/content/ItemBase/ItemBase.stories.tsx b/src/components/content/ItemBase/ItemBase.stories.tsx index c6d0c3752..d4aeb513f 100644 --- a/src/components/content/ItemBase/ItemBase.stories.tsx +++ b/src/components/content/ItemBase/ItemBase.stories.tsx @@ -1,10 +1,16 @@ -import { IconCoin, IconSettings, IconUser } from '@tabler/icons-react'; +import { + IconCoin, + IconEdit, + IconSettings, + IconTrash, + IconUser, +} from '@tabler/icons-react'; import { useState } from 'react'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import { DirectionIcon } from '../../../icons'; import { baseProps } from '../../../stories/lists/baseProps'; -import { Button } from '../../actions'; +import { Button, ItemAction } from '../../actions'; import { Space } from '../../layout/Space'; import { Title } from '../Title'; @@ -968,6 +974,7 @@ export const WithDescriptionBlock: StoryFn = (args) => ( } description="This description appears inside the content area" descriptionPlacement="inline" @@ -977,6 +984,7 @@ export const WithDescriptionBlock: StoryFn = (args) => ( } description="This description appears below the entire item" descriptionPlacement="block" @@ -990,6 +998,7 @@ export const WithDescriptionBlock: StoryFn = (args) => ( } description="Small size description block" @@ -1000,6 +1009,7 @@ export const WithDescriptionBlock: StoryFn = (args) => ( } description="Medium size description block" @@ -1010,6 +1020,7 @@ export const WithDescriptionBlock: StoryFn = (args) => ( } description="Large size description block" @@ -1024,6 +1035,7 @@ export const WithDescriptionBlock: StoryFn = (args) => ( } rightIcon={} prefix="$" @@ -1036,6 +1048,7 @@ export const WithDescriptionBlock: StoryFn = (args) => ( } hotkeys="cmd+u" description="User management with hotkey and description block" @@ -1049,6 +1062,7 @@ export const WithDescriptionBlock: StoryFn = (args) => ( } description="This is a very long description that demonstrates how the description text flows when positioned below the item. It can contain multiple lines and will wrap naturally." descriptionPlacement="block" @@ -1071,6 +1085,337 @@ WithDescriptionBlock.parameters = { }, }; +export const WithActions: StoryFn = (args) => ( + + Basic Item with Inline Actions + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with actions + + } + actions={ + <> + } aria-label="Edit" /> + + } + > + Single action + + + + Different Sizes with Actions + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Small item + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Medium item + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Large item + + + + Different Types with Actions + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item type + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Primary type + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Secondary type + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Outline type + + + + With Complex Configurations + + } + rightIcon={} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + With both icons + + } + prefix="$" + suffix=".99" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Product Item + + } + description="Additional information" + descriptionPlacement="inline" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + With inline description + + } + description="Additional information" + descriptionPlacement="block" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + With block description + + + + Long Text with Actions + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + This is a very long item name that demonstrates how the actions are + positioned inline as part of the grid layout + + +); + +WithActions.args = { + width: '450px', +}; + +WithActions.parameters = { + docs: { + description: { + story: + 'Demonstrates the `actions` prop which allows rendering action buttons inline as part of the grid layout. Unlike ItemButton which positions actions absolutely, ItemBase renders actions as a native grid column that automatically sizes to fit the content. Actions are rendered using the ItemAction component for consistent styling.', + }, + }, +}; + +export const WithActionsOnHover: StoryFn = (args) => ( + + Actions on Hover + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Hover to see actions + + + Different Sizes + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Small with hover actions + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Medium with hover actions + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Large with hover actions + + + + With Description + } + description="Additional information" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + With description + + +); + +WithActionsOnHover.args = { + width: '450px', +}; + +WithActionsOnHover.parameters = { + docs: { + description: { + story: + 'Demonstrates the `showActionsOnHover` prop which hides actions until the item is hovered, with a smooth transition. The actions space is reserved in the layout to prevent layout shift on hover.', + }, + }, +}; + const timeout = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/components/content/ItemBase/ItemBase.tsx b/src/components/content/ItemBase/ItemBase.tsx index d42d07b5f..ebb57d46c 100644 --- a/src/components/content/ItemBase/ItemBase.tsx +++ b/src/components/content/ItemBase/ItemBase.tsx @@ -2,6 +2,9 @@ import { ForwardedRef, forwardRef, HTMLAttributes, + KeyboardEvent, + MouseEvent, + PointerEvent, ReactNode, RefObject, useCallback, @@ -54,12 +57,15 @@ import { Styles, tasty, } from '../../../tasty'; -import { mergeProps, useCombinedRefs } from '../../../utils/react'; +import { mergeProps } from '../../../utils/react'; +import { ItemAction } from '../../actions/ItemAction'; +import { ItemActionProvider } from '../../actions/ItemActionContext'; import { CubeTooltipProviderProps, TooltipProvider, } from '../../overlays/Tooltip/TooltipProvider'; import { HotKeys } from '../HotKeys'; +import { ItemBadge } from '../ItemBadge'; export interface CubeItemBaseProps extends BaseProps, ContainerStyleProps { icon?: ReactNode | 'checkbox'; @@ -68,7 +74,17 @@ export interface CubeItemBaseProps extends BaseProps, ContainerStyleProps { suffix?: ReactNode; description?: ReactNode; descriptionPlacement?: 'inline' | 'block' | 'auto'; + /** + * Whether the item is selected. + * @default false + */ isSelected?: boolean; + /** + * Actions to render inline or placeholder mode for ItemButton wrapper. + * - ReactNode: renders actions inline as part of the grid layout + * - true: placeholder mode for ItemButton (enables --actions-width calculation) + */ + actions?: ReactNode | true; size?: | 'xsmall' | 'small' @@ -76,6 +92,7 @@ export interface CubeItemBaseProps extends BaseProps, ContainerStyleProps { | 'large' | 'xlarge' | 'inline' + | number | (string & {}); type?: | 'item' @@ -120,6 +137,10 @@ export interface CubeItemBaseProps extends BaseProps, ContainerStyleProps { * and makes the component disabled. */ isLoading?: boolean; + /** + * When true, applies card styling with increased border radius. + */ + isCard?: boolean; /** * @private * Default tooltip placement for the item. @@ -155,6 +176,19 @@ const ADDITION_STYLES: Styles = { gridRow: 'span 2', }; +const ACTIONS_EVENT_HANDLERS = { + onClick: (e: MouseEvent) => e.stopPropagation(), + onPointerDown: (e: PointerEvent) => e.stopPropagation(), + onPointerUp: (e: PointerEvent) => e.stopPropagation(), + onMouseDown: (e: MouseEvent) => e.stopPropagation(), + onMouseUp: (e: MouseEvent) => e.stopPropagation(), + onKeyDown: (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + } + }, +}; + const ItemBaseElement = tasty({ styles: { display: 'inline-grid', @@ -165,9 +199,14 @@ const ItemBaseElement = tasty({ placeContent: 'stretch', gridColumns: { '': '1sf max-content max-content', + 'with-actions': '1sf max-content max-content max-content', 'with-icon ^ with-prefix': 'max-content 1sf max-content max-content', + 'with-icon ^ with-prefix & with-actions': + 'max-content 1sf max-content max-content max-content', 'with-icon & with-prefix': 'max-content max-content 1sf max-content max-content', + 'with-icon & with-prefix & with-actions': + 'max-content max-content 1sf max-content max-content max-content', '(with-icon ^ with-right-icon) & !with-description & !with-prefix & !with-suffix & !with-label': 'max-content', }, @@ -179,7 +218,10 @@ const ItemBaseElement = tasty({ position: 'relative', padding: 0, margin: 0, - radius: true, + radius: { + '': true, + card: '1cr', + }, height: { '': 'min $size', '[data-size="inline"]': 'initial', @@ -253,17 +295,17 @@ const ItemBaseElement = tasty({ '': '$block-padding $inline-padding', '(with-icon | with-prefix)': '$block-padding $inline-padding $block-padding 0', - '(with-right-icon | with-suffix)': + '(with-right-icon | with-suffix | with-actions)': '$block-padding 0 $block-padding $inline-padding', - '(with-icon | with-prefix) & (with-right-icon | with-suffix)': + '(with-icon | with-prefix) & (with-right-icon | with-suffix | with-actions)': '$block-padding 0', 'with-description & !with-description-block': '$block-padding $inline-padding 0 $inline-padding', 'with-description & !with-description-block & (with-icon | with-prefix)': '$block-padding $inline-padding 0 0', - 'with-description & !with-description-block & (with-right-icon | with-suffix)': + 'with-description & !with-description-block & (with-right-icon | with-suffix | with-actions)': '$block-padding 0 0 $inline-padding', - 'with-description & !with-description-block & (with-icon | with-prefix) & (with-right-icon | with-suffix)': + 'with-description & !with-description-block & (with-icon | with-prefix) & (with-right-icon | with-suffix | with-actions)': '$block-padding 0 0 0', }, gridRow: { @@ -297,14 +339,17 @@ const ItemBaseElement = tasty({ '(with-icon | with-prefix) & (with-right-icon | with-suffix)': '0 0 $block-padding 0', 'with-description-block': - '0 ($inline-padding - $inline-compensation + 1bw) $block-padding ($inline-padding - $inline-compensation + 1bw)', + '0 ($inline-padding - $inline-compensation + 1bw) $bottom-padding ($inline-padding - $inline-compensation + 1bw)', 'with-description-block & !with-icon': - '0 ($inline-padding - $inline-compensation + 1bw) $block-padding $inline-padding', + '0 ($inline-padding - $inline-compensation + 1bw) $bottom-padding $inline-padding', 'with-description-block & !with-right-icon': - '0 $inline-padding $block-padding ($inline-padding - $inline-compensation + 1bw)', + '0 $inline-padding $bottom-padding ($inline-padding - $inline-compensation + 1bw)', 'with-description-block & !with-right-icon & !with-icon': - '0 $inline-padding $block-padding $inline-padding', + '0 $inline-padding $bottom-padding $inline-padding', }, + + '$bottom-padding': + 'max($block-padding, (($size - 4x) / 2) + $block-padding)', }, Prefix: { @@ -322,6 +367,26 @@ const ItemBaseElement = tasty({ 'with-right-icon': 0, }, }, + + Actions: { + display: 'flex', + gap: '1bw', + placeItems: 'center', + placeContent: 'stretch', + placeSelf: 'stretch', + padding: '0 $side-padding', + boxSizing: 'border-box', + height: 'min ($size - 2bw)', + gridRow: 'span 2', + width: { + '': 'var(--actions-width, 0px)', + 'with-actions-content': 'calc-size(max-content, size)', + }, + transition: 'width $transition ease-out', + interpolateSize: 'allow-keywords', + + '$side-padding': 'max(min(.5x, (($size - 3x + 2bw) / 2)), 1bw)', + }, }, variants: { // Default theme @@ -364,10 +429,12 @@ export function useAutoTooltip({ tooltip, children, labelProps, + isDynamicLabel = false, // if actions are set }: { tooltip: CubeItemBaseProps['tooltip']; children: ReactNode; labelProps?: Props; + isDynamicLabel?: boolean; }) { // Determine if auto tooltip is enabled // Auto tooltip only works when children is a string (overflow detection needs text) @@ -501,11 +568,15 @@ export function useAutoTooltip({ // Boolean tooltip - auto tooltip on overflow if (tooltip === true) { - if ((children || labelProps) && isLabelOverflowed) { + if ( + (children || labelProps) && + (isLabelOverflowed || isDynamicLabel) + ) { return ( {(triggerProps, ref) => renderElement(triggerProps, ref)} @@ -530,11 +601,19 @@ export function useAutoTooltip({ } // If title is provided with auto=true, OR no title but auto behavior enabled - if ((children || labelProps) && isLabelOverflowed) { + if ( + (children || labelProps) && + (isLabelOverflowed || isDynamicLabel) + ) { return ( {(triggerProps, ref) => renderElement(triggerProps, ref)} @@ -565,7 +644,7 @@ const ItemBase = ( ) => { let { children, - size, + size = 'medium', type = 'item', theme = 'default', mods, @@ -584,8 +663,11 @@ const ItemBase = ( hotkeys, tooltip = true, isDisabled, + style, loadingSlot = 'auto', isLoading = false, + isCard = false, + actions, defaultTooltipPlacement = 'top', ...rest } = props; @@ -606,6 +688,12 @@ const ItemBase = ( return 'icon'; // fallback }, [loadingSlot, icon, rightIcon]); + const showDescriptions = useMemo(() => { + const copyProps = { ...descriptionProps }; + delete copyProps.id; + return !!(description || Object.keys(copyProps).length > 0); + }, [description, descriptionProps]); + // Apply loading state to appropriate slots const finalIcon = isLoading && resolvedLoadingSlot === 'icon' ? : icon; @@ -662,13 +750,16 @@ const ItemBase = ( 'with-label': !!(children || labelProps), 'with-prefix': !!finalPrefix, 'with-suffix': !!finalSuffix, - 'with-description': !!description, + 'with-description': showDescriptions, 'with-description-block': - !!description && descriptionPlacement === 'block', + showDescriptions && descriptionPlacement === 'block', + 'with-actions': !!actions, + 'with-actions-content': !!(actions && actions !== true), checkbox: hasCheckbox, disabled: finalIsDisabled, selected: isSelected === true, loading: isLoading, + card: isCard === true, ...mods, }; }, [ @@ -676,11 +767,13 @@ const ItemBase = ( finalRightIcon, finalPrefix, finalSuffix, - description, + showDescriptions, descriptionPlacement, hasCheckbox, isSelected, isLoading, + isCard, + actions, mods, ]); @@ -688,7 +781,12 @@ const ItemBase = ( labelProps: finalLabelProps, labelRef, renderWithTooltip, - } = useAutoTooltip({ tooltip, children, labelProps }); + } = useAutoTooltip({ + tooltip, + children, + labelProps, + isDynamicLabel: !!actions, + }); // Create a stable render function that doesn't call hooks const renderItemElement = useCallback( @@ -710,6 +808,12 @@ const ItemBase = ( } }; + // Merge custom size style with provided style + const finalStyle = + typeof size === 'number' + ? ({ ...style, '--size': `${size}px` } as any) + : style; + return ( ( theme && type ? (`${theme}.${type}` as ItemVariant) : undefined } disabled={finalIsDisabled} - data-size={size} + data-size={typeof size === 'number' ? undefined : size} data-type={type} data-theme={theme} aria-disabled={finalIsDisabled} @@ -726,6 +830,7 @@ const ItemBase = ( styles={styles} type={htmlType as any} {...mergeProps(rest, tooltipTriggerProps || {})} + style={finalStyle} > {finalIcon && (
@@ -738,7 +843,7 @@ const ItemBase = ( {children}
) : null} - {description || descriptionProps ? ( + {showDescriptions ? (
{description}
@@ -747,6 +852,15 @@ const ItemBase = ( {finalRightIcon && (
{finalRightIcon}
)} + {actions && ( +
+ {actions !== true ? ( + + {actions} + + ) : null} +
+ )}
); }, @@ -771,12 +885,18 @@ const ItemBase = ( descriptionProps, finalSuffix, finalRightIcon, + actions, + size, + style, ], ); return renderWithTooltip(renderItemElement, defaultTooltipPlacement); }; -const _ItemBase = forwardRef(ItemBase); +const _ItemBase = Object.assign(forwardRef(ItemBase), { + Action: ItemAction, + Badge: ItemBadge, +}); export { _ItemBase as ItemBase }; diff --git a/src/components/content/ItemBase/index.ts b/src/components/content/ItemBase/index.ts index 71ab1d3b9..67759d5ad 100644 --- a/src/components/content/ItemBase/index.ts +++ b/src/components/content/ItemBase/index.ts @@ -1 +1,2 @@ export * from './ItemBase'; +export * from '../ItemBadge'; diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index 3abd3a2f1..74f53f9d9 100644 --- a/src/components/fields/ComboBox/ComboBox.tsx +++ b/src/components/fields/ComboBox/ComboBox.tsx @@ -1,4 +1,4 @@ -import { Key } from '@react-types/shared'; +import { FocusableRef, Key } from '@react-types/shared'; import React, { cloneElement, ForwardedRef, @@ -71,6 +71,14 @@ const InputElement = tasty({ styles: DEFAULT_INPUT_STYLES, }); +const ComboBoxOverlayWrapper = tasty({ + qa: 'ComboBoxOverlayWrapper', + styles: { + position: 'absolute', + zIndex: 1000, + }, +}); + const ComboBoxOverlayElement = tasty({ qa: 'ComboBoxOverlay', styles: { @@ -78,7 +86,7 @@ const ComboBoxOverlayElement = tasty({ gridRows: '1sf', gridColumns: '1sf', width: '$min-width max-content 50vw', - height: 'initial max-content (50vh - $size)', + height: 'initial max-content (50vh - 5x)', overflow: 'auto', background: '#white', radius: '1cr', @@ -89,7 +97,6 @@ const ComboBoxOverlayElement = tasty({ '': false, hidden: true, }, - transition: 'translate $transition ease-out, scale $transition ease-out, theme $transition ease-out', translate: { @@ -191,7 +198,7 @@ export interface CubeComboBoxProps /** Ref for accessing the popover element */ popoverRef?: RefObject; /** Ref for accessing the trigger button element */ - triggerRef?: RefObject; + triggerRef?: FocusableRef; /** Custom styles for the input */ inputStyles?: Styles; @@ -873,56 +880,57 @@ function ComboBoxOverlay({ const overlayContent = ( {({ phase, isShown, ref: transitionRef }) => ( - { - transitionRef(value as HTMLElement | null); - (popoverRef as any).current = value; - }} - data-placement={placementDirection} - data-phase={phase} - mods={{ - open: isShown, - hidden: phase === 'unmounted', - }} - styles={overlayStyles} - style={{ - '--min-width': comboBoxWidth ? `${comboBoxWidth}px` : undefined, - ...overlayPositionProps.style, - }} + ref={popoverRef} + style={overlayPositionProps.style} > - - {children as any} - - + + {children as any} + + + )} ); diff --git a/src/components/fields/DatePicker/DateInputBase.tsx b/src/components/fields/DatePicker/DateInputBase.tsx index 96a749269..16db6c5cb 100644 --- a/src/components/fields/DatePicker/DateInputBase.tsx +++ b/src/components/fields/DatePicker/DateInputBase.tsx @@ -55,6 +55,7 @@ interface CubeDateAtomInputProps extends ContainerStyleProps { size?: 'small' | 'medium' | 'large' | (string & {}); validationState?: ValidationState; isLoading?: boolean; + suffix?: React.ReactNode; } function DateInputBase(props: CubeDateAtomInputProps, ref) { @@ -70,6 +71,7 @@ function DateInputBase(props: CubeDateAtomInputProps, ref) { validationState, isLoading, size = 'medium', + suffix, } = props; let styles = extractStyles(props, CONTAINER_STYLES); @@ -92,6 +94,7 @@ function DateInputBase(props: CubeDateAtomInputProps, ref) { disabled: isDisabled, focused: isFocused && !disableFocusRing, invalid: isInvalid, + suffix: (validationState && !isLoading) || isLoading || !!suffix, }} {...mergeProps(fieldProps ?? {}, focusProps)} style={style} @@ -106,7 +109,16 @@ function DateInputBase(props: CubeDateAtomInputProps, ref) { {children} - {validationState && !isLoading ? validation : undefined} + {(validationState && !isLoading) || isLoading || suffix ? ( +
+ {(validationState && !isLoading) || isLoading ? ( +
+ {validationState && !isLoading ? validation : null} +
+ ) : null} + {suffix} +
+ ) : null} ); } diff --git a/src/components/fields/DatePicker/DatePicker.tsx b/src/components/fields/DatePicker/DatePicker.tsx index 52bd1b385..d701311b2 100644 --- a/src/components/fields/DatePicker/DatePicker.tsx +++ b/src/components/fields/DatePicker/DatePicker.tsx @@ -24,7 +24,6 @@ import { Dialog, DialogTrigger } from '../../overlays/Dialog'; import { DateInputBase } from './DateInputBase'; import { DatePickerButton } from './DatePickerButton'; -import { DatePickerElement } from './DatePickerElement'; import { DatePickerInput } from './DatePickerInput'; import { dateMessages } from './intl'; import { DEFAULT_DATE_PROPS } from './props'; @@ -118,56 +117,52 @@ function DatePicker( // let visibleMonths = useVisibleMonths(maxVisibleMonths); const component = ( - + + + + {showTimeField && ( + + )} + + + } > - - - - - - - - {showTimeField && ( - - )} - - - + + ); return wrapWithField(component, domRef, { diff --git a/src/components/fields/DatePicker/DatePickerButton.tsx b/src/components/fields/DatePicker/DatePickerButton.tsx index faf814420..4607158c8 100644 --- a/src/components/fields/DatePicker/DatePickerButton.tsx +++ b/src/components/fields/DatePicker/DatePickerButton.tsx @@ -1,12 +1,7 @@ import { CalendarIcon } from '../../../icons'; import { tasty } from '../../../tasty'; -import { Button } from '../../actions'; +import { ItemAction } from '../../actions'; -export const DatePickerButton = tasty(Button, { +export const DatePickerButton = tasty(ItemAction, { icon: , - styles: { - radius: '1r right', - border: 'top right bottom', - backgroundClip: 'content-box', - }, }); diff --git a/src/components/fields/DatePicker/DatePickerSegment.tsx b/src/components/fields/DatePicker/DatePickerSegment.tsx index c4ceebac4..fffb5d23c 100644 --- a/src/components/fields/DatePicker/DatePickerSegment.tsx +++ b/src/components/fields/DatePicker/DatePickerSegment.tsx @@ -29,6 +29,8 @@ const EditableSegmentElement = tasty({ fontVariantNumeric: 'tabular-nums lining-nums', textAlign: 'right', font: 'monospace', + border: 0, + outline: 0, color: { '': 'inherit', ':focus': '#white', diff --git a/src/components/fields/DatePicker/DateRangePicker.tsx b/src/components/fields/DatePicker/DateRangePicker.tsx index 7b10f5402..95a3fc548 100644 --- a/src/components/fields/DatePicker/DateRangePicker.tsx +++ b/src/components/fields/DatePicker/DateRangePicker.tsx @@ -26,7 +26,6 @@ import { Dialog, DialogTrigger } from '../../overlays/Dialog'; import { DateInputBase } from './DateInputBase'; import { DatePickerButton } from './DatePickerButton'; -import { DatePickerElement } from './DatePickerElement'; import { DatePickerInput } from './DatePickerInput'; import { dateMessages } from './intl'; import { DEFAULT_DATE_PROPS } from './props'; @@ -130,71 +129,68 @@ function DateRangePicker( // let visibleMonths = useVisibleMonths(maxVisibleMonths); const component = ( - + + + + {showTimeField && ( + + state.setTime('start', v)} + /> + state.setTime('end', v)} + /> + + )} + + + } > - - - - - - - - - - {showTimeField && ( - - state.setTime('start', v)} - /> - state.setTime('end', v)} - /> - - )} - - - + + + + ); return wrapWithField(component, domRef, { diff --git a/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx b/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx index a705064b6..f8c2830ef 100644 --- a/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx +++ b/src/components/fields/DatePicker/DateRangeSeparatedPicker.tsx @@ -37,7 +37,7 @@ import { useFocusManagerRef } from './utils'; const DateRangeDash = tasty({ 'aria-hidden': 'true', 'data-qa': 'DateRangeDash', - children: '––', + children: '–', styles: { padding: '0 .5x', color: '#dark-03', @@ -180,123 +180,123 @@ function DateRangeSeparatedPicker( const component = ( - - - - - - - - onChange(value, 'start')} + suffix={ + + - {showTimeField && ( - + onChange(value, 'start')} /> - )} - - - + {showTimeField && ( + + )} + + + } + > + + - - - - - - - - { - onChange(value, 'end'); - }} + suffix={ + + - {showTimeField && ( - + { + onChange(value, 'end'); + }} /> - )} - - - + {showTimeField && ( + + )} + + + } + > + + ); diff --git a/src/components/fields/FilterPicker/FilterPicker.stories.tsx b/src/components/fields/FilterPicker/FilterPicker.stories.tsx index 2933aa388..0dde0b789 100644 --- a/src/components/fields/FilterPicker/FilterPicker.stories.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.stories.tsx @@ -4,7 +4,6 @@ import { userEvent, within } from 'storybook/test'; import { CheckIcon, DatabaseIcon, - EditIcon, FilterIcon, PlusIcon, RightIcon, diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index 2507267fc..9c64350d7 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -731,7 +731,6 @@ export const FilterPicker = forwardRef(function FilterPicker( isDisabled={isDisabled || isLoading} mods={{ placeholder: !hasSelection, - selected: hasSelection, ...externalMods, }} icon={icon} diff --git a/src/components/fields/PasswordInput/PasswordInput.tsx b/src/components/fields/PasswordInput/PasswordInput.tsx index ac28d2bb6..394f55cc3 100644 --- a/src/components/fields/PasswordInput/PasswordInput.tsx +++ b/src/components/fields/PasswordInput/PasswordInput.tsx @@ -7,7 +7,7 @@ import { castNullableStringValue, WithNullableValue, } from '../../../utils/react/nullableValue'; -import { Button } from '../../actions'; +import { ItemAction } from '../../actions'; import { useFieldProps } from '../../form'; import { CubeTextInputBaseProps, TextInputBase } from '../TextInput'; @@ -48,15 +48,9 @@ function PasswordInput( const wrappedSuffix = ( <> {suffix} -