chore: wds select component (#32715)

## Description
Fixes #28466

## Automation

/ok-to-test tags=""

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!CAUTION]  
> If you modify the content in this section, you are likely to disrupt
the CI result for your PR.

<!-- end of auto-generated comment: Cypress test results  -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced customizable `Select` component with enhanced dropdown
features including error messages, loading state, and contextual help.
- Added a new `className` prop to the `Button` component for custom
class name manipulation.

- **Enhancements**
- Updated the design system package to include exports for new
components like `Select` and `ContextualHelp`.
- Improved accessibility and cohesive user interface across components.

- **Bug Fixes**
- Corrected import paths for `ContextualHelp` in various components to
ensure consistent functionality.

- **Documentation**
  - Updated component documentation to reflect new features and props.

- **Style Updates**
- Added comprehensive styles for the new `Select` component, including
form fields and error text styling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Valera Melnikov 2024-04-17 13:49:41 +03:00 committed by GitHub
parent 944fa23283
commit f22dffcf50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1650 additions and 1242 deletions

View File

@ -22,7 +22,7 @@
"clsx": "^2.0.0",
"colorjs.io": "^0.4.3",
"lodash": "*",
"react-aria-components": "^1.0.0-rc.0"
"react-aria-components": "^1.1.1"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",

View File

@ -3,7 +3,7 @@ import { useVisuallyHidden } from "@react-aria/visually-hidden";
import { Button as HeadlessButton } from "@design-system/headless";
import type { ButtonRef as HeadlessButtonRef } from "@design-system/headless";
import type { SIZES } from "../../../shared";
import clsx from "clsx";
import { Text } from "../../Text";
import { Spinner } from "../../Spinner";
import styles from "./styles.module.css";
@ -25,6 +25,7 @@ const _Button = (props: ButtonProps, ref: HeadlessButtonRef) => {
onKeyUp,
variant = "filled",
visuallyDisabled = false,
className,
...rest
} = props;
const { visuallyHiddenProps } = useVisuallyHidden();
@ -67,7 +68,7 @@ const _Button = (props: ButtonProps, ref: HeadlessButtonRef) => {
aria-disabled={
visuallyDisabled || isLoading || isDisabled ? true : undefined
}
className={styles.button}
className={clsx(className, styles.button)}
data-button=""
data-color={color}
data-icon-position={iconPosition === "start" ? "start" : "end"}

View File

@ -7,7 +7,7 @@ import type {
import { CheckboxGroup as HeadlessCheckboxGroup } from "@design-system/headless";
import { fieldStyles } from "../../../styles";
import { ContextualHelp } from "../../TextInput/src/ContextualHelp";
import { ContextualHelp } from "../../ContextualHelp";
import { getTypographyClassName } from "@design-system/theming";
export interface CheckboxGroupProps extends HeadlessCheckboxGroupProps {

View File

@ -0,0 +1 @@
export * from "./src";

View File

@ -1,10 +1,7 @@
import React from "react";
import { Tooltip } from "../../Tooltip";
import { IconButton } from "../../IconButton";
import type { TextInputProps } from "./TextInput";
export type ContextualProps = TextInputProps;
import type { ContextualProps } from "./types";
const _ContextualHelp = (props: ContextualProps) => {
const { contextualHelp } = props;

View File

@ -0,0 +1 @@
export * from "./ContextualHelp";

View File

@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export interface ContextualProps {
contextualHelp: ReactNode;
}

View File

@ -8,7 +8,7 @@ import { getTypographyClassName } from "@design-system/theming";
import { RadioGroup as HeadlessRadioGroup } from "@design-system/headless";
import { fieldStyles } from "../../../styles";
import { ContextualHelp } from "../../TextInput/src/ContextualHelp";
import { ContextualHelp } from "../../ContextualHelp";
export interface RadioGroupProps extends HeadlessRadioGroupProps {
className?: string;

View File

@ -0,0 +1 @@
export * from "./src";

View File

@ -0,0 +1,108 @@
import React, { useRef } from "react";
import clsx from "clsx";
import { getTypographyClassName } from "@design-system/theming";
import {
Button,
Label,
ListBox,
Popover,
Select as SpectrumSelect,
SelectValue,
ListBoxItem as SpectrumListBoxItem,
FieldError,
} from "react-aria-components";
import {
Text,
Icon,
Spinner,
ContextualHelp,
Flex,
} from "@design-system/widgets";
import styles from "./styles.module.css";
import type { SelectProps } from "./types";
export const Select = <T extends object>(props: SelectProps<T>) => {
const {
contextualHelp,
description,
errorMessage,
isLoading,
isRequired,
items,
label,
size = "medium",
...rest
} = props;
const triggerRef = useRef<HTMLButtonElement>(null);
// place Popover in the root theme provider to get access to the CSS tokens
const root = document.body.querySelector(
"[data-theme-provider]",
) as HTMLButtonElement;
return (
<SpectrumSelect
{...rest}
className={styles.formField}
data-size={size}
isRequired={isRequired}
>
{({ isInvalid }) => (
<>
<Flex alignItems="center" gap="spacing-1">
{Boolean(label) && (
<Label>
<Text fontWeight={600} variant="caption">
{label}
{Boolean(isRequired) && (
<span
aria-label="(required)"
className={styles.necessityIndicator}
>
*
</span>
)}
</Text>
</Label>
)}
{Boolean(contextualHelp) && (
<ContextualHelp contextualHelp={contextualHelp} />
)}
</Flex>
<Button className={styles.textField} ref={triggerRef}>
<SelectValue
className={clsx(
styles.fieldValue,
getTypographyClassName("body"),
)}
/>
{!Boolean(isLoading) && <Icon name="chevron-down" />}
{Boolean(isLoading) && <Spinner />}
</Button>
<FieldError
className={clsx(
styles.errorText,
getTypographyClassName("footnote"),
)}
>
{errorMessage}
</FieldError>
{Boolean(description) && !Boolean(isInvalid) && (
<Text className={styles.description} variant="footnote">
{description}
</Text>
)}
<Popover UNSTABLE_portalContainer={root}>
<ListBox className={styles.popover} items={items}>
{(item) => (
<SpectrumListBoxItem className={styles.item} key={item.key}>
{item.icon && <Icon name={item.icon} />}
{item.name}
</SpectrumListBoxItem>
)}
</ListBox>
</Popover>
</>
)}
</SpectrumSelect>
);
};

View File

@ -0,0 +1 @@
export * from "./Select";

View File

@ -0,0 +1,149 @@
@import "../../../shared/colors/colors.module.css";
.formField {
display: flex;
flex-direction: column;
gap: var(--inner-spacing-3);
width: 100%;
}
.textField {
display: flex;
position: relative;
padding: 0;
border: none;
align-items: center;
border-radius: var(--border-radius-elevation-3);
background-color: var(--color-bg-neutral-subtle);
max-inline-size: 100%;
padding-inline-start: var(--inner-spacing-2);
padding-inline-end: calc(var(--inner-spacing-3) + var(--icon-size-2));
padding-block: var(--inner-spacing-3);
box-shadow: inset 0 0 0 var(--border-width-1)
var(--color-bd-on-neutral-subtle);
cursor: pointer;
}
.formField[data-invalid] .textField {
box-shadow: 0 0 0 var(--border-width-1) var(--color-bd-negative);
}
.formField[data-size="small"] .textField {
padding-block: var(--inner-spacing-2);
}
.textField[data-focus-visible] {
box-shadow:
0 0 0 2px var(--color-bg),
0 0 0 4px var(--color-bd-focus);
}
.textField[data-hovered] {
background-color: var(--color-bg-neutral-subtle-hover);
box-shadow: inset 0 0 0 var(--border-width-1)
var(--color-bd-on-neutral-subtle-hover);
}
.textField [data-icon] {
position: absolute;
right: var(--inner-spacing-2);
}
.necessityIndicator {
color: var(--color-fg-negative);
margin-inline-start: var(--inner-spacing-1);
}
.errorText {
color: var(--color-fg-negative);
}
.description {
color: var(--color-fg-neutral);
}
.fieldValue {
text-align: left;
flex: 1;
}
.fieldValue [data-icon] {
display: none;
}
.popover {
background-color: var(--color-bg-elevation-3);
border-radius: var(--border-radius-elevation-3);
z-index: var(--z-index-99);
box-shadow: var(--box-shadow-1);
min-inline-size: var(--trigger-width);
max-height: var(--sizing-150);
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
padding-inline: var(--inner-spacing-4);
padding-block: var(--inner-spacing-4);
}
.item [data-icon] {
margin-inline-end: var(--inner-spacing-1);
}
.item:first-of-type {
border-top-left-radius: var(--border-radius-elevation-3);
border-top-right-radius: var(--border-radius-elevation-3);
}
.item:last-of-type {
border-bottom-left-radius: var(--border-radius-elevation-3);
border-bottom-right-radius: var(--border-radius-elevation-3);
}
.item:not([data-disabled]) {
cursor: pointer;
}
.item[data-hovered] {
background-color: var(--color-bg-accent-subtle-hover);
}
.item[data-selected] {
background-color: var(--color-bg-accent-subtle-active);
}
.item:not([data-disabled]) {
@each $color in colors {
&[data-color="$(color)"] {
color: var(--color-fg-$(color));
}
}
}
.item[data-disabled] {
opacity: var(--opacity-disabled);
cursor: not-allowed;
}
.item[data-focus-visible] {
box-shadow:
inset 0 0 0 2px var(--color-bg),
inset 0 0 0 4px var(--color-bd-focus);
}
.item [data-separator] {
border-top: var(--border-width-1) solid var(--color-bd);
padding: 0;
}
/* this is required so that separator ( <Item isSeprator /> ) if passed with a text as children, the text is hidden */
.item [data-separator] > * {
display: none;
}
/* making sure the first and last child are not displayed when they have the data-separator attribute */
.item:is(:first-child, :last-child):is([data-separator]) {
display: none;
}

View File

@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import type {
SelectProps as SpectrumSelectProps,
ValidationResult,
} from "react-aria-components";
import type { IconProps, SIZES } from "@design-system/widgets";
export interface SelectProps<T extends object> extends SpectrumSelectProps<T> {
/** Item objects in the collection. */
items: Iterable<SelectItem>;
/** The content to display as the label. */
label?: string;
/** The content to display as the description. */
description?: string;
/** The content to display as the error message. */
errorMessage?: string | ((validation: ValidationResult) => string);
/** size of the select
*
* @default medium
*/
size?: Omit<keyof typeof SIZES, "large">;
/** loading state for the input */
isLoading?: boolean;
/** A ContextualHelp element to place next to the label. */
contextualHelp?: ReactNode;
}
export interface SelectItem {
name: string;
key: number;
icon?: IconProps["name"];
}

View File

@ -0,0 +1,96 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import { Select, Button, Flex, SIZES } from "@design-system/widgets";
export const items = [
{ key: 1, name: "Aerospace", icon: "rocket" },
{ key: 2, name: "Mechanical", icon: "settings" },
{ key: 3, name: "Civil" },
{ key: 4, name: "Biomedical" },
{ key: 5, name: "Nuclear" },
{ key: 6, name: "Industrial" },
{ key: 7, name: "Chemical" },
{ key: 8, name: "Agricultural" },
{ key: 9, name: "Electrical" },
];
<Meta
title="Design-system/Widgets/Select"
component={Select}
args={{
items: items,
}}
/>
export const Template = (args) => (
<Flex width="sizing-60">
<Select {...args} />
</Flex>
);
# Select
A select displays a collapsible list of options and allows a user to select one of them.
<Canvas>
<Story name="Select">{Template.bind({})}</Story>
</Canvas>
<ArgsTable story="Select" of={Select} />
## Size
<Story name="Size">
<Flex direction="column" gap="spacing-2" width="sizing-60">
{Object.keys(SIZES)
.filter((size) => !["large"].includes(size))
.map((size) => (
<Select placeholder={size} items={items} size={size} />
))}
</Flex>
</Story>
## Loading
<Story
name="Loading"
args={{
placeholder: "Loading",
isLoading: true,
}}
>
{Template.bind({})}
</Story>
## Validation
<Story name="Validation">
<form
onSubmit={(e) => {
e.preventDefault();
alert("Form submitted");
}}
>
<Flex gap="spacing-2" direction="column" width="sizing-60">
<Select
label="Validation"
isRequired
description="description"
items={items}
/>
<Button type="submit">Submit</Button>
</Flex>
</form>
</Story>
## Contextual Help Text
<Story
name="Contextual Help Text"
args={{
label: "Label",
placeholder: "Contextual Help Text",
contextualHelp: "This is a contextual help text",
}}
>
{Template.bind({})}
</Story>

View File

@ -8,7 +8,7 @@ import { TextArea as HeadlessTextArea } from "@design-system/headless";
import textAreaStyles from "./styles.module.css";
import { textInputStyles, fieldStyles } from "../../../styles";
import { ContextualHelp } from "../../TextInput/src/ContextualHelp";
import { ContextualHelp } from "../../ContextualHelp";
import { getTypographyClassName } from "@design-system/theming";
export interface TextAreaProps extends HeadlessTextAreaProps {

View File

@ -10,7 +10,7 @@ import { TextInput as HeadlessTextInput } from "@design-system/headless";
import { Spinner } from "../../Spinner";
import type { IconProps } from "../../Icon";
import { IconButton } from "../../IconButton";
import { ContextualHelp } from "./ContextualHelp";
import { ContextualHelp } from "../../ContextualHelp";
import { textInputStyles, fieldStyles } from "../../../styles";
import type { SIZES } from "../../../shared";

View File

@ -19,6 +19,8 @@ export * from "./components/Modal";
export * from "./components/TagGroup";
export * from "./components/ActionGroup";
export * from "./components/ButtonGroup";
export * from "./components/Select";
export * from "./components/ContextualHelp";
export * from "./utils";
export * from "./styles";

View File

@ -172,7 +172,7 @@
* SIZE
*-----------------------------------------------------------------------------
*/
&:has(input[data-size="small"]) [data-field-input] {
& [data-field-input] input[data-size="small"] {
padding-block: var(--inner-spacing-2);
}
}

File diff suppressed because it is too large Load Diff