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 with tags #3647

Open
2 tasks done
donfour opened this issue May 2, 2024 · 1 comment
Open
2 tasks done

[feat]: Input with tags #3647

donfour opened this issue May 2, 2024 · 1 comment

Comments

@donfour
Copy link

donfour commented May 2, 2024

Feature description

Motivation

In many forms, it's a common use case for a user to have to input an array of strings for a field (#2236).

For example, when sharing a Figma file with other users, I can input multiple emails:
Screen Shot 2024-05-02 at 10 52 37 AM

I think this is a neat and common user pattern that could be useful to many people.

Proposal

An input component that allows users to enter multiple values. These become tags that appear within the input. Users can delete these tags afterwards.

Implementation

I tried implementing the component myself (demo here):

Screen Shot 2024-05-02 at 11 07 41 AM

Current behaviors

  1. Add a new tag on "," or "Enter"
  2. Click X to delete a tag
  3. When pasting in a comma-separated string, it's automatically turned into tags
  4. Can tab between tags and input
  5. Can hit backspace to delete a tag
  6. Duplicate values are ignored

Component implementation

// input-tags.tsx

"use client";

import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
import { cn } from "@/utils/cn";
import { type InputProps } from "./input";

type InputTagsProps = Omit<InputProps, "value" | "onChange"> & {
  value: string[];
  onChange: React.Dispatch<React.SetStateAction<string[]>>;
};

const InputTags = React.forwardRef<HTMLInputElement, InputTagsProps>(
  ({ className, value, onChange, ...props }, ref) => {
    const [pendingDataPoint, setPendingDataPoint] = React.useState("");

    React.useEffect(() => {
      if (pendingDataPoint.includes(",")) {
        const newDataPoints = new Set([
          ...value,
          ...pendingDataPoint.split(",").map((chunk) => chunk.trim()),
        ]);
        onChange(Array.from(newDataPoints));
        setPendingDataPoint("");
      }
    }, [pendingDataPoint, onChange, value]);

    const addPendingDataPoint = () => {
      if (pendingDataPoint) {
        const newDataPoints = new Set([...value, pendingDataPoint]);
        onChange(Array.from(newDataPoints));
        setPendingDataPoint("");
      }
    };

    return (
      <div
        className={cn(
          // caveat: :has() variant requires tailwind v3.4 or above: https://tailwindcss.com/blog/tailwindcss-v3-4#new-has-variant
          "has-[:focus-visible]:outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-neutral-950 has-[:focus-visible]:ring-offset-2 dark:has-[:focus-visible]:ring-neutral-300 min-h-10 flex w-full flex-wrap gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm ring-offset-white  disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950",
          className
        )}
      >
        {value.map((item) => (
          <Badge key={item} variant="secondary">
            {item}
            <Button
              variant="ghost"
              size="icon"
              className="ml-2 h-3 w-3"
              onClick={() => {
                onChange(value.filter((i) => i !== item));
              }}
            >
              <XIcon className="w-3" />
            </Button>
          </Badge>
        ))}
        <input
          className="flex-1 outline-none placeholder:text-neutral-500 dark:placeholder:text-neutral-400"
          value={pendingDataPoint}
          onChange={(e) => setPendingDataPoint(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === "Enter" || e.key === ",") {
              e.preventDefault();
              addPendingDataPoint();
            } else if (
              e.key === "Backspace" &&
              pendingDataPoint.length === 0 &&
              value.length > 0
            ) {
              e.preventDefault();
              onChange(value.slice(0, -1));
            }
          }}
          {...props}
          ref={ref}
        />
      </div>
    );
  }
);

InputTags.displayName = "InputTags";

export { InputTags };

Component usage

"use client";

import { useState } from "react";
import { InputTags } from "@/components/ui/input-tags";

export default function Page() {
  const [values, setValues] = useState<string[]>([]);
  return (
        <InputTags
          value={values}
          onChange={setValues}
          placeholder="Enter values, comma separated..."
          className="max-w-[500px]"
        />
  );
}

Inspirations

Let me know if this is useful! I'm more than happy to contribute a PR 😄

Affected component/components

Input, Badge

Additional Context

Additional details here...

Before submitting

  • I've made research efforts and searched the documentation
  • I've searched for existing issues and PRs
@izakfilmalter
Copy link

I liked this a lot. Added a validator to it that can optionally be passed in:

'use client'

import { forwardRef, useEffect, useState } from 'react'
import { Option, pipe, ReadonlyArray, String } from 'effect'
import { XIcon } from 'lucide-react'
import type { z } from 'zod'

import { noOp } from '@steepleinc/shared'

import { Badge } from '~/components/ui/badge'
import { Button } from '~/components/ui/button'
import { cn } from '~/shared/utils'
import type { InputProps } from './input'

const parseTagOpt = (params: { tag: string; tagValidator: z.ZodString }) => {
  const { tag, tagValidator } = params

  const parsedTag = tagValidator.safeParse(tag)

  if (parsedTag.success) {
    return pipe(parsedTag.data, Option.some)
  }

  return Option.none()
}

type TagInputProps = Omit<InputProps, 'value' | 'onChange'> & {
  value?: ReadonlyArray<string>
  onChange: (value: ReadonlyArray<string>) => void
  tagValidator?: z.ZodString
}

const TagInput = forwardRef<HTMLInputElement, TagInputProps>((props, ref) => {
  const { className, value = [], onChange, tagValidator, ...domProps } = props

  const [pendingDataPoint, setPendingDataPoint] = useState('')

  useEffect(() => {
    if (pendingDataPoint.includes(',')) {
      const newDataPoints = new Set([
        ...value,
        ...pipe(
          pendingDataPoint,
          String.split(','),
          ReadonlyArray.filterMap((x) => {
            const trimmedX = pipe(x, String.trim)

            return pipe(
              tagValidator,
              Option.fromNullable,
              Option.match({
                onNone: () => pipe(trimmedX, Option.some),
                onSome: (y) => parseTagOpt({ tag: trimmedX, tagValidator: y }),
              }),
            )
          }),
        ),
      ])
      onChange(Array.from(newDataPoints))
      setPendingDataPoint('')
    }
  }, [pendingDataPoint, onChange, value, tagValidator])

  const addPendingDataPoint = () => {
    if (pendingDataPoint) {
      pipe(
        tagValidator,
        Option.fromNullable,
        Option.match({
          onNone: () => {
            const newDataPoints = new Set([...value, pendingDataPoint])
            onChange(Array.from(newDataPoints))
            setPendingDataPoint('')
          },
          onSome: (y) =>
            pipe(
              parseTagOpt({ tag: pendingDataPoint, tagValidator: y }),
              Option.match({
                onNone: noOp,
                onSome: (x) => {
                  const newDataPoints = new Set([...value, x])
                  onChange(Array.from(newDataPoints))
                  setPendingDataPoint('')
                },
              }),
            ),
        }),
      )
    }
  }

  return (
    <div
      className={cn(
        // caveat: :has() variant requires tailwind v3.4 or above: https://tailwindcss.com/blog/tailwindcss-v3-4#new-has-variant
        'has-[:focus-visible]:ring-neutral-950 dark:has-[:focus-visible]:ring-neutral-300 border-neutral-200 dark:border-neutral-800 dark:bg-neutral-950 dark:ring-offset-neutral-950 flex min-h-10 w-full flex-wrap gap-2 rounded-md border bg-white px-3 py-2 text-sm ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 has-[:focus-visible]:outline-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-offset-2',
        className,
      )}
    >
      {value.map((item) => (
        <Badge key={item} variant={'secondary'}>
          {item}
          <Button
            variant={'ghost'}
            size={'icon'}
            className={'ml-2 h-3 w-3'}
            onClick={() => {
              onChange(value.filter((i) => i !== item))
            }}
          >
            <XIcon className={'w-3'} />
          </Button>
        </Badge>
      ))}
      <input
        className={
          'placeholder:text-neutral-500 dark:placeholder:text-neutral-400 flex-1 outline-none'
        }
        value={pendingDataPoint}
        onChange={(e) => setPendingDataPoint(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter' || e.key === ',') {
            e.preventDefault()
            addPendingDataPoint()
          } else if (
            e.key === 'Backspace' &&
            pendingDataPoint.length === 0 &&
            value.length > 0
          ) {
            e.preventDefault()
            onChange(value.slice(0, -1))
          }
        }}
        {...domProps}
        ref={ref}
      />
    </div>
  )
})

TagInput.displayName = 'TagInput'

export { TagInput }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants