@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 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 tailwindMerge.twMerge(clsx.clsx(inputs));
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 { twMerge } from 'tailwind-merge';
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
  }
@@ -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 tailwindMerge.twMerge(clsx.clsx(inputs));
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 { twMerge } from 'tailwind-merge';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smicolon/fasla-ui",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Professional UI components with animations and app building blocks",
5
5
  "license": "MIT",
6
6
  "author": "Smicolon",
@@ -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. It starts false, so the server and
8
- * the first client render agree, then follows the setting as it changes.
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
- const [reduced, setReduced] = React.useState(false)
12
- React.useEffect(() => {
13
- if (typeof window.matchMedia !== "function") return
14
- const query = window.matchMedia("(prefers-reduced-motion: reduce)")
15
- setReduced(query.matches)
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. It starts false, so the server and
8
- * the first client render agree, then follows the setting as it changes.
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
- const [reduced, setReduced] = React.useState(false)
12
- React.useEffect(() => {
13
- if (typeof window.matchMedia !== "function") return
14
- const query = window.matchMedia("(prefers-reduced-motion: reduce)")
15
- setReduced(query.matches)
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
- onCompleteRef.current = onComplete
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
- setIsTyping(false)
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 [displayText, setDisplayText] = React.useState("")
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 (displayText.length < currentWord.length) {
187
+ if (typedText.length < currentWord.length) {
183
188
  timeoutId = setTimeout(() => {
184
- setDisplayText(currentWord.slice(0, displayText.length + 1))
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 (displayText.length > 0) {
199
+ if (typedText.length > 0) {
195
200
  timeoutId = setTimeout(() => {
196
- setDisplayText(displayText.slice(0, -1))
201
+ setTypedText(typedText.slice(0, -1))
197
202
  }, speed / 2)
198
203
  } else {
199
- // Deletion complete, move to next word
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
- }, [displayText, isDeleting, wordIndex, words, speed, deleteDelay, wordDelay, prefersReducedMotion])
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
- expect(frame).toHaveClass(corner, text)
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
- className={cn(
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 ? (