diff --git a/playwright/components/Form/Controls/EditInPlace.test.ts-snapshots/EditInPlace-should-render-save-cancel-buttons-correctly-1-chromium-linux.png b/playwright/components/Form/Controls/EditInPlace.test.ts-snapshots/EditInPlace-should-render-save-cancel-buttons-correctly-1-chromium-linux.png index 04f21fc7..4df82769 100644 Binary files a/playwright/components/Form/Controls/EditInPlace.test.ts-snapshots/EditInPlace-should-render-save-cancel-buttons-correctly-1-chromium-linux.png and b/playwright/components/Form/Controls/EditInPlace.test.ts-snapshots/EditInPlace-should-render-save-cancel-buttons-correctly-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Empty-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Empty-1-chromium-linux.png index 56dead9c..0ae826be 100644 Binary files a/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Empty-1-chromium-linux.png and b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Empty-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Saving-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Saving-1-chromium-linux.png new file mode 100644 index 00000000..72873604 Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Saving-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Error-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Error-1-chromium-linux.png index ac2e286a..065d1e29 100644 Binary files a/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Error-1-chromium-linux.png and b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Error-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Help-Text-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Help-Text-1-chromium-linux.png index 2f450165..1d6e2d02 100644 Binary files a/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Help-Text-1-chromium-linux.png and b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Help-Text-1-chromium-linux.png differ diff --git a/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Text-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Text-1-chromium-linux.png index 36ebbe92..dc82c635 100644 Binary files a/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Text-1-chromium-linux.png and b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Text-1-chromium-linux.png differ diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.module.css b/src/components/Form/Controls/EditInPlace/EditInPlace.module.css index 9838cbb6..0536feb8 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.module.css +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.module.css @@ -14,137 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -.container { - min-block-size: 124px; -} - -.label { - font-size: 15px; - font-weight: 500; - margin-block-end: 4px; -} - .controls { display: flex; gap: 15px; -} - -.button-group { - display: none; - position: relative; - inset-block-start: 5px; - grid-template-columns: 1fr 1fr; - gap: 7px; -} - -.button { - display: flex; - inline-size: 36px; - block-size: 36px; - border: 1px solid var(--cpd-color-border-interactive-secondary); - border-radius: 20px; - background-color: transparent; - text-align: center; - cursor: pointer; -} - -.primary-button { - background-color: var(--cpd-color-bg-action-primary-rest); - border-color: var(--cpd-color-bg-action-primary-rest); - svg { - color: var(--cpd-color-icon-on-solid-primary); + & > input { + min-inline-size: 0; } } -.primary-button-disabled { - background-color: var(--cpd-color-bg-subtle-primary); - border-color: var(--cpd-color-bg-subtle-primary); - - svg { - color: var(--cpd-color-icon-disabled); - } -} - -.button svg { - inline-size: 24px; - block-size: 24px; - margin: auto; -} - -.control { - inline-size: 100%; -} - -.container-show-buttons .button-group, -.container:focus-within .button-group { - display: inline-grid; -} - -.container-error .control { - border-color: var(--cpd-color-border-critical-primary); -} - -.container-error .label { - color: var(--cpd-color-text-critical-primary); -} - -.caption-line { - margin-block-start: var(--cpd-space-2x); +.button-group { display: flex; + inset-block-start: var(--cpd-space-1x); align-items: center; gap: var(--cpd-space-2x); } - -.caption-icon { - display: inline-block; - inline-size: 20px; - block-size: 20px; -} - -.caption-icon-error { - color: var(--cpd-color-icon-critical-primary); -} - -.caption-icon-saved { - background-color: var(--cpd-color-icon-success-primary); - - /* - * We are trying to match the size of the error icon which - * is displayed 20px big but has 2px of border in an SVG - * with a viewBox of 24x24... - */ - inline-size: calc(20px * (20 / 24)); - block-size: calc(20px * (20 / 24)); - margin: calc(2px * (20 / 24)); - border-radius: 20px; - text-align: center; - - svg { - color: var(--cpd-color-icon-on-solid-primary); - position: relative; - inset-block-start: -1px; - } -} - -.caption-text { - font-size: var(--cpd-font-body-sm-medium); - font-weight: var(--cpd-font-weight-medium); -} - -.caption-text-error { - color: var(--cpd-color-text-critical-primary); -} - -.caption-text-saved { - color: var(--cpd-color-text-success-primary); -} - -.caption-text-saving { - color: var(--cpd-color-text-secondary); -} - -.caption-text-help { - font: var(--cpd-font-body-sm-regular); - color: var(--cpd-color-text-secondary); -} diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx index 1cd4f15e..354579b7 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx @@ -18,6 +18,7 @@ import React from "react"; import { EditInPlace } from "./"; import { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent, within } from "@storybook/test"; type Props = { invalid?: boolean } & React.ComponentProps; @@ -31,13 +32,11 @@ export default { "onChange", "onSave", "onCancel", - "value", - "initialValue", + "defaultValue", "error", "savedLabel", "saveButtonLabel", "cancelButtonLabel", - "disableSaveButton", "disabled", ], }, @@ -50,18 +49,12 @@ export default { label: { type: "string", }, - value: { + defaultValue: { type: "string", }, - disableSaveButton: { - type: "boolean", - }, onChange: { action: "changed", }, - onSave: { - action: "saved", - }, onCancel: { action: "cancelled", }, @@ -87,10 +80,11 @@ export default { render: ({ ...restArgs }) => , args: { label: "Label", - value: "", + onSave: () => new Promise((resolve) => setTimeout(resolve, 1000)), + savedLabel: "Saved", saveButtonLabel: "Save", cancelButtonLabel: "Cancel", - savingLabel: "Saving...", + savingLabel: "Saving…", }, } satisfies Meta; @@ -100,14 +94,29 @@ export const Empty: Story = {}; export const WithText: Story = { args: { - value: "Hello, Computer", + defaultValue: "Hello, Computer", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox"); + await userEvent.clear(input); + await userEvent.type(input, "Hello, Computer"); }, }; -export const SaveDisabled: Story = { +export const Saving: Story = { args: { - value: "Hello, World", - disableSaveButton: true, + defaultValue: "Hello", + onSave: () => new Promise(() => {}), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole("textbox"); + await userEvent.clear(input); + await userEvent.type(input, "Hello"); + const save = canvas.getByRole("button", { name: "Save" }); + await userEvent.click(save); + await expect(canvas.getByText("Saving…")).toBeInTheDocument(); }, }; diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx index cc0d7394..1b844549 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx @@ -15,19 +15,18 @@ limitations under the License. */ import { describe, it, expect, vi, afterEach } from "vitest"; -import React, { ChangeEvent } from "react"; -import { render, screen } from "@testing-library/react"; +import React from "react"; +import { render } from "@testing-library/react"; -import { EditInPlace } from "./EditInPlace"; -import { Root, Field, Control } from "@radix-ui/react-form"; import { userEvent } from "@storybook/test"; import { act } from "react-dom/test-utils"; +import { TooltipProvider } from "@radix-ui/react-tooltip"; +import { EditInPlace } from "./EditInPlace"; type EditInPlaceTestProps = { error?: string; - value?: string; - disableSaveButton?: boolean; - onChange?: (e: ChangeEvent) => void; + defaultValue?: string; + onChange?: (e: React.ChangeEvent) => void; onSave?: () => Promise; onCancel?: () => void; disabled?: boolean; @@ -35,27 +34,21 @@ type EditInPlaceTestProps = { describe("EditInPlace", () => { const EditInPlaceTest = (props: EditInPlaceTestProps) => ( - - - - {})} - onSave={props.onSave ?? (() => Promise.resolve())} - onCancel={props.onCancel ?? (() => {})} - saveButtonLabel="Save" - cancelButtonLabel="Cancel" - savedLabel={"Saved"} - disableSaveButton={props.disableSaveButton} - savingLabel="Saving..." - helpLabel="Help message" - disabled={props.disabled} - /> - - - + + {})} + onSave={props.onSave ?? (() => Promise.resolve())} + onCancel={props.onCancel ?? (() => {})} + saveButtonLabel="Save" + cancelButtonLabel="Cancel" + savedLabel="Saved" + savingLabel="Saving..." + disabled={props.disabled} + /> + ); afterEach(() => { @@ -71,14 +64,16 @@ describe("EditInPlace", () => { let value; const onChange = vi .fn() - .mockImplementation((e: ChangeEvent) => { + .mockImplementation((e: React.ChangeEvent) => { value = e.target.value; }); - render(); + + const { getByRole } = render( + , + ); await act(async () => { - const input = screen.getByRole("textbox"); - // nb. we don't test updating the value here so we only type one character + const input = getByRole("textbox"); await userEvent.type(input, "!"); }); @@ -87,44 +82,99 @@ describe("EditInPlace", () => { }); it("field is valid if no error specified", () => { - render(); + const { getByRole } = render(); - const input = screen.getByRole("textbox"); + const input = getByRole("textbox"); expect(input).toBeValid(); }); it("renders error icon and text if error provided", () => { - render(); + const { asFragment, getByRole, getByText } = render( + , + ); + expect(asFragment()).toMatchSnapshot(); - const input = screen.getByRole("textbox"); + const input = getByRole("textbox"); expect(input).toBeInvalid(); - expect(input).toHaveAttribute("aria-errormessage"); + expect(input).toHaveAttribute("aria-describedby"); - const errorText = screen.getByText("Missing Left Falangey"); + const errorText = getByText("Missing Left Falangey"); expect(errorText).toBeInTheDocument(); - expect(errorText.id).toEqual(input.getAttribute("aria-errormessage")); + expect(errorText.id).toEqual(input.getAttribute("aria-describedby")); }); - it("should disable save button if told to", () => { - render(); + it("should show the buttons when the input or buttons are focused", async () => { + const { queryByRole } = render(); - const saveButton = screen.getByRole("button", { name: "Save" }); - expect(saveButton).toBeDisabled(); + expect(queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); + + await act(async () => { + // Focus the input + await userEvent.keyboard("{tab}"); + }); + expect(queryByRole("button", { name: "Save" })).toBeInTheDocument(); + + await act(async () => { + // Focus the save button + await userEvent.keyboard("{tab}"); + }); + + // It should still be visible + expect(queryByRole("button", { name: "Save" })).toBeInTheDocument(); + + await act(async () => { + // Focus the cancel button + await userEvent.keyboard("{tab}"); + }); + + // It should still be visible + expect(queryByRole("button", { name: "Save" })).toBeInTheDocument(); + + await act(async () => { + // Focus away + await userEvent.keyboard("{tab}"); + }); + + // The button should be hidden + expect(queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); }); - it("enables save button by default", () => { - render(); + it("enables save button once we entered something", async () => { + const { getByRole, queryByRole } = render(); + + const input = getByRole("textbox"); + expect(queryByRole("button", { name: "Save" })).not.toBeInTheDocument(); + + await act(async () => { + // Focus the input + await userEvent.click(input); + }); + + // The button should be visible but disabled + expect(getByRole("button", { name: "Save" })).toHaveAttribute( + "aria-disabled", + "true", + ); + + await act(async () => { + await userEvent.type(input, "Changed"); + }); - expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + // The button should be enabled + expect(getByRole("button", { name: "Save" })).toHaveAttribute( + "aria-disabled", + "false", + ); }); it("calls save callback on save button click", async () => { const onSave = vi.fn(); - render(); + const { getByRole } = render(); await act(async () => { - await userEvent.click(screen.getByRole("button", { name: "Save" })); + await userEvent.type(getByRole("textbox"), "Changed"); + await userEvent.click(getByRole("button", { name: "Save" })); }); expect(onSave).toHaveBeenCalled(); @@ -132,10 +182,11 @@ describe("EditInPlace", () => { it("calls save callback if enter pressed in the text box", async () => { const onSave = vi.fn(); - render(); + const { getByRole } = render(); await act(async () => { - await userEvent.type(screen.getByRole("textbox"), "{enter}"); + await userEvent.type(getByRole("textbox"), "Changed"); + await userEvent.type(getByRole("textbox"), "{enter}"); }); expect(onSave).toHaveBeenCalled(); @@ -143,30 +194,61 @@ describe("EditInPlace", () => { it("calls onCancel when cancel button pressed", async () => { const onCancel = vi.fn(); - render(); + const { getByRole } = render(); - await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + await act(async () => { + await userEvent.type(getByRole("textbox"), "Changed"); + await userEvent.click(getByRole("button", { name: "Cancel" })); + }); + + expect(onCancel).toHaveBeenCalled(); + }); + + it("resets the form when cancel button pressed", async () => { + const onCancel = vi.fn(); + const { getByRole } = render( + , + ); + + const input = getByRole("textbox") as HTMLInputElement; + + await act(async () => { + await userEvent.clear(input); + await userEvent.type(input, "Changed"); + }); + + expect(input.value).toBe("Changed"); + + await act(async () => { + await userEvent.click(getByRole("button", { name: "Cancel" })); + }); + + expect(input.value).toBe("Initial"); expect(onCancel).toHaveBeenCalled(); }); it("unfocuses the input when cancel button pressed", async () => { - render(); + const { getByRole } = render(); - const input = screen.getByRole("textbox"); - await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + const input = getByRole("textbox"); + await act(async () => { + await userEvent.type(input, "Changed"); + await userEvent.click(getByRole("button", { name: "Cancel" })); + }); expect(document.activeElement).not.toEqual(input); }); - it("unfocuses the input when the save calllback promise resolves", async () => { - render(); + it("unfocuses the input when the save callback promise resolves", async () => { + const { getByRole } = render(); + const input = getByRole("textbox"); await act(async () => { - await userEvent.click(screen.getByRole("button", { name: "Save" })); + await userEvent.type(input, "Changed"); + await userEvent.click(getByRole("button", { name: "Save" })); }); - const input = screen.getByRole("textbox"); expect(document.activeElement).not.toEqual(input); }); @@ -174,42 +256,51 @@ describe("EditInPlace", () => { vi.useFakeTimers(); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - render(); + const { getByRole, getByText, queryByText, asFragment } = render( + , + ); - expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + expect(queryByText("Saved")).not.toBeInTheDocument(); await act(async () => { - await user.click(screen.getByRole("button", { name: "Save" })); + await user.type(getByRole("textbox"), "Changed"); + await user.click(getByRole("button", { name: "Save" })); }); - expect(screen.getByText("Saved")).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + expect(getByText("Saved")).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(1900); }); - expect(screen.queryByText("Saved")).toBeInTheDocument(); + expect(queryByText("Saved")).toBeInTheDocument(); act(() => { vi.advanceTimersByTime(200); }); - expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + expect(queryByText("Saved")).not.toBeInTheDocument(); }); it("does not call onSave if cancel pressed", async () => { const onSave = vi.fn(); - render(); + const { getByRole } = render(); - await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + await act(async () => { + await userEvent.type(getByRole("textbox"), "Changed"); + await userEvent.click(getByRole("button", { name: "Cancel" })); + }); expect(onSave).not.toHaveBeenCalled(); }); it("disables control when disabled", () => { - render(); + const { getByRole, asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); - const input = screen.getByRole("textbox"); + const input = getByRole("textbox"); expect(input).toBeDisabled(); }); }); diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx index cb988f55..20c143fc 100644 --- a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx @@ -14,16 +14,31 @@ See the License for the specific language governing permissions and limitations under the License. */ -import classnames from "classnames"; -import React, { FormEvent, forwardRef, useCallback, useRef, JSX } from "react"; -import styles from "./EditInPlace.module.css"; -import { TextInput } from "../Text"; -import useId from "../../../../utils/useId"; - +import React, { + forwardRef, + useCallback, + useRef, + useState, + useEffect, + useReducer, +} from "react"; +import { Submit } from "@radix-ui/react-form"; import CheckIcon from "@vector-im/compound-design-tokens/icons/check.svg"; import CancelIcon from "@vector-im/compound-design-tokens/icons/close.svg"; -import ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg"; -import { InlineSpinner } from "../../../InlineSpinner/InlineSpinner"; + +import styles from "./EditInPlace.module.css"; + +import { + ErrorMessage, + Field, + HelpMessage, + Label, + LoadingMessage, + Root, + SuccessMessage, + TextControl, +} from "../.."; +import { Button, Tooltip } from "../../../.."; type Props = { /** @@ -37,19 +52,19 @@ type Props = { className?: string; /** - * The content of the text box + * Callback for when the user confirms the change */ - value: string; + onSave?: (e: React.FormEvent) => Promise | void; /** - * Callback for when the user confirms the change + * Callback for when the user wishes to cancel the change */ - onSave: () => Promise; + onCancel?: (e: React.FormEvent) => void; /** - * Calback for when the user wishes to cancel the change + * onInput event handler on the text control */ - onCancel: () => void; + onInput?: (e: React.ChangeEvent) => void; /** * Error message to be displayed below the box. If supplied, will disable the @@ -73,16 +88,10 @@ type Props = { */ savingLabel: string; - /** - * True to disable the save button, false to enable. - * Default: false (enabled) - */ - disableSaveButton?: boolean; - /** * The label for the cancel button */ - cancelButtonLabel?: string; + cancelButtonLabel: string; /** * Label to be displayed under the input as a help text @@ -93,7 +102,62 @@ type Props = { * If true, disabled the entire component to disallow editing. */ disabled?: boolean; -} & React.ComponentProps; +} & React.ComponentProps; + +enum State { + /** No changes on the input has been made */ + Initial, + + /** The input has been changed */ + Dirty, + + /** The input is being saved */ + Saving, + + /** The input has been saved */ + Saved, +} + +enum Event { + Touch, // The user 'touched' the control + Save, // The user has clicked the save button + Saved, // The onSave callback finished successfully + SaveError, // The onSave callback finished with an error + Cancel, // The user has clicked the cancel button + SavedTimeout, // The user has clicked the save button and the saved label has been shown for 2 seconds +} + +function reducer(state: State, action: Event): State { + switch (action) { + case Event.Touch: + if (state === State.Initial || state === State.Saved) return State.Dirty; + else return state; + + case Event.Save: + return State.Saving; + + case Event.Cancel: + return State.Initial; + + case Event.Saved: + if (state === State.Saving) return State.Saved; + else return state; + + case Event.SaveError: + if (state === State.Saving) return State.Initial; + else return state; + + case Event.SavedTimeout: + if (state === State.Saved) return State.Initial; + else return state; + } + + assertNever(action); +} + +function assertNever(value: never): never { + throw new Error(`Unreachable value: ${value}`); +} /** * A text box with save/cancel buttons that appear when the field is active. @@ -107,9 +171,9 @@ export const EditInPlace = forwardRef( label, onSave, onCancel, + onInput, saveButtonLabel, cancelButtonLabel, - disableSaveButton, error, savedLabel, savingLabel, @@ -119,231 +183,163 @@ export const EditInPlace = forwardRef( }, ref, ) { - const id = useId(); - const labelId = useId(); - const errorTextId = useId(); + const [state, dispatch] = useReducer(reducer, State.Initial); - const [showSaved, setShowSaved] = React.useState(false); - const [saving, setSaving] = React.useState(false); + // Tracks the focus state of the form + // This uses a `ref` to make sure the onFocus/onBlur callback don't trigger unnecessary re-renders + // and a state to track the focus state and hide the buttons when the form is not focused + const isFocusWithinRef = useRef(false); + const [isFocusWithin, setFocusWithin] = useState(false); - const classes = classnames(styles.container, className, { - [styles["container-error"]]: Boolean(error), - [styles["container-show-buttons"]]: saving, - }); + const shouldShowSaveButton = + state === State.Dirty || state === State.Saving || isFocusWithin; - const hideTimer = useRef(null); + const hideTimer = useRef>(); - const saveButtonRef = useRef(null); - const cancelButtonRef = useRef(null); + useEffect(() => { + // Start a timer when we switch to the saved state + if (state === State.Saved) { + hideTimer.current = setTimeout(() => { + dispatch(Event.SavedTimeout); + hideTimer.current = undefined; + }, 2000); + } - React.useEffect(() => { return () => { + // Clear any timers that may have been set if (hideTimer.current) clearTimeout(hideTimer.current); + hideTimer.current = undefined; }; - }, []); + }, [state]); + + const formRef = useRef(null); + const saveButtonRef = useRef(null); + const cancelButtonRef = useRef(null); + + const onFocus = useCallback(() => { + if (isFocusWithinRef.current) return; + isFocusWithinRef.current = true; + setFocusWithin(true); + }, [isFocusWithin, setFocusWithin]); + + const onBlur = useCallback( + (e: React.FocusEvent) => { + if (!isFocusWithinRef.current) return; + // If the user switched to another element within the form + // consider that we're still focused within the form + if (e.currentTarget.contains(e.relatedTarget)) return; + + isFocusWithinRef.current = false; + setFocusWithin(false); + }, + [isFocusWithin, setFocusWithin], + ); + + const onInputHandler = useCallback( + (e: React.ChangeEvent) => { + dispatch(Event.Touch); + onInput?.(e); + }, + [dispatch, onInput], + ); const onFormSubmit = useCallback( - async (e: FormEvent) => { + async (e: React.FormEvent) => { e.preventDefault(); + + // Prevent submitting the form if the user has not yet entered any text + if (state === State.Initial) { + return; + } + try { - setSaving(true); - await onSave(); + dispatch(Event.Save); saveButtonRef.current?.blur(); - setShowSaved(true); - hideTimer.current = setTimeout(() => { - setShowSaved(false); - }, 2000); + await onSave?.(e); + dispatch(Event.Saved); } catch (e) { // We don't really need to do anything here, we just don't want to display the // 'saved' label, obviously. The user of the component can update the error to // show what failed. - } finally { - setSaving(false); + dispatch(Event.SaveError); } }, - [setShowSaved, onSave, hideTimer], + [onSave, state, hideTimer], ); - const onCancelButtonClicked = useCallback(() => { - cancelButtonRef.current?.blur(); - onCancel(); - }, [cancelButtonRef, onCancel]); + const onFormReset = useCallback( + (e: React.FormEvent) => { + cancelButtonRef.current?.blur(); + onCancel?.(e); + dispatch(Event.Cancel); + }, + [cancelButtonRef, onCancel], + ); return ( -
-
- {label} -
-
- -
- - + + + +
+ + + {shouldShowSaveButton && ( +
+ + +
+ )}
-
- - - ); - }, -); - -/** - * The labels that appear below the input box. - */ -interface LabelsProps { - /** - * The error message to display - */ - error?: string; - /** - * The ID of the error text element - */ - errorTextId: string; - /** - * True if the form is saving - */ - saving: boolean; - /** - * The label to display while saving - */ - savingLabel?: string; - /** - * The label to display when the form has been saved - */ - savedLabel?: string; - /** - * True to show the saved label - */ - showSaved?: boolean; - /** - * The label to display as a help message - */ - helpLabel?: string; -} - -function Labels({ - error, - errorTextId, - saving, - savingLabel, - savedLabel, - showSaved, - helpLabel, -}: LabelsProps): JSX.Element { - const labels: JSX.Element[] = []; - if (error) { - labels.push( -
- - - {error} - -
, - ); - } - - if (saving) { - labels.push( -
- - - {savingLabel} - -
, - ); - } - - if (savedLabel && showSaved) { - labels.push( -
-
{error}} + {state === State.Saving && ( + {savingLabel} )} - > - -
- {savedLabel} )} - > - {savedLabel} - -
, - ); - } - - if (labels.length === 0 && helpLabel) { - labels.push( - - {helpLabel} - , + {!error && + (state === State.Initial || state === State.Dirty) && + helpLabel && {helpLabel}} + + ); - } - - return <>{labels}; -} + }, +); diff --git a/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap b/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap index e598c5c3..6e343fa0 100644 --- a/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap +++ b/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap @@ -1,42 +1,197 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`EditInPlace > renders 1`] = ` +exports[`EditInPlace > disables control when disabled 1`] = ` -
-
+ +
+
+ +
+
+ + +`; + +exports[`EditInPlace > displays saved label for 2 seconds after save 1`] = ` + +
+
+ +
+ +
+
+
+
+`; + +exports[`EditInPlace > displays saved label for 2 seconds after save 2`] = ` + +
+
+ +
+
+ + + + + Saved + +
+
+
+`; + +exports[`EditInPlace > renders 1`] = ` + +
+
+
+
+
+
+
+`; + +exports[`EditInPlace > renders error icon and text if error provided 1`] = ` + +
+
+ +
+
- Help message + + + + Missing Left Falangey
diff --git a/src/components/Form/Message.tsx b/src/components/Form/Message.tsx index 46f07eaa..1e77084d 100644 --- a/src/components/Form/Message.tsx +++ b/src/components/Form/Message.tsx @@ -16,10 +16,12 @@ limitations under the License. import React, { forwardRef, PropsWithChildren } from "react"; import { Message } from "@radix-ui/react-form"; +import CheckCircleSolidIcon from "@vector-im/compound-design-tokens/icons/check-circle-solid.svg"; import ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg"; import styles from "./form.module.css"; import classNames from "classnames"; +import { InlineSpinner } from "../InlineSpinner/InlineSpinner"; type MessageProps = { /** @@ -48,6 +50,42 @@ export const ErrorMessage = forwardRef< ); }); +/** + * A success message to display below a form control. + */ +export const SuccessMessage = forwardRef< + HTMLSpanElement, + PropsWithChildren +>(function SuccessMessage({ children, className, ...props }, ref) { + const classes = classNames( + styles.message, + styles["success-message"], + className, + ); + return ( + + + {children} + + ); +}); + +/** + * A message showing a loading state + */ +export const LoadingMessage = forwardRef< + HTMLSpanElement, + PropsWithChildren +>(function LoadingMessage({ children, className, ...props }, ref) { + const classes = classNames(styles.message, className); + return ( + + + {children} + + ); +}); + /** * A help message to display below a form control. */ diff --git a/src/components/Form/form.module.css b/src/components/Form/form.module.css index 70b0c284..bdf74dff 100644 --- a/src/components/Form/form.module.css +++ b/src/components/Form/form.module.css @@ -109,6 +109,10 @@ https://developer.mozilla.org/en-US/docs/Web/CSS/:has#browser_compatibility */ color: var(--cpd-color-text-critical-primary); } +.success-message { + color: var(--cpd-color-text-success-primary); +} + /* Currently working everywhere but on Firefox (only behind a labs flag) https://developer.mozilla.org/en-US/docs/Web/CSS/:has#browser_compatibility */ input[disabled] ~ .message, diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts index d5385baf..572f908a 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -37,5 +37,10 @@ export { Field } from "./Field"; export { InlineField } from "./InlineField"; export { Label } from "./Label"; export { ValidityState, Message } from "@radix-ui/react-form"; -export { ErrorMessage, HelpMessage } from "./Message"; +export { + ErrorMessage, + HelpMessage, + LoadingMessage, + SuccessMessage, +} from "./Message"; export { Submit } from "./Submit"; diff --git a/src/components/ReleaseAnnouncement/__snapshots__/ReleaseAnnouncement.test.tsx.snap b/src/components/ReleaseAnnouncement/__snapshots__/ReleaseAnnouncement.test.tsx.snap index 5b7c4926..dea2cbb9 100644 --- a/src/components/ReleaseAnnouncement/__snapshots__/ReleaseAnnouncement.test.tsx.snap +++ b/src/components/ReleaseAnnouncement/__snapshots__/ReleaseAnnouncement.test.tsx.snap @@ -114,63 +114,6 @@ exports[`ReleaseAnnouncement > renders the component at the bottom of the trigge
`; -exports[`ReleaseAnnouncement > renders the component when closed 1`] = ` - -`; - exports[`ReleaseAnnouncement > renders with a long label header and description 1`] = `