From 1e5c48fabb0d88caa549b768f59a2b7331d63cb0 Mon Sep 17 00:00:00 2001 From: Sean Collings Date: Tue, 1 Sep 2026 12:47:40 -0600 Subject: [PATCH 1/8] feat: add Loading component and implement support for it in input-like fields --- .../Feedback/Loading/Loading.component.tsx | 42 ++ .../Feedback/Loading/Loading.context.ts | 18 + .../src/components/Feedback/Loading/index.tsx | 3 + .../ErrorMessage/ErrorMessage.component.tsx | 21 +- .../Fields/Atoms/Input/Input.component.tsx | 43 +- .../Fields/Atoms/Label/Label.component.tsx | 9 +- .../Atoms/Message/Message.component.tsx | 9 +- .../Atoms/TextArea/TextArea.component.tsx | 48 ++- .../ComboInput/ComboInput.component.tsx | 41 +- .../Fields/ComboInput/ComboInput.types.ts | 5 +- .../Fields/TextField/TextField.component.tsx | 19 +- .../src/components/Internal/BaseButton.tsx | 3 + .../atomic-elements/src/components/index.ts | 2 + packages/atomic-elements/src/i18n/en-US.ts | 1 + packages/atomic-elements/src/types/common.ts | 2 - playground/src/tabs/Fields.tsx | 378 +++++++++--------- 16 files changed, 437 insertions(+), 207 deletions(-) create mode 100644 packages/atomic-elements/src/components/Feedback/Loading/Loading.component.tsx create mode 100644 packages/atomic-elements/src/components/Feedback/Loading/Loading.context.ts create mode 100644 packages/atomic-elements/src/components/Feedback/Loading/index.tsx diff --git a/packages/atomic-elements/src/components/Feedback/Loading/Loading.component.tsx b/packages/atomic-elements/src/components/Feedback/Loading/Loading.component.tsx new file mode 100644 index 000000000..3395774d7 --- /dev/null +++ b/packages/atomic-elements/src/components/Feedback/Loading/Loading.component.tsx @@ -0,0 +1,42 @@ +import React, { useMemo } from "react"; +import { VisuallyHidden } from "@react-aria/visually-hidden"; +import { BaseProps, LoadingProps } from "../../../types"; +import { LoadingContext } from "./Loading.context"; +import { useRenderProps } from "@hooks"; +import { useTranslations } from "@hooks/useTranslations"; + +export interface LoadingProviderProps extends BaseProps, LoadingProps { + children?: React.ReactNode; +} + +/** Provides an ambient loading state to descendant components. + * Any component that reads its loading state with `useLoading` will + * pick this up unless it is given its own local loading props, which + * take priority over the ambient state. */ +export function Loading(props: LoadingProviderProps) { + const { isLoading, loadingLabel } = props; + + const t = useTranslations(); + + const value = useMemo( + () => ({ isLoading, loadingLabel: t("loading") }), + [isLoading, loadingLabel] + ); + + const renderProps = useRenderProps({ + componentClassName: "aje-loading", + values: value, + ...props, + }); + + return ( + +
+ + {isLoading ? loadingLabel : ""} + + {renderProps.children} +
+
+ ); +} diff --git a/packages/atomic-elements/src/components/Feedback/Loading/Loading.context.ts b/packages/atomic-elements/src/components/Feedback/Loading/Loading.context.ts new file mode 100644 index 000000000..d33bdf5a9 --- /dev/null +++ b/packages/atomic-elements/src/components/Feedback/Loading/Loading.context.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; +import { LoadingProps } from "../../../types"; + +export const LoadingContext = createContext( + undefined +); + +/** Reads the ambient loading state provided by the nearest `Loading` + * ancestor and merges it with the given local loading props. + * Local props take priority over the ambient state. */ +export function useLoading(props: LoadingProps = {}): LoadingProps { + const context = useContext(LoadingContext); + + return { + isLoading: props.isLoading ?? context?.isLoading, + loadingLabel: props.loadingLabel ?? context?.loadingLabel, + }; +} diff --git a/packages/atomic-elements/src/components/Feedback/Loading/index.tsx b/packages/atomic-elements/src/components/Feedback/Loading/index.tsx new file mode 100644 index 000000000..a7f678206 --- /dev/null +++ b/packages/atomic-elements/src/components/Feedback/Loading/index.tsx @@ -0,0 +1,3 @@ +export { Loading } from "./Loading.component"; +export type { LoadingProviderProps } from "./Loading.component"; +export { LoadingContext, useLoading } from "./Loading.context"; diff --git a/packages/atomic-elements/src/components/Fields/Atoms/ErrorMessage/ErrorMessage.component.tsx b/packages/atomic-elements/src/components/Fields/Atoms/ErrorMessage/ErrorMessage.component.tsx index 07a6e9e05..348bfeeb6 100644 --- a/packages/atomic-elements/src/components/Fields/Atoms/ErrorMessage/ErrorMessage.component.tsx +++ b/packages/atomic-elements/src/components/Fields/Atoms/ErrorMessage/ErrorMessage.component.tsx @@ -5,6 +5,7 @@ import { ElementWrapperProps } from "../../../../types"; import { useContextProps } from "@hooks/useContextProps"; import { useRenderProps } from "@hooks"; import { ErrorMessageContext } from "./ErrorMessage.context"; +import { filterDOMProps } from "@react-aria/utils"; /** A styled error message */ export const StyledErrorMessage = styled.p` @@ -26,7 +27,14 @@ export const ErrorMessage = React.forwardRef(function ErrorMessage( ref: React.Ref ) { [props, ref] = useContextProps(ErrorMessageContext, props, ref); - const { className, size = "medium", as = "p", isInvalid, ...rest } = props; + const { + className, + size = "medium", + as = "p", + isInvalid, + children, + ...rest + } = props; const renderProps = useRenderProps({ componentClassName: className, @@ -35,5 +43,14 @@ export const ErrorMessage = React.forwardRef(function ErrorMessage( if (!isInvalid) return; - return ; + return ( + + {children} + + ); }); diff --git a/packages/atomic-elements/src/components/Fields/Atoms/Input/Input.component.tsx b/packages/atomic-elements/src/components/Fields/Atoms/Input/Input.component.tsx index 1a1ca4771..373cec7b5 100644 --- a/packages/atomic-elements/src/components/Fields/Atoms/Input/Input.component.tsx +++ b/packages/atomic-elements/src/components/Fields/Atoms/Input/Input.component.tsx @@ -1,11 +1,14 @@ import React, { forwardRef } from "react"; import styled from "styled-components"; +import { filterDOMProps } from "@react-aria/utils"; import mixins from "../../../../styles/mixins"; -import { ElementWrapperProps } from "../../../../types"; +import { ElementWrapperProps, LoadingProps } from "../../../../types"; import { useContextProps } from "@hooks/useContextProps"; import { useRenderProps } from "@hooks"; import { InputContext } from "./Input.context"; import { SlotProps } from "@hooks/useSlottedContext"; +import { SkeletonLoader } from "@components/Feedback/SkeletonLoader"; +import { useLoading } from "@components/Feedback/Loading"; const StyledInput = styled.input` ${mixins.Regular} @@ -13,12 +16,19 @@ const StyledInput = styled.input` ${mixins.SizingX} `; +const StyledLoadingInput = styled(SkeletonLoader)` + ${mixins.SizingX} + height: var(--input-height); + display: block; +`; + export interface InputProps extends Omit< ElementWrapperProps>, "slot" >, - SlotProps {} + SlotProps, + LoadingProps {} /** The input element for a field. */ export const Input = forwardRef(function Input( @@ -27,7 +37,15 @@ export const Input = forwardRef(function Input( ) { [props, ref] = useContextProps(InputContext, props, ref); - const { className, size, style, ...rest } = props; + const { isLoading, loadingLabel } = useLoading(props); + const { + className, + size, + style, + isLoading: _isLoading, + loadingLabel: _loadingLabel, + ...rest + } = props; const renderProps = useRenderProps({ componentClassName: className, @@ -35,6 +53,25 @@ export const Input = forwardRef(function Input( style, }); + if (isLoading) { + return ( + + + + ); + } + return ( ` ${mixins.Bold} @@ -30,12 +31,16 @@ export const Label = React.forwardRef(function Label( ) { [props, ref] = useContextProps(LabelContext, props, ref); - const { className, size, as = "label", ...rest } = props; + const { className, size, as = "label", children, ...rest } = props; const renderProps = useRenderProps({ componentClassName: className, size, }); - return ; + return ( + + {children} + + ); }); diff --git a/packages/atomic-elements/src/components/Fields/Atoms/Message/Message.component.tsx b/packages/atomic-elements/src/components/Fields/Atoms/Message/Message.component.tsx index 238e2124c..c197fa5eb 100644 --- a/packages/atomic-elements/src/components/Fields/Atoms/Message/Message.component.tsx +++ b/packages/atomic-elements/src/components/Fields/Atoms/Message/Message.component.tsx @@ -5,6 +5,7 @@ import { ElementWrapperProps } from "../../../../types"; import { useContextProps } from "@hooks/useContextProps"; import { useRenderProps } from "@hooks"; import { MessageContext } from "./Message.context"; +import { filterDOMProps } from "@react-aria/utils"; export const StyledMessage = styled.p` ${mixins.Regular} @@ -25,12 +26,16 @@ export const Message = React.forwardRef(function Message( ref: React.Ref ) { [props, ref] = useContextProps(MessageContext, props, ref); - const { as, className, size = "auto", ...rest } = props; + const { as, className, size = "auto", children, ...rest } = props; const renderProps = useRenderProps({ componentClassName: className, size, }); - return ; + return ( + + {children} + + ); }); diff --git a/packages/atomic-elements/src/components/Fields/Atoms/TextArea/TextArea.component.tsx b/packages/atomic-elements/src/components/Fields/Atoms/TextArea/TextArea.component.tsx index 7055f1e86..71f83c793 100644 --- a/packages/atomic-elements/src/components/Fields/Atoms/TextArea/TextArea.component.tsx +++ b/packages/atomic-elements/src/components/Fields/Atoms/TextArea/TextArea.component.tsx @@ -1,14 +1,27 @@ import React, { forwardRef } from "react"; -import { ElementWrapperProps } from "../../../../types"; +import styled from "styled-components"; +import mixins from "../../../../styles/mixins"; +import { ElementWrapperProps, LoadingProps } from "../../../../types"; import { StyledTextArea } from "./TextArea.styles"; import { useContextProps } from "@hooks/useContextProps"; import { useRenderProps } from "@hooks"; import { TextAreaContext } from "./TextArea.context"; +import { SkeletonLoader } from "@components/Feedback/SkeletonLoader"; +import { useLoading } from "@components/Feedback/Loading"; + +const StyledLoadingTextArea = styled(SkeletonLoader)` + ${mixins.Border("input")} + min-width: 200px; + min-height: var(--textarea-height, 80px); + width: 100%; + display: block; +`; export interface TextAreaProps extends ElementWrapperProps< - React.TextareaHTMLAttributes - > {} + React.TextareaHTMLAttributes + >, + LoadingProps {} /** A wrapped `