From ac4cab97528525b2ecd4fce1eb3037866477aa08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 17:55:21 +0000 Subject: [PATCH 1/4] feat(ui): separate focus states for SearchForm input and button Rework SearchForm so the input and button are composed side by side as two independent controls instead of being wrapped in a single bordered control with one shared focus-within ring. - Drop the unified control border/background/overflow and shared focus-within outline. Each control now uses its own default focus ring (input ring on the input, button ring on the button), so focusing one no longer highlights the whole group. - Strip the touching corners with logical radius utilities (input rounded-e-none, button rounded-s-none) so the joined state reads as one unit while keeping each control's own border. - Add a `gapped` boolean prop: when true, inserts an 8px gap between the input and button (new --spacing-search-form-gapped token) and restores the full rounded corners on both controls. - Pin the button to the shared form-control height per size so it always matches the input height across sm/md/lg (the Button atom sizes lg by padding alone). - Update stories (gapped control + Gapped story) and Figma Code Connect. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JvsHgQRBmsL5otpYzsL2Q8 --- libs/ui/src/molecules/search-form.figma.tsx | 11 ++- libs/ui/src/molecules/search-form.tsx | 82 +++++++++---------- .../components/molecules/_search-form.css | 10 ++- .../stories/molecules/search-form.stories.tsx | 26 ++++++ 4 files changed, 80 insertions(+), 49 deletions(-) diff --git a/libs/ui/src/molecules/search-form.figma.tsx b/libs/ui/src/molecules/search-form.figma.tsx index 8e580c404..d688fa3f7 100644 --- a/libs/ui/src/molecules/search-form.figma.tsx +++ b/libs/ui/src/molecules/search-form.figma.tsx @@ -12,11 +12,14 @@ figma.connect( md: "md", lg: "lg", }), + gapped: figma.boolean("gapped"), }, - example: ({ size }) => ( - - - + example: ({ size, gapped }) => ( + + + + + ), } diff --git a/libs/ui/src/molecules/search-form.tsx b/libs/ui/src/molecules/search-form.tsx index 3ac512044..73324ca62 100644 --- a/libs/ui/src/molecules/search-form.tsx +++ b/libs/ui/src/molecules/search-form.tsx @@ -16,50 +16,39 @@ import { tv } from "../utils" const searchFormVariants = tv({ slots: { + // Layout-only wrapper. The input and button are composed side by side and + // each keep their own border, background, radius, and focus ring so they + // focus independently instead of sharing one ring around the whole group. root: ["relative grid"], - control: [ - "relative flex items-center overflow-hidden", - "form-control-base", - "hover:border-input-border-hover", - "focus-within:border-input-border-focus", - "focus-within:outline-(style:--default-ring-style) focus-within:outline-(length:--default-ring-width)", - "focus-within:outline-input-ring", - "focus-within:outline-offset-(length:--default-ring-offset)", - "transition-colors duration-200 motion-reduce:transition-none", - ], - input: [ - "peer", - "min-w-0 flex-1", - "border-none bg-transparent", - "focus-visible:outline-none", - ], - button: [ - "h-full shrink-0 items-center rounded-l-none", - ], - clearButton: [ - "h-full shrink-0 rounded-none p-search-form-clear-button", - "peer-hover:bg-input-hover peer-focus:bg-input-focus", - ], + control: ["flex items-stretch"], + // The input keeps its own styling/focus ring from the Input atom. + input: ["peer", "min-w-0 flex-1"], + // The button keeps its own styling/focus ring from the Button atom. + button: ["shrink-0"], + clearButton: ["shrink-0 self-stretch rounded-none"], }, variants: { size: { - sm: { - root: "gap-search-form-sm", - control: - "h-form-control-sm rounded-search-form-sm", - }, - md: { - root: "gap-search-form-md", - control: - "h-form-control-md rounded-search-form-md", - }, - lg: { - root: "gap-search-form-lg", + // Pin the button to the shared form-control height so it always matches + // the input height — including `lg`, which the Button atom sizes by + // padding alone. + sm: { root: "gap-search-form-sm", button: "h-form-control-sm" }, + md: { root: "gap-search-form-md", button: "h-form-control-md" }, + lg: { root: "gap-search-form-lg", button: "h-form-control-lg" }, + }, + gapped: { + // Joined: strip the touching corners so the two controls read as one. + false: { + input: "rounded-e-none", + button: "rounded-s-none", }, + // Detached: 8px gap and the controls keep their full rounded corners. + true: { control: "gap-search-form-gapped" }, }, }, defaultVariants: { size: "md", + gapped: false, }, }) @@ -67,6 +56,7 @@ export type SearchFormSize = "sm" | "md" | "lg" type SearchFormContextValue = { size: SearchFormSize + gapped: boolean inputId: string inputValue: string setInputValue: (value: string) => void @@ -96,6 +86,7 @@ export interface SearchFormProps export function SearchForm({ size = "md", + gapped = false, children, defaultValue = "", value, @@ -127,12 +118,13 @@ export function SearchForm({ onSubmit?.(e) } - const styles = searchFormVariants({ size }) + const styles = searchFormVariants({ size, gapped }) return ( @@ -199,8 +191,9 @@ SearchForm.Input = function SearchFormInput({ ref, ...props }: SearchFormInputProps) { - const { inputId, inputValue, setInputValue, size } = useSearchFormContext() - const styles = searchFormVariants({ size }) + const { inputId, inputValue, setInputValue, size, gapped } = + useSearchFormContext() + const styles = searchFormVariants({ size, gapped }) return ( = { options: ["sm", "md", "lg"], description: "Controls the size of all search form elements", }, + gapped: { + control: "boolean", + description: + "When true, adds an 8px gap between the input and button and restores their rounded corners", + }, }, } @@ -41,6 +46,27 @@ export const Default: Story = { ), } +export const Gapped: Story = { + render: () => ( +
+ console.log("submit")}> + + + Search + + +
+ ), + parameters: { + docs: { + description: { + story: + "With `gapped`, the input and button are detached by an 8px gap and each keep their own rounded corners. Focusing the input or the button shows that control's focus ring independently.", + }, + }, + }, +} + export const WithLabel: Story = { render: () => (
From 60c1ebe7a6a2583746e72450dc3a4fa40c3532d1 Mon Sep 17 00:00:00 2001 From: Lukas Chylik Date: Wed, 17 Jun 2026 22:48:42 +0200 Subject: [PATCH 2/4] fix(ui): address SearchForm review feedback + Figma/overview sync - ClearButton corners now follow the gapped variant (square when joined, rounded when detached) instead of being hard-coded square in the base slot - Gapped story uses fn() for onSubmit per the Storybook style guide - Point Code Connect at the rebuilt SearchForm Figma node - Add SearchForm (joined + gapped) to the component-comparison overview Co-Authored-By: Claude Opus 4.8 --- libs/ui/src/molecules/search-form.figma.tsx | 2 +- libs/ui/src/molecules/search-form.tsx | 9 +++-- .../stories/molecules/search-form.stories.tsx | 3 +- .../overview/component-comparison.stories.tsx | 34 +++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/libs/ui/src/molecules/search-form.figma.tsx b/libs/ui/src/molecules/search-form.figma.tsx index d688fa3f7..05a5fba9a 100644 --- a/libs/ui/src/molecules/search-form.figma.tsx +++ b/libs/ui/src/molecules/search-form.figma.tsx @@ -3,7 +3,7 @@ import { SearchForm } from "./search-form" figma.connect( SearchForm, - "https://www.figma.com/design/12xb1pqXKwE2vbOByN3ntg/New-Design-System-vol.-2?node-id=1146-48", + "https://www.figma.com/design/12xb1pqXKwE2vbOByN3ntg/New-Design-System-vol.-2?node-id=2620-122", { imports: ['import { SearchForm } from "@libs/ui/molecules/search-form"'], props: { diff --git a/libs/ui/src/molecules/search-form.tsx b/libs/ui/src/molecules/search-form.tsx index 73324ca62..1150c8de1 100644 --- a/libs/ui/src/molecules/search-form.tsx +++ b/libs/ui/src/molecules/search-form.tsx @@ -25,7 +25,9 @@ const searchFormVariants = tv({ input: ["peer", "min-w-0 flex-1"], // The button keeps its own styling/focus ring from the Button atom. button: ["shrink-0"], - clearButton: ["shrink-0 self-stretch rounded-none"], + // Radius is left to the gapped variant so it stays consistent with the + // input and button, which also manage their own corners per gapped state. + clearButton: ["shrink-0 self-stretch"], }, variants: { size: { @@ -38,11 +40,14 @@ const searchFormVariants = tv({ }, gapped: { // Joined: strip the touching corners so the two controls read as one. + // The clear button sits between them, so it stays square too. false: { input: "rounded-e-none", button: "rounded-s-none", + clearButton: "rounded-none", }, - // Detached: 8px gap and the controls keep their full rounded corners. + // Detached: 8px gap and the controls keep their full rounded corners, + // including the clear button (radius managed by the Button atom). true: { control: "gap-search-form-gapped" }, }, }, diff --git a/libs/ui/stories/molecules/search-form.stories.tsx b/libs/ui/stories/molecules/search-form.stories.tsx index de39f465a..a1ddd52f8 100644 --- a/libs/ui/stories/molecules/search-form.stories.tsx +++ b/libs/ui/stories/molecules/search-form.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react" import { useState } from "react" +import { fn } from "storybook/test" import { VariantContainer, VariantGroup } from "../../.storybook/decorator" import { SearchForm } from "../../src/molecules/search-form" @@ -49,7 +50,7 @@ export const Default: Story = { export const Gapped: Story = { render: () => (
- console.log("submit")}> + Search diff --git a/libs/ui/stories/overview/component-comparison.stories.tsx b/libs/ui/stories/overview/component-comparison.stories.tsx index 9cd5825c5..f8a8e29fa 100644 --- a/libs/ui/stories/overview/component-comparison.stories.tsx +++ b/libs/ui/stories/overview/component-comparison.stories.tsx @@ -443,6 +443,40 @@ function ComponentComparison() { ))} + {(['sm', 'md'] as const).map((size) => ( + +
+ + + + Search + + +
+ +
+ ))} + {(['sm', 'md'] as const).map((size) => ( + +
+ + + + Search + + +
+ +
+ ))}
Date: Thu, 18 Jun 2026 07:49:35 +0200 Subject: [PATCH 3/4] feat(ui): render SearchForm clear button inside the input + fix focus z-index - Clear button is now absolutely positioned inside the input at its trailing edge (= input padding-inline-end) via a portal into the input wrapper, instead of sitting as a flex sibling between the input and the button. The input reserves trailing padding (withButtonInside) only when a clear button is composed. - Focused control now wins the stacking order (focus-within/focus-visible z-10 on the input wrapper and button), so the focus ring is never painted underneath the adjacent control. - Drop the now-unused clearButton rounded-none joined-corner handling. - component-comparison: SearchForm has an integrated search button, so remove the extra standalone Button placed next to it. Co-Authored-By: Claude Opus 4.8 --- libs/ui/src/molecules/search-form.tsx | 125 +++++++++++++----- .../overview/component-comparison.stories.tsx | 34 ++--- 2 files changed, 107 insertions(+), 52 deletions(-) diff --git a/libs/ui/src/molecules/search-form.tsx b/libs/ui/src/molecules/search-form.tsx index 1150c8de1..efa24f873 100644 --- a/libs/ui/src/molecules/search-form.tsx +++ b/libs/ui/src/molecules/search-form.tsx @@ -5,9 +5,11 @@ import { type ReactNode, type Ref, useContext, + useEffect, useId, useState, } from "react" +import { createPortal } from "react-dom" import type { VariantProps } from "tailwind-variants" import { Button, type ButtonProps } from "../atoms/button" import { Input, type InputProps } from "../atoms/input" @@ -21,33 +23,51 @@ const searchFormVariants = tv({ // focus independently instead of sharing one ring around the whole group. root: ["relative grid"], control: ["flex items-stretch"], + // Positioning context for the absolutely-placed clear button, and the + // flex item that holds the input. `focus-within:z-10` lifts the focused + // input (and its outline) above the adjacent button so the focus ring is + // never painted underneath it. + inputWrapper: ["relative min-w-0 flex-1", "focus-within:z-10"], // The input keeps its own styling/focus ring from the Input atom. - input: ["peer", "min-w-0 flex-1"], + input: ["w-full"], // The button keeps its own styling/focus ring from the Button atom. - button: ["shrink-0"], - // Radius is left to the gapped variant so it stays consistent with the - // input and button, which also manage their own corners per gapped state. - clearButton: ["shrink-0 self-stretch"], + // `focus-visible:z-10` mirrors the input so a focused button outline wins. + button: ["relative shrink-0", "focus-visible:z-10"], + // The clear button lives inside the input, pinned to the trailing edge at + // the input's inline padding (set per size below). + clearButton: [ + "-translate-y-1/2 absolute top-1/2", + "inline-flex items-center justify-center", + ], }, variants: { size: { // Pin the button to the shared form-control height so it always matches // the input height — including `lg`, which the Button atom sizes by - // padding alone. - sm: { root: "gap-search-form-sm", button: "h-form-control-sm" }, - md: { root: "gap-search-form-md", button: "h-form-control-md" }, - lg: { root: "gap-search-form-lg", button: "h-form-control-lg" }, + // padding alone. The clear button trails the input by its inline padding. + sm: { + root: "gap-search-form-sm", + button: "h-form-control-sm", + clearButton: "end-(length:--padding-input-sm)", + }, + md: { + root: "gap-search-form-md", + button: "h-form-control-md", + clearButton: "end-(length:--padding-input-md)", + }, + lg: { + root: "gap-search-form-lg", + button: "h-form-control-lg", + clearButton: "end-(length:--padding-input-lg)", + }, }, gapped: { // Joined: strip the touching corners so the two controls read as one. - // The clear button sits between them, so it stays square too. false: { input: "rounded-e-none", button: "rounded-s-none", - clearButton: "rounded-none", }, - // Detached: 8px gap and the controls keep their full rounded corners, - // including the clear button (radius managed by the Button atom). + // Detached: 8px gap and the controls keep their full rounded corners. true: { control: "gap-search-form-gapped" }, }, }, @@ -67,6 +87,14 @@ type SearchFormContextValue = { setInputValue: (value: string) => void clearInput: () => void hasValue: boolean + // The input wrapper element the clear button portals into so it renders + // inside the input regardless of where it is composed in the JSX. + clearSlot: HTMLDivElement | null + setClearSlot: (element: HTMLDivElement | null) => void + // Whether a clear button is composed, so the input can reserve trailing + // padding for it only when one is present. + hasClearButton: boolean + setHasClearButton: (present: boolean) => void } const SearchFormContext = createContext(null) @@ -104,6 +132,8 @@ export function SearchForm({ const generatedId = useId() const inputId = `search-input-${generatedId}` const [internalValue, setInternalValue] = useState(defaultValue) + const [clearSlot, setClearSlot] = useState(null) + const [hasClearButton, setHasClearButton] = useState(false) const isControlled = value !== undefined const inputValue = isControlled ? value : internalValue @@ -135,6 +165,10 @@ export function SearchForm({ setInputValue, clearInput, hasValue: inputValue.length > 0, + clearSlot, + setClearSlot, + hasClearButton, + setHasClearButton, }} > @@ -196,23 +230,34 @@ SearchForm.Input = function SearchFormInput({ ref, ...props }: SearchFormInputProps) { - const { inputId, inputValue, setInputValue, size, gapped } = - useSearchFormContext() + const { + inputId, + inputValue, + setInputValue, + size, + gapped, + hasValue, + hasClearButton, + setClearSlot, + } = useSearchFormContext() const styles = searchFormVariants({ size, gapped }) return ( - setInputValue(e.target.value)} - placeholder={placeholder} - ref={ref} - size={size} - type="search" - value={inputValue} - {...props} - /> +
+ setInputValue(e.target.value)} + placeholder={placeholder} + ref={ref} + size={size} + type="search" + value={inputValue} + withButtonInside={hasValue && hasClearButton ? "right" : undefined} + {...props} + /> +
) } @@ -258,15 +303,30 @@ SearchForm.ClearButton = function SearchFormClearButton({ theme = "unstyled", ...props }: SearchFormClearButtonProps) { - const { size, gapped, clearInput, hasValue, inputValue } = - useSearchFormContext() + const { + size, + gapped, + clearInput, + hasValue, + inputValue, + clearSlot, + setHasClearButton, + } = useSearchFormContext() const styles = searchFormVariants({ size, gapped }) - if (!hasValue) { + // Tell the input a clear button is composed so it reserves trailing padding. + useEffect(() => { + setHasClearButton(true) + return () => setHasClearButton(false) + }, [setHasClearButton]) + + if (!(hasValue && clearSlot)) { return null } - return ( + // Render inside the input wrapper so the clear button sits inside the input, + // pinned to its trailing edge, instead of between the input and the button. + return createPortal( + + + + Search + + ))} {(['sm', 'md'] as const).map((size) => ( -
- - - - Search - - -
- + + + + Search + +
))}
From 3fb93f17631e748fd042d526b3cdb6beea4c0f02 Mon Sep 17 00:00:00 2001 From: Lukas Chylik Date: Thu, 18 Jun 2026 08:02:42 +0200 Subject: [PATCH 4/4] fix(ui): address review feedback on SearchForm clear button - Clear button hit target now spans the full input height (inset-y-0) instead of collapsing to the icon size (Kilo) - Omit the internal withButtonInside prop from SearchForm.Input's public props so consumers can't override the reserved clear-button padding (Kilo) - Use token width (w-full max-w-xs) for the SearchForm comparison cards to match the other form-control cards (CodeRabbit) Co-Authored-By: Claude Opus 4.8 --- libs/ui/src/molecules/search-form.tsx | 10 +++++++--- .../stories/overview/component-comparison.stories.tsx | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/libs/ui/src/molecules/search-form.tsx b/libs/ui/src/molecules/search-form.tsx index efa24f873..b79a063d6 100644 --- a/libs/ui/src/molecules/search-form.tsx +++ b/libs/ui/src/molecules/search-form.tsx @@ -34,9 +34,10 @@ const searchFormVariants = tv({ // `focus-visible:z-10` mirrors the input so a focused button outline wins. button: ["relative shrink-0", "focus-visible:z-10"], // The clear button lives inside the input, pinned to the trailing edge at - // the input's inline padding (set per size below). + // the input's inline padding (set per size below). `inset-y-0` keeps the + // hit target the full height of the input rather than just the icon. clearButton: [ - "-translate-y-1/2 absolute top-1/2", + "absolute inset-y-0", "inline-flex items-center justify-center", ], }, @@ -222,7 +223,10 @@ SearchForm.Control = function SearchFormControl({ } interface SearchFormInputProps - extends Omit {} + extends Omit< + InputProps, + "size" | "value" | "onChange" | "withButtonInside" + > {} SearchForm.Input = function SearchFormInput({ className, diff --git a/libs/ui/stories/overview/component-comparison.stories.tsx b/libs/ui/stories/overview/component-comparison.stories.tsx index dfb374400..c53de887f 100644 --- a/libs/ui/stories/overview/component-comparison.stories.tsx +++ b/libs/ui/stories/overview/component-comparison.stories.tsx @@ -447,7 +447,7 @@ function ComponentComparison() { @@ -461,7 +461,7 @@ function ComponentComparison() {