Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions pages/multiselect/style-api-v2.page.tsx
Original file line number Diff line number Diff line change
@@ -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<MultiselectProps.Option> = [
{ 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<MultiselectProps, 'selectedOptions' | 'options'> & {
selectedOptions?: ReadonlyArray<MultiselectProps.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. 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 (
<Multiselect
ariaLabel="Multiselect field"
placeholder="Choose options"
filteringAriaLabel="Filter options"
deselectAriaLabel={option => `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<HTMLDivElement>(null);
useEffect(() => {
ref.current?.querySelector('button')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
}, []);
return (
<div ref={ref}>
<StyledMultiselect {...props} />
</div>
);
}

export default function () {
return (
<SimplePage title="Multiselect - Style API v2" screenshotArea={{ disableAnimations: true }} i18n={{}}>
<SpaceBetween size="m" direction="horizontal">
<StyledMultiselect selectedOptions={[options[0], options[1]]} />
<StyledMultiselect />
<StyledMultiselect selectedOptions={[options[0]]} inlineLabelText="Options" />
</SpaceBetween>
<SpaceBetween size="m" direction="horizontal">
<StyledMultiselect selectedOptions={[options[0]]} invalid={true} />
<StyledMultiselect selectedOptions={[options[0]]} warning={true} />
<StyledMultiselect selectedOptions={[options[0], options[1]]} readOnly={true} />
</SpaceBetween>
<SpaceBetween size="m" direction="horizontal">
<StyledMultiselect selectedOptions={[options[0], options[1]]} disabled={true} />
<StyledMultiselect selectedOptions={[options[0]]} inlineLabelText="Options" disabled={true} />
<StyledMultiselect selectedOptions={[options[0], options[1]]} inlineTokens={true} />
</SpaceBetween>
<SpaceBetween size="m" direction="horizontal">
<OpenStyledMultiselect selectedOptions={[options[0]]} filteringType="auto" />
</SpaceBetween>
</SimplePage>
);
}
165 changes: 165 additions & 0 deletions pages/multiselect/style-api-v2.scss
Original file line number Diff line number Diff line change
@@ -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);
}
100 changes: 100 additions & 0 deletions src/multiselect/__tests__/style-api.test.tsx
Original file line number Diff line number Diff line change
@@ -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<MultiselectProps> & { styleClassNames?: Record<string, unknown> } = {}) {
const { container } = render(
<Multiselect
ariaLabel="Multiselect"
options={options}
selectedOptions={options}
deselectAriaLabel={option => `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();
});
});
Loading
Loading