| 1 | import { mergeProps } from "@base-ui/react/merge-props"; |
| 2 | import { useRender } from "@base-ui/react/use-render"; |
| 3 | import { cva, type VariantProps } from "class-variance-authority"; |
| 4 | import type * as React from "react"; |
| 5 | |
| 6 | import { cn } from "~/lib/utils"; |
| 7 | |
| 8 | /** |
| 9 | * The inline pill for one piece of chat context. It lives with the chat components rather than |
| 10 | * in components/ui because it is one feature's look, not a generic primitive. |
| 11 | * |
| 12 | * An inline pill for one piece of context (a file, mention, terminal excerpt, pull request, |
| 13 | * skill…) that sits in running text, both in the composer and in sent messages. |
| 14 | * |
| 15 | * Metrics are in em so the chip scales with the text around it (the composer honors the |
| 16 | * prompt font-size preference). Each kind keeps one restrained color identity: every accent |
| 17 | * shares a lightness so no kind reads heavier than another, and only hue carries identity. |
| 18 | * A consumer may override `--context-chip-accent` through `style` for a color that comes |
| 19 | * from content, such as an image's average color. |
| 20 | * |
| 21 | * Renders a span by default. Render it as a button, link or popover/tooltip trigger to make it |
| 22 | * interactive; the hover tint follows from that, not from a prop. Anything focusable (including a |
| 23 | * span with tabIndex for a tooltip) gets the focus outline. |
| 24 | */ |
| 25 | const contextChipVariants = cva( |
| 26 | "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] border px-[0.5em] align-middle font-medium text-[0.86em] leading-none [&_svg]:block [&_svg]:size-[1.17em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer [button&,a&]:transition-colors [button&,a&]:motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-foreground disabled:cursor-default", |
| 27 | { |
| 28 | defaultVariants: { kind: "neutral" }, |
| 29 | variants: { |
| 30 | kind: { |
| 31 | neutral: "border-border/70 bg-accent/40 text-foreground", |
| 32 | image: "[--context-chip-accent:oklch(0.62_0.16_16)]", |
| 33 | video: "[--context-chip-accent:oklch(0.62_0.16_48)]", |
| 34 | file: "[--context-chip-accent:oklch(0.62_0.136_237)]", |
| 35 | mention: "[--context-chip-accent:oklch(0.62_0.11_215)]", |
| 36 | terminal: "[--context-chip-accent:oklch(0.62_0.134_163)]", |
| 37 | element: "[--context-chip-accent:oklch(0.62_0.134_70)]", |
| 38 | "preview-annotation": "[--context-chip-accent:oklch(0.62_0.134_70)]", |
| 39 | "review-comment": "[--context-chip-accent:oklch(0.62_0.16_292)]", |
| 40 | "pull-request": "[--context-chip-accent:oklch(0.62_0.16_277)]", |
| 41 | "pr-open": "[--context-chip-accent:oklch(0.62_0.134_163)]", |
| 42 | "pr-draft": "[--context-chip-accent:oklch(0.62_0.02_259)]", |
| 43 | "pr-merged": "[--context-chip-accent:oklch(0.62_0.16_292)]", |
| 44 | "pr-closed": "[--context-chip-accent:oklch(0.62_0.16_16)]", |
| 45 | skill: "[--context-chip-accent:oklch(0.62_0.16_322)]", |
| 46 | thread: "[--context-chip-accent:oklch(0.62_0.12_190)]", |
| 47 | citation: "[--context-chip-accent:oklch(0.62_0.16_259)]", |
| 48 | }, |
| 49 | // Colors live in compoundVariants below so they come after the kind colors. |
| 50 | state: { |
| 51 | unresolved: "border-dashed", |
| 52 | invalid: "", |
| 53 | }, |
| 54 | }, |
| 55 | compoundVariants: [ |
| 56 | { |
| 57 | kind: [ |
| 58 | "image", |
| 59 | "video", |
| 60 | "file", |
| 61 | "mention", |
| 62 | "terminal", |
| 63 | "element", |
| 64 | "preview-annotation", |
| 65 | "review-comment", |
| 66 | "pull-request", |
| 67 | "pr-open", |
| 68 | "pr-draft", |
| 69 | "pr-merged", |
| 70 | "pr-closed", |
| 71 | "skill", |
| 72 | "thread", |
| 73 | "citation", |
| 74 | ], |
| 75 | className: |
| 76 | "[--context-chip-border:color-mix(in_oklab,var(--context-chip-accent)_34%,var(--contrast-border))] [--context-chip-border-hover:color-mix(in_oklab,var(--context-chip-accent)_48%,var(--contrast-border))] [--context-chip-foreground:color-mix(in_oklab,var(--context-chip-accent)_22%,var(--contrast-foreground))] border-(--context-chip-border) bg-(--context-chip-accent)/11 text-(--context-chip-foreground) [button:enabled&,a&]:hover:border-(--context-chip-border-hover) [button:enabled&,a&]:hover:bg-(--context-chip-accent)/17", |
| 77 | }, |
| 78 | // State colors win over any kind. |
| 79 | { state: "unresolved", className: "text-foreground" }, |
| 80 | { state: "invalid", className: "border-destructive/35 bg-destructive/8 text-destructive" }, |
| 81 | ], |
| 82 | }, |
| 83 | ); |
| 84 | |
| 85 | type ContextChipKind = NonNullable<VariantProps<typeof contextChipVariants>["kind"]>; |
| 86 | |
| 87 | interface ContextChipProps extends useRender.ComponentProps<"span"> { |
| 88 | kind?: ContextChipKind; |
| 89 | state?: "unresolved" | "invalid"; |
| 90 | } |
| 91 | |
| 92 | function ContextChip({ className, kind, state, render, ...props }: ContextChipProps) { |
| 93 | const defaultProps = { |
| 94 | className: cn(contextChipVariants({ kind, state }), className), |
| 95 | "data-slot": "context-chip", |
| 96 | "data-state": state, |
| 97 | }; |
| 98 | return useRender({ |
| 99 | defaultTagName: "span", |
| 100 | props: mergeProps<"span">(defaultProps, props), |
| 101 | render, |
| 102 | }); |
| 103 | } |
| 104 | |
| 105 | /** The chip's text. Truncates to the chip's width. */ |
| 106 | function ContextChipLabel({ className, ...props }: React.ComponentProps<"span">) { |
| 107 | return ( |
| 108 | <span |
| 109 | className={cn("block min-w-0 self-center truncate leading-tight", className)} |
| 110 | data-slot="context-chip-label" |
| 111 | {...props} |
| 112 | /> |
| 113 | ); |
| 114 | } |
| 115 | |
| 116 | /** An icon action inside a chip, such as editing a citation's comment. Tints with the chip's kind. */ |
| 117 | function ContextChipAction({ className, render, ...props }: useRender.ComponentProps<"button">) { |
| 118 | const defaultProps = { |
| 119 | className: cn( |
| 120 | "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-current transition-colors hover:bg-(--context-chip-accent,var(--color-foreground))/17 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-[0.85em]", |
| 121 | className, |
| 122 | ), |
| 123 | "data-slot": "context-chip-action", |
| 124 | type: render ? undefined : ("button" as const), |
| 125 | }; |
| 126 | return useRender({ |
| 127 | defaultTagName: "button", |
| 128 | props: mergeProps<"button">(defaultProps, props), |
| 129 | render, |
| 130 | }); |
| 131 | } |
| 132 | |
| 133 | export { ContextChip, ContextChipAction, ContextChipLabel, type ContextChipKind }; |