diff --git a/.changeset/nav-button-redesign.md b/.changeset/nav-button-redesign.md new file mode 100644 index 000000000..e0c76d54c --- /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. 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 ec3f49a6a..111a35aec 100644 --- a/apps/docs/scripts/ui-components.mjs +++ b/apps/docs/scripts/ui-components.mjs @@ -26,6 +26,12 @@ export const COMPONENTS = [ cssSources: ['shared/styles/field-control-size.module.css'], }, { 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: 'number-field', @@ -37,7 +43,7 @@ export const COMPONENTS = [ { 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..2786ebc80 100644 --- a/apps/docs/src/components/ui-examples/nav-button.tsx +++ b/apps/docs/src/components/ui-examples/nav-button.tsx @@ -1,11 +1,32 @@ -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 + + }> + Plain + + } 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 78e8e91cb..52c676347 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. - [NumberField](/ui-library/ui-components/number-field/) - Numeric input with stepper controls. 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..269a38c70 --- /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 a fixed-size NavButton composition for menu trigger icons. +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](/docs/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..61b4d8856 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. Leave `children` empty and provide `prefixIcon` for a +square icon-only button, and use `variant` to select square, round, or +no-background styling. + +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..658f10292 100644 --- a/packages/sdk/src/features/app-bar/components/controls/controls.tsx +++ b/packages/sdk/src/features/app-bar/components/controls/controls.tsx @@ -25,9 +25,11 @@ export function Controls() { {items.length > 0 && (
- - - + } + />
)} 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..872666750 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 @@ -89,9 +89,11 @@ 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.tsx b/packages/sdk/src/features/i18n/components/language-selector/language-selector.tsx index 7d0b53d4e..a485cc8c7 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; @@ -36,11 +34,12 @@ export function LanguageSelector() { return ( <> - - <> - {currentLanguage.code.toUpperCase()} - - + } + tooltip={t('tooltips.changeLanguage')} + > + {currentLanguage.code.toUpperCase()} 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 d2c4ef34c..7f1fb2ffd 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.tsx b/packages/sdk/src/features/variables/components/dynamic-typed-variable-or-input/dynamic-typed-variable-or-input.tsx index d079c1ccb..68a34809d 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 @@ -60,9 +60,13 @@ export function DynamicTypedVariableOrInput({ suggestionGroups={suggestionGroupsForType} hasError={isError} endAdornment={ - - - + } + /> } /> ); @@ -80,12 +84,12 @@ export function DynamicTypedVariableOrInput({ endAdornment={ suggestionGroupsForType.length > 0 ? ( - - + prefixIcon={} + /> ) : 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..98f9df18f 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 @@ -149,13 +149,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..a5ce99843 --- /dev/null +++ b/packages/ui/src/components/button/menu-trigger-button/menu-trigger-button.tsx @@ -0,0 +1,15 @@ +import { forwardRef } from 'react'; +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +import { NavButton } from '../nav-button/nav-button'; + +export type MenuTriggerButtonProps = { + children: ReactNode; + isOpen?: boolean; +} & ButtonHTMLAttributes; + +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..a07fff7e4 --- /dev/null +++ b/packages/ui/src/components/button/nav-button/nav-button.module.css @@ -0,0 +1,202 @@ +: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, var(--ax-public-nav-button-background-color-default)); + color: var(--ax-public-nav-button-color-default); + + &:not(:disabled):hover { + background-color: var(--ax-public-nav-button-background-color-hover); + color: var(--ax-public-nav-button-color-hover); + } + + &.selected:not(:disabled) { + background-color: var(--ax-public-nav-button-background-color-pressed); + color: var(--ax-public-nav-button-color-pressed); + } + + &:not(:disabled):focus-visible { + background-color: var(--ax-public-nav-button-background-color-focus); + color: var(--ax-public-nav-button-color-default); + } + + &:not(:disabled):active { + background-color: var(--ax-public-nav-button-background-color-active); + color: var(--ax-public-nav-button-color-active); + } + + &:disabled { + background-color: var(--ax-public-nav-button-background-color-disabled); + color: var(--ax-public-nav-button-color-disabled); + } + } + + .icon-only { + width: var(--nav-button-height); + padding: var(--nav-button-padding); + } + + .icon { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: var(--nav-button-icon-size); + height: var(--nav-button-icon-size); + + svg { + width: 100%; + height: 100%; + } + } + + .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 { + --nav-button-background: var(--ax-public-nav-button-background-color-default); + } + + .plain { + --nav-button-background: var(--wb-colors-transparent); + } +} 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 33807559d..dcac59143 100644 --- a/packages/ui/src/components/button/nav-button/nav-button.tsx +++ b/packages/ui/src/components/button/nav-button/nav-button.tsx @@ -1,111 +1,32 @@ import clsx from 'clsx'; -import { ReactElement, forwardRef } from 'react'; +import { forwardRef } from 'react'; -import borderRadiusStyles from '../styles/border-radius.module.css'; -import navBorderRadiusStyles from './styles/nav-button-border-radius.module.css'; -import navButtonStyles from './styles/nav-button.module.css'; +import styles from './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'; +import { BaseButton } from '../base-button/base-button'; +import type { NavButtonProps } from './types'; -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 = { +export const NavButton = forwardRef( + ( + { children, className, isSelected = false, prefixIcon, size = 'm', suffixIcon, variant = 'square', ...props }, ref, - ...props, - className: clsx( - borderRadiusStyles[size], - 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; -}); - -/** - * 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; + ) => { + const isIconOnly = children == null; + + return ( + + {prefixIcon != null && {prefixIcon}} + {children != null && {children}} + {suffixIcon != null && {suffixIcon}} + + ); + }, +); 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 8d0430719..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-button/nav-button-icon-padding.module.css +++ /dev/null @@ -1,14 +0,0 @@ -:root { - --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 { - .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 e0dffbca3..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-button/nav-icon-button.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import clsx from 'clsx'; -import { forwardRef } from 'react'; - -import borderRadiusStyles from '../../styles/border-radius.module.css'; -import iconPaddingStyles from '../../styles/icon-padding.module.css'; -import iconSizeStyles from '../../styles/icon-size.module.css'; -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 2197614db..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-button-gap.module.css +++ /dev/null @@ -1,14 +0,0 @@ -:root { - --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 { - .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 26c50926d..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-icon-label-button-padding.module.css +++ /dev/null @@ -1,14 +0,0 @@ -:root { - --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 { - .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 7ae31c5d7..000000000 --- a/packages/ui/src/components/button/nav-button/nav-icon-label-button/nav-icon-label-button.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import clsx from 'clsx'; -import { forwardRef } from 'react'; - -import fontSizeStyles from '../../styles/font-size.module.css'; -import gapStyles from '../../styles/gap.module.css'; -import paddingStyles from '../../styles/icon-label-button-padding.module.css'; -import iconSizeStyles from '../../styles/icon-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 5d806dff3..000000000 --- a/packages/ui/src/components/button/nav-button/nav-label-button/nav-label-button-padding.module.css +++ /dev/null @@ -1,14 +0,0 @@ -:root { - --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 { - .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 2f837dab0..000000000 --- a/packages/ui/src/components/button/nav-button/nav-label-button/nav-label-button.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import clsx from 'clsx'; -import { forwardRef } from 'react'; - -import fontSizeStyles from '../../styles/font-size.module.css'; -import paddingStyles from '../../styles/label-button-padding.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 ce3fcb3c2..000000000 --- a/packages/ui/src/components/button/nav-button/styles/nav-button-border-radius.module.css +++ /dev/null @@ -1,16 +0,0 @@ -:root { - --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) { - &.xx-small { - border-radius: var(--ax-public-button-border-radius-xx-small); - } - - &.xxx-small { - border-radius: var(--ax-public-button-border-radius-xxx-small); - } - } -} 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 14f15bb3e..000000000 --- a/packages/ui/src/components/button/nav-button/styles/nav-button-icon-size.module.css +++ /dev/null @@ -1,20 +0,0 @@ -:root { - --ax-public-icon-size-xx-small: 0.75rem /* missing token */; - --ax-public-icon-size-xxx-small: 0.75rem /* missing token */; -} - -@layer ui.component { - .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..a6cba0976 100644 --- a/packages/ui/src/components/button/nav-button/types.ts +++ b/packages/ui/src/components/button/nav-button/types.ts @@ -1,16 +1,39 @@ -import { Size } from '@ui/shared/types/size'; +import type { ButtonHTMLAttributes, ReactNode } from 'react'; -import { BaseButtonProps } from '../types'; +import type { TooltipVariant } from '../../tooltip/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]; + +export type NavLabelButtonProps = { + /** @default 'm' */ + size?: NavButtonSize; + /** @default 'square' */ + variant?: NavButtonVariant; + isSelected?: boolean; + children: ReactNode; + prefixIcon?: ReactNode; + suffixIcon?: ReactNode; + tooltip?: string; + tooltipType?: TooltipVariant; +} & Omit, 'children'>; + +export type NavIconButtonProps = { + /** @default 'm' */ + size?: NavButtonSize; + /** @default 'square' */ + variant?: NavButtonVariant; isSelected?: boolean; -}; + prefixIcon: ReactNode; + children?: never; + suffixIcon?: never; + tooltip?: string; + tooltipType?: TooltipVariant; +} & Omit, 'children'>; -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 NavButtonProps = NavLabelButtonProps | NavIconButtonProps; diff --git a/packages/ui/src/components/button/styles/icon-label-button-padding.module.css b/packages/ui/src/components/button/styles/icon-label-button-padding.module.css deleted file mode 100644 index 6db63d038..000000000 --- a/packages/ui/src/components/button/styles/icon-label-button-padding.module.css +++ /dev/null @@ -1,29 +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); -} - -@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); - } -} diff --git a/packages/ui/src/components/button/styles/label-button-padding.module.css b/packages/ui/src/components/button/styles/label-button-padding.module.css deleted file mode 100644 index 000c52197..000000000 --- a/packages/ui/src/components/button/styles/label-button-padding.module.css +++ /dev/null @@ -1,26 +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); -} - -@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); - } -} 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..fa9ccd9a0 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,68 +1,62 @@ -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, type ReactNode, isValidElement, 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 - ); - -/** - * 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) { - const context = useContext(SegmentPickerContext); - - if (!context) { - console.error('SegmentPicker.Item must be used within a SegmentPicker'); - return null; - } +export type SegmentPickerItemProps = BaseButtonProps & + Pick & { + value: string; + }; - const { selectedValue, onSelect, shape, ...other } = context; +type Slots = { prefixIcon?: ReactNode; label?: ReactNode; suffixIcon?: ReactNode }; - const props = { - className: clsx(itemShapeStyles['item'], itemShapeStyles[shape ?? 'default']), - isSelected: selectedValue === value, - onClick: (event: MouseEvent) => onSelect(event, value), - shape, - children, - ...other, - ...buttonProps, - }; +function toSlots(children: ReactNode): Slots { + if (Array.isArray(children)) { + const parts = children.filter((child) => child != null && child !== false); + const prefixIcon = isValidElement(parts[0]) ? parts[0] : undefined; + const last = parts.at(-1); + const suffixIcon = parts.length > 1 && isValidElement(last) ? last : undefined; + const label = parts.filter((child) => !isValidElement(child)); - if (hasStringChildrenOnly(props)) { - return ; + return { prefixIcon, label: label.length > 0 ? label : undefined, suffixIcon }; } - if (hasIconChildrenOnly(props)) { - return ; - } + return isValidElement(children) ? { prefixIcon: children } : { label: children }; +} + +export function Item({ children, className, prefixIcon, suffixIcon, value, ...buttonProps }: SegmentPickerItemProps) { + const context = useContext(SegmentPickerContext); - if (hasChildrenWithStringAndIcons(props)) { - return ; + if (!context) { + console.error('SegmentPicker.Item must be used within a SegmentPicker'); + return null; } - return null; + const { selectedValue, onSelect, shape, size, navVariant } = context; + const slots = toSlots(children); + const isSelected = selectedValue === value; + + return ( + ) => { + if (isSelected) return; + onSelect(event, value); + }} + prefixIcon={prefixIcon ?? slots.prefixIcon} + size={size} + variant={navVariant} + suffixIcon={suffixIcon ?? slots.suffixIcon} + {...buttonProps} + > + {slots.label} + + ); } diff --git a/packages/ui/src/components/segment-picker/segment-picker.tsx b/packages/ui/src/components/segment-picker/segment-picker.tsx index c1568da9e..4b736188b 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,7 +67,6 @@ 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) => { @@ -69,8 +81,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..a64f70dd7 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: NavButtonVariant; }; 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 && ( - - - + } /> )}
);