diff --git a/.changeset/nav-button-redesign.md b/.changeset/nav-button-redesign.md new file mode 100644 index 000000000..dc2549c73 --- /dev/null +++ b/.changeset/nav-button-redesign.md @@ -0,0 +1,6 @@ +--- +'@workflowbuilder/ui': major +'@workflowbuilder/sdk': minor +--- + +`NavButton` takes `size`, `variant` (`square`, `round`, `plain`), `prefixIcon`, `suffixIcon` and `children` instead of inferring a subtype from the children structure, sizes are letter-based, and the selected state no longer shares a treatment with the pointer-down state. `MenuTriggerButton` is new, and `SegmentPicker` keeps its API while adopting the new slots; icons passed as `SegmentPicker.Item` children are no longer inferred as icons and must use an explicit icon slot. diff --git a/.changeset/ui-export-prop-types.md b/.changeset/ui-export-prop-types.md index 383d256fe..382ad638c 100644 --- a/.changeset/ui-export-prop-types.md +++ b/.changeset/ui-export-prop-types.md @@ -2,4 +2,4 @@ '@workflowbuilder/ui': minor --- -Component prop types are now exported: `AvatarProps`, `CheckboxProps`, `RadioProps`, `StatusProps`, `TooltipProps`, `MenuProps`, `ModalProps`, `EdgeLabelProps`, `NodeIconProps`, `NodeDescriptionProps`, `NodeAsPortWrapperProps`, `SegmentPickerProps` (with its controlled/uncontrolled variants), the NavButton variant prop types, and `DatePickerProps` now covers the component's full runtime surface (`value`, `defaultValue`, `placeholder`, `valueFormat`, `type`, `error`). Supporting types used in those signatures (`Shape`, `IconNode`) are exported as well. +Component prop types are now exported: `AvatarProps`, `CheckboxProps`, `RadioProps`, `StatusProps`, `TooltipProps`, `MenuProps`, `ModalProps`, `EdgeLabelProps`, `NodeIconProps`, `NodeDescriptionProps`, `NodeAsPortWrapperProps`, `SegmentPickerProps` (with its controlled/uncontrolled variants), `NavButtonProps`, and `DatePickerProps` now covers the component's full runtime surface (`value`, `defaultValue`, `placeholder`, `valueFormat`, `type`, `error`). Supporting types used in those signatures (`Shape`, `IconNode`) are exported as well. diff --git a/apps/ai-studio/src/components/controls/ai-studio-controls.tsx b/apps/ai-studio/src/components/controls/ai-studio-controls.tsx index be95533b7..51ea1582d 100644 --- a/apps/ai-studio/src/components/controls/ai-studio-controls.tsx +++ b/apps/ai-studio/src/components/controls/ai-studio-controls.tsx @@ -38,18 +38,28 @@ export function AiStudioControls() { >
{isRunning ? ( - - - + } + /> ) : ( - - - + } + /> )} {isDone && ( - - - + } + /> )}
diff --git a/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx b/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx index 7472ffbca..b65347b69 100644 --- a/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx +++ b/apps/ai-studio/src/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx @@ -12,12 +12,20 @@ export function ButtonsUndoRedo() { return ( <> - - - - - - + } + /> + } + /> ); } diff --git a/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx b/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx index 7472ffbca..b65347b69 100644 --- a/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx +++ b/apps/demo/src/app/plugins/undo-redo/components/buttons-undo-redo/buttons-undo-redo.tsx @@ -12,12 +12,20 @@ export function ButtonsUndoRedo() { return ( <> - - - - - - + } + /> + } + /> ); } diff --git a/apps/docs/scripts/ui-components.mjs b/apps/docs/scripts/ui-components.mjs index 72627dafc..d8e618a5d 100644 --- a/apps/docs/scripts/ui-components.mjs +++ b/apps/docs/scripts/ui-components.mjs @@ -30,11 +30,17 @@ export const COMPONENTS = [ ], }, { slug: 'menu', name: 'Menu', propsType: 'MenuProps', dir: 'menu' }, + { + slug: 'menu-trigger-button', + name: 'MenuTriggerButton', + propsType: 'MenuTriggerButtonProps', + dir: 'button/menu-trigger-button', + }, { slug: 'modal', name: 'Modal', propsType: 'ModalProps', dir: 'modal' }, { slug: 'nav-button', name: 'NavButton', - propsType: ['NavLabelButtonProps', 'NavIconButtonProps', 'NavIconLabelButtonProps'], + propsType: ['NavLabelButtonProps', 'NavIconButtonProps'], dir: 'button/nav-button', }, { slug: 'radio', name: 'Radio', propsType: 'RadioProps', dir: 'radio-button' }, diff --git a/apps/docs/src/components/ui-examples/menu-trigger-button.tsx b/apps/docs/src/components/ui-examples/menu-trigger-button.tsx new file mode 100644 index 000000000..51979d6ce --- /dev/null +++ b/apps/docs/src/components/ui-examples/menu-trigger-button.tsx @@ -0,0 +1,27 @@ +import { DotsThreeVertical } from '@phosphor-icons/react'; +import { Menu, MenuTriggerButton } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +import { ComponentPreview } from './component-preview'; + +export function MenuTriggerButtonExample() { + const [isOpen, setIsOpen] = useState(false); + + return ( + + {} }, + { label: 'Duplicate', onClick: () => {} }, + { label: 'Delete', destructive: true, onClick: () => {} }, + ]} + > + + + + + + ); +} diff --git a/apps/docs/src/components/ui-examples/nav-button.module.css b/apps/docs/src/components/ui-examples/nav-button.module.css new file mode 100644 index 000000000..270525845 --- /dev/null +++ b/apps/docs/src/components/ui-examples/nav-button.module.css @@ -0,0 +1,12 @@ +.rows { + display: flex; + flex-direction: column; + gap: var(--wb-space-150); +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--wb-space-100); +} diff --git a/apps/docs/src/components/ui-examples/nav-button.tsx b/apps/docs/src/components/ui-examples/nav-button.tsx index edba42241..9af095e50 100644 --- a/apps/docs/src/components/ui-examples/nav-button.tsx +++ b/apps/docs/src/components/ui-examples/nav-button.tsx @@ -1,11 +1,30 @@ -import { NavButton } from '@workflowbuilder/ui'; +import { ArrowRight, House, Plus } from '@phosphor-icons/react'; +import { NAV_BUTTON_SIZES, NavButton } from '@workflowbuilder/ui'; + +import styles from './nav-button.module.css'; import { ComponentPreview } from './component-preview'; export function NavButtonExample() { return ( - Nav button +
+
+ }>Square + }> + Round + + } /> + } suffixIcon={}> + Selected + +
+
+ {NAV_BUTTON_SIZES.map((size) => ( + } /> + ))} +
+
); } diff --git a/apps/docs/src/content/docs/ui-library/ui-components/index.mdx b/apps/docs/src/content/docs/ui-library/ui-components/index.mdx index e2d67c132..88561ab3a 100644 --- a/apps/docs/src/content/docs/ui-library/ui-components/index.mdx +++ b/apps/docs/src/content/docs/ui-library/ui-components/index.mdx @@ -17,6 +17,7 @@ live, interactive example plus the component's props and CSS variables. - [DatePicker](/ui-library/ui-components/date-picker/) - Date selection with a calendar popover. - [Input](/ui-library/ui-components/input/) - Text input with icons and explicit field states. - [Menu](/ui-library/ui-components/menu/) - Popup menu for dropdowns. +- [MenuTriggerButton](/ui-library/ui-components/menu-trigger-button/) - Icon button for opening a menu. - [Modal](/ui-library/ui-components/modal/) - Dialog overlay with a backdrop. - [NavButton](/ui-library/ui-components/nav-button/) - Compact icon / label navigation button. - [Radio](/ui-library/ui-components/radio/) - Radio button for a single choice. diff --git a/apps/docs/src/content/docs/ui-library/ui-components/menu-trigger-button.mdx b/apps/docs/src/content/docs/ui-library/ui-components/menu-trigger-button.mdx new file mode 100644 index 000000000..144aa64c6 --- /dev/null +++ b/apps/docs/src/content/docs/ui-library/ui-components/menu-trigger-button.mdx @@ -0,0 +1,44 @@ +--- +title: Menu Trigger Button +description: A compact icon button that reflects whether its menu is open. +--- + +import CssVariablesTable from '../../../../components/api/css-variables-table.astro'; +import PropsTable from '../../../../components/api/props-table.astro'; +import { MenuTriggerButtonExample } from '../../../../components/ui-examples/menu-trigger-button'; + +`MenuTriggerButton` is an icon-only NavButton composition for menu triggers. +Set `isOpen` from the menu state to render the persistent Pressed state. + + + +## Usage + +```tsx +import { DotsThreeVertical } from '@phosphor-icons/react'; +import { Menu, MenuTriggerButton } from '@workflowbuilder/ui'; +import { useState } from 'react'; + +function Example() { + const [isOpen, setIsOpen] = useState(false); + + return ( + {} }]} open={isOpen} onOpenChange={setIsOpen}> + + + + + ); +} +``` + +## Props + + + +## CSS variables + +It renders a `NavButton`, so its appearance is customized through the +[NavButton variables](/ui-library/ui-components/nav-button/#css-variables). + + diff --git a/apps/docs/src/content/docs/ui-library/ui-components/nav-button.mdx b/apps/docs/src/content/docs/ui-library/ui-components/nav-button.mdx index fd60a6659..f0ca50008 100644 --- a/apps/docs/src/content/docs/ui-library/ui-components/nav-button.mdx +++ b/apps/docs/src/content/docs/ui-library/ui-components/nav-button.mdx @@ -1,26 +1,34 @@ --- title: Nav Button -description: A compact, type-safe navigation button with a selected state. +description: A compact navigation button with explicit content slots and a selected state. --- import CssVariablesTable from '../../../../components/api/css-variables-table.astro'; import PropsTable from '../../../../components/api/props-table.astro'; import { NavButtonExample } from '../../../../components/ui-examples/nav-button'; -`NavButton` is a compact, type-safe navigation button. Like `Button`, it -automatically selects the correct type (label, icon, or icon + label) based on -the structure of its `children`, and it adds an `isSelected` state for marking -the active item. +`NavButton` renders its label from `children` and accepts optional `prefixIcon` +and `suffixIcon` props. Omit `children` and provide `prefixIcon` for an icon-only +button. Use `variant` to select square or round styling; icon-only buttons also +support the no-background `plain` variant. + +The `isSelected` prop renders the persistent Pressed state, including while the +button is hovered. The mouse-down `:active` state uses a separate Active token. ## Usage ```tsx +import { House } from '@phosphor-icons/react'; import { NavButton } from '@workflowbuilder/ui'; function Example() { - return Overview; + return ( + }> + Overview + + ); } ``` diff --git a/packages/sdk/src/features/app-bar/components/controls/controls.tsx b/packages/sdk/src/features/app-bar/components/controls/controls.tsx index bbcf74b27..b94340a2e 100644 --- a/packages/sdk/src/features/app-bar/components/controls/controls.tsx +++ b/packages/sdk/src/features/app-bar/components/controls/controls.tsx @@ -1,6 +1,6 @@ import { DotsThreeVertical } from '@phosphor-icons/react'; -import { Menu, type MenuItemProps, NavButton } from '@workflowbuilder/ui'; -import { useMemo } from 'react'; +import { Menu, type MenuItemProps, MenuTriggerButton } from '@workflowbuilder/ui'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styles from '../../app-bar.module.css'; @@ -12,6 +12,7 @@ import { ToggleReadyOnlyMode } from '../toggle-read-only-mode/toggle-read-only-m export function Controls() { const { t } = useTranslation(); + const [isMenuOpen, setIsMenuOpen] = useState(false); // eslint-disable-next-line react-hooks/exhaustive-deps const items: MenuItemProps[] = useMemo(() => getControlsDotsItems(), [t]); @@ -24,10 +25,10 @@ export function Controls() { {items.length > 0 && (
- - + + - +
)} diff --git a/packages/sdk/src/features/app-bar/components/project-selection/project-selection.spec.tsx b/packages/sdk/src/features/app-bar/components/project-selection/project-selection.spec.tsx index ffe55ca71..f51e0ce71 100644 --- a/packages/sdk/src/features/app-bar/components/project-selection/project-selection.spec.tsx +++ b/packages/sdk/src/features/app-bar/components/project-selection/project-selection.spec.tsx @@ -3,7 +3,7 @@ import type { MenuItemProps } from '@workflowbuilder/ui'; import { describe, expect, it, vi } from 'vitest'; // Render the menu's `items` inline so we can assert on them without driving the -// real menu popover. The component also imports Input/NavButton, so the +// real menu popover. The component also imports Input/MenuTriggerButton, so the // mock must expose them too. vi.mock('@workflowbuilder/ui', () => ({ Menu: ({ items }: { items: MenuItemProps[] }) => ( @@ -15,7 +15,7 @@ vi.mock('@workflowbuilder/ui', () => ({ ))} ), - NavButton: () => null, + MenuTriggerButton: () => null, Input: () => null, })); diff --git a/packages/sdk/src/features/app-bar/components/project-selection/project-selection.tsx b/packages/sdk/src/features/app-bar/components/project-selection/project-selection.tsx index e25a62748..2ec71b5ec 100644 --- a/packages/sdk/src/features/app-bar/components/project-selection/project-selection.tsx +++ b/packages/sdk/src/features/app-bar/components/project-selection/project-selection.tsx @@ -1,5 +1,5 @@ import { CaretDown } from '@phosphor-icons/react'; -import { Input, Menu, NavButton } from '@workflowbuilder/ui'; +import { Input, Menu, MenuTriggerButton } from '@workflowbuilder/ui'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -39,6 +39,7 @@ function ProjectSelectionComponent({ onDuplicateClick }: ProjectSelectionProps) const isReadOnlyMode = useStore((store) => store.isReadOnlyMode); const setDocumentName = useStore((state) => state.setDocumentName); const [editName, setEditName] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); const { t } = useTranslation(); @@ -88,10 +89,14 @@ function ProjectSelectionComponent({ onDuplicateClick }: ProjectSelectionProps) )}
- - + + - +
diff --git a/packages/sdk/src/features/i18n/components/language-selector/language-selector.module.css b/packages/sdk/src/features/i18n/components/language-selector/language-selector.module.css deleted file mode 100644 index 5cb959f0f..000000000 --- a/packages/sdk/src/features/i18n/components/language-selector/language-selector.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.title { - composes: ax-public-p9 from global; - - color: var(--wb-app-bar-diagram-title-color); - padding-right: 0.5rem; -} diff --git a/packages/sdk/src/features/i18n/components/language-selector/language-selector.spec.tsx b/packages/sdk/src/features/i18n/components/language-selector/language-selector.spec.tsx index 3ad24e990..cbe1bc985 100644 --- a/packages/sdk/src/features/i18n/components/language-selector/language-selector.spec.tsx +++ b/packages/sdk/src/features/i18n/components/language-selector/language-selector.spec.tsx @@ -8,7 +8,11 @@ const i18nState = { language: 'en', resolvedLanguage: 'en', changeLanguage: vi.f // Render the Menu's trigger (children) so the displayed language code is queryable. vi.mock('@workflowbuilder/ui', () => ({ Menu: ({ children }: { children?: ReactNode }) =>
{children}
, - NavButton: ({ children }: { children?: ReactNode }) => , + NavButton: ({ 'aria-label': ariaLabel, children }: { 'aria-label'?: string; children?: ReactNode }) => ( + + ), })); vi.mock('@workflow-builder/icons', () => ({ @@ -29,6 +33,7 @@ describe('LanguageSelector — label reflects the resolved language', () => { render(); expect(screen.getByText('PL')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'PL - tooltips.changeLanguage' })).toBeTruthy(); expect(screen.queryByText('EN')).toBeNull(); }); diff --git a/packages/sdk/src/features/i18n/components/language-selector/language-selector.tsx b/packages/sdk/src/features/i18n/components/language-selector/language-selector.tsx index 7d0b53d4e..4ba674773 100644 --- a/packages/sdk/src/features/i18n/components/language-selector/language-selector.tsx +++ b/packages/sdk/src/features/i18n/components/language-selector/language-selector.tsx @@ -5,8 +5,6 @@ import { useTranslation } from 'react-i18next'; import { Icon } from '@workflow-builder/icons'; -import styles from './language-selector.module.css'; - type Language = { code: string; label: string; @@ -22,6 +20,7 @@ export function LanguageSelector() { const resolvedCode = i18n.resolvedLanguage ?? i18n.language?.split('-')[0]; const currentLanguage = languages.find((lang) => lang.code === resolvedCode) || languages[0]; + const visibleCode = currentLanguage.code.toUpperCase(); const languageItems: MenuItemProps[] = useMemo( () => @@ -36,11 +35,12 @@ export function LanguageSelector() { return ( <> - - <> - {currentLanguage.code.toUpperCase()} - - + } + tooltip={t('tooltips.changeLanguage')} + > + {visibleCode} diff --git a/packages/sdk/src/features/i18n/locales/en.ts b/packages/sdk/src/features/i18n/locales/en.ts index 3ac14147f..e21f1e74a 100644 --- a/packages/sdk/src/features/i18n/locales/en.ts +++ b/packages/sdk/src/features/i18n/locales/en.ts @@ -160,7 +160,7 @@ export const en = { cantRemoveOnlyOption: "You can't remove the only option", addOption: 'Add Option', menu: 'Menu', - pickTheProject: 'Pick the project', + pickTheProject: 'Project actions', openPalette: 'Open palette', closePalette: 'Close palette', openPropertiesBar: 'Open properties bar', diff --git a/packages/sdk/src/features/i18n/locales/pl.ts b/packages/sdk/src/features/i18n/locales/pl.ts index e8e71cbbd..3fd7ae020 100644 --- a/packages/sdk/src/features/i18n/locales/pl.ts +++ b/packages/sdk/src/features/i18n/locales/pl.ts @@ -123,7 +123,7 @@ export const pl = { cantRemoveOnlyOption: 'Nie możesz usunąć jedynej opcji', addOption: 'Dodaj opcję', menu: 'Menu', - pickTheProject: 'Wybierz projekt', + pickTheProject: 'Akcje projektu', openPalette: 'Otwórz paletę', closePalette: 'Zamknij paletę', openPropertiesBar: 'Otwórz panel właściwości', diff --git a/packages/sdk/src/features/integration/components/save-button/save-button.tsx b/packages/sdk/src/features/integration/components/save-button/save-button.tsx index 01a9d3954..0301a6c90 100644 --- a/packages/sdk/src/features/integration/components/save-button/save-button.tsx +++ b/packages/sdk/src/features/integration/components/save-button/save-button.tsx @@ -21,11 +21,16 @@ export function SaveButton() { useAutoSaveOnClose(); return ( - - <> - - - - + + + + + } + /> ); } diff --git a/packages/sdk/src/features/json-form/controls/ai-tools-control/ai-tools-control.tsx b/packages/sdk/src/features/json-form/controls/ai-tools-control/ai-tools-control.tsx index 1a49025a2..a1c019cd3 100644 --- a/packages/sdk/src/features/json-form/controls/ai-tools-control/ai-tools-control.tsx +++ b/packages/sdk/src/features/json-form/controls/ai-tools-control/ai-tools-control.tsx @@ -16,7 +16,7 @@ import { openAddToolModal } from './open-add-tool-modal'; import { toolOptions } from './select-options'; function AiToolsControl({ path, handleChange, data, enabled, uischema }: AiToolsControlProps) { - const { t } = useTranslation(undefined, { keyPrefix: 'aiTools' }); + const { t } = useTranslation(); const isDisabled = !enabled || uischema.disabled === true; const handleSubmit = useCallback( (change: AiAgentTool) => { @@ -74,15 +74,18 @@ function AiToolsControl({ path, handleChange, data, enabled, uischema }: AiTools ) : ( )} - onRemoveTool(toolData.id)} disabled={isDisabled}> - - + onRemoveTool(toolData.id)} + disabled={isDisabled} + prefixIcon={} + /> ); })} ); diff --git a/packages/sdk/src/features/json-form/controls/decision-branches-control/branch-card/branch-card.tsx b/packages/sdk/src/features/json-form/controls/decision-branches-control/branch-card/branch-card.tsx index 6cc20dd6f..4cf2d8722 100644 --- a/packages/sdk/src/features/json-form/controls/decision-branches-control/branch-card/branch-card.tsx +++ b/packages/sdk/src/features/json-form/controls/decision-branches-control/branch-card/branch-card.tsx @@ -61,12 +61,12 @@ export function BranchCard({ branch, index, onUpdate, onRemove, enabled = true }

{t('decisionBranches.branch', { index: index + 1 })}

- - - - - - + } + /> + } />
diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-control.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-control.tsx index 99beb7c6f..4dcc0855c 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-control.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-control.tsx @@ -44,9 +44,14 @@ function DynamicConditionsControl(props: DynamicConditionsControlProps) {
{t('title')} - - - + } + />
diff --git a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx index b79c8153e..068936934 100644 --- a/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx +++ b/packages/sdk/src/features/json-form/controls/dynamic-conditions-control/dynamic-conditions-form-field/conditions-form-field.tsx @@ -91,9 +91,12 @@ export function ConditionsFormField(props: ConditionsFormFieldProps) { [styles['container-error']]: shouldShowValidation && (!condition.x || !condition.y), })} > - {}} tooltip={t('tooltips.menu')}> - - + {}} + tooltip={t('tooltips.menu')} + prefixIcon={} + />
- - - + } + />
); diff --git a/packages/sdk/src/features/palette/components/header/palette-header.tsx b/packages/sdk/src/features/palette/components/header/palette-header.tsx index a4ad59477..f6fc7ccae 100644 --- a/packages/sdk/src/features/palette/components/header/palette-header.tsx +++ b/packages/sdk/src/features/palette/components/header/palette-header.tsx @@ -17,12 +17,12 @@ export function PaletteHeader({ onClick, isSidebarExpanded }: PaletteHeaderProps
{t('palette.nodesLibrary')} - - + prefixIcon={} + />
); } diff --git a/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx b/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx index 059c7a0e9..3fb0a2c23 100644 --- a/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx +++ b/packages/sdk/src/features/properties-bar/components/header/properties-bar-header.tsx @@ -27,21 +27,24 @@ export function PropertiesBarHeader({ return (
- - + prefixIcon={} + />
{header} {name &&

{name}

}
{onDotsClick && ( - - - + } + /> )}
); diff --git a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.module.css b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.module.css deleted file mode 100644 index e0ff1d2ed..000000000 --- a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.button-toggle { - padding: 0; - margin: 0; -} diff --git a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx index d079c1ccb..23e7a6621 100644 --- a/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx +++ b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx @@ -4,8 +4,6 @@ import { useTranslation } from 'react-i18next'; import { Icon } from '@workflow-builder/icons'; -import styles from './dynamic-typed-variable-or-input.module.css'; - import type { VariableTypePrimitive } from '../../../../node/node-output-schema'; import { getIsSingleVariable } from '../../actions/get-is-single-variable'; import { filterSuggestionGroupsByType } from '../../utils/filter-suggestion-groups-by-type'; @@ -60,9 +58,14 @@ export function DynamicTypedVariableOrInput({ suggestionGroups={suggestionGroupsForType} hasError={isError} endAdornment={ - - - + } + size="xs" + variant="plain" + /> } /> ); @@ -80,12 +83,13 @@ export function DynamicTypedVariableOrInput({ endAdornment={ suggestionGroupsForType.length > 0 ? ( - - + prefixIcon={} + size="xs" + variant="plain" + /> ) : undefined } /> diff --git a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx index 8acc7b417..0905ac537 100644 --- a/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx +++ b/packages/sdk/src/features/variables/components/variable-text/variable-text.tsx @@ -105,6 +105,7 @@ function SuggestionsContainer({ renderGroupHeader: (group: VariableSuggestionGroup) => ReactNode; children: ReactElement; }) { + const { t } = useTranslation(); const ul = children as ReactElement<{ children?: ReactElement[]; className?: string }>; const items = ul.props.children; @@ -149,13 +150,13 @@ function SuggestionsContainer({
{title} { event.stopPropagation(); handleClose(); }} - > - - + prefixIcon={} + />
{cloneElement(ul, {}, grouped)}
diff --git a/packages/sdk/src/features/variables/modals/settings/settings-navigation.module.css b/packages/sdk/src/features/variables/modals/settings/settings-navigation.module.css index dfd4f4f03..fb4342c84 100644 --- a/packages/sdk/src/features/variables/modals/settings/settings-navigation.module.css +++ b/packages/sdk/src/features/variables/modals/settings/settings-navigation.module.css @@ -36,7 +36,7 @@ &:hover, &.button--active { - background: var(--ax-public-button-nav-background-color); + background: var(--ax-public-nav-button-background-color-hover); } &.button--active { diff --git a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-preview.tsx b/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-preview.tsx index a872a1f5c..b4c76fab5 100644 --- a/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-preview.tsx +++ b/packages/sdk/src/features/variables/modals/tab-global-variables/variable-preview/variable-preview.tsx @@ -29,14 +29,22 @@ export function VariablePreview({ id, onEdit, onRemove }: Props) {
{onEdit && ( - - - + } + /> )} {onRemove && ( - - - + } + /> )}
diff --git a/packages/sdk/src/features/variables/modals/tab/tab-header.tsx b/packages/sdk/src/features/variables/modals/tab/tab-header.tsx index 2d417d231..bbde7c994 100644 --- a/packages/sdk/src/features/variables/modals/tab/tab-header.tsx +++ b/packages/sdk/src/features/variables/modals/tab/tab-header.tsx @@ -23,9 +23,13 @@ export function TabHeader({ title, description, onGoBack, children, className = return (
{onGoBack && ( - - - + } + /> )}

{translateIfPossible(title) || title}

diff --git a/packages/ui/src/components/button/guards.spec.ts b/packages/ui/src/components/button/guards.spec.ts deleted file mode 100644 index a9738caf1..000000000 --- a/packages/ui/src/components/button/guards.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { type ReactNode, createElement } from 'react'; - -import { hasChildrenWithStringAndIcons, hasIconChildrenOnly, hasStringChildrenOnly } from './guards'; - -const icon = createElement('span', null, 'icon'); -const otherIcon = createElement('svg'); - -describe('hasIconChildrenOnly', () => { - it('returns true for a single element child', () => { - expect(hasIconChildrenOnly({ children: icon })).toBe(true); - }); - - it('returns true for a function child', () => { - // A render-prop function is not a valid `ReactNode`, but the guard - // accepts it at runtime via a `typeof === 'function'` check. - const functionChild = (() => null) as unknown as ReactNode; - expect(hasIconChildrenOnly({ children: functionChild })).toBe(true); - }); - - it('returns false for a string child', () => { - expect(hasIconChildrenOnly({ children: 'label' })).toBe(false); - }); - - it('returns false for an array of children', () => { - expect(hasIconChildrenOnly({ children: ['label', icon] })).toBe(false); - }); - - it('returns false for number/boolean/null children', () => { - expect(hasIconChildrenOnly({ children: 42 })).toBe(false); - expect(hasIconChildrenOnly({ children: true })).toBe(false); - expect(hasIconChildrenOnly({ children: null })).toBe(false); - }); -}); - -describe('hasChildrenWithStringAndIcons', () => { - it('returns true for [string, element]', () => { - expect(hasChildrenWithStringAndIcons({ children: ['label', icon] })).toBe(true); - }); - - it('returns true for [element, string]', () => { - expect(hasChildrenWithStringAndIcons({ children: [icon, 'label'] })).toBe(true); - }); - - it('returns true for two elements', () => { - expect(hasChildrenWithStringAndIcons({ children: [icon, otherIcon] })).toBe(true); - }); - - it('returns false for an array shorter than 2', () => { - expect(hasChildrenWithStringAndIcons({ children: [icon] })).toBe(false); - }); - - it('returns false for a single string child (not an array)', () => { - expect(hasChildrenWithStringAndIcons({ children: 'label' })).toBe(false); - }); - - it('returns false for a single element child (not an array)', () => { - expect(hasChildrenWithStringAndIcons({ children: icon })).toBe(false); - }); - - it('returns false when an entry is number/boolean/null', () => { - expect(hasChildrenWithStringAndIcons({ children: [42, icon] })).toBe(false); - expect(hasChildrenWithStringAndIcons({ children: [icon, null] })).toBe(false); - expect(hasChildrenWithStringAndIcons({ children: [true, false] })).toBe(false); - }); -}); - -describe('hasStringChildrenOnly', () => { - it('returns true for a string child', () => { - expect(hasStringChildrenOnly({ children: 'label' })).toBe(true); - }); - - it('returns false for an element child', () => { - expect(hasStringChildrenOnly({ children: icon })).toBe(false); - }); - - it('returns false for an array child', () => { - expect(hasStringChildrenOnly({ children: ['label', icon] })).toBe(false); - }); - - it('returns false for number/boolean/null children', () => { - expect(hasStringChildrenOnly({ children: 42 })).toBe(false); - expect(hasStringChildrenOnly({ children: true })).toBe(false); - expect(hasStringChildrenOnly({ children: null })).toBe(false); - }); -}); diff --git a/packages/ui/src/components/button/guards.ts b/packages/ui/src/components/button/guards.ts deleted file mode 100644 index 6ba289aa2..000000000 --- a/packages/ui/src/components/button/guards.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { PropsWithChildren, isValidElement } from 'react'; - -export function hasIconChildrenOnly(props: PropsWithChildren): props is PropsWithChildren { - return isValidElement(props.children) || typeof props.children === 'function'; -} - -export function hasChildrenWithStringAndIcons(props: PropsWithChildren): props is PropsWithChildren { - return ( - Array.isArray(props.children) && - props.children.length >= 2 && - (typeof props.children[0] === 'string' || isValidElement(props.children[0])) && - (typeof props.children[1] === 'string' || isValidElement(props.children[1])) - ); -} - -export function hasStringChildrenOnly(props: PropsWithChildren): props is PropsWithChildren { - return typeof (props as PropsWithChildren).children === 'string'; -} diff --git a/packages/ui/src/components/button/index.ts b/packages/ui/src/components/button/index.ts index bf89cdc25..dfa1db191 100644 --- a/packages/ui/src/components/button/index.ts +++ b/packages/ui/src/components/button/index.ts @@ -1,3 +1,4 @@ +export * from './menu-trigger-button'; export * from './nav-button/nav-button'; export * from './nav-button/types'; export * from './regular-button/button'; diff --git a/packages/ui/src/components/button/menu-trigger-button/index.ts b/packages/ui/src/components/button/menu-trigger-button/index.ts new file mode 100644 index 000000000..a546b3652 --- /dev/null +++ b/packages/ui/src/components/button/menu-trigger-button/index.ts @@ -0,0 +1 @@ +export * from './menu-trigger-button'; diff --git a/packages/ui/src/components/button/menu-trigger-button/menu-trigger-button.tsx b/packages/ui/src/components/button/menu-trigger-button/menu-trigger-button.tsx new file mode 100644 index 000000000..6961e3546 --- /dev/null +++ b/packages/ui/src/components/button/menu-trigger-button/menu-trigger-button.tsx @@ -0,0 +1,20 @@ +import { forwardRef } from 'react'; + +import { NavButton } from '../nav-button/nav-button'; +import type { NavButtonSize, NavButtonVariant } from '../nav-button/types'; +import type { BaseButtonProps, IconNode } from '../types'; + +export type MenuTriggerButtonProps = BaseButtonProps & { + children: IconNode; + isOpen?: boolean; + /** @default 'm' */ + size?: NavButtonSize; + /** @default 'square' */ + variant?: NavButtonVariant; +}; + +export const MenuTriggerButton = forwardRef( + ({ children, isOpen = false, ...props }, ref) => ( + + ), +); diff --git a/packages/ui/src/components/button/nav-button/nav-button.module.css b/packages/ui/src/components/button/nav-button/nav-button.module.css new file mode 100644 index 000000000..648b52ad6 --- /dev/null +++ b/packages/ui/src/components/button/nav-button/nav-button.module.css @@ -0,0 +1,206 @@ +:root { + --ax-public-nav-button-background-color-default: var(--wb-components-nav-button-bg-primary-default); + --ax-public-nav-button-background-color-hover: var(--wb-components-nav-button-bg-primary-hover); + --ax-public-nav-button-background-color-pressed: var(--wb-components-nav-button-bg-primary-pressed); + --ax-public-nav-button-background-color-active: var(--wb-components-nav-button-bg-primary-active); + --ax-public-nav-button-background-color-focus: var(--wb-components-nav-button-bg-primary-focus); + --ax-public-nav-button-background-color-disabled: var(--wb-components-nav-button-bg-primary-disabled); + --ax-public-nav-button-color-default: var(--wb-components-nav-button-icon-primary-default); + --ax-public-nav-button-color-hover: var(--wb-components-nav-button-icon-primary-hover); + --ax-public-nav-button-color-pressed: var(--wb-components-nav-button-icon-primary-pressed); + --ax-public-nav-button-color-active: var(--wb-components-nav-button-icon-primary-active); + --ax-public-nav-button-color-disabled: var(--wb-components-nav-button-icon-primary-disabled); + --ax-public-nav-button-height-xl: var(--wb-size-600); + --ax-public-nav-button-height-l: calc(var(--wb-size-500) + var(--wb-size-25)); + --ax-public-nav-button-height-m: calc(var(--wb-size-400) + var(--wb-size-50)); + --ax-public-nav-button-height-s: var(--wb-size-400); + --ax-public-nav-button-height-xs: calc(var(--wb-size-300) + var(--wb-size-50)); + --ax-public-nav-button-height-xxs: var(--wb-size-300); + --ax-public-nav-button-height-xxxs: calc(var(--wb-size-200) + var(--wb-size-50)); + --ax-public-nav-button-padding-xl: var(--wb-space-150); + --ax-public-nav-button-padding-l: var(--wb-space-137); + --ax-public-nav-button-padding-m: var(--wb-space-112); + --ax-public-nav-button-padding-s: var(--wb-space-87); + --ax-public-nav-button-padding-xs: var(--wb-space-75); + --ax-public-nav-button-padding-xxs: var(--wb-space-75); + --ax-public-nav-button-padding-xxxs: var(--wb-space-50); + --ax-public-nav-button-label-padding-inline-xl: var(--wb-space-150); + --ax-public-nav-button-label-padding-inline-l: var(--wb-space-150); + --ax-public-nav-button-label-padding-inline-m: var(--wb-space-112); + --ax-public-nav-button-label-padding-inline-s: var(--wb-space-87); + --ax-public-nav-button-label-padding-inline-xs: var(--wb-space-75); + --ax-public-nav-button-label-padding-inline-xxs: var(--wb-space-75); + --ax-public-nav-button-label-padding-inline-xxxs: var(--wb-space-50); + --ax-public-nav-button-gap-xl: var(--wb-space-100); + --ax-public-nav-button-gap-l: var(--wb-space-100); + --ax-public-nav-button-gap-m: var(--wb-space-100); + --ax-public-nav-button-gap-s: var(--wb-space-100); + --ax-public-nav-button-gap-xs: var(--wb-space-100); + --ax-public-nav-button-gap-xxs: var(--wb-space-75); + --ax-public-nav-button-gap-xxxs: var(--wb-space-75); + --ax-public-nav-button-border-radius-xl: var(--wb-radius-100); + --ax-public-nav-button-border-radius-l: var(--wb-radius-100); + --ax-public-nav-button-border-radius-m: var(--wb-radius-75); + --ax-public-nav-button-border-radius-s: var(--wb-radius-50); + --ax-public-nav-button-border-radius-xs: var(--wb-radius-50); + --ax-public-nav-button-border-radius-xxs: var(--wb-radius-50); + --ax-public-nav-button-border-radius-xxxs: var(--wb-radius-50); + --ax-public-nav-button-border-radius-round: var(--wb-radius-full); + --ax-public-nav-button-icon-size-xl: var(--wb-size-300); + --ax-public-nav-button-icon-size-l: calc(var(--wb-size-200) + var(--wb-size-50)); + --ax-public-nav-button-icon-size-m: calc(var(--wb-size-200) + var(--wb-size-25)); + --ax-public-nav-button-icon-size-s: calc(var(--wb-size-200) + var(--wb-size-25)); + --ax-public-nav-button-icon-size-xs: var(--wb-size-200); + --ax-public-nav-button-icon-size-xxs: var(--wb-size-150); + --ax-public-nav-button-icon-size-xxxs: var(--wb-size-150); +} + +@layer ui.component { + .nav-button { + box-sizing: border-box; + height: var(--nav-button-height); + padding-block: var(--nav-button-padding); + padding-inline: var(--nav-button-label-padding-inline); + gap: var(--nav-button-gap); + border-radius: var(--nav-button-border-radius); + background-color: var(--nav-button-background-default); + color: var(--ax-public-nav-button-color-default); + + &:not(:disabled):hover { + background-color: var(--nav-button-background-hover); + color: var(--ax-public-nav-button-color-hover); + } + + &.selected:not(:disabled) { + background-color: var(--nav-button-background-selected); + color: var(--ax-public-nav-button-color-pressed); + } + + &:not(.selected):not(:disabled):focus-visible { + background-color: var(--nav-button-background-focus); + color: var(--ax-public-nav-button-color-default); + } + + &:not(:disabled):active { + background-color: var(--nav-button-background-active); + color: var(--ax-public-nav-button-color-active); + } + + &:disabled { + background-color: var(--nav-button-background-disabled); + color: var(--ax-public-nav-button-color-disabled); + } + } + + .icon-only { + width: var(--nav-button-height); + padding: var(--nav-button-padding); + } + + .icon { + composes: icon from '../styles/icon-size.module.css'; + + width: var(--nav-button-icon-size); + height: var(--nav-button-icon-size); + } + + .xl { + --nav-button-height: var(--ax-public-nav-button-height-xl); + --nav-button-padding: var(--ax-public-nav-button-padding-xl); + --nav-button-label-padding-inline: var(--ax-public-nav-button-label-padding-inline-xl); + --nav-button-gap: var(--ax-public-nav-button-gap-xl); + --nav-button-border-radius: var(--ax-public-nav-button-border-radius-xl); + --nav-button-icon-size: var(--ax-public-nav-button-icon-size-xl); + + composes: wb-text-label-xl from global; + } + + .l { + --nav-button-height: var(--ax-public-nav-button-height-l); + --nav-button-padding: var(--ax-public-nav-button-padding-l); + --nav-button-label-padding-inline: var(--ax-public-nav-button-label-padding-inline-l); + --nav-button-gap: var(--ax-public-nav-button-gap-l); + --nav-button-border-radius: var(--ax-public-nav-button-border-radius-l); + --nav-button-icon-size: var(--ax-public-nav-button-icon-size-l); + + composes: wb-text-label-l from global; + } + + .m { + --nav-button-height: var(--ax-public-nav-button-height-m); + --nav-button-padding: var(--ax-public-nav-button-padding-m); + --nav-button-label-padding-inline: var(--ax-public-nav-button-label-padding-inline-m); + --nav-button-gap: var(--ax-public-nav-button-gap-m); + --nav-button-border-radius: var(--ax-public-nav-button-border-radius-m); + --nav-button-icon-size: var(--ax-public-nav-button-icon-size-m); + + composes: wb-text-label-m from global; + } + + .s { + --nav-button-height: var(--ax-public-nav-button-height-s); + --nav-button-padding: var(--ax-public-nav-button-padding-s); + --nav-button-label-padding-inline: var(--ax-public-nav-button-label-padding-inline-s); + --nav-button-gap: var(--ax-public-nav-button-gap-s); + --nav-button-border-radius: var(--ax-public-nav-button-border-radius-s); + --nav-button-icon-size: var(--ax-public-nav-button-icon-size-s); + + composes: wb-text-label-m from global; + } + + .xs { + --nav-button-height: var(--ax-public-nav-button-height-xs); + --nav-button-padding: var(--ax-public-nav-button-padding-xs); + --nav-button-label-padding-inline: var(--ax-public-nav-button-label-padding-inline-xs); + --nav-button-gap: var(--ax-public-nav-button-gap-xs); + --nav-button-border-radius: var(--ax-public-nav-button-border-radius-xs); + --nav-button-icon-size: var(--ax-public-nav-button-icon-size-xs); + + composes: wb-text-label-m from global; + } + + .xxs { + --nav-button-height: var(--ax-public-nav-button-height-xxs); + --nav-button-padding: var(--ax-public-nav-button-padding-xxs); + --nav-button-label-padding-inline: var(--ax-public-nav-button-label-padding-inline-xxs); + --nav-button-gap: var(--ax-public-nav-button-gap-xxs); + --nav-button-border-radius: var(--ax-public-nav-button-border-radius-xxs); + --nav-button-icon-size: var(--ax-public-nav-button-icon-size-xxs); + + composes: wb-text-label-m from global; + } + + .xxxs { + --nav-button-height: var(--ax-public-nav-button-height-xxxs); + --nav-button-padding: var(--ax-public-nav-button-padding-xxxs); + --nav-button-label-padding-inline: var(--ax-public-nav-button-label-padding-inline-xxxs); + --nav-button-gap: var(--ax-public-nav-button-gap-xxxs); + --nav-button-border-radius: var(--ax-public-nav-button-border-radius-xxxs); + --nav-button-icon-size: var(--ax-public-nav-button-icon-size-xxxs); + + composes: wb-text-label-m from global; + } + + .round { + --nav-button-border-radius: var(--ax-public-nav-button-border-radius-round); + } + + .square, + .round { + --nav-button-background-default: var(--ax-public-nav-button-background-color-default); + --nav-button-background-hover: var(--ax-public-nav-button-background-color-hover); + --nav-button-background-selected: var(--ax-public-nav-button-background-color-pressed); + --nav-button-background-focus: var(--ax-public-nav-button-background-color-focus); + --nav-button-background-active: var(--ax-public-nav-button-background-color-active); + --nav-button-background-disabled: var(--ax-public-nav-button-background-color-disabled); + } + + .plain { + --nav-button-background-default: var(--wb-colors-transparent); + --nav-button-background-hover: var(--wb-colors-transparent); + --nav-button-background-selected: var(--wb-colors-transparent); + --nav-button-background-focus: var(--wb-colors-transparent); + --nav-button-background-active: var(--wb-colors-transparent); + --nav-button-background-disabled: var(--wb-colors-transparent); + } +} diff --git a/packages/ui/src/components/button/nav-button/nav-button.spec.tsx b/packages/ui/src/components/button/nav-button/nav-button.spec.tsx new file mode 100644 index 000000000..53fc52ac5 --- /dev/null +++ b/packages/ui/src/components/button/nav-button/nav-button.spec.tsx @@ -0,0 +1,46 @@ +import { type ReactNode, act } from 'react'; +import { type Root, createRoot } from 'react-dom/client'; + +import { NavButton } from './nav-button'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +const emptyChildren: Array<[string, ReactNode]> = [ + ['null', null], + ['false', false], + ['empty string', ''], +]; + +const iconSlots = ['prefixIcon', 'suffixIcon'] as const; + +it.each(emptyChildren.flatMap(([label, child]) => iconSlots.map((slot) => [label, slot, child] as const)))( + 'renders the %s child with the %s slot', + (_label, slot, child) => { + const icon = ; + const slotProps = slot === 'prefixIcon' ? { prefixIcon: icon } : { suffixIcon: icon }; + + act(() => { + root.render( + + {child} + , + ); + }); + + expect(container.querySelector(`svg[data-icon-slot="${slot}"]`)).not.toBeNull(); + }, +); diff --git a/packages/ui/src/components/button/nav-button/nav-button.tsx b/packages/ui/src/components/button/nav-button/nav-button.tsx index 68021c8a6..da8b0c5d6 100644 --- a/packages/ui/src/components/button/nav-button/nav-button.tsx +++ b/packages/ui/src/components/button/nav-button/nav-button.tsx @@ -1,109 +1,44 @@ import clsx from 'clsx'; -import { ReactElement, forwardRef } from 'react'; - -import navBorderRadiusStyles from './styles/nav-button-border-radius.module.css'; -import navButtonStyles from './styles/nav-button.module.css'; - -import { hasChildrenWithStringAndIcons, hasIconChildrenOnly, hasStringChildrenOnly } from '../guards'; -import { NavIconButton, NavIconButtonProps } from './nav-icon-button/nav-icon-button'; -import { NavIconLabelButton, NavIconLabelButtonProps } from './nav-icon-label-button/nav-icon-label-button'; -import { NavLabelButton, NavLabelButtonProps } from './nav-label-button/nav-label-button'; - -type WithRef = T & { - ref?: React.Ref; -}; - -/** - * NavButtonProps defines **discriminated overloads** for the Button component using - * **structural discrimination** rather than a `type` field. - * - * The component dynamically determines which button variant to render based on the - * **structure of the `children` prop**: - * - * - If `children` is a single `string`, it's treated as a **Label Button**. - * - If `children` is a single icon (ReactElement), it's treated as an **Icon Button**. - * - If `children` includes both a string and one or two icons (before/after), - * it's treated as an **Icon Label Button**. - * - * Based on the inferred variant, **only props specific to that variant are allowed**. - * This ensures that incorrect prop combinations (e.g., passing label-specific props - * to an Icon Button) are caught at compile time. - * - * This is intentionally implemented with **overloads** instead of a union type, - * which would incorrectly allow mixing props between types and compromise type safety. - */ -type NavButtonProps = { - (props: WithRef): ReactElement; - (props: WithRef): ReactElement; - (props: WithRef): ReactElement; -}; - -const NavButtonComponent = forwardRef< - HTMLButtonElement, - NavLabelButtonProps | NavIconButtonProps | NavIconLabelButtonProps ->(({ className, isSelected, size = 'medium', ...props }, ref) => { - const buttonProps = { - ref, - ...props, - className: clsx( - navBorderRadiusStyles[size], - navButtonStyles['nav-button'], - { [navButtonStyles['selected']]: isSelected }, - className, - ), - size, - }; - - if (hasStringChildrenOnly(props)) { - return {props.children}; - } - - if (hasIconChildrenOnly(props)) { - return {props.children}; - } - - if (hasChildrenWithStringAndIcons(props)) { - return {props.children}; - } - - return null; +import { Children, forwardRef } from 'react'; + +import styles from './nav-button.module.css'; + +import { BaseButton } from '../base-button/base-button'; +import type { NavButtonProps } from './types'; + +export const NavButton = forwardRef((buttonProps, ref) => { + const { + children, + className, + isSelected = false, + prefixIcon, + size = 'm', + suffixIcon, + variant = 'square', + ...props + } = buttonProps; + const renderableChildren = Children.toArray(children).filter((child) => child !== ''); + const isIconOnly = renderableChildren.length === 0; + + return ( + + {isIconOnly ? ( + {prefixIcon ?? suffixIcon} + ) : ( + <> + {prefixIcon != null && {prefixIcon}} + {renderableChildren} + {suffixIcon != null && {suffixIcon}} + + )} + + ); }); - -/** - * Button is a flexible, and type-safe component that automatically selects - * the correct type (Label Button, Icon Button, or Icon Label Button) based on the - * structure of its `children` prop. - * - * **Automatic Type Selection (Structural Discrimination)** - * The component uses the shape of `children` to infer which button variant to render: - * - **Label Button**: If `children` is a single `string` - * - **Icon Button**: If `children` is a single React element (e.g., an icon) - * - **Icon Label Button**: If `children` is a combination of string + icon(s) - * - * **Type Safety via Overloads** - * Each variant supports its own unique set of props. Thanks to TypeScript overloads, - * only the correct props for a given structure are allowed—invalid combinations - * are caught at compile time. - * - * **How to Use** - * - * ```tsx - * Submit // Label Button - * - * - * - * // Icon Button - * - * - * - * Confirm - * - * // Icon Label Button - * ``` - * - * This approach ensures: - * - Simplified usage with fewer props - * - No accidental mixing of incompatible props - * - Autocomplete and type-checking experience - */ -export const NavButton = NavButtonComponent as NavButtonProps; diff --git a/packages/ui/src/components/button/nav-button/nav-icon-button/nav-button-icon-padding.module.css b/packages/ui/src/components/button/nav-button/nav-icon-button/nav-button-icon-padding.module.css deleted file mode 100644 index 647927eae..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-button/nav-button-icon-padding.module.css +++ /dev/null @@ -1,39 +0,0 @@ -:root { - --ax-public-button-icon-padding-extra-large: var(--wb-space-150); - --ax-public-button-icon-padding-large: var(--wb-space-137); - --ax-public-button-icon-padding-medium: var(--wb-space-112); - --ax-public-button-icon-padding-small: var(--wb-space-87); - --ax-public-button-icon-padding-extra-small: var(--wb-space-75); - --ax-public-button-icon-padding-xx-small: var(--wb-space-75); - --ax-public-button-icon-padding-xxx-small: var(--wb-space-50); -} - -@layer ui.component { - .extra-large { - padding: var(--ax-public-button-icon-padding-extra-large); - } - - .large { - padding: var(--ax-public-button-icon-padding-large); - } - - .medium { - padding: var(--ax-public-button-icon-padding-medium); - } - - .small { - padding: var(--ax-public-button-icon-padding-small); - } - - .extra-small { - padding: var(--ax-public-button-icon-padding-extra-small); - } - - .xx-small { - padding: var(--ax-public-button-icon-padding-xx-small); - } - - .xxx-small { - padding: var(--ax-public-button-icon-padding-xxx-small); - } -} diff --git a/packages/ui/src/components/button/nav-button/nav-icon-button/nav-icon-button.module.css b/packages/ui/src/components/button/nav-button/nav-icon-button/nav-icon-button.module.css deleted file mode 100644 index 17df928e7..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-button/nav-icon-button.module.css +++ /dev/null @@ -1,11 +0,0 @@ -:root { - --ax-public-button-nav-color-hover: var(--wb-components-nav-button-icon-primary-hover); -} - -@layer ui.component { - button:hover { - &.transparent { - background: transparent; - } - } -} diff --git a/packages/ui/src/components/button/nav-button/nav-icon-button/nav-icon-button.tsx b/packages/ui/src/components/button/nav-button/nav-icon-button/nav-icon-button.tsx deleted file mode 100644 index 7c7a135a0..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-button/nav-icon-button.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import clsx from 'clsx'; -import { forwardRef } from 'react'; - -import navButtonBorderRadiusStyles from '../styles/nav-button-border-radius.module.css'; -import navButtonIconSizeStyles from '../styles/nav-button-icon-size.module.css'; -import navButtonIconPaddingStyles from './nav-button-icon-padding.module.css'; -import navIconButtonStyles from './nav-icon-button.module.css'; - -import { BaseButton } from '../../base-button/base-button'; -import { IconNode, Shape } from '../../types'; -import { NavBaseButtonProps } from '../types'; - -export type NavIconButtonProps = { - shape?: Shape; - transparent?: boolean; - children: IconNode; -} & NavBaseButtonProps; - -export const NavIconButton = forwardRef( - ({ size = 'medium', shape = 'default', children, transparent, ...props }, ref) => ( - - {children} - - ), -); diff --git a/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-button-gap.module.css b/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-button-gap.module.css deleted file mode 100644 index fbf36e3f0..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-button-gap.module.css +++ /dev/null @@ -1,39 +0,0 @@ -:root { - --ax-public-button-gap-extra-large: var(--wb-space-100); - --ax-public-button-gap-large: var(--wb-space-100); - --ax-public-button-gap-medium: var(--wb-space-100); - --ax-public-button-gap-small: var(--wb-space-100); - --ax-public-button-gap-extra-small: var(--wb-space-100); - --ax-public-button-gap-xx-small: var(--wb-space-75) var(--wb-space-75); - --ax-public-button-gap-xxx-small: var(--wb-space-50) var(--wb-space-75); -} - -@layer ui.component { - .extra-large { - gap: var(--ax-public-button-gap-extra-large); - } - - .large { - gap: var(--ax-public-button-gap-large); - } - - .medium { - gap: var(--ax-public-button-gap-medium); - } - - .small { - gap: var(--ax-public-button-gap-small); - } - - .extra-small { - gap: var(--ax-public-button-gap-extra-small); - } - - .xx-small { - gap: var(--ax-public-button-gap-xx-small); - } - - .xxx-small { - gap: var(--ax-public-button-gap-xxx-small); - } -} diff --git a/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-icon-label-button-padding.module.css b/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-icon-label-button-padding.module.css deleted file mode 100644 index af44f7382..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-icon-label-button-padding.module.css +++ /dev/null @@ -1,39 +0,0 @@ -:root { - --ax-public-icon-label-button-padding-extra-large: var(--wb-space-150) var(--wb-space-150); - --ax-public-icon-label-button-padding-large: var(--wb-space-137) var(--wb-space-150); - --ax-public-icon-label-button-padding-medium: var(--wb-space-112) var(--wb-space-150); - --ax-public-icon-label-button-padding-small: var(--wb-space-87) var(--wb-space-125); - --ax-public-icon-label-button-padding-extra-small: var(--wb-space-75) var(--wb-space-100); - --ax-public-icon-label-button-padding-xx-small: var(--wb-space-75) var(--wb-space-75); - --ax-public-icon-label-button-padding-xxx-small: var(--wb-space-50) var(--wb-space-75); -} - -@layer ui.component { - .extra-large { - padding: var(--ax-public-icon-label-button-padding-extra-large); - } - - .large { - padding: var(--ax-public-icon-label-button-padding-large); - } - - .medium { - padding: var(--ax-public-icon-label-button-padding-medium); - } - - .small { - padding: var(--ax-public-icon-label-button-padding-small); - } - - .extra-small { - padding: var(--ax-public-icon-label-button-padding-extra-small); - } - - .xx-small { - padding: var(--ax-public-icon-label-button-padding-xx-small); - } - - .xxx-small { - padding: var(--ax-public-icon-label-button-padding-xxx-small); - } -} diff --git a/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-icon-label-button.tsx b/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-icon-label-button.tsx deleted file mode 100644 index a780ec4cf..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-icon-label-button.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import clsx from 'clsx'; -import { forwardRef } from 'react'; - -import fontSizeStyles from '../../styles/font-size.module.css'; -import navFontSizeStyles from '../styles/nav-button-font-size.module.css'; -import navButtonIconSizeStyles from '../styles/nav-button-icon-size.module.css'; -import navGapStyles from './nav-button-gap.module.css'; -import navPaddingStyles from './nav-icon-label-button-padding.module.css'; - -import { BaseButton } from '../../base-button/base-button'; -import { IconNode } from '../../types'; -import { NavBaseButtonProps } from '../types'; - -export type NavIconLabelButtonProps = { - children: [IconNode, string] | [string, IconNode] | [IconNode, string, IconNode]; -} & NavBaseButtonProps; - -export const NavIconLabelButton = forwardRef( - ({ size = 'medium', children, ...props }, ref) => ( - - {children} - - ), -); diff --git a/packages/ui/src/components/button/nav-button/nav-label-button/nav-label-button-padding.module.css b/packages/ui/src/components/button/nav-button/nav-label-button/nav-label-button-padding.module.css deleted file mode 100644 index c3f429c55..000000000 --- a/packages/ui/src/components/button/nav-button/nav-label-button/nav-label-button-padding.module.css +++ /dev/null @@ -1,36 +0,0 @@ -:root { - --ax-public-label-button-padding-extra-large: var(--wb-space-200) var(--wb-space-200); - --ax-public-label-button-padding-large: var(--wb-space-200) var(--wb-space-200); - --ax-public-label-button-padding-medium: var(--wb-space-150) var(--wb-space-150); - --ax-public-label-button-padding-small: var(--wb-space-125) var(--wb-space-125); - --ax-public-label-button-padding-extra-small: var(--wb-space-100) var(--wb-space-100); - --ax-public-label-button-padding-xx-small: var(--wb-space-87) var(--wb-space-75); - --ax-public-label-button-padding-xxx-small: var(--wb-space-62) var(--wb-space-75); -} - -@layer ui.component { - .extra-large, - .large { - padding: var(--ax-public-label-button-padding-extra-large); - } - - .medium { - padding: var(--ax-public-label-button-padding-medium); - } - - .small { - padding: var(--ax-public-label-button-padding-small); - } - - .extra-small { - padding: var(--ax-public-label-button-padding-extra-small); - } - - .xx-small { - padding: var(--ax-public-label-button-padding-xx-small); - } - - .xxx-small { - padding: var(--ax-public-label-button-padding-xxx-small); - } -} diff --git a/packages/ui/src/components/button/nav-button/nav-label-button/nav-label-button.tsx b/packages/ui/src/components/button/nav-button/nav-label-button/nav-label-button.tsx deleted file mode 100644 index a7668cb92..000000000 --- a/packages/ui/src/components/button/nav-button/nav-label-button/nav-label-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import clsx from 'clsx'; -import { forwardRef } from 'react'; - -import fontSizeStyles from '../../styles/font-size.module.css'; -import navFontSizeStyles from '../styles/nav-button-font-size.module.css'; -import navPaddingStyles from './nav-label-button-padding.module.css'; - -import { BaseButton } from '../../base-button/base-button'; -import { NavBaseButtonProps } from '../types'; - -export type NavLabelButtonProps = { - children: string; -} & NavBaseButtonProps; - -export const NavLabelButton = forwardRef( - ({ size = 'medium', children, ...props }, ref) => ( - - {children} - - ), -); diff --git a/packages/ui/src/components/button/nav-button/styles/nav-button-border-radius.module.css b/packages/ui/src/components/button/nav-button/styles/nav-button-border-radius.module.css deleted file mode 100644 index 682b4efc3..000000000 --- a/packages/ui/src/components/button/nav-button/styles/nav-button-border-radius.module.css +++ /dev/null @@ -1,46 +0,0 @@ -:root { - --ax-public-button-border-radius-extra-large: var(--wb-radius-100); - --ax-public-button-border-radius-large: var(--wb-radius-100); - --ax-public-button-border-radius-medium: var(--wb-radius-75); - --ax-public-button-border-radius-small: var(--wb-radius-50); - --ax-public-button-border-radius-extra-small: var(--wb-radius-50); - --ax-public-button-border-radius-circle: var(--wb-radius-full); - --ax-public-button-border-radius-xx-small: var(--wb-radius-50); - --ax-public-button-border-radius-xxx-small: var(--wb-radius-50); -} - -@layer ui.component { - :not(.circle) { - &.extra-large { - border-radius: var(--ax-public-button-border-radius-extra-large); - } - - &.large { - border-radius: var(--ax-public-button-border-radius-large); - } - - &.medium { - border-radius: var(--ax-public-button-border-radius-medium); - } - - &.small { - border-radius: var(--ax-public-button-border-radius-small); - } - - &.extra-small { - border-radius: var(--ax-public-button-border-radius-extra-small); - } - - &.xx-small { - border-radius: var(--ax-public-button-border-radius-xx-small); - } - - &.xxx-small { - border-radius: var(--ax-public-button-border-radius-xxx-small); - } - } - - .circle { - border-radius: var(--ax-public-button-border-radius-circle); - } -} diff --git a/packages/ui/src/components/button/nav-button/styles/nav-button-font-size.module.css b/packages/ui/src/components/button/nav-button/styles/nav-button-font-size.module.css deleted file mode 100644 index e1eec5551..000000000 --- a/packages/ui/src/components/button/nav-button/styles/nav-button-font-size.module.css +++ /dev/null @@ -1,6 +0,0 @@ -@layer ui.component { - .xx-small, - .xxx-small { - composes: ax-public-button-extra-small from global; - } -} diff --git a/packages/ui/src/components/button/nav-button/styles/nav-button-icon-size.module.css b/packages/ui/src/components/button/nav-button/styles/nav-button-icon-size.module.css deleted file mode 100644 index 58bd7a868..000000000 --- a/packages/ui/src/components/button/nav-button/styles/nav-button-icon-size.module.css +++ /dev/null @@ -1,60 +0,0 @@ -:root { - --ax-public-icon-size-extra-large: 1.5rem /* missing token */; - --ax-public-icon-size-large: 1.25rem /* missing token */; - --ax-public-icon-size-medium: 1.125rem /* missing token */; - --ax-public-icon-size-small: 1.125rem /* missing token */; - --ax-public-icon-size-extra-small: 1rem /* missing token */; - --ax-public-icon-size-xx-small: 0.75rem /* missing token */; - --ax-public-icon-size-xxx-small: 0.75rem /* missing token */; -} - -@layer ui.component { - .extra-large { - svg { - width: var(--ax-public-icon-size-extra-large); - height: var(--ax-public-icon-size-extra-large); - } - } - - .large { - svg { - width: var(--ax-public-icon-size-large); - height: var(--ax-public-icon-size-large); - } - } - - .medium { - svg { - width: var(--ax-public-icon-size-medium); - height: var(--ax-public-icon-size-medium); - } - } - - .small { - svg { - width: var(--ax-public-icon-size-small); - height: var(--ax-public-icon-size-small); - } - } - - .extra-small { - svg { - width: var(--ax-public-icon-size-extra-small); - height: var(--ax-public-icon-size-extra-small); - } - } - - .xx-small { - svg { - width: var(--ax-public-icon-size-xx-small); - height: var(--ax-public-icon-size-xx-small); - } - } - - .xxx-small { - svg { - width: var(--ax-public-icon-size-xxx-small); - height: var(--ax-public-icon-size-xxx-small); - } - } -} diff --git a/packages/ui/src/components/button/nav-button/styles/nav-button.module.css b/packages/ui/src/components/button/nav-button/styles/nav-button.module.css deleted file mode 100644 index fcc35eef2..000000000 --- a/packages/ui/src/components/button/nav-button/styles/nav-button.module.css +++ /dev/null @@ -1,37 +0,0 @@ -:root { - --ax-public-button-nav-background-color: var(--wb-components-nav-button-bg-primary-hover); - --ax-public-button-nav-color: var(--wb-components-nav-button-icon-primary-default); - --ax-public-button-nav-color-active: var(--wb-components-nav-button-icon-primary-pressed); - --ax-public-button-nav-color-disabled: var(--wb-components-nav-button-icon-primary-disabled); -} - -@layer ui.component { - .nav-button { - background-color: transparent; - color: var(--ax-public-button-nav-color); - - &:hover { - background-color: var(--ax-public-button-nav-background-color); - } - - &:active { - color: var(--ax-public-button-nav-color-active); - background-color: var(--ax-public-button-nav-background-color); - } - - &:focus-visible { - background-color: var(--ax-public-button-nav-background-color); - } - - &:disabled { - color: var(--ax-public-button-nav-color-disabled); - background-color: transparent; - } - - &.selected { - pointer-events: none; - background-color: var(--wb-components-button-solid-primary-default); - color: var(--wb-components-nav-button-icon-primary-active); - } - } -} diff --git a/packages/ui/src/components/button/nav-button/types.ts b/packages/ui/src/components/button/nav-button/types.ts index 89ebd99bf..57360c71c 100644 --- a/packages/ui/src/components/button/nav-button/types.ts +++ b/packages/ui/src/components/button/nav-button/types.ts @@ -1,16 +1,35 @@ -import { Size } from '@ui/shared/types/size'; +import type { ReactNode } from 'react'; -import { BaseButtonProps } from '../types'; +import type { BaseButtonProps, IconNode } from '../types'; -export type NavBaseButtonProps = BaseButtonProps & { - /** - * Size variant of the nav button. - * @default 'medium' - */ - size?: Size; +export const NAV_BUTTON_SIZES = ['xl', 'l', 'm', 's', 'xs', 'xxs', 'xxxs'] as const; + +export type NavButtonSize = (typeof NAV_BUTTON_SIZES)[number]; + +export const NAV_BUTTON_VARIANTS = ['square', 'round', 'plain'] as const; + +export type NavButtonVariant = (typeof NAV_BUTTON_VARIANTS)[number]; + +type NavButtonBaseProps = Omit & { + /** @default 'm' */ + size?: NavButtonSize; isSelected?: boolean; }; -export type { NavIconButtonProps } from './nav-icon-button/nav-icon-button'; -export type { NavIconLabelButtonProps } from './nav-icon-label-button/nav-icon-label-button'; -export type { NavLabelButtonProps } from './nav-label-button/nav-label-button'; +export type NavLabelButtonProps = NavButtonBaseProps & { + /** @default 'square' */ + variant?: Exclude; + children: ReactNode; + prefixIcon?: IconNode; + suffixIcon?: IconNode; +}; + +export type NavIconButtonProps = NavButtonBaseProps & { + /** @default 'square' */ + variant?: NavButtonVariant; + prefixIcon: IconNode; + children?: never; + suffixIcon?: never; +}; + +export type NavButtonProps = NavLabelButtonProps | NavIconButtonProps; diff --git a/packages/ui/src/components/collapsible/collapsible.tsx b/packages/ui/src/components/collapsible/collapsible.tsx index e2fbc59bd..c54ae2127 100644 --- a/packages/ui/src/components/collapsible/collapsible.tsx +++ b/packages/ui/src/components/collapsible/collapsible.tsx @@ -54,13 +54,13 @@ Collapsible.Button = function CollapsibleButton() { return ( - - + prefixIcon={} + /> ); }; diff --git a/packages/ui/src/components/modal/modal.tsx b/packages/ui/src/components/modal/modal.tsx index 0d521d061..ba57d2dd2 100644 --- a/packages/ui/src/components/modal/modal.tsx +++ b/packages/ui/src/components/modal/modal.tsx @@ -99,11 +99,7 @@ export const Modal = forwardRef( )}
- {onClose && ( - - - - )} + {onClose && } />} {children &&
{children}
} diff --git a/packages/ui/src/components/segment-picker/item/segment-picker-item.tsx b/packages/ui/src/components/segment-picker/item/segment-picker-item.tsx index 0cabf15d1..cb92efe5b 100644 --- a/packages/ui/src/components/segment-picker/item/segment-picker-item.tsx +++ b/packages/ui/src/components/segment-picker/item/segment-picker-item.tsx @@ -1,38 +1,27 @@ -import { - hasChildrenWithStringAndIcons, - hasIconChildrenOnly, - hasStringChildrenOnly, -} from '@ui/components/button/guards'; import { NavButton } from '@ui/components/button/nav-button/nav-button'; -import { NavIconButtonProps } from '@ui/components/button/nav-button/nav-icon-button/nav-icon-button'; -import { NavIconLabelButtonProps } from '@ui/components/button/nav-button/nav-icon-label-button/nav-icon-label-button'; -import { NavLabelButtonProps } from '@ui/components/button/nav-button/nav-label-button/nav-label-button'; +import type { NavButtonProps } from '@ui/components/button/nav-button/types'; import clsx from 'clsx'; -import { MouseEvent, useContext } from 'react'; +import { type MouseEvent, useContext } from 'react'; import itemShapeStyles from './segment-picker-item-shape.module.css'; -import { BaseButtonProps } from '../../button/types'; +import type { BaseButtonProps } from '../../button/types'; import { SegmentPickerContext } from '../utils/context'; -export type SegmentPickerItemProps = BaseButtonProps & { - value: string; -} & ( - | Pick - | Pick - | Pick - ); +export type SegmentPickerItemProps = BaseButtonProps & + Pick & { + value: string; + }; -/** - * A single item in the SegmentPicker, rendered as a NavButton under the hood. - * - * Automatically receives size and shape from SegmentPicker context. - * Must be used only within a SegmentPicker component. - * - * Determines which NavButton variant to render based on its children - * (label only, icon only, or icon + label). - */ -export function Item({ children, value, ...buttonProps }: SegmentPickerItemProps) { +export function Item({ + children, + className, + onClick, + prefixIcon, + suffixIcon, + value, + ...buttonProps +}: SegmentPickerItemProps) { const context = useContext(SegmentPickerContext); if (!context) { @@ -40,29 +29,25 @@ export function Item({ children, value, ...buttonProps }: SegmentPickerItemProps return null; } - const { selectedValue, onSelect, shape, ...other } = context; - - const props = { - className: clsx(itemShapeStyles['item'], itemShapeStyles[shape ?? 'default']), - isSelected: selectedValue === value, - onClick: (event: MouseEvent) => onSelect(event, value), - shape, - children, - ...other, - ...buttonProps, - }; - - if (hasStringChildrenOnly(props)) { - return ; - } - - if (hasIconChildrenOnly(props)) { - return ; - } - - if (hasChildrenWithStringAndIcons(props)) { - return ; - } - - return null; + const { selectedValue, onSelect, shape, size, navVariant } = context; + const isSelected = selectedValue === value; + + return ( + ) => { + onSelect(event, value); + onClick?.(event); + }} + prefixIcon={prefixIcon} + size={size} + variant={navVariant} + suffixIcon={suffixIcon} + {...buttonProps} + > + {children} + + ); } diff --git a/packages/ui/src/components/segment-picker/segment-picker.spec.tsx b/packages/ui/src/components/segment-picker/segment-picker.spec.tsx new file mode 100644 index 000000000..0e6235ca2 --- /dev/null +++ b/packages/ui/src/components/segment-picker/segment-picker.spec.tsx @@ -0,0 +1,67 @@ +import { act } from 'react'; +import { type Root, createRoot } from 'react-dom/client'; + +import { SegmentPicker } from './segment-picker'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +it('preserves element children inside the label', () => { + act(() => { + root.render( + + First + + Count important items + + , + ); + }); + + expect(container.querySelector('strong')?.textContent).toBe('important'); + expect(container.querySelectorAll('button')[1]?.textContent).toBe('Count important items'); +}); + +it('keeps selection internal while composing the consumer click handler', () => { + const onChange = vi.fn(); + const onSelectedClick = vi.fn(); + const onNextClick = vi.fn(); + + act(() => { + root.render( + + + First + + + Second + + , + ); + }); + + const buttons = container.querySelectorAll('button'); + act(() => buttons[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + + expect(onSelectedClick).toHaveBeenCalledOnce(); + expect(onChange).not.toHaveBeenCalled(); + + act(() => buttons[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))); + + expect(onNextClick).toHaveBeenCalledOnce(); + expect(onChange).toHaveBeenCalledOnce(); + expect(onChange.mock.calls[0]?.[1]).toBe('b'); +}); diff --git a/packages/ui/src/components/segment-picker/segment-picker.tsx b/packages/ui/src/components/segment-picker/segment-picker.tsx index c1568da9e..c8ee237ec 100644 --- a/packages/ui/src/components/segment-picker/segment-picker.tsx +++ b/packages/ui/src/components/segment-picker/segment-picker.tsx @@ -1,27 +1,44 @@ -import { Shape } from '@ui/components/button/types'; -import { Size } from '@ui/shared/types/size'; +import type { NavButtonSize, NavButtonVariant } from '@ui/components/button/nav-button/types'; +import type { Shape } from '@ui/components/button/types'; +import type { Size } from '@ui/shared/types/size'; import clsx from 'clsx'; -import { ForwardRefExoticComponent, MouseEvent, ReactElement, forwardRef, useState } from 'react'; +import { + type ForwardRefExoticComponent, + type MouseEvent, + type ReactElement, + type RefAttributes, + forwardRef, + useState, +} from 'react'; import borderRadiusStyles from './border-radius-size.module.css'; import styles from './segment-picker.module.css'; -import { Item, SegmentPickerItemProps } from './item/segment-picker-item'; +import { Item, type SegmentPickerItemProps } from './item/segment-picker-item'; import { SegmentPickerContext } from './utils/context'; import { getValidShape } from './utils/get-valid-shape'; +const NAV_BUTTON_SIZE_BY_SEGMENT_PICKER_SIZE: Record = { + 'extra-large': 'xl', + large: 'l', + medium: 'm', + small: 's', + 'extra-small': 'xs', + 'xx-small': 'xxs', + 'xxx-small': 'xxxs', +}; + +const NAV_BUTTON_VARIANT_BY_SEGMENT_PICKER_SHAPE: Record> = { + default: 'square', + circle: 'round', +}; + export type SegmentPickerPropsBase = { children: ReactElement[]; - /** - * Size variant of the SegmentPicker and its items. - * @default 'medium' - */ + /** @default 'medium' */ size?: Size; /** - * Controls the shape of the SegmentPicker and its items. - * (default) - Items stretch to fill the container equally. - * 'circle' - Items fit tightly around their content to maintain a circular shape. - * Only supported when items contain icons only. + * Circle is supported only when every item contains an icon without a label. * @default 'default' */ shape?: Shape; @@ -30,22 +47,18 @@ export type SegmentPickerPropsBase = { }; export type ControlledSegmentPickerProps = { - /** The currently selected value (controlled mode). */ value: string; - /** Must not be used in controlled mode. */ defaultValue?: never; } & SegmentPickerPropsBase; export type UncontrolledSegmentPickerProps = { - /** The initial selected value (uncontrolled mode). */ defaultValue: string; - /** Must not be used in uncontrolled mode. */ value?: never; } & SegmentPickerPropsBase; export type SegmentPickerProps = ControlledSegmentPickerProps | UncontrolledSegmentPickerProps; -type SegmentPickerComponent = ForwardRefExoticComponent> & { +type SegmentPickerComponent = ForwardRefExoticComponent> & { Item: typeof Item; }; @@ -54,10 +67,11 @@ export const SegmentPicker = forwardRef( const validShape = getValidShape(shape, children); const isControlled = value !== undefined; const [internalValue, setInternalValue] = useState(defaultValue); - const selectedValue = isControlled ? value : internalValue; const handleSelect = (event: MouseEvent, newValue: string) => { + if (newValue === selectedValue) return; + if (!isControlled) { setInternalValue(newValue); } @@ -69,8 +83,9 @@ export const SegmentPicker = forwardRef( value={{ selectedValue, onSelect: handleSelect, - size, + size: NAV_BUTTON_SIZE_BY_SEGMENT_PICKER_SIZE[size], shape: validShape, + navVariant: NAV_BUTTON_VARIANT_BY_SEGMENT_PICKER_SHAPE[validShape], }} >
diff --git a/packages/ui/src/components/segment-picker/utils/context.ts b/packages/ui/src/components/segment-picker/utils/context.ts index 0a2a33938..524c8d02d 100644 --- a/packages/ui/src/components/segment-picker/utils/context.ts +++ b/packages/ui/src/components/segment-picker/utils/context.ts @@ -1,12 +1,13 @@ -import { Shape } from '@ui/components/button/types'; -import { Size } from '@ui/shared/types/size'; -import { MouseEvent, createContext } from 'react'; +import type { NavButtonSize, NavButtonVariant } from '@ui/components/button/nav-button/types'; +import type { Shape } from '@ui/components/button/types'; +import { type MouseEvent, createContext } from 'react'; type SegmentPickerContextType = { selectedValue: string | undefined; onSelect: (event: MouseEvent, value: string) => void; - size?: Size; - shape?: Shape; + size: NavButtonSize; + shape: Shape; + navVariant: Exclude; }; export const SegmentPickerContext = createContext(undefined); diff --git a/packages/ui/src/components/segment-picker/utils/get-valid-shape.spec.ts b/packages/ui/src/components/segment-picker/utils/get-valid-shape.spec.ts index 5ae8ea34b..5449ca285 100644 --- a/packages/ui/src/components/segment-picker/utils/get-valid-shape.spec.ts +++ b/packages/ui/src/components/segment-picker/utils/get-valid-shape.spec.ts @@ -8,16 +8,16 @@ type PickerItem = ReactElement; const icon = createElement('svg'); -// `SegmentPickerItemProps` requires `children` in props, so the guards read -// `item.props.children` — pass it as a prop rather than a createElement arg. function iconItem(value: string): PickerItem { - // eslint-disable-next-line react/no-children-prop - return createElement(Item, { value, children: icon }) as PickerItem; + return createElement(Item, { value, prefixIcon: icon }) as PickerItem; +} + +function legacyIconItem(value: string): PickerItem { + return createElement(Item, { value }, icon) as PickerItem; } function labelItem(value: string): PickerItem { - // eslint-disable-next-line react/no-children-prop - return createElement(Item, { value, children: 'label' }) as PickerItem; + return createElement(Item, { value }, 'label') as PickerItem; } describe('getValidShape', () => { @@ -25,11 +25,15 @@ describe('getValidShape', () => { expect(getValidShape('default', [labelItem('a')])).toBe('default'); }); - it("returns 'circle' when every item has icon-only children", () => { + it("returns 'circle' when every item has an explicit prefix icon", () => { expect(getValidShape('circle', [iconItem('a'), iconItem('b')])).toBe('circle'); }); - it("falls back to 'default' and logs an error when an item has a string child", () => { + it("returns 'circle' for the existing icon-child API", () => { + expect(getValidShape('circle', [legacyIconItem('a'), legacyIconItem('b')])).toBe('circle'); + }); + + it("falls back to 'default' and logs an error when an item has a label", () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); expect(getValidShape('circle', [iconItem('a'), labelItem('b')])).toBe('default'); diff --git a/packages/ui/src/components/segment-picker/utils/get-valid-shape.ts b/packages/ui/src/components/segment-picker/utils/get-valid-shape.ts index d70248787..f3d857df0 100644 --- a/packages/ui/src/components/segment-picker/utils/get-valid-shape.ts +++ b/packages/ui/src/components/segment-picker/utils/get-valid-shape.ts @@ -1,20 +1,22 @@ -import { Shape } from '@ui/components/button/types'; -import { ReactElement } from 'react'; +import type { Shape } from '@ui/components/button/types'; +import { type ReactElement, isValidElement } from 'react'; -import { hasIconChildrenOnly } from '../../button/guards'; -import { Item, SegmentPickerItemProps } from '../item/segment-picker-item'; +import { Item, type SegmentPickerItemProps } from '../item/segment-picker-item'; export function getValidShape(shape: Shape, items: ReactElement[]): Shape { if (shape !== 'circle') { return shape; } - const everyItemHasOnlyIcon = items.every((item) => hasIconChildrenOnly({ children: item.props.children })); + const everyItemHasOnlyIcon = items.every(({ props }) => { + const hasExplicitIcon = props.prefixIcon != null && props.children == null && props.suffixIcon == null; + const hasLegacyIconChild = props.prefixIcon == null && isValidElement(props.children) && props.suffixIcon == null; + + return hasExplicitIcon || hasLegacyIconChild; + }); if (!everyItemHasOnlyIcon) { - console.error( - '[SegmentPicker] The "circle" shape can only be used when all SegmentPicker.Item components contain icon-only children.', - ); + console.error('[SegmentPicker] The "circle" shape can only be used when all items contain only a prefix icon.'); return 'default'; } diff --git a/packages/ui/src/components/snackbar/components/action-buttons.tsx b/packages/ui/src/components/snackbar/components/action-buttons.tsx index 32dc2f8fd..c526cd203 100644 --- a/packages/ui/src/components/snackbar/components/action-buttons.tsx +++ b/packages/ui/src/components/snackbar/components/action-buttons.tsx @@ -34,9 +34,7 @@ export function ActionButtons({ variant, buttonLabel, onButtonClick, close, onCl )} {close && onClose && ( - - - + } /> )}
);