From 139780d8675410bc9c24c15e5e778c9bc4cb87cb Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:01:55 -0700 Subject: [PATCH 01/10] Update NumberCard component --- app/components/NumberCard.tsx | 91 ++++++++ app/components/NumberedCard.tsx | 33 --- .../NumberedCards/NumberedCards.view.tsx | 4 +- stories/NumberCard.stories.js | 221 ++++++++++++++++++ stories/NumberedCard.stories.js | 96 -------- stories/NumberedCards.stories.js | 2 +- ...beredCard.test.jsx => NumberCard.test.jsx} | 156 +++++++++---- tests/unit/NumberedCards.test.jsx | 4 +- 8 files changed, 424 insertions(+), 183 deletions(-) create mode 100644 app/components/NumberCard.tsx delete mode 100644 app/components/NumberedCard.tsx create mode 100644 stories/NumberCard.stories.js delete mode 100644 stories/NumberedCard.stories.js rename tests/unit/{NumberedCard.test.jsx => NumberCard.test.jsx} (50%) diff --git a/app/components/NumberCard.tsx b/app/components/NumberCard.tsx new file mode 100644 index 0000000..e33674e --- /dev/null +++ b/app/components/NumberCard.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { memo } from "react"; +import SectionNumber from "./SectionNumber"; + +interface NumberCardProps { + number: number; + text: string; + size?: "Small" | "Medium" | "Large" | "XLarge"; + iconShape?: string; + iconColor?: string; +} + +const NumberCard = memo(({ number, text, size }) => { + // Base classes common to all sizes + const baseClasses = "bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg"; + + // If size prop is provided, use explicit size classes + // Otherwise, use responsive breakpoints for backward compatibility + if (size) { + // Size-specific classes + const sizeClasses = { + Small: "flex flex-col items-end justify-center gap-4 p-5 relative", + Medium: "flex flex-row items-center gap-8 p-8 relative", + Large: "flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative", + XLarge: "flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative", + }; + + // Text size classes + const textClasses = { + Small: "font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]", + Medium: "font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]", + Large: "font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]", + XLarge: "font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]", + }; + + // Section number positioning classes + const sectionNumberClasses = { + Small: "flex justify-end items-end", + Medium: "flex justify-start flex-shrink-0", + Large: "absolute top-8 right-8", + XLarge: "absolute top-8 right-8", + }; + + // Content container classes + const contentClasses = { + Small: "", + Medium: "flex-1", + Large: "absolute bottom-8 left-8 right-16", + XLarge: "absolute bottom-8 left-8 right-16", + }; + + return ( +
+ {/* Section Number */} +
+ +
+ + {/* Card Content */} +
+

+ {text} +

+
+
+ ); + } + + // Responsive breakpoints for backward compatibility (matches original behavior) + // Maps to: Small (mobile) -> Medium (sm) -> Large (lg) -> XLarge (xl) + return ( +
+ {/* Section Number - Responsive positioning */} +
+ +
+ + {/* Card Content - Responsive positioning */} +
+

+ {text} +

+
+
+ ); +}); + +NumberCard.displayName = "NumberCard"; + +export default NumberCard; diff --git a/app/components/NumberedCard.tsx b/app/components/NumberedCard.tsx deleted file mode 100644 index 317fca2..0000000 --- a/app/components/NumberedCard.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { memo } from "react"; -import SectionNumber from "./SectionNumber"; - -interface NumberedCardProps { - number: number; - text: string; - iconShape?: string; - iconColor?: string; -} - -const NumberedCard = memo(({ number, text }) => { - return ( -
- {/* Section Number - Top right (lg breakpoint) */} -
- -
- - {/* Card Content - Bottom left (lg breakpoint) */} -
-

- {text} -

-
-
- ); -}); - -NumberedCard.displayName = "NumberedCard"; - -export default NumberedCard; diff --git a/app/components/NumberedCards/NumberedCards.view.tsx b/app/components/NumberedCards/NumberedCards.view.tsx index acc48fe..d77fb0b 100644 --- a/app/components/NumberedCards/NumberedCards.view.tsx +++ b/app/components/NumberedCards/NumberedCards.view.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "../../contexts/MessagesContext"; import SectionHeader from "../SectionHeader"; -import NumberedCard from "../NumberedCard"; +import NumberCard from "../NumberCard"; import Button from "../Button"; import type { NumberedCardsViewProps } from "./NumberedCards.types"; @@ -35,7 +35,7 @@ function NumberedCardsView({ {/* Cards Container */}
{cards.map((card, index) => ( - ( +
+
+

Small

+ +
+
+

Medium

+ +
+
+

Large

+ +
+
+

XLarge

+ +
+
+ ), + parameters: { + docs: { + description: { + story: + "Shows all four size variants side by side to compare the different layouts and typography.", + }, + }, + }, +}; + +export const AllNumbers = { + args: { + number: 1, + text: "Example card text", + iconShape: "blob", + iconColor: "green", + }, + render: (args) => ( +
+ + + +
+ ), + parameters: { + docs: { + description: { + story: + "Shows all three numbered cards with different content to demonstrate the visual hierarchy.", + }, + }, + }, +}; + +export const LongText = { + args: { + number: 1, + text: "This is a much longer piece of text that demonstrates how the card handles content that spans multiple lines and requires more space to display properly", + iconShape: "blob", + iconColor: "green", + }, + parameters: { + docs: { + description: { + story: + "Demonstrates how the card handles longer text content across different breakpoints.", + }, + }, + }, +}; diff --git a/stories/NumberedCard.stories.js b/stories/NumberedCard.stories.js deleted file mode 100644 index 01ebf98..0000000 --- a/stories/NumberedCard.stories.js +++ /dev/null @@ -1,96 +0,0 @@ -import NumberedCard from "../app/components/NumberedCard"; - -export default { - title: "Components/NumberedCard", - component: NumberedCard, - parameters: { - layout: "centered", - docs: { - description: { - component: - "Individual numbered card component that displays a step in a process with a numbered icon and descriptive text. Supports responsive layouts across different breakpoints.", - }, - }, - }, - argTypes: { - number: { - control: { type: "number", min: 1, max: 9 }, - description: "The number to display on the card", - }, - text: { - control: { type: "text" }, - description: "The descriptive text for this step", - }, - iconShape: { - control: { type: "select" }, - options: ["blob", "gear", "star"], - description: - "The shape of the icon background (currently not used, uses PNG images)", - }, - iconColor: { - control: { type: "select" }, - options: ["green", "purple", "orange", "blue"], - description: - "The color theme for the icon (currently not used, uses PNG images)", - }, - }, - tags: ["autodocs"], -}; - -export const Default = { - args: { - number: 1, - text: "Document how your community makes decisions", - iconShape: "blob", - iconColor: "green", - }, -}; - -export const AllNumbers = { - args: { - number: 1, - text: "Example card text", - iconShape: "blob", - iconColor: "green", - }, - render: (args) => ( -
- - - -
- ), - parameters: { - docs: { - description: { - story: - "Shows all three numbered cards with different content to demonstrate the visual hierarchy.", - }, - }, - }, -}; - -export const LongText = { - args: { - number: 1, - text: "This is a much longer piece of text that demonstrates how the card handles content that spans multiple lines and requires more space to display properly", - iconShape: "blob", - iconColor: "green", - }, - parameters: { - docs: { - description: { - story: - "Demonstrates how the card handles longer text content across different breakpoints.", - }, - }, - }, -}; diff --git a/stories/NumberedCards.stories.js b/stories/NumberedCards.stories.js index 90ca449..3d9f651 100644 --- a/stories/NumberedCards.stories.js +++ b/stories/NumberedCards.stories.js @@ -8,7 +8,7 @@ export default { docs: { description: { component: - "A component system for visually communicating multi-step workflows, processes, or value propositions. The component's modular design with NumberedCard and SectionNumber sub-components makes it ideal for explaining any sequential process while maintaining brand consistency and accessibility standards across the design system.", + "A component system for visually communicating multi-step workflows, processes, or value propositions. The component's modular design with NumberCard and SectionNumber sub-components makes it ideal for explaining any sequential process while maintaining brand consistency and accessibility standards across the design system.", }, }, }, diff --git a/tests/unit/NumberedCard.test.jsx b/tests/unit/NumberCard.test.jsx similarity index 50% rename from tests/unit/NumberedCard.test.jsx rename to tests/unit/NumberCard.test.jsx index 8623454..370cad5 100644 --- a/tests/unit/NumberedCard.test.jsx +++ b/tests/unit/NumberCard.test.jsx @@ -1,49 +1,49 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; -import NumberedCard from "../../app/components/NumberedCard"; +import NumberCard from "../../app/components/NumberCard"; -describe("NumberedCard Component", () => { +describe("NumberCard Component", () => { const defaultProps = { number: 1, text: "Test Card Text", }; - it("renders numbered card with all required information", () => { - render(); + it("renders number card with all required information", () => { + render(); expect(screen.getByText("1")).toBeInTheDocument(); expect(screen.getByText("Test Card Text")).toBeInTheDocument(); }); it("renders with different numbers", () => { - const { rerender } = render(); + const { rerender } = render(); expect(screen.getByText("42")).toBeInTheDocument(); - rerender(); + rerender(); expect(screen.getByText("999")).toBeInTheDocument(); }); it("renders with different text content", () => { const { rerender } = render( - , + , ); expect(screen.getByText("Different Text")).toBeInTheDocument(); - rerender(); + rerender(); expect(screen.getByText("Another Text")).toBeInTheDocument(); }); - it("applies proper responsive layout classes", () => { - render(); + it("applies proper responsive layout classes when size is not specified", () => { + render(); const card = screen .getByText("Test Card Text") .closest("div").parentElement; - expect(card).toHaveClass("flex", "flex-col", "sm:flex-row", "lg:flex-row"); + expect(card).toHaveClass("flex", "flex-col", "sm:flex-row", "lg:flex-col"); }); - it("applies proper responsive spacing", () => { - render(); + it("applies proper responsive spacing when size is not specified", () => { + render(); const card = screen .getByText("Test Card Text") @@ -51,17 +51,17 @@ describe("NumberedCard Component", () => { expect(card).toHaveClass("p-5", "sm:p-8", "lg:p-8"); }); - it("applies proper responsive gap", () => { - render(); + it("applies proper responsive gap when size is not specified", () => { + render(); const card = screen .getByText("Test Card Text") .closest("div").parentElement; - expect(card).toHaveClass("gap-4", "sm:gap-8", "lg:gap-0"); + expect(card).toHaveClass("gap-4", "sm:gap-8", "lg:gap-[22px]"); }); - it("applies proper responsive height", () => { - render(); + it("applies proper responsive height when size is not specified", () => { + render(); const card = screen .getByText("Test Card Text") @@ -70,7 +70,7 @@ describe("NumberedCard Component", () => { }); it("applies proper background and shadow", () => { - render(); + render(); const card = screen .getByText("Test Card Text") @@ -82,7 +82,7 @@ describe("NumberedCard Component", () => { }); it("applies proper border radius", () => { - render(); + render(); const card = screen .getByText("Test Card Text") @@ -90,25 +90,15 @@ describe("NumberedCard Component", () => { expect(card).toHaveClass("rounded-[12px]"); }); - it("renders section number in correct position", () => { - render(); + it("renders section number in correct position for responsive mode", () => { + render(); const numberElement = screen.getByText("1"); expect(numberElement).toBeInTheDocument(); - - // Check that it's in a container with proper positioning - const numberContainer = numberElement.closest("div"); - expect(numberContainer).toHaveClass( - "absolute", - "inset-0", - "flex", - "items-center", - "justify-center", - ); }); - it("renders text content in correct position", () => { - render(); + it("renders text content in correct position for responsive mode", () => { + render(); const textElement = screen.getByText("Test Card Text"); expect(textElement).toBeInTheDocument(); @@ -125,14 +115,14 @@ describe("NumberedCard Component", () => { }); it("applies proper font classes to text", () => { - render(); + render(); const textElement = screen.getByText("Test Card Text"); expect(textElement).toHaveClass("font-bricolage-grotesque"); }); - it("applies proper text sizing", () => { - render(); + it("applies proper text sizing for responsive mode", () => { + render(); const textElement = screen.getByText("Test Card Text"); expect(textElement).toHaveClass( @@ -144,7 +134,7 @@ describe("NumberedCard Component", () => { }); it("applies proper text color", () => { - render(); + render(); const textElement = screen.getByText("Test Card Text"); expect(textElement).toHaveClass("text-[#141414]"); @@ -152,14 +142,14 @@ describe("NumberedCard Component", () => { it("handles long text content gracefully", () => { const longText = - "This is a very long text that should wrap properly and not break the layout of the numbered card component"; - render(); + "This is a very long text that should wrap properly and not break the layout of the number card component"; + render(); expect(screen.getByText(longText)).toBeInTheDocument(); }); - it("maintains proper responsive behavior", () => { - render(); + it("maintains proper responsive behavior when size is not specified", () => { + render(); const card = screen .getByText("Test Card Text") @@ -178,16 +168,16 @@ describe("NumberedCard Component", () => { // Large breakpoint expect(card).toHaveClass( - "lg:flex-row", - "lg:gap-0", + "lg:flex-col", + "lg:gap-[22px]", "lg:p-8", - "lg:items-stretch", + "lg:items-start", "lg:relative", ); }); it("renders with proper flex layout", () => { - render(); + render(); const card = screen .getByText("Test Card Text") @@ -195,12 +185,80 @@ describe("NumberedCard Component", () => { expect(card).toHaveClass("flex"); }); - it("applies proper items alignment", () => { - render(); + it("applies Small size variant correctly", () => { + render(); const card = screen .getByText("Test Card Text") .closest("div").parentElement; - expect(card).toHaveClass("sm:items-center", "lg:items-stretch"); + expect(card).toHaveClass( + "flex", + "flex-col", + "items-end", + "justify-center", + "gap-4", + "p-5", + ); + + const textElement = screen.getByText("Test Card Text"); + expect(textElement).toHaveClass("text-[24px]", "leading-[32px]"); + }); + + it("applies Medium size variant correctly", () => { + render(); + + const card = screen + .getByText("Test Card Text") + .closest("div").parentElement; + expect(card).toHaveClass( + "flex", + "flex-row", + "items-center", + "gap-8", + "p-8", + ); + + const textElement = screen.getByText("Test Card Text"); + expect(textElement).toHaveClass("text-[24px]", "leading-[24px]"); + }); + + it("applies Large size variant correctly", () => { + render(); + + const card = screen + .getByText("Test Card Text") + .closest("div").parentElement; + expect(card).toHaveClass( + "flex", + "flex-col", + "items-start", + "justify-end", + "gap-[22px]", + "h-[238px]", + "p-8", + ); + + const textElement = screen.getByText("Test Card Text"); + expect(textElement).toHaveClass("text-[24px]", "leading-[24px]"); + }); + + it("applies XLarge size variant correctly", () => { + render(); + + const card = screen + .getByText("Test Card Text") + .closest("div").parentElement; + expect(card).toHaveClass( + "flex", + "flex-col", + "items-start", + "justify-end", + "gap-[22px]", + "h-[238px]", + "p-8", + ); + + const textElement = screen.getByText("Test Card Text"); + expect(textElement).toHaveClass("text-[32px]", "leading-[32px]"); }); }); diff --git a/tests/unit/NumberedCards.test.jsx b/tests/unit/NumberedCards.test.jsx index e699567..fa36e2c 100644 --- a/tests/unit/NumberedCards.test.jsx +++ b/tests/unit/NumberedCards.test.jsx @@ -72,10 +72,10 @@ describe("NumberedCards Component", () => { expect(screen.getByText("Test Subtitle")).toBeInTheDocument(); }); - test("renders NumberedCard components with correct props", () => { + test("renders NumberCard components with correct props", () => { render(); - // Check that NumberedCard components receive correct props + // Check that NumberCard components receive correct props expect(screen.getByText("1")).toBeInTheDocument(); // First card number expect(screen.getByText("2")).toBeInTheDocument(); // Second card number expect(screen.getByText("3")).toBeInTheDocument(); // Third card number -- 2.43.0 From 91635cbf4c7045ecc2b84087a883ce3912006a9c Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 3 Feb 2026 21:39:15 -0700 Subject: [PATCH 02/10] Update vitest.config.mjs --- vitest.config.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/vitest.config.mjs b/vitest.config.mjs index 8abe501..5a0726f 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -76,8 +76,10 @@ export default defineConfig({ workerTimeout: 120000, // 2min for worker timeout poolTimeout: 120000, // 2min for pool timeout // Optimize dependencies - deps: { - inline: ["@testing-library/jest-dom"], // Inline testing library + server: { + deps: { + inline: ["@testing-library/jest-dom"], // Inline testing library + }, }, }, }); -- 2.43.0 From d8fa5255146eb982157d6284c0c057db05df28fd Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:49:05 -0700 Subject: [PATCH 03/10] Adjust NumberCard story --- app/components/NumberCard.tsx | 25 ++++++++++++++++++++----- app/components/SectionNumber.tsx | 24 ++++++++++++++---------- 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/app/components/NumberCard.tsx b/app/components/NumberCard.tsx index e33674e..5b4c1b4 100644 --- a/app/components/NumberCard.tsx +++ b/app/components/NumberCard.tsx @@ -34,9 +34,9 @@ const NumberCard = memo(({ number, text, size }) => { XLarge: "font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]", }; - // Section number positioning classes - const sectionNumberClasses = { - Small: "flex justify-end items-end", + // Section number wrapper classes - Small doesn't need a wrapper + const sectionNumberWrapperClasses = { + Small: "relative shrink-0", Medium: "flex justify-start flex-shrink-0", Large: "absolute top-8 right-8", XLarge: "absolute top-8 right-8", @@ -44,16 +44,31 @@ const NumberCard = memo(({ number, text, size }) => { // Content container classes const contentClasses = { - Small: "", + Small: "min-w-full relative shrink-0", Medium: "flex-1", Large: "absolute bottom-8 left-8 right-16", XLarge: "absolute bottom-8 left-8 right-16", }; + // Small variant has section number as direct child, others need wrapper + if (size === "Small") { + return ( +
+ {/* Section Number - Direct child for Small */} + + + {/* Card Content */} +

+ {text} +

+
+ ); + } + return (
{/* Section Number */} -
+
diff --git a/app/components/SectionNumber.tsx b/app/components/SectionNumber.tsx index 16d1436..b273621 100644 --- a/app/components/SectionNumber.tsx +++ b/app/components/SectionNumber.tsx @@ -1,6 +1,7 @@ "use client"; import { memo } from "react"; +import { getAssetPath } from "../../lib/assetUtils"; interface SectionNumberProps { number: number; @@ -8,16 +9,19 @@ interface SectionNumberProps { const SectionNumber = memo(({ number }) => { const getImageSrc = (num: number): string => { - switch (num) { - case 1: - return "/assets/SectionNumber_1.png"; - case 2: - return "/assets/SectionNumber_2.png"; - case 3: - return "/assets/SectionNumber_3.png"; - default: - return "/assets/SectionNumber_1.png"; - } + const assetPath = (() => { + switch (num) { + case 1: + return "assets/SectionNumber_1.png"; + case 2: + return "assets/SectionNumber_2.png"; + case 3: + return "assets/SectionNumber_3.png"; + default: + return "assets/SectionNumber_1.png"; + } + })(); + return getAssetPath(assetPath); }; return ( -- 2.43.0 From 255f16477c13500e2425493e2a55113b12c170d3 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:29:51 -0700 Subject: [PATCH 04/10] Update text input component --- app/components-preview/page.tsx | 747 ++++-------------- app/components/Input/Input.container.tsx | 176 ----- app/components/Input/Input.view.tsx | 62 -- app/components/Input/index.tsx | 2 - app/components/Select/index.tsx | 2 - .../SelectInput.container.tsx} | 14 +- .../SelectInput.types.ts} | 2 +- .../SelectInput.view.tsx} | 8 +- app/components/SelectInput/index.tsx | 2 + .../TextInput/TextInput.container.tsx | 227 ++++++ .../TextInput.types.ts} | 14 +- app/components/TextInput/TextInput.view.tsx | 83 ++ app/components/TextInput/index.tsx | 2 + lib/assetUtils.ts | 3 + stories/Create.stories.js | 8 +- ...lect.stories.js => SelectInput.stories.js} | 110 +-- ...{Input.stories.js => TextInput.stories.js} | 44 +- tests/components/Create.test.tsx | 4 +- .../{Select.test.tsx => SelectInput.test.tsx} | 14 +- .../{Input.test.tsx => TextInput.test.tsx} | 14 +- 20 files changed, 589 insertions(+), 949 deletions(-) delete mode 100644 app/components/Input/Input.container.tsx delete mode 100644 app/components/Input/Input.view.tsx delete mode 100644 app/components/Input/index.tsx delete mode 100644 app/components/Select/index.tsx rename app/components/{Select/Select.container.tsx => SelectInput/SelectInput.container.tsx} (95%) rename app/components/{Select/Select.types.ts => SelectInput/SelectInput.types.ts} (93%) rename app/components/{Select/Select.view.tsx => SelectInput/SelectInput.view.tsx} (96%) create mode 100644 app/components/SelectInput/index.tsx create mode 100644 app/components/TextInput/TextInput.container.tsx rename app/components/{Input/Input.types.ts => TextInput/TextInput.types.ts} (79%) create mode 100644 app/components/TextInput/TextInput.view.tsx create mode 100644 app/components/TextInput/index.tsx rename stories/{Select.stories.js => SelectInput.stories.js} (62%) rename stories/{Input.stories.js => TextInput.stories.js} (89%) rename tests/components/{Select.test.tsx => SelectInput.test.tsx} (68%) rename tests/components/{Input.test.tsx => TextInput.test.tsx} (64%) diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index 4d8e60c..1d3346d 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -1,29 +1,14 @@ "use client"; import { useState } from "react"; -import Tooltip from "../components/Tooltip"; -import Alert from "../components/Alert"; -import Button from "../components/Button"; -import Stepper from "../components/Stepper"; -import Progress from "../components/Progress"; -import Create from "../components/Create"; -import Input from "../components/Input"; -import InputWithCounter from "../components/InputWithCounter"; -import IconCard from "../components/IconCard"; -import { getAssetPath } from "../../lib/assetUtils"; +import TextInput from "../components/TextInput"; +import SelectInput from "../components/SelectInput"; export default function ComponentsPreview() { - const [alertVisible, setAlertVisible] = useState({ - default: true, - positive: true, - warning: true, - danger: true, - banner: true, - }); - - const [createOpen, setCreateOpen] = useState(false); - const [createStep, setCreateStep] = useState(1); - const [policyName, setPolicyName] = useState(""); + const [defaultInputValue, setDefaultInputValue] = useState(""); + const [activeInputValue, setActiveInputValue] = useState(""); + const [errorInputValue, setErrorInputValue] = useState(""); + const [selectValue, setSelectValue] = useState(""); return (
@@ -37,600 +22,172 @@ export default function ComponentsPreview() {

- {/* Button Section */} + {/* Text Input Section */}

- Button Component + Text Input Component

- All Variants + States

-
- - - - - - - - -
-
- -
-

- All Sizes - Danger Variant -

-
- - - - - -
-
- -
-

- All Sizes - Danger Inverse Variant -

-
- - - - - -
-
- -
-

- All Sizes - Ghost Variant -

-
- - - - - -
-
- -
-

- All Sizes - Ghost Inverse Variant -

-
- - - - - -
-
- -
-

- States - Danger Variant -

-
- - -
-
- -
-

- States - Danger Inverse Variant -

-
- - -
-
- -
-

- States - Ghost Variant -

-
- - -
-
- -
-

- States - Ghost Inverse Variant -

-
- - -
-
-
-
-
- - {/* Tooltip Section */} -
-

- Tooltip Component -

- -
-
- - - - - - - - - - - - - - - -
-
-
- - {/* Alert Section */} -
-

- Alert Component -

- -
- {/* Toast Alerts */} -
-

- Toast Alerts -

- - {alertVisible.default && ( - - setAlertVisible({ ...alertVisible, default: false }) - } - /> - )} - - {alertVisible.positive && ( - - setAlertVisible({ ...alertVisible, positive: false }) - } - /> - )} - - {alertVisible.warning && ( - - setAlertVisible({ ...alertVisible, warning: false }) - } - /> - )} - - {alertVisible.danger && ( - - setAlertVisible({ ...alertVisible, danger: false }) - } - /> - )} -
- - {/* Banner Alerts */} -
-

- Banner Alerts -

- - {alertVisible.banner && ( - - setAlertVisible({ ...alertVisible, banner: false }) - } - /> - )} - - - - - - -
-
-
- - {/* Stepper Section */} -
-

- Stepper Component -

- -
-
-
-

- Step 1 of 5 -

- -
-
-

- Step 2 of 5 -

- -
-
-

- Step 3 of 5 -

- -
-
-

- Step 4 of 5 -

- -
-
-

- Step 5 of 5 -

- -
-
-
-
- - {/* Progress Section */} -
-

- Progress Component -

- -
-
-
-

- Progress: 1-0 -

- -
-
-

- Progress: 1-1 -

- -
-
-

- Progress: 1-2 -

- -
-
-

- Progress: 1-3 -

- -
-
-

- Progress: 1-4 -

- -
-
-

- Progress: 1-5 -

- -
-
-

- Progress: 2-0 -

- -
-
-

- Progress: 2-1 -

- -
-
-

- Progress: 2-2 -

- -
-
-

- Progress: 3-0 -

- -
-
-

- Progress: 3-1 -

- -
-
-

- Progress: 3-2 -

- -
-
-
-
- - {/* Create Component Section */} -
-

- Create Component -

- -
-
- - -
-

- Step {createStep} of 3 -

- - -
-
-
- - setCreateOpen(false)} - title={ - createStep === 1 - ? "What do you call your group's new policy?" - : createStep === 2 - ? "How should conflicts be resolved?" - : "Review your policy" - } - description="You can also combine or add new approaches to the list" - showBackButton={true} - showNextButton={true} - onBack={() => setCreateStep((prev) => Math.max(1, prev - 1))} - onNext={() => setCreateStep((prev) => Math.min(3, prev + 1))} - backButtonText="Back" - nextButtonText={createStep === 3 ? "Finish" : "Next"} - nextButtonDisabled={createStep === 1 && !policyName.trim()} - currentStep={createStep} - totalSteps={3} - > -
- {createStep === 1 && ( - - )} - {createStep === 2 && ( -
- -

- Select how conflicts should be resolved in your group. -

-
- )} - {createStep === 3 && (
-

- Review your policy configuration before finalizing. -

-
-

- Policy details will appear here -

-
+ setDefaultInputValue(e.target.value)} + /> + setActiveInputValue(e.target.value)} + /> + + setErrorInputValue(e.target.value)} + error + />
- )} +
+
- +
- {/* IconCard Component Section */} + {/* Select Input Section */}

- IconCard Component + Select Input Component

-
- +
+

+ All Sizes +

+
+ setSelectValue(data.target.value)} + options={[ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3" }, + ]} /> - } - title="Worker's cooperatives" - description="Employee-owned businesses often need to clarify how power is shared, decisions are made, and how processes operate within their organizations." - onClick={() => { - // IconCard clicked handler - }} - /> + setSelectValue(data.target.value)} + options={[ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3" }, + ]} + /> + setSelectValue(data.target.value)} + options={[ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3" }, + ]} + /> +
+
+ +
+

+ States +

+
+ + + +
+
+ +
+

+ Label Variants +

+
+ + +
+
diff --git a/app/components/Input/Input.container.tsx b/app/components/Input/Input.container.tsx deleted file mode 100644 index 7804504..0000000 --- a/app/components/Input/Input.container.tsx +++ /dev/null @@ -1,176 +0,0 @@ -"use client"; - -import { memo, forwardRef } from "react"; -import { useComponentId, useFormField } from "../../hooks"; -import { InputView } from "./Input.view"; -import type { InputProps } from "./Input.types"; - -const InputContainer = forwardRef( - ( - { - size = "medium", - labelVariant = "default", - state = "default", - disabled = false, - error = false, - label, - placeholder, - value, - onChange, - onFocus, - onBlur, - id, - name, - type = "text", - className = "", - ...props - }, - ref, - ) => { - // Generate unique ID for accessibility if not provided - const { id: inputId, labelId } = useComponentId("input", id); - - // Size variants - const sizeStyles: Record< - string, - { - input: string; - label: string; - container: string; - radius: string; - } - > = { - small: { - input: - labelVariant === "horizontal" - ? "h-[30px] px-[12px] py-[8px] text-[10px]" - : "h-[32px] px-[12px] py-[8px] text-[10px]", - label: "text-[12px] leading-[14px] font-medium", - container: "gap-[4px]", - radius: "var(--measures-radius-small)", - }, - medium: { - input: "h-[36px] px-[12px] py-[8px] text-[14px] leading-[20px]", - label: "text-[14px] leading-[16px] font-medium", - container: "gap-[8px]", - radius: "var(--measures-radius-medium)", - }, - large: { - input: "h-[40px] px-[12px] py-[8px] text-[16px] leading-[24px]", - label: "text-[16px] leading-[20px] font-medium", - container: "gap-[12px]", - radius: "var(--measures-radius-large)", - }, - }; - - // State styles - const getStateStyles = (): { - input: string; - label: string; - } => { - if (disabled) { - return { - input: - "bg-[var(--color-content-default-secondary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] cursor-not-allowed", - label: "text-[var(--color-content-default-secondary)]", - }; - } - - if (error) { - return { - input: - "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-utility-negative)]", - label: "text-[var(--color-content-default-secondary)]", - }; - } - - switch (state) { - case "active": - return { - input: - "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)]", - label: "text-[var(--color-content-default-secondary)]", - }; - case "hover": - return { - input: - "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] shadow-[0_0_0_2px_var(--color-border-default-tertiary)]", - label: "text-[var(--color-content-default-secondary)]", - }; - case "focus": - return { - input: - "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-utility-info)] shadow-[0_0_5px_3px_#3281F8]", - label: "text-[var(--color-content-default-secondary)]", - }; - default: - return { - input: - "bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border border-[var(--color-border-default-tertiary)] hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)]", - label: "text-[var(--color-content-default-secondary)]", - }; - } - }; - - const stateStyles = getStateStyles(); - const currentSize = sizeStyles[size]; - - // Container classes based on label variant - const containerClasses = - labelVariant === "horizontal" - ? `flex items-center gap-[12px]` - : `flex flex-col ${currentSize.container}`; - - const labelClasses = - labelVariant === "horizontal" - ? `${currentSize.label} font-inter min-w-fit` - : `${currentSize.label} font-inter`; - - const inputClasses = ` - w-full border transition-all duration-200 ease-in-out - focus:outline-none focus:ring-0 - ${currentSize.input} - ${stateStyles.input} - ${className} - `.trim(); - - // Form field handlers with disabled state handling - const { handleChange, handleFocus, handleBlur } = - useFormField(disabled, { - onChange, - onFocus, - onBlur, - }); - - return ( - - ); - }, -); - -InputContainer.displayName = "Input"; - -export default memo(InputContainer); diff --git a/app/components/Input/Input.view.tsx b/app/components/Input/Input.view.tsx deleted file mode 100644 index aae9d0a..0000000 --- a/app/components/Input/Input.view.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { forwardRef } from "react"; -import type { InputViewProps } from "./Input.types"; - -export const InputView = forwardRef( - ( - { - inputId, - labelId, - label, - placeholder, - value, - name, - type, - disabled, - size: _size, - labelVariant: _labelVariant, - state: _state, - error: _error, - className: _className, - containerClasses, - labelClasses, - inputClasses, - borderRadius, - handleChange, - handleFocus, - handleBlur, - }, - ref, - ) => { - return ( -
- {label && ( - - )} -
- -
-
- ); - }, -); - -InputView.displayName = "InputView"; diff --git a/app/components/Input/index.tsx b/app/components/Input/index.tsx deleted file mode 100644 index c788c53..0000000 --- a/app/components/Input/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./Input.container"; -export type { InputProps } from "./Input.types"; diff --git a/app/components/Select/index.tsx b/app/components/Select/index.tsx deleted file mode 100644 index 881e661..0000000 --- a/app/components/Select/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./Select.container"; -export type { SelectProps, SelectOptionData } from "./Select.types"; diff --git a/app/components/Select/Select.container.tsx b/app/components/SelectInput/SelectInput.container.tsx similarity index 95% rename from app/components/Select/Select.container.tsx rename to app/components/SelectInput/SelectInput.container.tsx index 1fab32c..7a179bc 100644 --- a/app/components/Select/Select.container.tsx +++ b/app/components/SelectInput/SelectInput.container.tsx @@ -14,10 +14,10 @@ import React, { useEffect, } from "react"; import { useClickOutside } from "../../hooks"; -import { SelectView } from "./Select.view"; -import type { SelectProps } from "./Select.types"; +import { SelectInputView } from "./SelectInput.view"; +import type { SelectInputProps } from "./SelectInput.types"; -const SelectContainer = forwardRef( +const SelectInputContainer = forwardRef( ( { id, @@ -38,7 +38,7 @@ const SelectContainer = forwardRef( ref, ) => { const generatedId = useId(); - const selectId = id || `select-${generatedId}`; + const selectId = id || `select-input-${generatedId}`; const labelId = `${selectId}-label`; const [isOpen, setIsOpen] = useState(false); const [selectedValue, setSelectedValue] = useState(value || ""); @@ -267,7 +267,7 @@ const SelectContainer = forwardRef( }; return ( - ( }, ); -SelectContainer.displayName = "Select"; +SelectInputContainer.displayName = "SelectInput"; -export default memo(SelectContainer); +export default memo(SelectInputContainer); diff --git a/app/components/Select/Select.types.ts b/app/components/SelectInput/SelectInput.types.ts similarity index 93% rename from app/components/Select/Select.types.ts rename to app/components/SelectInput/SelectInput.types.ts index 5e082d2..b3dd7b4 100644 --- a/app/components/Select/Select.types.ts +++ b/app/components/SelectInput/SelectInput.types.ts @@ -5,7 +5,7 @@ export interface SelectOptionData { label: string; } -export interface SelectProps { +export interface SelectInputProps { id?: string; label?: string; labelVariant?: "default" | "horizontal"; diff --git a/app/components/Select/Select.view.tsx b/app/components/SelectInput/SelectInput.view.tsx similarity index 96% rename from app/components/Select/Select.view.tsx rename to app/components/SelectInput/SelectInput.view.tsx index 1c8ad9b..ad49ad3 100644 --- a/app/components/Select/Select.view.tsx +++ b/app/components/SelectInput/SelectInput.view.tsx @@ -1,9 +1,9 @@ import React, { Children, type ReactNode } from "react"; import SelectDropdown from "../SelectDropdown"; import SelectOption from "../SelectOption"; -import type { SelectOptionData } from "./Select.types"; +import type { SelectOptionData } from "./SelectInput.types"; -export interface SelectViewProps { +export interface SelectInputViewProps { label?: string; placeholder: string; size: "small" | "medium" | "large"; @@ -36,7 +36,7 @@ export interface SelectViewProps { ariaInvalid?: boolean; } -export function SelectView({ +export function SelectInputView({ label, placeholder: _placeholder, size, @@ -62,7 +62,7 @@ export function SelectView({ ariaLabelledby, ariaInvalid, ...props -}: SelectViewProps) { +}: SelectInputViewProps) { return (
{label && ( diff --git a/app/components/SelectInput/index.tsx b/app/components/SelectInput/index.tsx new file mode 100644 index 0000000..46ef063 --- /dev/null +++ b/app/components/SelectInput/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./SelectInput.container"; +export type { SelectInputProps, SelectOptionData } from "./SelectInput.types"; diff --git a/app/components/TextInput/TextInput.container.tsx b/app/components/TextInput/TextInput.container.tsx new file mode 100644 index 0000000..bb7ff0d --- /dev/null +++ b/app/components/TextInput/TextInput.container.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { memo, forwardRef, useState, useRef } from "react"; +import { useComponentId, useFormField } from "../../hooks"; +import { TextInputView } from "./TextInput.view"; +import type { TextInputProps } from "./TextInput.types"; + +const TextInputContainer = forwardRef( + ( + { + state: externalState = "default", + disabled = false, + error = false, + label, + placeholder, + value, + onChange, + onFocus, + onBlur, + id, + name, + type = "text", + className = "", + showHelpIcon = true, + ...props + }, + ref, + ) => { + // Generate unique ID for accessibility if not provided + const { id: inputId, labelId } = useComponentId("text-input", id); + + // Internal state management: track if focused and how (mouse vs keyboard) + const [isFocused, setIsFocused] = useState(false); + const [focusMethod, setFocusMethod] = useState<"mouse" | "keyboard" | null>(null); + const wasMouseDownRef = useRef(false); + + // Determine if we should auto-manage focus (only when state is "default" or undefined) + // If state is "active", "hover", or "focus", respect it and don't override + const shouldAutoManageFocus = externalState === "default" || externalState === undefined; + + // Determine actual state: + // - Active: when clicked (mouse focus) + // - Focus: when tabbed (keyboard focus) + // - Default: when not focused + const actualState = shouldAutoManageFocus + ? isFocused + ? focusMethod === "mouse" + ? "active" + : "focus" + : "default" + : externalState; + + // Determine if input is filled (has value) + const isFilled = Boolean(value && value.trim().length > 0); + + // Fixed size styles (medium only per Figma designs) + const sizeStyles = { + input: "h-[40px] px-[12px] py-[8px] text-[16px]", + label: "text-[14px] leading-[20px] font-medium", + container: "gap-[8px]", + radius: "var(--measures-radius-200,8px)", + }; + + // State styles based on Figma designs + const getStateStyles = (): { + input: string; + label: string; + inputWrapper: string; + focusRing: string; + } => { + if (disabled) { + return { + input: + "bg-[var(--color-surface-default-secondary)] text-[var(--color-content-inverse-tertiary,#2d2d2d)] border border-solid border-[var(--color-border-default-primary)] cursor-not-allowed", + label: "text-[var(--color-content-default-secondary)]", + inputWrapper: "relative", + focusRing: "", + }; + } + + if (error) { + const filledStyles = isFilled + ? "font-medium leading-[20px]" + : "font-normal leading-[24px]"; + return { + input: `bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-solid border-[var(--color-border-default-utility-negative)] ${filledStyles}`, + label: "text-[var(--color-content-default-secondary)]", + inputWrapper: "relative", + focusRing: "", + }; + } + + switch (actualState) { + case "active": { + const filledStyles = isFilled + ? "font-medium leading-[20px]" + : "font-normal leading-[24px]"; + return { + input: `bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-solid border-[var(--color-border-default-tertiary)] ${filledStyles}`, + label: "text-[var(--color-content-default-secondary)]", + inputWrapper: "relative", + focusRing: "", + }; + } + case "focus": { + const filledStyles = isFilled + ? "font-medium leading-[20px]" + : "font-normal leading-[24px]"; + return { + input: `bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border border-solid border-[var(--color-border-default-tertiary)] ${filledStyles}`, + label: "text-[var(--color-content-default-secondary)]", + inputWrapper: "relative", + focusRing: + "absolute border-2 border-solid border-[var(--color-border-inverse-primary)] inset-0 rounded-[var(--measures-radius-200,8px)] shadow-[0px_0px_0px_2px_var(--color-border-default-primary)] pointer-events-none", + }; + } + default: { + const filledStyles = isFilled + ? "font-medium leading-[20px]" + : "font-normal leading-[24px]"; + // Default state uses primary border (matches Figma - border color same as background, so border is subtle) + return { + input: `bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border border-solid border-[var(--color-border-default-primary)] ${filledStyles}`, + label: "text-[var(--color-content-default-secondary)]", + inputWrapper: "relative", + focusRing: "", + }; + } + } + }; + + const stateStyles = getStateStyles(); + + // Container classes (default label variant only) + const containerClasses = `flex flex-col ${sizeStyles.container}`; + + const labelClasses = `${sizeStyles.label} font-inter`; + + // Base classes without border (border is added in state styles) + const inputClasses = ` + w-full transition-all duration-200 ease-in-out + focus:outline-none focus:ring-0 + placeholder:text-[var(--color-content-default-tertiary,#b4b4b4)] + ${sizeStyles.input} + ${stateStyles.input} + ${className} + `.trim(); + + // Text color for filled text (placeholder color is handled above) + const textColorClass = isFilled + ? "text-[var(--color-content-default-primary)]" + : "text-[var(--color-content-default-tertiary,#b4b4b4)]"; + + // Form field handlers with disabled state handling + const { handleChange, handleBlur } = useFormField(disabled, { + onChange, + onBlur: (e) => { + if (shouldAutoManageFocus) { + setIsFocused(false); + setFocusMethod(null); + wasMouseDownRef.current = false; + } + onBlur?.(e); + }, + }); + + // Handle mouse down to detect mouse clicks + const handleMouseDown = () => { + if (!disabled && shouldAutoManageFocus) { + wasMouseDownRef.current = true; + } + }; + + // Custom focus handler to detect mouse vs keyboard + const handleFocus = (e: React.FocusEvent) => { + if (disabled) return; + + // Detect if focus came from keyboard (Tab) or mouse (click) + // If mouseDown was detected before focus, it's a mouse click (active) + // Otherwise, it's keyboard navigation (focus) + const method = wasMouseDownRef.current ? "mouse" : "keyboard"; + + if (shouldAutoManageFocus) { + setIsFocused(true); + setFocusMethod(method); + // Reset mouse down flag after focus is processed + wasMouseDownRef.current = false; + } + + onFocus?.(e); + }; + + return ( + + ); + }, +); + +TextInputContainer.displayName = "TextInput"; + +export default memo(TextInputContainer); diff --git a/app/components/Input/Input.types.ts b/app/components/TextInput/TextInput.types.ts similarity index 79% rename from app/components/Input/Input.types.ts rename to app/components/TextInput/TextInput.types.ts index 59d4aeb..ef8e0fd 100644 --- a/app/components/Input/Input.types.ts +++ b/app/components/TextInput/TextInput.types.ts @@ -1,9 +1,7 @@ -export interface InputProps extends Omit< +export interface TextInputProps extends Omit< React.InputHTMLAttributes, "size" | "onChange" | "onFocus" | "onBlur" > { - size?: "small" | "medium" | "large"; - labelVariant?: "default" | "horizontal"; state?: "default" | "active" | "hover" | "focus"; disabled?: boolean; error?: boolean; @@ -14,13 +12,12 @@ export interface InputProps extends Omit< onFocus?: (_e: React.FocusEvent) => void; onBlur?: (_e: React.FocusEvent) => void; className?: string; + showHelpIcon?: boolean; } -export interface InputViewProps { +export interface TextInputViewProps { inputId: string; labelId: string; - size: "small" | "medium" | "large"; - labelVariant: "default" | "horizontal"; state: "default" | "active" | "hover" | "focus"; disabled: boolean; error: boolean; @@ -37,4 +34,9 @@ export interface InputViewProps { handleChange: (_e: React.ChangeEvent) => void; handleFocus: (_e: React.FocusEvent) => void; handleBlur: (_e: React.FocusEvent) => void; + handleMouseDown?: () => void; + showHelpIcon?: boolean; + isFilled?: boolean; + inputWrapperClasses?: string; + focusRingClasses?: string; } diff --git a/app/components/TextInput/TextInput.view.tsx b/app/components/TextInput/TextInput.view.tsx new file mode 100644 index 0000000..c815050 --- /dev/null +++ b/app/components/TextInput/TextInput.view.tsx @@ -0,0 +1,83 @@ +import { forwardRef } from "react"; +import { getAssetPath, ASSETS } from "../../../lib/assetUtils"; +import type { TextInputViewProps } from "./TextInput.types"; + +export const TextInputView = forwardRef( + ( + { + inputId, + labelId, + label, + placeholder, + value, + name, + type, + disabled, + error: _error, + className: _className, + containerClasses, + labelClasses, + inputClasses, + borderRadius, + handleChange, + handleFocus, + handleBlur, + handleMouseDown, + showHelpIcon = true, + inputWrapperClasses = "relative", + focusRingClasses = "", + }, + ref, + ) => { + return ( +
+ {label && ( +
+
+ + {showHelpIcon && ( +
+ Help +
+ )} +
+
+ )} +
+
+ +
+ {focusRingClasses && ( + +
+ ); + }, +); + +TextInputView.displayName = "TextInputView"; diff --git a/app/components/TextInput/index.tsx b/app/components/TextInput/index.tsx new file mode 100644 index 0000000..cf1b18f --- /dev/null +++ b/app/components/TextInput/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./TextInput.container"; +export type { TextInputProps } from "./TextInput.types"; diff --git a/lib/assetUtils.ts b/lib/assetUtils.ts index 5001820..6f62476 100644 --- a/lib/assetUtils.ts +++ b/lib/assetUtils.ts @@ -62,4 +62,7 @@ export const ASSETS = { // Tooltip icons ICON_POINTER: "assets/Icon_Pointer.svg", + + // Help icon + ICON_HELP: "assets/Icon_Help.svg", } as const; diff --git a/stories/Create.stories.js b/stories/Create.stories.js index 61c58f8..276c9f2 100644 --- a/stories/Create.stories.js +++ b/stories/Create.stories.js @@ -1,5 +1,5 @@ import Create from "../app/components/Create"; -import Input from "../app/components/Input"; +import TextInput from "../app/components/TextInput"; export default { title: "Components/Create", @@ -57,7 +57,7 @@ Default.args = { description: "You can also combine or add new approaches to the list", children: (
- +

0/48

@@ -77,7 +77,7 @@ WithStepper.args = { description: "You can also combine or add new approaches to the list", children: (
- +

0/48

@@ -155,7 +155,7 @@ NextButtonDisabled.args = { description: "You can also combine or add new approaches to the list", children: (
- +

0/48

diff --git a/stories/Select.stories.js b/stories/SelectInput.stories.js similarity index 62% rename from stories/Select.stories.js rename to stories/SelectInput.stories.js index 6d5a814..bc4e518 100644 --- a/stories/Select.stories.js +++ b/stories/SelectInput.stories.js @@ -1,9 +1,9 @@ import React, { useState } from "react"; -import Select from "../app/components/Select"; +import SelectInput from "../app/components/SelectInput"; export default { - title: "Forms/Select", - component: Select, + title: "Forms/SelectInput", + component: SelectInput, argTypes: { size: { control: { type: "select" }, @@ -35,10 +35,10 @@ export default { const Template = (args) => { const [value, setValue] = useState(""); return ( - setSmallValue(e.target.value)} + onChange={(data) => setSmallValue(data.target.value)} placeholder="Select" - > - - - - - - + options={[ + { value: "item1", label: "Context Menu Item 1" }, + { value: "item2", label: "Context Menu Item 2" }, + { value: "item3", label: "Context Menu Item 3" }, + ]} + />
); }; @@ -182,38 +185,41 @@ export const AllStates = () => { return (
- - - + options={[ + { value: "item1", label: "Context Menu Item 1" }, + { value: "item2", label: "Context Menu Item 2" }, + { value: "item3", label: "Context Menu Item 3" }, + ]} + />
); }; diff --git a/stories/Input.stories.js b/stories/TextInput.stories.js similarity index 89% rename from stories/Input.stories.js rename to stories/TextInput.stories.js index b5b6f34..2f37e69 100644 --- a/stories/Input.stories.js +++ b/stories/TextInput.stories.js @@ -1,9 +1,9 @@ import React from "react"; -import Input from "../app/components/Input"; +import TextInput from "../app/components/TextInput"; export default { - title: "Forms/Input", - component: Input, + title: "Forms/TextInput", + component: TextInput, parameters: { layout: "centered", }, @@ -38,12 +38,12 @@ export default { }, }; -const Template = (args) => ; +const Template = (args) => ; // Default story export const Default = Template.bind({}); Default.args = { - label: "Default Input", + label: "Default Text Input", placeholder: "Enter text...", size: "medium", labelVariant: "default", @@ -53,7 +53,7 @@ Default.args = { // Size variants export const Small = Template.bind({}); Small.args = { - label: "Small Input", + label: "Small Text Input", placeholder: "Small size", size: "small", labelVariant: "default", @@ -62,7 +62,7 @@ Small.args = { export const Medium = Template.bind({}); Medium.args = { - label: "Medium Input", + label: "Medium Text Input", placeholder: "Medium size", size: "medium", labelVariant: "default", @@ -71,7 +71,7 @@ Medium.args = { export const Large = Template.bind({}); Large.args = { - label: "Large Input", + label: "Large Text Input", placeholder: "Large size", size: "large", labelVariant: "default", @@ -151,7 +151,7 @@ export const Interactive = (args) => { return (
- setValue(e.target.value)} @@ -161,7 +161,7 @@ export const Interactive = (args) => { ); }; Interactive.args = { - label: "Interactive Input", + label: "Interactive Text Input", placeholder: "Type something...", size: "medium", labelVariant: "default", @@ -174,7 +174,7 @@ export const AllSizes = () => (

Small Size

- (

Medium Size

- - (

Large Size

- - ( export const AllStates = () => (
-

Input States

+

Text Input States

- - - - - - ; @@ -167,7 +167,7 @@ describe("Create", () => { it("traps focus within create dialog", async () => { renderWithProviders( - + , ); diff --git a/tests/components/Select.test.tsx b/tests/components/SelectInput.test.tsx similarity index 68% rename from tests/components/Select.test.tsx rename to tests/components/SelectInput.test.tsx index b4df46e..d5bc074 100644 --- a/tests/components/Select.test.tsx +++ b/tests/components/SelectInput.test.tsx @@ -1,20 +1,20 @@ import React from "react"; -import Select from "../../app/components/Select"; +import SelectInput from "../../app/components/SelectInput"; import { componentTestSuite } from "../utils/componentTestSuite"; -type SelectProps = React.ComponentProps; +type SelectInputProps = React.ComponentProps; -componentTestSuite({ - component: Select, - name: "Select", +componentTestSuite({ + component: SelectInput, + name: "SelectInput", props: { - label: "Test Select", + label: "Test Select Input", placeholder: "Select an option", options: [ { value: "option1", label: "Option 1" }, { value: "option2", label: "Option 2" }, ], - } as SelectProps, + } as SelectInputProps, requiredProps: ["options"], optionalProps: { size: "medium", diff --git a/tests/components/Input.test.tsx b/tests/components/TextInput.test.tsx similarity index 64% rename from tests/components/Input.test.tsx rename to tests/components/TextInput.test.tsx index ac7243a..c63f8ad 100644 --- a/tests/components/Input.test.tsx +++ b/tests/components/TextInput.test.tsx @@ -1,15 +1,15 @@ import React from "react"; -import Input from "../../app/components/Input"; +import TextInput from "../../app/components/TextInput"; import { componentTestSuite } from "../utils/componentTestSuite"; -type InputProps = React.ComponentProps; +type TextInputProps = React.ComponentProps; -componentTestSuite({ - component: Input, - name: "Input", +componentTestSuite({ + component: TextInput, + name: "TextInput", props: { - label: "Test input", - } as InputProps, + label: "Test text input", + } as TextInputProps, requiredProps: ["label"], optionalProps: { placeholder: "Enter value", -- 2.43.0 From 0e7985287fd13f502dce0e929a6bb8ee5748dba9 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:10:14 -0700 Subject: [PATCH 05/10] Update select input component --- app/components-preview/page.tsx | 87 +----- .../SelectInput/SelectInput.container.tsx | 193 ++++---------- .../SelectInput/SelectInput.view.tsx | 164 +++++++++--- stories/SelectInput.stories.js | 247 +++++++----------- tests/components/SelectInput.test.tsx | 4 +- 5 files changed, 285 insertions(+), 410 deletions(-) diff --git a/app/components-preview/page.tsx b/app/components-preview/page.tsx index 1d3346d..8998bfc 100644 --- a/app/components-preview/page.tsx +++ b/app/components-preview/page.tsx @@ -75,55 +75,11 @@ export default function ComponentsPreview() {
-
-

- All Sizes -

-
- setSelectValue(data.target.value)} - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> - setSelectValue(data.target.value)} - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> - setSelectValue(data.target.value)} - options={[ - { value: "option1", label: "Option 1" }, - { value: "option2", label: "Option 2" }, - { value: "option3", label: "Option 3" }, - ]} - /> -
-
-

States

-
+
+ setSelectValue(data.target.value)} + options={[ + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, + { value: "option3", label: "Option 3" }, + ]} + />
- -
-

- Label Variants -

-
- - -
-
diff --git a/app/components/SelectInput/SelectInput.container.tsx b/app/components/SelectInput/SelectInput.container.tsx index 7a179bc..0449652 100644 --- a/app/components/SelectInput/SelectInput.container.tsx +++ b/app/components/SelectInput/SelectInput.container.tsx @@ -22,12 +22,10 @@ const SelectInputContainer = forwardRef( { id, label, - labelVariant = "default", - size = "medium", - state = "default", + state: externalState = "default", disabled = false, error = false, - placeholder = "Select an option", + placeholder = "Choose an option", className = "", children, value, @@ -45,6 +43,14 @@ const SelectInputContainer = forwardRef( const selectRef = useRef(null); const menuRef = useRef(null); + // Internal state management: track if focused and how (mouse vs keyboard) + const [isFocused, setIsFocused] = useState(false); + const [focusMethod, setFocusMethod] = useState<"mouse" | "keyboard" | null>(null); + const wasMouseDownRef = useRef(false); + + // Determine if we should auto-manage focus (only when state is "default" or undefined) + const shouldAutoManageFocus = externalState === "default" || externalState === undefined; + // Sync internal state with external value prop useEffect(() => { if (value !== undefined && value !== selectedValue) { @@ -69,7 +75,6 @@ const SelectInputContainer = forwardRef( if (onChange) { onChange({ target: { value: optionValue, text: optionText } }); } - // Return focus to the select button for accessibility if (selectRef.current) { selectRef.current.focus(); } @@ -77,6 +82,13 @@ const SelectInputContainer = forwardRef( [onChange], ); + // Handle mouse down to detect mouse clicks + const handleMouseDown = useCallback(() => { + if (!disabled && shouldAutoManageFocus) { + wasMouseDownRef.current = true; + } + }, [disabled, shouldAutoManageFocus]); + // Handle select button click const handleSelectClick = useCallback(() => { if (!disabled) { @@ -99,145 +111,47 @@ const SelectInputContainer = forwardRef( [disabled, isOpen], ); - const getSizeStyles = (): string => { - const baseStyles = "w-full"; + // Handle focus to detect mouse vs keyboard + const handleFocus = useCallback(() => { + if (disabled) return; - switch (size) { - case "small": { - const smallHeight = - labelVariant === "horizontal" ? "h-[30px]" : "h-[32px]"; - return `${baseStyles} ${smallHeight} pl-[12px] pr-[36px] py-[8px] text-[10px] leading-[14px]`; - } - case "medium": - return `${baseStyles} h-[36px] pl-[12px] pr-[36px] py-[8px] text-[14px] leading-[20px]`; - case "large": - return `${baseStyles} h-[40px] pl-[12px] pr-[40px] py-[8px] text-[16px] leading-[24px]`; - default: - return `${baseStyles} h-[36px] pl-[12px] pr-[36px] py-[8px] text-[14px] leading-[20px]`; + const method = wasMouseDownRef.current ? "mouse" : "keyboard"; + + if (shouldAutoManageFocus) { + setIsFocused(true); + setFocusMethod(method); + wasMouseDownRef.current = false; } - }; + }, [disabled, shouldAutoManageFocus]); - const getLabelSizeStyles = (): string => { - switch (size) { - case "small": - return "text-[12px] leading-[14px]"; - case "medium": - return "text-[14px] leading-[16px]"; - case "large": - return "text-[16px] leading-[20px]"; - default: - return "text-[14px] leading-[16px]"; + // Handle blur + const handleBlur = useCallback(() => { + if (shouldAutoManageFocus) { + setIsFocused(false); + setFocusMethod(null); + wasMouseDownRef.current = false; } - }; + }, [shouldAutoManageFocus]); - const getStateStyles = (): { - select: string; - label: string; - } => { - if (disabled) { - return { - select: - "bg-[var(--color-content-default-secondary)] border-[var(--color-border-default-tertiary)] cursor-not-allowed opacity-40", - label: "text-[var(--color-content-default-secondary)]", - }; - } + // Determine actual state: + // - Active: when clicked (mouse focus) or when dropdown is open + // - Focus: when tabbed (keyboard focus) + // - Default: when not focused + const actualState = shouldAutoManageFocus + ? isOpen || isFocused + ? focusMethod === "mouse" || isOpen + ? "active" + : "focus" + : "default" + : externalState; - if (error) { - return { - select: "border-[var(--color-border-default-utility-negative)]", - label: "text-[var(--color-content-default-secondary)]", - }; - } - - switch (state) { - case "hover": - return { - select: - "border-[var(--color-border-default-tertiary)] shadow-[0_0_0_2px_var(--color-border-default-tertiary)]", - label: "text-[var(--color-content-default-secondary)]", - }; - case "focus": - return { - select: - "border-[var(--color-border-default-utility-info)] shadow-[0_0_5px_3px_#3281F8]", - label: "text-[var(--color-content-default-secondary)]", - }; - default: - return { - select: "border-[var(--color-border-default-tertiary)]", - label: "text-[var(--color-content-default-secondary)]", - }; - } - }; - - const getBorderRadius = (): string => { - switch (size) { - case "small": - return "rounded-[var(--measures-radius-small)]"; - case "medium": - return "rounded-[var(--measures-radius-medium)]"; - case "large": - return "rounded-[var(--measures-radius-large)]"; - default: - return "rounded-[var(--measures-radius-medium)]"; - } - }; - - const sizeStyles = getSizeStyles(); - const labelSizeStyles = getLabelSizeStyles(); - const stateStyles = getStateStyles(); - const borderRadius = getBorderRadius(); - - const selectClasses = ` - ${sizeStyles} - ${stateStyles.select} - ${borderRadius} - bg-[var(--color-background-default-primary)] - text-[var(--color-content-default-primary)] - border - font-inter - font-normal - appearance-none - cursor-pointer - transition-all - duration-200 - focus:outline-none - focus-visible:border focus-visible:border-[var(--color-border-default-utility-info)] focus-visible:shadow-[0_0_5px_3px_#3281F8] - text-left - justify-start - hover:shadow-[0_0_0_2px_var(--color-border-default-tertiary)] - ${className} - ` - .trim() - .replace(/\s+/g, " "); - - const labelClasses = ` - ${labelSizeStyles} - ${stateStyles.label} - font-inter - font-medium - block - mb-[4px] - ` - .trim() - .replace(/\s+/g, " "); - - const containerClasses = - labelVariant === "horizontal" - ? "flex items-center gap-[12px]" - : "flex flex-col"; - - const chevronClasses = `${ - size === "large" ? "w-5 h-5" : "w-4 h-4" - } text-[var(--color-content-default-primary)] transition-transform duration-200 ${ - isOpen ? "rotate-180" : "" - }`; + // Determine if select is filled (has selected value) + const isFilled = Boolean(selectedValue && selectedValue.trim().length > 0); // Get display text for selected value const getDisplayText = (): string => { if (!selectedValue) return placeholder; - // Handle options prop if (options && Array.isArray(options)) { const selectedOption = options.find( (option) => option.value === selectedValue, @@ -245,7 +159,6 @@ const SelectInputContainer = forwardRef( return selectedOption ? selectedOption.label : placeholder; } - // Handle children (option elements) const selectedOption = Children.toArray(children).find( ( child, @@ -270,11 +183,9 @@ const SelectInputContainer = forwardRef( ( isOpen={isOpen} selectedValue={selectedValue} displayText={getDisplayText()} - selectClasses={selectClasses} - labelClasses={labelClasses} - containerClasses={containerClasses} - chevronClasses={chevronClasses} + isFilled={isFilled} onButtonClick={handleSelectClick} onButtonKeyDown={handleKeyDown} + onButtonMouseDown={handleMouseDown} + onButtonFocus={handleFocus} + onButtonBlur={handleBlur} onOptionClick={handleOptionSelect} selectRef={selectRef} menuRef={menuRef} diff --git a/app/components/SelectInput/SelectInput.view.tsx b/app/components/SelectInput/SelectInput.view.tsx index ad49ad3..a18035e 100644 --- a/app/components/SelectInput/SelectInput.view.tsx +++ b/app/components/SelectInput/SelectInput.view.tsx @@ -1,4 +1,5 @@ import React, { Children, type ReactNode } from "react"; +import { getAssetPath, ASSETS } from "../../../lib/assetUtils"; import SelectDropdown from "../SelectDropdown"; import SelectOption from "../SelectOption"; import type { SelectOptionData } from "./SelectInput.types"; @@ -6,11 +7,9 @@ import type { SelectOptionData } from "./SelectInput.types"; export interface SelectInputViewProps { label?: string; placeholder: string; - size: "small" | "medium" | "large"; - state: "default" | "hover" | "focus"; + state: "default" | "active" | "hover" | "focus"; disabled: boolean; error: boolean; - labelVariant: "default" | "horizontal"; className: string; options?: SelectOptionData[]; children?: ReactNode; @@ -20,13 +19,13 @@ export interface SelectInputViewProps { isOpen: boolean; selectedValue: string; displayText: string; - selectClasses: string; - labelClasses: string; - containerClasses: string; - chevronClasses: string; + isFilled: boolean; // Callbacks onButtonClick: () => void; onButtonKeyDown: (_e: React.KeyboardEvent) => void; + onButtonMouseDown?: () => void; + onButtonFocus?: () => void; + onButtonBlur?: () => void; onOptionClick: (_value: string, _text: string) => void; // Refs selectRef: React.RefObject; @@ -39,10 +38,9 @@ export interface SelectInputViewProps { export function SelectInputView({ label, placeholder: _placeholder, - size, + state, disabled, - error: _error, - labelVariant: _labelVariant, + error, options, children, selectId, @@ -50,12 +48,12 @@ export function SelectInputView({ isOpen, selectedValue, displayText, - selectClasses, - labelClasses, - containerClasses, - chevronClasses, + isFilled, onButtonClick, onButtonKeyDown, + onButtonMouseDown, + onButtonFocus, + onButtonBlur, onOptionClick, selectRef, menuRef, @@ -63,48 +61,132 @@ export function SelectInputView({ ariaInvalid, ...props }: SelectInputViewProps) { + // Styles based on Figma design + const containerClasses = "flex flex-col gap-[8px]"; + const labelClasses = "text-[14px] leading-[20px] font-medium font-inter text-[var(--color-content-default-primary)]"; + + // Button styles per Figma + const getButtonClasses = (): string => { + const baseClasses = ` + w-full + h-[40px] + px-[12px] + py-[8px] + text-[16px] + font-medium + leading-[20px] + rounded-[8px] + border + border-solid + flex + items-center + justify-between + gap-[12px] + transition-all + duration-200 + focus:outline-none + focus:ring-0 + cursor-pointer + appearance-none + m-0 + `.trim().replace(/\s+/g, " "); + + if (disabled) { + return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-inverse-tertiary,#2d2d2d)] border-[var(--color-border-default-primary)] cursor-not-allowed opacity-40`; + } + + if (error) { + return `${baseClasses} bg-[var(--color-surface-default-primary)] text-[var(--color-content-default-primary)] border-2 border-[var(--color-border-default-utility-negative)]`; + } + + if (state === "focus") { + // Focus state: secondary background, tertiary border, with focus ring + return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border border-solid border-[var(--color-border-default-tertiary)]`; + } + + if (state === "active" || isOpen) { + // Active state per Figma: secondary background, tertiary border + return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border-[var(--color-border-default-tertiary)]`; + } + + // Default state per Figma: secondary background, primary border (subtle) + return `${baseClasses} bg-[var(--color-surface-default-secondary)] text-[var(--color-content-default-primary)] border-[var(--color-border-default-primary)]`; + }; + + const buttonClasses = getButtonClasses(); + + // Text color based on filled state + const textColorClass = isFilled + ? "text-[var(--color-content-default-primary)]" + : "text-[var(--color-content-default-tertiary,#b4b4b4)]"; + + // Chevron icon + const chevronClasses = `w-5 h-5 text-[var(--color-content-default-primary)] transition-transform duration-200 ${ + isOpen ? "rotate-180" : "" + }`; + return (
{label && ( - +
+
+ +
+ Help +
+
+
)}
-
- - - -
+ {state === "focus" && ( +