All recipes

Recipe

Tag input

A record's tags with suggestions, where typing a name adds a tag or creates it. Web and native call useTagInput from better-supabase/blocks/tags/react.

The tags blockWebReact Native

1.What you build

No screenshot yet. Run the Next.js example (pnpm dev:example) to see it.

2.Add the components

Web: shadcn

terminal
npx shadcn add badge button input skeleton

Native: React Native

The snippet uses ActivityIndicator, Button, Pressable, Text, TextInput, View from react-native, so it needs no styling library. For shadcn-style native components, React Native Reusables serves them from a registry the shadcn CLI reads.

3.Install the glue

Install the block with npx better-supabase sql add tags. The glue items are copied into your app, so edit them as you need.

Web

None: the hook calls the block from the client.

Native

None: the hook calls the block from the client.

4.Write the component

Web: React and shadcn

View in the example
tag-input.tsx
"use client";

import { useTagInput } from "better-supabase/blocks/tags/react";
import { XIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { type SubmitEvent, useId, useState } from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { blockConfig } from "@/lib/supabase/blocks.generated";
import { useErrorMessage } from "@/lib/use-error-message";

/**
 * A customer's tags. Typing a name adds the organization's tag with that
 * name, or creates it for callers with `tags.manage`.
 */
export function TagInput({
  organizationId,
  customerId,
}: {
  organizationId: string;
  customerId: string;
}) {
  const t = useExtracted("customers");
  const listId = useId();
  const errorMessage = useErrorMessage();
  const [name, setName] = useState("");
  const { tags, available, pending, error, create, remove } = useTagInput({
    organizationId,
    subjectType: "customer",
    subjectId: customerId,
    kind: "customer",
    schema: blockConfig.tags.api,
  });
  const applied = new Set(tags?.map((tag) => tag.id));
  const suggestions = available?.filter((tag) => !applied.has(tag.id)) ?? [];

  const submit = async (event: SubmitEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (!name.trim()) return;
    if ((await create(name)).ok) setName("");
  };

  if (tags === undefined) return <Skeleton className="h-16" />;
  return (
    <div className="space-y-3">
      <ul className="flex flex-wrap gap-1.5" data-testid="tags">
        {tags.length === 0 ? (
          <li className="text-muted-foreground text-sm">{t("No tags yet.")}</li>
        ) : null}
        {tags.map((tag) => (
          <li key={tag.id}>
            <Badge
              variant="secondary"
              className={pending.includes(tag.id) ? "opacity-50" : undefined}
            >
              {tag.name}
              <button
                type="button"
                aria-label={t("Remove {name}", { name: tag.name })}
                disabled={pending.includes(tag.id)}
                onClick={() => {
                  void remove(tag.id);
                }}
              >
                <XIcon className="size-3" />
              </button>
            </Badge>
          </li>
        ))}
      </ul>
      <form onSubmit={(event) => void submit(event)} className="flex gap-2">
        <Input
          value={name}
          onChange={(event) => {
            setName(event.target.value);
          }}
          list={listId}
          maxLength={80}
          placeholder={t("Add a tag")}
          aria-label={t("Tag")}
        />
        <datalist id={listId}>
          {suggestions.map((tag) => (
            <option key={tag.id} value={tag.name}>
              {tag.name}
            </option>
          ))}
        </datalist>
        <Button type="submit" variant="outline" disabled={!name.trim()}>
          {t("Add")}
        </Button>
      </form>
      {error ? (
        <p className="text-destructive text-sm">{errorMessage(error)}</p>
      ) : null}
    </div>
  );
}

Native: React Native

View in the example
tag-input.tsx
import { useTagInput } from "better-supabase/blocks/tags/react";
import { tenantOf, useAuth } from "better-supabase/react";
import { useState } from "react";
import {
  ActivityIndicator,
  Button,
  Pressable,
  Text,
  TextInput,
  View,
} from "react-native";

import { API_SCHEMA } from "../lib/blocks";

/**
 * A customer's tags as chips that remove themselves when pressed, the
 * organization's other tags as suggestions, and a field that adds or
 * creates one by name.
 */
export function TagInput({ customerId }: { readonly customerId: string }) {
  const [name, setName] = useState("");
  const { tags, available, pending, error, add, remove, create } = useTagInput({
    organizationId: tenantOf(useAuth()),
    subjectType: "customer",
    subjectId: customerId,
    kind: "customer",
    schema: API_SCHEMA,
  });
  if (tags === undefined) return <ActivityIndicator />;
  const applied = new Set(tags.map((tag) => tag.id));
  const suggestions = (available ?? []).filter((tag) => !applied.has(tag.id));

  const chip = (label: string, busy: boolean, onPress: () => void) => (
    <Pressable
      key={label}
      accessibilityRole="button"
      disabled={busy}
      onPress={onPress}
      style={{
        borderWidth: 1,
        borderRadius: 16,
        paddingHorizontal: 10,
        paddingVertical: 2,
        opacity: busy ? 0.5 : 1,
      }}
    >
      <Text>{label}</Text>
    </Pressable>
  );

  return (
    <View style={{ gap: 8 }}>
      <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
        {tags.length === 0 ? <Text>No tags yet.</Text> : null}
        {tags.map((tag) =>
          chip(`${tag.name} ×`, pending.includes(tag.id), () => {
            void remove(tag.id);
          }),
        )}
      </View>
      <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
        {suggestions.map((tag) =>
          chip(`+ ${tag.name}`, pending.includes(tag.id), () => {
            void add(tag.id);
          }),
        )}
      </View>
      {error ? <Text style={{ color: "#b91c1c" }}>{error.message}</Text> : null}
      <TextInput
        value={name}
        onChangeText={setName}
        placeholder="Add a tag"
        maxLength={80}
        style={{ borderWidth: 1, borderRadius: 8, padding: 8 }}
      />
      <Button
        title="Add"
        disabled={!name.trim()}
        onPress={() =>
          void create(name).then((result) => {
            if (result.ok) setName("");
          })
        }
      />
    </View>
  );
}