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/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 type { ForwardedRef } from "react";
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
|
import { Text, Spinner, Icon } from "@appsmith/wds";
|
||||||
import { useVisuallyHidden } from "@react-aria/visually-hidden";
|
import { useVisuallyHidden } from "@react-aria/visually-hidden";
|
||||||
import { Button as HeadlessButton } from "react-aria-components";
|
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 styles from "./styles.module.css";
|
||||||
import type { ButtonProps } from "./types";
|
import type { ButtonProps } from "./types";
|
||||||
import { Icon } from "../../Icon";
|
|
||||||
|
|
||||||
const _Button = (props: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) => {
|
const _Button = (props: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) => {
|
||||||
props = useVisuallyDisabled(props);
|
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 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 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";
|
import type { CheckboxProps } from "./types";
|
||||||
|
|
||||||
const _Checkbox = (
|
const _Checkbox = (
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,59 @@
|
||||||
import {
|
import {
|
||||||
FieldError,
|
Popover,
|
||||||
FieldDescription,
|
ListBox,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldListPopover,
|
FieldError,
|
||||||
Button,
|
inputFieldStyles,
|
||||||
} from "@appsmith/wds";
|
} from "@appsmith/wds";
|
||||||
import { getTypographyClassName } from "@appsmith/wds-theming";
|
|
||||||
import clsx from "clsx";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ComboBox as HeadlessCombobox, Input } from "react-aria-components";
|
import { ComboBox as HeadlessCombobox } from "react-aria-components";
|
||||||
import styles from "./styles.module.css";
|
|
||||||
import type { ComboBoxProps } from "./types";
|
import type { ComboBoxProps } from "./types";
|
||||||
|
import { ComboBoxTrigger } from "./ComboBoxTrigger";
|
||||||
|
|
||||||
export const ComboBox = (props: ComboBoxProps) => {
|
export const ComboBox = (props: ComboBoxProps) => {
|
||||||
const {
|
const {
|
||||||
|
children,
|
||||||
contextualHelp,
|
contextualHelp,
|
||||||
description,
|
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
isDisabled,
|
||||||
isLoading,
|
isLoading,
|
||||||
isRequired,
|
isRequired,
|
||||||
items,
|
|
||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
size = "medium",
|
size = "medium",
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
|
const root = document.body.querySelector(
|
||||||
|
"[data-theme-provider]",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessCombobox
|
<HeadlessCombobox
|
||||||
aria-label={Boolean(label) ? undefined : "ComboBox"}
|
aria-label={Boolean(label) ? undefined : "ComboBox"}
|
||||||
className={styles.formField}
|
className={inputFieldStyles.field}
|
||||||
data-size={size}
|
data-size={size}
|
||||||
|
isDisabled={isDisabled}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{({ isInvalid }) => (
|
|
||||||
<>
|
|
||||||
<FieldLabel
|
<FieldLabel
|
||||||
contextualHelp={contextualHelp}
|
contextualHelp={contextualHelp}
|
||||||
|
isDisabled={isDisabled}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
text={label}
|
>
|
||||||
/>
|
{label}
|
||||||
<div className={styles.inputWrapper}>
|
</FieldLabel>
|
||||||
<Input
|
<ComboBoxTrigger
|
||||||
className={clsx(styles.input, getTypographyClassName("body"))}
|
isDisabled={isDisabled}
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
color={Boolean(isLoading) ? "neutral" : "accent"}
|
|
||||||
icon="chevron-down"
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
size={size === "medium" ? "small" : "xSmall"}
|
placeholder={placeholder}
|
||||||
slot={Boolean(isLoading) ? null : ""}
|
size={size}
|
||||||
variant={Boolean(isLoading) ? "ghost" : "filled"}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<FieldError>{errorMessage}</FieldError>
|
||||||
<FieldError errorMessage={errorMessage} />
|
<Popover UNSTABLE_portalContainer={root}>
|
||||||
<FieldDescription description={description} isInvalid={isInvalid} />
|
<ListBox shouldFocusWrap>{children}</ListBox>
|
||||||
<FieldListPopover items={items} />
|
</Popover>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HeadlessCombobox>
|
</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 { SIZES, FieldProps } from "@appsmith/wds";
|
||||||
import type {
|
import type { ComboBoxProps as SpectrumComboBoxProps } from "react-aria-components";
|
||||||
ComboBoxProps as SpectrumComboBoxProps,
|
|
||||||
ValidationResult,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import type { IconProps, SIZES } from "@appsmith/wds";
|
|
||||||
|
|
||||||
export interface ComboBoxProps
|
export interface ComboBoxProps
|
||||||
extends Omit<SpectrumComboBoxProps<ComboBoxItem>, "slot"> {
|
extends Omit<SpectrumComboBoxProps<object>, "slot">,
|
||||||
/** Item objects in the collection. */
|
FieldProps {
|
||||||
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);
|
|
||||||
/** size of the select
|
/** size of the select
|
||||||
*
|
*
|
||||||
* @default medium
|
* @default medium
|
||||||
*/
|
*/
|
||||||
size?: Omit<keyof typeof SIZES, "xSmall" | "large">;
|
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. */
|
/** The content to display as the placeholder. */
|
||||||
placeholder?: string;
|
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 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> = {
|
const meta: Meta<typeof ComboBox> = {
|
||||||
component: ComboBox,
|
|
||||||
title: "WDS/Widgets/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;
|
export default meta;
|
||||||
|
|
@ -16,71 +23,60 @@ type Story = StoryObj<typeof ComboBox>;
|
||||||
|
|
||||||
export const Main: Story = {
|
export const Main: Story = {
|
||||||
args: {
|
args: {
|
||||||
items: items,
|
label: "Select an option",
|
||||||
|
placeholder: "Choose...",
|
||||||
},
|
},
|
||||||
render: (args) => (
|
|
||||||
<Flex width="sizing-60">
|
|
||||||
<ComboBox {...args} />
|
|
||||||
</Flex>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const WithLabel: Story = {
|
||||||
* The component supports two sizes `small` and `medium`. Default size is `medium`.
|
args: {
|
||||||
*/
|
label: "Favorite Fruit",
|
||||||
export const Sizes: Story = {
|
},
|
||||||
render: () => (
|
};
|
||||||
<Flex direction="column" gap="spacing-4" width="sizing-60">
|
|
||||||
{Object.keys(SIZES)
|
export const WithContextualHelp: Story = {
|
||||||
.filter((size) => !["xSmall", "large"].includes(size))
|
args: {
|
||||||
.map((size) => (
|
label: "Country",
|
||||||
<ComboBox items={items} key={size} placeholder={size} size={size} />
|
contextualHelp: "Select the country you currently reside in",
|
||||||
))}
|
},
|
||||||
</Flex>
|
};
|
||||||
),
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
isDisabled: true,
|
||||||
|
label: "Disabled ComboBox",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Loading: Story = {
|
export const Loading: Story = {
|
||||||
args: {
|
args: {
|
||||||
placeholder: "Loading",
|
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
items: items,
|
label: "Loading ComboBox",
|
||||||
|
placeholder: "Loading options...",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Validation: Story = {
|
export const Size: Story = {
|
||||||
render: () => (
|
render: (args) => (
|
||||||
<form
|
<Flex direction="column" gap="spacing-4">
|
||||||
onSubmit={(e) => {
|
<ComboBox {...args} label="Small" size="small" />
|
||||||
e.preventDefault();
|
<ComboBox {...args} label="Medium" size="medium" />
|
||||||
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>
|
</Flex>
|
||||||
</form>
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ContextualHelp: Story = {
|
export const Validation: Story = {
|
||||||
args: {
|
render: (args) => (
|
||||||
label: "Label",
|
<Form onSubmit={(e) => e.preventDefault()}>
|
||||||
placeholder: "Contextual Help Text",
|
<Flex direction="column" gap="spacing-3" width="sizing-60">
|
||||||
contextualHelp: "This is a contextual help text",
|
<ComboBox
|
||||||
items: items,
|
errorMessage="Please select an option"
|
||||||
},
|
isRequired
|
||||||
};
|
label="Required Selection"
|
||||||
|
{...args}
|
||||||
export const WithIcons: Story = {
|
/>
|
||||||
args: {
|
<Button type="submit">Submit</Button>
|
||||||
label: "With icons",
|
</Flex>
|
||||||
items: itemsWithIcons,
|
</Form>
|
||||||
},
|
),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import type { ComboBoxItem } from "../src/types";
|
export const items = [
|
||||||
|
|
||||||
export const items: ComboBoxItem[] = [
|
|
||||||
{ id: 1, label: "Aerospace" },
|
{ id: 1, label: "Aerospace" },
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|
@ -15,7 +13,7 @@ export const items: ComboBoxItem[] = [
|
||||||
{ id: 9, label: "Electrical" },
|
{ id: 9, label: "Electrical" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const itemsWithIcons: ComboBoxItem[] = [
|
export const itemsWithIcons = [
|
||||||
{ id: 1, label: "Aerospace", icon: "galaxy" },
|
{ id: 1, label: "Aerospace", icon: "galaxy" },
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Tooltip } from "../../Tooltip";
|
import { Tooltip, IconButton } from "@appsmith/wds";
|
||||||
import { IconButton } from "../../IconButton";
|
|
||||||
import type { ContextualProps } from "./types";
|
import type { ContextualProps } from "./types";
|
||||||
|
|
||||||
const _ContextualHelp = (props: ContextualProps) => {
|
const _ContextualHelp = (props: ContextualProps) => {
|
||||||
const { contextualHelp } = props;
|
const { contextualHelp } = props;
|
||||||
|
|
||||||
|
if (!Boolean(contextualHelp)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip interaction="click" tooltip={contextualHelp}>
|
<Tooltip interaction="click" tooltip={contextualHelp}>
|
||||||
<IconButton
|
<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 React from "react";
|
||||||
import { getTypographyClassName } from "@appsmith/wds-theming";
|
import { Text } from "@appsmith/wds";
|
||||||
import clsx from "clsx";
|
import { FieldError as AriaFieldError } from "react-aria-components";
|
||||||
import { FieldError as HeadlessFieldError } from "react-aria-components";
|
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
import type { FieldErrorProps } from "./types";
|
import type { FieldErrorProps } from "./types";
|
||||||
|
|
||||||
export const FieldError = (props: FieldErrorProps) => {
|
export const FieldError = (props: FieldErrorProps) => {
|
||||||
const { errorMessage } = props;
|
const { children } = props;
|
||||||
|
|
||||||
|
if (!Boolean(children)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessFieldError
|
<AriaFieldError className={styles.errorText}>
|
||||||
className={clsx(styles.errorText, getTypographyClassName("footnote"))}
|
<Text color="negative" size="caption">
|
||||||
>
|
{children}
|
||||||
{errorMessage}
|
</Text>
|
||||||
</HeadlessFieldError>
|
</AriaFieldError>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
.errorText {
|
.errorText {
|
||||||
margin-block-start: var(--inner-spacing-3);
|
margin-block-start: var(--inner-spacing-2);
|
||||||
color: var(--color-fg-negative);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,5 @@ import type { ValidationResult } from "react-aria-components";
|
||||||
|
|
||||||
export interface FieldErrorProps {
|
export interface FieldErrorProps {
|
||||||
/** The content to display as the error message. */
|
/** 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 clsx from "clsx";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Text, ContextualHelp } from "@appsmith/wds";
|
import { ContextualHelp, Text } from "@appsmith/wds";
|
||||||
import { Label as HeadlessLabel } from "react-aria-components";
|
import { Label as HeadlessLabel, Group } from "react-aria-components";
|
||||||
|
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
import type { LabelProps } from "./types";
|
import type { LabelProps } from "./types";
|
||||||
|
|
||||||
export const FieldLabel = (props: LabelProps) => {
|
export function FieldLabel(props: LabelProps) {
|
||||||
const { className, contextualHelp, isDisabled, isRequired, text, ...rest } =
|
const { children, contextualHelp, isDisabled, isRequired, ...rest } = props;
|
||||||
props;
|
|
||||||
|
|
||||||
if (!Boolean(text) && !Boolean(contextualHelp)) return null;
|
if (!Boolean(children) && !Boolean(contextualHelp)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessLabel
|
<Group
|
||||||
aria-label={text}
|
className={styles.labelGroup}
|
||||||
className={clsx(className, styles.label)}
|
data-field-label-wrapper=""
|
||||||
data-disabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
data-field-label-wrapper
|
|
||||||
elementType="label"
|
|
||||||
{...rest}
|
|
||||||
>
|
>
|
||||||
<Text fontWeight={600} lineClamp={1} size="caption">
|
<HeadlessLabel
|
||||||
{text}
|
{...rest}
|
||||||
|
className={clsx(styles.label)}
|
||||||
|
elementType="label"
|
||||||
|
>
|
||||||
|
<Text fontWeight={600} size="caption">
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
{Boolean(isRequired) && (
|
{Boolean(isRequired) && (
|
||||||
<span aria-label="(required)" className={styles.necessityIndicator}>
|
<span aria-label="(required)" className={styles.necessityIndicator}>
|
||||||
*
|
*
|
||||||
</span>
|
</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 {
|
.label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: var(--sizing-3);
|
height: fit-content;
|
||||||
margin-block-end: var(--inner-spacing-3);
|
max-width: 100%;
|
||||||
gap: var(--inner-spacing-1);
|
gap: var(--inner-spacing-1);
|
||||||
|
|
||||||
&[data-disabled="true"] {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: var(--opacity-disabled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.necessityIndicator {
|
.necessityIndicator {
|
||||||
color: var(--color-fg-negative);
|
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 {
|
export type LabelProps = AriaLabelProps & {
|
||||||
text?: string;
|
contextualHelp?: React.ReactNode;
|
||||||
contextualHelp?: string;
|
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
isDisabled?: 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
|
export interface LinkProps
|
||||||
extends Omit<TextProps, "color">,
|
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 React, { createContext, useContext } from "react";
|
||||||
import { Icon, listItemStyles, Popover, Text } from "@appsmith/wds";
|
import { listStyles, Popover } from "@appsmith/wds";
|
||||||
import {
|
import { Menu as HeadlessMenu } from "react-aria-components";
|
||||||
Menu as HeadlessMenu,
|
|
||||||
MenuItem,
|
import type { MenuProps } from "./types";
|
||||||
Separator,
|
|
||||||
SubmenuTrigger,
|
const MenuNestingContext = createContext(0);
|
||||||
} from "react-aria-components";
|
|
||||||
import styles from "./styles.module.css";
|
|
||||||
import type { MenuProps, MenuItemProps } from "./types";
|
|
||||||
import type { Key } from "@react-types/shared";
|
|
||||||
|
|
||||||
export const Menu = (props: MenuProps) => {
|
export const Menu = (props: MenuProps) => {
|
||||||
const { hasSubmenu = false } = props;
|
const { children } = props;
|
||||||
// place Popover in the root theme provider to get access to the CSS tokens
|
|
||||||
const root = document.body.querySelector(
|
const root = document.body.querySelector(
|
||||||
"[data-theme-provider]",
|
"[data-theme-provider]",
|
||||||
) as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
|
const nestingLevel = useContext(MenuNestingContext);
|
||||||
|
const isRootMenu = nestingLevel === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// We should put only parent Popover in the root, if we put the child ones, then Menu will work incorrectly
|
<MenuNestingContext.Provider value={nestingLevel + 1}>
|
||||||
<Popover UNSTABLE_portalContainer={hasSubmenu ? undefined : root}>
|
{/* Only the parent Popover should be placed in the root. Placing child popoves in root would cause the menu to function incorrectly */}
|
||||||
<HeadlessMenu className={styles.menu} {...props}>
|
<Popover UNSTABLE_portalContainer={isRootMenu ? root : undefined}>
|
||||||
{(item) => renderFunc(item, props)}
|
<HeadlessMenu className={listStyles.listBox} {...props}>
|
||||||
|
{children}
|
||||||
</HeadlessMenu>
|
</HeadlessMenu>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
</MenuNestingContext.Provider>
|
||||||
};
|
|
||||||
|
|
||||||
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} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
className={listItemStyles.item}
|
|
||||||
isDisabled={isItemDisabled()}
|
|
||||||
key={id}
|
|
||||||
>
|
|
||||||
{icon && <Icon name={icon} />}
|
|
||||||
<Text className={listItemStyles.text} lineClamp={1}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export * from "./Menu";
|
export * from "./Menu";
|
||||||
export { MenuTrigger } from "react-aria-components";
|
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
export { MenuTrigger, SubmenuTrigger } from "react-aria-components";
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,7 @@
|
||||||
import type {
|
import type { MenuProps as AriaMenuProps } from "react-aria-components";
|
||||||
MenuProps as HeadlessMenuProps,
|
|
||||||
MenuItemProps as HeadlessMenuItemProps,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import type { Key } from "@react-types/shared";
|
|
||||||
import type { IconProps } from "../../Icon";
|
|
||||||
|
|
||||||
export interface MenuProps
|
export interface MenuProps
|
||||||
extends Omit<
|
extends Omit<
|
||||||
HeadlessMenuProps<MenuItem>,
|
AriaMenuProps<object>,
|
||||||
"slot" | "selectionMode" | "selectedKeys"
|
"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 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 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.
|
* 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>;
|
type Story = StoryObj<typeof Menu>;
|
||||||
|
|
||||||
export const Main: Story = {
|
export const Main: Story = {
|
||||||
render: (args) => (
|
render: () => (
|
||||||
<MenuTrigger>
|
<MenuTrigger>
|
||||||
<Button>Open The Menu…</Button>
|
<Button>Open The Menu…</Button>
|
||||||
<Menu
|
<Menu>
|
||||||
items={menuItems}
|
<MenuItem id="1">Item 1</MenuItem>
|
||||||
onAction={(key) => alert(`Selected key: ${key}`)}
|
<MenuItem id="2">Item 2</MenuItem>
|
||||||
{...args}
|
<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>
|
</MenuTrigger>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -33,20 +48,38 @@ export const Submenus: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<MenuTrigger>
|
<MenuTrigger>
|
||||||
<Button>Open The Menu…</Button>
|
<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>
|
</MenuTrigger>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* The items can be disabled by passing `disabledKeys` or `isDisabled` in the item configuration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const DisabledItems: Story = {
|
export const DisabledItems: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<MenuTrigger>
|
<MenuTrigger>
|
||||||
<Button>Open The Menu…</Button>
|
<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>
|
</MenuTrigger>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -55,7 +88,12 @@ export const WithIcons: Story = {
|
||||||
render: () => (
|
render: () => (
|
||||||
<MenuTrigger>
|
<MenuTrigger>
|
||||||
<Button>Open The Menu…</Button>
|
<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>
|
</MenuTrigger>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import type { MenuItem } from "../src";
|
export const menuItems = [
|
||||||
|
|
||||||
export const menuItems: MenuItem[] = [
|
|
||||||
{ id: 1, label: "Aerospace" },
|
{ id: 1, label: "Aerospace" },
|
||||||
{ id: 2, label: "Mechanical" },
|
{ id: 2, label: "Mechanical" },
|
||||||
{ id: 3, label: "Civil" },
|
{ id: 3, label: "Civil" },
|
||||||
|
|
@ -12,7 +10,7 @@ export const menuItems: MenuItem[] = [
|
||||||
{ id: 9, label: "Electrical" },
|
{ id: 9, label: "Electrical" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const submenusItems: MenuItem[] = [
|
export const submenusItems = [
|
||||||
{ id: 1, label: "Level 1-1" },
|
{ id: 1, label: "Level 1-1" },
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|
@ -37,7 +35,7 @@ export const submenusItems: MenuItem[] = [
|
||||||
{ id: 8, label: "Level 1-8" },
|
{ id: 8, label: "Level 1-8" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const submenusItemsWithIcons: MenuItem[] = [
|
export const submenusItemsWithIcons = [
|
||||||
{ id: 1, label: "Level 1-1", icon: "galaxy" },
|
{ id: 1, label: "Level 1-1", icon: "galaxy" },
|
||||||
{
|
{
|
||||||
id: 2,
|
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 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 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) => {
|
export const Popover = (props: PopoverProps) => {
|
||||||
const { children, ...rest } = props;
|
const { children, ...rest } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessPopover className={styles.popover} {...rest}>
|
<HeadlessPopover {...rest} className={styles.popover}>
|
||||||
{children}
|
{children}
|
||||||
</HeadlessPopover>
|
</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 {
|
.radio {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 React from "react";
|
||||||
import type { Checkbox } from "@appsmith/wds";
|
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 type { Meta, StoryObj } from "@storybook/react";
|
||||||
import { StoryGrid, DataAttrWrapper } from "@design-system/storybook";
|
import { StoryGrid, DataAttrWrapper } from "@design-system/storybook";
|
||||||
|
|
||||||
|
|
@ -22,12 +22,24 @@ export const LightMode: Story = {
|
||||||
<StoryGrid>
|
<StoryGrid>
|
||||||
{states.map((state) => (
|
{states.map((state) => (
|
||||||
<DataAttrWrapper attr={state} key={state} target="label">
|
<DataAttrWrapper attr={state} key={state} target="label">
|
||||||
<RadioGroup items={items} />
|
<RadioGroup>
|
||||||
|
{items.map(({ label, value }) => (
|
||||||
|
<Radio key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</Radio>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
</DataAttrWrapper>
|
</DataAttrWrapper>
|
||||||
))}
|
))}
|
||||||
{states.map((state) => (
|
{states.map((state) => (
|
||||||
<DataAttrWrapper attr={state} key={state} target="label">
|
<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>
|
</DataAttrWrapper>
|
||||||
))}
|
))}
|
||||||
</StoryGrid>
|
</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 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";
|
import type { RadioGroupProps } from "./types";
|
||||||
|
|
||||||
const _RadioGroup = (
|
const _RadioGroup = (
|
||||||
|
|
@ -16,11 +16,12 @@ const _RadioGroup = (
|
||||||
ref: ForwardedRef<HTMLDivElement>,
|
ref: ForwardedRef<HTMLDivElement>,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const {
|
||||||
|
children,
|
||||||
contextualHelp,
|
contextualHelp,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
|
isReadOnly,
|
||||||
isRequired,
|
isRequired,
|
||||||
items,
|
|
||||||
label,
|
label,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
|
|
@ -32,31 +33,30 @@ const _RadioGroup = (
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessRadioGroup
|
<HeadlessRadioGroup
|
||||||
className={styles.radioGroup}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
ref={ref}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
|
className={inputFieldStyles.field}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
isRequired={isRequired}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
{Boolean(label) && (
|
||||||
<FieldLabel
|
<FieldLabel
|
||||||
contextualHelp={contextualHelp}
|
contextualHelp={contextualHelp}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
text={label}
|
|
||||||
/>
|
|
||||||
<Flex
|
|
||||||
direction={orientation === "vertical" ? "column" : "row"}
|
|
||||||
gap={orientation === "vertical" ? "spacing-2" : "spacing-4"}
|
|
||||||
isInner
|
|
||||||
ref={containerRef}
|
|
||||||
wrap="wrap"
|
|
||||||
>
|
>
|
||||||
{items.map(({ label, value, ...rest }, index) => (
|
{label}
|
||||||
<Radio className={styles.radio} key={index} value={value} {...rest}>
|
</FieldLabel>
|
||||||
<Text lineClamp={1}>{label}</Text>
|
)}
|
||||||
</Radio>
|
<Group
|
||||||
))}
|
className={toggleGroupStyles.toggleGroup}
|
||||||
</Flex>
|
data-orientation={orientation}
|
||||||
<FieldError errorMessage={errorMessage} />
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Group>
|
||||||
|
<FieldError>{errorMessage}</FieldError>
|
||||||
</HeadlessRadioGroup>
|
</HeadlessRadioGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,13 @@
|
||||||
|
import type { FieldProps, ORIENTATION } from "@appsmith/wds";
|
||||||
import type { RadioGroupProps as HeadlessRadioGroupProps } from "react-aria-components";
|
import type { RadioGroupProps as HeadlessRadioGroupProps } from "react-aria-components";
|
||||||
import type { ORIENTATION } from "../../../shared";
|
|
||||||
|
|
||||||
interface RadioGroupItemProps {
|
export interface RadioGroupProps extends HeadlessRadioGroupProps, FieldProps {
|
||||||
value: string;
|
|
||||||
label?: string;
|
|
||||||
isSelected?: boolean;
|
|
||||||
isDisabled?: boolean;
|
|
||||||
index?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RadioGroupProps extends HeadlessRadioGroupProps {
|
|
||||||
/**
|
/**
|
||||||
* A ContextualHelp element to place next to the label.
|
* The orientation of the radio group.
|
||||||
*/
|
|
||||||
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'
|
|
||||||
*/
|
*/
|
||||||
orientation?: keyof typeof ORIENTATION;
|
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 React from "react";
|
||||||
import type { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
import { RadioGroup, Flex } from "@appsmith/wds";
|
import { RadioGroup, Flex, Radio } 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>;
|
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Value 1", value: "value-1" },
|
{ label: "Value 1", value: "value-1" },
|
||||||
{ label: "Value 2", value: "value-2" },
|
{ label: "Value 2", value: "value-2" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const Main: Story = {
|
const meta: Meta<typeof RadioGroup> = {
|
||||||
|
title: "WDS/Widgets/RadioGroup",
|
||||||
|
component: RadioGroup,
|
||||||
|
tags: ["autodocs"],
|
||||||
args: {
|
args: {
|
||||||
label: "Radio Group",
|
|
||||||
defaultValue: "value-1",
|
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 = {
|
export const Orientation: Story = {
|
||||||
render: () => (
|
render: () => {
|
||||||
|
return (
|
||||||
<Flex direction="column" gap="spacing-4">
|
<Flex direction="column" gap="spacing-4">
|
||||||
<RadioGroup items={items} />
|
<RadioGroup label="Vertical" orientation="vertical">
|
||||||
<RadioGroup items={items} 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>
|
</Flex>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: "Radio Group",
|
|
||||||
defaultValue: "value-1",
|
|
||||||
isDisabled: true,
|
isDisabled: true,
|
||||||
items: items,
|
label: "Disabled",
|
||||||
},
|
},
|
||||||
render: (args) => <RadioGroup {...args} />,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Required: Story = {
|
export const Required: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: "Radio Group",
|
|
||||||
defaultValue: "value-1",
|
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
items: items,
|
label: "Required",
|
||||||
},
|
},
|
||||||
render: (args) => <RadioGroup {...args} />,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Invalid: Story = {
|
export const Invalid: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: "Radio Group",
|
errorMessage: "There is an error",
|
||||||
|
label: "Invalid",
|
||||||
isInvalid: true,
|
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 "@testing-library/jest-dom";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { RadioGroup } from "@appsmith/wds";
|
import { Radio, RadioGroup } from "@appsmith/wds";
|
||||||
|
|
||||||
describe("@appsmith/wds/RadioGroup", () => {
|
describe("@appsmith/wds/RadioGroup", () => {
|
||||||
const items = [
|
const items = [
|
||||||
|
|
@ -12,7 +12,13 @@ describe("@appsmith/wds/RadioGroup", () => {
|
||||||
|
|
||||||
it("should render the Radio group", async () => {
|
it("should render the Radio group", async () => {
|
||||||
const { container } = render(
|
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();
|
expect(screen.getByText("Value 1")).toBeInTheDocument();
|
||||||
|
|
@ -46,11 +52,13 @@ describe("@appsmith/wds/RadioGroup", () => {
|
||||||
|
|
||||||
it("should support custom props", () => {
|
it("should support custom props", () => {
|
||||||
render(
|
render(
|
||||||
<RadioGroup
|
<RadioGroup data-testid="t--radio-group" label="Radio Group Label">
|
||||||
data-testid="t--radio-group"
|
{items.map(({ label, value }) => (
|
||||||
items={items}
|
<Radio key={value} value={value}>
|
||||||
label="Radio Group Label"
|
{label}
|
||||||
/>,
|
</Radio>
|
||||||
|
))}
|
||||||
|
</RadioGroup>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const radioGroup = screen.getByTestId("t--radio-group");
|
const radioGroup = screen.getByTestId("t--radio-group");
|
||||||
|
|
@ -60,7 +68,13 @@ describe("@appsmith/wds/RadioGroup", () => {
|
||||||
|
|
||||||
it("should render checked checkboxes when value is passed", () => {
|
it("should render checked checkboxes when value is passed", () => {
|
||||||
render(
|
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");
|
const options = screen.getAllByRole("radio");
|
||||||
|
|
@ -73,11 +87,13 @@ describe("@appsmith/wds/RadioGroup", () => {
|
||||||
const onChangeSpy = jest.fn();
|
const onChangeSpy = jest.fn();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<RadioGroup
|
<RadioGroup label="Radio Group Label" onChange={onChangeSpy}>
|
||||||
items={items}
|
{items.map(({ label, value }) => (
|
||||||
label="Radio Group Label"
|
<Radio key={value} value={value}>
|
||||||
onChange={onChangeSpy}
|
{label}
|
||||||
/>,
|
</Radio>
|
||||||
|
))}
|
||||||
|
</RadioGroup>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const options = screen.getAllByRole("radio");
|
const options = screen.getAllByRole("radio");
|
||||||
|
|
@ -87,7 +103,15 @@ describe("@appsmith/wds/RadioGroup", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be able to render disabled checkboxes", () => {
|
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");
|
const options = screen.getAllByRole("radio");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,36 @@
|
||||||
import React, { useRef } from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
FieldDescription,
|
|
||||||
FieldError,
|
FieldError,
|
||||||
Icon,
|
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
Spinner,
|
ListBox,
|
||||||
FieldListPopover,
|
inputFieldStyles,
|
||||||
|
Popover,
|
||||||
} from "@appsmith/wds";
|
} from "@appsmith/wds";
|
||||||
import { getTypographyClassName } from "@appsmith/wds-theming";
|
import { Select as HeadlessSelect } from "react-aria-components";
|
||||||
import clsx from "clsx";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Select as HeadlessSelect,
|
|
||||||
SelectValue,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import styles from "./styles.module.css";
|
|
||||||
import type { SelectProps } from "./types";
|
import type { SelectProps } from "./types";
|
||||||
|
import { SelectTrigger } from "./SelectTrigger";
|
||||||
|
|
||||||
export const Select = (props: SelectProps) => {
|
export const Select = (props: SelectProps) => {
|
||||||
const {
|
const {
|
||||||
|
children,
|
||||||
contextualHelp,
|
contextualHelp,
|
||||||
description,
|
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
isDisabled,
|
||||||
isLoading,
|
isLoading,
|
||||||
isRequired,
|
isRequired,
|
||||||
items,
|
|
||||||
label,
|
label,
|
||||||
|
placeholder,
|
||||||
size = "medium",
|
size = "medium",
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const root = document.body.querySelector(
|
||||||
|
"[data-theme-provider]",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessSelect
|
<HeadlessSelect
|
||||||
aria-label={Boolean(label) ? undefined : "Select"}
|
className={inputFieldStyles.field}
|
||||||
className={styles.formField}
|
|
||||||
data-size={size}
|
data-size={size}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|
@ -43,30 +39,22 @@ export const Select = (props: SelectProps) => {
|
||||||
<>
|
<>
|
||||||
<FieldLabel
|
<FieldLabel
|
||||||
contextualHelp={contextualHelp}
|
contextualHelp={contextualHelp}
|
||||||
|
isDisabled={isDisabled}
|
||||||
isRequired={isRequired}
|
isRequired={isRequired}
|
||||||
text={label}
|
|
||||||
/>
|
|
||||||
<Button className={styles.textField} ref={triggerRef}>
|
|
||||||
<SelectValue
|
|
||||||
className={clsx(
|
|
||||||
styles.fieldValue,
|
|
||||||
getTypographyClassName("body"),
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{({ defaultChildren, isPlaceholder }) => {
|
{label}
|
||||||
if (isPlaceholder) {
|
</FieldLabel>
|
||||||
return props.placeholder;
|
<SelectTrigger
|
||||||
}
|
isDisabled={isDisabled}
|
||||||
|
isInvalid={isInvalid}
|
||||||
return defaultChildren;
|
isLoading={isLoading}
|
||||||
}}
|
placeholder={placeholder}
|
||||||
</SelectValue>
|
size={size}
|
||||||
{!Boolean(isLoading) && <Icon name="chevron-down" />}
|
/>
|
||||||
{Boolean(isLoading) && <Spinner />}
|
<FieldError>{errorMessage}</FieldError>
|
||||||
</Button>
|
<Popover UNSTABLE_portalContainer={root}>
|
||||||
<FieldError errorMessage={errorMessage} />
|
<ListBox shouldFocusWrap>{children}</ListBox>
|
||||||
<FieldDescription description={description} isInvalid={isInvalid} />
|
</Popover>
|
||||||
<FieldListPopover items={items} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</HeadlessSelect>
|
</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 {
|
import type { SIZES, FieldProps } from "@appsmith/wds";
|
||||||
SelectProps as SpectrumSelectProps,
|
import type { SelectProps as SpectrumSelectProps } from "react-aria-components";
|
||||||
ValidationResult,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import type { SIZES, FieldListPopoverItem } from "@appsmith/wds";
|
|
||||||
|
|
||||||
export interface SelectProps
|
export interface SelectProps
|
||||||
extends Omit<SpectrumSelectProps<FieldListPopoverItem>, "slot"> {
|
extends Omit<SpectrumSelectProps<object>, "slot">,
|
||||||
/** Item objects in the collection. */
|
FieldProps {
|
||||||
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);
|
|
||||||
/** size of the select
|
/** size of the select
|
||||||
*
|
*
|
||||||
* @default medium
|
* @default medium
|
||||||
*/
|
*/
|
||||||
size?: Omit<keyof typeof SIZES, "xSmall" | "large">;
|
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 React from "react";
|
||||||
import type { Meta, StoryObj } from "@storybook/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";
|
import { selectItems, selectItemsWithIcons } from "./selectData";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -9,6 +10,13 @@ import { selectItems, selectItemsWithIcons } from "./selectData";
|
||||||
const meta: Meta<typeof Select> = {
|
const meta: Meta<typeof Select> = {
|
||||||
component: Select,
|
component: Select,
|
||||||
title: "WDS/Widgets/Select",
|
title: "WDS/Widgets/Select",
|
||||||
|
args: {
|
||||||
|
children: selectItems.map((item) => (
|
||||||
|
<ListBoxItem key={item.id} textValue={item.label}>
|
||||||
|
{item.label}
|
||||||
|
</ListBoxItem>
|
||||||
|
)),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
@ -16,13 +24,13 @@ type Story = StoryObj<typeof Select>;
|
||||||
|
|
||||||
export const Main: Story = {
|
export const Main: Story = {
|
||||||
args: {
|
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)
|
{Object.keys(SIZES)
|
||||||
.filter((size) => !["xSmall", "large"].includes(size))
|
.filter((size) => !["xSmall", "large"].includes(size))
|
||||||
.map((size) => (
|
.map((size) => (
|
||||||
<Select
|
<Select key={size} label={size} placeholder={size} size={size}>
|
||||||
items={selectItems}
|
{selectItems.map((item) => (
|
||||||
key={size}
|
<ListBoxItem key={item.id} textValue={item.label}>
|
||||||
placeholder={size}
|
{item.label}
|
||||||
size={size}
|
</ListBoxItem>
|
||||||
/>
|
))}
|
||||||
|
</Select>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
),
|
),
|
||||||
|
|
@ -49,7 +58,13 @@ export const Loading: Story = {
|
||||||
args: {
|
args: {
|
||||||
placeholder: "Loading",
|
placeholder: "Loading",
|
||||||
isLoading: true,
|
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">
|
<Flex direction="column" gap="spacing-5" width="sizing-60">
|
||||||
<Select
|
<Select
|
||||||
description="description"
|
errorMessage="There is an error"
|
||||||
isRequired
|
isRequired
|
||||||
items={selectItems}
|
|
||||||
label="Validation"
|
label="Validation"
|
||||||
/>
|
/>
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit">Submit</Button>
|
||||||
|
|
@ -79,13 +93,16 @@ export const ContextualHelp: Story = {
|
||||||
label: "Label",
|
label: "Label",
|
||||||
placeholder: "Contextual Help Text",
|
placeholder: "Contextual Help Text",
|
||||||
contextualHelp: "This is a contextual help text",
|
contextualHelp: "This is a contextual help text",
|
||||||
items: selectItems,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithIcons: Story = {
|
export const WithIcons: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: "With icons",
|
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 = [
|
||||||
|
|
||||||
export const selectItems: FieldListPopoverItem[] = [
|
|
||||||
{ id: 1, label: "Aerospace" },
|
{ id: 1, label: "Aerospace" },
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|
@ -15,7 +13,7 @@ export const selectItems: FieldListPopoverItem[] = [
|
||||||
{ id: 9, label: "Electrical" },
|
{ id: 9, label: "Electrical" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const selectItemsWithIcons: FieldListPopoverItem[] = [
|
export const selectItemsWithIcons = [
|
||||||
{ id: 1, label: "Aerospace", icon: "galaxy" },
|
{ id: 1, label: "Aerospace", icon: "galaxy" },
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|
@ -25,4 +23,4 @@ export const selectItemsWithIcons: FieldListPopoverItem[] = [
|
||||||
{ id: 3, label: "Civil", icon: "circuit-ground" },
|
{ id: 3, label: "Civil", icon: "circuit-ground" },
|
||||||
{ id: 4, label: "Biomedical", icon: "biohazard" },
|
{ id: 4, label: "Biomedical", icon: "biohazard" },
|
||||||
{ id: 5, label: "Nuclear", icon: "atom" },
|
{ id: 5, label: "Nuclear", icon: "atom" },
|
||||||
];
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import React, { forwardRef } from "react";
|
|
||||||
import { Text } from "@appsmith/wds";
|
import { Text } from "@appsmith/wds";
|
||||||
import { Checkbox as HeadlessCheckbox } from "react-aria-components";
|
import React, { forwardRef } from "react";
|
||||||
import styles from "./styles.module.css";
|
|
||||||
import type { ForwardedRef } 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";
|
import type { SwitchProps } from "./types";
|
||||||
|
|
||||||
const _Switch = (props: SwitchProps, ref: ForwardedRef<HTMLLabelElement>) => {
|
const _Switch = (props: SwitchProps, ref: ForwardedRef<HTMLLabelElement>) => {
|
||||||
|
|
|
||||||
|
|
@ -18,20 +18,12 @@ export interface TagGroupProps<T>
|
||||||
extends Omit<HeadlessTagGroupProps, "children">,
|
extends Omit<HeadlessTagGroupProps, "children">,
|
||||||
Pick<HeadlessTagListProps<T>, "items" | "children" | "renderEmptyState"> {
|
Pick<HeadlessTagListProps<T>, "items" | "children" | "renderEmptyState"> {
|
||||||
label?: string;
|
label?: string;
|
||||||
description?: string;
|
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TagGroup<T extends object>(props: TagGroupProps<T>) {
|
function TagGroup<T extends object>(props: TagGroupProps<T>) {
|
||||||
const {
|
const { children, errorMessage, items, label, renderEmptyState, ...rest } =
|
||||||
children,
|
props;
|
||||||
description,
|
|
||||||
errorMessage,
|
|
||||||
items,
|
|
||||||
label,
|
|
||||||
renderEmptyState,
|
|
||||||
...rest
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeadlessTagGroup {...rest} className={styles["tag-group"]}>
|
<HeadlessTagGroup {...rest} className={styles["tag-group"]}>
|
||||||
|
|
@ -43,14 +35,6 @@ function TagGroup<T extends object>(props: TagGroupProps<T>) {
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</HeadlessTagList>
|
</HeadlessTagList>
|
||||||
{Boolean(description) && (
|
|
||||||
<HeadlessText
|
|
||||||
className={getTypographyClassName("footnote")}
|
|
||||||
slot="description"
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</HeadlessText>
|
|
||||||
)}
|
|
||||||
{Boolean(errorMessage) && (
|
{Boolean(errorMessage) && (
|
||||||
<HeadlessText
|
<HeadlessText
|
||||||
className={getTypographyClassName("footnote")}
|
className={getTypographyClassName("footnote")}
|
||||||
|
|
|
||||||
|
|
@ -85,15 +85,6 @@ export const WithLabel: Story = {
|
||||||
render: (args) => <TagGroupExample {...args} />,
|
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 = {
|
export const WithError: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: "Categories",
|
label: "Categories",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type {
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { COLORS } from "../../../shared";
|
import type { COLORS } from "../../../shared";
|
||||||
|
|
||||||
export interface TextProps {
|
export interface TextProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
/** size variant of the text
|
/** size variant of the text
|
||||||
* @default body
|
* @default body
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,53 +1,110 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import React, { forwardRef } from "react";
|
import {
|
||||||
import type {
|
FieldError,
|
||||||
TextAreaRef as HeadlessTextAreaRef,
|
FieldLabel,
|
||||||
TextAreaProps as HeadlessTextAreaProps,
|
inputFieldStyles,
|
||||||
} from "@appsmith/wds-headless";
|
TextAreaInput,
|
||||||
import { TextArea as HeadlessTextArea } from "@appsmith/wds-headless";
|
} 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 type { TextAreaProps } from "./types";
|
||||||
import { textInputStyles, fieldStyles } from "../../../styles";
|
|
||||||
import { ContextualHelp } from "../../ContextualHelp";
|
|
||||||
import { getTypographyClassName } from "@appsmith/wds-theming";
|
|
||||||
|
|
||||||
export interface TextAreaProps extends HeadlessTextAreaProps {
|
export function TextArea(props: TextAreaProps) {
|
||||||
/** loading state for the input */
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const _TextArea = (props: TextAreaProps, ref: HeadlessTextAreaRef) => {
|
|
||||||
const {
|
const {
|
||||||
contextualHelp: contextualHelpProp,
|
contextualHelp,
|
||||||
description,
|
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
isDisabled,
|
||||||
|
isInvalid,
|
||||||
|
isLoading,
|
||||||
|
isReadOnly,
|
||||||
isRequired,
|
isRequired,
|
||||||
label,
|
label,
|
||||||
|
onChange,
|
||||||
|
suffix,
|
||||||
|
value,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const contextualHelp = Boolean(contextualHelpProp) && (
|
const [inputValue, setInputValue] = useControlledState(
|
||||||
<ContextualHelp contextualHelp={contextualHelpProp} />
|
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 (
|
return (
|
||||||
<HeadlessTextArea
|
<HeadlessTextField
|
||||||
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}
|
|
||||||
{...rest}
|
{...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 { 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 React from "react";
|
||||||
|
import { Form } from "react-aria-components";
|
||||||
import type { Meta, StoryObj } from "@storybook/react";
|
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> = {
|
const meta: Meta<typeof TextArea> = {
|
||||||
component: TextArea,
|
|
||||||
title: "WDS/Widgets/TextArea",
|
title: "WDS/Widgets/TextArea",
|
||||||
|
component: TextArea,
|
||||||
|
tags: ["autodocs"],
|
||||||
|
args: {
|
||||||
|
placeholder: "Write something...",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
@ -15,51 +17,58 @@ type Story = StoryObj<typeof TextArea>;
|
||||||
|
|
||||||
export const Main: Story = {
|
export const Main: Story = {
|
||||||
args: {
|
args: {
|
||||||
label: "Label",
|
label: "Description",
|
||||||
placeholder: "Placeholder",
|
placeholder: "Write something...",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Description: Story = {
|
export const WithLabel: Story = {
|
||||||
args: {
|
args: {
|
||||||
placeholder: "Description",
|
label: "Label",
|
||||||
description: "This is a description",
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithContextualHelp: Story = {
|
||||||
|
args: {
|
||||||
|
label: "Description",
|
||||||
|
contextualHelp: "This is a contextual help",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Disabled: Story = {
|
export const Disabled: Story = {
|
||||||
args: {
|
args: {
|
||||||
placeholder: "Disabled",
|
|
||||||
isDisabled: true,
|
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 = {
|
export const Validation: Story = {
|
||||||
args: {
|
render: (args) => (
|
||||||
placeholder: "Validation",
|
<Form onSubmit={(e) => e.preventDefault()}>
|
||||||
validationState: "invalid",
|
<Flex direction="column" gap="spacing-3" width="sizing-60">
|
||||||
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
|
<TextArea
|
||||||
|
{...args}
|
||||||
|
errorMessage="Please enter at least 10 characters"
|
||||||
isRequired
|
isRequired
|
||||||
label="Required - Label Indicator"
|
label="Description"
|
||||||
necessityIndicator="label"
|
|
||||||
/>
|
/>
|
||||||
<TextArea label="Required - Label Indicator" necessityIndicator="label" />
|
<Button type="submit">Submit</Button>
|
||||||
</Flex>
|
</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 clsx from "clsx";
|
||||||
import type {
|
import React from "react";
|
||||||
TextInputRef as HeadlessTextInputRef,
|
import { FieldError, FieldLabel, Input, inputFieldStyles } from "@appsmith/wds";
|
||||||
TextInputProps as HeadlessTextInputProps,
|
import { TextField as HeadlessTextField } from "react-aria-components";
|
||||||
} 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 { Spinner } from "../../Spinner";
|
import type { TextInputProps } from "./types";
|
||||||
import type { IconProps } from "../../Icon";
|
|
||||||
import { IconButton } from "../../IconButton";
|
|
||||||
import { ContextualHelp } from "../../ContextualHelp";
|
|
||||||
import { textInputStyles, fieldStyles } from "../../../styles";
|
|
||||||
import type { SIZES } from "../../../shared";
|
|
||||||
|
|
||||||
export interface TextInputProps extends HeadlessTextInputProps {
|
export function TextInput(props: TextInputProps) {
|
||||||
/** 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) => {
|
|
||||||
const {
|
const {
|
||||||
contextualHelp: contextualHelpProp,
|
contextualHelp,
|
||||||
description,
|
|
||||||
errorMessage,
|
errorMessage,
|
||||||
isLoading = false,
|
isDisabled,
|
||||||
|
isInvalid,
|
||||||
|
isLoading,
|
||||||
|
isReadOnly,
|
||||||
isRequired,
|
isRequired,
|
||||||
label,
|
label,
|
||||||
prefix,
|
prefix,
|
||||||
size = "medium",
|
size = "medium",
|
||||||
suffix,
|
suffix,
|
||||||
type,
|
type,
|
||||||
|
value,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = 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 (
|
return (
|
||||||
<IconButton
|
<HeadlessTextField
|
||||||
color="neutral"
|
{...rest}
|
||||||
excludeFromTabOrder
|
className={clsx(inputFieldStyles.field)}
|
||||||
icon={icon}
|
data-field=""
|
||||||
onPress={onPressEyeIcon}
|
isDisabled={isDisabled}
|
||||||
size={size === "medium" ? "small" : "xSmall"}
|
isInvalid={isInvalid}
|
||||||
variant="ghost"
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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