From 54abe1c988e601ccea32529b314a76fe422d5a15 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 22 Oct 2025 15:36:21 +0200 Subject: [PATCH 01/24] feat: item actions --- .changeset/big-pugs-deny.md | 5 + src/components/Item.tsx | 8 +- .../CommandMenu/CommandMenu.stories.tsx | 107 ++++++++ .../actions/ItemAction/ItemAction.tsx | 23 +- src/components/actions/ItemActionContext.tsx | 33 +++ .../actions/ItemButton/ItemButton.docs.mdx | 67 ++++- .../actions/ItemButton/ItemButton.stories.tsx | 155 ++++++++++- .../actions/ItemButton/ItemButton.tsx | 116 ++++++++- src/components/actions/Menu/Menu.stories.tsx | 91 +++++++ src/components/actions/Menu/MenuItem.tsx | 2 + src/components/actions/index.ts | 1 + .../content/ItemBase/ItemBase.docs.mdx | 63 ++++- .../content/ItemBase/ItemBase.stories.tsx | 241 +++++++++++++++++- src/components/content/ItemBase/ItemBase.tsx | 82 +++++- src/components/fields/ComboBox/ComboBox.tsx | 4 +- 15 files changed, 970 insertions(+), 28 deletions(-) create mode 100644 .changeset/big-pugs-deny.md create mode 100644 src/components/actions/ItemActionContext.tsx 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/src/components/Item.tsx b/src/components/Item.tsx index f63cedbef..9c7173486 100644 --- a/src/components/Item.tsx +++ b/src/components/Item.tsx @@ -1,6 +1,7 @@ import { ReactElement } from 'react'; import { Item, ItemProps } from 'react-stately'; +import { ItemAction } from './actions/ItemAction'; import { CubeItemBaseProps } from './content/ItemBase/ItemBase'; export interface CubeItemProps @@ -11,6 +12,11 @@ export interface CubeItemProps [key: string]: any; } -const _Item = Item as (props: CubeItemProps) => ReactElement; +const _Item = Object.assign( + Item as (props: CubeItemProps) => ReactElement, + { + Action: ItemAction, + }, +); 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..5c2a6fa7e 100644 --- a/src/components/actions/ItemAction/ItemAction.tsx +++ b/src/components/actions/ItemAction/ItemAction.tsx @@ -3,14 +3,15 @@ import { forwardRef } from 'react'; import { tasty } from '../../../tasty'; import { Button, CubeButtonProps } from '../Button'; +import { useItemActionContext } from '../ItemActionContext'; export interface CubeItemActionProps extends CubeButtonProps { // All props from Button are inherited } -export const ItemAction = tasty(Button, { - type: 'neutral', +const StyledButton = tasty(Button, { styles: { + border: 0, height: '($size - 1x)', width: '($size - 1x)', margin: { @@ -18,6 +19,24 @@ export const ItemAction = tasty(Button, { ':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)', + context: '0', }, }, }); + +export const ItemAction = forwardRef(function ItemAction( + props: CubeItemActionProps, + ref: FocusableRef, +) { + const { type: contextType } = useItemActionContext(); + const { type = contextType ?? 'neutral', ...rest } = props; + + return ( + + ); +}); diff --git a/src/components/actions/ItemActionContext.tsx b/src/components/actions/ItemActionContext.tsx new file mode 100644 index 000000000..73c628f7c --- /dev/null +++ b/src/components/actions/ItemActionContext.tsx @@ -0,0 +1,33 @@ +import { createContext, ReactNode, useContext } from 'react'; + +import { CubeItemBaseProps } from '../content/ItemBase'; + +interface ItemActionContextValue { + type?: CubeItemBaseProps['type']; +} + +const ItemActionContext = createContext( + undefined, +); + +export interface ItemActionProviderProps { + type?: CubeItemBaseProps['type']; + children: ReactNode; +} + +export function ItemActionProvider({ + type, + 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..01fcbeeb5 100644 --- a/src/components/actions/ItemButton/ItemButton.stories.tsx +++ b/src/components/actions/ItemButton/ItemButton.stories.tsx @@ -1,4 +1,11 @@ -import { IconExternalLink, IconFile } from '@tabler/icons-react'; +import { + IconEdit, + IconExternalLink, + IconFile, + IconTrash, +} from '@tabler/icons-react'; + +import { ItemAction } from '../ItemAction'; import { ItemButton } from './ItemButton'; @@ -595,3 +602,149 @@ export const AutoTooltipOnOverflow: Story = { }, }, }; + +export const WithActions: Story = { + render: (args) => ( +
+
+

Basic Item with Actions

+
+ } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Actions + +
+
+ +
+

Different Button Types with Actions

+
+ } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Primary Item + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Secondary Item + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Outline Item + +
+
+ +
+

With Description and Actions

+
+ } + description="Additional information" + descriptionPlacement="block" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Description + +
+
+ +
+

Long Text with Actions

+
+ } + style={{ maxWidth: '300px' }} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + This is a very long item name that should truncate properly while + leaving space for actions + +
+
+ +
+

Multiple Actions

+
+ } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Two Actions + + } + actions={ + <> + } aria-label="Edit" /> + + } + > + Single Action + +
+
+
+ ), + parameters: { + docs: { + description: { + story: + 'Demonstrates ItemButton with actions displayed on the right side. The actions are absolutely positioned and the button automatically reserves space for them to prevent content overlap. Actions use the ItemAction component for consistent styling.', + }, + }, + }, +}; diff --git a/src/components/actions/ItemButton/ItemButton.tsx b/src/components/actions/ItemButton/ItemButton.tsx index 865d77ab2..035e4997f 100644 --- a/src/components/actions/ItemButton/ItemButton.tsx +++ b/src/components/actions/ItemButton/ItemButton.tsx @@ -1,14 +1,25 @@ import { FocusableRef } from '@react-types/shared'; -import { forwardRef } from 'react'; +import { + forwardRef, + ReactNode, + useLayoutEffect, + useRef, + useState, +} from 'react'; -import { tasty } from '../../../tasty'; +import { Styles, tasty } from '../../../tasty'; import { mergeProps } from '../../../utils/react'; import { CubeItemBaseProps, ItemBase } from '../../content/ItemBase'; +import { CubeItemActionProps, ItemAction } from '../ItemAction'; +import { ItemActionProvider } from '../ItemActionContext'; import { CubeUseActionProps, useAction } from '../use-action'; export interface CubeItemButtonProps extends CubeItemBaseProps, - Omit {} + Omit { + actions?: ReactNode; + wrapperStyles?: Styles; +} const StyledItemBase = tasty(ItemBase, { as: 'button', @@ -20,28 +31,113 @@ const StyledItemBase = tasty(ItemBase, { }, }); -export const ItemButton = forwardRef(function ItemButton( +const ActionsWrapper = tasty({ + styles: { + display: 'grid', + position: 'relative', + placeContent: 'stretch', + placeItems: 'stretch', + + $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', + }, + }, +}); + +const ActionsContainer = tasty({ + styles: { + position: 'absolute', + inset: '1bw 1bw auto auto', + display: 'flex', + gap: '1bw', + placeItems: 'center', + placeContent: 'end', + pointerEvents: 'auto', + padding: '0 .5x 0 0', + height: '$size', + }, +}); + +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', + ...rest + } = allProps as CubeItemButtonProps & { + as?: 'a' | 'button' | 'div' | 'span'; + }; + + const actionsRef = useRef(null); + const [actionsWidth, setActionsWidth] = useState(0); + + useLayoutEffect(() => { + if (actions && actionsRef.current) { + const width = Math.round(actionsRef.current.offsetWidth); + if (width !== actionsWidth) { + setActionsWidth(width); + } + } + }, [actions, actionsWidth]); const { actionProps } = useAction( { ...(allProps as any), htmlType, to, as, mods }, ref, ); - return ( + const button = ( ); + + if (actions) { + return ( + + {button} + + {actions} + + + ); + } + + return button; +}); + +const _ItemButton = Object.assign(ItemButton, { + Action: ItemAction, }); -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..29f4b7828 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,92 @@ 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={ + <> + } + aria-label="Edit" + onPress={() => handleItemAction('file1', 'edit')} + /> + } + aria-label="Delete" + onPress={() => handleItemAction('file1', 'delete')} + /> + + } + > + Document.pdf + + } + actions={ + <> + } + aria-label="Edit" + onPress={() => handleItemAction('file2', 'edit')} + /> + } + aria-label="Delete" + onPress={() => handleItemAction('file2', 'delete')} + /> + + } + > + Spreadsheet.xlsx + + } + actions={ + <> + } + aria-label="Edit" + onPress={() => handleItemAction('file3', 'edit')} + /> + } + aria-label="Delete" + 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/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/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..1cfc9cab3 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'; @@ -1071,6 +1077,237 @@ 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.', + }, + }, +}; + 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..d6094fdc7 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,7 +57,9 @@ import { Styles, tasty, } from '../../../tasty'; -import { mergeProps, useCombinedRefs } from '../../../utils/react'; +import { mergeProps } from '../../../utils/react'; +import { CubeItemActionProps, ItemAction } from '../../actions/ItemAction'; +import { ItemActionProvider } from '../../actions/ItemActionContext'; import { CubeTooltipProviderProps, TooltipProvider, @@ -68,14 +73,24 @@ 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' | 'medium' | 'large' | 'xlarge' - | 'inline' + | number | (string & {}); type?: | 'item' @@ -155,6 +170,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 +193,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', }, @@ -216,7 +249,7 @@ const ItemBaseElement = tasty({ }, $size: { - '': '$size-md', + '': 'var(--size, $size-md)', '[data-size="xsmall"]': '$size-xs', '[data-size="small"]': '$size-sm', '[data-size="medium"]': '$size-md', @@ -322,6 +355,21 @@ const ItemBaseElement = tasty({ 'with-right-icon': 0, }, }, + + Actions: { + display: 'flex', + gap: '1bw', + placeItems: 'center', + placeContent: 'stretch', + placeSelf: 'stretch', + padding: '0 .5x 0 0', + height: 'min ($size - 2bw)', + gridRow: 'span 2', + width: { + '': 'var(--actions-width, 0px)', + 'with-actions-content': 'auto', + }, + }, }, variants: { // Default theme @@ -584,8 +632,10 @@ const ItemBase = ( hotkeys, tooltip = true, isDisabled, + style, loadingSlot = 'auto', isLoading = false, + actions, defaultTooltipPlacement = 'top', ...rest } = props; @@ -665,6 +715,8 @@ const ItemBase = ( 'with-description': !!description, 'with-description-block': !!description && descriptionPlacement === 'block', + 'with-actions': !!actions, + 'with-actions-content': !!(actions && actions !== true), checkbox: hasCheckbox, disabled: finalIsDisabled, selected: isSelected === true, @@ -681,6 +733,7 @@ const ItemBase = ( hasCheckbox, isSelected, isLoading, + actions, mods, ]); @@ -710,6 +763,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 +785,7 @@ const ItemBase = ( styles={styles} type={htmlType as any} {...mergeProps(rest, tooltipTriggerProps || {})} + style={finalStyle} > {finalIcon && (
@@ -747,6 +807,13 @@ const ItemBase = ( {finalRightIcon && (
{finalRightIcon}
)} + {actions && ( +
+ {actions !== true ? ( + {actions} + ) : null} +
+ )} ); }, @@ -771,12 +838,17 @@ const ItemBase = ( descriptionProps, finalSuffix, finalRightIcon, + actions, + size, + style, ], ); return renderWithTooltip(renderItemElement, defaultTooltipPlacement); }; -const _ItemBase = forwardRef(ItemBase); +const _ItemBase = Object.assign(forwardRef(ItemBase), { + Action: ItemAction, +}); export { _ItemBase as ItemBase }; diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index e207484ad..bc40233c0 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, @@ -191,7 +191,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; From cb011d8936f668eaa79ac09bfae0f39255c198a9 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 22 Oct 2025 18:03:52 +0200 Subject: [PATCH 02/24] feat(ItemButton): show actions on hover flag --- .../actions/ItemButton/ItemButton.stories.tsx | 93 +++++++++++++++- .../actions/ItemButton/ItemButton.tsx | 54 +++++++++- src/components/actions/Menu/Menu.stories.tsx | 4 +- .../content/ItemBase/ItemBase.stories.tsx | 100 ++++++++++++++++++ src/components/content/ItemBase/ItemBase.tsx | 14 ++- .../DisplayTransition/DisplayTransition.tsx | 22 ++++ 6 files changed, 274 insertions(+), 13 deletions(-) diff --git a/src/components/actions/ItemButton/ItemButton.stories.tsx b/src/components/actions/ItemButton/ItemButton.stories.tsx index 01fcbeeb5..bf1d667fd 100644 --- a/src/components/actions/ItemButton/ItemButton.stories.tsx +++ b/src/components/actions/ItemButton/ItemButton.stories.tsx @@ -75,6 +75,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: { @@ -737,13 +742,99 @@ export const WithActions: Story = {
+ +
+

Actions Visible on Hover Only

+

+ Use showActionsOnHover to hide actions by default and + show them only when hovering over the button: +

+
+ } + width="200px" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Hover to see actions + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Primary with hover actions + + } + description="Additional information" + descriptionPlacement="block" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + With description and hover actions + +
+
+ +
+

Comparison: Always Visible vs Hover Only

+
+ } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Actions always visible (default) + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Actions on hover only + +
+
), parameters: { docs: { description: { story: - 'Demonstrates ItemButton with actions displayed on the right side. The actions are absolutely positioned and the button automatically reserves space for them to prevent content overlap. Actions use the ItemAction component for consistent styling.', + 'Demonstrates ItemButton with actions displayed on the right side. The actions are absolutely positioned and the button automatically reserves space for them to prevent content overlap. Use `showActionsOnHover={true}` to hide actions by default and show them with a smooth fade transition when hovering over the button. Actions use the ItemAction component for consistent styling.', }, }, }, diff --git a/src/components/actions/ItemButton/ItemButton.tsx b/src/components/actions/ItemButton/ItemButton.tsx index 035e4997f..b5ad4ebe8 100644 --- a/src/components/actions/ItemButton/ItemButton.tsx +++ b/src/components/actions/ItemButton/ItemButton.tsx @@ -6,10 +6,12 @@ import { useRef, useState, } from 'react'; +import { useHover } from 'react-aria'; import { Styles, tasty } from '../../../tasty'; import { mergeProps } from '../../../utils/react'; 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'; @@ -19,6 +21,7 @@ export interface CubeItemButtonProps Omit { actions?: ReactNode; wrapperStyles?: Styles; + showActionsOnHover?: boolean; } const StyledItemBase = tasty(ItemBase, { @@ -60,6 +63,15 @@ const ActionsContainer = tasty({ pointerEvents: 'auto', padding: '0 .5x 0 0', height: '$size', + opacity: { + '': 1, + hidden: 0, + }, + translate: { + '': '0 0', + hidden: '.5x 0', + }, + transition: 'theme, translate', }, }); @@ -77,6 +89,7 @@ const ItemButton = forwardRef(function ItemButton( onPress, actions, size = 'medium', + showActionsOnHover = false, ...rest } = allProps as CubeItemButtonProps & { as?: 'a' | 'button' | 'div' | 'span'; @@ -84,6 +97,7 @@ const ItemButton = forwardRef(function ItemButton( const actionsRef = useRef(null); const [actionsWidth, setActionsWidth] = useState(0); + const [areActionsVisible, setAreActionsVisible] = useState(false); useLayoutEffect(() => { if (actions && actionsRef.current) { @@ -92,7 +106,9 @@ const ItemButton = forwardRef(function ItemButton( setActionsWidth(width); } } - }, [actions, actionsWidth]); + }, [actions, areActionsVisible]); + + const { hoverProps, isHovered } = useHover({}); const { actionProps } = useAction( { ...(allProps as any), htmlType, to, as, mods }, @@ -113,18 +129,46 @@ const ItemButton = forwardRef(function ItemButton( if (actions) { return ( {button} - - {actions} - + {showActionsOnHover ? ( + { + setAreActionsVisible(phase !== 'unmounted'); + }} + > + {({ isShown, ref: transitionRef }) => { + return ( + { + actionsRef.current = node; + transitionRef(node); + }} + mods={{ hidden: !isShown }} + > + {actions} + + ); + }} + + ) : ( + + {actions} + + )} ); } diff --git a/src/components/actions/Menu/Menu.stories.tsx b/src/components/actions/Menu/Menu.stories.tsx index 29f4b7828..7889c72b5 100644 --- a/src/components/actions/Menu/Menu.stories.tsx +++ b/src/components/actions/Menu/Menu.stories.tsx @@ -2009,12 +2009,12 @@ export const ItemsWithActions = (props) => { icon={} actions={ <> - } aria-label="Edit" onPress={() => handleItemAction('file2', 'edit')} /> - } aria-label="Delete" onPress={() => handleItemAction('file2', 'delete')} diff --git a/src/components/content/ItemBase/ItemBase.stories.tsx b/src/components/content/ItemBase/ItemBase.stories.tsx index 1cfc9cab3..ccb8f6a71 100644 --- a/src/components/content/ItemBase/ItemBase.stories.tsx +++ b/src/components/content/ItemBase/ItemBase.stories.tsx @@ -1308,6 +1308,106 @@ WithActions.parameters = { }, }; +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 d6094fdc7..15cc82265 100644 --- a/src/components/content/ItemBase/ItemBase.tsx +++ b/src/components/content/ItemBase/ItemBase.tsx @@ -58,7 +58,7 @@ import { tasty, } from '../../../tasty'; import { mergeProps } from '../../../utils/react'; -import { CubeItemActionProps, ItemAction } from '../../actions/ItemAction'; +import { ItemAction } from '../../actions/ItemAction'; import { ItemActionProvider } from '../../actions/ItemActionContext'; import { CubeTooltipProviderProps, @@ -286,17 +286,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: { @@ -369,6 +369,10 @@ const ItemBaseElement = tasty({ '': 'var(--actions-width, 0px)', 'with-actions-content': 'auto', }, + transition: { + '': false, + 'with-action && !with-actions-content': 'width $transition ease-out', + }, }, }, variants: { diff --git a/src/components/helpers/DisplayTransition/DisplayTransition.tsx b/src/components/helpers/DisplayTransition/DisplayTransition.tsx index 4700b8a31..f4b1f9945 100644 --- a/src/components/helpers/DisplayTransition/DisplayTransition.tsx +++ b/src/components/helpers/DisplayTransition/DisplayTransition.tsx @@ -19,6 +19,10 @@ export type DisplayTransitionProps = { duration?: number; /** Fires after enter settles or after exit completes (unmount). */ onRest?: (transition: 'enter' | 'exit') => void; + /** Fires when phase changes. */ + onPhaseChange?: (phase: Phase) => void; + /** Fires when isShown (derived from phase) changes. */ + onToggle?: (isShown: boolean) => void; /** Keep calling children during "unmounted" (you decide what to render). */ exposeUnmounted?: boolean; /** If false and initially shown, start at "entered" (no first-mount animation/SSR pop). */ @@ -48,6 +52,8 @@ export function DisplayTransition({ isShown: targetShown, duration, onRest, + onPhaseChange, + onToggle, exposeUnmounted = false, animateOnMount = true, respectReducedMotion = true, @@ -77,6 +83,8 @@ export function DisplayTransition({ phaseRef.current = phase; const onRestEvent = useEvent(onRest); + const onPhaseChangeEvent = useEvent(onPhaseChange); + const onToggleEvent = useEvent(onToggle); // Versioned scheduling (Strict Mode & rapid toggles safe) const flowRef = useRef(0); @@ -276,8 +284,22 @@ export function DisplayTransition({ return cancelRAF; }, [phase]); + // Call onPhaseChange when phase changes + useLayoutEffect(() => { + onPhaseChangeEvent?.(phase); + }, [phase, onPhaseChangeEvent]); + // Render-time boolean (true only when visually shown) const isShownNow = phase === 'entered'; + const prevIsShownRef = useRef(isShownNow); + + // Call onToggle when isShown changes + useLayoutEffect(() => { + if (prevIsShownRef.current !== isShownNow) { + prevIsShownRef.current = isShownNow; + onToggleEvent?.(isShownNow); + } + }, [isShownNow, onToggleEvent]); // Ref callback to attach to transitioned element const refCallback: RefCallback = (node) => { From 67904575ee22473b47b8880682df8d80895eda27 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 23 Oct 2025 13:12:48 +0200 Subject: [PATCH 03/24] fix(ItemButton): minor --- .../actions/ItemButton/ItemButton.tsx | 7 ++--- .../DisplayTransition/DisplayTransition.tsx | 2 +- src/tasty/styles/dimension.test.ts | 27 +++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/components/actions/ItemButton/ItemButton.tsx b/src/components/actions/ItemButton/ItemButton.tsx index b5ad4ebe8..7de0afb98 100644 --- a/src/components/actions/ItemButton/ItemButton.tsx +++ b/src/components/actions/ItemButton/ItemButton.tsx @@ -1,5 +1,6 @@ import { FocusableRef } from '@react-types/shared'; import { + CSSProperties, forwardRef, ReactNode, useLayoutEffect, @@ -106,7 +107,7 @@ const ItemButton = forwardRef(function ItemButton( setActionsWidth(width); } } - }, [actions, areActionsVisible]); + }, [actions]); const { hoverProps, isHovered } = useHover({}); @@ -137,8 +138,8 @@ const ItemButton = forwardRef(function ItemButton( areActionsVisible || !showActionsOnHover ? `${actionsWidth}px` : '0px', - '--size': typeof size === 'number' ? `${size}px` : undefined, - } as any + ...(typeof size === 'number' && { '--size': `${size}px` }), + } as CSSProperties } > {button} diff --git a/src/components/helpers/DisplayTransition/DisplayTransition.tsx b/src/components/helpers/DisplayTransition/DisplayTransition.tsx index f4b1f9945..0aa762ff9 100644 --- a/src/components/helpers/DisplayTransition/DisplayTransition.tsx +++ b/src/components/helpers/DisplayTransition/DisplayTransition.tsx @@ -8,7 +8,7 @@ import { useState, } from 'react'; -const AUTO_FALLBACK_DURATION = 180; +const AUTO_FALLBACK_DURATION = 500; type Phase = 'enter' | 'entered' | 'exit' | 'unmounted'; diff --git a/src/tasty/styles/dimension.test.ts b/src/tasty/styles/dimension.test.ts index a9d20b824..888f70882 100644 --- a/src/tasty/styles/dimension.test.ts +++ b/src/tasty/styles/dimension.test.ts @@ -83,4 +83,31 @@ describe('dimensionStyle – width & height helpers', () => { const res = heightStyle({ height: 'stretch' }) as any; expect(res.height).toBe('auto'); }); + + test('calc-size(auto) width', () => { + const res = widthStyle({ width: 'calc-size(auto)' }) as any; + expect(res.width).toBe('calc-size(auto)'); + expect(res['min-width']).toBe('initial'); + expect(res['max-width']).toBe('initial'); + }); + + test('calc-size(auto) with min constraint', () => { + const res = widthStyle({ width: '100px calc-size(auto)' }) as any; + expect(res.width).toBe('auto'); + expect(res['min-width']).toBe('100px'); + expect(res['max-width']).toBe('calc-size(auto)'); + }); + + test('parseStyle processes calc-size(auto) correctly', () => { + const parsed = parseStyle('calc-size(auto)'); + expect(parsed.output).toBe('calc-size(auto)'); + expect(parsed.groups[0].values).toEqual(['calc-size(auto)']); + expect(parsed.groups[0].mods).toEqual([]); + }); + + test('parseStyle processes calc-size with complex inner value', () => { + const parsed = parseStyle('calc-size(fit-content)'); + expect(parsed.output).toBe('calc-size(fit-content)'); + expect(parsed.groups[0].values).toEqual(['calc-size(fit-content)']); + }); }); From 51a83d15f599f909bbbdd79031c31135921898eb Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 23 Oct 2025 13:21:36 +0200 Subject: [PATCH 04/24] fix(ItemBase): description logic --- src/components/actions/Menu/Menu.stories.tsx | 4 +++- src/components/content/ItemBase/ItemBase.tsx | 22 ++++++++++++-------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/components/actions/Menu/Menu.stories.tsx b/src/components/actions/Menu/Menu.stories.tsx index 7889c72b5..757585515 100644 --- a/src/components/actions/Menu/Menu.stories.tsx +++ b/src/components/actions/Menu/Menu.stories.tsx @@ -1982,7 +1982,7 @@ export const ItemsWithActions = (props) => { }; return ( -
+
{ } aria-label="Edit" + tooltip="Edit" onPress={() => handleItemAction('file1', 'edit')} /> } aria-label="Delete" + tooltip="Delete" onPress={() => handleItemAction('file1', 'delete')} /> diff --git a/src/components/content/ItemBase/ItemBase.tsx b/src/components/content/ItemBase/ItemBase.tsx index 15cc82265..2f92a6596 100644 --- a/src/components/content/ItemBase/ItemBase.tsx +++ b/src/components/content/ItemBase/ItemBase.tsx @@ -367,12 +367,10 @@ const ItemBaseElement = tasty({ gridRow: 'span 2', width: { '': 'var(--actions-width, 0px)', - 'with-actions-content': 'auto', - }, - transition: { - '': false, - 'with-action && !with-actions-content': 'width $transition ease-out', + 'with-actions-content': 'calc-size(max-content, size)', }, + transition: 'width $transition ease-out', + interpolateSize: 'allow-keywords', }, }, variants: { @@ -660,6 +658,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; @@ -716,9 +720,9 @@ 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, @@ -732,7 +736,7 @@ const ItemBase = ( finalRightIcon, finalPrefix, finalSuffix, - description, + showDescriptions, descriptionPlacement, hasCheckbox, isSelected, @@ -802,7 +806,7 @@ const ItemBase = ( {children}
) : null} - {description || descriptionProps ? ( + {showDescriptions ? (
{description}
From c202b09039134a055f5a19c69e5650400c4255f3 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 23 Oct 2025 15:13:13 +0200 Subject: [PATCH 05/24] fix(Tooltip): disabled logic --- .../actions/ItemAction/ItemAction.tsx | 1 + .../actions/ItemButton/ItemButton.tsx | 122 +++++++++++------- src/components/content/ItemBase/ItemBase.tsx | 8 +- .../overlays/Tooltip/TooltipTrigger.tsx | 3 +- 4 files changed, 80 insertions(+), 54 deletions(-) diff --git a/src/components/actions/ItemAction/ItemAction.tsx b/src/components/actions/ItemAction/ItemAction.tsx index 5c2a6fa7e..9f98f34a3 100644 --- a/src/components/actions/ItemAction/ItemAction.tsx +++ b/src/components/actions/ItemAction/ItemAction.tsx @@ -33,6 +33,7 @@ export const ItemAction = forwardRef(function ItemAction( return ( [data-element="Actions"]': { + position: 'absolute', + inset: '1bw 1bw auto auto', + display: 'flex', + gap: '1bw', + placeItems: 'center', + placeContent: 'end', + pointerEvents: 'auto', + padding: '0 .5x 0 0', + height: 'min ($size - 2bw)', + opacity: { + '': 1, + 'actions-hidden': 0, + }, + translate: { + '': '0 0', + 'actions-hidden': '.5x 0', + }, + transition: 'theme, translate', }, - translate: { - '': '0 0', - hidden: '.5x 0', - }, - transition: 'theme, translate', }, }); @@ -90,6 +89,8 @@ const ItemButton = forwardRef(function ItemButton( onPress, actions, size = 'medium', + styles, + wrapperStyles, showActionsOnHover = false, ...rest } = allProps as CubeItemButtonProps & { @@ -99,6 +100,7 @@ const ItemButton = forwardRef(function ItemButton( const actionsRef = useRef(null); const [actionsWidth, setActionsWidth] = useState(0); const [areActionsVisible, setAreActionsVisible] = useState(false); + const [areActionsShown, setAreActionsShown] = useState(false); useLayoutEffect(() => { if (actions && actionsRef.current) { @@ -107,10 +109,24 @@ const ItemButton = forwardRef(function ItemButton( setActionsWidth(width); } } - }, [actions]); + }, [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, @@ -131,7 +147,8 @@ const ItemButton = forwardRef(function ItemButton( return ( {button} - {showActionsOnHover ? ( - { - setAreActionsVisible(phase !== 'unmounted'); - }} - > - {({ isShown, ref: transitionRef }) => { - return ( - { - actionsRef.current = node; - transitionRef(node); - }} - mods={{ hidden: !isShown }} - > - {actions} - - ); - }} - - ) : ( - - {actions} - - )} + + {showActionsOnHover ? ( + { + setAreActionsVisible(phase !== 'unmounted'); + }} + onToggle={(isShown) => { + setAreActionsShown(isShown); + }} + > + {({ ref: transitionRef }) => { + return ( +
{ + actionsRef.current = node; + transitionRef(node); + }} + data-element="Actions" + > + {actions} +
+ ); + }} +
+ ) : ( +
+ {actions} +
+ )} +
); } diff --git a/src/components/content/ItemBase/ItemBase.tsx b/src/components/content/ItemBase/ItemBase.tsx index 2f92a6596..b4f61c40b 100644 --- a/src/components/content/ItemBase/ItemBase.tsx +++ b/src/components/content/ItemBase/ItemBase.tsx @@ -551,11 +551,12 @@ export function useAutoTooltip({ // Boolean tooltip - auto tooltip on overflow if (tooltip === true) { - if ((children || labelProps) && isLabelOverflowed) { + if (children || labelProps) { return ( {(triggerProps, ref) => renderElement(triggerProps, ref)} @@ -580,11 +581,14 @@ export function useAutoTooltip({ } // If title is provided with auto=true, OR no title but auto behavior enabled - if ((children || labelProps) && isLabelOverflowed) { + if (children || labelProps) { return ( {(triggerProps, ref) => renderElement(triggerProps, ref)} diff --git a/src/components/overlays/Tooltip/TooltipTrigger.tsx b/src/components/overlays/Tooltip/TooltipTrigger.tsx index 29fefc1c8..4f357510a 100644 --- a/src/components/overlays/Tooltip/TooltipTrigger.tsx +++ b/src/components/overlays/Tooltip/TooltipTrigger.tsx @@ -148,7 +148,6 @@ export function TooltipTrigger(props: CubeTooltipTriggerProps) { let { triggerProps, tooltipProps } = useTooltipTrigger( { - isDisabled, trigger: triggerAction, delay, isOpen, @@ -227,7 +226,7 @@ export function TooltipTrigger(props: CubeTooltipTriggerProps) { ) : ( trigger )} - + {({ phase, isShown, ref: transitionRef }) => ( Date: Thu, 23 Oct 2025 15:16:32 +0200 Subject: [PATCH 06/24] fix(Tooltip): disabled logic * 2 --- .../overlays/Tooltip/TooltipTrigger.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/components/overlays/Tooltip/TooltipTrigger.tsx b/src/components/overlays/Tooltip/TooltipTrigger.tsx index 4f357510a..1d303f51c 100644 --- a/src/components/overlays/Tooltip/TooltipTrigger.tsx +++ b/src/components/overlays/Tooltip/TooltipTrigger.tsx @@ -227,19 +227,21 @@ export function TooltipTrigger(props: CubeTooltipTriggerProps) { trigger )} - {({ phase, isShown, ref: transitionRef }) => ( - - {tooltip} - - )} + {({ phase, isShown, ref: transitionRef }) => + isDisabled ? null : ( + + {tooltip} + + ) + } ); From 645b65e9b0d2029714a7d296e167eb3598491b51 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 23 Oct 2025 16:17:39 +0200 Subject: [PATCH 07/24] fix(Tooltip): dynamic label case --- src/components/actions/Menu/Menu.test.tsx | 10 +++--- src/components/content/ItemBase/ItemBase.tsx | 25 ++++++++++++--- src/components/overlays/Tooltip/Tooltip.tsx | 2 +- .../overlays/Tooltip/TooltipProvider.tsx | 19 ++++++++--- .../overlays/Tooltip/TooltipTrigger.tsx | 32 +++++++++---------- 5 files changed, 56 insertions(+), 32 deletions(-) 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/content/ItemBase/ItemBase.tsx b/src/components/content/ItemBase/ItemBase.tsx index b4f61c40b..a88b89e14 100644 --- a/src/components/content/ItemBase/ItemBase.tsx +++ b/src/components/content/ItemBase/ItemBase.tsx @@ -414,10 +414,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) @@ -551,12 +553,15 @@ export function useAutoTooltip({ // Boolean tooltip - auto tooltip on overflow if (tooltip === true) { - if (children || labelProps) { + if ( + (children || labelProps) && + (isLabelOverflowed || isDynamicLabel) + ) { return ( {(triggerProps, ref) => renderElement(triggerProps, ref)} @@ -581,13 +586,18 @@ export function useAutoTooltip({ } // If title is provided with auto=true, OR no title but auto behavior enabled - if (children || labelProps) { + if ( + (children || labelProps) && + (isLabelOverflowed || isDynamicLabel) + ) { return ( @@ -753,7 +763,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( diff --git a/src/components/overlays/Tooltip/Tooltip.tsx b/src/components/overlays/Tooltip/Tooltip.tsx index 3b9a56ce5..bfa03e67d 100644 --- a/src/components/overlays/Tooltip/Tooltip.tsx +++ b/src/components/overlays/Tooltip/Tooltip.tsx @@ -174,7 +174,7 @@ function Tooltip( const styles = extractStyles(otherProps, CONTAINER_STYLES); - let { tooltipProps } = useTooltip(props, state); + let { tooltipProps } = useTooltip({ ...props, isDismissable: false }, state); // Sync ref with overlayRef from context. useImperativeHandle(ref, () => createDOMRef(finalOverlayRef)); diff --git a/src/components/overlays/Tooltip/TooltipProvider.tsx b/src/components/overlays/Tooltip/TooltipProvider.tsx index 31dbe7b57..6317c7ae1 100644 --- a/src/components/overlays/Tooltip/TooltipProvider.tsx +++ b/src/components/overlays/Tooltip/TooltipProvider.tsx @@ -26,7 +26,8 @@ export interface CubeTooltipProviderProps export function TooltipProvider(props: CubeTooltipProviderProps): ReactElement { const [rendered, setRendered] = useState(false); - const { title, children, tooltipStyles, width, ...otherProps } = props; + const { title, children, tooltipStyles, width, isDisabled, ...otherProps } = + props; useEffect(() => { setRendered(true); @@ -48,7 +49,11 @@ export function TooltipProvider(props: CubeTooltipProviderProps): ReactElement { // Both patterns pass through to TooltipTrigger // The difference is whether we pass function or element as first child return ( - + {isFunction || isValidElement(children) || typeof children === 'string' ? ( @@ -56,9 +61,13 @@ export function TooltipProvider(props: CubeTooltipProviderProps): ReactElement { ) : ( <>{children} )} - - {title} - + {isDisabled ? ( +
+ ) : ( + + {title} + + )} ); } diff --git a/src/components/overlays/Tooltip/TooltipTrigger.tsx b/src/components/overlays/Tooltip/TooltipTrigger.tsx index 1d303f51c..8b8d63427 100644 --- a/src/components/overlays/Tooltip/TooltipTrigger.tsx +++ b/src/components/overlays/Tooltip/TooltipTrigger.tsx @@ -144,7 +144,7 @@ export function TooltipTrigger(props: CubeTooltipTriggerProps) { let tooltipTriggerRef = externalRef ?? internalRef; let overlayRef = useRef(null); - let state = useTooltipTriggerState({ delay, ...props }); + let state = useTooltipTriggerState({ delay, ...props, isDismissable: false }); let { triggerProps, tooltipProps } = useTooltipTrigger( { @@ -152,6 +152,7 @@ export function TooltipTrigger(props: CubeTooltipTriggerProps) { delay, isOpen, onOpenChange, + isDismissable: false, defaultOpen, }, state, @@ -165,6 +166,7 @@ export function TooltipTrigger(props: CubeTooltipTriggerProps) { overlayRef, offset, crossOffset, + isDismissable: false, isOpen: state.isOpen, }); @@ -227,21 +229,19 @@ export function TooltipTrigger(props: CubeTooltipTriggerProps) { trigger )} - {({ phase, isShown, ref: transitionRef }) => - isDisabled ? null : ( - - {tooltip} - - ) - } + {({ phase, isShown, ref: transitionRef }) => ( + + {tooltip} + + )} ); From f9ee5db26123d3867856a9b56b341497e35f112a Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 23 Oct 2025 16:31:46 +0200 Subject: [PATCH 08/24] fix(ItemBase): default size --- src/components/content/ItemBase/ItemBase.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/content/ItemBase/ItemBase.tsx b/src/components/content/ItemBase/ItemBase.tsx index a88b89e14..ff716a277 100644 --- a/src/components/content/ItemBase/ItemBase.tsx +++ b/src/components/content/ItemBase/ItemBase.tsx @@ -249,7 +249,7 @@ const ItemBaseElement = tasty({ }, $size: { - '': 'var(--size, $size-md)', + '': '$size-md', '[data-size="xsmall"]': '$size-xs', '[data-size="small"]': '$size-sm', '[data-size="medium"]': '$size-md', From bfe8eafb862e0ecfdfb9c7ab1f92fcd667a57822 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 23 Oct 2025 16:32:06 +0200 Subject: [PATCH 09/24] fix(ItemBase): default size * 2 --- src/components/content/ItemBase/ItemBase.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/content/ItemBase/ItemBase.tsx b/src/components/content/ItemBase/ItemBase.tsx index ff716a277..61e519e75 100644 --- a/src/components/content/ItemBase/ItemBase.tsx +++ b/src/components/content/ItemBase/ItemBase.tsx @@ -629,7 +629,7 @@ const ItemBase = ( ) => { let { children, - size, + size = 'medium', type = 'item', theme = 'default', mods, From 4cb51208f7f42f57f36f6b784ebea1d2ca75e02c Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 23 Oct 2025 16:58:05 +0200 Subject: [PATCH 10/24] fix(ItemAction): type from context --- src/components/actions/ItemActionContext.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/actions/ItemActionContext.tsx b/src/components/actions/ItemActionContext.tsx index 73c628f7c..0af959cd9 100644 --- a/src/components/actions/ItemActionContext.tsx +++ b/src/components/actions/ItemActionContext.tsx @@ -21,7 +21,14 @@ export function ItemActionProvider({ }: ItemActionProviderProps) { return ( {children} From bed8032b781d79e5b1b9929a4f59a627a6eda039 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 23 Oct 2025 17:42:14 +0200 Subject: [PATCH 11/24] fix(ItemBase): return inline size --- src/components/actions/ItemButton/ItemButton.tsx | 3 ++- src/components/content/ItemBase/ItemBase.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/actions/ItemButton/ItemButton.tsx b/src/components/actions/ItemButton/ItemButton.tsx index 617032d4f..9c0f2d371 100644 --- a/src/components/actions/ItemButton/ItemButton.tsx +++ b/src/components/actions/ItemButton/ItemButton.tsx @@ -19,9 +19,10 @@ import { ItemActionProvider } from '../ItemActionContext'; import { CubeUseActionProps, useAction } from '../use-action'; export interface CubeItemButtonProps - extends CubeItemBaseProps, + extends Omit, Omit { actions?: ReactNode; + size?: Omit; wrapperStyles?: Styles; showActionsOnHover?: boolean; } diff --git a/src/components/content/ItemBase/ItemBase.tsx b/src/components/content/ItemBase/ItemBase.tsx index 61e519e75..8c2e8e6f6 100644 --- a/src/components/content/ItemBase/ItemBase.tsx +++ b/src/components/content/ItemBase/ItemBase.tsx @@ -90,6 +90,7 @@ export interface CubeItemBaseProps extends BaseProps, ContainerStyleProps { | 'medium' | 'large' | 'xlarge' + | 'inline' | number | (string & {}); type?: From f1907e31cb209de1e4d4fcbd880438e086388953 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 23 Oct 2025 18:03:36 +0200 Subject: [PATCH 12/24] fix: popover height limit --- .changeset/sour-toys-accept.md | 5 + src/components/fields/ComboBox/ComboBox.tsx | 101 ++++++------ .../FilterPicker/FilterPicker.stories.tsx | 1 - src/components/fields/Select/Select.tsx | 150 +++++++++--------- 4 files changed, 137 insertions(+), 120 deletions(-) create mode 100644 .changeset/sour-toys-accept.md 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/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index 42351f704..1fce7d824 100644 --- a/src/components/fields/ComboBox/ComboBox.tsx +++ b/src/components/fields/ComboBox/ComboBox.tsx @@ -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: { @@ -873,56 +880,54 @@ 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, - }} + - - {children as any} - - + + {children as any} + + + )} ); 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/Select/Select.tsx b/src/components/fields/Select/Select.tsx index 4de585e70..b187f0f24 100644 --- a/src/components/fields/Select/Select.tsx +++ b/src/components/fields/Select/Select.tsx @@ -121,13 +121,20 @@ const OptionItem = tasty(ItemBase, { }, }); -const OverlayElement = tasty({ +const SelectOverlayWrapper = tasty({ + qa: 'SelectOverlayWrapper', styles: { position: 'absolute', + zIndex: 1000, + }, +}); + +const OverlayElement = tasty({ + styles: { width: 'min $overlay-min-width', display: 'grid', gridRows: '1sf', - height: 'initial max-content (50vh - $size)', + height: 'initial max-content (50vh - 5x)', overflow: 'auto', background: '#white', radius: '1cr', @@ -572,83 +579,84 @@ export function ListBoxPopup({ {({ phase, isShown, ref: transitionRef }) => ( - { - transitionRef(value as HTMLElement | null); - (popoverRef as any).current = value; - }} - data-placement={placementDirection} - data-phase={phase} - mods={{ - open: isShown, - }} - styles={overlayStyles} - style={{ - '--overlay-min-width': minWidth ? `${minWidth}px` : 'initial', - ...parentOverlayProps?.style, - }} + ref={popoverRef} + style={parentOverlayProps?.style} > - - state.close()} /> - {(() => { - const renderedItems: React.ReactNode[] = []; - let isFirstSection = true; - - for (const item of state.collection) { - if (item.type === 'section') { - if (!isFirstSection) { + + + state.close()} /> + {(() => { + const renderedItems: React.ReactNode[] = []; + let isFirstSection = true; + + for (const item of state.collection) { + if (item.type === 'section') { + if (!isFirstSection) { + renderedItems.push( + , + ); + } + renderedItems.push( - , ); - } - renderedItems.push( - , - ); - - isFirstSection = false; - } else { - renderedItems.push( - - + + return ( + + {renderedItems} + + ); + })()} + state.close()} /> + + + )} From e5eed6224c2883e0f00e361c5ada37dc78c04278 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Thu, 23 Oct 2025 18:51:33 +0200 Subject: [PATCH 13/24] fix(ItemButton): styles desctruct --- src/components/actions/ItemButton/ItemButton.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/actions/ItemButton/ItemButton.tsx b/src/components/actions/ItemButton/ItemButton.tsx index 9c0f2d371..bdd552d36 100644 --- a/src/components/actions/ItemButton/ItemButton.tsx +++ b/src/components/actions/ItemButton/ItemButton.tsx @@ -90,7 +90,6 @@ const ItemButton = forwardRef(function ItemButton( onPress, actions, size = 'medium', - styles, wrapperStyles, showActionsOnHover = false, ...rest From 5f625b5ccd1b83c007a60a7cf4453b3204a0b80f Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Fri, 24 Oct 2025 14:20:45 +0200 Subject: [PATCH 14/24] feat(ItemAction): brand new component --- .../actions/ItemAction/ItemAction.tsx | 294 +++++++++++- .../actions/ItemButton/ItemButton.stories.tsx | 417 ++++++++++++++++-- .../actions/ItemButton/ItemButton.tsx | 12 +- src/components/content/ItemBase/ItemBase.tsx | 4 +- .../fields/DatePicker/DateInputBase.tsx | 14 +- .../fields/DatePicker/DatePicker.tsx | 89 ++-- .../fields/DatePicker/DatePickerButton.tsx | 9 +- .../fields/DatePicker/DatePickerSegment.tsx | 2 + .../fields/DatePicker/DateRangePicker.tsx | 120 +++-- .../DatePicker/DateRangeSeparatedPicker.tsx | 222 +++++----- .../fields/FilterPicker/FilterPicker.tsx | 1 - .../fields/PasswordInput/PasswordInput.tsx | 12 +- .../fields/Picker/Picker.stories.tsx | 39 +- src/components/fields/Picker/Picker.tsx | 1 - src/components/fields/Select/Select.tsx | 1 - src/components/other/Calendar/Calendar.tsx | 4 +- .../other/Calendar/CalendarCell.tsx | 15 +- 17 files changed, 921 insertions(+), 335 deletions(-) diff --git a/src/components/actions/ItemAction/ItemAction.tsx b/src/components/actions/ItemAction/ItemAction.tsx index 9f98f34a3..7b578916b 100644 --- a/src/components/actions/ItemAction/ItemAction.tsx +++ b/src/components/actions/ItemAction/ItemAction.tsx @@ -1,43 +1,293 @@ 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, + 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; + }); } -const StyledButton = tasty(Button, { +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: { - border: 0, - height: '($size - 1x)', - width: '($size - 1x)', + display: 'inline-grid', + flow: 'column', + placeItems: 'center', + placeContent: 'center', + gap: '.75x', + position: 'relative', 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)', + ':last-child & !:first-child': '0 $side-padding 0 0', + '!:last-child & :first-child': '0 0 0 $side-padding', + ':last-child & :first-child': '0 $side-padding', context: '0', }, + padding: 0, + reset: 'button', + outline: 0, + outlineOffset: 1, + cursor: { '': 'pointer', disabled: 'default' }, + radius: true, + transition: 'theme', + flexShrink: 0, + textDecoration: 'none', + boxSizing: 'border-box', + whiteSpace: 'nowrap', + border: 0, + height: '$action-size', + width: { + '': '$action-size', + 'with-label': 'auto', + }, + placeSelf: 'center', + + // Size using custom property + '$action-size': 'min(max((2x + 2bw), ($size - 1x - 2bw)), (3x - 2bw))', + // Side padding for the button + '$side-padding': 'max(min(.5x, (($size - 3x + 2bw) / 2)), 1bw)', + + // Icon styles + Icon: { + display: 'grid', + placeItems: 'center', + aspectRatio: '1 / 1', + width: '$action-size', + opacity: { + '': 1, + 'checkbox & selected': 1, + 'checkbox & !selected': 0, + 'checkbox & !selected & hovered': 0.4, + }, + }, + }, + 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( - props: CubeItemActionProps, + allProps: CubeItemActionProps, ref: FocusableRef, ) { const { type: contextType } = useItemActionContext(); - const { type = contextType ?? 'neutral', ...rest } = props; - - return ( - + + const { + type = contextType ?? 'neutral', + theme = '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]); + + // 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/ItemButton/ItemButton.stories.tsx b/src/components/actions/ItemButton/ItemButton.stories.tsx index bf1d667fd..256f0c3c8 100644 --- a/src/components/actions/ItemButton/ItemButton.stories.tsx +++ b/src/components/actions/ItemButton/ItemButton.stories.tsx @@ -4,7 +4,9 @@ import { 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'; @@ -608,14 +610,44 @@ export const AutoTooltipOnOverflow: Story = { }, }; -export const WithActions: Story = { +export const WithActionsLayouts: Story = { render: (args) => (
-

Basic Item with Actions

+

Different Sizes

} + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + XSmall Size + + } + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Small Size + + } actions={ <> @@ -623,18 +655,77 @@ export const WithActions: Story = { } 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 +
-

Different Button Types with Actions

+

With Left Icon

} actions={ <> @@ -643,12 +734,13 @@ export const WithActions: Story = { } > - Primary Item + Item with Icon } + wrapperStyles={{ width: 'max 250px' }} actions={ <> } aria-label="Edit" /> @@ -656,12 +748,32 @@ export const WithActions: Story = { } > - Secondary Item + Very long item name with icon that should truncate properly +
+
+ +
+

With Prefix

+
} + prefix="$" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Prefix + + } aria-label="Edit" /> @@ -669,19 +781,69 @@ export const WithActions: Story = { } > - Outline Item + Very long item name with prefix that should truncate properly
-

With Description and Actions

+

With Left Icon and Prefix

} - description="Additional information" - descriptionPlacement="block" + 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" /> @@ -689,18 +851,36 @@ export const WithActions: Story = { } > - Item with Description + Very long item name with inline description that should truncate
-

Long Text with Actions

+

With Inline Description and Left Icon

} + description="Additional info" + descriptionPlacement="inline" + actions={ + <> + } aria-label="Edit" /> + } aria-label="Delete" /> + + } + > + Item with Both + + } - style={{ maxWidth: '300px' }} + description="Additional info" + descriptionPlacement="inline" + wrapperStyles={{ width: 'max 250px' }} actions={ <> } aria-label="Edit" /> @@ -708,18 +888,55 @@ export const WithActions: Story = { } > - This is a very long item name that should truncate properly while - leaving space for actions + Very long item name with icon and inline description
-

Multiple Actions

+

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" /> @@ -727,83 +944,173 @@ export const WithActions: Story = { } > - Two Actions + Item with Both } + description="Additional information" + descriptionPlacement="block" + wrapperStyles={{ width: 'max 250px' }} actions={ <> } aria-label="Edit" /> + } aria-label="Delete" /> } > - Single Action + 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) => ( +
-

Actions Visible on Hover Only

+

Only Actions

- Use showActionsOnHover to hide actions by default and - show them only when hovering over the button: + 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

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

With Left Icon and Inline Description

+
} - description="Additional information" - descriptionPlacement="block" + description="Additional info" + descriptionPlacement="inline" + showActionsOnHover={false} + wrapperStyles={{ width: 'max 250px' }} actions={ <> - } aria-label="Edit" /> - } aria-label="Delete" /> + } aria-label="Edit" /> + } aria-label="Delete" /> } > - With description and hover actions + 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
-

Comparison: Always Visible vs Hover Only

-
+

With Left Icon and Block Description

+
} + description="Additional information" + descriptionPlacement="block" + showActionsOnHover={false} + wrapperStyles={{ width: 'max 250px' }} actions={ <> } aria-label="Edit" /> @@ -811,12 +1118,16 @@ export const WithActions: Story = { } > - Actions always visible (default) + Long item name with icon and block description } + description="Additional information" + descriptionPlacement="block" + showActionsOnHover={true} + wrapperStyles={{ width: 'max 250px' }} actions={ <> } aria-label="Edit" /> @@ -824,17 +1135,27 @@ export const WithActions: Story = { } > - Actions on hover only + 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 ItemButton with actions displayed on the right side. The actions are absolutely positioned and the button automatically reserves space for them to prevent content overlap. Use `showActionsOnHover={true}` to hide actions by default and show them with a smooth fade transition when hovering over the button. Actions use the ItemAction component for consistent styling.', + '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 bdd552d36..ab90da82e 100644 --- a/src/components/actions/ItemButton/ItemButton.tsx +++ b/src/components/actions/ItemButton/ItemButton.tsx @@ -43,6 +43,11 @@ const ActionsWrapper = tasty({ position: 'relative', placeContent: 'stretch', placeItems: 'stretch', + preset: { + '': 't3m', + '[data-size="xsmall"]': 't4', + '[data-size="xlarge"]': 't2m', + }, $size: { '': '$size-md', @@ -59,9 +64,9 @@ const ActionsWrapper = tasty({ display: 'flex', gap: '1bw', placeItems: 'center', - placeContent: 'end', + placeContent: 'center end', pointerEvents: 'auto', - padding: '0 .5x 0 0', + padding: '0 $side-padding 0 0', height: 'min ($size - 2bw)', opacity: { '': 1, @@ -72,6 +77,8 @@ const ActionsWrapper = tasty({ 'actions-hidden': '.5x 0', }, transition: 'theme, translate', + + '$side-padding': 'max(min(.5x, (($size - 3x + 2bw) / 2)), 1bw)', }, }, }); @@ -147,6 +154,7 @@ const ItemButton = forwardRef(function ItemButton( return (
- {validationState && !isLoading ? validation : undefined} + {(validationState && !isLoading) || isLoading || suffix ? ( +
+ {suffix} + {(validationState && !isLoading) || isLoading ? ( +
+ {validationState && !isLoading ? validation : null} +
+ ) : null} +
+ ) : 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.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} -