diff --git a/package-lock.json b/package-lock.json index b9e915d..cd7b35e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@storybook/addon-onboarding": "^1.0.10", "@storybook/blocks": "^7.6.7", "@storybook/react": "^7.6.7", - "@storybook/react-vite": "^8.0.9", + "@storybook/react-vite": "^8.0.0", "@storybook/testing-library": "^0.2.2", "@storybook/theming": "7.6.10", "@testing-library/jest-dom": "^6.1.3", diff --git a/src/lib/elements/LimitedInput/LimitedInput.scss b/src/lib/elements/LimitedInput/LimitedInput.scss new file mode 100644 index 0000000..48944b8 --- /dev/null +++ b/src/lib/elements/LimitedInput/LimitedInput.scss @@ -0,0 +1,15 @@ +@import "vanilla-framework"; + +.limited-input { + position: relative; + + &__wrapper::before { + position: absolute; + pointer-events: none; + padding-left: $spv--small; + padding-bottom: calc(0.4rem - 1px); + padding-top: calc(0.4rem - 1px); + content: var(--immutable, ""); + top: var(--top, "inherit"); + } +} diff --git a/src/lib/elements/LimitedInput/LimitedInput.stories.tsx b/src/lib/elements/LimitedInput/LimitedInput.stories.tsx new file mode 100644 index 0000000..28236a4 --- /dev/null +++ b/src/lib/elements/LimitedInput/LimitedInput.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { LimitedInput } from "@/lib/elements/LimitedInput/LimitedInput"; + +const meta: Meta = { + title: "elements/LimitedInput", + component: LimitedInput, + tags: ["autodocs"], +}; + +export default meta; + +export const Example: StoryObj = { + args: { + help: "The valid range for this subnet is 192.168.0.[1-254]", + label: "IP address", + immutableText: "192.168.0.", + placeholder: "[1-254]", + }, +}; diff --git a/src/lib/elements/LimitedInput/LimitedInput.test.tsx b/src/lib/elements/LimitedInput/LimitedInput.test.tsx new file mode 100644 index 0000000..5f383c6 --- /dev/null +++ b/src/lib/elements/LimitedInput/LimitedInput.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; + +import { LimitedInput } from "./LimitedInput"; + +const { getComputedStyle } = window; + +beforeAll(() => { + // getComputedStyle is not implemeneted in jsdom, so we need to do this. + window.getComputedStyle = (elt) => getComputedStyle(elt); +}); + +afterAll(() => { + // Reset to original implementation + window.getComputedStyle = getComputedStyle; +}); + +it("renders without crashing", async () => { + render(); + + expect( + screen.getByRole("textbox", { name: "Limited input" }), + ).toBeInTheDocument(); +}); + +it("sets the --immutable css variable to the provided immutable text", async () => { + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect( + screen.getByRole("textbox", { name: "Limited input" }).parentElement + ?.parentElement, + ).toHaveStyle(`--immutable: "Some text";`); +}); diff --git a/src/lib/elements/LimitedInput/LimitedInput.tsx b/src/lib/elements/LimitedInput/LimitedInput.tsx new file mode 100644 index 0000000..f499f48 --- /dev/null +++ b/src/lib/elements/LimitedInput/LimitedInput.tsx @@ -0,0 +1,61 @@ +import { RefObject, useEffect, useRef } from "react"; + +import { Input, InputProps } from "@canonical/react-components"; +import "./LimitedInput.scss"; +import classNames from "classnames"; + +export type LimitedInputProps = Omit & { + immutableText: string; +}; + +/** + * An input component with a fixed prefix that cannot be edited. + * + * @param immutableText The prefixed text that cannot be edited. + * @returns A LimitedInput component. + */ +export const LimitedInput = ({ + immutableText, + ...props +}: LimitedInputProps) => { + const limitedInputRef: RefObject = useRef(null); + + useEffect(() => { + const inputWrapper = limitedInputRef.current?.firstElementChild; + if (inputWrapper) { + if (props.label) { + // CSS variable "--immutable" is the content of the :before element, which shows the immutable octets + // "--top" is the `top` property of the :before element, which is adjusted if there is a label to prevent overlap + inputWrapper.setAttribute( + "style", + `--top: 2.5rem; --immutable: "${immutableText}"`, + ); + } else { + inputWrapper.setAttribute("style", `--immutable: "${immutableText}"`); + } + + const width = window.getComputedStyle(inputWrapper, ":before").width; + + // Adjust the left padding of the input to be the same width as the immutable octets. + // This displays the user input and the unchangeable text together as one IP address. + inputWrapper.lastElementChild?.firstElementChild?.setAttribute( + "style", + `padding-left: ${width}`, + ); + } + }, [limitedInputRef, immutableText, props.label]); + + return ( +
+ +
+ ); +}; diff --git a/src/lib/elements/LimitedInput/index.ts b/src/lib/elements/LimitedInput/index.ts new file mode 100644 index 0000000..55e5e65 --- /dev/null +++ b/src/lib/elements/LimitedInput/index.ts @@ -0,0 +1 @@ +export * from "./LimitedInput"; diff --git a/src/lib/elements/index.ts b/src/lib/elements/index.ts index d1a5d75..348c5c2 100644 --- a/src/lib/elements/index.ts +++ b/src/lib/elements/index.ts @@ -2,4 +2,5 @@ export * from "./Meter"; export * from "./ExternalLink"; export * from "./ProgressIndicator"; -export * from "./Placeholder"; \ No newline at end of file +export * from "./Placeholder"; +export * from "./LimitedInput"; \ No newline at end of file