chore: refactor inputs (#36680)

/ok-to-test tags="@tag.Anvil"

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

- **New Features**
- Introduced a new `ChatInput` component for enhanced user input in chat
interfaces, featuring structured input fields and dynamic height
adjustments.
- Added a customizable `Input` component for both single-line and
multi-line text entry, with support for password visibility and loading
states.
- Launched a `TextAreaInput` component that integrates loading states
and optional prefix/suffix elements for a versatile text area
experience.

- **Improvements**
- Enhanced input components with new interfaces for better customization
options and shared properties.

- **Bug Fixes**
- Resolved issues related to input sizing and alignment, ensuring a
smoother user experience.

- **Documentation**
- Expanded documentation to include details on the new input components
and their usage examples.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/11251960399>
> Commit: dc87d2de1213d23fdda1f52cee5b346d68627263
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=11251960399&attempt=2"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Anvil`
> Spec:
> <hr>Wed, 09 Oct 2024 10:49:28 UTC
<!-- end of auto-generated comment: Cypress test results  -->
This commit is contained in:
Pawan Kumar 2024-10-09 16:22:44 +05:30 committed by GitHub
parent 7f31c9e269
commit 6e59db227d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
134 changed files with 1961 additions and 2420 deletions

View File

@ -1,123 +0,0 @@
import type { ReactNode, Ref } from "react";
import React, { forwardRef } from "react";
import type { SpectrumFieldProps } from "@react-types/label";
import { Label } from "./Label";
import { HelpText } from "./HelpText";
export type FieldProps = Pick<
SpectrumFieldProps,
| "contextualHelp"
| "description"
| "descriptionProps"
| "elementType"
| "errorMessage"
| "errorMessageProps"
| "includeNecessityIndicatorInAccessibilityName"
| "isDisabled"
| "isRequired"
| "label"
| "labelProps"
| "necessityIndicator"
| "wrapperClassName"
| "wrapperProps"
> & {
fieldType?: "field" | "field-group";
labelClassName?: string;
helpTextClassName?: string;
validationState?: ValidationState;
children: ReactNode;
isReadOnly?: boolean;
};
import type { ValidationState } from "@react-types/shared";
export type FieldRef = Ref<HTMLDivElement>;
const _Field = (props: FieldProps, ref: FieldRef) => {
const {
children,
contextualHelp,
description,
descriptionProps,
elementType,
errorMessage,
errorMessageProps = {},
fieldType = "field",
helpTextClassName,
includeNecessityIndicatorInAccessibilityName,
isDisabled = false,
isReadOnly = false,
isRequired,
label,
labelClassName,
labelProps,
necessityIndicator,
validationState,
wrapperClassName,
wrapperProps = {},
} = props;
// Readonly has a higher priority than disabled.
const getDisabledState = () => Boolean(isDisabled) && !Boolean(isReadOnly);
const hasHelpText =
Boolean(description) ||
(Boolean(errorMessage) && validationState === "invalid");
const renderHelpText = () => {
return (
<HelpText
className={helpTextClassName}
description={description}
descriptionProps={descriptionProps}
errorMessage={errorMessage}
errorMessageProps={errorMessageProps}
isDisabled={getDisabledState()}
validationState={validationState}
/>
);
};
const labelAndContextualHelp = (Boolean(label) ||
Boolean(contextualHelp)) && (
<div data-field-label-wrapper="">
{Boolean(label) && (
<Label
{...labelProps}
className={labelClassName}
elementType={elementType}
includeNecessityIndicatorInAccessibilityName={
includeNecessityIndicatorInAccessibilityName
}
isRequired={isRequired}
necessityIndicator={
!Boolean(isReadOnly) ? necessityIndicator : undefined
}
>
<span>{label}</span>
</Label>
)}
{contextualHelp}
</div>
);
return (
<div
{...wrapperProps}
className={wrapperClassName}
data-disabled={getDisabledState() ? "" : undefined}
data-field=""
data-field-type={fieldType}
data-readonly={Boolean(isReadOnly) ? "" : undefined}
ref={ref}
>
{labelAndContextualHelp}
<div data-field-input-wrapper="">
{children}
{hasHelpText && renderHelpText()}
</div>
</div>
);
};
export const Field = forwardRef(_Field);

View File

@ -1,48 +0,0 @@
import React, { forwardRef } from "react";
import type { HTMLAttributes } from "react";
import { useDOMRef } from "@react-spectrum/utils";
import type {
DOMRef,
SpectrumHelpTextProps,
ValidationState,
} from "@react-types/shared";
interface HelpTextProps extends Omit<SpectrumHelpTextProps, "showErrorIcon"> {
/** Props for the help text description element. */
descriptionProps?: HTMLAttributes<HTMLElement>;
/** Props for the help text error message element. */
errorMessageProps?: HTMLAttributes<HTMLElement>;
/** classname */
className?: string;
/** validation state for help text */
validationState?: ValidationState;
}
function _HelpText(props: HelpTextProps, ref: DOMRef<HTMLDivElement>) {
const {
className,
description,
descriptionProps,
errorMessage,
errorMessageProps,
validationState,
} = props;
const domRef = useDOMRef(ref);
const isErrorMessage = Boolean(errorMessage) && validationState === "invalid";
return (
<div className={className} ref={domRef}>
{isErrorMessage ? (
<div {...errorMessageProps} data-field-error-text="">
{errorMessage}
</div>
) : (
<div {...descriptionProps} data-field-description-text="">
{description}
</div>
)}
</div>
);
}
export const HelpText = forwardRef(_HelpText);

View File

@ -1,76 +0,0 @@
import React, { forwardRef } from "react";
import { useDOMRef } from "@react-spectrum/utils";
import { filterDOMProps } from "@react-aria/utils";
import type { DOMRef, StyleProps } from "@react-types/shared";
import type { SpectrumLabelProps } from "@react-types/label";
export type LabelProps = Omit<
SpectrumLabelProps,
keyof StyleProps | "labelPosition" | "labelAlign"
>;
const _Label = (props: LabelProps, ref: DOMRef<HTMLLabelElement>) => {
const {
children,
className,
elementType: ElementType = "label",
for: labelFor,
htmlFor,
includeNecessityIndicatorInAccessibilityName,
isRequired,
necessityIndicator = "icon",
onClick,
...otherProps
} = props;
const domRef = useDOMRef(ref);
const necessityLabel = Boolean(isRequired) ? "(required)" : "(optional)";
const icon = (
<span
aria-label={
Boolean(includeNecessityIndicatorInAccessibilityName)
? "(required)"
: undefined
}
data-field-necessity-indicator-icon=""
>
*
</span>
);
return (
<ElementType
data-field-label=""
{...filterDOMProps(otherProps)}
className={className}
htmlFor={
ElementType === "label" ? Boolean(labelFor) || htmlFor : undefined
}
onClick={onClick}
ref={domRef}
>
{children}
{/* necessityLabel is hidden to screen readers if the field is required because
* aria-required is set on the field in that case. That will already be announced,
* so no need to duplicate it here. If optional, we do want it to be announced here. */}
{(necessityIndicator === "label" ||
(necessityIndicator === "icon" && Boolean(isRequired))) &&
" \u200b"}
{necessityIndicator === "label" && (
<span
aria-hidden={
includeNecessityIndicatorInAccessibilityName == null
? isRequired
: undefined
}
>
{necessityLabel}
</span>
)}
{necessityIndicator === "icon" && Boolean(isRequired) && icon}
</ElementType>
);
};
export const Label = forwardRef(_Label);

View File

@ -1,3 +0,0 @@
export * from "./Field";
export type { LabelProps } from "./Label";
export type { FieldProps, FieldRef } from "./Field";

View File

@ -1,120 +0,0 @@
import type { Ref } from "react";
import React, { useCallback, useRef } from "react";
import { useTextField } from "@react-aria/textfield";
import { chain, useLayoutEffect } from "@react-aria/utils";
import { useControlledState } from "@react-stately/utils";
import type { TextAreaProps } from "./types";
import { TextInputBase } from "../../TextInputBase";
export type TextAreaRef = Ref<HTMLDivElement>;
function TextArea(props: TextAreaProps, ref: TextAreaRef) {
const {
defaultValue,
isDisabled = false,
isReadOnly = false,
isRequired = false,
onChange,
value,
...otherProps
} = props;
const isEmpty = isReadOnly && !Boolean(value) && !Boolean(defaultValue);
// not in stately because this is so we know when to re-measure, which is a spectrum design
const [inputValue, setInputValue] = useControlledState(
props.value,
props.defaultValue ?? "",
() => {
//
},
);
const inputRef = useRef<HTMLTextAreaElement>(null);
const onHeightChange = useCallback(() => {
// Quiet textareas always grow based on their text content.
// Standard textareas also grow by default, unless an explicit height is set.
if (props.height == null && inputRef.current) {
const input = inputRef.current;
const prevAlignment = input.style.alignSelf;
const prevOverflow = input.style.overflow;
// Firefox scroll position is lost when overflow: 'hidden' is applied so we skip applying it.
// The measure/applied height is also incorrect/reset if we turn on and off
// overflow: hidden in Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1787062
const isFirefox = "MozAppearance" in input.style;
if (!isFirefox) {
input.style.overflow = "hidden";
}
input.style.alignSelf = "start";
input.style.height = "auto";
const computedStyle = getComputedStyle(input);
const paddingTop = parseFloat(computedStyle.paddingTop);
const paddingBottom = parseFloat(computedStyle.paddingBottom);
input.style.height = `${
// subtract comptued padding and border to get the actual content height
input.scrollHeight -
paddingTop -
paddingBottom +
// Also, adding 1px to fix a bug in browser where there is a scrolllbar on certain heights
1
}px`;
input.style.overflow = prevOverflow;
input.style.alignSelf = prevAlignment;
}
}, [inputRef, props.height]);
useLayoutEffect(() => {
if (inputRef.current) {
onHeightChange();
}
}, [onHeightChange, inputValue, inputRef.current]);
if (props.placeholder != null) {
// eslint-disable-next-line no-console
console.warn(
"Placeholders are deprecated due to accessibility issues. Please use help text instead. See the docs for details: https://react-spectrum.adobe.com/react-spectrum/TextArea.html#help-text",
);
}
const { descriptionProps, errorMessageProps, inputProps, labelProps } =
useTextField(
{
...props,
value: isEmpty ? "—" : value,
defaultValue,
onChange: chain(onChange, setInputValue),
inputElementType: "textarea",
},
inputRef,
);
return (
<TextInputBase
{...otherProps}
descriptionProps={descriptionProps}
errorMessageProps={errorMessageProps}
inputProps={inputProps}
inputRef={inputRef}
isDisabled={isDisabled}
isReadOnly={isReadOnly}
isRequired={isRequired}
labelProps={labelProps}
multiLine
ref={ref}
/>
);
}
/**
* TextAreas are multiline text inputs, useful for cases where users have
* a sizable amount of text to enter. They allow for all customizations that
* are available to text fields.
*/
const _TextArea = React.forwardRef(TextArea);
export { _TextArea as TextArea };

View File

@ -1,3 +0,0 @@
export * from "./types";
export { TextArea } from "./TextArea";
export type { TextAreaRef } from "./TextArea";

View File

@ -1,19 +0,0 @@
import type { TextInputBaseProps } from "../../TextInputBase";
import type { OmitedSpectrumTextFieldProps } from "../../TextInput";
export interface TextAreaProps
extends OmitedSpectrumTextFieldProps,
Pick<TextInputBaseProps, "inputClassName"> {
height?: number | string;
inputClassName?: string;
/** spell check attribute */
spellCheck?: boolean;
/** classname for label */
labelClassName?: string;
/** classname for errorMessage or description */
helpTextClassName?: string;
/** classname for the field */
fieldClassName?: string;
/** className for the text input. */
className?: string;
}

View File

@ -1,55 +0,0 @@
import type { Ref } from "react";
import React, { forwardRef, useRef } from "react";
import { useTextField } from "@react-aria/textfield";
import type { TextInputProps } from "./types";
import { TextInputBase } from "../../TextInputBase";
export type TextInputRef = Ref<HTMLDivElement>;
function TextInput(props: TextInputProps, ref: TextInputRef) {
const inputRef = useRef<HTMLInputElement>(null);
const {
defaultValue,
isReadOnly = false,
spellCheck,
type: typeProp,
value,
...rest
} = props;
const isEmpty = isReadOnly && !Boolean(value) && !Boolean(defaultValue);
const type = typeProp === "password" && isEmpty ? "text" : typeProp;
const { descriptionProps, errorMessageProps, inputProps, labelProps } =
useTextField(
{ ...rest, type, defaultValue, value: isEmpty ? "—" : value },
inputRef,
);
if (props.placeholder != null) {
// eslint-disable-next-line no-console
console.warn(
"Placeholders are deprecated due to accessibility issues. Please use help text instead. See the docs for details: https://react-spectrum.adobe.com/react-spectrum/TextField.html#help-text",
);
}
return (
<TextInputBase
{...props}
descriptionProps={descriptionProps}
errorMessageProps={errorMessageProps}
inputProps={{
...inputProps,
spellCheck,
}}
inputRef={inputRef}
labelProps={labelProps}
ref={ref}
/>
);
}
const _TextInput = forwardRef(TextInput);
export { _TextInput as TextInput };

View File

@ -1,3 +0,0 @@
export * from "./types";
export { TextInput } from "./TextInput";
export type { TextInputRef } from "./TextInput";

View File

@ -1,28 +0,0 @@
import type { StyleProps } from "@react-types/shared";
import type { SpectrumTextFieldProps } from "@react-types/textfield";
export type OmitedSpectrumTextFieldProps = Omit<
SpectrumTextFieldProps,
keyof StyleProps | "icon" | "isQuiet" | "labelPosition" | "labelAlign"
>;
export interface TextInputProps extends OmitedSpectrumTextFieldProps {
/** classname for the input element */
inputClassName?: string;
/** spell check attribute */
spellCheck?: boolean;
/** classname for label */
labelClassName?: string;
/** classname for errorMessage or description */
helpTextClassName?: string;
/** classname for the field */
fieldClassName?: string;
/** className for the text input. */
className?: string;
/** indicates loading state of the text input */
isLoading?: boolean;
/** suffix component */
prefix?: React.ReactNode;
/** prefix component */
suffix?: React.ReactNode;
}

View File

@ -1,19 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { TextInput } from "@appsmith/wds-headless";
/**
* TextInput component allows users to input text. It is mostly used in forms.
*/
const meta: Meta<typeof TextInput> = {
component: TextInput,
title: "WDS/headless/TextInput",
args: {
label: "Label",
placeholder: "Placeholder",
},
};
export default meta;
type Story = StoryObj<typeof TextInput>;
export const Main: Story = {};

View File

@ -1,105 +0,0 @@
import type { Ref } from "react";
import { mergeProps } from "@react-aria/utils";
import React, { forwardRef, useRef } from "react";
import { useHover } from "@react-aria/interactions";
import { useFocusRing, useFocusable } from "@react-aria/focus";
import { Field } from "../../Field";
import type { TextInputBaseProps } from "./types";
function TextInputBase(props: TextInputBaseProps, ref: Ref<HTMLDivElement>) {
const {
autoFocus,
descriptionProps,
errorMessageProps,
fieldClassName,
inputClassName,
inputProps,
inputRef: userInputRef,
isDisabled = false,
isLoading = false,
isReadOnly = false,
labelProps,
multiLine = false,
onBlur,
onFocus,
prefix,
suffix,
validationState,
} = props;
// Readonly has a higher priority than disabled.
const getDisabledState = () => isDisabled && !isReadOnly;
const { hoverProps, isHovered } = useHover({
isDisabled: getDisabledState(),
});
const domRef = useRef<HTMLDivElement>(null);
const defaultInputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
const inputRef = userInputRef ?? defaultInputRef;
const ElementType: React.ElementType = Boolean(multiLine)
? "textarea"
: "input";
const isInvalid =
validationState === "invalid" &&
!Boolean(isDisabled) &&
!Boolean(isReadOnly);
const { focusProps, isFocused, isFocusVisible } = useFocusRing({
isTextInput: true,
autoFocus,
});
const { focusableProps } = useFocusable(
{ isDisabled: getDisabledState(), onFocus: onFocus, onBlur: onBlur },
inputRef,
);
// When user clicks on the startIcon or endIcon, we want to focus the input.
const focusInput: React.MouseEventHandler = () => {
inputRef.current?.focus();
};
return (
<Field
{...props}
descriptionProps={descriptionProps}
errorMessageProps={errorMessageProps}
labelProps={labelProps}
ref={domRef}
wrapperClassName={fieldClassName}
>
<div
aria-busy={isLoading ? true : undefined}
data-disabled={getDisabledState() ? "" : undefined}
data-field-input=""
data-focused={
isFocusVisible || (isFocused && !isReadOnly) ? "" : undefined
}
data-hovered={isHovered ? "" : undefined}
data-invalid={isInvalid ? "" : undefined}
data-loading={isLoading ? "" : undefined}
data-readonly={isReadOnly ? "" : undefined}
onClick={focusInput}
ref={ref}
>
{Boolean(prefix) && <span data-field-input-prefix>{prefix}</span>}
<ElementType
{...mergeProps(inputProps, hoverProps, focusProps, focusableProps)}
className={inputClassName}
disabled={getDisabledState()}
readOnly={isReadOnly}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref={inputRef as any}
rows={multiLine ? 1 : undefined}
/>
{Boolean(suffix) && <span data-field-input-suffix>{suffix}</span>}
</div>
</Field>
);
}
const _TextInputBase = forwardRef(TextInputBase);
export { _TextInputBase as TextInputBase };

View File

@ -1,2 +0,0 @@
export * from "./types";
export { TextInputBase } from "./TextInputBase";

View File

@ -1,25 +0,0 @@
import type { RefObject } from "react";
import type { TextFieldAria } from "@react-aria/textfield";
import type { PressEvents } from "@react-types/shared";
import type { TextInputProps } from "../../TextInput";
export interface TextInputBaseProps
extends Omit<TextInputProps, "onChange">,
PressEvents {
/** indicates if the component is textarea */
multiLine?: boolean;
/** props to be passed to label component */
labelProps?: TextFieldAria["labelProps"];
/** props to be passed to input component */
inputProps: TextFieldAria<"input" | "textarea">["inputProps"];
/** props to be passed to description component */
descriptionProps?: TextFieldAria["descriptionProps"];
/** props to be passed to error component */
errorMessageProps?: TextFieldAria["errorMessageProps"];
/** ref for input component */
inputRef?: RefObject<HTMLInputElement | HTMLTextAreaElement>;
/** Whether the input can be selected but not changed by the user. Readonly has a higher priority than disabled. */
isReadOnly?: boolean;
}

View File

@ -1,6 +1,2 @@
// components
export * from "./components/Field";
export * from "./components/Tooltip";
export * from "./components/TextInput";
export * from "./components/TextArea";
export * from "./components/Popover";
export * from "./components/Tooltip";

View File

@ -1,14 +1,13 @@
import clsx from "clsx";
import type { SIZES } from "@appsmith/wds";
import type { ForwardedRef } from "react";
import React, { forwardRef } from "react";
import { Text, Spinner, Icon } from "@appsmith/wds";
import { useVisuallyHidden } from "@react-aria/visually-hidden";
import { Button as HeadlessButton } from "react-aria-components";
import type { SIZES } from "../../../shared";
import clsx from "clsx";
import { Text } from "../../Text";
import { Spinner } from "../../Spinner";
import styles from "./styles.module.css";
import type { ButtonProps } from "./types";
import { Icon } from "../../Icon";
const _Button = (props: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) => {
props = useVisuallyDisabled(props);

View File

@ -0,0 +1,164 @@
import clsx from "clsx";
import {
FieldError,
FieldLabel,
inputFieldStyles,
IconButton,
TextAreaInput,
} from "@appsmith/wds";
import React, { useCallback, useRef, useEffect, useState } from "react";
import { useControlledState } from "@react-stately/utils";
import { chain, useLayoutEffect } from "@react-aria/utils";
import { TextField as HeadlessTextField } from "react-aria-components";
import type { ChatInputProps } from "./types";
export function ChatInput(props: ChatInputProps) {
const {
contextualHelp,
errorMessage,
isDisabled,
isInvalid,
isLoading,
isReadOnly,
isRequired,
label,
onChange,
onSubmit,
prefix,
suffix: suffixProp,
value,
...rest
} = props;
const inputRef = useRef<HTMLTextAreaElement>(null);
const [initialHeight, setInitialHeight] = useState<number | null>(null);
const [inputValue, setInputValue] = useControlledState(
props.value,
props.defaultValue ?? "",
() => {
//
},
);
useEffect(() => {
if (inputRef.current && initialHeight === null) {
const input = inputRef.current;
const computedStyle = window.getComputedStyle(input);
const height = parseFloat(computedStyle.height) || 0;
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
setInitialHeight(height + paddingTop + paddingBottom);
}
}, [initialHeight]);
const onHeightChange = useCallback(() => {
// Quiet textareas always grow based on their text content.
// Standard textareas also grow by default, unless an explicit height is set.
if (props.height == null && inputRef.current) {
const input = inputRef.current;
const prevAlignment = input.style.alignSelf;
const prevOverflow = input.style.overflow;
// Firefox scroll position is lost when overflow: 'hidden' is applied so we skip applying it.
// The measure/applied height is also incorrect/reset if we turn on and off
// overflow: hidden in Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1787062
const isFirefox = "MozAppearance" in input.style;
if (!isFirefox) {
input.style.overflow = "hidden";
}
input.style.alignSelf = "start";
input.style.height = "auto";
const computedStyle = window.getComputedStyle(input);
const height = parseFloat(computedStyle.height) || 0;
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
const textHeight = input.scrollHeight - paddingTop - paddingBottom + 1;
if (Math.abs(textHeight - height) > 10) {
input.style.height = `${textHeight}px`;
} else {
input.style.height = "auto";
}
input.style.overflow = prevOverflow;
input.style.alignSelf = prevAlignment;
}
}, [inputRef, props.height]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
onSubmit?.();
}
},
[onSubmit],
);
useLayoutEffect(() => {
if (inputRef.current) {
onHeightChange();
}
}, [onHeightChange, inputValue]);
const suffix = (function () {
if (Boolean(suffixProp)) return suffixProp;
if (Boolean(isLoading)) {
return (
<IconButton
icon="player-stop-filled"
isDisabled={isDisabled}
onPress={onSubmit}
/>
);
}
return (
<IconButton icon="arrow-up" isDisabled={isDisabled} onPress={onSubmit} />
);
})();
const styles = {
// The --input-height is required to make the icon button vertically centered.
// Why can't we do this with CSS? Reason is that the height of the input is calculated based on the content.
"--input-height": Boolean(initialHeight) ? `${initialHeight}px` : "auto",
} as React.CSSProperties;
return (
<HeadlessTextField
{...rest}
className={clsx(inputFieldStyles.field)}
isDisabled={Boolean(isDisabled)}
isInvalid={isInvalid}
isReadOnly={isReadOnly}
isRequired={isRequired}
onChange={chain(onChange, setInputValue)}
style={styles}
value={value}
>
<FieldLabel
contextualHelp={contextualHelp}
isDisabled={isDisabled}
isRequired={isRequired}
>
{label}
</FieldLabel>
<TextAreaInput
isReadOnly={isReadOnly}
onKeyDown={handleKeyDown}
prefix={prefix}
ref={inputRef}
rows={1}
size="large"
suffix={suffix}
value={value}
/>
<FieldError>{errorMessage}</FieldError>
</HeadlessTextField>
);
}

View File

@ -0,0 +1 @@
export { ChatInput } from "./ChatInput";

View File

@ -0,0 +1,5 @@
import type { TextAreaProps } from "@appsmith/wds";
export interface ChatInputProps extends TextAreaProps {
onSubmit?: () => void;
}

View File

@ -0,0 +1,68 @@
import React from "react";
import { Form } from "react-aria-components";
import type { Meta, StoryObj } from "@storybook/react";
import { Flex, ChatInput, Button } from "@appsmith/wds";
const meta: Meta<typeof ChatInput> = {
title: "WDS/Widgets/ChatInput",
component: ChatInput,
tags: ["autodocs"],
args: {
placeholder: "Write something...",
onSubmit: () => alert("Action triggered"),
},
};
export default meta;
type Story = StoryObj<typeof ChatInput>;
export const Main: Story = {
args: {
label: "Description",
placeholder: "Write something...",
},
};
export const WithLabel: Story = {
args: {
label: "Description",
},
};
export const WithContextualHelp: Story = {
args: {
label: "Description",
contextualHelp: "This is a contextual help",
},
};
export const Disabled: Story = {
args: {
isDisabled: true,
label: "Disabled",
},
};
export const Loading: Story = {
args: {
isLoading: true,
label: "Loading",
placeholder: "Loading...",
},
};
export const Validation: Story = {
render: (args) => (
<Form>
<Flex direction="column" gap="spacing-3" width="sizing-60">
<ChatInput
{...args}
errorMessage="Please enter at least 10 characters"
isRequired
label="Description"
/>
<Button type="submit">Submit</Button>
</Flex>
</Form>
),
};

View File

@ -1,8 +1,9 @@
import React, { forwardRef } from "react";
import { Checkbox as HeadlessCheckbox } from "react-aria-components";
import { Text, Icon } from "@appsmith/wds";
import styles from "./styles.module.css";
import type { ForwardedRef } from "react";
import { Text, Icon } from "@appsmith/wds";
import { Checkbox as HeadlessCheckbox } from "react-aria-components";
import styles from "./styles.module.css";
import type { CheckboxProps } from "./types";
const _Checkbox = (

View File

@ -1,65 +1,59 @@
import {
FieldError,
FieldDescription,
Popover,
ListBox,
FieldLabel,
FieldListPopover,
Button,
FieldError,
inputFieldStyles,
} from "@appsmith/wds";
import { getTypographyClassName } from "@appsmith/wds-theming";
import clsx from "clsx";
import React from "react";
import { ComboBox as HeadlessCombobox, Input } from "react-aria-components";
import styles from "./styles.module.css";
import { ComboBox as HeadlessCombobox } from "react-aria-components";
import type { ComboBoxProps } from "./types";
import { ComboBoxTrigger } from "./ComboBoxTrigger";
export const ComboBox = (props: ComboBoxProps) => {
const {
children,
contextualHelp,
description,
errorMessage,
isDisabled,
isLoading,
isRequired,
items,
label,
placeholder,
size = "medium",
...rest
} = props;
const root = document.body.querySelector(
"[data-theme-provider]",
) as HTMLButtonElement;
return (
<HeadlessCombobox
aria-label={Boolean(label) ? undefined : "ComboBox"}
className={styles.formField}
className={inputFieldStyles.field}
data-size={size}
isDisabled={isDisabled}
isRequired={isRequired}
{...rest}
>
{({ isInvalid }) => (
<>
<FieldLabel
contextualHelp={contextualHelp}
isRequired={isRequired}
text={label}
/>
<div className={styles.inputWrapper}>
<Input
className={clsx(styles.input, getTypographyClassName("body"))}
placeholder={placeholder}
/>
<Button
color={Boolean(isLoading) ? "neutral" : "accent"}
icon="chevron-down"
isLoading={isLoading}
size={size === "medium" ? "small" : "xSmall"}
slot={Boolean(isLoading) ? null : ""}
variant={Boolean(isLoading) ? "ghost" : "filled"}
/>
</div>
<FieldError errorMessage={errorMessage} />
<FieldDescription description={description} isInvalid={isInvalid} />
<FieldListPopover items={items} />
</>
)}
<FieldLabel
contextualHelp={contextualHelp}
isDisabled={isDisabled}
isRequired={isRequired}
>
{label}
</FieldLabel>
<ComboBoxTrigger
isDisabled={isDisabled}
isLoading={isLoading}
placeholder={placeholder}
size={size}
/>
<FieldError>{errorMessage}</FieldError>
<Popover UNSTABLE_portalContainer={root}>
<ListBox shouldFocusWrap>{children}</ListBox>
</Popover>
</HeadlessCombobox>
);
};

View File

@ -0,0 +1,38 @@
import clsx from "clsx";
import React, { useMemo } from "react";
import { getTypographyClassName } from "@appsmith/wds-theming";
import { Spinner, textInputStyles, Input, IconButton } from "@appsmith/wds";
import type { ComboBoxProps } from "./types";
interface ComboBoxTriggerProps {
size?: ComboBoxProps["size"];
isLoading?: boolean;
isDisabled?: boolean;
placeholder?: string;
}
export const ComboBoxTrigger: React.FC<ComboBoxTriggerProps> = (props) => {
const { isDisabled, isLoading, placeholder, size } = props;
const suffix = useMemo(() => {
if (Boolean(isLoading)) return <Spinner />;
return (
<IconButton
icon="chevron-down"
isDisabled={isDisabled}
size={size === "medium" ? "small" : "xSmall"}
/>
);
}, [isLoading, size, isDisabled]);
return (
<Input
className={clsx(textInputStyles.input, getTypographyClassName("body"))}
placeholder={placeholder}
size={size}
suffix={suffix}
/>
);
};

View File

@ -1,84 +0,0 @@
.formField {
display: flex;
flex-direction: column;
width: 100%;
}
.inputWrapper {
position: relative;
display: flex;
align-items: center;
border-radius: var(--border-radius-elevation-3);
background-color: var(--color-bg-neutral-subtle);
flex: 1;
max-inline-size: 100%;
isolation: isolate;
box-shadow: inset 0 0 0 1px var(--color-bd-on-neutral-subtle);
padding-inline-end: var(--inner-spacing-2);
}
.input {
border: 0;
background-color: transparent;
font-family: inherit;
flex-grow: 1;
color: var(--color-fg);
text-overflow: ellipsis;
max-inline-size: 100%;
width: 100%;
box-sizing: content-box;
padding-block: var(--inner-spacing-1);
padding-inline: var(--inner-spacing-2);
}
.inputWrapper:has([data-hovered]) {
background-color: var(--color-bg-neutral-subtle-hover);
box-shadow: inset 0 0 0 1px var(--color-bd-on-neutral-subtle-hover);
}
.inputWrapper:has([data-focused]) {
background-color: transparent;
box-shadow: none;
}
.inputWrapper:has([data-focused]):before {
content: "";
left: 0;
width: 100%;
height: 100%;
position: absolute;
box-shadow: 0 0 0 2px var(--color-bd-focus);
border-radius: var(--border-radius-elevation-3);
z-index: -1;
}
.inputWrapper:has([data-invalid]) {
box-shadow: 0 0 0 1px var(--color-bd-negative);
}
.inputWrapper:has([data-invalid][data-hovered]) {
box-shadow: 0 0 0 1px var(--color-bd-negative-hover);
}
.formField[data-size="small"] .input {
block-size: calc(
var(--body-line-height) + var(--body-margin-start) + var(--body-margin-end)
);
padding-block: var(--inner-spacing-2);
}
.formField[data-size="small"] .inputWrapper {
padding-inline-end: var(--inner-spacing-1);
}
.formField .inputWrapper [data-button] {
border-radius: calc(
var(--border-radius-elevation-3) - var(--inner-spacing-1)
);
}
.formField[data-size="small"] .inputWrapper {
border-radius: calc(
var(--border-radius-elevation-3) - var(--inner-spacing-2)
);
}

View File

@ -1,35 +1,15 @@
import type { Key } from "@react-types/shared";
import type {
ComboBoxProps as SpectrumComboBoxProps,
ValidationResult,
} from "react-aria-components";
import type { IconProps, SIZES } from "@appsmith/wds";
import type { SIZES, FieldProps } from "@appsmith/wds";
import type { ComboBoxProps as SpectrumComboBoxProps } from "react-aria-components";
export interface ComboBoxProps
extends Omit<SpectrumComboBoxProps<ComboBoxItem>, "slot"> {
/** Item objects in the collection. */
items: ComboBoxItem[];
/** 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);
extends Omit<SpectrumComboBoxProps<object>, "slot">,
FieldProps {
/** size of the select
*
* @default medium
*/
size?: Omit<keyof typeof SIZES, "xSmall" | "large">;
/** loading state for the input */
isLoading?: boolean;
/** A ContextualHelp element to place next to the label. */
contextualHelp?: string;
/** The content to display as the placeholder. */
placeholder?: string;
}
export interface ComboBoxItem {
id: Key;
label: string;
icon?: IconProps["name"];
}

View File

@ -1,14 +1,21 @@
import { Button, ComboBox, Flex, SIZES } from "@appsmith/wds";
import type { Meta, StoryObj } from "@storybook/react";
import React from "react";
import { items, itemsWithIcons } from "./items";
import { Form } from "react-aria-components";
import type { Meta, StoryObj } from "@storybook/react";
import { ComboBox, ListBoxItem, Flex, Button } from "@appsmith/wds";
import { items } from "./items";
/**
* A select displays a collapsible list of options and allows a user to select one of them.
*/
const meta: Meta<typeof ComboBox> = {
component: ComboBox,
title: "WDS/Widgets/ComboBox",
component: ComboBox,
tags: ["autodocs"],
args: {
children: items.map((item) => (
<ListBoxItem key={item.id} textValue={item.label}>
{item.label}
</ListBoxItem>
)),
},
};
export default meta;
@ -16,71 +23,60 @@ type Story = StoryObj<typeof ComboBox>;
export const Main: Story = {
args: {
items: items,
label: "Select an option",
placeholder: "Choose...",
},
render: (args) => (
<Flex width="sizing-60">
<ComboBox {...args} />
</Flex>
),
};
/**
* The component supports two sizes `small` and `medium`. Default size is `medium`.
*/
export const Sizes: Story = {
render: () => (
<Flex direction="column" gap="spacing-4" width="sizing-60">
{Object.keys(SIZES)
.filter((size) => !["xSmall", "large"].includes(size))
.map((size) => (
<ComboBox items={items} key={size} placeholder={size} size={size} />
))}
</Flex>
),
export const WithLabel: Story = {
args: {
label: "Favorite Fruit",
},
};
export const WithContextualHelp: Story = {
args: {
label: "Country",
contextualHelp: "Select the country you currently reside in",
},
};
export const Disabled: Story = {
args: {
isDisabled: true,
label: "Disabled ComboBox",
},
};
export const Loading: Story = {
args: {
placeholder: "Loading",
isLoading: true,
items: items,
label: "Loading ComboBox",
placeholder: "Loading options...",
},
};
export const Validation: Story = {
render: () => (
<form
onSubmit={(e) => {
e.preventDefault();
alert("Form submitted");
}}
>
<Flex direction="column" gap="spacing-5" width="sizing-60">
<ComboBox
description="description"
isRequired
items={items}
label="Validation"
/>
<Button type="submit">Submit</Button>
</Flex>
</form>
export const Size: Story = {
render: (args) => (
<Flex direction="column" gap="spacing-4">
<ComboBox {...args} label="Small" size="small" />
<ComboBox {...args} label="Medium" size="medium" />
</Flex>
),
};
export const ContextualHelp: Story = {
args: {
label: "Label",
placeholder: "Contextual Help Text",
contextualHelp: "This is a contextual help text",
items: items,
},
};
export const WithIcons: Story = {
args: {
label: "With icons",
items: itemsWithIcons,
},
export const Validation: Story = {
render: (args) => (
<Form onSubmit={(e) => e.preventDefault()}>
<Flex direction="column" gap="spacing-3" width="sizing-60">
<ComboBox
errorMessage="Please select an option"
isRequired
label="Required Selection"
{...args}
/>
<Button type="submit">Submit</Button>
</Flex>
</Form>
),
};

View File

@ -1,6 +1,4 @@
import type { ComboBoxItem } from "../src/types";
export const items: ComboBoxItem[] = [
export const items = [
{ id: 1, label: "Aerospace" },
{
id: 2,
@ -15,7 +13,7 @@ export const items: ComboBoxItem[] = [
{ id: 9, label: "Electrical" },
];
export const itemsWithIcons: ComboBoxItem[] = [
export const itemsWithIcons = [
{ id: 1, label: "Aerospace", icon: "galaxy" },
{
id: 2,

View File

@ -1,11 +1,13 @@
import React from "react";
import { Tooltip } from "../../Tooltip";
import { IconButton } from "../../IconButton";
import { Tooltip, IconButton } from "@appsmith/wds";
import type { ContextualProps } from "./types";
const _ContextualHelp = (props: ContextualProps) => {
const { contextualHelp } = props;
if (!Boolean(contextualHelp)) return null;
return (
<Tooltip interaction="click" tooltip={contextualHelp}>
<IconButton

View File

@ -0,0 +1,2 @@
export type { FieldProps } from "./types";
export { default as inputFieldStyles } from "./styles.module.css";

View File

@ -0,0 +1,5 @@
.field {
width: 100%;
display: flex;
flex-direction: column;
}

View File

@ -0,0 +1,10 @@
export interface FieldProps {
/** Error message to display when the field has an error */
errorMessage?: string;
/** Label text for the field */
label?: string;
/** Additional help text that is displayed in a tooltip beside label */
contextualHelp?: string;
/** Indicates whether the field is in a loading state */
isLoading?: boolean;
}

View File

@ -1,16 +0,0 @@
import React from "react";
import { Text } from "../../Text";
import styles from "./styles.module.css";
import type { FieldDescriptionProps } from "./types";
export const FieldDescription = (props: FieldDescriptionProps) => {
const { description, isInvalid } = props;
if (!Boolean(description) || Boolean(isInvalid)) return null;
return (
<Text className={styles.description} lineClamp={2} size="footnote">
{description}
</Text>
);
};

View File

@ -1,2 +0,0 @@
export * from "./FieldDescription";
export type { FieldDescriptionProps } from "./types";

View File

@ -1,4 +0,0 @@
.description {
margin-block-start: var(--inner-spacing-3);
color: var(--color-fg-neutral);
}

View File

@ -1,6 +0,0 @@
export interface FieldDescriptionProps {
/** The content to display as the description. */
description?: string;
/** Whether the input value is invalid. */
isInvalid?: boolean;
}

View File

@ -1,18 +1,20 @@
import React from "react";
import { getTypographyClassName } from "@appsmith/wds-theming";
import clsx from "clsx";
import { FieldError as HeadlessFieldError } from "react-aria-components";
import { Text } from "@appsmith/wds";
import { FieldError as AriaFieldError } from "react-aria-components";
import styles from "./styles.module.css";
import type { FieldErrorProps } from "./types";
export const FieldError = (props: FieldErrorProps) => {
const { errorMessage } = props;
const { children } = props;
if (!Boolean(children)) return null;
return (
<HeadlessFieldError
className={clsx(styles.errorText, getTypographyClassName("footnote"))}
>
{errorMessage}
</HeadlessFieldError>
<AriaFieldError className={styles.errorText}>
<Text color="negative" size="caption">
{children}
</Text>
</AriaFieldError>
);
};

View File

@ -1,4 +1,3 @@
.errorText {
margin-block-start: var(--inner-spacing-3);
color: var(--color-fg-negative);
margin-block-start: var(--inner-spacing-2);
}

View File

@ -2,5 +2,5 @@ import type { ValidationResult } from "react-aria-components";
export interface FieldErrorProps {
/** The content to display as the error message. */
errorMessage?: string | ((validation: ValidationResult) => string);
children?: string | ((validation: ValidationResult) => string);
}

View File

@ -1,36 +1,37 @@
import clsx from "clsx";
import React from "react";
import { Text, ContextualHelp } from "@appsmith/wds";
import { Label as HeadlessLabel } from "react-aria-components";
import { ContextualHelp, Text } from "@appsmith/wds";
import { Label as HeadlessLabel, Group } from "react-aria-components";
import styles from "./styles.module.css";
import type { LabelProps } from "./types";
export const FieldLabel = (props: LabelProps) => {
const { className, contextualHelp, isDisabled, isRequired, text, ...rest } =
props;
export function FieldLabel(props: LabelProps) {
const { children, contextualHelp, isDisabled, isRequired, ...rest } = props;
if (!Boolean(text) && !Boolean(contextualHelp)) return null;
if (!Boolean(children) && !Boolean(contextualHelp)) return null;
return (
<HeadlessLabel
aria-label={text}
className={clsx(className, styles.label)}
data-disabled={isDisabled}
data-field-label-wrapper
elementType="label"
{...rest}
<Group
className={styles.labelGroup}
data-field-label-wrapper=""
isDisabled={isDisabled}
>
<Text fontWeight={600} lineClamp={1} size="caption">
{text}
<HeadlessLabel
{...rest}
className={clsx(styles.label)}
elementType="label"
>
<Text fontWeight={600} size="caption">
{children}
</Text>
{Boolean(isRequired) && (
<span aria-label="(required)" className={styles.necessityIndicator}>
*
</span>
)}
</Text>
{Boolean(contextualHelp) && (
<ContextualHelp contextualHelp={contextualHelp} />
)}
</HeadlessLabel>
</HeadlessLabel>
<ContextualHelp contextualHelp={contextualHelp} />
</Group>
);
};
}

View File

@ -1,17 +1,23 @@
.labelGroup {
display: flex;
align-items: center;
gap: var(--inner-spacing-1);
height: var(--sizing-3);
margin-block-end: var(--inner-spacing-3);
}
.labelGroup[data-disabled] {
opacity: var(--opacity-disabled);
}
.label {
display: flex;
align-items: center;
height: var(--sizing-3);
margin-block-end: var(--inner-spacing-3);
height: fit-content;
max-width: 100%;
gap: var(--inner-spacing-1);
&[data-disabled="true"] {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
}
.necessityIndicator {
color: var(--color-fg-negative);
margin-inline-start: var(--inner-spacing-1);
}

View File

@ -1,8 +1,7 @@
import type { LabelProps as HeadlessLabelProps } from "react-aria-components";
import type { LabelProps as AriaLabelProps } from "react-aria-components";
export interface LabelProps extends HeadlessLabelProps {
text?: string;
contextualHelp?: string;
export type LabelProps = AriaLabelProps & {
contextualHelp?: React.ReactNode;
isRequired?: boolean;
isDisabled?: boolean;
}
};

View File

@ -1,36 +0,0 @@
import React from "react";
import clsx from "clsx";
import { getTypographyClassName } from "@appsmith/wds-theming";
import { listItemStyles, Popover, Icon } from "@appsmith/wds";
import { ListBox, ListBoxItem } from "react-aria-components";
import styles from "./styles.module.css";
import type { FieldListPopoverProps } from "./types";
export const FieldListPopover = (props: FieldListPopoverProps) => {
const { items } = props;
// 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 (
<Popover UNSTABLE_portalContainer={root}>
<ListBox className={styles.listBox} items={items} shouldFocusWrap>
{(item) => (
<ListBoxItem
className={clsx(
listItemStyles.item,
getTypographyClassName("body"),
)}
key={item.id}
textValue={item.label}
>
{item.icon && <Icon name={item.icon} />}
{item.label}
</ListBoxItem>
)}
</ListBox>
</Popover>
);
};

View File

@ -1,2 +0,0 @@
export * from "./FieldListPopover";
export type { FieldListPopoverProps, FieldListPopoverItem } from "./types";

View File

@ -1,12 +0,0 @@
.listBox {
min-inline-size: var(--trigger-width);
max-height: inherit;
overflow-y: auto;
}
/** If at least one select item has an icon, we need to add extra padding for items that doesn't have an icon. */
.listBox:has([data-icon]) [role="option"]:not(:has([data-icon])) {
padding-inline-start: calc(
var(--icon-size-4) + var(--inner-spacing-3) + var(--inner-spacing-2)
);
}

View File

@ -1,13 +0,0 @@
import type { Key } from "@react-types/shared";
import type { IconProps } from "@appsmith/wds";
export interface FieldListPopoverProps {
/** Item objects in the collection. */
items: FieldListPopoverItem[];
}
export interface FieldListPopoverItem {
id: Key;
label: string;
icon?: IconProps["name"];
}

View File

@ -0,0 +1,65 @@
import clsx from "clsx";
import React, { forwardRef, useState } from "react";
import { getTypographyClassName } from "@appsmith/wds-theming";
import { IconButton, Spinner, type IconProps } from "@appsmith/wds";
import { Group, Input as HeadlessInput } from "react-aria-components";
import styles from "./styles.module.css";
import type { InputProps } from "./types";
function _Input(props: InputProps, ref: React.Ref<HTMLInputElement>) {
const {
defaultValue,
isLoading,
isReadOnly,
prefix,
size,
suffix: suffixProp,
type,
value,
...rest
} = props;
const [showPassword, setShowPassword] = useState(false);
const togglePasswordVisibility = () => setShowPassword((prev) => !prev);
const isEmpty = !Boolean(value) && !Boolean(defaultValue);
const suffix = (() => {
if (Boolean(isLoading)) return <Spinner />;
if (type === "password") {
const icon: IconProps["name"] = showPassword ? "eye-off" : "eye";
return (
<IconButton
color="neutral"
excludeFromTabOrder
icon={icon}
onPress={togglePasswordVisibility}
size={size === "medium" ? "small" : "xSmall"}
variant="ghost"
/>
);
}
return suffixProp;
})();
return (
<Group className={styles.inputGroup}>
<HeadlessInput
{...rest}
className={clsx(styles.input, getTypographyClassName("body"))}
data-readonly={Boolean(isReadOnly) ? true : undefined}
data-size={Boolean(size) ? size : undefined}
defaultValue={defaultValue}
ref={ref}
type={showPassword ? "text" : type}
value={isEmpty && Boolean(isReadOnly) ? "—" : value}
/>
{Boolean(prefix) && <span data-input-prefix>{prefix}</span>}
{Boolean(suffix) && <span data-input-suffix>{suffix}</span>}
</Group>
);
}
export const Input = forwardRef(_Input);

View File

@ -0,0 +1,51 @@
import clsx from "clsx";
import { Spinner } from "@appsmith/wds";
import React, { forwardRef } from "react";
import { getTypographyClassName } from "@appsmith/wds-theming";
import { Group, TextArea as HeadlessTextArea } from "react-aria-components";
import styles from "./styles.module.css";
import type { TextAreaInputProps } from "./types";
function _TextAreaInput(
props: TextAreaInputProps,
ref: React.Ref<HTMLTextAreaElement>,
) {
const {
defaultValue,
isLoading,
isReadOnly,
prefix,
rows,
size,
suffix: suffixProp,
value,
...rest
} = props;
const isEmpty = !Boolean(value) && !Boolean(defaultValue);
const suffix = (() => {
if (Boolean(isLoading)) return <Spinner />;
return suffixProp;
})();
return (
<Group className={styles.inputGroup}>
<HeadlessTextArea
{...rest}
className={clsx(styles.input, getTypographyClassName("body"))}
data-readonly={Boolean(isReadOnly) ? true : undefined}
data-size={Boolean(size) ? size : undefined}
defaultValue={defaultValue}
ref={ref}
rows={Boolean(rows) ? rows : undefined}
value={isEmpty && Boolean(isReadOnly) ? "—" : value}
/>
{Boolean(prefix) && <span data-input-prefix>{prefix}</span>}
{Boolean(suffix) && <span data-input-suffix>{suffix}</span>}
</Group>
);
}
export const TextAreaInput = forwardRef(_TextAreaInput);

View File

@ -0,0 +1,3 @@
export * from "./Input";
export * from "./TextAreaInput";
export { default as textInputStyles } from "./styles.module.css";

View File

@ -0,0 +1,223 @@
.inputGroup {
position: relative;
display: flex;
align-items: center;
gap: var(--inner-spacing-1);
width: 100%;
}
.input {
position: relative;
display: flex;
flex: 1;
align-items: center;
box-sizing: content-box;
max-inline-size: 100%;
padding-block: var(--inner-spacing-1);
padding-inline: var(--inner-spacing-2);
gap: var(--inner-spacing-1);
border: 0;
border-radius: var(--border-radius-elevation-3);
background-color: var(--color-bg-neutral-subtle);
box-shadow: inset 0 0 0 1px var(--color-bd-on-neutral-subtle);
isolation: isolate;
}
.input:has(> [data-select-text]) {
block-size: var(--body-line-height);
}
.input:is(textarea) {
block-size: auto;
min-block-size: var(--sizing-16);
align-items: flex-start;
resize: none;
font-family: inherit;
}
.input:is(textarea)[rows="1"] {
min-block-size: initial;
}
.input:autofill,
.input:autofill:hover,
.input:autofill:focus,
.input:autofill:active {
font-size: initial;
-webkit-text-fill-color: var(--color-fg);
-webkit-box-shadow: 0 0 0 40rem var(--color-bg-neutral-subtle) inset;
}
/**
* ----------------------------------------------------------------------------
* SUFFIX and PREFIX
* ----------------------------------------------------------------------------
*/
.inputGroup [data-input-suffix] button {
border-radius: calc(
var(--border-radius-elevation-3) - var(--inner-spacing-1)
);
}
.inputGroup:has(> [data-input-prefix]) .input {
padding-left: var(--sizing-8);
}
.inputGroup:has(> [data-input-prefix]) .input[data-size="small"] {
padding-left: var(--sizing-6);
}
.inputGroup:has(> [data-input-prefix]) [data-input-prefix] {
left: var(--inner-spacing-1);
position: absolute;
}
.inputGroup:has(> [data-input-suffix]) .input {
padding-right: var(--sizing-8);
}
.inputGroup:has(> [data-input-suffix]) .input[data-size="small"] {
padding-right: var(--sizing-6);
}
.inputGroup:has(> [data-input-suffix]) [data-input-suffix] {
right: var(--inner-spacing-1);
position: absolute;
}
/* Note: the following calculations are done so that icon button isn chat input is centered vertically */
.inputGroup:has(.input[rows="1"]) [data-input-suffix] {
--icon-size: calc(
var(--body-line-height) + var(--body-margin-start) + var(--body-margin-end) +
var(--inner-spacing-3) * 2
);
--icon-offset: calc((var(--input-height) - var(--icon-size)) / 2);
bottom: var(--icon-offset);
right: var(--icon-offset);
}
.inputGroup :is([data-input-suffix], [data-input-prefix]) {
display: flex;
justify-content: center;
align-items: center;
}
/**
* ----------------------------------------------------------------------------
* HOVERED
* ----------------------------------------------------------------------------
*/
.inputGroup[data-hovered]
.input:not(:is([data-focused], [data-readonly], [data-disabled])) {
background-color: var(--color-bg-neutral-subtle-hover);
box-shadow: inset 0 0 0 1px var(--color-bd-on-neutral-subtle-hover);
}
/**
* ----------------------------------------------------------------------------
* READONLY
* ----------------------------------------------------------------------------
*/
.input[data-readonly] {
background-color: transparent;
box-shadow: none;
padding-inline: 0;
}
/** Reason for doing this is because for readonly inputs, we want focus state to be wider than the component width */
.inputGroup:has(> .input[data-readonly][data-focus-visible])::before {
content: "";
left: calc(-0.5 * var(--inner-spacing-1));
width: calc(100% + var(--inner-spacing-1));
height: 100%;
position: absolute;
box-shadow: 0 0 0 2px var(--color-bd-focus);
border-radius: var(--border-radius-elevation-3);
}
/**
* ----------------------------------------------------------------------------
* PLACEHOLDER
* ----------------------------------------------------------------------------
*/
.input::placeholder {
color: var(--color-fg-neutral-subtle) !important;
opacity: 1;
}
.input:placeholder-shown {
text-overflow: ellipsis;
}
/**
* ----------------------------------------------------------------------------
* DISABLED
* ----------------------------------------------------------------------------
*/
.input[data-disabled],
.input[data-disabled] :is(input, textarea),
.input[data-disabled] label {
cursor: not-allowed;
box-shadow: none;
}
/**
* ----------------------------------------------------------------------------
* INVALID
* ----------------------------------------------------------------------------
*/
.input[data-invalid] {
box-shadow: 0 0 0 1px var(--color-bd-negative);
}
.inputGroup[data-hovered]
.input[data-invalid]:not(
:is([data-focused], [data-readonly], [data-disabled])
) {
box-shadow: 0 0 0 1px var(--color-bd-negative-hover);
}
/**
* ----------------------------------------------------------------------------
* FOCUSSED
* ----------------------------------------------------------------------------
*/
.input[data-focused]:not([data-readonly]) {
background-color: transparent;
box-shadow: 0 0 0 2px var(--color-bd-focus);
}
/**
* ----------------------------------------------------------------------------
* SIZE
* ----------------------------------------------------------------------------
*/
.input[data-size="small"] {
block-size: calc(
var(--body-line-height) + var(--body-margin-start) + var(--body-margin-end)
);
padding-block: var(--inner-spacing-2);
}
.input[data-size="large"] {
block-size: calc(
var(--body-line-height) + var(--body-margin-start) + var(--body-margin-end)
);
padding-block: var(--inner-spacing-3);
padding-inline: var(--inner-spacing-3);
}
/**
* ----------------------------------------------------------------------------
* SELECT BUTTON's TEXT
* ----------------------------------------------------------------------------
*/
.input [data-select-text] {
display: flex;
align-items: center;
}
.input [data-select-text] [data-icon] {
margin-inline-end: var(--inner-spacing-1);
}

View File

@ -0,0 +1,24 @@
import type { SIZES } from "@appsmith/wds";
import type {
InputProps as HeadlessInputProps,
TextAreaProps as HeadlessTextAreaProps,
} from "react-aria-components";
// Common properties for both Input and TextArea
interface CommonInputProps {
prefix?: React.ReactNode;
suffix?: React.ReactNode;
isLoading?: boolean;
isReadOnly?: boolean;
size?: Omit<keyof typeof SIZES, "xSmall">;
}
export interface InputProps
extends Omit<HeadlessInputProps, "prefix" | "size">,
CommonInputProps {}
export interface TextAreaInputProps
extends Omit<HeadlessTextAreaProps, "prefix" | "size">,
CommonInputProps {
rows?: number;
}

View File

@ -4,4 +4,15 @@ import type { TextProps } from "../../Text";
export interface LinkProps
extends Omit<TextProps, "color">,
Omit<AriaLinkProps, "style" | "className" | "children" | "isDisabled"> {}
Omit<
AriaLinkProps,
| "style"
| "className"
| "children"
| "isDisabled"
| "onBlur"
| "onFocus"
| "onKeyDown"
| "onKeyUp"
| "slot"
> {}

View File

@ -0,0 +1,15 @@
import React from "react";
import styles from "./styles.module.css";
import { ListBox as HeadlessListBox } from "react-aria-components";
import type { ListBoxProps } from "./types";
export function ListBox(props: ListBoxProps) {
const { children, ...rest } = props;
return (
<HeadlessListBox {...rest} className={styles.listBox}>
{children}
</HeadlessListBox>
);
}

View File

@ -0,0 +1,2 @@
export { ListBox } from "./ListBox";
export { default as listStyles } from "./styles.module.css";

View File

@ -0,0 +1,5 @@
.listBox {
min-inline-size: var(--trigger-width);
max-height: inherit;
overflow-y: auto;
}

View File

@ -0,0 +1,3 @@
import type { ListBoxProps as AriaListBoxProps } from "react-aria-components";
export interface ListBoxProps extends AriaListBoxProps<object> {}

View File

@ -0,0 +1,17 @@
import React from "react";
import { Icon, Text } from "@appsmith/wds";
import { ListBoxItem as HeadlessListBoxItem } from "react-aria-components";
import styles from "./styles.module.css";
import type { ListBoxItemProps } from "./types";
export function ListBoxItem(props: ListBoxItemProps) {
const { children, icon, ...rest } = props;
return (
<HeadlessListBoxItem {...rest} className={styles.listBoxItem}>
{icon && <Icon name={icon} />}
<Text lineClamp={1}>{children}</Text>
</HeadlessListBoxItem>
);
}

View File

@ -0,0 +1,2 @@
export { ListBoxItem } from "./ListBoxItem";
export { default as listBoxItemStyles } from "./styles.module.css";

View File

@ -0,0 +1,83 @@
@import "../../../shared/colors/colors.module.css";
.listBoxItem {
display: flex;
align-items: center;
padding-inline: var(--inner-spacing-3);
block-size: var(--sizing-11);
}
.listBoxItem:first-of-type {
border-top-left-radius: var(--border-radius-elevation-3);
border-top-right-radius: var(--border-radius-elevation-3);
}
.listBoxItem:last-of-type {
border-bottom-left-radius: var(--border-radius-elevation-3);
border-bottom-right-radius: var(--border-radius-elevation-3);
}
/**
* ----------------------------------------------------------------------------
* ICON STYLES
*-----------------------------------------------------------------------------
*/
.listBoxItem [data-icon] {
margin-inline-end: var(--inner-spacing-2);
}
.listBoxItem [data-submenu-icon] {
margin-inline-end: 0;
margin-inline-start: auto;
}
/**
* ----------------------------------------------------------------------------
* HOVER AND ACTIVE STATES
*-----------------------------------------------------------------------------
*/
.listBoxItem:not([data-disabled]) {
cursor: pointer;
}
.listBoxItem[data-hovered] {
background-color: var(--color-bg-accent-subtle-hover);
}
.listBoxItem[data-selected] {
background-color: var(--color-bg-accent-subtle-active);
}
/**
* ----------------------------------------------------------------------------
* DISABLED STATE
*-----------------------------------------------------------------------------
*/
.listBoxItem[data-disabled] {
opacity: var(--opacity-disabled);
cursor: not-allowed;
}
/**
* ----------------------------------------------------------------------------
* FOCUS VISIBLE
*-----------------------------------------------------------------------------
*/
.listBoxItem[data-focus-visible] {
box-shadow: inset 0 0 0 2px var(--color-bd-focus);
}
/**
* ----------------------------------------------------------------------------
* SEPARATOR
*-----------------------------------------------------------------------------
*/
.separator {
border-top: var(--border-width-1) solid var(--color-bd);
padding: 0;
}
/* making sure the first and last child are not displayed when they have the data-separator attribute */
.separator:is(:first-child, :last-child) {
display: none;
}

View File

@ -0,0 +1,6 @@
import type { ListBoxItemProps as AriaListBoxItemProps } from "react-aria-components";
import type { IconProps } from "@appsmith/wds";
export interface ListBoxItemProps extends AriaListBoxItemProps<object> {
icon?: IconProps["name"];
}

View File

@ -1,71 +1,28 @@
import React from "react";
import { Icon, listItemStyles, Popover, Text } from "@appsmith/wds";
import {
Menu as HeadlessMenu,
MenuItem,
Separator,
SubmenuTrigger,
} from "react-aria-components";
import styles from "./styles.module.css";
import type { MenuProps, MenuItemProps } from "./types";
import type { Key } from "@react-types/shared";
import React, { createContext, useContext } from "react";
import { listStyles, Popover } from "@appsmith/wds";
import { Menu as HeadlessMenu } from "react-aria-components";
import type { MenuProps } from "./types";
const MenuNestingContext = createContext(0);
export const Menu = (props: MenuProps) => {
const { hasSubmenu = false } = props;
// place Popover in the root theme provider to get access to the CSS tokens
const { children } = props;
const root = document.body.querySelector(
"[data-theme-provider]",
) as HTMLButtonElement;
return (
// We should put only parent Popover in the root, if we put the child ones, then Menu will work incorrectly
<Popover UNSTABLE_portalContainer={hasSubmenu ? undefined : root}>
<HeadlessMenu className={styles.menu} {...props}>
{(item) => renderFunc(item, props)}
</HeadlessMenu>
</Popover>
);
};
const renderFunc = (item: MenuItemProps, props: MenuProps) => {
const { childItems, icon, id, isDisabled, isSeparator = false, label } = item;
const isItemDisabled = () =>
Boolean((props.disabledKeys as Key[])?.includes(id)) || isDisabled;
if (childItems != null)
return (
<SubmenuTrigger {...props}>
<MenuItem
className={listItemStyles.item}
isDisabled={isItemDisabled()}
key={id}
>
{icon && <Icon name={icon} />}
<Text className={listItemStyles.text} lineClamp={1}>
{label}
</Text>
<Icon data-chevron name="chevron-right" size="small" />
</MenuItem>
<Menu hasSubmenu items={childItems}>
{(item) => renderFunc(item, props)}
</Menu>
</SubmenuTrigger>
);
if (isSeparator)
return <Separator className={listItemStyles.separator} key={id} />;
const nestingLevel = useContext(MenuNestingContext);
const isRootMenu = nestingLevel === 0;
return (
<MenuItem
className={listItemStyles.item}
isDisabled={isItemDisabled()}
key={id}
>
{icon && <Icon name={icon} />}
<Text className={listItemStyles.text} lineClamp={1}>
{label}
</Text>
</MenuItem>
<MenuNestingContext.Provider value={nestingLevel + 1}>
{/* Only the parent Popover should be placed in the root. Placing child popoves in root would cause the menu to function incorrectly */}
<Popover UNSTABLE_portalContainer={isRootMenu ? root : undefined}>
<HeadlessMenu className={listStyles.listBox} {...props}>
{children}
</HeadlessMenu>
</Popover>
</MenuNestingContext.Provider>
);
};

View File

@ -1,3 +1,3 @@
export * from "./Menu";
export { MenuTrigger } from "react-aria-components";
export * from "./types";
export { MenuTrigger, SubmenuTrigger } from "react-aria-components";

View File

@ -1,31 +1,7 @@
import type {
MenuProps as HeadlessMenuProps,
MenuItemProps as HeadlessMenuItemProps,
} from "react-aria-components";
import type { Key } from "@react-types/shared";
import type { IconProps } from "../../Icon";
import type { MenuProps as AriaMenuProps } from "react-aria-components";
export interface MenuProps
extends Omit<
HeadlessMenuProps<MenuItem>,
AriaMenuProps<object>,
"slot" | "selectionMode" | "selectedKeys"
> {
/**
* Whether the item has a submenu.
*/
hasSubmenu?: boolean;
}
export interface MenuItem {
id: Key;
label?: string;
icon?: IconProps["name"];
isDisabled?: boolean;
isSeparator?: boolean;
childItems?: Iterable<MenuItem>;
hasSubmenu?: boolean;
}
export interface MenuItemProps
extends Omit<HeadlessMenuItemProps, "id">,
MenuItem {}
> {}

View File

@ -1,7 +1,12 @@
import React from "react";
import { Button, Menu, MenuTrigger } from "@appsmith/wds";
import {
Button,
Menu,
MenuTrigger,
MenuItem,
SubmenuTrigger,
} from "@appsmith/wds";
import type { Meta, StoryObj } from "@storybook/react";
import { menuItems, submenusItems, submenusItemsWithIcons } from "./menuData";
/**
* A menu displays a list of actions or options that a user can choose.
@ -17,14 +22,24 @@ export default meta;
type Story = StoryObj<typeof Menu>;
export const Main: Story = {
render: (args) => (
render: () => (
<MenuTrigger>
<Button>Open The Menu</Button>
<Menu
items={menuItems}
onAction={(key) => alert(`Selected key: ${key}`)}
{...args}
/>
<Menu>
<MenuItem id="1">Item 1</MenuItem>
<MenuItem id="2">Item 2</MenuItem>
<MenuItem id="3">Item 3</MenuItem>
<MenuItem id="4">Item 4</MenuItem>
<SubmenuTrigger>
<MenuItem id="5">Submenu</MenuItem>
<Menu>
<MenuItem id="6">Submenu Item 1</MenuItem>
<MenuItem id="7">Submenu Item 2</MenuItem>
<MenuItem id="8">Submenu Item 3</MenuItem>
<MenuItem id="9">Submenu Item 4</MenuItem>
</Menu>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
),
};
@ -33,20 +48,38 @@ export const Submenus: Story = {
render: () => (
<MenuTrigger>
<Button>Open The Menu</Button>
<Menu items={submenusItems} />
<Menu>
<MenuItem id="1">Item 1</MenuItem>
<MenuItem id="2">Item 2</MenuItem>
<SubmenuTrigger>
<MenuItem id="3">Submenu 1</MenuItem>
<Menu>
<MenuItem id="4">Submenu 1 Item 1</MenuItem>
<MenuItem id="5">Submenu 1 Item 2</MenuItem>
<SubmenuTrigger>
<MenuItem id="6">Submenu 2</MenuItem>
<Menu>
<MenuItem id="7">Submenu 2 Item 1</MenuItem>
<MenuItem id="8">Submenu 2 Item 2</MenuItem>
</Menu>
</SubmenuTrigger>
</Menu>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
),
};
/**
* The items can be disabled by passing `disabledKeys` or `isDisabled` in the item configuration.
*/
export const DisabledItems: Story = {
render: () => (
<MenuTrigger>
<Button>Open The Menu</Button>
<Menu disabledKeys={[1, 2]} items={submenusItems} />
<Menu disabledKeys={["2", "3"]}>
<MenuItem id="1">Enabled Item</MenuItem>
<MenuItem id="2">Disabled Item 1</MenuItem>
<MenuItem id="3">Disabled Item 2</MenuItem>
<MenuItem id="4">Enabled Item</MenuItem>
</Menu>
</MenuTrigger>
),
};
@ -55,7 +88,12 @@ export const WithIcons: Story = {
render: () => (
<MenuTrigger>
<Button>Open The Menu</Button>
<Menu items={submenusItemsWithIcons} />
<Menu>
<MenuItem icon="home">Home</MenuItem>
<MenuItem icon="file">Files</MenuItem>
<MenuItem icon="settings">Settings</MenuItem>
<MenuItem icon="question-mark">Help</MenuItem>
</Menu>
</MenuTrigger>
),
};

View File

@ -1,6 +1,4 @@
import type { MenuItem } from "../src";
export const menuItems: MenuItem[] = [
export const menuItems = [
{ id: 1, label: "Aerospace" },
{ id: 2, label: "Mechanical" },
{ id: 3, label: "Civil" },
@ -12,7 +10,7 @@ export const menuItems: MenuItem[] = [
{ id: 9, label: "Electrical" },
];
export const submenusItems: MenuItem[] = [
export const submenusItems = [
{ id: 1, label: "Level 1-1" },
{
id: 2,
@ -37,7 +35,7 @@ export const submenusItems: MenuItem[] = [
{ id: 8, label: "Level 1-8" },
];
export const submenusItemsWithIcons: MenuItem[] = [
export const submenusItemsWithIcons = [
{ id: 1, label: "Level 1-1", icon: "galaxy" },
{
id: 2,

View File

@ -0,0 +1,26 @@
import React from "react";
import {
composeRenderProps,
MenuItem as HeadlessMenuItem,
} from "react-aria-components";
import { Icon, Text, listBoxItemStyles } from "@appsmith/wds";
import type { MenuItemProps } from "./types";
export function MenuItem(props: MenuItemProps) {
const { children, icon, ...rest } = props;
return (
<HeadlessMenuItem {...rest} className={listBoxItemStyles.listBoxItem}>
{composeRenderProps(children, (children, { hasSubmenu }) => (
<>
{icon && <Icon name={icon} />}
<Text lineClamp={1}>{children}</Text>
{Boolean(hasSubmenu) && (
<Icon data-submenu-icon="" name="chevron-right" />
)}
</>
))}
</HeadlessMenuItem>
);
}

View File

@ -0,0 +1 @@
export { MenuItem } from "./MenuItem";

View File

@ -0,0 +1,7 @@
import type { IconProps } from "@appsmith/wds";
import type { MenuItemProps as AriaMenuItemProps } from "react-aria-components";
export interface MenuItemProps extends AriaMenuItemProps<object> {
icon?: IconProps["name"];
isSubMenuItem?: boolean;
}

View File

@ -1,13 +1,14 @@
import React from "react";
import { Popover as HeadlessPopover } from "react-aria-components";
import styles from "./styles.module.css";
import type { PopoverProps } from "react-aria-components";
import { Popover as HeadlessPopover } from "react-aria-components";
import styles from "./styles.module.css";
export const Popover = (props: PopoverProps) => {
const { children, ...rest } = props;
return (
<HeadlessPopover className={styles.popover} {...rest}>
<HeadlessPopover {...rest} className={styles.popover}>
{children}
</HeadlessPopover>
);

View File

@ -0,0 +1,24 @@
import { Text } from "@appsmith/wds";
import React, { forwardRef } from "react";
import type { ForwardedRef } from "react";
import { Radio as AriaRadio } from "react-aria-components";
import styles from "./styles.module.css";
import type { RadioProps } from "./types";
const _Radio = (props: RadioProps, ref: ForwardedRef<HTMLLabelElement>) => {
const { children, labelPosition = "end", ...rest } = props;
return (
<AriaRadio
ref={ref}
{...rest}
className={styles.radio}
data-label-position={labelPosition}
>
{Boolean(children) && <Text lineClamp={1}>{children}</Text>}
</AriaRadio>
);
};
export const Radio = forwardRef(_Radio);

View File

@ -0,0 +1,2 @@
export { Radio } from "./Radio";
export type { RadioProps } from "./types";

View File

@ -1,21 +1,3 @@
.radioGroup {
display: flex;
flex-direction: column;
width: 100%;
&[data-orientation="vertical"] {
align-items: start;
.radio {
margin-inline-end: auto;
}
}
&[data-disabled] {
cursor: not-allowed;
}
}
.radio {
display: flex;
align-items: center;

View File

@ -0,0 +1,6 @@
import type { POSITION } from "@appsmith/wds";
import type { RadioProps as AriaRadioProps } from "react-aria-components";
export interface RadioProps extends AriaRadioProps {
labelPosition?: keyof typeof POSITION;
}

View File

@ -1,6 +1,6 @@
import React from "react";
import type { Checkbox } from "@appsmith/wds";
import { RadioGroup } from "@appsmith/wds";
import { Radio, RadioGroup } from "@appsmith/wds";
import type { Meta, StoryObj } from "@storybook/react";
import { StoryGrid, DataAttrWrapper } from "@design-system/storybook";
@ -22,12 +22,24 @@ export const LightMode: Story = {
<StoryGrid>
{states.map((state) => (
<DataAttrWrapper attr={state} key={state} target="label">
<RadioGroup items={items} />
<RadioGroup>
{items.map(({ label, value }) => (
<Radio key={value} value={value}>
{label}
</Radio>
))}
</RadioGroup>
</DataAttrWrapper>
))}
{states.map((state) => (
<DataAttrWrapper attr={state} key={state} target="label">
<RadioGroup defaultValue="value-1" items={items} />
<RadioGroup defaultValue="value-1">
{items.map(({ label, value }) => (
<Radio key={value} value={value}>
{label}
</Radio>
))}
</RadioGroup>
</DataAttrWrapper>
))}
</StoryGrid>

View File

@ -1,14 +1,14 @@
import React, { forwardRef, useRef } from "react";
import { RadioGroup as HeadlessRadioGroup, Radio } from "react-aria-components";
import {
FieldLabel,
Flex,
Text,
useGroupOrientation,
FieldError,
} from "@appsmith/wds";
import styles from "./styles.module.css";
import type { ForwardedRef } from "react";
import React, { forwardRef, useRef } from "react";
import {
useGroupOrientation,
inputFieldStyles,
FieldLabel,
FieldError,
toggleGroupStyles,
} from "@appsmith/wds";
import { RadioGroup as HeadlessRadioGroup, Group } from "react-aria-components";
import type { RadioGroupProps } from "./types";
const _RadioGroup = (
@ -16,11 +16,12 @@ const _RadioGroup = (
ref: ForwardedRef<HTMLDivElement>,
) => {
const {
children,
contextualHelp,
errorMessage,
isDisabled,
isReadOnly,
isRequired,
items,
label,
...rest
} = props;
@ -32,31 +33,30 @@ const _RadioGroup = (
return (
<HeadlessRadioGroup
className={styles.radioGroup}
isDisabled={isDisabled}
ref={ref}
{...rest}
className={inputFieldStyles.field}
isDisabled={isDisabled}
isReadOnly={isReadOnly}
isRequired={isRequired}
ref={ref}
>
<FieldLabel
contextualHelp={contextualHelp}
isDisabled={isDisabled}
isRequired={isRequired}
text={label}
/>
<Flex
direction={orientation === "vertical" ? "column" : "row"}
gap={orientation === "vertical" ? "spacing-2" : "spacing-4"}
isInner
{Boolean(label) && (
<FieldLabel
contextualHelp={contextualHelp}
isDisabled={isDisabled}
isRequired={isRequired}
>
{label}
</FieldLabel>
)}
<Group
className={toggleGroupStyles.toggleGroup}
data-orientation={orientation}
ref={containerRef}
wrap="wrap"
>
{items.map(({ label, value, ...rest }, index) => (
<Radio className={styles.radio} key={index} value={value} {...rest}>
<Text lineClamp={1}>{label}</Text>
</Radio>
))}
</Flex>
<FieldError errorMessage={errorMessage} />
{children}
</Group>
<FieldError>{errorMessage}</FieldError>
</HeadlessRadioGroup>
);
};

View File

@ -1,34 +1,13 @@
import type { FieldProps, ORIENTATION } from "@appsmith/wds";
import type { RadioGroupProps as HeadlessRadioGroupProps } from "react-aria-components";
import type { ORIENTATION } from "../../../shared";
interface RadioGroupItemProps {
value: string;
label?: string;
isSelected?: boolean;
isDisabled?: boolean;
index?: number;
}
export interface RadioGroupProps extends HeadlessRadioGroupProps {
export interface RadioGroupProps extends HeadlessRadioGroupProps, FieldProps {
/**
* A ContextualHelp element to place next to the label.
*/
contextualHelp?: string;
/**
* The content to display as the label.
*/
label?: string;
/**
* Radio that belong to this group.
*/
items: RadioGroupItemProps[];
/**
* The axis the checkboxes should align with.
* @default 'horizontal'
* The orientation of the radio group.
*/
orientation?: keyof typeof ORIENTATION;
/**
* An error message for the field.
* children for the radio group
*/
errorMessage?: string;
children?: React.ReactNode;
}

View File

@ -1,70 +1,89 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { RadioGroup, Flex } from "@appsmith/wds";
/**
* Radio group is a component that allows users to select one option from a set of options.
*/
const meta: Meta<typeof RadioGroup> = {
component: RadioGroup,
title: "WDS/Widgets/RadioGroup",
};
export default meta;
type Story = StoryObj<typeof RadioGroup>;
import { RadioGroup, Flex, Radio } from "@appsmith/wds";
const items = [
{ label: "Value 1", value: "value-1" },
{ label: "Value 2", value: "value-2" },
];
export const Main: Story = {
const meta: Meta<typeof RadioGroup> = {
title: "WDS/Widgets/RadioGroup",
component: RadioGroup,
tags: ["autodocs"],
args: {
label: "Radio Group",
defaultValue: "value-1",
items: items,
children: items.map((item) => (
<Radio key={item.value} value={item.value}>
{item.label}
</Radio>
)),
},
};
export default meta;
type Story = StoryObj<typeof RadioGroup>;
export const Main: Story = {
args: {
label: "Label",
},
};
export const WithLabel: Story = {
args: {
label: "Description",
},
};
export const WithContextualHelp: Story = {
args: {
label: "Label",
contextualHelp: "Contextual help",
},
render: (args) => <RadioGroup {...args} />,
};
/**
* The component supports two label orientations `vertical` and `horizontal`. Default size is `horizontal`.
*/
export const Orientation: Story = {
render: () => (
<Flex direction="column" gap="spacing-4">
<RadioGroup items={items} />
<RadioGroup items={items} orientation="vertical" />
</Flex>
),
render: () => {
return (
<Flex direction="column" gap="spacing-4">
<RadioGroup label="Vertical" orientation="vertical">
{items.map((item) => (
<Radio key={item.value} value={item.value}>
{item.label}
</Radio>
))}
</RadioGroup>
<RadioGroup label="Horizontal" orientation="horizontal">
{items.map((item) => (
<Radio key={item.value} value={item.value}>
{item.label}
</Radio>
))}
</RadioGroup>
</Flex>
);
},
};
export const Disabled: Story = {
args: {
label: "Radio Group",
defaultValue: "value-1",
isDisabled: true,
items: items,
label: "Disabled",
},
render: (args) => <RadioGroup {...args} />,
};
export const Required: Story = {
args: {
label: "Radio Group",
defaultValue: "value-1",
isRequired: true,
items: items,
label: "Required",
},
render: (args) => <RadioGroup {...args} />,
};
export const Invalid: Story = {
args: {
label: "Radio Group",
errorMessage: "There is an error",
label: "Invalid",
isInvalid: true,
errorMessage: "This is a error message",
items: items,
},
render: (args) => <RadioGroup {...args} />,
};

View File

@ -2,7 +2,7 @@ import React from "react";
import "@testing-library/jest-dom";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { RadioGroup } from "@appsmith/wds";
import { Radio, RadioGroup } from "@appsmith/wds";
describe("@appsmith/wds/RadioGroup", () => {
const items = [
@ -12,7 +12,13 @@ describe("@appsmith/wds/RadioGroup", () => {
it("should render the Radio group", async () => {
const { container } = render(
<RadioGroup items={items} label="Radio Group" />,
<RadioGroup label="Radio Group">
{items.map(({ label, value }) => (
<Radio key={value} value={value}>
{label}
</Radio>
))}
</RadioGroup>,
);
expect(screen.getByText("Value 1")).toBeInTheDocument();
@ -46,11 +52,13 @@ describe("@appsmith/wds/RadioGroup", () => {
it("should support custom props", () => {
render(
<RadioGroup
data-testid="t--radio-group"
items={items}
label="Radio Group Label"
/>,
<RadioGroup data-testid="t--radio-group" label="Radio Group Label">
{items.map(({ label, value }) => (
<Radio key={value} value={value}>
{label}
</Radio>
))}
</RadioGroup>,
);
const radioGroup = screen.getByTestId("t--radio-group");
@ -60,7 +68,13 @@ describe("@appsmith/wds/RadioGroup", () => {
it("should render checked checkboxes when value is passed", () => {
render(
<RadioGroup items={items} label="Radio Group Label" value="value-1" />,
<RadioGroup label="Radio Group Label" value="value-1">
{items.map(({ label, value }) => (
<Radio key={value} value={value}>
{label}
</Radio>
))}
</RadioGroup>,
);
const options = screen.getAllByRole("radio");
@ -73,11 +87,13 @@ describe("@appsmith/wds/RadioGroup", () => {
const onChangeSpy = jest.fn();
render(
<RadioGroup
items={items}
label="Radio Group Label"
onChange={onChangeSpy}
/>,
<RadioGroup label="Radio Group Label" onChange={onChangeSpy}>
{items.map(({ label, value }) => (
<Radio key={value} value={value}>
{label}
</Radio>
))}
</RadioGroup>,
);
const options = screen.getAllByRole("radio");
@ -87,7 +103,15 @@ describe("@appsmith/wds/RadioGroup", () => {
});
it("should be able to render disabled checkboxes", () => {
render(<RadioGroup isDisabled items={items} label="Radio Group Label" />);
render(
<RadioGroup isDisabled label="Radio Group Label">
{items.map(({ label, value }) => (
<Radio key={value} value={value}>
{label}
</Radio>
))}
</RadioGroup>,
);
const options = screen.getAllByRole("radio");

View File

@ -1,40 +1,36 @@
import React, { useRef } from "react";
import React from "react";
import {
FieldDescription,
FieldError,
Icon,
FieldLabel,
Spinner,
FieldListPopover,
ListBox,
inputFieldStyles,
Popover,
} from "@appsmith/wds";
import { getTypographyClassName } from "@appsmith/wds-theming";
import clsx from "clsx";
import {
Button,
Select as HeadlessSelect,
SelectValue,
} from "react-aria-components";
import styles from "./styles.module.css";
import { Select as HeadlessSelect } from "react-aria-components";
import type { SelectProps } from "./types";
import { SelectTrigger } from "./SelectTrigger";
export const Select = (props: SelectProps) => {
const {
children,
contextualHelp,
description,
errorMessage,
isDisabled,
isLoading,
isRequired,
items,
label,
placeholder,
size = "medium",
...rest
} = props;
const triggerRef = useRef<HTMLButtonElement>(null);
const root = document.body.querySelector(
"[data-theme-provider]",
) as HTMLButtonElement;
return (
<HeadlessSelect
aria-label={Boolean(label) ? undefined : "Select"}
className={styles.formField}
className={inputFieldStyles.field}
data-size={size}
isRequired={isRequired}
{...rest}
@ -43,30 +39,22 @@ export const Select = (props: SelectProps) => {
<>
<FieldLabel
contextualHelp={contextualHelp}
isDisabled={isDisabled}
isRequired={isRequired}
text={label}
>
{label}
</FieldLabel>
<SelectTrigger
isDisabled={isDisabled}
isInvalid={isInvalid}
isLoading={isLoading}
placeholder={placeholder}
size={size}
/>
<Button className={styles.textField} ref={triggerRef}>
<SelectValue
className={clsx(
styles.fieldValue,
getTypographyClassName("body"),
)}
>
{({ defaultChildren, isPlaceholder }) => {
if (isPlaceholder) {
return props.placeholder;
}
return defaultChildren;
}}
</SelectValue>
{!Boolean(isLoading) && <Icon name="chevron-down" />}
{Boolean(isLoading) && <Spinner />}
</Button>
<FieldError errorMessage={errorMessage} />
<FieldDescription description={description} isInvalid={isInvalid} />
<FieldListPopover items={items} />
<FieldError>{errorMessage}</FieldError>
<Popover UNSTABLE_portalContainer={root}>
<ListBox shouldFocusWrap>{children}</ListBox>
</Popover>
</>
)}
</HeadlessSelect>

View File

@ -0,0 +1,41 @@
import React from "react";
import { Icon, Spinner, textInputStyles } from "@appsmith/wds";
import { getTypographyClassName } from "@appsmith/wds-theming";
import { Button, Group, SelectValue } from "react-aria-components";
import type { SelectProps } from "./types";
interface SelectTriggerProps {
size?: SelectProps["size"];
isLoading?: boolean;
isInvalid?: boolean;
placeholder?: string;
isDisabled?: boolean;
}
export const SelectTrigger: React.FC<SelectTriggerProps> = (props) => {
const { isDisabled, isInvalid, isLoading, placeholder, size } = props;
return (
<Group className={textInputStyles.inputGroup}>
<Button
className={textInputStyles.input}
data-invalid={Boolean(isInvalid) ? "" : undefined}
data-size={size}
isDisabled={isDisabled}
>
<SelectValue
className={getTypographyClassName("body")}
data-select-text=""
>
{({ defaultChildren, isPlaceholder }) =>
isPlaceholder ? placeholder : defaultChildren
}
</SelectValue>
</Button>
<span data-input-suffix>
{Boolean(isLoading) ? <Spinner /> : <Icon name="chevron-down" />}
</span>
</Group>
);
};

View File

@ -1,61 +0,0 @@
.formField {
display: flex;
flex-direction: column;
width: 100%;
}
.textField {
display: flex;
position: relative;
padding: 0;
height: var(--sizing-9);
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-4));
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-1);
}
.fieldValue {
text-align: left;
flex: 1;
}
.fieldValue[data-placeholder] {
color: var(--color-fg-neutral-subtle);
}
.fieldValue [data-icon] {
display: none;
}

View File

@ -1,26 +1,12 @@
import type {
SelectProps as SpectrumSelectProps,
ValidationResult,
} from "react-aria-components";
import type { SIZES, FieldListPopoverItem } from "@appsmith/wds";
import type { SIZES, FieldProps } from "@appsmith/wds";
import type { SelectProps as SpectrumSelectProps } from "react-aria-components";
export interface SelectProps
extends Omit<SpectrumSelectProps<FieldListPopoverItem>, "slot"> {
/** Item objects in the collection. */
items: FieldListPopoverItem[];
/** 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);
extends Omit<SpectrumSelectProps<object>, "slot">,
FieldProps {
/** size of the select
*
* @default medium
*/
size?: Omit<keyof typeof SIZES, "xSmall" | "large">;
/** loading state for the input */
isLoading?: boolean;
/** A ContextualHelp element to place next to the label. */
contextualHelp?: string;
}

View File

@ -1,6 +1,7 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { Select, Button, Flex, SIZES } from "@appsmith/wds";
import { Select, Button, Flex, SIZES, ListBoxItem } from "@appsmith/wds";
import { selectItems, selectItemsWithIcons } from "./selectData";
/**
@ -9,6 +10,13 @@ import { selectItems, selectItemsWithIcons } from "./selectData";
const meta: Meta<typeof Select> = {
component: Select,
title: "WDS/Widgets/Select",
args: {
children: selectItems.map((item) => (
<ListBoxItem key={item.id} textValue={item.label}>
{item.label}
</ListBoxItem>
)),
},
};
export default meta;
@ -16,13 +24,13 @@ type Story = StoryObj<typeof Select>;
export const Main: Story = {
args: {
items: selectItems,
label: "Label",
children: selectItems.map((item) => (
<ListBoxItem key={item.id} textValue={item.label}>
{item.label}
</ListBoxItem>
)),
},
render: (args) => (
<Flex width="sizing-60">
<Select {...args} />
</Flex>
),
};
/**
@ -34,12 +42,13 @@ export const Sizes: Story = {
{Object.keys(SIZES)
.filter((size) => !["xSmall", "large"].includes(size))
.map((size) => (
<Select
items={selectItems}
key={size}
placeholder={size}
size={size}
/>
<Select key={size} label={size} placeholder={size} size={size}>
{selectItems.map((item) => (
<ListBoxItem key={item.id} textValue={item.label}>
{item.label}
</ListBoxItem>
))}
</Select>
))}
</Flex>
),
@ -49,7 +58,13 @@ export const Loading: Story = {
args: {
placeholder: "Loading",
isLoading: true,
items: selectItems,
},
};
export const Disabled: Story = {
args: {
placeholder: "Disabled",
isDisabled: true,
},
};
@ -63,9 +78,8 @@ export const Validation: Story = {
>
<Flex direction="column" gap="spacing-5" width="sizing-60">
<Select
description="description"
errorMessage="There is an error"
isRequired
items={selectItems}
label="Validation"
/>
<Button type="submit">Submit</Button>
@ -79,13 +93,16 @@ export const ContextualHelp: Story = {
label: "Label",
placeholder: "Contextual Help Text",
contextualHelp: "This is a contextual help text",
items: selectItems,
},
};
export const WithIcons: Story = {
args: {
label: "With icons",
items: selectItemsWithIcons,
children: selectItemsWithIcons.map((item) => (
<ListBoxItem icon={item.icon} key={item.id} textValue={item.label}>
{item.label}
</ListBoxItem>
)),
},
};

View File

@ -1,6 +1,4 @@
import type { FieldListPopoverItem } from "@appsmith/wds";
export const selectItems: FieldListPopoverItem[] = [
export const selectItems = [
{ id: 1, label: "Aerospace" },
{
id: 2,
@ -15,7 +13,7 @@ export const selectItems: FieldListPopoverItem[] = [
{ id: 9, label: "Electrical" },
];
export const selectItemsWithIcons: FieldListPopoverItem[] = [
export const selectItemsWithIcons = [
{ id: 1, label: "Aerospace", icon: "galaxy" },
{
id: 2,
@ -25,4 +23,4 @@ export const selectItemsWithIcons: FieldListPopoverItem[] = [
{ id: 3, label: "Civil", icon: "circuit-ground" },
{ id: 4, label: "Biomedical", icon: "biohazard" },
{ id: 5, label: "Nuclear", icon: "atom" },
];
] as const;

View File

@ -1,8 +1,9 @@
import React, { forwardRef } from "react";
import { Text } from "@appsmith/wds";
import { Checkbox as HeadlessCheckbox } from "react-aria-components";
import styles from "./styles.module.css";
import React, { forwardRef } from "react";
import type { ForwardedRef } from "react";
import { Checkbox as HeadlessCheckbox } from "react-aria-components";
import styles from "./styles.module.css";
import type { SwitchProps } from "./types";
const _Switch = (props: SwitchProps, ref: ForwardedRef<HTMLLabelElement>) => {

View File

@ -18,20 +18,12 @@ export interface TagGroupProps<T>
extends Omit<HeadlessTagGroupProps, "children">,
Pick<HeadlessTagListProps<T>, "items" | "children" | "renderEmptyState"> {
label?: string;
description?: string;
errorMessage?: string;
}
function TagGroup<T extends object>(props: TagGroupProps<T>) {
const {
children,
description,
errorMessage,
items,
label,
renderEmptyState,
...rest
} = props;
const { children, errorMessage, items, label, renderEmptyState, ...rest } =
props;
return (
<HeadlessTagGroup {...rest} className={styles["tag-group"]}>
@ -43,14 +35,6 @@ function TagGroup<T extends object>(props: TagGroupProps<T>) {
>
{children}
</HeadlessTagList>
{Boolean(description) && (
<HeadlessText
className={getTypographyClassName("footnote")}
slot="description"
>
{description}
</HeadlessText>
)}
{Boolean(errorMessage) && (
<HeadlessText
className={getTypographyClassName("footnote")}

View File

@ -85,15 +85,6 @@ export const WithLabel: Story = {
render: (args) => <TagGroupExample {...args} />,
};
export const WithDescription: Story = {
args: {
label: "Categories",
description: "Select one or more categories.",
selectionMode: "multiple",
},
render: (args) => <TagGroupExample {...args} />,
};
export const WithError: Story = {
args: {
label: "Categories",

View File

@ -5,7 +5,7 @@ import type {
import type { ReactNode } from "react";
import type { COLORS } from "../../../shared";
export interface TextProps {
export interface TextProps extends React.HTMLAttributes<HTMLElement> {
/** size variant of the text
* @default body
*/

View File

@ -1,53 +1,110 @@
import clsx from "clsx";
import React, { forwardRef } from "react";
import type {
TextAreaRef as HeadlessTextAreaRef,
TextAreaProps as HeadlessTextAreaProps,
} from "@appsmith/wds-headless";
import { TextArea as HeadlessTextArea } from "@appsmith/wds-headless";
import {
FieldError,
FieldLabel,
inputFieldStyles,
TextAreaInput,
} from "@appsmith/wds";
import React, { useCallback, useRef } from "react";
import { useControlledState } from "@react-stately/utils";
import { chain, useLayoutEffect } from "@react-aria/utils";
import { TextField as HeadlessTextField } from "react-aria-components";
import textAreaStyles from "./styles.module.css";
import { textInputStyles, fieldStyles } from "../../../styles";
import { ContextualHelp } from "../../ContextualHelp";
import { getTypographyClassName } from "@appsmith/wds-theming";
import type { TextAreaProps } from "./types";
export interface TextAreaProps extends HeadlessTextAreaProps {
/** loading state for the input */
isLoading?: boolean;
}
const _TextArea = (props: TextAreaProps, ref: HeadlessTextAreaRef) => {
export function TextArea(props: TextAreaProps) {
const {
contextualHelp: contextualHelpProp,
description,
contextualHelp,
errorMessage,
isDisabled,
isInvalid,
isLoading,
isReadOnly,
isRequired,
label,
onChange,
suffix,
value,
...rest
} = props;
const contextualHelp = Boolean(contextualHelpProp) && (
<ContextualHelp contextualHelp={contextualHelpProp} />
const inputRef = useRef<HTMLTextAreaElement>(null);
const [inputValue, setInputValue] = useControlledState(
props.value,
props.defaultValue ?? "",
() => {
//
},
);
const onHeightChange = useCallback(() => {
// Quiet textareas always grow based on their text content.
// Standard textareas also grow by default, unless an explicit height is set.
if (props.height == null && inputRef.current) {
const input = inputRef.current;
const prevAlignment = input.style.alignSelf;
const prevOverflow = input.style.overflow;
// Firefox scroll position is lost when overflow: 'hidden' is applied so we skip applying it.
// The measure/applied height is also incorrect/reset if we turn on and off
// overflow: hidden in Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1787062
const isFirefox = "MozAppearance" in input.style;
if (!isFirefox) {
input.style.overflow = "hidden";
}
input.style.alignSelf = "start";
input.style.height = "auto";
const computedStyle = getComputedStyle(input);
const paddingTop = parseFloat(computedStyle.paddingTop);
const paddingBottom = parseFloat(computedStyle.paddingBottom);
input.style.height = `${
// subtract comptued padding and border to get the actual content height
input.scrollHeight -
paddingTop -
paddingBottom +
// Also, adding 1px to fix a bug in browser where there is a scrolllbar on certain heights
1
}px`;
input.style.overflow = prevOverflow;
input.style.alignSelf = prevAlignment;
}
}, [inputRef, props.height]);
useLayoutEffect(() => {
if (inputRef.current) {
onHeightChange();
}
}, [onHeightChange, inputValue]);
return (
<HeadlessTextArea
contextualHelp={contextualHelp}
description={description}
errorMessage={errorMessage}
fieldClassName={clsx(
textInputStyles["text-input"],
fieldStyles.field,
textAreaStyles["textarea"],
)}
inputClassName={getTypographyClassName("body")}
isRequired={isRequired}
label={label}
labelClassName={getTypographyClassName("caption")}
ref={ref}
<HeadlessTextField
{...rest}
/>
);
};
className={clsx(inputFieldStyles.field)}
isDisabled={isDisabled}
isInvalid={isInvalid}
isReadOnly={isReadOnly}
isRequired={isRequired}
onChange={chain(onChange, setInputValue)}
value={value}
>
<FieldLabel
contextualHelp={contextualHelp}
isDisabled={isDisabled}
isRequired={isRequired}
>
{label}
</FieldLabel>
<TextAreaInput
isLoading={isLoading}
isReadOnly={isReadOnly}
ref={inputRef}
suffix={suffix}
value={value}
/>
export const TextArea = forwardRef(_TextArea);
<FieldError>{errorMessage}</FieldError>
</HeadlessTextField>
);
}

View File

@ -1,2 +1,2 @@
export { TextArea } from "./TextArea";
export type { TextAreaProps } from "./TextArea";
export type { TextAreaProps } from "./types";

View File

@ -1,12 +0,0 @@
.textarea {
& [data-field-input] {
block-size: auto;
}
& [data-field-input] textarea {
height: auto;
resize: none;
min-block-size: var(--sizing-16);
align-items: flex-start;
}
}

View File

@ -0,0 +1,11 @@
import type { FieldProps } from "@appsmith/wds";
import type { ReactNode } from "react";
import type { TextFieldProps as AriaTextFieldProps } from "react-aria-components";
export interface TextAreaProps extends AriaTextFieldProps, FieldProps {
placeholder?: string;
height?: number | string;
suffix?: ReactNode;
prefix?: ReactNode;
rows?: number;
}

View File

@ -1,13 +1,15 @@
import React from "react";
import { Form } from "react-aria-components";
import type { Meta, StoryObj } from "@storybook/react";
import { TextArea, Flex } from "@appsmith/wds";
import { Flex, TextArea, Button } from "@appsmith/wds";
/**
* TextArea is a component that is similar to the native textarea element, but with a few extra features.
*/
const meta: Meta<typeof TextArea> = {
component: TextArea,
title: "WDS/Widgets/TextArea",
component: TextArea,
tags: ["autodocs"],
args: {
placeholder: "Write something...",
},
};
export default meta;
@ -15,51 +17,58 @@ type Story = StoryObj<typeof TextArea>;
export const Main: Story = {
args: {
label: "Label",
placeholder: "Placeholder",
label: "Description",
placeholder: "Write something...",
},
};
export const Description: Story = {
export const WithLabel: Story = {
args: {
placeholder: "Description",
description: "This is a description",
label: "Label",
},
};
export const WithContextualHelp: Story = {
args: {
label: "Description",
contextualHelp: "This is a contextual help",
},
};
export const Disabled: Story = {
args: {
placeholder: "Disabled",
isDisabled: true,
label: "Disabled",
},
};
export const Loading: Story = {
args: {
isLoading: true,
label: "Loading",
placeholder: "Loading...",
},
};
export const Readonly: Story = {
args: {
isReadOnly: true,
label: "Readonly",
},
};
export const Validation: Story = {
args: {
placeholder: "Validation",
validationState: "invalid",
errorMessage: "This field is required",
},
};
export const RequiredIndicator: Story = {
render: () => (
<Flex direction="column" gap="spacing-4" width="100%">
<TextArea isRequired label="Required - Icon Indicator" />
<TextArea
isRequired
label="Required - Label Indicator"
necessityIndicator="label"
/>
<TextArea label="Required - Label Indicator" necessityIndicator="label" />
</Flex>
render: (args) => (
<Form onSubmit={(e) => e.preventDefault()}>
<Flex direction="column" gap="spacing-3" width="sizing-60">
<TextArea
{...args}
errorMessage="Please enter at least 10 characters"
isRequired
label="Description"
/>
<Button type="submit">Submit</Button>
</Flex>
</Form>
),
};
export const ContextualHelp: Story = {
args: {
label: "Label",
placeholder: "Contextual Help Text",
contextualHelp: "This is a contextual help text",
},
};

View File

@ -1,93 +1,56 @@
import clsx from "clsx";
import type {
TextInputRef as HeadlessTextInputRef,
TextInputProps as HeadlessTextInputProps,
} from "@appsmith/wds-headless";
import React, { forwardRef, useState } from "react";
import { getTypographyClassName } from "@appsmith/wds-theming";
import { TextInput as HeadlessTextInput } from "@appsmith/wds-headless";
import React from "react";
import { FieldError, FieldLabel, Input, inputFieldStyles } from "@appsmith/wds";
import { TextField as HeadlessTextField } from "react-aria-components";
import { Spinner } from "../../Spinner";
import type { IconProps } from "../../Icon";
import { IconButton } from "../../IconButton";
import { ContextualHelp } from "../../ContextualHelp";
import { textInputStyles, fieldStyles } from "../../../styles";
import type { SIZES } from "../../../shared";
import type { TextInputProps } from "./types";
export interface TextInputProps extends HeadlessTextInputProps {
/** loading state for the input */
isLoading?: boolean;
/** size of the input
*
* @default medium
*/
size?: Omit<keyof typeof SIZES, "xSmall" | "large">;
}
const _TextInput = (props: TextInputProps, ref: HeadlessTextInputRef) => {
export function TextInput(props: TextInputProps) {
const {
contextualHelp: contextualHelpProp,
description,
contextualHelp,
errorMessage,
isLoading = false,
isDisabled,
isInvalid,
isLoading,
isReadOnly,
isRequired,
label,
prefix,
size = "medium",
suffix,
type,
value,
...rest
} = props;
const [showPassword, togglePassword] = useState(false);
const contextualHelp = Boolean(contextualHelpProp) && (
<ContextualHelp contextualHelp={contextualHelpProp} />
);
const onPressEyeIcon = () => {
togglePassword((prev) => !prev);
};
const renderSuffix = () => {
if (isLoading) return <Spinner />;
if (type === "password") {
const icon: IconProps["name"] = showPassword ? "eye-off" : "eye";
return (
<IconButton
color="neutral"
excludeFromTabOrder
icon={icon}
onPress={onPressEyeIcon}
size={size === "medium" ? "small" : "xSmall"}
variant="ghost"
/>
);
}
return suffix;
};
return (
<HeadlessTextInput
contextualHelp={contextualHelp}
data-size={Boolean(size) ? size : undefined}
description={description}
errorMessage={errorMessage}
fieldClassName={clsx(textInputStyles["text-input"], fieldStyles.field)}
helpTextClassName={getTypographyClassName("footnote")}
inputClassName={getTypographyClassName("body")}
isRequired={isRequired}
label={label}
labelClassName={getTypographyClassName("caption")}
prefix={prefix}
ref={ref}
suffix={renderSuffix()}
type={showPassword ? "text" : type}
<HeadlessTextField
{...rest}
/>
className={clsx(inputFieldStyles.field)}
data-field=""
isDisabled={isDisabled}
isInvalid={isInvalid}
isReadOnly={isReadOnly}
isRequired={isRequired}
value={value}
>
<FieldLabel
contextualHelp={contextualHelp}
isDisabled={isDisabled}
isRequired={isRequired}
>
{label}
</FieldLabel>
<Input
isLoading={isLoading}
isReadOnly={isReadOnly}
prefix={prefix}
size={size}
suffix={suffix}
type={type}
value={value}
/>
<FieldError>{errorMessage}</FieldError>
</HeadlessTextField>
);
};
export const TextInput = forwardRef(_TextInput);
}

Some files were not shown because too many files have changed in this diff Show More