@smicolon/fasla-ui 0.3.0 → 0.4.1

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.
Files changed (35) hide show
  1. package/README.md +52 -20
  2. package/dist/index.cjs +319 -0
  3. package/dist/index.d.cts +5 -0
  4. package/dist/index.js +1 -1
  5. package/dist/lib/utils.cjs +11 -0
  6. package/dist/lib/utils.d.cts +9 -0
  7. package/dist/motion/index.cjs +61 -0
  8. package/dist/motion/index.d.cts +33 -0
  9. package/dist/registry/index.cjs +179 -0
  10. package/dist/registry/index.d.cts +17 -0
  11. package/dist/registry/index.js +1 -1
  12. package/dist/tokens/index.cjs +77 -0
  13. package/dist/tokens/index.d.cts +124 -0
  14. package/package.json +14 -7
  15. package/registry/blocks/empty-state/EmptyState.test.tsx +11 -0
  16. package/registry/blocks/empty-state/EmptyState.tsx +10 -2
  17. package/registry/blocks/sidebar/Sidebar.test.tsx +39 -0
  18. package/registry/blocks/sidebar/Sidebar.tsx +28 -11
  19. package/registry/blocks/stats-card/StatsCard.tsx +3 -2
  20. package/registry/effects/animated-gradient/animated-gradient.tsx +3 -7
  21. package/registry/effects/effects.test.tsx +61 -0
  22. package/registry/effects/glow-card/glow-card.tsx +18 -12
  23. package/registry/effects/text-reveal/text-reveal.tsx +3 -1
  24. package/registry/effects/typewriter-text/typewriter-text.tsx +49 -44
  25. package/registry/next14-tsconfig.test.ts +43 -0
  26. package/registry/ui/checkbox/checkbox.test.tsx +23 -0
  27. package/registry/ui/checkbox/checkbox.tsx +3 -1
  28. package/registry/ui/combobox/combobox.test.tsx +30 -1
  29. package/registry/ui/combobox/combobox.tsx +7 -4
  30. package/registry/ui/input/input.test.tsx +1 -1
  31. package/registry/ui/input/input.tsx +1 -1
  32. package/registry/ui/skeleton/index.ts +1 -0
  33. package/registry/ui/skeleton/skeleton.test.tsx +113 -29
  34. package/registry/ui/skeleton/skeleton.tsx +101 -41
  35. package/registry.json +1 -1
@@ -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). */
@@ -38,7 +38,9 @@ function splitGraphemes(text: string): string[] {
38
38
  }
39
39
  // Without Segmenter (Firefox before 125, for one), keep each combining mark
40
40
  // with the character before it, so a letter and its accent animate together.
41
- return text.match(/\P{M}\p{M}*|\p{M}+/gu) ?? []
41
+ // Built from a string: TypeScript rejects the u flag in a regex literal when
42
+ // the target is below ES2015, as it is in a Next.js 14 tsconfig.
43
+ return text.match(new RegExp("\\P{M}\\p{M}*|\\p{M}+", "gu")) ?? []
42
44
  }
43
45
 
44
46
  /**
@@ -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,43 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from "vitest"
3
+ import { readdirSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import ts from "typescript"
6
+
7
+ const registry = __dirname
8
+
9
+ function sourceFiles(dir: string): string[] {
10
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
11
+ const path = join(dir, entry.name)
12
+ if (entry.isDirectory()) return sourceFiles(path)
13
+ return /\.tsx?$/.test(entry.name) && !/\.(test|stories)\.tsx?$/.test(entry.name) ? [path] : []
14
+ })
15
+ }
16
+
17
+ // The compiler options create-next-app@14 writes. It sets no target, so
18
+ // TypeScript falls back to ES5, and syntax that needs ES2015 or later (a u
19
+ // flag on a regex literal, for one) fails `next build`.
20
+ const next14 = {
21
+ lib: ["lib.dom.d.ts", "lib.dom.iterable.d.ts", "lib.esnext.d.ts"],
22
+ allowJs: true,
23
+ skipLibCheck: true,
24
+ strict: true,
25
+ noEmit: true,
26
+ esModuleInterop: true,
27
+ module: ts.ModuleKind.ESNext,
28
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
29
+ resolveJsonModule: true,
30
+ isolatedModules: true,
31
+ jsx: ts.JsxEmit.Preserve,
32
+ }
33
+
34
+ describe("registry under a Next.js 14 tsconfig", () => {
35
+ it("type-checks with no target set", () => {
36
+ const program = ts.createProgram(sourceFiles(registry), next14)
37
+ const errors = ts.getPreEmitDiagnostics(program).map((d) => {
38
+ const where = d.file ? `${d.file.fileName.replace(registry, "registry")}:${d.file.getLineAndCharacterOfPosition(d.start ?? 0).line + 1}` : ""
39
+ return `${where} TS${d.code} ${ts.flattenDiagnosticMessageText(d.messageText, " ")}`
40
+ })
41
+ expect(errors).toEqual([])
42
+ }, 60_000)
43
+ })
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { render, screen } from "@testing-library/react"
3
+ import { Checkbox } from "./checkbox"
4
+
5
+ describe("Checkbox id", () => {
6
+ it("uses the id it is given", () => {
7
+ render(<Checkbox id="terms" label="Accept the terms" />)
8
+ expect(screen.getByLabelText("Accept the terms").id).toBe("terms")
9
+ })
10
+
11
+ it("generates an id that ties the label to the input", () => {
12
+ render(<Checkbox label="Accept the terms" />)
13
+ expect(screen.getByLabelText("Accept the terms").id).not.toBe("")
14
+ })
15
+
16
+ it("keeps working when the id is added and removed between renders", () => {
17
+ const { rerender } = render(<Checkbox label="Accept the terms" />)
18
+ rerender(<Checkbox id="terms" label="Accept the terms" />)
19
+ expect(screen.getByLabelText("Accept the terms").id).toBe("terms")
20
+ rerender(<Checkbox label="Accept the terms" />)
21
+ expect(screen.getByLabelText("Accept the terms").id).not.toBe("terms")
22
+ })
23
+ })
@@ -26,7 +26,9 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
26
26
  }
27
27
  }, [indeterminate])
28
28
 
29
- const inputId = id || React.useId()
29
+ // Called on every render, so the hook order holds when id comes and goes
30
+ const generatedId = React.useId()
31
+ const inputId = id || generatedId
30
32
 
31
33
  const checkbox = (
32
34
  <div className="relative flex items-center">
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest"
1
+ import { describe, it, expect, beforeAll } from "vitest"
2
2
  import { render, screen, fireEvent } from "@testing-library/react"
3
3
  import { Combobox } from "./combobox"
4
4
 
@@ -41,3 +41,32 @@ describe("Combobox text", () => {
41
41
  expect(screen.getByText("جارٍ التحميل")).toBeTruthy()
42
42
  })
43
43
  })
44
+
45
+ describe("Combobox highlight", () => {
46
+ // The highlighted option is scrolled into view, which jsdom does not implement
47
+ beforeAll(() => {
48
+ Element.prototype.scrollIntoView ??= () => {}
49
+ })
50
+
51
+ it("moves back to the first option when the search changes", () => {
52
+ const cities = [...options, { value: "dammam", label: "Dammam" }]
53
+ render(<Combobox options={cities} />)
54
+ const input = screen.getByRole("combobox")
55
+
56
+ fireEvent.focus(input)
57
+ fireEvent.keyDown(input, { key: "ArrowDown" })
58
+ fireEvent.keyDown(input, { key: "ArrowDown" })
59
+ expect(screen.getByRole("option", { name: "Dammam" })).toHaveAttribute("data-highlighted", "true")
60
+
61
+ fireEvent.change(input, { target: { value: "a" } })
62
+ expect(screen.getByRole("option", { name: "Riyadh" })).toHaveAttribute("data-highlighted", "true")
63
+ expect(screen.getByRole("option", { name: "Dammam" })).toHaveAttribute("data-highlighted", "false")
64
+ })
65
+
66
+ it("quotes the search in the create option", () => {
67
+ render(<Combobox options={options} creatable onCreate={() => {}} />)
68
+ const input = screen.getByRole("combobox")
69
+ fireEvent.change(input, { target: { value: "Mecca" } })
70
+ expect(screen.getByRole("option").textContent).toBe('Create "Mecca"')
71
+ })
72
+ })
@@ -189,10 +189,13 @@ export function Combobox({
189
189
  }
190
190
  }, [highlightedIndex, open])
191
191
 
192
- // Reset highlighted index when search changes
193
- React.useEffect(() => {
192
+ // Reset highlighted index when search changes. Done while rendering rather
193
+ // than in an effect, so the list never paints with the old highlight.
194
+ const [highlightedSearch, setHighlightedSearch] = React.useState(search)
195
+ if (highlightedSearch !== search) {
196
+ setHighlightedSearch(search)
194
197
  setHighlightedIndex(0)
195
- }, [search])
198
+ }
196
199
 
197
200
  // Close on outside click
198
201
  React.useEffect(() => {
@@ -334,7 +337,7 @@ export function Combobox({
334
337
  )}
335
338
  >
336
339
  <PlusIcon size={16} strokeWidth={1.5} className="me-2" />
337
- {createText} "{search}"
340
+ {createText} &quot;{search}&quot;
338
341
  </li>
339
342
  )}
340
343
  </>
@@ -16,7 +16,7 @@ describe("Input", () => {
16
16
  expect(screen.getByTestId("input")).toHaveClass("border-destructive")
17
17
 
18
18
  rerender(<Input variant="success" data-testid="input" />)
19
- expect(screen.getByTestId("input")).toHaveClass("border-green-500")
19
+ expect(screen.getByTestId("input")).toHaveClass("border-success")
20
20
  })
21
21
 
22
22
  it("renders with different sizes", () => {
@@ -9,7 +9,7 @@ const inputVariants = cva(
9
9
  variant: {
10
10
  default: "border-input shadow-sm",
11
11
  error: "border-destructive focus-visible:ring-destructive",
12
- success: "border-green-500 focus-visible:ring-green-500",
12
+ success: "border-success focus-visible:ring-success",
13
13
  },
14
14
  inputSize: {
15
15
  default: "h-9 px-3 py-1",
@@ -2,6 +2,7 @@ export {
2
2
  Skeleton,
3
3
  SkeletonText,
4
4
  SkeletonAvatar,
5
+ SkeletonListItem,
5
6
  SkeletonCard,
6
7
  type SkeletonProps,
7
8
  } from "./skeleton"