diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index b88b375..3ab77aa 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -26,8 +26,8 @@ import type * as seed from "../seed.js"; import type * as topics from "../topics.js"; import type * as userFormData from "../userFormData.js"; import type * as userQuests from "../userQuests.js"; -import type * as users from "../users.js"; import type * as userSettings from "../userSettings.js"; +import type * as users from "../users.js"; import type * as validators from "../validators.js"; /** @@ -52,8 +52,8 @@ declare const fullApi: ApiFromModules<{ topics: typeof topics; userFormData: typeof userFormData; userQuests: typeof userQuests; - users: typeof users; userSettings: typeof userSettings; + users: typeof users; validators: typeof validators; }>; export declare const api: FilterApi< diff --git a/package.json b/package.json index 06c3cc9..a281d67 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,9 @@ "@auth/core": "0.37.4", "@convex-dev/auth": "^0.0.77", "@faker-js/faker": "^9.3.0", + "@maskito/core": "^3.2.0", + "@maskito/react": "^3.2.0", + "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-router": "^1.90.0", "@tiptap/extension-blockquote": "^2.10.3", "@tiptap/extension-bold": "^2.10.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09960b4..86a74e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,15 @@ importers: '@faker-js/faker': specifier: ^9.3.0 version: 9.3.0 + '@maskito/core': + specifier: ^3.2.0 + version: 3.2.0 + '@maskito/react': + specifier: ^3.2.0 + version: 3.2.0(@maskito/core@3.2.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@tailwindcss/container-queries': + specifier: ^0.1.1 + version: 0.1.1(tailwindcss@3.4.16) '@tanstack/react-router': specifier: ^1.90.0 version: 1.90.0(@tanstack/router-generator@1.87.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -1221,6 +1230,16 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true + '@maskito/core@3.2.0': + resolution: {integrity: sha512-c8GNwuz4PQmZqf5CSXIncwIkZSwBeU2FZtpBJtD21DaIe4uBQ541C0BncHMqEbLP2+n+HyFKEnT90m03BBlGrw==} + + '@maskito/react@3.2.0': + resolution: {integrity: sha512-njL/Pmc2cSU9fC3FaX5iwesXaBBdgZH/xgTqyEhc1UCRP3zN1me2XFww0FK2LmdFK76wSQ2MC5ggFXKvP3rzTw==} + peerDependencies: + '@maskito/core': ^3.2.0 + react: '>=16.8' + react-dom: '>=16.8' + '@node-rs/argon2-android-arm-eabi@1.7.0': resolution: {integrity: sha512-udDqkr5P9E+wYX1SZwAVPdyfYvaF4ry9Tm+R9LkfSHbzWH0uhU6zjIwNRp7m+n4gx691rk+lqqDAIP8RLKwbhg==} engines: {node: '>= 10'} @@ -2258,6 +2277,11 @@ packages: '@swc/types@0.1.17': resolution: {integrity: sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==} + '@tailwindcss/container-queries@0.1.1': + resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==} + peerDependencies: + tailwindcss: '>=3.2.0' + '@tanstack/history@1.90.0': resolution: {integrity: sha512-riNhDGm+fAwxgZRJ0J/36IZis1UDHsDCNIxfEodbw6BgTWJr0ah+G20V4HT91uBXiCqYFvX3somlfTLhS5yHDA==} engines: {node: '>=12'} @@ -6242,6 +6266,14 @@ snapshots: - supports-color optional: true + '@maskito/core@3.2.0': {} + + '@maskito/react@3.2.0(@maskito/core@3.2.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@maskito/core': 3.2.0 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + '@node-rs/argon2-android-arm-eabi@1.7.0': optional: true @@ -7633,6 +7665,10 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.16)': + dependencies: + tailwindcss: 3.4.16 + '@tanstack/history@1.90.0': {} '@tanstack/react-router@1.90.0(@tanstack/router-generator@1.87.7)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': diff --git a/src/components/common/Button/Button.tsx b/src/components/common/Button/Button.tsx index a79ed06..b935f5c 100644 --- a/src/components/common/Button/Button.tsx +++ b/src/components/common/Button/Button.tsx @@ -11,7 +11,7 @@ export interface ButtonProps extends AriaButtonProps { children?: React.ReactNode; icon?: LucideIcon; variant?: "primary" | "secondary" | "destructive" | "icon" | "ghost"; - size?: "small" | "medium"; + size?: "small" | "medium" | "large"; } export const buttonStyles = tv({ @@ -29,6 +29,7 @@ export const buttonStyles = tv({ size: { small: "h-8 px-2", medium: "h-10 px-3", + large: "h-12 px-3.5 text-lg", }, isDisabled: { false: "cursor-pointer", @@ -73,7 +74,12 @@ export function Button({ }), )} > - {Icon && } + {Icon && ( + + )} {children} ); diff --git a/src/components/common/Field/Field.tsx b/src/components/common/Field/Field.tsx index f936bab..1ea207d 100644 --- a/src/components/common/Field/Field.tsx +++ b/src/components/common/Field/Field.tsx @@ -1,14 +1,14 @@ import { composeTailwindRenderProps, focusRing } from "@/components/utils"; import { FieldError as AriaFieldError, + type GroupProps as AriaGroupProps, Input as AriaInput, + type InputProps as AriaInputProps, Label as AriaLabel, + type LabelProps as AriaLabelProps, TextArea as AriaTextArea, type FieldErrorProps, Group, - type GroupProps, - type InputProps, - type LabelProps, Text, type TextAreaProps, type TextProps, @@ -17,14 +17,28 @@ import { import { twMerge } from "tailwind-merge"; import { tv } from "tailwind-variants"; -export function Label(props: LabelProps) { +interface LabelProps extends AriaLabelProps { + size?: "medium" | "large"; +} + +const labelStyles = tv({ + base: "text-sm text-gray-dim cursor-default w-fit", + variants: { + size: { + medium: "text-sm", + large: "text-base", + }, + }, + defaultVariants: { + size: "medium", + }, +}); + +export function Label({ size, ...props }: LabelProps) { return ( ); } @@ -68,29 +82,58 @@ export const fieldBorderStyles = tv({ export const fieldGroupStyles = tv({ extend: focusRing, - base: "group h-10 flex items-center bg-gray-subtle forced-colors:bg-[Field] border rounded-lg overflow-hidden", - variants: fieldBorderStyles.variants, + base: "group flex items-center bg-gray-subtle forced-colors:bg-[Field] border rounded-lg overflow-hidden", + variants: { + ...fieldBorderStyles.variants, + size: { + medium: "h-10", + large: "h-12", + }, + }, + defaultVariants: { + size: "medium", + }, }); -export function FieldGroup(props: GroupProps) { +interface GroupProps extends AriaGroupProps { + size?: "medium" | "large"; +} + +export function FieldGroup({ size, ...props }: GroupProps) { return ( - fieldGroupStyles({ ...renderProps, className }), + fieldGroupStyles({ ...renderProps, size, className }), )} /> ); } -export const inputStyles = - "px-3 h-10 flex-1 min-w-0 outline outline-0 bg-transparent text-gray-normal disabled:text-gray-dim"; +interface InputProps extends Omit { + size?: "medium" | "large"; +} -export function Input(props: InputProps) { +export const inputStyles = tv({ + base: "flex-1 min-w-0 outline outline-0 bg-transparent text-gray-normal disabled:text-gray-dim", + variants: { + size: { + medium: "px-3 h-10", + large: "px-3.5 h-12 text-lg", + }, + }, + defaultVariants: { + size: "medium", + }, +}); + +export function Input({ size, ...props }: InputProps) { return ( + inputStyles({ ...renderProps, size, className }), + )} /> ); } @@ -99,7 +142,9 @@ export function InputTextArea(props: TextAreaProps) { return ( + inputStyles({ ...renderProps, className }), + )} /> ); } diff --git a/src/components/common/Link/Link.tsx b/src/components/common/Link/Link.tsx index 17e3fc1..956bb1e 100644 --- a/src/components/common/Link/Link.tsx +++ b/src/components/common/Link/Link.tsx @@ -9,7 +9,7 @@ import { type ButtonProps, buttonStyles } from "../Button"; export interface LinkProps extends AriaLinkProps { variant?: "primary" | "secondary"; - button?: ButtonProps; + button?: Omit; } const linkStyles = tv({ diff --git a/src/components/common/Select/Select.tsx b/src/components/common/Select/Select.tsx index a62fd36..bc96618 100644 --- a/src/components/common/Select/Select.tsx +++ b/src/components/common/Select/Select.tsx @@ -1,16 +1,15 @@ -import { composeTailwindRenderProps, focusRing } from "@/components/utils"; +import { composeTailwindRenderProps } from "@/components/utils"; import { ChevronDown } from "lucide-react"; import type React from "react"; import { Select as AriaSelect, type SelectProps as AriaSelectProps, - Button, ListBox, type ListBoxItemProps, SelectValue, type ValidationResult, } from "react-aria-components"; -import { tv } from "tailwind-variants"; +import { Button } from "../Button"; import { FieldDescription, FieldError, Label } from "../Field"; import { DropdownItem, @@ -19,17 +18,10 @@ import { } from "../ListBox"; import { Popover } from "../Popover"; -const styles = tv({ - extend: focusRing, - base: "flex items-center text-start gap-4 w-full cursor-pointer border border-black/10 dark:border-white/10 rounded-lg pl-3 pr-2 py-2 min-w-[150px] transition bg-gray-ui", - variants: { - isDisabled: { - false: - "hover:bg-gray-1 pressed:bg-gray-2 dark:hover:bg-graydark-1 dark:pressed:bg-graydark-2 group-invalid:border-red- forced-colors:group-invalid:border-[Mark]", - true: "opacity-50 forced-colors:text-[GrayText] forced-colors:border-[GrayText] cursor-default", - }, - }, -}); +// const selectStyles = tv({ +// extend: focusRing, +// base: "flex items-center text-start gap-4 w-full cursor-pointer min-w-[150px] transition", +// }); export interface SelectProps extends Omit, "children"> { @@ -37,6 +29,7 @@ export interface SelectProps description?: string; errorMessage?: string | ((validation: ValidationResult) => string); items?: Iterable; + size?: "medium" | "large"; children: React.ReactNode | ((item: T) => React.ReactNode); } @@ -46,6 +39,7 @@ export function Select({ errorMessage, children, items, + size = "medium", ...props }: SelectProps) { return ( @@ -56,12 +50,12 @@ export function Select({ "group flex flex-col gap-1.5", )} > - {label && {label}} - + {label && {label}} + {description && {description}} diff --git a/src/components/common/TextField/TextField.tsx b/src/components/common/TextField/TextField.tsx index 085a43e..b6a9f7b 100644 --- a/src/components/common/TextField/TextField.tsx +++ b/src/components/common/TextField/TextField.tsx @@ -1,11 +1,12 @@ import { composeTailwindRenderProps } from "@/components/utils"; import { Eye, EyeOff } from "lucide-react"; -import { useState } from "react"; +import { forwardRef, useState } from "react"; import { TextField as AriaTextField, type TextFieldProps as AriaTextFieldProps, type ValidationResult, } from "react-aria-components"; +import { twMerge } from "tailwind-merge"; import { Button } from "../Button"; import { FieldDescription, @@ -22,52 +23,67 @@ export interface TextFieldProps extends AriaTextFieldProps { prefix?: React.ReactNode; suffix?: React.ReactNode; errorMessage?: string | ((validation: ValidationResult) => string); + size?: "medium" | "large"; } -export function TextField({ - label, - description, - prefix, - suffix, - errorMessage, - ...props -}: TextFieldProps) { - const [isPasswordVisible, setIsPasswordVisible] = useState(false); +export const TextField = forwardRef( + function TextField( + { + label, + description, + prefix, + suffix, + errorMessage, + type, + size = "medium", + ...props + }, + ref, + ) { + const [isPasswordVisible, setIsPasswordVisible] = useState(false); - return ( - - {label && {label}} - - {prefix} - - {suffix} - {props.type === "password" && ( - - setIsPasswordVisible(!isPasswordVisible)} - size="small" - icon={isPasswordVisible ? Eye : EyeOff} - className="mr-1" - /> - - {isPasswordVisible ? "Hide password" : "Show password"} - - + return ( + - {description && {description}} - {errorMessage} - - ); -} + type={type === "password" && isPasswordVisible ? "text" : type} + > + {label && {label}} + + {prefix} + + {suffix} + {type === "password" && ( + + setIsPasswordVisible(!isPasswordVisible)} + size="small" + icon={isPasswordVisible ? Eye : EyeOff} + className="mr-1" + /> + + {isPasswordVisible ? "Hide password" : "Show password"} + + + )} + + {description && {description}} + {errorMessage} + + ); + }, +); diff --git a/src/components/forms/AddressField/AddressField.stories.tsx b/src/components/forms/AddressField/AddressField.stories.tsx new file mode 100644 index 0000000..8800f35 --- /dev/null +++ b/src/components/forms/AddressField/AddressField.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta } from "@storybook/react"; +import { AddressField } from "."; + +const meta: Meta = { + component: AddressField, + parameters: { + layout: "padded", + }, +}; + +export default meta; + +export const Example = (args: any) => ; + +Example.args = { + children: ( + + Children + + ), +}; diff --git a/src/components/forms/AddressField/AddressField.tsx b/src/components/forms/AddressField/AddressField.tsx new file mode 100644 index 0000000..43c9409 --- /dev/null +++ b/src/components/forms/AddressField/AddressField.tsx @@ -0,0 +1,78 @@ +import { Select, SelectItem, TextField } from "@/components/common"; +import { JURISDICTIONS, type Jurisdiction } from "@convex/constants"; +import { useMaskito } from "@maskito/react"; +import { useState } from "react"; + +interface AddressFieldProps { + children?: React.ReactNode; +} + +export function AddressField({ children }: AddressFieldProps) { + const [address, setAddress] = useState(""); + const [city, setCity] = useState(""); + const [state, setState] = useState(null); + const [zip, setZip] = useState(""); + + const maskedZIPRef = useMaskito({ + options: { + mask: [/\d/, /\d/, /\d/, /\d/, /\d/, "-", /\d/, /\d/, /\d/, /\d/], + }, + }); + + return ( + + + + + + { + setState(key as Jurisdiction); + }} + className="max-w-[22ch]" + size="large" + > + {Object.entries(JURISDICTIONS).map(([value, label]) => ( + + {label} + + ))} + + setZip(e.currentTarget.value)} + ref={maskedZIPRef} + className="max-w-[14ch]" + maxLength={10} + size="large" + /> + + + {children} + + ); +} diff --git a/src/components/forms/AddressField/index.ts b/src/components/forms/AddressField/index.ts new file mode 100644 index 0000000..0d89d44 --- /dev/null +++ b/src/components/forms/AddressField/index.ts @@ -0,0 +1 @@ +export * from "./AddressField"; diff --git a/src/components/forms/EmailField/EmailField.stories.tsx b/src/components/forms/EmailField/EmailField.stories.tsx new file mode 100644 index 0000000..2dcfe25 --- /dev/null +++ b/src/components/forms/EmailField/EmailField.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta } from "@storybook/react"; +import { EmailField } from "."; + +const meta: Meta = { + component: EmailField, + parameters: { + layout: "padded", + }, +}; + +export default meta; + +export const Example = (args: any) => ; + +Example.args = { + children: ( + + Children + + ), +}; diff --git a/src/components/forms/EmailField/EmailField.test.tsx b/src/components/forms/EmailField/EmailField.test.tsx new file mode 100644 index 0000000..677c07f --- /dev/null +++ b/src/components/forms/EmailField/EmailField.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { EmailField } from "./EmailField"; + +describe("EmailField", () => { + it("renders email input field", () => { + render(); + + const emailInput = screen.getByLabelText("Email"); + + expect(emailInput).toBeInTheDocument(); + expect(emailInput).toHaveAttribute("type", "email"); + expect(emailInput).toHaveAttribute("name", "email"); + expect(emailInput).toHaveAttribute("autocomplete", "email"); + }); + + it("allows entering an email address", async () => { + render(); + + const emailInput: HTMLInputElement = screen.getByLabelText("Email"); + + await userEvent.type(emailInput, "user@example.com"); + expect(emailInput.value).toBe("user@example.com"); + }); + + it("supports optional children", () => { + render( + + Additional Info + , + ); + + const childComponent = screen.getByTestId("child-component"); + expect(childComponent).toBeInTheDocument(); + }); +}); diff --git a/src/components/forms/EmailField/EmailField.tsx b/src/components/forms/EmailField/EmailField.tsx new file mode 100644 index 0000000..d57a681 --- /dev/null +++ b/src/components/forms/EmailField/EmailField.tsx @@ -0,0 +1,20 @@ +import { TextField } from "@/components/common"; + +interface EmailFieldProps { + children?: React.ReactNode; +} + +export function EmailField({ children }: EmailFieldProps) { + return ( + + + {children} + + ); +} diff --git a/src/components/forms/EmailField/index.ts b/src/components/forms/EmailField/index.ts new file mode 100644 index 0000000..b76869a --- /dev/null +++ b/src/components/forms/EmailField/index.ts @@ -0,0 +1 @@ +export * from "./EmailField"; diff --git a/src/components/forms/FormQuestion/FormQuestion.stories.tsx b/src/components/forms/FormQuestion/FormQuestion.stories.tsx new file mode 100644 index 0000000..8ac65c7 --- /dev/null +++ b/src/components/forms/FormQuestion/FormQuestion.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta } from "@storybook/react"; +import { FormQuestion } from "."; + +const meta: Meta = { + component: FormQuestion, + argTypes: { + title: { + control: { type: "text" }, + }, + description: { + control: { type: "text" }, + }, + }, +}; + +export default meta; + +export const Example = (args: any) => ; + +Example.args = { + title: "What is your name?", + description: + "This is your current legal name, exactly as it appears on your ID.", + children: ( + + Children + + ), +}; diff --git a/src/components/forms/FormQuestion/FormQuestion.test.tsx b/src/components/forms/FormQuestion/FormQuestion.test.tsx new file mode 100644 index 0000000..0df0f4b --- /dev/null +++ b/src/components/forms/FormQuestion/FormQuestion.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FormQuestion } from "./FormQuestion"; + +describe("FormQuestion", () => { + it("renders title correctly", () => { + const testTitle = "What is your legal name?"; + render( + + Form Content + , + ); + + const titleElement = screen.getByText(testTitle); + expect(titleElement).toBeInTheDocument(); + expect(titleElement).toHaveClass("text-2xl"); + expect(titleElement).toHaveClass("font-semibold"); + expect(titleElement).toHaveClass("text-gray-normal"); + }); + + it("renders optional description", () => { + const testTitle = "What is your legal name?"; + const testDescription = "Type your name exactly as it appears on your ID."; + + render( + + Form Content + , + ); + + const descriptionElement = screen.getByText(testDescription); + expect(descriptionElement).toBeInTheDocument(); + expect(descriptionElement).toHaveClass("text-base"); + expect(descriptionElement).toHaveClass("text-gray-dim"); + }); + + it("does not render description when not provided", () => { + const testTitle = "Basic Information"; + + render( + + Form Content + , + ); + + const titleElement = screen.getByText(testTitle); + expect(titleElement).toBeInTheDocument(); + + const descriptionQuery = screen.queryByRole("paragraph"); + expect(descriptionQuery).toBeNull(); + }); + + it("renders children correctly", () => { + render( + + + , + ); + + const childElement = screen.getByTestId("test-input"); + expect(childElement).toBeInTheDocument(); + }); +}); diff --git a/src/components/forms/FormQuestion/FormQuestion.tsx b/src/components/forms/FormQuestion/FormQuestion.tsx new file mode 100644 index 0000000..552769a --- /dev/null +++ b/src/components/forms/FormQuestion/FormQuestion.tsx @@ -0,0 +1,23 @@ +import { Heading } from "react-aria-components"; + +interface FormQuestionProps { + title: string; + description?: string; + children: React.ReactNode; +} + +export function FormQuestion({ + title, + description, + children, +}: FormQuestionProps) { + return ( + + + {title} + + {description && {description}} + {children} + + ); +} diff --git a/src/components/forms/FormQuestion/index.ts b/src/components/forms/FormQuestion/index.ts new file mode 100644 index 0000000..2189148 --- /dev/null +++ b/src/components/forms/FormQuestion/index.ts @@ -0,0 +1 @@ +export * from "./FormQuestion"; diff --git a/src/components/forms/NameField/NameField.stories.tsx b/src/components/forms/NameField/NameField.stories.tsx new file mode 100644 index 0000000..5d5fed9 --- /dev/null +++ b/src/components/forms/NameField/NameField.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta } from "@storybook/react"; +import { NameField } from "."; + +const meta: Meta = { + component: NameField, + parameters: { + layout: "padded", + }, +}; + +export default meta; + +export const Example = (args: any) => ; + +Example.args = { + children: ( + + Children + + ), +}; diff --git a/src/components/forms/NameField/NameField.test.tsx b/src/components/forms/NameField/NameField.test.tsx new file mode 100644 index 0000000..e722704 --- /dev/null +++ b/src/components/forms/NameField/NameField.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { NameField } from "./NameField"; + +describe("NameField", () => { + it("renders all name input fields", () => { + render(); + + const firstNameInput = screen.getByLabelText("First name"); + const middleNameInput = screen.getByLabelText("Middle name"); + const lastNameInput = screen.getByLabelText("Last name"); + + expect(firstNameInput).toBeInTheDocument(); + expect(middleNameInput).toBeInTheDocument(); + expect(lastNameInput).toBeInTheDocument(); + }); + + it("has correct autocomplete attributes", () => { + render(); + + const firstNameInput = screen.getByLabelText("First name"); + const middleNameInput = screen.getByLabelText("Middle name"); + const lastNameInput = screen.getByLabelText("Last name"); + + expect(firstNameInput).toHaveAttribute("autocomplete", "given-name"); + expect(middleNameInput).toHaveAttribute("autocomplete", "additional-name"); + expect(lastNameInput).toHaveAttribute("autocomplete", "family-name"); + }); + + it("allows entering text in all name fields", async () => { + render(); + + const firstNameInput: HTMLInputElement = + screen.getByLabelText("First name"); + const middleNameInput: HTMLInputElement = + screen.getByLabelText("Middle name"); + const lastNameInput: HTMLInputElement = screen.getByLabelText("Last name"); + + await userEvent.type(firstNameInput, "John"); + await userEvent.type(middleNameInput, "Michael"); + await userEvent.type(lastNameInput, "Doe"); + + expect(firstNameInput.value).toBe("John"); + expect(middleNameInput.value).toBe("Michael"); + expect(lastNameInput.value).toBe("Doe"); + }); + + it("supports optional children", () => { + render( + + Additional Info + , + ); + + const childComponent = screen.getByTestId("child-component"); + expect(childComponent).toBeInTheDocument(); + }); +}); diff --git a/src/components/forms/NameField/NameField.tsx b/src/components/forms/NameField/NameField.tsx new file mode 100644 index 0000000..9e72ddb --- /dev/null +++ b/src/components/forms/NameField/NameField.tsx @@ -0,0 +1,33 @@ +import { TextField } from "@/components/common"; + +interface NameFieldProps { + children?: React.ReactNode; +} + +export function NameField({ children }: NameFieldProps) { + return ( + + + + + + + {children} + + ); +} diff --git a/src/components/forms/NameField/index.ts b/src/components/forms/NameField/index.ts new file mode 100644 index 0000000..9eda0f1 --- /dev/null +++ b/src/components/forms/NameField/index.ts @@ -0,0 +1 @@ +export * from "./NameField"; diff --git a/src/components/forms/PhoneField/PhoneField.stories.tsx b/src/components/forms/PhoneField/PhoneField.stories.tsx new file mode 100644 index 0000000..c85f223 --- /dev/null +++ b/src/components/forms/PhoneField/PhoneField.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta } from "@storybook/react"; +import { PhoneField } from "."; + +const meta: Meta = { + component: PhoneField, + parameters: { + layout: "padded", + }, +}; + +export default meta; + +export const Example = (args: any) => ; + +Example.args = { + children: ( + + Children + + ), +}; diff --git a/src/components/forms/PhoneField/PhoneField.test.tsx b/src/components/forms/PhoneField/PhoneField.test.tsx new file mode 100644 index 0000000..ca4e913 --- /dev/null +++ b/src/components/forms/PhoneField/PhoneField.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { PhoneField } from "./PhoneField"; + +describe("PhoneField", () => { + it("renders the phone number input field", () => { + render(); + + const phoneInput = screen.getByLabelText("Phone number"); + expect(phoneInput).toBeInTheDocument(); + expect(phoneInput).toHaveValue(""); + expect(phoneInput).toHaveAttribute("type", "tel"); + expect(phoneInput).toHaveAttribute("name", "phone"); + expect(phoneInput).toHaveAttribute("autocomplete", "tel"); + }); + + it("allows entering and formatting a phone number", async () => { + render(); + const phoneInput = screen.getByLabelText( + "Phone number", + ) as HTMLInputElement; + await userEvent.type(phoneInput, "4567890123"); + expect(phoneInput.value).toBe("+1 (456) 789-0123"); + await userEvent.type( + phoneInput, + "{backspace}{backspace}{backspace}{backspace}", + ); + expect(phoneInput.value).toBe("+1 (456) 789"); + }); + + it("does not allow non-numeric characters", async () => { + render(); + const phoneInput = screen.getByLabelText( + "Phone number", + ) as HTMLInputElement; + await userEvent.type(phoneInput, "abcdg890jklmnop3456asdf789"); + expect(phoneInput.value).toBe("+1 (890) 345-6789"); + }); + + it("does not allow more than 10 digits", async () => { + render(); + const phoneInput = screen.getByLabelText( + "Phone number", + ) as HTMLInputElement; + await userEvent.type(phoneInput, "4567890123456789012345"); + expect(phoneInput.value).toBe("+1 (456) 789-0123"); + }); + + it("supports optional children", () => { + render( + + Additional Info + , + ); + + const childComponent = screen.getByTestId("child-component"); + expect(childComponent).toBeInTheDocument(); + }); +}); diff --git a/src/components/forms/PhoneField/PhoneField.tsx b/src/components/forms/PhoneField/PhoneField.tsx new file mode 100644 index 0000000..d352fd4 --- /dev/null +++ b/src/components/forms/PhoneField/PhoneField.tsx @@ -0,0 +1,52 @@ +import { TextField } from "@/components/common"; +import { useMaskito } from "@maskito/react"; +import type React from "react"; +import { useState } from "react"; + +interface PhoneFieldProps { + children?: React.ReactNode; +} + +export function PhoneField({ children }: PhoneFieldProps) { + const [value, setValue] = useState(""); + const maskedInputRef = useMaskito({ + options: { + mask: [ + "+", + "1", + " ", + "(", + /\d/, + /\d/, + /\d/, + ")", + " ", + /\d/, + /\d/, + /\d/, + "-", + /\d/, + /\d/, + /\d/, + /\d/, + ], + }, + }); + + return ( + + setValue(e.currentTarget.value)} + className="max-w-[20ch]" + size="large" + /> + {children} + + ); +} diff --git a/src/components/forms/PhoneField/index.ts b/src/components/forms/PhoneField/index.ts new file mode 100644 index 0000000..dee7aa8 --- /dev/null +++ b/src/components/forms/PhoneField/index.ts @@ -0,0 +1 @@ +export * from "./PhoneField"; diff --git a/src/components/forms/ShortTextField/ShortTextField.stories.tsx b/src/components/forms/ShortTextField/ShortTextField.stories.tsx new file mode 100644 index 0000000..6c51a8d --- /dev/null +++ b/src/components/forms/ShortTextField/ShortTextField.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta } from "@storybook/react"; +import { ShortTextField } from "."; + +const meta: Meta = { + component: ShortTextField, + parameters: { + layout: "padded", + }, +}; + +export default meta; + +export const Example = (args: any) => ; + +Example.args = { + label: "Custom field", + name: "customField", + description: "A custom description", + children: ( + + Children + + ), +}; diff --git a/src/components/forms/ShortTextField/ShortTextField.test.tsx b/src/components/forms/ShortTextField/ShortTextField.test.tsx new file mode 100644 index 0000000..c080dd4 --- /dev/null +++ b/src/components/forms/ShortTextField/ShortTextField.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { ShortTextField } from "./ShortTextField"; + +describe("ShortTextField", () => { + it("renders field", () => { + render(); + + const customField = screen.getByLabelText("Custom field"); + expect(customField).toBeInTheDocument(); + expect(customField).toHaveAttribute("name", "customField"); + }); + + it("supports optional description", () => { + render( + , + ); + + const description = screen.getByText("A custom description"); + expect(description).toBeInTheDocument(); + }); + + it("allows entering text", async () => { + render(); + + const customField = screen.getByLabelText("Custom field"); + await userEvent.type(customField, "Hello, world!"); + expect(customField).toHaveValue("Hello, world!"); + }); + + it("supports optional children", () => { + render( + + Additional Info + , + ); + + const childComponent = screen.getByTestId("child-component"); + expect(childComponent).toBeInTheDocument(); + }); +}); diff --git a/src/components/forms/ShortTextField/ShortTextField.tsx b/src/components/forms/ShortTextField/ShortTextField.tsx new file mode 100644 index 0000000..c61da86 --- /dev/null +++ b/src/components/forms/ShortTextField/ShortTextField.tsx @@ -0,0 +1,21 @@ +import { TextField, type TextFieldProps } from "@/components/common"; + +interface ShortTextFieldProps extends Omit { + label: string; + name: string; + children?: React.ReactNode; +} + +export function ShortTextField({ + label, + name, + children, + ...props +}: ShortTextFieldProps) { + return ( + + + {children} + + ); +} diff --git a/src/components/forms/ShortTextField/index.ts b/src/components/forms/ShortTextField/index.ts new file mode 100644 index 0000000..7084fa2 --- /dev/null +++ b/src/components/forms/ShortTextField/index.ts @@ -0,0 +1 @@ +export * from "./ShortTextField"; diff --git a/src/components/forms/index.ts b/src/components/forms/index.ts new file mode 100644 index 0000000..ee5c2b5 --- /dev/null +++ b/src/components/forms/index.ts @@ -0,0 +1,6 @@ +export * from "./AddressField"; +export * from "./EmailField"; +export * from "./FormQuestion"; +export * from "./NameField"; +export * from "./PhoneField"; +export * from "./ShortTextField"; diff --git a/src/components/quests/QuestForm/QuestForm.tsx b/src/components/quests/QuestForm/QuestForm.tsx index 3b75390..604d25d 100644 --- a/src/components/quests/QuestForm/QuestForm.tsx +++ b/src/components/quests/QuestForm/QuestForm.tsx @@ -2,8 +2,7 @@ import "survey-core/defaultV2.min.css"; import { Link } from "@/components/common"; import type { Doc, Id } from "@convex/_generated/dataModel"; -import { Pencil, Plus } from "lucide-react"; -import { Heading } from "react-aria-components"; +import { ArrowRight, Pencil, Plus } from "lucide-react"; export type QuestFormProps = { quest: Doc<"quests">; @@ -24,9 +23,9 @@ export const EditButton = ({ to: "/admin/quests/$questId/form", params: { questId }, }} - button={{ variant: "secondary" }} + button={{ variant: "secondary", size: "large" }} > - {buttonText} + {buttonText} ); }; @@ -34,27 +33,18 @@ export const EditButton = ({ export const QuestForm = ({ quest, editable }: QuestFormProps) => { if (!quest.formSchema && !editable) return null; - return ( - - - Answer questions - - We'll walk you through the steps to fill out your forms. - - - {editable ? ( - - ) : ( - - Get Started - - )} - + return editable ? ( + + ) : ( + + Get Started + + ); }; diff --git a/src/routes/_authenticated/_home/quests.$questId.edit.tsx b/src/routes/_authenticated/_home/quests.$questId.edit.tsx index 18c6976..b3102bf 100644 --- a/src/routes/_authenticated/_home/quests.$questId.edit.tsx +++ b/src/routes/_authenticated/_home/quests.$questId.edit.tsx @@ -91,9 +91,9 @@ function QuestEditRoute() { - + ); } diff --git a/src/routes/_authenticated/_home/quests.$questId.index.tsx b/src/routes/_authenticated/_home/quests.$questId.index.tsx index c3100b5..2ffb608 100644 --- a/src/routes/_authenticated/_home/quests.$questId.index.tsx +++ b/src/routes/_authenticated/_home/quests.$questId.index.tsx @@ -105,8 +105,8 @@ function QuestDetailRoute() { - + ); } diff --git a/src/styles/index.css b/src/styles/index.css index b7587ab..6866273 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -117,6 +117,14 @@ body, min-height: 100dvh; } +::selection { + @apply bg-amber-5 text-gray-12; +} + +.dark ::selection { + @apply bg-purpledark-5 text-graydark-12; +} + .svc-full-creator { flex: 1; } diff --git a/tailwind.config.ts b/tailwind.config.ts index ca39578..2eeca01 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,3 +1,4 @@ +import tailwindcssContainerQueries from "@tailwindcss/container-queries"; import type { Config } from "tailwindcss"; import tailwindcssAnimate from "tailwindcss-animate"; import tailwindcssAriaAttributes from "tailwindcss-aria-attributes"; @@ -12,5 +13,6 @@ export default { tailwindcssAnimate, tailwindcssAriaAttributes, tailwindcssRadixColors, + tailwindcssContainerQueries, ], } satisfies Config;
{description}
- We'll walk you through the steps to fill out your forms. -