diff --git a/pages/autosuggest/style-api-v2.page.tsx b/pages/autosuggest/style-api-v2.page.tsx new file mode 100644 index 0000000000..52fe9cf8e8 --- /dev/null +++ b/pages/autosuggest/style-api-v2.page.tsx @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import clsx from 'clsx'; + +import { Autosuggest, AutosuggestProps, SpaceBetween } from '~components'; + +import { SimplePage } from '../app/templates'; +import { getInputFieldClassNames } from '../style-api/input-field'; + +import styles from './style-api-v2.scss'; + +const options: AutosuggestProps.Options = [ + { value: 'apple', label: 'Apple' }, + { value: 'apricot', label: 'Apricot' }, + { value: 'avocado', label: 'Avocado', disabled: true }, +]; + +type StyledAutosuggestProps = Omit & { value?: string }; + +// The `input` slot forwards to the whole Input style API; the shared helper maps the owned states (invalid, +// warning, disabled, read-only) to classes. The dropdown forwards to Dropdown and opens on focus. +function StyledAutosuggest({ + value: initialValue = '', + invalid, + warning, + disabled, + readOnly, + ...rest +}: StyledAutosuggestProps) { + const [value, setValue] = useState(initialValue); + const fieldClassNames = getInputFieldClassNames({ invalid, warning, disabled, readOnly }); + return ( + `Use: "${text}"`} + {...rest} + options={options} + value={value} + onChange={({ detail }) => setValue(detail.value)} + invalid={invalid} + warning={warning} + disabled={disabled} + readOnly={readOnly} + {...{ + styleClassNames: { + input: { + ...fieldClassNames, + input: clsx(fieldClassNames.input, styles['input-input-search']), + searchIcon: clsx(styles['input-search-icon'], disabled && styles['input-search-icon-disabled']), + clearButton: styles['input-clear-button'], + }, + dropdown: styles.dropdown, + }, + }} + /> + ); +} + +export default function () { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/pages/autosuggest/style-api-v2.scss b/pages/autosuggest/style-api-v2.scss new file mode 100644 index 0000000000..41d8a8909f --- /dev/null +++ b/pages/autosuggest/style-api-v2.scss @@ -0,0 +1,46 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '~components/styling/input'; +@use '~components/styling/button'; +@use '~components/styling/dropdown'; +@use '../style-api/palette' as palette; + +// The field is a search input: the inline padding replaces the room the component reserves for the search icon +// and the clear button, so it is set with that room included, in every state. Added to the shared input class. +.input-input-search, +.input-input-search:placeholder-shown { + @include input.input($padding-inline: 40px); +} + +// Search icon (Input.searchIcon): the disabled state is a class. +.input-search-icon { + @include input.searchIcon($color: palette.$accent); +} + +.input-search-icon-disabled { + @include input.searchIcon($color: palette.$text-disabled); +} + +// Clear button (Input.clearButton) forwards to Button.root. +.input-clear-button { + @include button.root($color: palette.$accent, $focus-ring-color: palette.$accent); + + &:hover, + &:focus { + @include button.root($color: palette.$accent-hover); + } +} + +// 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 + ); +} diff --git a/pages/select/style-api-v2.page.tsx b/pages/select/style-api-v2.page.tsx new file mode 100644 index 0000000000..af590a7271 --- /dev/null +++ b/pages/select/style-api-v2.page.tsx @@ -0,0 +1,101 @@ +// 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 { Select, SelectProps, SpaceBetween } from '~components'; + +import { SimplePage } from '../app/templates'; + +import styles from './style-api-v2.scss'; + +const options: SelectProps.Options = [ + { 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', disabled: true }, +]; + +type StyledSelectProps = Omit & { selectedOption?: SelectProps.Option }; + +// 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. +function StyledSelect({ + selectedOption: initialSelectedOption, + invalid, + warning, + disabled, + readOnly, + ...rest +}: StyledSelectProps) { + const [selectedOption, setSelectedOption] = useState(initialSelectedOption ?? null); + 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, + }); + return ( + {}} {...props} /> + ); + return { container, wrapper: createWrapper(container).findSelect()! }; +} + +describe('Select Style API v2', () => { + test('applies the trigger class to the trigger button in the label and option trigger variants', () => { + for (const triggerVariant of ['label', 'option'] as const) { + const { wrapper } = renderSelect({ triggerVariant, styleClassNames: { trigger: 'a' } }); + expect(wrapper.findTrigger().getElement()).toHaveClass('a'); + wrapper.openDropdown(); + expect(wrapper.findTrigger().getElement()).toHaveClass('a'); + } + }); + + test('applies the inline label class only when an inline label is rendered and ignores unknown keys', () => { + const { container, wrapper } = renderSelect({ + inlineLabelText: 'Label', + styleClassNames: { inlineLabel: 'a', unknown: 'b' }, + }); + expect(wrapper.findInlineLabel()!.getElement()).toHaveClass('a'); + expect(wrapper.findTrigger().getElement()).not.toHaveClass('a'); + expect(container.querySelector('.b')).toBeNull(); + + const { container: withoutInlineLabel } = renderSelect({ styleClassNames: { inlineLabel: 'a' } }); + expect(withoutInlineLabel.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 } = renderSelect({ + 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('does not leak the styleClassNames prop to the DOM', () => { + const { container } = renderSelect({ styleClassNames: { trigger: 'a' } }); + expect(container.querySelector('[styleClassNames]')).toBeNull(); + }); +}); diff --git a/src/select/index.tsx b/src/select/index.tsx index fe216c1d3e..37dc3b26cc 100644 --- a/src/select/index.tsx +++ b/src/select/index.tsx @@ -10,7 +10,7 @@ import { applyDisplayName } from '../internal/utils/apply-display-name'; import { getExternalProps } from '../internal/utils/external-props'; import { GeneratedAnalyticsMetadataSelectComponent } from './analytics-metadata/interfaces'; import { SelectProps } from './interfaces'; -import InternalSelect from './internal'; +import InternalSelect, { StyleClassNames } from './internal'; export { SelectProps }; @@ -68,6 +68,7 @@ const Select = React.forwardRef( triggerVariant={triggerVariant} {...externalProps} {...baseComponentProps} + styleClassNames={(restProps as { styleClassNames?: StyleClassNames }).styleClassNames} ref={ref} {...getAnalyticsMetadataAttribute({ component: componentAnalyticsMetadata })} /> diff --git a/src/select/internal.tsx b/src/select/internal.tsx index 64f1dddf2b..6b6af6ab4f 100644 --- a/src/select/internal.tsx +++ b/src/select/internal.tsx @@ -7,6 +7,7 @@ import { useMergeRefs, useResizeObserver, useUniqueId, warnOnce } from '@cloudsc import Dropdown from '../dropdown/internal'; import { useInternalI18n } from '../i18n/context.js'; +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'; @@ -39,6 +40,15 @@ import styles from './styles.css.js'; export interface InternalSelectProps extends SomeRequired, InternalBaseComponentProps { __inFilteringToken?: 'root' | 'nested'; + styleClassNames?: StyleClassNames; +} + +// Style API v2 +export interface StyleClassNames { + trigger?: string; + inlineLabel?: string; + filter?: InputStyleClassNames; + dropdown?: string; } const InternalSelect = React.forwardRef( @@ -79,6 +89,7 @@ const InternalSelect = React.forwardRef( renderDropdownFooter, dropdownRole, dropdownAriaDescribedby, + styleClassNames, ...restProps }: InternalSelectProps, externalRef: React.Ref @@ -197,6 +208,7 @@ const InternalSelect = React.forwardRef( ariaLabel={filteringAriaLabel} ariaRequired={ariaRequired} value={filteringValue} + styleClassNames={styleClassNames?.filter} {...getFilterProps()} /> ); @@ -215,6 +227,7 @@ const InternalSelect = React.forwardRef( inFilteringToken={__inFilteringToken} groupedControlProps={groupedControlProps} inlineLabelText={inlineLabelText} + styleClassNames={{ trigger: styleClassNames?.trigger, inlineLabel: styleClassNames?.inlineLabel }} {...formFieldContext} controlId={controlId} ariaLabelledby={joinStrings(formFieldContext.ariaLabelledby, selectAriaLabelId)} @@ -302,6 +315,7 @@ const InternalSelect = React.forwardRef( > ; renderOption?: SelectProps.SelectOptionItemRenderer; groupedControlProps?: GroupedControlContextProps; + styleClassNames?: { trigger?: string; inlineLabel?: string }; } const Trigger = React.forwardRef( @@ -54,6 +55,7 @@ const Trigger = React.forwardRef( readOnly, renderOption, groupedControlProps, + styleClassNames, }: TriggerProps, ref: React.Ref ) => { @@ -138,6 +140,7 @@ const Trigger = React.forwardRef( {...triggerProps} id={id} ref={mergedRef} + className={clsx(styleClassNames?.trigger, styles['trigger-button'])} pressed={!!isOpen} disabled={disabled} readOnly={readOnly} @@ -160,6 +163,7 @@ const Trigger = React.forwardRef(