{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "segment-builder",
  "type": "registry:component",
  "title": "Segment Builder",
  "description": "Describe an audience in a few rules and watch it form: the head count rolls, the size bar slides, and real people from your sample glide in and out of the list as each rule tightens or loosens.",
  "categories": [
    "component",
    "Filters"
  ],
  "dependencies": [
    "@qnot/core",
    "@qnot/react",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/input.json",
    "https://coss.com/ui/r/select.json"
  ],
  "files": [
    {
      "path": "registry/devl/segment-builder.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  type AnyFieldDef,\n  compile,\n  type EditorSpec,\n  type Fields,\n  formatInput,\n  formatList,\n  type Issue,\n  parseInput,\n  parseList,\n  type Query,\n  type QueryOf,\n  validateQuery,\n} from \"@qnot/core\";\nimport { QueryProvider, useGroup, useQuery, useQueryStore, useRule, useStore } from \"@qnot/react\";\nimport { PlusIcon, UsersRoundIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { memo, useEffect, useMemo, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from \"@/components/ui/select\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SegmentPerson {\n  id: string;\n  name: string;\n  email: string;\n  [attribute: string]: unknown;\n}\n\nexport interface SegmentBuilderProps {\n  fields: Fields;\n  people: readonly SegmentPerson[];\n  defaultName?: string;\n  defaultQuery?: Query;\n  sampleSize?: number;\n  describe?: (person: SegmentPerson) => string;\n  onChange?: (segment: { name: string; query: Query; size: number }) => void;\n  className?: string;\n}\n\nconst easeOut = [0.23, 1, 0.32, 1] as const;\nconst glide = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\nconst tones = [\n  \"bg-violet-500/15 text-violet-700 dark:text-violet-300\",\n  \"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300\",\n  \"bg-amber-500/15 text-amber-700 dark:text-amber-300\",\n  \"bg-sky-500/15 text-sky-700 dark:text-sky-300\",\n  \"bg-rose-500/15 text-rose-700 dark:text-rose-300\",\n];\n\nexport function SegmentBuilder({\n  fields,\n  people,\n  defaultName = \"Untitled segment\",\n  defaultQuery = { kind: \"group\", combinator: \"and\", children: [] },\n  sampleSize = 5,\n  describe,\n  onChange,\n  className,\n}: SegmentBuilderProps) {\n  const [name, setName] = useState(defaultName);\n  const store = useQueryStore({ fields, initial: defaultQuery as QueryOf<Fields> });\n  return (\n    <QueryProvider store={store}>\n      <div\n        className={cn(\n          \"flex w-full min-w-0 max-w-[580px] flex-col overflow-hidden rounded-2xl border bg-card text-sm shadow-xs/5\",\n          className,\n        )}\n      >\n        <div className=\"px-4 pt-3.5\">\n          <input\n            aria-label=\"Segment name\"\n            className=\"-mx-1.5 w-[calc(100%+0.75rem)] rounded-md bg-transparent px-1.5 py-0.5 font-medium outline-none transition-colors placeholder:text-muted-foreground hover:bg-accent/50 focus-visible:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring\"\n            onChange={(e) => setName(e.target.value)}\n            placeholder=\"Name this segment\"\n            value={name}\n          />\n        </div>\n        <Audience describe={describe} name={name} onChange={onChange} people={people} sampleSize={sampleSize} />\n      </div>\n    </QueryProvider>\n  );\n}\n\nfunction Audience({\n  people,\n  sampleSize,\n  name,\n  describe,\n  onChange,\n}: {\n  people: readonly SegmentPerson[];\n  sampleSize: number;\n  name: string;\n  describe?: (person: SegmentPerson) => string;\n  onChange?: SegmentBuilderProps[\"onChange\"];\n}) {\n  const reduce = useReducedMotion();\n  const store = useStore();\n  const query = useQuery();\n  const issues = useMemo(() => validateQuery(store.fields, query), [store, query]);\n  const broken = issues.some((i) => i.severity === \"error\" && i.code !== \"required\");\n  const incomplete = issues.filter((i) => i.code === \"required\").length;\n  const matching = useMemo(() => (broken ? [] : people.filter(compile(store.fields, query) as (p: SegmentPerson) => boolean)), [broken, people, query, store]);\n  const size = broken ? 0 : matching.length;\n  const share = people.length ? size / people.length : 0;\n\n  useEffect(() => {\n    onChange?.({ name, query, size });\n  }, [name, query, size, onChange]);\n\n  const spring = useSpring(size, { duration: 0.45, bounce: 0 });\n  const shown = useTransform(spring, (v) => Math.round(v).toLocaleString());\n  useEffect(() => {\n    if (reduce) spring.jump(size);\n    else spring.set(size);\n  }, [size, reduce, spring]);\n\n  const sample = matching.slice(0, sampleSize);\n  const rest = size - sample.length;\n\n  return (\n    <>\n      <div className=\"px-4 pt-1 pb-3\">\n        <div className=\"flex items-end justify-between gap-3\">\n          <p className=\"flex items-baseline gap-1.5\">\n            <motion.span className=\"font-semibold text-2xl tabular-nums tracking-tight\">{broken ? \"—\" : shown}</motion.span>\n            <span className=\"text-muted-foreground text-xs\">people</span>\n          </p>\n          <p className=\"pb-1 text-muted-foreground text-xs tabular-nums\">\n            {broken ? \"Fix the rule below\" : `${(share * 100).toFixed(1)}% of ${people.length.toLocaleString()}`}\n          </p>\n        </div>\n        <div aria-hidden className=\"mt-2 h-1.5 overflow-hidden rounded-full bg-muted\">\n          <motion.div\n            animate={{ scaleX: share }}\n            className=\"h-full w-full origin-left rounded-full bg-primary\"\n            initial={false}\n            transition={reduce ? { duration: 0 } : glide}\n          />\n        </div>\n      </div>\n\n      <div className=\"border-t px-4 py-3\">\n        <Conditions\n          id={store.rootId}\n          status={incomplete > 0 ? `${incomplete} incomplete, ignored` : null}\n        />\n      </div>\n\n      <div className=\"border-t bg-muted/32 px-2 pt-2.5 pb-2 dark:bg-muted/16\">\n        <div className=\"flex items-baseline justify-between px-2 pb-1.5\">\n          <h4 className=\"font-medium text-xs\">In this segment</h4>\n          {rest > 0 && <span className=\"text-muted-foreground text-xs tabular-nums\">and {rest.toLocaleString()} more</span>}\n        </div>\n        <div className=\"relative\" style={{ height: `${sampleSize * 2.5}rem` }}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {sample.map((person, index) => (\n              <motion.div\n                animate={{ opacity: 1 }}\n                className=\"flex h-10 items-center gap-2.5 rounded-lg px-2\"\n                exit={{ opacity: 0, transition: { duration: reduce ? 0 : 0.12 } }}\n                initial={{ opacity: 0 }}\n                key={person.id}\n                layout={reduce ? false : \"position\"}\n                layoutDependency={index}\n                transition={reduce ? { duration: 0 } : { ...glide, opacity: { duration: 0.18, ease: easeOut } }}\n              >\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"flex size-7 shrink-0 items-center justify-center rounded-full font-semibold text-[10px]\",\n                    tones[person.id.charCodeAt(person.id.length - 1) % tones.length],\n                  )}\n                >\n                  {person.name\n                    .split(\" \")\n                    .map((p) => p[0])\n                    .join(\"\")}\n                </span>\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block truncate font-medium text-xs\">{person.name}</span>\n                  <span className=\"block truncate text-muted-foreground text-xs\">{person.email}</span>\n                </span>\n                {describe && <span className=\"shrink-0 text-muted-foreground text-xs tabular-nums max-[400px]:hidden\">{describe(person)}</span>}\n              </motion.div>\n            ))}\n          </AnimatePresence>\n          <AnimatePresence>\n            {size === 0 && (\n              <motion.div\n                animate={{ opacity: 1 }}\n                className=\"absolute inset-0 flex flex-col items-center justify-center gap-1 text-center\"\n                exit={{ opacity: 0 }}\n                initial={{ opacity: 0 }}\n                transition={{ duration: 0.18 }}\n              >\n                <UsersRoundIcon className=\"mb-1 size-5 text-muted-foreground\" />\n                <p className=\"font-medium text-xs\">{broken ? \"This segment can't be evaluated yet\" : \"Nobody matches\"}</p>\n                <p className=\"text-muted-foreground text-xs\">\n                  {broken ? \"Fix the highlighted rule to see who is in it.\" : \"Loosen a rule, or match any rule instead of all.\"}\n                </p>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n    </>\n  );\n}\n\nfunction Conditions({ id, status }: { id: string; status: string | null }) {\n  const { group, addRule, setCombinator } = useGroup(id);\n  return (\n    <div className=\"@container flex flex-col gap-2.5 @sm:gap-1.5\">\n      {group.children.map((child, i) => (\n        <div className=\"flex items-start gap-1.5\" key={child}>\n          {i === 0 ? (\n            <span className=\"flex h-8 w-11 shrink-0 sm:h-7 items-center text-muted-foreground text-xs\">Where</span>\n          ) : i === 1 ? (\n            <button\n              aria-label={`Match ${group.combinator === \"and\" ? \"any\" : \"all\"} rules instead`}\n              className=\"flex h-8 w-11 shrink-0 sm:h-7 items-center justify-center rounded-lg border border-input bg-background font-medium text-xs shadow-xs/5 outline-none transition-[background-color,scale] duration-150 hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] dark:bg-input/32\"\n              onClick={() => setCombinator(group.combinator === \"and\" ? \"or\" : \"and\")}\n              type=\"button\"\n            >\n              {group.combinator}\n            </button>\n          ) : (\n            <span className=\"flex h-8 w-11 shrink-0 sm:h-7 items-center ps-1.5 text-muted-foreground text-xs\">{group.combinator}</span>\n          )}\n          <div className=\"min-w-0 flex-1\">\n            <Condition id={child} />\n          </div>\n        </div>\n      ))}\n      {group.children.length === 0 && (\n        <p className=\"rounded-xl border border-dashed px-3 py-3 text-center text-muted-foreground text-xs\">\n          No rules yet, so everyone is in this segment.\n        </p>\n      )}\n      <div className={cn(\"flex items-center justify-between gap-2\", group.children.length > 0 && \"ps-12.5\")}>\n        <Button className={cn(group.children.length > 0 && \"-ms-2\")} onClick={() => addRule()} size=\"xs\" variant=\"ghost\">\n          <PlusIcon />\n          Add rule\n        </Button>\n        {status && <span className=\"min-w-0 truncate text-muted-foreground text-xs\">{status}</span>}\n      </div>\n    </div>\n  );\n}\n\nconst Condition = memo(function Condition({ id }: { id: string }) {\n  const { rule, def, editor, operators, fieldOptions, issues, setField, setOperator, setValue, remove } = useRule(id);\n  const field = typeof rule.field === \"string\" ? rule.field : \"\";\n  const label = fieldOptions.find((o) => o.value === field)?.label ?? field;\n  const errors = issues.filter((i) => i.severity === \"error\" && i.code !== \"required\");\n  return (\n    <div>\n      <div className=\"grid grid-cols-[minmax(0,1fr)_2rem] items-start gap-1.5 @sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2rem] @lg:grid-cols-[8.5rem_8rem_minmax(0,1fr)_2rem]\">\n        <Select items={fieldOptions} onValueChange={(v) => v && setField(v)} value={field}>\n          <SelectTrigger aria-label=\"Attribute\" className=\"col-start-1 row-start-1 min-w-0\" size=\"sm\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectPopup>\n            {fieldOptions.map((o) => (\n              <SelectItem key={o.value} value={o.value}>\n                {o.label}\n              </SelectItem>\n            ))}\n          </SelectPopup>\n        </Select>\n        <Select items={operators} onValueChange={(v) => v && setOperator(v)} value={rule.operator}>\n          <SelectTrigger\n            aria-label=\"Operator\"\n            className=\"col-start-1 row-start-2 min-w-0 @sm:col-start-2 @sm:row-start-1\"\n            size=\"sm\"\n          >\n            <SelectValue />\n          </SelectTrigger>\n          <SelectPopup>\n            {operators.map((o) => (\n              <SelectItem key={o.value} value={o.value}>\n                {o.label}\n              </SelectItem>\n            ))}\n          </SelectPopup>\n        </Select>\n        <div\n          className={cn(\n            \"col-start-1 row-start-3 min-w-0 @sm:col-span-2 @sm:row-start-2 @lg:col-span-1 @lg:col-start-3 @lg:row-start-1\",\n            editor.arity === \"none\" && \"hidden\",\n          )}\n        >\n          <ValueEditor def={def} editor={editor} issues={issues} label={label} onChange={setValue} value={rule.value} />\n        </div>\n        <Button\n          aria-label={`Remove condition on ${label}`}\n          className=\"col-start-2 row-start-1 @sm:col-start-3 @lg:col-start-4\"\n          onClick={remove}\n          size=\"icon-sm\"\n          variant=\"ghost\"\n        >\n          <XIcon />\n        </Button>\n      </div>\n      {errors.length > 0 && <p className=\"pt-1 text-destructive-foreground text-xs\">{errors.map((e) => e.message).join(\" · \")}</p>}\n    </div>\n  );\n});\n\nfunction ValueEditor({\n  editor,\n  def,\n  value,\n  issues,\n  label,\n  onChange,\n}: {\n  editor: EditorSpec;\n  def: AnyFieldDef | undefined;\n  value: unknown;\n  issues: readonly Issue[];\n  label: string;\n  onChange: (value: unknown) => void;\n}) {\n  const invalidAt = (index?: number) =>\n    issues.some((i) => i.severity === \"error\" && i.code !== \"required\" && (index === undefined || i.index === undefined || i.index === index));\n  if (editor.arity === \"none\") return null;\n  if (editor.arity === \"two\") {\n    const pair = Array.isArray(value) ? value : [null, null];\n    return (\n      <div className=\"flex items-center gap-1.5\">\n        <Scalar def={def} editor={editor} invalid={invalidAt(0)} label={`${label} from`} onChange={(v) => onChange([v, pair[1] ?? null])} value={pair[0]} />\n        <span className=\"shrink-0 text-muted-foreground text-xs\">and</span>\n        <Scalar def={def} editor={editor} invalid={invalidAt(1)} label={`${label} to`} onChange={(v) => onChange([pair[0] ?? null, v])} value={pair[1]} />\n      </div>\n    );\n  }\n  if (editor.type === \"multiselect\") {\n    const options = editor.options ?? [];\n    const selected = Array.isArray(value) ? value.map(String) : [];\n    return (\n      <Select\n        items={options.map((o) => ({ value: String(o.value), label: o.label }))}\n        multiple\n        onValueChange={(next) => onChange((next as string[]).map((raw) => parseInput(def, raw)))}\n        value={selected}\n      >\n        <SelectTrigger aria-invalid={invalidAt() || undefined} aria-label={`${label} values`} className=\"min-w-0\" size=\"sm\">\n          <SelectValue>\n            {(vals: string[]) =>\n              vals.length === 0 ? (\n                <span className=\"text-muted-foreground\">Choose…</span>\n              ) : (\n                vals.map((v) => options.find((o) => String(o.value) === v)?.label ?? v).join(\", \")\n              )\n            }\n          </SelectValue>\n        </SelectTrigger>\n        <SelectPopup>\n          {options.map((o) => (\n            <SelectItem key={String(o.value)} value={String(o.value)}>\n              {o.label}\n            </SelectItem>\n          ))}\n        </SelectPopup>\n      </Select>\n    );\n  }\n  if (editor.type === \"list\") {\n    return (\n      <DraftInput\n        format={formatList}\n        invalid={invalidAt()}\n        label={`${label} values`}\n        onChange={onChange}\n        parse={(raw) => parseList(def, raw)}\n        placeholder=\"Comma, separated\"\n        value={value}\n      />\n    );\n  }\n  return <Scalar def={def} editor={editor} invalid={invalidAt()} label={label} onChange={onChange} value={value} />;\n}\n\nfunction Scalar({\n  editor,\n  def,\n  value,\n  invalid,\n  label,\n  onChange,\n}: {\n  editor: EditorSpec;\n  def: AnyFieldDef | undefined;\n  value: unknown;\n  invalid: boolean;\n  label: string;\n  onChange: (value: unknown) => void;\n}) {\n  if (editor.type === \"select\" || editor.type === \"boolean\") {\n    const items =\n      editor.type === \"boolean\"\n        ? [\n            { value: \"true\", label: \"Yes\" },\n            { value: \"false\", label: \"No\" },\n          ]\n        : (editor.options ?? []).map((o) => ({ value: String(o.value), label: o.label }));\n    return (\n      <Select\n        items={items}\n        onValueChange={(raw) => raw !== null && onChange(parseInput(def, String(raw)))}\n        value={value === null || value === undefined ? null : String(value)}\n      >\n        <SelectTrigger aria-invalid={invalid || undefined} aria-label={label} className=\"min-w-0\" size=\"sm\">\n          <SelectValue placeholder=\"Choose…\" />\n        </SelectTrigger>\n        <SelectPopup>\n          {items.map((o) => (\n            <SelectItem key={o.value} value={o.value}>\n              {o.label}\n            </SelectItem>\n          ))}\n        </SelectPopup>\n      </Select>\n    );\n  }\n  return (\n    <DraftInput\n      format={formatInput}\n      inputMode={editor.type === \"number\" ? \"decimal\" : undefined}\n      invalid={invalid}\n      label={label}\n      onChange={onChange}\n      parse={(raw) => parseInput(def, raw)}\n      placeholder={editor.type === \"number\" ? \"0\" : \"Value\"}\n      type={editor.type === \"date\" ? \"date\" : \"text\"}\n      value={value}\n    />\n  );\n}\n\nfunction DraftInput({\n  value,\n  parse,\n  format,\n  invalid,\n  label,\n  placeholder,\n  inputMode,\n  type = \"text\",\n  onChange,\n}: {\n  value: unknown;\n  parse: (raw: string) => unknown;\n  format: (value: unknown) => string;\n  invalid: boolean;\n  label: string;\n  placeholder?: string;\n  inputMode?: \"decimal\";\n  type?: \"text\" | \"date\";\n  onChange: (value: unknown) => void;\n}) {\n  const [draft, setDraft] = useState<string | null>(null);\n  const unreadable = draft !== null && draft.trim() !== \"\" && parse(draft) === null;\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1\">\n      <Input\n        aria-invalid={invalid || unreadable || undefined}\n        aria-label={label}\n        className={type === \"date\" ? \"[color-scheme:light] dark:[color-scheme:dark]\" : undefined}\n        inputMode={inputMode}\n        onBlur={() => setDraft(null)}\n        onChange={(e) => {\n          setDraft(e.target.value);\n          onChange(parse(e.target.value));\n        }}\n        onFocus={() => setDraft(format(value))}\n        placeholder={placeholder}\n        size=\"sm\"\n        type={type}\n        value={draft ?? format(value)}\n      />\n      {unreadable && <p className=\"text-destructive-foreground text-xs\">Enter a number</p>}\n    </div>\n  );\n}\n\n"
    }
  ]
}