@smicolon/fasla-ui 0.4.0 → 0.4.2
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/README.md +11 -2
- package/dist/index.cjs +4 -1
- package/dist/index.js +4 -1
- package/dist/lib/utils.cjs +4 -1
- package/dist/lib/utils.js +4 -1
- package/package.json +1 -1
- package/registry/effects/effects.test.tsx +61 -0
- package/registry/effects/glow-card/glow-card.tsx +18 -12
- package/registry/effects/typewriter-text/typewriter-text.tsx +49 -44
- package/registry/ui/avatar/avatar.shadcn-cn.test.tsx +38 -0
- package/registry/ui/avatar/avatar.test.tsx +4 -2
- package/registry/ui/avatar/avatar.tsx +8 -3
package/README.md
CHANGED
|
@@ -129,13 +129,22 @@ export function MyComponent() {
|
|
|
129
129
|
## Theme
|
|
130
130
|
|
|
131
131
|
Components take every colour from your theme. `init` asks which of two layers
|
|
132
|
-
you want; both include the base layer.
|
|
132
|
+
you want and installs it; both include the base layer. The shadcn commands below
|
|
133
|
+
install one too, or switch a project to the other one later.
|
|
134
|
+
|
|
135
|
+
They need a project that is already set up, with a `components.json`: in a new
|
|
136
|
+
project, run `npx @smicolon/cli init` first, or `npx shadcn@latest init`. Without
|
|
137
|
+
one, the shadcn CLI sets the project up itself, and its preset's colours replace
|
|
138
|
+
Fasla's. On Next.js 14, use `npx @smicolon/cli init`: `shadcn init` writes
|
|
139
|
+
Tailwind 4 styles and loads Geist from `next/font`, which Next.js 14 has neither of.
|
|
133
140
|
|
|
134
141
|
```bash
|
|
135
142
|
# Starting from scratch: use Fasla's colours (palette, radius, Geist).
|
|
136
|
-
# On Next.js 14, leave out the font-geist.json URL: next/font there has no Geist.
|
|
137
143
|
npx shadcn@latest add https://ui.smicolon.com/r/theme.json https://ui.smicolon.com/r/font-geist.json
|
|
138
144
|
|
|
145
|
+
# The same on Next.js 14, without Geist: its template already loads it.
|
|
146
|
+
npx shadcn@latest add https://ui.smicolon.com/r/theme.json
|
|
147
|
+
|
|
139
148
|
# I have a brand: keep my colours (base layer only)
|
|
140
149
|
npx shadcn@latest add https://ui.smicolon.com/r/theme-base.json
|
|
141
150
|
```
|
package/dist/index.cjs
CHANGED
|
@@ -5,8 +5,11 @@ var tailwindMerge = require('tailwind-merge');
|
|
|
5
5
|
var framerMotion = require('framer-motion');
|
|
6
6
|
|
|
7
7
|
// src/lib/utils.ts
|
|
8
|
+
var twMerge = tailwindMerge.extendTailwindMerge({
|
|
9
|
+
extend: { classGroups: { "font-size": [{ text: ["xxs", "link", "list-header"] }] } }
|
|
10
|
+
});
|
|
8
11
|
function cn(...inputs) {
|
|
9
|
-
return
|
|
12
|
+
return twMerge(clsx.clsx(inputs));
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
// src/tokens/model.ts
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { clsx } from 'clsx';
|
|
2
|
-
import {
|
|
2
|
+
import { extendTailwindMerge } from 'tailwind-merge';
|
|
3
3
|
import { useReducedMotion } from 'framer-motion';
|
|
4
4
|
|
|
5
5
|
// src/lib/utils.ts
|
|
6
|
+
var twMerge = extendTailwindMerge({
|
|
7
|
+
extend: { classGroups: { "font-size": [{ text: ["xxs", "link", "list-header"] }] } }
|
|
8
|
+
});
|
|
6
9
|
function cn(...inputs) {
|
|
7
10
|
return twMerge(clsx(inputs));
|
|
8
11
|
}
|
package/dist/lib/utils.cjs
CHANGED
|
@@ -4,8 +4,11 @@ var clsx = require('clsx');
|
|
|
4
4
|
var tailwindMerge = require('tailwind-merge');
|
|
5
5
|
|
|
6
6
|
// src/lib/utils.ts
|
|
7
|
+
var twMerge = tailwindMerge.extendTailwindMerge({
|
|
8
|
+
extend: { classGroups: { "font-size": [{ text: ["xxs", "link", "list-header"] }] } }
|
|
9
|
+
});
|
|
7
10
|
function cn(...inputs) {
|
|
8
|
-
return
|
|
11
|
+
return twMerge(clsx.clsx(inputs));
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
exports.cn = cn;
|
package/dist/lib/utils.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { clsx } from 'clsx';
|
|
2
|
-
import {
|
|
2
|
+
import { extendTailwindMerge } from 'tailwind-merge';
|
|
3
3
|
|
|
4
4
|
// src/lib/utils.ts
|
|
5
|
+
var twMerge = extendTailwindMerge({
|
|
6
|
+
extend: { classGroups: { "font-size": [{ text: ["xxs", "link", "list-header"] }] } }
|
|
7
|
+
});
|
|
5
8
|
function cn(...inputs) {
|
|
6
9
|
return twMerge(clsx(inputs));
|
|
7
10
|
}
|
package/package.json
CHANGED
|
@@ -61,6 +61,67 @@ describe("TypewriterText", () => {
|
|
|
61
61
|
expect(first).not.toHaveBeenCalled()
|
|
62
62
|
expect(latest).toHaveBeenCalledTimes(1)
|
|
63
63
|
})
|
|
64
|
+
|
|
65
|
+
it("starts a new text from nothing, never showing the old one", () => {
|
|
66
|
+
vi.useFakeTimers()
|
|
67
|
+
const { container, rerender } = render(<TypewriterText text="Hello" speed={10} cursor={false} />)
|
|
68
|
+
act(() => {
|
|
69
|
+
vi.advanceTimersByTime(100)
|
|
70
|
+
})
|
|
71
|
+
expect(container.textContent).toBe("Hello")
|
|
72
|
+
rerender(<TypewriterText text="Bye" speed={10} cursor={false} />)
|
|
73
|
+
expect(container.textContent).toBe("")
|
|
74
|
+
act(() => {
|
|
75
|
+
vi.advanceTimersByTime(100)
|
|
76
|
+
})
|
|
77
|
+
expect(container.textContent).toBe("Bye")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("types the text again after loopDelay when looping", () => {
|
|
81
|
+
vi.useFakeTimers()
|
|
82
|
+
const { container } = render(<TypewriterText text="Hi" speed={10} loop loopDelay={50} cursor={false} />)
|
|
83
|
+
act(() => {
|
|
84
|
+
vi.advanceTimersByTime(40)
|
|
85
|
+
})
|
|
86
|
+
expect(container.textContent).toBe("Hi")
|
|
87
|
+
// Done at 20ms; the second pass starts at 70ms and types its next letter at 80.
|
|
88
|
+
act(() => {
|
|
89
|
+
vi.advanceTimersByTime(35)
|
|
90
|
+
})
|
|
91
|
+
expect(container.textContent).toBe("H")
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("Typewriter under reduced motion", () => {
|
|
96
|
+
const reduceMotion = () =>
|
|
97
|
+
vi.spyOn(window, "matchMedia").mockImplementation(
|
|
98
|
+
(query: string) =>
|
|
99
|
+
({
|
|
100
|
+
matches: query.includes("reduce"),
|
|
101
|
+
media: query,
|
|
102
|
+
addEventListener: () => {},
|
|
103
|
+
removeEventListener: () => {},
|
|
104
|
+
}) as unknown as MediaQueryList
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
vi.restoreAllMocks()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("TypewriterText shows the whole text at once, with no cursor, and reports it complete", () => {
|
|
112
|
+
reduceMotion()
|
|
113
|
+
const onComplete = vi.fn()
|
|
114
|
+
const { container } = render(<TypewriterText text="Your order is on its way." onComplete={onComplete} />)
|
|
115
|
+
expect(container.textContent).toBe("Your order is on its way.")
|
|
116
|
+
expect(onComplete).toHaveBeenCalledTimes(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("TypewriterWords shows the first word, still", async () => {
|
|
120
|
+
reduceMotion()
|
|
121
|
+
const { TypewriterWords } = await import("./typewriter-text/typewriter-text")
|
|
122
|
+
const { container } = render(<TypewriterWords words={["linen", "wool"]} />)
|
|
123
|
+
expect(container.textContent).toBe("linen")
|
|
124
|
+
})
|
|
64
125
|
})
|
|
65
126
|
|
|
66
127
|
describe("SpotlightCard", () => {
|
|
@@ -3,21 +3,27 @@
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import { cn } from "../../../src/lib/utils"
|
|
5
5
|
|
|
6
|
+
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
|
|
7
|
+
|
|
8
|
+
function subscribeToReducedMotion(onChange: () => void) {
|
|
9
|
+
if (typeof window.matchMedia !== "function") return () => {}
|
|
10
|
+
const query = window.matchMedia(REDUCED_MOTION)
|
|
11
|
+
query.addEventListener("change", onChange)
|
|
12
|
+
return () => query.removeEventListener("change", onChange)
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
/**
|
|
7
|
-
* Whether the user asks for reduced motion.
|
|
8
|
-
*
|
|
16
|
+
* Whether the user asks for reduced motion. False on the server and while
|
|
17
|
+
* hydrating, so both renders agree, then the setting as it is and as it
|
|
18
|
+
* changes. Read through useSyncExternalStore, not set from an effect: an
|
|
19
|
+
* effect's setState renders everything twice, and React's lint rejects it.
|
|
9
20
|
*/
|
|
10
21
|
function usePrefersReducedMotion() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const onChange = () => setReduced(query.matches)
|
|
17
|
-
query.addEventListener("change", onChange)
|
|
18
|
-
return () => query.removeEventListener("change", onChange)
|
|
19
|
-
}, [])
|
|
20
|
-
return reduced
|
|
22
|
+
return React.useSyncExternalStore(
|
|
23
|
+
subscribeToReducedMotion,
|
|
24
|
+
() => typeof window.matchMedia === "function" && window.matchMedia(REDUCED_MOTION).matches,
|
|
25
|
+
() => false
|
|
26
|
+
)
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/** The value, or the fallback when it is missing or blank (a cleared control, say). */
|
|
@@ -3,21 +3,27 @@
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import { cn } from "../../../src/lib/utils"
|
|
5
5
|
|
|
6
|
+
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
|
|
7
|
+
|
|
8
|
+
function subscribeToReducedMotion(onChange: () => void) {
|
|
9
|
+
if (typeof window.matchMedia !== "function") return () => {}
|
|
10
|
+
const query = window.matchMedia(REDUCED_MOTION)
|
|
11
|
+
query.addEventListener("change", onChange)
|
|
12
|
+
return () => query.removeEventListener("change", onChange)
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
/**
|
|
7
|
-
* Whether the user asks for reduced motion.
|
|
8
|
-
*
|
|
16
|
+
* Whether the user asks for reduced motion. False on the server and while
|
|
17
|
+
* hydrating, so both renders agree, then the setting as it is and as it
|
|
18
|
+
* changes. Read through useSyncExternalStore, not set from an effect: an
|
|
19
|
+
* effect's setState renders everything twice, and React's lint rejects it.
|
|
9
20
|
*/
|
|
10
21
|
function usePrefersReducedMotion() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const onChange = () => setReduced(query.matches)
|
|
17
|
-
query.addEventListener("change", onChange)
|
|
18
|
-
return () => query.removeEventListener("change", onChange)
|
|
19
|
-
}, [])
|
|
20
|
-
return reduced
|
|
22
|
+
return React.useSyncExternalStore(
|
|
23
|
+
subscribeToReducedMotion,
|
|
24
|
+
() => typeof window.matchMedia === "function" && window.matchMedia(REDUCED_MOTION).matches,
|
|
25
|
+
() => false
|
|
26
|
+
)
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
/** The value, or the fallback when it is missing or blank (a cleared control, say). */
|
|
@@ -56,57 +62,57 @@ export function TypewriterText({
|
|
|
56
62
|
...props
|
|
57
63
|
}: TypewriterTextProps) {
|
|
58
64
|
const cursorChar = filled(cursorCharProp, "|")
|
|
59
|
-
const [displayText, setDisplayText] = React.useState("")
|
|
60
|
-
const [isTyping, setIsTyping] = React.useState(false)
|
|
61
65
|
const prefersReducedMotion = usePrefersReducedMotion()
|
|
62
66
|
const showCursor = cursor && !prefersReducedMotion
|
|
63
67
|
|
|
68
|
+
// How far the current run has typed. A new text, speed or timing starts a
|
|
69
|
+
// new run, and progress left by an older one reads as nothing typed yet, so
|
|
70
|
+
// the run never has to clear state as it starts.
|
|
71
|
+
const run = JSON.stringify([text, speed, delay, loop, loopDelay])
|
|
72
|
+
const [progress, setProgress] = React.useState({ run: "", typed: 0, typing: false })
|
|
73
|
+
const current = progress.run === run
|
|
74
|
+
// Reduced motion: the whole text, at once.
|
|
75
|
+
const displayText = prefersReducedMotion ? text : current ? text.slice(0, progress.typed) : ""
|
|
76
|
+
const isTyping = current ? progress.typing : true
|
|
77
|
+
|
|
64
78
|
// Held in a ref: an inline callback is a new function every render, and as
|
|
65
79
|
// an effect dependency it restarted the typing each time the parent rendered.
|
|
80
|
+
// Updated after each render, never during one.
|
|
66
81
|
const onCompleteRef = React.useRef(onComplete)
|
|
67
|
-
|
|
82
|
+
React.useEffect(() => {
|
|
83
|
+
onCompleteRef.current = onComplete
|
|
84
|
+
})
|
|
68
85
|
|
|
69
86
|
React.useEffect(() => {
|
|
70
|
-
// If user prefers reduced motion, show full text immediately
|
|
71
87
|
if (prefersReducedMotion) {
|
|
72
|
-
setDisplayText(text)
|
|
73
88
|
onCompleteRef.current?.()
|
|
74
89
|
return
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
let timeoutId: ReturnType<typeof setTimeout>
|
|
78
|
-
let charIndex = 0
|
|
79
|
-
setDisplayText("")
|
|
80
|
-
setIsTyping(true)
|
|
81
93
|
|
|
82
94
|
const startTyping = () => {
|
|
95
|
+
let charIndex = 0
|
|
83
96
|
const typeChar = () => {
|
|
84
97
|
if (charIndex < text.length) {
|
|
85
|
-
setDisplayText(text.slice(0, charIndex + 1))
|
|
86
98
|
charIndex++
|
|
99
|
+
setProgress({ run, typed: charIndex, typing: true })
|
|
87
100
|
timeoutId = setTimeout(typeChar, speed)
|
|
88
101
|
} else {
|
|
89
|
-
|
|
102
|
+
setProgress({ run, typed: charIndex, typing: false })
|
|
90
103
|
onCompleteRef.current?.()
|
|
91
|
-
|
|
92
|
-
if (loop) {
|
|
93
|
-
timeoutId = setTimeout(() => {
|
|
94
|
-
charIndex = 0
|
|
95
|
-
setDisplayText("")
|
|
96
|
-
setIsTyping(true)
|
|
97
|
-
startTyping()
|
|
98
|
-
}, loopDelay)
|
|
99
|
-
}
|
|
104
|
+
if (loop) timeoutId = setTimeout(startTyping, loopDelay)
|
|
100
105
|
}
|
|
101
106
|
}
|
|
102
107
|
|
|
108
|
+
setProgress({ run, typed: 0, typing: true })
|
|
103
109
|
typeChar()
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
timeoutId = setTimeout(startTyping, delay)
|
|
107
113
|
|
|
108
114
|
return () => clearTimeout(timeoutId)
|
|
109
|
-
}, [text, speed, delay, loop, loopDelay, prefersReducedMotion])
|
|
115
|
+
}, [run, text, speed, delay, loop, loopDelay, prefersReducedMotion])
|
|
110
116
|
|
|
111
117
|
// Cursor blink effect
|
|
112
118
|
const [cursorVisible, setCursorVisible] = React.useState(true)
|
|
@@ -163,25 +169,24 @@ export function TypewriterWords({
|
|
|
163
169
|
...props
|
|
164
170
|
}: TypewriterWordsProps) {
|
|
165
171
|
const [wordIndex, setWordIndex] = React.useState(0)
|
|
166
|
-
const [
|
|
172
|
+
const [typedText, setTypedText] = React.useState("")
|
|
167
173
|
const [isDeleting, setIsDeleting] = React.useState(false)
|
|
168
174
|
|
|
169
175
|
const prefersReducedMotion = usePrefersReducedMotion()
|
|
176
|
+
// Reduced motion: the first word, still.
|
|
177
|
+
const displayText = prefersReducedMotion ? (words[0] ?? "") : typedText
|
|
170
178
|
|
|
171
179
|
React.useEffect(() => {
|
|
172
|
-
if (prefersReducedMotion)
|
|
173
|
-
setDisplayText(words[0] ?? "")
|
|
174
|
-
return
|
|
175
|
-
}
|
|
180
|
+
if (prefersReducedMotion) return
|
|
176
181
|
|
|
177
182
|
const currentWord = words[wordIndex] ?? ""
|
|
178
183
|
let timeoutId: ReturnType<typeof setTimeout>
|
|
179
184
|
|
|
180
185
|
if (!isDeleting) {
|
|
181
186
|
// Typing
|
|
182
|
-
if (
|
|
187
|
+
if (typedText.length < currentWord.length) {
|
|
183
188
|
timeoutId = setTimeout(() => {
|
|
184
|
-
|
|
189
|
+
setTypedText(currentWord.slice(0, typedText.length + 1))
|
|
185
190
|
}, speed)
|
|
186
191
|
} else {
|
|
187
192
|
// Word complete, wait then start deleting
|
|
@@ -191,21 +196,21 @@ export function TypewriterWords({
|
|
|
191
196
|
}
|
|
192
197
|
} else {
|
|
193
198
|
// Deleting
|
|
194
|
-
if (
|
|
199
|
+
if (typedText.length > 0) {
|
|
195
200
|
timeoutId = setTimeout(() => {
|
|
196
|
-
|
|
201
|
+
setTypedText(typedText.slice(0, -1))
|
|
197
202
|
}, speed / 2)
|
|
198
203
|
} else {
|
|
199
|
-
// Deletion complete,
|
|
200
|
-
setIsDeleting(false)
|
|
204
|
+
// Deletion complete: after a pause, type the next word.
|
|
201
205
|
timeoutId = setTimeout(() => {
|
|
206
|
+
setIsDeleting(false)
|
|
202
207
|
setWordIndex((prev) => (prev + 1) % words.length)
|
|
203
208
|
}, wordDelay)
|
|
204
209
|
}
|
|
205
210
|
}
|
|
206
211
|
|
|
207
212
|
return () => clearTimeout(timeoutId)
|
|
208
|
-
}, [
|
|
213
|
+
}, [typedText, isDeleting, wordIndex, words, speed, deleteDelay, wordDelay, prefersReducedMotion])
|
|
209
214
|
|
|
210
215
|
// Cursor blink
|
|
211
216
|
const [cursorVisible, setCursorVisible] = React.useState(true)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { describe, it, expect, vi } from "vitest"
|
|
3
|
+
import { render } from "@testing-library/react"
|
|
4
|
+
|
|
5
|
+
// shadcn's cn, which a project set up with `shadcn init` keeps: plain
|
|
6
|
+
// tailwind-merge, which doesn't know Fasla's `text-xxs`.
|
|
7
|
+
vi.mock("../../../src/lib/utils", async () => {
|
|
8
|
+
const { clsx } = await import("clsx")
|
|
9
|
+
const { twMerge } = await import("tailwind-merge")
|
|
10
|
+
return { cn: (...inputs: Parameters<typeof clsx>) => twMerge(clsx(inputs)) }
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
import { Avatar, avatarVariants } from "./avatar"
|
|
14
|
+
|
|
15
|
+
const sizes = ["32", "24", "12"] as const
|
|
16
|
+
const radii = ["standard", "rounded"] as const
|
|
17
|
+
|
|
18
|
+
function frameOf(props: React.ComponentProps<typeof Avatar>) {
|
|
19
|
+
const { container } = render(<Avatar data-testid="avatar" {...props} />)
|
|
20
|
+
return container.querySelector("[data-testid='avatar']")!.firstElementChild as HTMLElement
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("Avatar with shadcn's cn", () => {
|
|
24
|
+
it.each(sizes)("keeps text-foreground at %s, where the initials and icon take it", (size) => {
|
|
25
|
+
expect(frameOf({ size, variant: "initials", name: "Layla" })).toHaveClass("text-foreground")
|
|
26
|
+
expect(frameOf({ size, variant: "icon" })).toHaveClass("text-foreground")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Every class, in order, exactly as the variants give them: nothing merged
|
|
30
|
+
// away, nothing added, so Avatar looks as it did at every size.
|
|
31
|
+
it.each(sizes.flatMap((size) => radii.flatMap((radius) => [true, false].map((border) => [size, radius, border] as const))))(
|
|
32
|
+
"draws %s %s border=%s with the variants' classes and bg-muted, unmerged",
|
|
33
|
+
(size, radius, border) => {
|
|
34
|
+
const frame = frameOf({ size, radius, border, variant: "initials", name: "Layla" })
|
|
35
|
+
expect(frame.className).toBe(`${avatarVariants({ size, radius, border })} bg-muted`)
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
})
|
|
@@ -31,10 +31,12 @@ describe("Avatar", () => {
|
|
|
31
31
|
["24", "size-6", "rounded-[var(--radius-md)]", "text-xs"],
|
|
32
32
|
// Figma's XXS: 10px, leaded 14 in English and 16 in Arabic.
|
|
33
33
|
["12", "size-3", "rounded-[var(--radius-xs)]", "text-xxs"],
|
|
34
|
-
] as const)("Standard %s is %s, %s, %s", (size, box, corner, text) => {
|
|
34
|
+
] as const)("Standard %s is %s, %s, %s, in the foreground colour", (size, box, corner, text) => {
|
|
35
35
|
const { root, frame } = avatar({ size, name: "Layla" })
|
|
36
36
|
expect(root).toHaveClass(box)
|
|
37
|
-
|
|
37
|
+
// text-foreground too: merged beside an unknown size, cn dropped it at
|
|
38
|
+
// 12 and the initials took the colour of whatever held them.
|
|
39
|
+
expect(frame).toHaveClass(corner, text, "text-foreground")
|
|
38
40
|
})
|
|
39
41
|
|
|
40
42
|
it.each(["32", "24", "12"] as const)("Rounded %s is a circle", (size) => {
|
|
@@ -207,11 +207,16 @@ const Avatar = React.forwardRef<HTMLSpanElement, AvatarProps>(
|
|
|
207
207
|
{...props}
|
|
208
208
|
>
|
|
209
209
|
<span
|
|
210
|
-
|
|
210
|
+
// Joined, not cn(): every class here is Avatar's own and none
|
|
211
|
+
// conflicts. A project's cn may not know `text-xxs` — shadcn's
|
|
212
|
+
// doesn't — and takes it for a colour, dropping `text-foreground`.
|
|
213
|
+
className={[
|
|
211
214
|
avatarVariants({ size, radius, border }),
|
|
212
215
|
// Figma's Image variants have no fill; the fallback sits on `muted`.
|
|
213
|
-
!loaded && "bg-muted"
|
|
214
|
-
|
|
216
|
+
!loaded && "bg-muted",
|
|
217
|
+
]
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
.join(" ")}
|
|
215
220
|
>
|
|
216
221
|
{!loaded &&
|
|
217
222
|
(letters ? (
|