Skip to content

Commit

Permalink
feat: add InputGroup component
Browse files Browse the repository at this point in the history
  • Loading branch information
kripod committed Jul 20, 2024
1 parent 88c953b commit 315c850
Show file tree
Hide file tree
Showing 8 changed files with 239 additions and 30 deletions.
83 changes: 83 additions & 0 deletions src/components/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { clsx } from "clsx/lite";
import { forwardRef, useContext, useImperativeHandle, useRef } from "react";

import { controlClassName } from "../utils/controlClassName";
import {
InputGroupAddon,
InputGroupAddonEndContext,
InputGroupAddonStartContext,
} from "./InputGroup";

export interface InputProps
extends Omit<React.ComponentPropsWithRef<"input">, "size"> {
size?: "sm" | "md" | "lg";
shape?: "rectangle" | "pill";
}

export const Input = forwardRef(function Input(
{ size = "md", shape: shapeRaw, className, ...props }: InputProps,
ref: React.ForwardedRef<HTMLInputElement>,
) {
const localRef = useRef<HTMLInputElement>(null as never);
useImperativeHandle(ref, () => localRef.current, []);

const addonStart = useContext(InputGroupAddonStartContext);
const addonEnd = useContext(InputGroupAddonEndContext);
const shape =
shapeRaw ?? (addonStart != null || addonEnd != null ? "pill" : "rectangle");

return (
<>
{addonStart != null ? (
<InputGroupAddon
className={clsx(
"justify-self-start",
size === "sm" && "px-2.5 pe-1",
size === "md" && "px-3 pe-1.5",
size === "lg" && "px-4 pe-2",
)}
onWidthChange={(value) => {
localRef.current.style.setProperty(
"padding-inline-start",
`${value}px`,
);
}}
>
{addonStart}
</InputGroupAddon>
) : null}

<input
ref={localRef}
className={clsx(
className,
controlClassName({ size, shape }),
"text-ui-neutral-950 placeholder:text-ui-neutral-950/65 aria-invalid:ring-2 aria-invalid:ring-inset aria-invalid:ring-ui-danger-600",
size === "sm" && "px-2.5",
size === "md" && "px-3",
size === "lg" && "px-4",
)}
{...props}
/>

{addonEnd != null ? (
<InputGroupAddon
className={clsx(
"justify-self-end",
size === "sm" && "px-2.5 ps-1",
size === "md" && "px-3 ps-1.5",
size === "lg" && "px-4 ps-2",
)}
onWidthChange={(value) => {
localRef.current.style.setProperty(
"padding-inline-end",
`${value}px`,
);
}}
>
{addonEnd}
</InputGroupAddon>
) : null}
</>
);
});
23 changes: 6 additions & 17 deletions src/components/InputFilled.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,19 @@
import { clsx } from "clsx/lite";
import { forwardRef } from "react";

import { controlClassName } from "../utils/controlClassName";
import { Input, type InputProps } from "./Input";

export interface InputFilledProps
extends Omit<React.ComponentPropsWithRef<"input">, "size"> {
size?: "sm" | "md" | "lg";
shape?: "rectangle" | "pill";
placeholder: string;
}
export interface InputFilledProps extends InputProps {}

export const InputFilled = forwardRef(function InputFilled(
{ size = "md", shape = "rectangle", className, ...props }: InputFilledProps,
{ size = "md", className, ...props }: InputFilledProps,
ref: React.ForwardedRef<HTMLInputElement>,
) {
return (
<input
<Input
ref={ref}
className={clsx(
className,
controlClassName({ size, shape }),
"bg-ui-neutral-200 text-ui-neutral-950 placeholder:text-ui-neutral-950/65 aria-invalid:ring-2 aria-invalid:ring-inset aria-invalid:ring-ui-danger-600",
size === "sm" && "px-2.5",
size === "md" && "px-3",
size === "lg" && "px-4",
)}
size={size}
className={clsx(className, "bg-ui-neutral-200")}
{...props}
/>
);
Expand Down
32 changes: 32 additions & 0 deletions src/components/InputGroup.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { MagnifyingGlassIcon } from "@heroicons/react/24/solid";
import type { Meta, StoryObj } from "@storybook/react";

import { ButtonSecondary } from "./ButtonSecondary";
import { InputGroup } from "./InputGroup";
import { InputOutlined } from "./InputOutlined";

const meta = {
component: InputGroup,
subcomponents: {
InputOutlined: InputOutlined as React.ComponentType<unknown>,
},
} satisfies Meta<typeof InputGroup>;

export default meta;
type Story = StoryObj<typeof meta>;

export const WithInputOutlined = {
args: {
addonStart: <MagnifyingGlassIcon className="size-6" />,
addonEnd: (
<ButtonSecondary size="sm" shape="pill">
Go
</ButtonSecondary>
),
},
render: (args) => (
<InputGroup {...args}>
<InputOutlined size="lg" shape="pill" placeholder="Search…" />
</InputGroup>
),
} satisfies Story;
72 changes: 72 additions & 0 deletions src/components/InputGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { clsx } from "clsx/lite";
import { createContext, useRef } from "react";

import { useResizeObserver } from "../hooks/useResizeObserver";

export interface InputGroupProps {
addonStart?: React.ReactNode;
addonEnd?: React.ReactNode;
disabled?: boolean;
children?: React.ReactNode;
}

export const InputGroupAddonStartContext =
createContext<InputGroupProps["addonStart"]>(null);

export const InputGroupAddonEndContext =
createContext<InputGroupProps["addonEnd"]>(null);

export function InputGroup({
addonStart,
addonEnd,
disabled,
children,
}: InputGroupProps) {
return (
<InputGroupAddonStartContext.Provider value={addonStart}>
<InputGroupAddonEndContext.Provider value={addonEnd}>
<fieldset
disabled={disabled}
className={clsx(
"inline-grid items-center *:col-start-1 *:row-start-1",
)}
>
{children}
</fieldset>
</InputGroupAddonEndContext.Provider>
</InputGroupAddonStartContext.Provider>
);
}

export interface InputGroupAddonProps {
className?: string;
children?: React.ReactNode;
onWidthChange: (value: number) => void;
}

export function InputGroupAddon({
className,
children,
onWidthChange,
}: InputGroupAddonProps) {
const ref = useRef<HTMLSpanElement>(null);
useResizeObserver(ref, (entry) => {
onWidthChange(
// TODO: Remove fallback once most browsers support `borderBoxSize`
entry.borderBoxSize?.[0]?.inlineSize ??
entry.target.getBoundingClientRect().width,
);
});

return (
<span
ref={ref}
className={clsx(
className,
"pointer-events-none z-10 *:pointer-events-auto",
)}
>
{children}
</span>
);
}
19 changes: 6 additions & 13 deletions src/components/InputOutlined.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
import { clsx } from "clsx/lite";
import { forwardRef } from "react";

import { controlClassName } from "../utils/controlClassName";
import { Input, type InputProps } from "./Input";

export interface InputOutlinedProps
extends Omit<React.ComponentPropsWithRef<"input">, "size"> {
size?: "sm" | "md" | "lg";
shape?: "rectangle" | "pill";
}
export interface InputOutlinedProps extends InputProps {}

export const InputOutlined = forwardRef(function InputOutlined(
{ size = "md", shape = "rectangle", className, ...props }: InputOutlinedProps,
{ size = "md", className, ...props }: InputOutlinedProps,
ref: React.ForwardedRef<HTMLInputElement>,
) {
return (
<input
<Input
ref={ref}
size={size}
className={clsx(
className,
controlClassName({ size, shape }),
"bg-ui-neutral-50 text-ui-neutral-950 ring-1 ring-inset ring-ui-neutral-600 placeholder:text-ui-neutral-950/65 aria-invalid:ring-2 aria-invalid:ring-ui-danger-600",
size === "sm" && "px-2.5",
size === "md" && "px-3",
size === "lg" && "px-4",
"bg-ui-neutral-50 ring-1 ring-inset ring-ui-neutral-600",
)}
{...props}
/>
Expand Down
15 changes: 15 additions & 0 deletions src/hooks/useEffectEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useCallback, useEffect, useRef } from "react";

export function useEffectEvent<T, A extends unknown[]>(
callback: (...args: A) => T,
): typeof callback {
const ref = useRef<typeof callback>(() => {
throw new Error("Cannot call an event handler while rendering");
});

useEffect(() => {
ref.current = callback;
});

return useCallback((...args) => ref.current(...args), []);
}
24 changes: 24 additions & 0 deletions src/hooks/useResizeObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect } from "react";

import { useEffectEvent } from "./useEffectEvent";

export function useResizeObserver(
ref: React.RefObject<Element>,
callback: (entry: ResizeObserverEntry) => void,
) {
const handleCallback = useEffectEvent(callback);
useEffect(() => {
if (ref.current != null) {
const resizeObserver = new ResizeObserver(([entry]) => {
if (entry != null) {
handleCallback(entry);
}
});
resizeObserver.observe(ref.current, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}
return () => {};
}, [handleCallback, ref]);
}
1 change: 1 addition & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
type ButtonTertiaryProps,
} from "./components/ButtonTertiary";
export { InputFilled, type InputFilledProps } from "./components/InputFilled";
export { InputGroup, type InputGroupProps } from "./components/InputGroup";
export {
InputOutlined,
type InputOutlinedProps,
Expand Down

0 comments on commit 315c850

Please sign in to comment.