@hoardodile/ui 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/plugin-download-consent.d.ts +8 -0
- package/dist/components/plugin-download-consent.js +2 -0
- package/dist/components/plugin-download-consent.js.map +1 -1
- package/dist/components/popover.d.ts +10 -1
- package/dist/components/popover.js +9 -0
- package/dist/components/popover.js.map +1 -1
- package/dist/components/preview-card.d.ts +14 -0
- package/dist/components/preview-card.js +102 -0
- package/dist/components/preview-card.js.map +1 -0
- package/dist/components/special-tag-surface.d.ts +36 -0
- package/dist/components/special-tag-surface.js +533 -0
- package/dist/components/special-tag-surface.js.map +1 -0
- package/dist/components/tag-chip.d.ts +87 -0
- package/dist/components/tag-chip.js +713 -0
- package/dist/components/tag-chip.js.map +1 -0
- package/dist/lib/colors.d.ts +33 -0
- package/dist/lib/colors.js +65 -0
- package/dist/lib/colors.js.map +1 -0
- package/dist/lib/tag-surface.d.ts +22 -0
- package/dist/lib/tag-surface.js +615 -0
- package/dist/lib/tag-surface.js.map +1 -0
- package/package.json +2 -2
- package/src/components/plugin-download-consent.test.tsx +13 -0
- package/src/components/plugin-download-consent.tsx +10 -0
- package/src/components/popover.tsx +21 -0
- package/src/components/preview-card.tsx +100 -0
- package/src/components/special/gold.tsx +73 -0
- package/src/components/special/kintsugi.tsx +143 -0
- package/src/components/special/oilslick.tsx +138 -0
- package/src/components/special/rainbow.tsx +63 -0
- package/src/components/special/silver.tsx +73 -0
- package/src/components/special/types.ts +21 -0
- package/src/components/special-tag-surface.tsx +53 -0
- package/src/components/tag-chip.test.tsx +180 -0
- package/src/components/tag-chip.tsx +221 -0
- package/src/lib/colors.test.ts +94 -0
- package/src/lib/colors.ts +96 -0
- package/src/lib/tag-surface.ts +71 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
2
|
+
|
|
3
|
+
import type { SpecialTagStyleConfig } from "./types"
|
|
4
|
+
|
|
5
|
+
function RainbowSurface({
|
|
6
|
+
id,
|
|
7
|
+
active,
|
|
8
|
+
}: {
|
|
9
|
+
readonly id: string
|
|
10
|
+
readonly active?: boolean
|
|
11
|
+
}) {
|
|
12
|
+
const gradientId = `${id}-rainbow-gradient`
|
|
13
|
+
return (
|
|
14
|
+
<svg
|
|
15
|
+
className={cn(
|
|
16
|
+
"size-full",
|
|
17
|
+
!active && "group-hover:[filter:brightness(1.08)]",
|
|
18
|
+
)}
|
|
19
|
+
width="100%"
|
|
20
|
+
height="100%"
|
|
21
|
+
viewBox="0 0 100 100"
|
|
22
|
+
preserveAspectRatio="none"
|
|
23
|
+
>
|
|
24
|
+
<defs>
|
|
25
|
+
<linearGradient
|
|
26
|
+
id={gradientId}
|
|
27
|
+
x1="0%"
|
|
28
|
+
y1="0%"
|
|
29
|
+
x2="100%"
|
|
30
|
+
y2="0%"
|
|
31
|
+
gradientTransform="rotate(30, 0.5, 0.5)"
|
|
32
|
+
>
|
|
33
|
+
<stop offset="0%" stopColor="#fbffca" />
|
|
34
|
+
<stop offset="33%" stopColor="#baffbf" />
|
|
35
|
+
<stop offset="66%" stopColor="#a7efff" />
|
|
36
|
+
<stop offset="100%" stopColor="#ffabff" />
|
|
37
|
+
</linearGradient>
|
|
38
|
+
</defs>
|
|
39
|
+
<rect
|
|
40
|
+
width="100"
|
|
41
|
+
height="100"
|
|
42
|
+
fill={active ? "#005458" : `url(#${gradientId})`}
|
|
43
|
+
/>
|
|
44
|
+
</svg>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const rainbowConfig: SpecialTagStyleConfig = {
|
|
49
|
+
render: RainbowSurface,
|
|
50
|
+
default: {
|
|
51
|
+
className: "font-bold dark:[text-shadow:0_0_5px_rgba(0,0,0,0.5)]",
|
|
52
|
+
style: {
|
|
53
|
+
color: "#ffffff",
|
|
54
|
+
textShadow: "0 0 5px rgba(0, 0, 0, 0.2)",
|
|
55
|
+
boxShadow: "inset 0 0 0 1px rgba(0, 247, 255, 0.1)",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
active: {
|
|
59
|
+
style: {
|
|
60
|
+
color: "#ffffff",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
2
|
+
|
|
3
|
+
import type { SpecialTagStyleConfig } from "./types"
|
|
4
|
+
|
|
5
|
+
function SilverSurface({
|
|
6
|
+
id,
|
|
7
|
+
active,
|
|
8
|
+
}: {
|
|
9
|
+
readonly id: string
|
|
10
|
+
readonly active?: boolean
|
|
11
|
+
}) {
|
|
12
|
+
const gradientId = `${id}-silver-gradient`
|
|
13
|
+
return (
|
|
14
|
+
<svg
|
|
15
|
+
className={cn(
|
|
16
|
+
"size-full",
|
|
17
|
+
!active &&
|
|
18
|
+
"[filter:brightness(1.1)] group-hover:[filter:brightness(1.16)]",
|
|
19
|
+
)}
|
|
20
|
+
width="100%"
|
|
21
|
+
height="100%"
|
|
22
|
+
viewBox="0 0 100 100"
|
|
23
|
+
preserveAspectRatio="none"
|
|
24
|
+
>
|
|
25
|
+
<defs>
|
|
26
|
+
<linearGradient
|
|
27
|
+
id={gradientId}
|
|
28
|
+
x1="0%"
|
|
29
|
+
y1="0%"
|
|
30
|
+
x2="100%"
|
|
31
|
+
y2="0%"
|
|
32
|
+
gradientTransform="rotate(30, 0.5, 0.5)"
|
|
33
|
+
>
|
|
34
|
+
<stop offset="10%" stopColor="#afaeae" />
|
|
35
|
+
<stop offset="30%" stopColor="#d6d6d6" />
|
|
36
|
+
<stop offset="50%" stopColor="#eeeeee" />
|
|
37
|
+
<stop offset="70%" stopColor="#d6d6d6" />
|
|
38
|
+
<stop offset="90%" stopColor="#afaeae" />
|
|
39
|
+
</linearGradient>
|
|
40
|
+
</defs>
|
|
41
|
+
<rect
|
|
42
|
+
width="100"
|
|
43
|
+
height="100"
|
|
44
|
+
fill={active ? "#5a5a5a" : `url(#${gradientId})`}
|
|
45
|
+
/>
|
|
46
|
+
{!active && (
|
|
47
|
+
<rect
|
|
48
|
+
x="0"
|
|
49
|
+
y="0"
|
|
50
|
+
width="100"
|
|
51
|
+
height="8"
|
|
52
|
+
fill="#ffffff"
|
|
53
|
+
opacity="0.16"
|
|
54
|
+
/>
|
|
55
|
+
)}
|
|
56
|
+
</svg>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const silverConfig: SpecialTagStyleConfig = {
|
|
61
|
+
render: SilverSurface,
|
|
62
|
+
default: {
|
|
63
|
+
style: {
|
|
64
|
+
color: "#2a2a2a",
|
|
65
|
+
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.08)",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
active: {
|
|
69
|
+
style: {
|
|
70
|
+
color: "#ffffff",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CSSProperties, ReactElement } from "react"
|
|
2
|
+
|
|
3
|
+
export type SpecialTagSurfaceRendererProps = {
|
|
4
|
+
readonly id: string
|
|
5
|
+
readonly active?: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type SpecialTagSurfaceRenderer = (
|
|
9
|
+
props: SpecialTagSurfaceRendererProps,
|
|
10
|
+
) => ReactElement
|
|
11
|
+
|
|
12
|
+
export type SpecialTagStyleAppearance = {
|
|
13
|
+
readonly className?: string
|
|
14
|
+
readonly style?: CSSProperties
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SpecialTagStyleConfig = {
|
|
18
|
+
readonly render: SpecialTagSurfaceRenderer
|
|
19
|
+
readonly default: SpecialTagStyleAppearance
|
|
20
|
+
readonly active?: SpecialTagStyleAppearance
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
2
|
+
import { useId } from "react"
|
|
3
|
+
import type { TagSpecialStyle } from "@hoardodile/ui/lib/colors"
|
|
4
|
+
import { goldConfig } from "./special/gold"
|
|
5
|
+
import { kintsugiConfig } from "./special/kintsugi"
|
|
6
|
+
import { oilslickConfig } from "./special/oilslick"
|
|
7
|
+
import { rainbowConfig } from "./special/rainbow"
|
|
8
|
+
import { silverConfig } from "./special/silver"
|
|
9
|
+
import type { SpecialTagStyleConfig } from "./special/types"
|
|
10
|
+
|
|
11
|
+
export type SpecialTagSurfaceProps = {
|
|
12
|
+
readonly style: TagSpecialStyle
|
|
13
|
+
readonly active?: boolean
|
|
14
|
+
readonly className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SPECIAL_TAG_CONFIG: Record<TagSpecialStyle, SpecialTagStyleConfig> = {
|
|
18
|
+
silver: silverConfig,
|
|
19
|
+
gold: goldConfig,
|
|
20
|
+
rainbow: rainbowConfig,
|
|
21
|
+
oilslick: oilslickConfig,
|
|
22
|
+
kintsugi: kintsugiConfig,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getSpecialTagStyleConfig(
|
|
26
|
+
style: TagSpecialStyle,
|
|
27
|
+
): SpecialTagStyleConfig {
|
|
28
|
+
return SPECIAL_TAG_CONFIG[style]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* SVG background surface for special tag styles.
|
|
33
|
+
*
|
|
34
|
+
* Each style owns a self-contained SVG renderer in `src/components/special/`.
|
|
35
|
+
* This component only picks the right renderer from the registry and applies
|
|
36
|
+
* positioning; all styling (filters, colors, shadows) lives in the renderer or
|
|
37
|
+
* the chip container config.
|
|
38
|
+
*/
|
|
39
|
+
export function SpecialTagSurface(props: SpecialTagSurfaceProps) {
|
|
40
|
+
const { style, active, className } = props
|
|
41
|
+
const config = SPECIAL_TAG_CONFIG[style]
|
|
42
|
+
const Renderer = config.render
|
|
43
|
+
const reactId = useId()
|
|
44
|
+
// React useId() can produce ids containing colons, which break SVG
|
|
45
|
+
// url(#id) references in some browsers. Replace them with a safe delimiter.
|
|
46
|
+
const safeId = reactId.replace(/:/g, "-")
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<span className={cn(className)} aria-hidden="true">
|
|
50
|
+
<Renderer id={safeId} active={active} />
|
|
51
|
+
</span>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react"
|
|
2
|
+
import userEvent from "@testing-library/user-event"
|
|
3
|
+
import { describe, expect, test, vi } from "vitest"
|
|
4
|
+
import { TAG_SPECIAL_STYLES } from "@hoardodile/ui/lib/colors"
|
|
5
|
+
import { TagChip } from "./tag-chip"
|
|
6
|
+
|
|
7
|
+
function mockBoundingRect(width: number, height: number) {
|
|
8
|
+
const original = Element.prototype.getBoundingClientRect
|
|
9
|
+
Element.prototype.getBoundingClientRect = vi.fn(() =>
|
|
10
|
+
DOMRect.fromRect({ x: 0, y: 0, width, height }),
|
|
11
|
+
)
|
|
12
|
+
return () => {
|
|
13
|
+
Element.prototype.getBoundingClientRect = original
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("TagChip", () => {
|
|
18
|
+
test("renders a plain span with a single text wrapper (no layered nesting)", () => {
|
|
19
|
+
const { container } = render(<TagChip color="">tag</TagChip>)
|
|
20
|
+
const chip = container.firstElementChild
|
|
21
|
+
expect(chip).toHaveTextContent("tag")
|
|
22
|
+
expect(chip).not.toHaveClass("border")
|
|
23
|
+
expect(chip?.querySelectorAll("span")).toHaveLength(1)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("renders normal colored chips with the tinted fill variables", () => {
|
|
27
|
+
const { container } = render(<TagChip color="#E74C3C">tag</TagChip>)
|
|
28
|
+
const chip = container.firstElementChild as HTMLElement
|
|
29
|
+
expect(chip.style.getPropertyValue("--chip-bg")).toContain("#E74C3C")
|
|
30
|
+
expect(chip).toHaveStyle({ color: "#E74C3C" })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("renders every registered special style with an SVG texture", () => {
|
|
34
|
+
for (const style of TAG_SPECIAL_STYLES) {
|
|
35
|
+
const { container } = render(<TagChip color={style}>tag</TagChip>)
|
|
36
|
+
expect(container.querySelector("svg")).toBeInTheDocument()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("falls back to the default palette when color is empty", () => {
|
|
41
|
+
const { container } = render(<TagChip color="">tag</TagChip>)
|
|
42
|
+
expect(container.firstElementChild).toHaveTextContent("tag")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("display='button' renders a real button and fires onClick", async () => {
|
|
46
|
+
const user = userEvent.setup()
|
|
47
|
+
const onClick = vi.fn()
|
|
48
|
+
render(
|
|
49
|
+
<TagChip color="" display="button" onClick={onClick} data-testid="chip">
|
|
50
|
+
hello
|
|
51
|
+
</TagChip>,
|
|
52
|
+
)
|
|
53
|
+
const el = screen.getByTestId("chip")
|
|
54
|
+
expect(el.tagName).toBe("BUTTON")
|
|
55
|
+
expect(el.className).toContain("cursor-pointer")
|
|
56
|
+
await user.click(el)
|
|
57
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("an onClick handler alone never promotes — the element stays a span", async () => {
|
|
61
|
+
const user = userEvent.setup()
|
|
62
|
+
const onClick = vi.fn()
|
|
63
|
+
render(
|
|
64
|
+
<TagChip color="" onClick={onClick} data-testid="chip">
|
|
65
|
+
hello
|
|
66
|
+
</TagChip>,
|
|
67
|
+
)
|
|
68
|
+
const el = screen.getByTestId("chip")
|
|
69
|
+
expect(el.tagName).toBe("SPAN")
|
|
70
|
+
expect(el.className).not.toContain("cursor-pointer")
|
|
71
|
+
await user.click(el)
|
|
72
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("display mode stays a span", () => {
|
|
76
|
+
render(
|
|
77
|
+
<TagChip color="" data-testid="chip">
|
|
78
|
+
hello
|
|
79
|
+
</TagChip>,
|
|
80
|
+
)
|
|
81
|
+
expect(screen.getByTestId("chip").tagName).toBe("SPAN")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("render swaps the root element and keeps the chip's children", () => {
|
|
85
|
+
render(
|
|
86
|
+
<TagChip color="" data-testid="chip" render={<button type="button" />}>
|
|
87
|
+
label
|
|
88
|
+
</TagChip>,
|
|
89
|
+
)
|
|
90
|
+
const el = screen.getByTestId("chip")
|
|
91
|
+
expect(el.tagName).toBe("BUTTON")
|
|
92
|
+
expect(el).toHaveTextContent("label")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("active uncolored chips take the primary palette", () => {
|
|
96
|
+
render(
|
|
97
|
+
<TagChip color="" active onClick={() => undefined} data-testid="chip">
|
|
98
|
+
x
|
|
99
|
+
</TagChip>,
|
|
100
|
+
)
|
|
101
|
+
const el = screen.getByTestId("chip")
|
|
102
|
+
expect(el.className).toContain("bg-primary")
|
|
103
|
+
expect(el.className).toContain("text-primary-foreground")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("dashed border mode renders the quiet outline and ignores color", () => {
|
|
107
|
+
render(
|
|
108
|
+
<TagChip color="#ff0000" border="dashed" data-testid="chip">
|
|
109
|
+
x
|
|
110
|
+
</TagChip>,
|
|
111
|
+
)
|
|
112
|
+
const el = screen.getByTestId("chip")
|
|
113
|
+
expect(el.className).toContain("border-dashed")
|
|
114
|
+
expect(el.className).toContain("border-border-strong")
|
|
115
|
+
expect(el.className).toContain("text-muted-foreground")
|
|
116
|
+
expect(el.className).not.toContain("bg-primary")
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("dashed border mode active swaps in the accent fill with a transparent border", () => {
|
|
120
|
+
render(
|
|
121
|
+
<TagChip
|
|
122
|
+
color=""
|
|
123
|
+
active
|
|
124
|
+
border="dashed"
|
|
125
|
+
onClick={() => undefined}
|
|
126
|
+
data-testid="chip"
|
|
127
|
+
>
|
|
128
|
+
x
|
|
129
|
+
</TagChip>,
|
|
130
|
+
)
|
|
131
|
+
const el = screen.getByTestId("chip")
|
|
132
|
+
expect(el.className).toContain("bg-accent")
|
|
133
|
+
expect(el.className).toContain("border-transparent")
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test("roundedRight=false removes the right border-radius", () => {
|
|
137
|
+
render(
|
|
138
|
+
<TagChip color="" roundedRight={false} data-testid="chip">
|
|
139
|
+
x
|
|
140
|
+
</TagChip>,
|
|
141
|
+
)
|
|
142
|
+
expect(screen.getByTestId("chip").className).toContain("rounded-r-none")
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("special style renders an SVG gradient surface", () => {
|
|
146
|
+
const restore = mockBoundingRect(100, 36)
|
|
147
|
+
render(
|
|
148
|
+
<TagChip color="rainbow" data-testid="chip">
|
|
149
|
+
rainbow
|
|
150
|
+
</TagChip>,
|
|
151
|
+
)
|
|
152
|
+
const el = screen.getByTestId("chip")
|
|
153
|
+
expect(el.querySelector("svg")).toBeInTheDocument()
|
|
154
|
+
expect(el.querySelector("linearGradient")).toBeInTheDocument()
|
|
155
|
+
restore()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("special style active state uses the active fill", () => {
|
|
159
|
+
const restore = mockBoundingRect(100, 36)
|
|
160
|
+
render(
|
|
161
|
+
<TagChip color="gold" active data-testid="chip">
|
|
162
|
+
gold
|
|
163
|
+
</TagChip>,
|
|
164
|
+
)
|
|
165
|
+
const rect = screen.getByTestId("chip").querySelector("rect")
|
|
166
|
+
expect(rect).toHaveAttribute("fill", "#8a6d1f")
|
|
167
|
+
restore()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test("special style does not apply the regular border or tint classes", () => {
|
|
171
|
+
render(
|
|
172
|
+
<TagChip color="silver" data-testid="chip">
|
|
173
|
+
silver
|
|
174
|
+
</TagChip>,
|
|
175
|
+
)
|
|
176
|
+
const el = screen.getByTestId("chip")
|
|
177
|
+
expect(el.className).not.toContain("border-transparent")
|
|
178
|
+
expect(el.className).not.toContain(" bg-(--chip-bg)")
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { cn } from "@hoardodile/ui/lib/utils"
|
|
2
|
+
import {
|
|
3
|
+
type ComponentPropsWithoutRef,
|
|
4
|
+
cloneElement,
|
|
5
|
+
forwardRef,
|
|
6
|
+
type MouseEvent,
|
|
7
|
+
type ReactElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type Ref,
|
|
10
|
+
} from "react"
|
|
11
|
+
import { SpecialTagSurface } from "@hoardodile/ui/components/special-tag-surface"
|
|
12
|
+
import { resolveTagChipSurface } from "@hoardodile/ui/lib/tag-surface"
|
|
13
|
+
|
|
14
|
+
/** The two sanctioned chip sizes: `sm` (default, `px-2 py-1`) for cards and
|
|
15
|
+
inline rows, `md` (`px-2 py-1.5`) for the filterer facets (Categories,
|
|
16
|
+
Traits, Relations). */
|
|
17
|
+
export type TagChipSize = "sm" | "md"
|
|
18
|
+
|
|
19
|
+
type TagChipBaseProps = {
|
|
20
|
+
/**
|
|
21
|
+
* Effective display color. Special names (`silver`, `gold`, `rainbow`,
|
|
22
|
+
* ...) render their SVG texture; an empty string falls back to the
|
|
23
|
+
* default muted chip, taking the primary fill when `active`. Ignored
|
|
24
|
+
* when `border` is set — bordered modes never take a tint.
|
|
25
|
+
*/
|
|
26
|
+
readonly color?: string
|
|
27
|
+
/** Chip height tier — see {@link TagChipSize}. */
|
|
28
|
+
readonly size?: TagChipSize
|
|
29
|
+
/**
|
|
30
|
+
* Border mode instead of the fill/ghost anatomies. Only `"dashed"`
|
|
31
|
+
* exists today (add pills, unused entities): a `border-dashed` hairline
|
|
32
|
+
* with muted ink and no color tint — `active` swaps in the accent fill
|
|
33
|
+
* with a transparent border so the width never moves with the state.
|
|
34
|
+
*/
|
|
35
|
+
readonly border?: "dashed"
|
|
36
|
+
/** Selected: colored chips deepen to their hover tint, special styles
|
|
37
|
+
switch to their own active appearance, uncolored chips take the
|
|
38
|
+
primary fill. */
|
|
39
|
+
readonly active?: boolean
|
|
40
|
+
/** Leading icon; rides the chip's own gap, so no wrapper is needed. */
|
|
41
|
+
readonly icon?: ReactNode
|
|
42
|
+
/** False removes the right border-radius so the chip can glue flush
|
|
43
|
+
against a sibling (e.g. the category rail's chevron). */
|
|
44
|
+
readonly roundedRight?: boolean
|
|
45
|
+
/**
|
|
46
|
+
* Root element mode: `"inline"` (default) renders a plain `<span>`,
|
|
47
|
+
* `"button"` a real `<button type="button">`. The element is chosen
|
|
48
|
+
* only from this prop (or `render`) — passing an `onClick` never
|
|
49
|
+
* changes the element, so interactive wrappers (hover cards,
|
|
50
|
+
* popovers) that merge click handlers onto the chip cannot swap its
|
|
51
|
+
* DOM node mid-interaction.
|
|
52
|
+
*/
|
|
53
|
+
readonly display?: "inline" | "button"
|
|
54
|
+
/**
|
|
55
|
+
* Polymorphic root: pass a `render={<button ... />}` element to render
|
|
56
|
+
* the chip as that element. The chip's own classes, style, handlers,
|
|
57
|
+
* texture and icon merge onto it, and the chip's children become the
|
|
58
|
+
* element's children — so the render element should carry only its own
|
|
59
|
+
* props. Wins over `display`; `display="button"` is the plain way to
|
|
60
|
+
* get a real button without supplying a render element.
|
|
61
|
+
*/
|
|
62
|
+
readonly render?: ReactElement<Record<string, unknown>>
|
|
63
|
+
/**
|
|
64
|
+
* Trailing suffix, separated from the label by a middle dot (e.g. a
|
|
65
|
+
* trait's kind "cm" or a count). Rendered at the label's own size —
|
|
66
|
+
* same line box, so the chip height never depends on the suffix —
|
|
67
|
+
* with muted ink and a bold dot.
|
|
68
|
+
*/
|
|
69
|
+
readonly suffix?: ReactNode
|
|
70
|
+
readonly children?: ReactNode
|
|
71
|
+
readonly onMouseDown?: (event: MouseEvent<HTMLSpanElement>) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type TagChipProps = TagChipBaseProps &
|
|
75
|
+
Omit<ComponentPropsWithoutRef<"button">, keyof TagChipBaseProps>
|
|
76
|
+
|
|
77
|
+
/** Text container: ellipsis truncation that only clips horizontally —
|
|
78
|
+
`truncate`'s `overflow: hidden` would also clip descenders (y, g, p)
|
|
79
|
+
at the tight `leading-none` line box, so the x axis clips while the y
|
|
80
|
+
axis stays visible. */
|
|
81
|
+
const tagChipLabelClassName =
|
|
82
|
+
"min-w-0 overflow-x-clip text-ellipsis whitespace-nowrap"
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The one tag chip: a single rounded pill (inline-flex, `rounded-sm`,
|
|
86
|
+
* `px-2 py-1`, 12px text) that may carry an icon and a special SVG
|
|
87
|
+
* texture. It renders as a span by default; interactive surfaces pass
|
|
88
|
+
* `display="button"` (or `render={<button ... />}` for a custom root,
|
|
89
|
+
* e.g. a navigation anchor) and the chip's styling lands on that
|
|
90
|
+
* element — one element in the DOM plus a single text wrapper, no
|
|
91
|
+
* deeper nesting.
|
|
92
|
+
*
|
|
93
|
+
* The root element is decided purely by `display`/`render` — never by
|
|
94
|
+
* the presence of an `onClick`, so a chip's DOM element cannot change
|
|
95
|
+
* between renders (the hover-card trigger merge in {@link TagChipHover}
|
|
96
|
+
* relies on this).
|
|
97
|
+
*
|
|
98
|
+
* Coloring is delegated to {@link resolveTagChipSurface} so cards,
|
|
99
|
+
* pickers, the doc editor and the character pills all share one
|
|
100
|
+
* definition of "what a colored tag looks like".
|
|
101
|
+
*
|
|
102
|
+
* The chip forwards its root ref so composition wrappers (e.g. the
|
|
103
|
+
* {@link TagChipHover} preview-card trigger, which clones the chip and
|
|
104
|
+
* attaches a DOM ref for anchoring) can reach the actual element.
|
|
105
|
+
*/
|
|
106
|
+
export const TagChip = forwardRef<HTMLElement, TagChipProps>(
|
|
107
|
+
function TagChip(props, ref) {
|
|
108
|
+
const {
|
|
109
|
+
color,
|
|
110
|
+
size = "sm",
|
|
111
|
+
border,
|
|
112
|
+
active,
|
|
113
|
+
icon,
|
|
114
|
+
roundedRight,
|
|
115
|
+
display,
|
|
116
|
+
render,
|
|
117
|
+
suffix,
|
|
118
|
+
children,
|
|
119
|
+
className,
|
|
120
|
+
style,
|
|
121
|
+
onMouseDown,
|
|
122
|
+
...rest
|
|
123
|
+
} = props
|
|
124
|
+
const surface =
|
|
125
|
+
border !== undefined
|
|
126
|
+
? undefined
|
|
127
|
+
: resolveTagChipSurface(color ?? "", active === true)
|
|
128
|
+
|
|
129
|
+
// The root element is fixed by the display mode / render slot —
|
|
130
|
+
// an onClick merged on by an interactive wrapper must never swap
|
|
131
|
+
// the chip's element (see the component doc comment).
|
|
132
|
+
const interactiveRoot = display === "button" || render !== undefined
|
|
133
|
+
|
|
134
|
+
const stateClass =
|
|
135
|
+
border === "dashed"
|
|
136
|
+
? active === true
|
|
137
|
+
? "border border-transparent bg-accent text-foreground"
|
|
138
|
+
: "border border-dashed border-border-strong text-muted-foreground hover:text-secondary-foreground"
|
|
139
|
+
: undefined
|
|
140
|
+
|
|
141
|
+
// Texture and icon are the chip's own decoration, so they lead the
|
|
142
|
+
// children even when the root element is swapped (render / button).
|
|
143
|
+
const content = (
|
|
144
|
+
<>
|
|
145
|
+
{surface !== undefined && surface.texture !== null && (
|
|
146
|
+
<SpecialTagSurface
|
|
147
|
+
style={surface.texture}
|
|
148
|
+
active={active}
|
|
149
|
+
className="absolute inset-0 -z-10 overflow-hidden rounded-[inherit]"
|
|
150
|
+
/>
|
|
151
|
+
)}
|
|
152
|
+
{icon !== undefined && (
|
|
153
|
+
// Flex row: the slot accepts several icons as a fragment
|
|
154
|
+
// (e.g. a kind glyph + the pin), and flex keeps the
|
|
155
|
+
// blockified preflight svgs on one line.
|
|
156
|
+
<span className="flex shrink-0 items-center gap-1 text-muted-foreground">
|
|
157
|
+
{icon}
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
<span className={tagChipLabelClassName}>
|
|
161
|
+
{children}
|
|
162
|
+
{suffix !== undefined ? (
|
|
163
|
+
<>
|
|
164
|
+
<span className="font-bold mx-0.5">·</span>
|
|
165
|
+
<span className="shrink-0 opacity-70">{suffix}</span>
|
|
166
|
+
</>
|
|
167
|
+
) : null}
|
|
168
|
+
</span>
|
|
169
|
+
</>
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const chipClassName = cn(
|
|
173
|
+
"inline-flex min-w-0 max-w-full items-center justify-center gap-1.5 rounded-sm text-xs font-normal leading-none disabled:pointer-events-none disabled:opacity-50",
|
|
174
|
+
size === "md" ? "px-2 py-1.5" : "px-2 py-1",
|
|
175
|
+
roundedRight === false && "rounded-r-none",
|
|
176
|
+
interactiveRoot && "cursor-pointer",
|
|
177
|
+
surface?.className,
|
|
178
|
+
stateClass,
|
|
179
|
+
className,
|
|
180
|
+
)
|
|
181
|
+
const chipStyle = { ...surface?.style, ...style }
|
|
182
|
+
|
|
183
|
+
if (render !== undefined) {
|
|
184
|
+
return cloneElement(render, {
|
|
185
|
+
...rest,
|
|
186
|
+
ref,
|
|
187
|
+
className: chipClassName,
|
|
188
|
+
style: chipStyle,
|
|
189
|
+
onMouseDown,
|
|
190
|
+
children: content,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (display === "button") {
|
|
195
|
+
return (
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
ref={ref as Ref<HTMLButtonElement>}
|
|
199
|
+
className={chipClassName}
|
|
200
|
+
style={chipStyle}
|
|
201
|
+
onMouseDown={onMouseDown}
|
|
202
|
+
{...rest}
|
|
203
|
+
>
|
|
204
|
+
{content}
|
|
205
|
+
</button>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<span
|
|
211
|
+
ref={ref as Ref<HTMLSpanElement>}
|
|
212
|
+
className={chipClassName}
|
|
213
|
+
style={chipStyle}
|
|
214
|
+
onMouseDown={onMouseDown}
|
|
215
|
+
{...rest}
|
|
216
|
+
>
|
|
217
|
+
{content}
|
|
218
|
+
</span>
|
|
219
|
+
)
|
|
220
|
+
},
|
|
221
|
+
)
|