Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions libs/ui/src/molecules/search-form.figma.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -12,11 +12,14 @@ figma.connect(
md: "md",
lg: "lg",
}),
gapped: figma.boolean("gapped"),
},
example: ({ size }) => (
<SearchForm size={size}>
<SearchForm.Input placeholder="Search..." />
<SearchForm.Button />
example: ({ size, gapped }) => (
<SearchForm gapped={gapped} size={size}>
<SearchForm.Control>
<SearchForm.Input placeholder="Search..." />
<SearchForm.Button />
</SearchForm.Control>
</SearchForm>
),
}
Expand Down
164 changes: 114 additions & 50 deletions libs/ui/src/molecules/search-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -16,62 +18,84 @@ 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",
],
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: ["w-full"],
// The button keeps its own styling/focus ring from the Button atom.
// `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). `inset-y-0` keeps the
// hit target the full height of the input rather than just the icon.
clearButton: [
Comment thread
BleedingDev marked this conversation as resolved.
"h-full shrink-0 rounded-none p-search-form-clear-button",
"peer-hover:bg-input-hover peer-focus:bg-input-focus",
"absolute inset-y-0",
"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. The clear button trails the input by its inline padding.
sm: {
root: "gap-search-form-sm",
control:
"h-form-control-sm rounded-search-form-sm",
button: "h-form-control-sm",
clearButton: "end-(length:--padding-input-sm)",
},
md: {
root: "gap-search-form-md",
control:
"h-form-control-md rounded-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.
false: {
input: "rounded-e-none",
Comment thread
Luko248 marked this conversation as resolved.
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,
},
})

export type SearchFormSize = "sm" | "md" | "lg"

type SearchFormContextValue = {
size: SearchFormSize
gapped: boolean
inputId: string
inputValue: string
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<SearchFormContextValue | null>(null)
Expand All @@ -96,6 +120,7 @@ export interface SearchFormProps

export function SearchForm({
size = "md",
gapped = false,
children,
defaultValue = "",
value,
Expand All @@ -108,6 +133,8 @@ export function SearchForm({
const generatedId = useId()
const inputId = `search-input-${generatedId}`
const [internalValue, setInternalValue] = useState(defaultValue)
const [clearSlot, setClearSlot] = useState<HTMLDivElement | null>(null)
const [hasClearButton, setHasClearButton] = useState(false)
const isControlled = value !== undefined
const inputValue = isControlled ? value : internalValue

Expand All @@ -127,17 +154,22 @@ export function SearchForm({
onSubmit?.(e)
}

const styles = searchFormVariants({ size })
const styles = searchFormVariants({ size, gapped })

return (
<SearchFormContext.Provider
value={{
size,
gapped,
inputId,
inputValue,
setInputValue,
clearInput,
hasValue: inputValue.length > 0,
clearSlot,
setClearSlot,
hasClearButton,
setHasClearButton,
}}
>
<search>
Expand Down Expand Up @@ -180,8 +212,8 @@ SearchForm.Control = function SearchFormControl({
ref,
...props
}: SearchFormControlProps) {
const { size } = useSearchFormContext()
const styles = searchFormVariants({ size })
const { size, gapped } = useSearchFormContext()
const styles = searchFormVariants({ size, gapped })

return (
<div className={styles.control({ className })} ref={ref} {...props}>
Expand All @@ -191,30 +223,45 @@ SearchForm.Control = function SearchFormControl({
}

interface SearchFormInputProps
extends Omit<InputProps, "size" | "value" | "onChange"> {}
extends Omit<
InputProps,
"size" | "value" | "onChange" | "withButtonInside"
> {}

SearchForm.Input = function SearchFormInput({
className,
placeholder = "Search...",
ref,
...props
}: SearchFormInputProps) {
const { inputId, inputValue, setInputValue, size } = useSearchFormContext()
const styles = searchFormVariants({ size })
const {
inputId,
inputValue,
setInputValue,
size,
gapped,
hasValue,
hasClearButton,
setClearSlot,
} = useSearchFormContext()
const styles = searchFormVariants({ size, gapped })

return (
<Input
aria-label={props["aria-label"] || "Search"}
className={styles.input({ className })}
id={inputId}
onChange={(e) => setInputValue(e.target.value)}
placeholder={placeholder}
ref={ref}
size={size}
type="search"
value={inputValue}
{...props}
/>
<div className={styles.inputWrapper()} ref={setClearSlot}>
<Input
aria-label={props["aria-label"] || "Search"}
className={styles.input({ className })}
id={inputId}
onChange={(e) => setInputValue(e.target.value)}
placeholder={placeholder}
ref={ref}
size={size}
type="search"
value={inputValue}
withButtonInside={hasValue && hasClearButton ? "right" : undefined}
Comment thread
Luko248 marked this conversation as resolved.
{...props}
/>
</div>
)
}

Expand All @@ -230,8 +277,8 @@ SearchForm.Button = function SearchFormButton({
iconPosition = "right",
...props
}: SearchFormButtonProps) {
const { size } = useSearchFormContext()
const styles = searchFormVariants({ size })
const { size, gapped } = useSearchFormContext()
const styles = searchFormVariants({ size, gapped })

// Use provided icon, or search icon if showSearchIcon is true
const effectiveIcon =
Expand Down Expand Up @@ -260,14 +307,30 @@ SearchForm.ClearButton = function SearchFormClearButton({
theme = "unstyled",
...props
}: SearchFormClearButtonProps) {
const { size, clearInput, hasValue, inputValue } = useSearchFormContext()
const styles = searchFormVariants({ size })
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(
<Button
aria-label={`Clear search: ${inputValue}`}
className={styles.clearButton({ className })}
Expand All @@ -277,7 +340,8 @@ SearchForm.ClearButton = function SearchFormClearButton({
theme={theme}
type="button"
{...props}
/>
/>,
clearSlot
)
}

Expand Down
10 changes: 9 additions & 1 deletion libs/ui/src/tokens/components/molecules/_search-form.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
/*
* SearchForm — runtime tokens come entirely from
* SearchForm — runtime tokens come mostly from
* libs/ui/src/tokens/figma/variables.css.
*
* This file only declares what Figma does not export:
* - `--spacing-search-form-gapped` — the horizontal gap inserted between
* the input and the button when the `gapped` prop is enabled (8px).
*/

@theme static {
--spacing-search-form-gapped: var(--dimension-8);
}
27 changes: 27 additions & 0 deletions libs/ui/stories/molecules/search-form.stories.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -22,6 +23,11 @@ const meta: Meta<typeof SearchForm> = {
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",
},
},
}

Expand All @@ -41,6 +47,27 @@ export const Default: Story = {
),
}

export const Gapped: Story = {
render: () => (
<div className="w-sm">
<SearchForm gapped onSubmit={fn()}>
<SearchForm.Control>
<SearchForm.Input placeholder="Search products..." />
<SearchForm.Button>Search</SearchForm.Button>
</SearchForm.Control>
</SearchForm>
</div>
),
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: () => (
<div className="w-sm">
Expand Down
Loading
Loading