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:
parent
944fa23283
commit
f22dffcf50
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from "./src";
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./ContextualHelp";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ContextualProps {
|
||||
contextualHelp: ReactNode;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from "./src";
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./Select";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2474
app/client/yarn.lock
2474
app/client/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user