Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(input-component): support icons left/right #5407

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
32 changes: 32 additions & 0 deletions apps/www/__registry__/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1885,6 +1885,22 @@ export const Index: Record<string, any> = {
subcategory: "",
chunks: []
},
"input-with-icons": {
name: "input-with-icons",
description: "",
type: "registry:example",
registryDependencies: ["input"],
files: [{
path: "registry/new-york/example/input-with-icons.tsx",
type: "registry:example",
target: ""
}],
component: React.lazy(() => import("@/registry/new-york/example/input-with-icons.tsx")),
source: "",
category: "",
subcategory: "",
chunks: []
},
"input-otp-demo": {
name: "input-otp-demo",
description: "",
Expand Down Expand Up @@ -7378,6 +7394,22 @@ export const Index: Record<string, any> = {
subcategory: "",
chunks: []
},
"input-with-icons": {
name: "input-with-icons",
description: "",
type: "registry:example",
registryDependencies: ["input"],
files: [{
path: "registry/default/example/input-with-icons.tsx",
type: "registry:example",
target: ""
}],
component: React.lazy(() => import("@/registry/default/example/input-with-icons.tsx")),
source: "",
category: "",
subcategory: "",
chunks: []
},
"input-otp-demo": {
name: "input-otp-demo",
description: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,16 @@ export default function Component() {

const filteredData = chartData.filter((item) => {
const date = new Date(item.date)
const now = new Date()
const referenceDate = new Date("2024-06-30")
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
now.setDate(now.getDate() - daysToSubtract)
return date >= now
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})

return (
Expand Down
7 changes: 7 additions & 0 deletions apps/www/content/docs/components/input.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,10 @@ import { Input } from "@/components/ui/input"
### Form

<ComponentPreview name="input-form" />

### With Icons

<ComponentPreview
name="input-with-icons"
description="An input with icons on both sides"
/>
15 changes: 15 additions & 0 deletions apps/www/public/r/styles/default/input-with-icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "input-with-icons",
"type": "registry:example",
"registryDependencies": [
"input"
],
"files": [
{
"path": "example/input-with-icons.tsx",
"content": "import { MapIcon, MapPin } from \"lucide-react\"\n\nimport { Input } from \"@/registry/default/ui/input\"\n\nexport default function InputWithIcons() {\n return (\n <div>\n <Input>\n <Input.Icon side=\"left\">\n <MapIcon />\n </Input.Icon>\n <Input.Icon side=\"right\">\n <MapPin />\n </Input.Icon>\n </Input>\n </div>\n )\n}\n",
"type": "registry:example",
"target": ""
}
]
}
2 changes: 1 addition & 1 deletion apps/www/public/r/styles/default/input.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"files": [
{
"path": "ui/input.tsx",
"content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Input = React.forwardRef<\n HTMLInputElement,\n React.ComponentProps<\"input\">\n>(({ className, type, ...props }, ref) => {\n return (\n <input\n type={type}\n className={cn(\n \"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n className\n )}\n ref={ref}\n {...props}\n />\n )\n})\nInput.displayName = \"Input\"\n\nexport { Input }\n",
"content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useComposition } from \"@/registry/default/hooks/use-composition\"\n\ninterface InputComposition {\n Icon: typeof InputIcon\n}\n\nconst iconVariants = cva(\"absolute top-3\", {\n variants: {\n size: {\n default: \"h-4 w-4\",\n },\n side: {\n left: \"left-3\",\n right: \"right-3\",\n },\n },\n defaultVariants: {\n size: \"default\",\n side: \"left\",\n },\n})\n\nexport interface InputIconProps\n extends React.HTMLAttributes<HTMLOrSVGElement>,\n VariantProps<typeof iconVariants> {}\n\nconst InputIcon = React.forwardRef<HTMLSlotElement, InputIconProps>(\n ({ children, className, size, side }, ref) => {\n return (\n <Slot\n data-icon\n ref={ref}\n className={cn(iconVariants({ size, side }), className)}\n >\n {children}\n </Slot>\n )\n }\n)\nInputIcon.displayName = \"InputIcon\"\n\nconst inputVariants = cva(\n \"flex h-10 w-full rounded-md border border-input bg-background py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50\",\n {\n variants: {\n composition: {\n true: \"px-10\",\n false: \"px-3\",\n },\n },\n defaultVariants: {\n composition: false,\n },\n }\n)\nexport interface InputProps\n extends React.InputHTMLAttributes<HTMLInputElement> {}\n\nconst Input = React.forwardRef<HTMLInputElement, InputProps>(\n ({ className, type, ...props }, ref) => {\n return <input type={type} className={className} ref={ref} {...props} />\n }\n)\nInput.displayName = \"Input\"\n\nconst Root = React.forwardRef<HTMLInputElement, InputProps>(\n ({ children, className, ...props }, ref) => {\n const IconLeft = useComposition(children, \"InputIconLeft\")\n const IconRight = useComposition(children, \"InputIconRight\")\n\n if (IconLeft || IconRight) {\n return (\n <div className=\"relative\">\n {IconLeft}\n <Input\n ref={ref}\n className={cn(\n inputVariants({ composition: !!IconLeft || !!IconRight }),\n className\n )}\n {...props}\n />\n {IconRight}\n </div>\n )\n }\n return (\n <Input\n ref={ref}\n className={cn(\n inputVariants({ composition: !!IconLeft || !!IconRight }),\n className\n )}\n {...props}\n />\n )\n }\n) as React.ForwardRefExoticComponent<\n InputProps & React.RefAttributes<HTMLInputElement>\n> &\n InputComposition\n\nRoot.displayName = \"Input\"\nRoot.Icon = InputIcon\n\nexport { Root as Input }\n",
"type": "registry:ui",
"target": ""
}
Expand Down

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions apps/www/public/r/styles/new-york/input-with-icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "input-with-icons",
"type": "registry:example",
"registryDependencies": [
"input"
],
"files": [
{
"path": "example/input-with-icons.tsx",
"content": "import { MapIcon, MapPin } from \"lucide-react\"\n\nimport { Input } from \"@/registry/new-york/ui/input\"\n\nexport default function InputWithIcons() {\n return (\n <div>\n <Input>\n <Input.Icon side=\"left\">\n <MapIcon />\n </Input.Icon>\n <Input.Icon side=\"right\">\n <MapPin />\n </Input.Icon>\n </Input>\n </div>\n )\n}\n",
"type": "registry:example",
"target": ""
}
]
}
2 changes: 1 addition & 1 deletion apps/www/public/r/styles/new-york/input.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"files": [
{
"path": "ui/input.tsx",
"content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Input = React.forwardRef<\n HTMLInputElement,\n React.ComponentProps<\"input\">\n>(({ className, type, ...props }, ref) => {\n return (\n <input\n type={type}\n className={cn(\n \"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm\",\n className\n )}\n ref={ref}\n {...props}\n />\n )\n})\nInput.displayName = \"Input\"\n\nexport { Input }\n",
"content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useComposition } from \"@/registry/default/hooks/use-composition\"\n\ninterface InputComposition {\n IconLeft: typeof InputIconLeft\n IconRight: typeof InputIconRight\n}\n\nconst iconVariants = cva(\"absolute left-3 top-3\", {\n variants: {\n size: {\n default: \"h-3 w-3\",\n },\n },\n defaultVariants: {\n size: \"default\",\n },\n})\n\nexport interface InputIconProps\n extends React.HTMLAttributes<HTMLOrSVGElement>,\n VariantProps<typeof iconVariants> {}\n\nconst InputIconLeft = React.forwardRef<HTMLSlotElement, InputIconProps>(\n ({ children, className, size }, ref) => {\n return (\n <Slot ref={ref} className={cn(iconVariants({ size }), className)}>\n {children}\n </Slot>\n )\n }\n)\nInputIconLeft.displayName = \"InputIconLeft\"\n\nconst InputIconRight = React.forwardRef<HTMLSlotElement, InputIconProps>(\n ({ children, className, size }, ref) => {\n return (\n <Slot ref={ref} className={cn(iconVariants({ size }), className)}>\n {children}\n </Slot>\n )\n }\n)\nInputIconRight.displayName = \"InputIconRight\"\n\nconst inputVariants = cva(\n \"flex h-9 w-full rounded-md border border-input bg-transparent py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n {\n variants: {\n composition: {\n true: \"px-9\",\n false: \"px-3\",\n },\n },\n defaultVariants: {\n composition: false,\n },\n }\n)\nexport interface InputProps\n extends React.InputHTMLAttributes<HTMLInputElement> {}\n\nconst Input = React.forwardRef<HTMLInputElement, InputProps>(\n ({ className, type, ...props }, ref) => {\n return <input type={type} className={className} ref={ref} {...props} />\n }\n)\nInput.displayName = \"Input\"\n\nconst Root = React.forwardRef<HTMLInputElement, InputProps>(\n ({ children, className, ...props }, ref) => {\n const IconLeft = useComposition(children, \"InputIconLeft\")\n const IconRight = useComposition(children, \"InputIconRight\")\n\n if (IconLeft || IconRight) {\n return (\n <div className=\"relative\">\n {IconLeft}\n <Input\n ref={ref}\n className={cn(\n inputVariants({ composition: !!IconLeft || !!IconRight }),\n className\n )}\n {...props}\n />\n {IconRight}\n </div>\n )\n }\n return (\n <Input\n ref={ref}\n className={cn(\n inputVariants({ composition: !!IconLeft || !!IconRight }),\n className\n )}\n {...props}\n />\n )\n }\n) as React.ForwardRefExoticComponent<\n InputProps & React.RefAttributes<HTMLInputElement>\n> &\n InputComposition\n\nRoot.displayName = \"Input\"\nRoot.IconLeft = InputIconLeft\nRoot.IconRight = InputIconRight\n\nexport { Root as Input }\n",
"type": "registry:ui",
"target": ""
}
Expand Down
18 changes: 18 additions & 0 deletions apps/www/registry/default/example/input-with-icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { MapIcon, MapPin } from "lucide-react"

import { Input } from "@/registry/default/ui/input"

export default function InputWithIcons() {
return (
<div>
<Input>
<Input.Icon side="left">
<MapIcon />
</Input.Icon>
<Input.Icon side="right">
<MapPin />
</Input.Icon>
</Input>
</div>
)
}
29 changes: 29 additions & 0 deletions apps/www/registry/default/hooks/use-composition.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from "react"

type ReactElementWithDisplayName = React.ReactElement & {
type: { displayName: string }
}

const isElement = (
element: unknown | undefined
): element is ReactElementWithDisplayName => {
return (
(element as ReactElementWithDisplayName)?.type?.displayName !== undefined
)
}

export function useComposition(
children: React.ReactNode,
component: string | undefined
) {
const Children = React.useMemo(
() =>
React.Children.toArray(children).filter((child) => {
if (isElement(child)) return child.type.displayName === component
return false
}),
[children, component]
)

return Children
}
101 changes: 91 additions & 10 deletions apps/www/registry/default/ui/input.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,103 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { useComposition } from "@/registry/default/hooks/use-composition"

const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
interface InputComposition {
Icon: typeof InputIcon
}

const iconVariants = cva("absolute top-3", {
variants: {
size: {
default: "h-4 w-4",
},
side: {
left: "left-3",
right: "right-3",
},
},
defaultVariants: {
size: "default",
side: "left",
},
})

export interface InputIconProps
extends React.HTMLAttributes<HTMLOrSVGElement>,
VariantProps<typeof iconVariants> {}

const InputIcon = React.forwardRef<HTMLSlotElement, InputIconProps>(
({ children, className, size, side }, ref) => {
return (
<Slot
data-icon
ref={ref}
className={cn(iconVariants({ size, side }), className)}
>
{children}
</Slot>
)
}
)
InputIcon.displayName = "InputIcon"

const inputVariants = cva(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
{
variants: {
composition: {
true: "px-10",
false: "px-3",
},
},
defaultVariants: {
composition: false,
},
}
)
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return <input type={type} className={className} ref={ref} {...props} />
}
)
Input.displayName = "Input"

const Root = React.forwardRef<HTMLInputElement, InputProps>(
({ children, className, ...props }, ref) => {
const Icons = useComposition(children, InputIcon.displayName)

if (Icons.length > 0) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve noticed one issue with composition. For my use case and most of the time when building other input components I’ve been playing with the padding-x property only on the side where the icon is added. This way, there’s no extra space on both sides from only one icon being present.

Something like this is what I thought could do the job:

 ...
 const Icons = useComposition(children, InputIcon.displayName);

    if (Icons.length > 0) {
      // Since we're sure that the children are InputIcon components, we can cast them to the correct type
      // to access the `side` prop
      type IconElement = React.ReactElement<InputIconProps>;

      const hasRightIcon = Icons.some(
        (icon) => (icon as IconElement).props.side === 'right',
      );
      const hasLeftIcon = Icons.some(
        (icon) =>
          (icon as IconElement).props.side === 'left' ||
          (icon as IconElement).props.side === undefined,
      );
      
      // conditionally check for composition (maybe add more variants such as left, right, true, false)
   ...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@florinkrasniqi I don't fully understand the comment:
do you mean I should be using px-3 instead of left-3 for example? (see iconVariants)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for any confusion. The whole idea is to not have input padding on both sides if only one icon is present but instead only on the icon side.

return (
<div className="relative">
{Icons}
<Input
ref={ref}
className={cn(inputVariants({ composition: true }), className)}
{...props}
/>
</div>
)
}
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
<Input
ref={ref}
className={cn(inputVariants({ composition: false }), className)}
{...props}
/>
)
}
)
Input.displayName = "Input"
) as React.ForwardRefExoticComponent<
InputProps & React.RefAttributes<HTMLInputElement>
> &
InputComposition

Root.displayName = "Input"
Root.Icon = InputIcon

export { Input }
export { Root as Input }
18 changes: 18 additions & 0 deletions apps/www/registry/new-york/example/input-with-icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { MapIcon, MapPin } from "lucide-react"

import { Input } from "@/registry/new-york/ui/input"

export default function InputWithIcons() {
return (
<div>
<Input>
<Input.Icon side="left">
<MapIcon />
</Input.Icon>
<Input.Icon side="right">
<MapPin />
</Input.Icon>
</Input>
</div>
)
}
26 changes: 26 additions & 0 deletions apps/www/registry/new-york/hooks/use-composition.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as React from "react"

type ReactElementWithDisplayName = React.ReactElement & {
type: { displayName: string }
}

const isElement = (
element: unknown | undefined
): element is ReactElementWithDisplayName => {
return (
(element as ReactElementWithDisplayName)?.type?.displayName !== undefined
)
}

export function useComposition(children: React.ReactNode, component: string) {
const Child = React.useMemo(
() =>
React.Children.toArray(children).find((child) => {
if (isElement(child)) return child.type.displayName === component
return false
}),
[children, component]
)

return Child ?? null
}
Loading