From 998acd3712d8dd9cc8e045e9553002760c63fcd8 Mon Sep 17 00:00:00 2001 From: Vahab Valiyev Date: Tue, 3 Nov 2026 16:38:51 +0100 Subject: [PATCH] chore: Adds internal style api v2 to multiselect --- pages/multiselect/style-api-v2.page.tsx | 116 +++++++++++++ pages/multiselect/style-api-v2.scss | 165 +++++++++++++++++++ src/multiselect/__tests__/style-api.test.tsx | 100 +++++++++++ src/multiselect/index.tsx | 3 +- src/multiselect/internal.tsx | 24 ++- src/multiselect/styles.scss | 22 +++ src/select/parts/trigger.tsx | 12 +- src/token-group/internal.tsx | 6 +- 8 files changed, 442 insertions(+), 6 deletions(-) create mode 100644 pages/multiselect/style-api-v2.page.tsx create mode 100644 pages/multiselect/style-api-v2.scss create mode 100644 src/multiselect/__tests__/style-api.test.tsx diff --git a/pages/multiselect/style-api-v2.page.tsx b/pages/multiselect/style-api-v2.page.tsx new file mode 100644 index 00000000000..0247f86a850 --- /dev/null +++ b/pages/multiselect/style-api-v2.page.tsx @@ -0,0 +1,116 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { Multiselect, MultiselectProps, SpaceBetween } from '~components'; + +import { SimplePage } from '../app/templates'; +import { getInputFieldClassNames } from '../style-api/input-field'; + +import styles from './style-api-v2.scss'; + +const options: ReadonlyArray = [ + { value: '1', label: 'Option 1', description: 'The first option' }, + { value: '2', label: 'Option 2', description: 'The second option' }, + { value: '3', label: 'Option 3', description: 'The third option' }, + { value: '4', label: 'Option 4', description: 'The fourth option', disabled: true }, +]; + +type StyledMultiselectProps = Omit & { + selectedOptions?: ReadonlyArray; +}; + +// Owned states (invalid, warning, disabled, read-only) are passed as a different class; the interaction states +// (:hover, :focus, :focus-within) are handled by selectors in the stylesheet. The token forward gets one class for +// the tokens below the trigger and one for the inline tokens. +function StyledMultiselect({ + selectedOptions: initialSelectedOptions = [], + invalid, + warning, + disabled, + readOnly, + inlineTokens, + ...rest +}: StyledMultiselectProps) { + const [selectedOptions, setSelectedOptions] = useState(initialSelectedOptions); + const triggerClassName = clsx({ + [styles['trigger-invalid']]: invalid, + [styles['trigger-warning']]: warning && !invalid, + [styles['trigger-disabled']]: disabled && !invalid && !warning, + [styles['trigger-readonly']]: readOnly && !disabled && !invalid && !warning, + [styles.trigger]: !invalid && !warning && !disabled && !readOnly, + }); + const filterClassNames = getInputFieldClassNames({}); + return ( + `Remove ${option.label}`} + {...rest} + options={options} + selectedOptions={selectedOptions} + onChange={({ detail }) => setSelectedOptions(detail.selectedOptions)} + invalid={invalid} + warning={warning} + disabled={disabled} + readOnly={readOnly} + inlineTokens={inlineTokens} + {...{ + styleClassNames: { + trigger: triggerClassName, + inlineLabel: clsx(styles['inline-label'], disabled && styles['inline-label-disabled']), + filter: { + ...filterClassNames, + input: clsx(filterClassNames.input, styles['filter-input-search']), + searchIcon: styles['filter-search-icon'], + }, + dropdown: styles.dropdown, + token: { + root: clsx(inlineTokens ? styles['token-inline'] : styles.token, disabled && styles['token-disabled']), + dismissButton: clsx(styles['token-dismiss'], (disabled || readOnly) && styles['token-dismiss-disabled']), + }, + }, + }} + /> + ); +} + +// Opens the dropdown on mount so the filter and dropdown slots are visible. +function OpenStyledMultiselect(props: StyledMultiselectProps) { + const ref = useRef(null); + useEffect(() => { + ref.current?.querySelector('button')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + }, []); + return ( +
+ +
+ ); +} + +export default function () { + return ( + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/pages/multiselect/style-api-v2.scss b/pages/multiselect/style-api-v2.scss new file mode 100644 index 00000000000..31b4101d0be --- /dev/null +++ b/pages/multiselect/style-api-v2.scss @@ -0,0 +1,165 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '~components/styling/multiselect'; +@use '~components/styling/input'; +@use '~components/styling/dropdown'; +@use '~components/styling/token'; +@use '~components/styling/button'; +@use '../style-api/palette' as palette; + +// Trigger button, which is also the field box. One class per owned state (invalid, warning, disabled, read-only); +// `:hover` and `:focus` for the interaction states. The inline end padding keeps the room the caret needs. +@mixin field { + @include multiselect.trigger( + $color: palette.$text, + $background: palette.$surface, + $border-width: 2px, + $border-radius: 12px, + $box-shadow: 0 2px 4px palette.$subtle, + $padding-block: 10px, + $padding-inline: 16px 40px, + $font-family: monospace, + $font-size: 15px, + $font-weight: 600, + $placeholder-color: palette.$accent + ); +} + +.trigger { + @include field; + @include multiselect.trigger($border-color: palette.$border); + + &:hover { + @include multiselect.trigger($background: palette.$surface-alt, $border-color: palette.$border-accent); + } + + &:focus { + @include multiselect.trigger( + $color: palette.$accent, + $border-color: palette.$accent, + $box-shadow: 0 0 0 3px palette.$accent-active + ); + } +} + +.trigger-invalid { + @include field; + @include multiselect.trigger($background: palette.$error-surface, $border-color: palette.$error); + + &:focus { + @include multiselect.trigger($box-shadow: 0 0 0 3px palette.$error-hover); + } +} + +.trigger-warning { + @include field; + @include multiselect.trigger($background: palette.$warning-surface, $border-color: palette.$warning); + + &:focus { + @include multiselect.trigger($box-shadow: 0 0 0 3px palette.$warning); + } +} + +.trigger-disabled { + @include field; + @include multiselect.trigger( + $color: palette.$text-disabled, + $background: palette.$control-bg-disabled, + $border-color: palette.$border-disabled, + $box-shadow: none, + $placeholder-color: palette.$text-disabled + ); +} + +.trigger-readonly { + @include field; + @include multiselect.trigger($background: palette.$surface-alt, $border-color: palette.$border); + + &:focus { + @include multiselect.trigger($border-color: palette.$accent, $box-shadow: 0 0 0 3px palette.$accent-active); + } +} + +// Inline label: the disabled state is a class. +.inline-label { + @include multiselect.inlineLabel( + $color: palette.$text-on-accent, + $background: palette.$accent, + $font-family: monospace, + $font-size: 11px, + $font-weight: 700 + ); +} + +.inline-label-disabled { + @include multiselect.inlineLabel($color: palette.$control-fg-disabled, $background: palette.$control-bg-disabled); +} + +// Filter forwards to Input. The field box and the native input use the shared input-field classes; the inline +// padding keeps the room the search icon needs, in every state. +.filter-input-search, +.filter-input-search:placeholder-shown { + @include input.input($padding-inline: 40px); +} + +.filter-search-icon { + @include input.searchIcon($color: palette.$accent); +} + +// Dropdown forwards to Dropdown.container. +.dropdown { + @include dropdown.container( + $background: palette.$surface-alt, + $border-radius: 12px, + $box-shadow: 0 4px 12px palette.$subtle-active, + $outline-color: palette.$accent, + $outline-width: 2px + ); +} + +// Token forwards to Token.root: one class for the tokens below the trigger, one for the inline tokens (no padding +// change, so the trigger keeps its height). The disabled state is a class and comes last so it wins. +.token { + @include token.root( + $background: palette.$surface-alt, + $color: palette.$accent, + $border-color: palette.$accent, + $border-width: 2px, + $border-radius: 12px, + $padding-block: 4px, + $padding-inline: 10px 4px + ); +} + +.token-inline { + @include token.root( + $background: palette.$surface-alt, + $color: palette.$accent, + $border-color: palette.$accent, + $border-radius: 8px + ); +} + +.token-disabled { + @include token.root( + $background: palette.$control-bg-disabled, + $color: palette.$text-disabled, + $border-color: palette.$border-disabled + ); +} + +// Token dismiss button forwards to Button.root; disabled and read-only tokens share one class. +.token-dismiss { + @include button.root($color: palette.$accent, $focus-ring-color: palette.$accent, $focus-ring-radius: 4px); + + &:hover { + @include button.root($color: palette.$accent-hover); + } +} + +.token-dismiss-disabled { + @include button.root($color: palette.$text-disabled); +} diff --git a/src/multiselect/__tests__/style-api.test.tsx b/src/multiselect/__tests__/style-api.test.tsx new file mode 100644 index 00000000000..5a0cb3e41e3 --- /dev/null +++ b/src/multiselect/__tests__/style-api.test.tsx @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import Multiselect, { MultiselectProps } from '../../../lib/components/multiselect'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import dropdownStyles from '../../../lib/components/dropdown/styles.css.js'; +import tokenStyles from '../../../lib/components/token/styles.css.js'; + +const options: MultiselectProps.Options = [ + { value: '1', label: 'One' }, + { value: '2', label: 'Two' }, +]; + +function renderMultiselect(props: Partial & { styleClassNames?: Record } = {}) { + const { container } = render( + `Remove ${option.label}`} + onChange={() => {}} + {...props} + /> + ); + return { container, wrapper: createWrapper(container).findMultiselect()! }; +} + +describe('Multiselect Style API v2', () => { + test('applies the trigger class to the trigger button with tokens below and inline, and ignores unknown keys', () => { + for (const inlineTokens of [false, true]) { + const { container, wrapper } = renderMultiselect({ + inlineTokens, + styleClassNames: { trigger: 'a', unknown: 'b' }, + }); + expect(wrapper.findTrigger().getElement()).toHaveClass('a'); + wrapper.openDropdown(); + expect(wrapper.findTrigger().getElement()).toHaveClass('a'); + expect(container.querySelector('.b')).toBeNull(); + } + }); + + test('applies the inline label class only when an inline label is rendered', () => { + const { wrapper } = renderMultiselect({ inlineLabelText: 'Label', styleClassNames: { inlineLabel: 'a' } }); + expect(wrapper.findInlineLabel()!.getElement()).toHaveClass('a'); + expect(wrapper.findTrigger().getElement()).not.toHaveClass('a'); + + const { container } = renderMultiselect({ styleClassNames: { inlineLabel: 'a' } }); + expect(container.querySelector('.a')).toBeNull(); + }); + + test('forwards the filter classes to the filtering input and the dropdown class to the dropdown container', () => { + for (const expandToViewport of [false, true]) { + const { wrapper } = renderMultiselect({ + filteringType: 'auto', + expandToViewport, + styleClassNames: { filter: { root: 'a', input: 'b' }, dropdown: 'c' }, + }); + wrapper.openDropdown(); + const nativeInput = wrapper.findFilteringInput({ expandToViewport })!.findNativeInput().getElement(); + expect(nativeInput.parentElement).toHaveClass('a'); + expect(nativeInput).toHaveClass('b'); + const panel = wrapper + .findDropdown({ expandToViewport }) + .findByClassName(dropdownStyles['dropdown-content-wrapper']); + expect(panel!.getElement()).toHaveClass('c'); + } + }); + + test('forwards the token classes to every token below the trigger', () => { + const { wrapper } = renderMultiselect({ styleClassNames: { token: { root: 'a', dismissButton: 'b' } } }); + const tokens = wrapper.findTokens(); + expect(tokens).toHaveLength(2); + for (const token of tokens) { + const dismissButton = token.findDismiss().getElement(); + expect(dismissButton).toHaveClass('b'); + expect(dismissButton.parentElement).toHaveClass('a'); + } + }); + + test('forwards the token root class to every inline token', () => { + const { container, wrapper } = renderMultiselect({ + inlineTokens: true, + styleClassNames: { token: { root: 'a', dismissButton: 'b' } }, + }); + const inlineTokens = wrapper.findInlineTokens(); + expect(inlineTokens).toHaveLength(2); + for (const token of inlineTokens) { + expect(token.findByClassName(tokenStyles['token-box-inline'])!.getElement()).toHaveClass('a'); + } + expect(container.querySelector('.b')).toBeNull(); + }); + + test('does not leak the styleClassNames prop to the DOM', () => { + const { container } = renderMultiselect({ styleClassNames: { trigger: 'a' } }); + expect(container.querySelector('[styleClassNames]')).toBeNull(); + }); +}); diff --git a/src/multiselect/index.tsx b/src/multiselect/index.tsx index 7e8d8f02a01..6a1f0542127 100644 --- a/src/multiselect/index.tsx +++ b/src/multiselect/index.tsx @@ -9,7 +9,7 @@ import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { GeneratedAnalyticsMetadataMultiselectComponent } from './analytics-metadata/interfaces'; import { MultiselectProps } from './interfaces'; -import InternalMultiselect from './internal'; +import InternalMultiselect, { StyleClassNames } from './internal'; import buttonTriggerAnalyticsSelectors from '../internal/components/button-trigger/analytics-metadata/styles.css.js'; @@ -80,6 +80,7 @@ const Multiselect = React.forwardRef( hideTokens={hideTokens} {...restProps} {...baseComponentProps} + styleClassNames={(restProps as { styleClassNames?: StyleClassNames }).styleClassNames} ref={ref} {...getAnalyticsMetadataAttribute({ component: componentAnalyticsMetadata })} /> diff --git a/src/multiselect/internal.tsx b/src/multiselect/internal.tsx index d90e9317ced..9562451cdc6 100644 --- a/src/multiselect/internal.tsx +++ b/src/multiselect/internal.tsx @@ -7,6 +7,7 @@ import { useResizeObserver, useUniqueId } from '@cloudscape-design/component-too import Dropdown from '../dropdown/internal'; import { useInternalI18n } from '../i18n/context'; +import { StyleClassNames as InputStyleClassNames } from '../input/internal'; import { getBaseProps } from '../internal/base-component'; import { getBreakpointValue } from '../internal/breakpoints'; import DropdownFooter from '../internal/components/dropdown-footer/index.js'; @@ -23,6 +24,7 @@ import PlainList from '../select/parts/plain-list'; import Trigger from '../select/parts/trigger'; import VirtualList from '../select/parts/virtual-list'; import { composeDropdownContent } from '../select/utils/dropdown-customization'; +import { StyleClassNames as TokenStyleClassNames } from '../token/internal'; import { TokenGroupProps } from '../token-group/interfaces'; import InternalTokenGroup from '../token-group/internal'; import { MultiselectProps } from './interfaces'; @@ -34,7 +36,18 @@ type InternalMultiselectProps = SomeRequired< MultiselectProps, 'options' | 'selectedOptions' | 'filteringType' | 'statusType' | 'keepOpen' | 'hideTokens' > & - InternalBaseComponentProps; + InternalBaseComponentProps & { + styleClassNames?: StyleClassNames; + }; + +// Style API v2 +export interface StyleClassNames { + trigger?: string; + inlineLabel?: string; + filter?: InputStyleClassNames; + dropdown?: string; + token?: TokenStyleClassNames; +} type ExtendedToken = TokenGroupProps.Item & { _readOnly: boolean }; @@ -70,6 +83,7 @@ const InternalMultiselect = React.forwardRef( renderDropdownFooter, dropdownRole, dropdownAriaDescribedby, + styleClassNames, ...restProps }: InternalMultiselectProps, externalRef: React.Ref @@ -141,6 +155,7 @@ const InternalMultiselect = React.forwardRef( ariaLabel={filteringAriaLabel} ariaRequired={ariaRequired} value={filteringValue} + styleClassNames={styleClassNames?.filter} {...multiselectProps.getFilterProps()} /> ); @@ -165,6 +180,11 @@ const InternalMultiselect = React.forwardRef( isOpen={multiselectProps.isOpen} groupedControlProps={groupedControlProps} inlineLabelText={inlineLabelText} + styleClassNames={{ + trigger: styleClassNames?.trigger, + inlineLabel: styleClassNames?.inlineLabel, + token: styleClassNames?.token, + }} {...formFieldContext} controlId={controlId} ariaLabelledby={joinStrings(formFieldContext.ariaLabelledby, ariaLabelId)} @@ -225,6 +245,7 @@ const InternalMultiselect = React.forwardRef( > (item as ExtendedToken)._readOnly} + styleClassNames={{ token: styleClassNames?.token }} /> )} diff --git a/src/multiselect/styles.scss b/src/multiselect/styles.scss index 445ab017209..7dac7820871 100644 --- a/src/multiselect/styles.scss +++ b/src/multiselect/styles.scss @@ -5,6 +5,28 @@ @use '../internal/styles' as styles; @use '../internal/styles/tokens' as awsui; +@use '../internal/styles/style-api' as style-api; + +// The trigger is the Select trigger part; its placeholder reads the carrier set on `.trigger-button` there. +$trigger-tokens: style-api.resolve((placeholder-color), carrier); +@include style-api.register($trigger-tokens); +@include style-api.docs( + 'trigger', + $trigger-tokens, + $properties: (color, background, border, border-radius, box-shadow, padding, font-family, font-size, font-weight), + $descriptions: ( + slot: + 'The trigger button, which is also the field box. Pass a different class for the states you control (`invalid`, `warning`, `disabled`, `readOnly`); use `:hover` and `:focus` for interaction states.', + properties: ( + color: 'The placeholder is styled with `--awsui-style-placeholder-color`.', + box-shadow: 'Also draws the focus ring under `:focus`.', + ) + ) +); +@include style-api.docs('inlineLabel', $properties: (color, background, font-family, font-size, font-weight)); +@include style-api.docs-forward('filter', 'input'); +@include style-api.docs-forward('dropdown', 'dropdown', 'container'); +@include style-api.docs-forward('token', 'token'); .root { @include styles.styles-reset; diff --git a/src/select/parts/trigger.tsx b/src/select/parts/trigger.tsx index def851c70cc..e69fb6ca5fd 100644 --- a/src/select/parts/trigger.tsx +++ b/src/select/parts/trigger.tsx @@ -11,7 +11,7 @@ import { GroupedControlContextProps } from '../../internal/context/control-group import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; import { joinStrings } from '../../internal/utils/strings'; import { MultiselectProps } from '../../multiselect/interfaces'; -import InternalToken from '../../token/internal'; +import InternalToken, { StyleClassNames as TokenStyleClassNames } from '../../token/internal'; import { FormFieldValidationControlProps } from '../../types/form-field'; import { OptionDefinition } from '../../types/option'; import { SelectProps } from '../interfaces'; @@ -32,7 +32,7 @@ export interface TriggerProps extends FormFieldValidationControlProps { selectedOptions?: ReadonlyArray; renderOption?: SelectProps.SelectOptionItemRenderer; groupedControlProps?: GroupedControlContextProps; - styleClassNames?: { trigger?: string; inlineLabel?: string }; + styleClassNames?: { trigger?: string; inlineLabel?: string; token?: TokenStyleClassNames }; } const Trigger = React.forwardRef( @@ -80,7 +80,13 @@ const Trigger = React.forwardRef( > {selectedOptions.map(({ label }, i) => ( - + ))} diff --git a/src/token-group/internal.tsx b/src/token-group/internal.tsx index 5e75b0e676d..537a9c7db30 100644 --- a/src/token-group/internal.tsx +++ b/src/token-group/internal.tsx @@ -14,7 +14,7 @@ import checkControlled from '../internal/hooks/check-controlled'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useListFocusController } from '../internal/hooks/use-list-focus-controller'; import { SomeRequired } from '../internal/types'; -import InternalToken from '../token/internal'; +import InternalToken, { StyleClassNames as TokenStyleClassNames } from '../token/internal'; import { TokenGroupProps } from './interfaces'; import tokenListStyles from '../internal/components/token-list/styles.css.js'; @@ -23,6 +23,8 @@ import styles from './styles.css.js'; type InternalTokenGroupProps = SomeRequired & InternalBaseComponentProps & { isItemReadOnly?: (item: TokenGroupProps.Item) => boolean; + // Style API v2: the Token classes applied to every token + styleClassNames?: { token?: TokenStyleClassNames }; }; export default function InternalTokenGroup({ @@ -36,6 +38,7 @@ export default function InternalTokenGroup({ limitShowMoreAriaLabel, readOnly, isItemReadOnly, + styleClassNames, __internalRootRef, ...props }: InternalTokenGroupProps) { @@ -81,6 +84,7 @@ export default function InternalTokenGroup({ }} readOnly={readOnly || isItemReadOnly?.(item)} variant={'normal'} + styleClassNames={styleClassNames?.token} icon={ item.iconName || item.iconUrl || item.iconSvg ? (