RegistryUI recipes
Tag input
A record's tags with suggestions, where typing a name adds a tag or creates it.
Both sides call useTagInput from better-supabase/blocks/tags/react; the tags block must be installed (npx better-supabase add tags). Its page on bettersupabase.com is /recipes/tag-input.
Web, with shadcn components:
npx shadcn add badge button input skeleton"use client";
import type { SubmitEvent } from "react";
import { useTagInput } from "better-supabase/blocks/tags/react";
import { XIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { 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 { useErrorMessage } from "@/lib/use-error-message";
/**
* A record's tags. Typing a name adds the organization's tag of the subject's
* kind with that name, or creates it for callers with `tags.manage`.
*/
export function TagInput({
subject,
}: {
subject: { readonly type: string; readonly id: string };
}) {
const t = useExtracted("customers");
const listId = useId();
const errorMessage = useErrorMessage();
const [name, setName] = useState("");
const { tags, available, pending, error, create, remove } = useTagInput({
subject,
kind: subject.type,
});
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-sm text-muted-foreground">{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-sm text-destructive">{errorMessage(error)}</p>
) : null}
</div>
);
}React Native, with ActivityIndicator, Button, Pressable, Text, TextInput, View from react-native:
import { useTagInput } from "better-supabase/blocks/tags/react";
import { useState } from "react";
import {
ActivityIndicator,
Button,
Pressable,
Text,
TextInput,
View,
} from "react-native";
/**
* 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({
subject: { type: "customer", id: customerId },
kind: "customer",
});
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>
);
}Last updated on