From 0b86a1fed61670de3b221975dbc1db17411f20a5 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Mon, 3 Aug 2026 17:10:08 +0000 Subject: [PATCH] feat(selector): add ghost variant --- .changeset/ghost-selector-variants.md | 7 + .../stories/MultiSelector.stories.tsx | 47 ++++++ apps/storybook/stories/Selector.stories.tsx | 55 +++++++ .../MultiSelectorGhostToolbar.doc.mjs | 14 ++ .../MultiSelectorGhostToolbar.tsx | 42 +++++ .../Selector/SelectorGhostToolbar.doc.mjs | 14 ++ .../Selector/SelectorGhostToolbar.tsx | 46 ++++++ .../src/MultiSelector/MultiSelector.doc.mjs | 37 ++++- .../src/MultiSelector/MultiSelector.test.tsx | 45 ++++++ .../core/src/MultiSelector/MultiSelector.tsx | 151 ++++++++++++++++-- packages/core/src/Selector/Selector.doc.mjs | 28 +++- packages/core/src/Selector/Selector.test.tsx | 41 +++++ packages/core/src/Selector/Selector.tsx | 150 +++++++++++++++-- 13 files changed, 639 insertions(+), 38 deletions(-) create mode 100644 .changeset/ghost-selector-variants.md create mode 100644 packages/cli/assets/templates/blocks/components/MultiSelector/MultiSelectorGhostToolbar.doc.mjs create mode 100644 packages/cli/assets/templates/blocks/components/MultiSelector/MultiSelectorGhostToolbar.tsx create mode 100644 packages/cli/assets/templates/blocks/components/Selector/SelectorGhostToolbar.doc.mjs create mode 100644 packages/cli/assets/templates/blocks/components/Selector/SelectorGhostToolbar.tsx diff --git a/.changeset/ghost-selector-variants.md b/.changeset/ghost-selector-variants.md new file mode 100644 index 000000000000..3340a7fa5dca --- /dev/null +++ b/.changeset/ghost-selector-variants.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] Add a ghost trigger variant for Selector and MultiSelector for toolbar-style controls, with ghost status messages detached by default. + +@cixzhang diff --git a/apps/storybook/stories/MultiSelector.stories.tsx b/apps/storybook/stories/MultiSelector.stories.tsx index bd560d7d59f0..ebf092907d2a 100644 --- a/apps/storybook/stories/MultiSelector.stories.tsx +++ b/apps/storybook/stories/MultiSelector.stories.tsx @@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import {useState} from 'react'; +import {Button} from '@astryxdesign/core/Button'; import {MultiSelector} from '@astryxdesign/core/MultiSelector'; import {Theme, defineTheme} from '@astryxdesign/core/theme'; @@ -25,6 +26,7 @@ const meta: Meta = { description: {control: 'text'}, placeholder: {control: 'text'}, size: {control: 'radio', options: ['sm', 'md', 'lg']}, + variant: {control: 'radio', options: ['input', 'ghost']}, triggerDisplay: { control: 'radio', options: ['count', 'labels', 'badges'], @@ -269,6 +271,51 @@ export const DisabledWithMessage: Story = { decorators: [Story => ], }; +// Ghost variant for toolbar composition +export const GhostVariant: Story = { + render: () => { + const [columns, setColumns] = useState(['Name', 'Email']); + const [filters, setFilters] = useState(['Active']); + return ( +
+
+ ); + }, + decorators: [Story => ], +}; + // Status variants export const Status: Story = { render: () => { diff --git a/apps/storybook/stories/Selector.stories.tsx b/apps/storybook/stories/Selector.stories.tsx index a1f2882e6cc5..53a6e8a8e8fc 100644 --- a/apps/storybook/stories/Selector.stories.tsx +++ b/apps/storybook/stories/Selector.stories.tsx @@ -2,6 +2,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import {useState} from 'react'; +import {Button} from '@astryxdesign/core/Button'; import {Selector, SelectorOption} from '@astryxdesign/core/Selector'; import {Theme, defineTheme} from '@astryxdesign/core/theme'; import {UserIcon, CogIcon, BellIcon} from '@heroicons/react/24/outline'; @@ -51,6 +52,11 @@ const meta: Meta = { options: ['sm', 'md', 'lg'], description: 'Size variant of the selector', }, + variant: { + control: 'radio', + options: ['input', 'ghost'], + description: 'Visual trigger style', + }, placement: { control: 'select', options: ['above', 'below', 'start', 'end'], @@ -435,6 +441,55 @@ export const SizeVariants: Story = { decorators: [Story => ], }; +// Ghost variant for toolbar composition +export const GhostVariant: Story = { + render: () => { + const [view, setView] = useState('week'); + const [density, setDensity] = useState('comfortable'); + return ( +
+
+ ); + }, + decorators: [Story => ], +}; + // With status export const WithStatus: Story = { render: () => { diff --git a/packages/cli/assets/templates/blocks/components/MultiSelector/MultiSelectorGhostToolbar.doc.mjs b/packages/cli/assets/templates/blocks/components/MultiSelector/MultiSelectorGhostToolbar.doc.mjs new file mode 100644 index 000000000000..a48802fc63df --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/MultiSelector/MultiSelectorGhostToolbar.doc.mjs @@ -0,0 +1,14 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */ +export const doc = { + type: 'block', + exampleFor: 'MultiSelector', + name: 'MultiSelector — Ghost Toolbar', + displayName: 'MultiSelector — Ghost Toolbar', + description: + 'Borderless MultiSelector variant composed with ghost buttons in a toolbar.', + isReady: true, + aspectRatio: 16 / 9, + componentsUsed: ['MultiSelector', 'Button', 'HStack'], +}; diff --git a/packages/cli/assets/templates/blocks/components/MultiSelector/MultiSelectorGhostToolbar.tsx b/packages/cli/assets/templates/blocks/components/MultiSelector/MultiSelectorGhostToolbar.tsx new file mode 100644 index 000000000000..2a050d39b2e2 --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/MultiSelector/MultiSelectorGhostToolbar.tsx @@ -0,0 +1,42 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import {useState} from 'react'; +import {Button} from '@astryxdesign/core/Button'; +import {HStack} from '@astryxdesign/core/Layout'; +import {MultiSelector} from '@astryxdesign/core/MultiSelector'; + +export default function MultiSelectorGhostToolbar() { + const [columns, setColumns] = useState(['Name', 'Email']); + const [filters, setFilters] = useState(['Active']); + + return ( + + + ) : ( + + ) ) : ( ({ }, )} + {showStatusTooltip && statusTooltip.renderTooltip(status?.message ?? '')} + {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)} @@ -1508,7 +1631,7 @@ export function MultiSelector({ } : undefined } - statusVariant={statusVariant} + statusVariant={effectiveStatusVariant} labelTooltip={labelTooltip} width={width}> {multiSelectorContent} diff --git a/packages/core/src/Selector/Selector.doc.mjs b/packages/core/src/Selector/Selector.doc.mjs index c8df91ccb3d0..4a35914d411a 100644 --- a/packages/core/src/Selector/Selector.doc.mjs +++ b/packages/core/src/Selector/Selector.doc.mjs @@ -21,7 +21,10 @@ export const docs = { ], theming: { targets: [ - {className: 'astryx-selector', visualProps: ['size', 'status']}, + { + className: 'astryx-selector', + visualProps: ['variant', 'size', 'status'], + }, {className: 'astryx-selector-option'}, {className: 'astryx-selector-clear-icon'}, {className: 'astryx-selector-indicator-icon', states: ['state']}, @@ -84,6 +87,13 @@ export const docs = { description: 'Size variant for the selector.', default: "'md'", }, + { + name: 'variant', + type: "'input' | 'ghost'", + description: + 'Visual trigger style. input is the bordered input treatment for forms; ghost is borderless and matches ghost buttons for toolbar usage.', + default: "'input'", + }, { name: 'isDisabled', type: 'boolean', @@ -132,10 +142,10 @@ export const docs = { }, { name: 'statusVariant', - type: "'attached' | 'detached'", + type: "'attached' | 'detached' | 'tooltip'", description: - 'How the status message is placed relative to the input. attached overlaps directly below the input (bordered treatment); detached floats below as a separate element with spacing.', - default: "'attached'", + 'How the status message is placed relative to the input. attached overlaps directly below the bordered input and is only valid for the input variant; ghost selectors detach attached status messages by default. Use tooltip for compact toolbar controls.', + default: "'attached' for input selectors; 'detached' for ghost selectors", }, { name: 'renderOption', @@ -186,6 +196,11 @@ export const docs = { description: 'Use inside InputGroup only when the selector needs a short prefix or suffix addon as part of one decorated input surface.', }, + { + guidance: true, + description: + 'Use variant="ghost" when a selector sits in a toolbar with ghost buttons. If validation status is needed there, prefer statusVariant="tooltip" so the toolbar height stays compact.', + }, { guidance: false, description: @@ -375,6 +390,11 @@ export const docsDense = { description: 'Use inside InputGroup only when the selector needs a short prefix or suffix addon.', }, + { + guidance: true, + description: + 'Use variant="ghost" in toolbars with ghost buttons; prefer statusVariant="tooltip" for compact validation status.', + }, { guidance: false, description: diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index 778b07593532..1b59e0561d82 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -1271,6 +1271,47 @@ describe('Selector statusVariant forwarding', () => { container.querySelector('.astryx-selector-indicator-icon'), ).not.toBeNull(); }); + + it('detaches attached status by default for the ghost variant', () => { + const {container} = render( + , + ); + expect(container.querySelector('.astryx-selector')).toHaveAttribute( + 'data-variant', + 'ghost', + ); + expect(container.querySelector('.astryx-field-status')).toHaveAttribute( + 'data-variant', + 'detached', + ); + }); + + it('uses a status tooltip for ghost selectors when requested', () => { + const {container} = render( + , + ); + expect(container.querySelector('.astryx-field-status')).toBeNull(); + const statusButton = screen.getByRole('button', { + name: /warning details/i, + }); + const tooltip = screen.getByRole('tooltip', h); + expect(tooltip).toHaveTextContent('Visible to all users'); + expect(statusButton.getAttribute('aria-describedby')).toContain(tooltip.id); + expect( + screen.getByRole('combobox').getAttribute('aria-describedby'), + ).toContain(tooltip.id); + }); }); describe('Selector clear icon theme target', () => { diff --git a/packages/core/src/Selector/Selector.tsx b/packages/core/src/Selector/Selector.tsx index 6a483342c38a..dee927d5f57a 100644 --- a/packages/core/src/Selector/Selector.tsx +++ b/packages/core/src/Selector/Selector.tsx @@ -154,6 +154,47 @@ const styles = stylex.create({ // Disable rotation transition for status icons transition: 'none', }, + triggerGhost: { + width: 'auto', + borderWidth: 0, + backgroundColor: 'transparent', + backgroundImage: { + default: null, + ':hover': { + '@media (hover: hover)': `linear-gradient(${colorVars['--color-overlay-hover']}, ${colorVars['--color-overlay-hover']})`, + }, + ':active': `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`, + }, + boxShadow: { + default: 'none', + ':hover:not(:focus-within)': { + '@media (hover: hover)': 'none', + }, + ':focus-within': 'none', + }, + fontWeight: fontWeightVars['--font-weight-medium'], + outline: { + default: 'none', + ':has(:focus-visible)': `2px solid ${colorVars['--color-accent']}`, + }, + outlineOffset: { + default: '0', + ':has(:focus-visible)': '3px', + }, + transitionProperty: + 'background-image, background-color, color, opacity, transform', + transform: { + default: 'scale(1)', + ':active': 'scale(0.98)', + }, + }, + triggerGhostDisabled: { + backgroundImage: 'none', + transform: { + default: 'none', + ':active': 'none', + }, + }, // Clear button clearButton: { @@ -173,6 +214,24 @@ const styles = stylex.create({ }, outlineOffset: 1, }, + statusButton: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 0, + margin: 0, + borderWidth: 0, + borderStyle: 'none', + backgroundColor: 'transparent', + color: 'inherit', + cursor: 'pointer', + borderRadius: radiusVars['--radius-element'], + outline: { + default: 'none', + ':focus-visible': `${borderVars['--border-width']} solid ${colorVars['--color-accent']}`, + }, + outlineOffset: 1, + }, // Dropdown container dropdown: { @@ -309,8 +368,16 @@ const STATUS_ICON_COLOR_MAP: Record< success: 'success', }; +const STATUS_BUTTON_LABEL_KEY: Record = { + warning: '@astryx.input.statusButton.warning', + error: '@astryx.input.statusButton.error', + success: '@astryx.input.statusButton.success', +}; + export type SelectorSize = 'sm' | 'md' | 'lg'; +export type SelectorVariant = 'input' | 'ghost'; + export type SelectorStatusType = 'warning' | 'error' | 'success'; export interface SelectorStatus { @@ -411,6 +478,14 @@ interface SelectorPropsBase< */ size?: SelectorSize; + /** + * Visual style of the selector trigger. + * - 'input': bordered input-style trigger for forms + * - 'ghost': borderless trigger matching ghost buttons, for toolbars + * @default 'input' + */ + variant?: SelectorVariant; + /** * Status indicator for the selector. * When set, displays a colored border and status icon. @@ -419,9 +494,10 @@ interface SelectorPropsBase< status?: SelectorStatus; /** * How the status message is placed relative to the input. - * - 'attached': message overlaps directly below the input (bordered treatment) + * - 'attached': message overlaps directly below the bordered input (input variant only) * - 'detached': message floats below as a separate element with spacing - * @default 'attached' + * - 'tooltip': message is exposed from the on-field status icon + * @default 'attached' for input selectors; 'detached' for ghost selectors */ statusVariant?: FieldStatusVariant; @@ -590,6 +666,7 @@ export function Selector( isLoading = false, placeholder: placeholderFromProps, size: sizeProp, + variant = 'input', status, statusVariant = 'attached', labelTooltip, @@ -613,6 +690,10 @@ export function Selector( searchPlaceholderFromProps ?? t('@astryx.selector.searchPlaceholder'); const hasClear = hasClearProp === true; const size = useSize(sizeProp, 'md'); + const effectiveStatusVariant = + variant === 'ghost' && statusVariant === 'attached' + ? 'detached' + : statusVariant; // Normalize null to undefined for internal use (null is the clear sentinel) const normalizedValue = value === null ? undefined : value; @@ -648,12 +729,21 @@ export function Selector( focusTrigger: 'always', isEnabled: showsDisabledMessage, }); + const statusTooltip = useTooltip({ + placement: 'above', + isEnabled: effectiveStatusVariant === 'tooltip' && !!status?.message, + }); const {ariaLabelledBy, ariaDescribedBy} = getInputARIA( inputLabelId, [ description ? descriptionId : null, - status?.message ? statusMessageId : null, + !inputGroup && effectiveStatusVariant !== 'tooltip' && status?.message + ? statusMessageId + : null, + effectiveStatusVariant === 'tooltip' && status?.message + ? statusTooltip.describedBy + : null, showsDisabledMessage ? disabledMessageTooltip.describedBy : null, ], inputGroup, @@ -1051,7 +1141,10 @@ export function Selector( // The detached message box renders its own leading status icon, so the // on-field icon would duplicate it — keep the chevron indicator instead. - const showStatusIcon = status != null && statusVariant !== 'detached'; + const showStatusIcon = + status != null && effectiveStatusVariant !== 'detached'; + const showStatusTooltip = + status != null && effectiveStatusVariant === 'tooltip' && !!status.message; const selectorContent = ( <> @@ -1066,16 +1159,27 @@ export function Selector( onClick={onTriggerClick} data-testid={testId} {...mergeProps( - themeProps('selector', {size, status: status?.type ?? null}), + themeProps('selector', { + variant, + size, + status: status?.type ?? null, + }), stylex.props( inputWrapperStyles.base, styles.triggerContainer, sizeStyles[size], + variant === 'ghost' && styles.triggerGhost, isDisabled && inputWrapperStyles.disabled, + variant === 'ghost' && isDisabled && styles.triggerGhostDisabled, !selectedItem && styles.triggerPlaceholder, - status && inputStatusBorderStyles[status.type], - status && !isDisabled && inputStatusHoverShadowStyles[status.type], - inputGroup && groupStyles.inGroup, + variant !== 'ghost' && + status && + inputStatusBorderStyles[status.type], + variant !== 'ghost' && + status && + !isDisabled && + inputStatusHoverShadowStyles[status.type], + variant !== 'ghost' && inputGroup && groupStyles.inGroup, xstyle, ), className, @@ -1157,11 +1261,27 @@ export function Selector( showStatusIcon && styles.triggerIconStatus, )}> {showStatusIcon ? ( - + showStatusTooltip ? ( + + ) : ( + + ) ) : ( ( }, )} + {showStatusTooltip && statusTooltip.renderTooltip(status?.message ?? '')} + {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)} @@ -1242,7 +1364,7 @@ export function Selector( } : undefined } - statusVariant={statusVariant} + statusVariant={effectiveStatusVariant} labelTooltip={labelTooltip} width={width}> {selectorContent}