diff --git a/playwright/components/Form/Controls/EditInPlace.test.ts b/playwright/components/Form/Controls/EditInPlace.test.ts new file mode 100644 index 00000000..f5cece0d --- /dev/null +++ b/playwright/components/Form/Controls/EditInPlace.test.ts @@ -0,0 +1,32 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { expect, test } from "@playwright/test"; + +test.describe("EditInPlace", () => { + test("should render save & cancel buttons correctly", async ({ page }) => { + await page.goto( + `iframe.html?viewMode=story&id=form-controls-editinplace--empty`, + { + waitUntil: "networkidle", + }, + ); + + await page.getByLabel("Label").focus(); + + await expect(page).toHaveScreenshot({ fullPage: true }); + }); +}); 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 new file mode 100644 index 00000000..04f21fc7 Binary files /dev/null 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 new file mode 100644 index 00000000..63caa4fd Binary files /dev/null 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-Save-Disabled-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Save-Disabled-1-chromium-linux.png new file mode 100644 index 00000000..2e25f554 Binary files /dev/null and b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-Save-Disabled-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 new file mode 100644 index 00000000..378e0992 Binary files /dev/null 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-Text-1-chromium-linux.png b/playwright/visual.test.ts-snapshots/Form-Controls-EditInPlace-With-Text-1-chromium-linux.png new file mode 100644 index 00000000..ecde4ab6 Binary files /dev/null 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 new file mode 100644 index 00000000..131f0b83 --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.module.css @@ -0,0 +1,130 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.container { + min-block-size: 110px; +} + +.label { + font-size: 15px; + font-weight: 500; + margin-block-end: 4px; +} + +.controls { + display: grid; + grid-template-columns: 1fr auto; + 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); + } +} + +.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: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); +} + +.caption-icon { + display: inline-block; + vertical-align: middle; + inline-size: 20px; + block-size: 20px; + margin-inline-end: var(--cpd-space-2x); +} + +.caption-icon-error { + color: var(--cpd-color-icon-critical-primary); +} + +.caption-icon-saved { + background-color: var(--cpd-color-icon-success-primary); + border-radius: 20px; + text-align: center; + margin-inline-end: var(--cpd-space-2x); + + svg { + color: var(--cpd-color-icon-on-solid-primary); + } +} + +.caption-text { + vertical-align: middle; + font-size: 13px; +} + +.caption-text-error { + color: var(--cpd-color-text-critical-primary); +} + +.caption-text-saved { + color: var(--cpd-color-text-success-primary); +} diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx new file mode 100644 index 00000000..ac6955a0 --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.stories.tsx @@ -0,0 +1,109 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import { EditInPlace } from "./"; +import { Meta, StoryObj } from "@storybook/react"; + +type Props = { invalid?: boolean } & React.ComponentProps; + +export default { + title: "Form/Controls/EditInPlace", + component: EditInPlace, + tags: ["autodocs"], + parameters: { + controls: { + include: [ + "onChange", + "onSave", + "onCancel", + "value", + "initialValue", + "error", + "savedLabel", + "saveButtonLabel", + "cancelButtonLabel", + ], + }, + design: { + type: "figma", + url: "https://www.figma.com/file/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?type=design&node-id=4335-2016&mode=design&t=BvxRca0YDRaq20IR-0", + }, + }, + argTypes: { + label: { + type: "string", + }, + value: { + type: "string", + }, + disableSaveButton: { + type: "string", + }, + onChange: { + action: "changed", + }, + onSave: { + action: "saved", + }, + onCancel: { + action: "cancelled", + }, + error: { + type: "string", + }, + savedLabel: { + type: "string", + }, + saveButtonLabel: { + type: "string", + }, + cancelButtonLabel: { + type: "string", + }, + }, + render: ({ ...restArgs }) => , + args: { + label: "Label", + value: "", + saveButtonLabel: "Save", + cancelButtonLabel: "Cancel", + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Empty: Story = {}; + +export const WithText: Story = { + args: { + value: "Hello, Computer", + }, +}; + +export const SaveDisabled: Story = { + args: { + value: "Hello, World", + disableSaveButton: true, + }, +}; + +export const WithError: Story = { + args: { + error: "I am a teapot", + }, +}; diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx new file mode 100644 index 00000000..4b6af325 --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.test.tsx @@ -0,0 +1,193 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +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 { EditInPlace } from "./EditInPlace"; +import { Root, Field, Control } from "@radix-ui/react-form"; +import { userEvent } from "@storybook/test"; +import { act } from "react-dom/test-utils"; + +type EditInPlaceTestProps = { + error?: string; + value?: string; + disableSaveButton?: boolean; + onChange?: (e: ChangeEvent) => void; + onSave?: () => Promise; + onCancel?: () => void; +}; + +describe("EditInPlace", () => { + const EditInPlaceTest = (props: EditInPlaceTestProps) => ( + + + + {})} + onSave={props.onSave ?? (() => Promise.resolve())} + onCancel={props.onCancel ?? (() => {})} + saveButtonLabel="Save" + cancelButtonLabel="Cancel" + savedLabel={"Saved"} + disableSaveButton={props.disableSaveButton} + /> + + + + ); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders", () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("calls onChange when text edited", async () => { + let value; + const onChange = vi + .fn() + .mockImplementation((e: ChangeEvent) => { + value = e.target.value; + }); + render(); + + await act(async () => { + const input = screen.getByRole("textbox"); + // nb. we don't test updating the value here so we only type one character + await userEvent.type(input, "!"); + }); + + expect(onChange).toHaveBeenCalled(); + expect(value).toEqual("Edit this text!"); + }); + + it("field is valid if no error specified", () => { + render(); + + const input = screen.getByRole("textbox"); + expect(input).toBeValid(); + }); + + it("renders error icon and text if error provided", () => { + render(); + + const input = screen.getByRole("textbox"); + expect(input).toBeInvalid(); + expect(input).toHaveAttribute("aria-errormessage"); + + const errorText = screen.getByText("Missing Left Falangey"); + expect(errorText).toBeInTheDocument(); + + expect(errorText.id).toEqual(input.getAttribute("aria-errormessage")); + }); + + it("should disable save button if told to", () => { + render(); + + const saveButton = screen.getByRole("button", { name: "Save" }); + expect(saveButton).toBeDisabled(); + }); + + it("enables save button by default", () => { + render(); + + expect(screen.getByRole("button", { name: "Save" })).toBeEnabled(); + }); + + it("calls save callback on save button click", async () => { + const onSave = vi.fn(); + render(); + + await act(async () => { + await userEvent.click(screen.getByRole("button", { name: "Save" })); + }); + + expect(onSave).toHaveBeenCalled(); + }); + + it("calls onCancel when cancel button pressed", async () => { + const onCancel = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onCancel).toHaveBeenCalled(); + }); + + it("unfocuses the input when cancel button pressed", async () => { + render(); + + const input = screen.getByRole("textbox"); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(document.activeElement).not.toEqual(input); + }); + + it("unfocuses the input when the save calllback promise resolves", async () => { + render(); + + await act(async () => { + await userEvent.click(screen.getByRole("button", { name: "Save" })); + }); + + const input = screen.getByRole("textbox"); + expect(document.activeElement).not.toEqual(input); + }); + + it("displays saved label for 2 seconds after save", async () => { + vi.useFakeTimers(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + + render(); + + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + + await act(async () => { + await user.click(screen.getByRole("button", { name: "Save" })); + }); + + expect(screen.getByText("Saved")).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(1900); + }); + + expect(screen.queryByText("Saved")).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); + }); + + it("does not call onSave if cancel pressed", async () => { + const onSave = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Form/Controls/EditInPlace/EditInPlace.tsx b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx new file mode 100644 index 00000000..ab85fd31 --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/EditInPlace.tsx @@ -0,0 +1,222 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import classnames from "classnames"; +import React, { forwardRef, useCallback, useRef } from "react"; +import styles from "./EditInPlace.module.css"; +import { TextInput } from "../Text"; +import useId from "../../../../utils/useId"; + +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"; + +type Props = { + /** + * The label for the control + */ + label: string; + + /** + * The CSS class name. + */ + className?: string; + + /** + * The content of the text box + */ + value: string; + + /** + * Callback for when the user confirms the change + */ + onSave: () => Promise; + + /** + * Calback for when the user wishes to cancel the change + */ + onCancel: () => void; + + /** + * Error message to be displayed below the box. If supplied, will disable the + * save button. + */ + error?: string; + + /** + * Label to be displayed by the green check at the bottom. Will only be displayed + * for 2 seconds after the onSave callback promise resolves successfully. + */ + savedLabel?: string; + + /** + * The label for the save button + */ + saveButtonLabel: string; + + /** + * True to disable the save button, false to enasble. + * Default: false (enabled) + */ + disableSaveButton?: boolean; + + /** + * The label for the cancel button + */ + cancelButtonLabel?: string; +} & React.ComponentProps; + +/** + * A text box with save/cancel buttons that appear when the field is active + */ +export const EditInPlace = forwardRef( + function EditInPlace( + { + className, + label, + onSave, + onCancel, + saveButtonLabel, + cancelButtonLabel, + disableSaveButton, + error, + savedLabel, + ...props + }, + ref, + ) { + const id = useId(); + const labelId = useId(); + const errorTextId = useId(); + const classes = classnames(styles.container, className, { + [styles["container-error"]]: Boolean(error), + }); + + const [showSaved, setShowSaved] = React.useState(false); + + const hideTimer = useRef(null); + + const saveButtonRef = useRef(null); + const cancelButtonRef = useRef(null); + + React.useEffect(() => { + return () => { + if (hideTimer.current) clearTimeout(hideTimer.current); + }; + }, []); + + const onSaveButonClicked = useCallback(async () => { + try { + await onSave(); + saveButtonRef.current?.blur(); + setShowSaved(true); + hideTimer.current = setTimeout(() => { + setShowSaved(false); + }, 2000); + } 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. + } + }, [setShowSaved, onSave, hideTimer]); + + const onCancelButtonClicked = useCallback(() => { + cancelButtonRef.current?.blur(); + onCancel(); + }, [cancelButtonRef, onCancel]); + + return ( +
+
+ {label} +
+
+ +
+ + +
+
+ {error && ( +
+ + + {error} + +
+ )} + {savedLabel && showSaved && ( +
+
+ +
+ + {savedLabel} + +
+ )} +
+ ); + }, +); diff --git a/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap b/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap new file mode 100644 index 00000000..015fa03a --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/__snapshots__/EditInPlace.test.tsx.snap @@ -0,0 +1,149 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`EditInPlace > renders 1`] = ` + +
+
+
+
+ Edit Me +
+
+ +
+ + +
+
+
+
+
+
+`; + +exports[`PasswordControl > renders 1`] = ` + +
+
+
+
+ Edit Me +
+
+ +
+ + +
+
+
+
+
+
+`; diff --git a/src/components/Form/Controls/EditInPlace/index.ts b/src/components/Form/Controls/EditInPlace/index.ts new file mode 100644 index 00000000..7d6ffb37 --- /dev/null +++ b/src/components/Form/Controls/EditInPlace/index.ts @@ -0,0 +1,17 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { EditInPlace } from "./EditInPlace"; diff --git a/src/components/Form/Controls/index.ts b/src/components/Form/Controls/index.ts index ece5b302..cd0caf84 100644 --- a/src/components/Form/Controls/index.ts +++ b/src/components/Form/Controls/index.ts @@ -22,3 +22,4 @@ export { MFAControl, MFAInput } from "./MFA"; export { CheckboxControl, CheckboxInput } from "./Checkbox"; export { RadioControl, RadioInput } from "./Radio"; export { ToggleControl, ToggleInput } from "./Toggle"; +export { EditInPlace } from "./EditInPlace"; diff --git a/src/components/Form/index.ts b/src/components/Form/index.ts index 500aa69e..d5385baf 100644 --- a/src/components/Form/index.ts +++ b/src/components/Form/index.ts @@ -29,6 +29,7 @@ export { RadioInput, ToggleControl, ToggleInput, + EditInPlace, } from "./Controls"; export { Root } from "./Root"; diff --git a/src/index.ts b/src/index.ts index ab50fb37..8f788f3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,7 @@ export { Field, InlineField, Label, + EditInPlace, } from "./components/Form"; export * as Form from "./components/Form";