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:
parent
7f31c9e269
commit
6e59db227d
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./Field";
|
||||
export type { LabelProps } from "./Label";
|
||||
export type { FieldProps, FieldRef } from "./Field";
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./types";
|
||||
export { TextArea } from "./TextArea";
|
||||
export type { TextAreaRef } from "./TextArea";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./types";
|
||||
export { TextInput } from "./TextInput";
|
||||
export type { TextInputRef } from "./TextInput";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 = {};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./types";
|
||||
export { TextInputBase } from "./TextInputBase";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { ChatInput } from "./ChatInput";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { TextAreaProps } from "@appsmith/wds";
|
||||
|
||||
export interface ChatInputProps extends TextAreaProps {
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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"];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export type { FieldProps } from "./types";
|
||||
export { default as inputFieldStyles } from "./styles.module.css";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.field {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./FieldDescription";
|
||||
export type { FieldDescriptionProps } from "./types";
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
.description {
|
||||
margin-block-start: var(--inner-spacing-3);
|
||||
color: var(--color-fg-neutral);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export interface FieldDescriptionProps {
|
||||
/** The content to display as the description. */
|
||||
description?: string;
|
||||
/** Whether the input value is invalid. */
|
||||
isInvalid?: boolean;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
.errorText {
|
||||
margin-block-start: var(--inner-spacing-3);
|
||||
color: var(--color-fg-negative);
|
||||
margin-block-start: var(--inner-spacing-2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./FieldListPopover";
|
||||
export type { FieldListPopoverProps, FieldListPopoverItem } from "./types";
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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"];
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./Input";
|
||||
export * from "./TextAreaInput";
|
||||
export { default as textInputStyles } from "./styles.module.css";
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
> {}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { ListBox } from "./ListBox";
|
||||
export { default as listStyles } from "./styles.module.css";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.listBox {
|
||||
min-inline-size: var(--trigger-width);
|
||||
max-height: inherit;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import type { ListBoxProps as AriaListBoxProps } from "react-aria-components";
|
||||
|
||||
export interface ListBoxProps extends AriaListBoxProps<object> {}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { ListBoxItem } from "./ListBoxItem";
|
||||
export { default as listBoxItemStyles } from "./styles.module.css";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"];
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
export * from "./Menu";
|
||||
export { MenuTrigger } from "react-aria-components";
|
||||
export * from "./types";
|
||||
export { MenuTrigger, SubmenuTrigger } from "react-aria-components";
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
> {}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { MenuItem } from "./MenuItem";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { Radio } from "./Radio";
|
||||
export type { RadioProps } from "./types";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>) => {
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { TextArea } from "./TextArea";
|
||||
export type { TextAreaProps } from "./TextArea";
|
||||
export type { TextAreaProps } from "./types";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue
Block a user