@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.
- package/README.md +52 -20
- package/dist/index.cjs +319 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.js +1 -1
- package/dist/lib/utils.cjs +11 -0
- package/dist/lib/utils.d.cts +9 -0
- package/dist/motion/index.cjs +61 -0
- package/dist/motion/index.d.cts +33 -0
- package/dist/registry/index.cjs +179 -0
- package/dist/registry/index.d.cts +17 -0
- package/dist/registry/index.js +1 -1
- package/dist/tokens/index.cjs +77 -0
- package/dist/tokens/index.d.cts +124 -0
- package/package.json +14 -7
- package/registry/blocks/empty-state/EmptyState.test.tsx +11 -0
- package/registry/blocks/empty-state/EmptyState.tsx +10 -2
- package/registry/blocks/sidebar/Sidebar.test.tsx +39 -0
- package/registry/blocks/sidebar/Sidebar.tsx +28 -11
- package/registry/blocks/stats-card/StatsCard.tsx +3 -2
- package/registry/effects/animated-gradient/animated-gradient.tsx +3 -7
- package/registry/effects/effects.test.tsx +61 -0
- package/registry/effects/glow-card/glow-card.tsx +18 -12
- package/registry/effects/text-reveal/text-reveal.tsx +3 -1
- package/registry/effects/typewriter-text/typewriter-text.tsx +49 -44
- package/registry/next14-tsconfig.test.ts +43 -0
- package/registry/ui/checkbox/checkbox.test.tsx +23 -0
- package/registry/ui/checkbox/checkbox.tsx +3 -1
- package/registry/ui/combobox/combobox.test.tsx +30 -1
- package/registry/ui/combobox/combobox.tsx +7 -4
- package/registry/ui/input/input.test.tsx +1 -1
- package/registry/ui/input/input.tsx +1 -1
- package/registry/ui/skeleton/index.ts +1 -0
- package/registry/ui/skeleton/skeleton.test.tsx +113 -29
- package/registry/ui/skeleton/skeleton.tsx +101 -41
- 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.
|
|
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). */
|
|
@@ -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
|
-
|
|
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.
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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}
|
|
340
|
+
{createText} "{search}"
|
|
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-
|
|
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-
|
|
12
|
+
success: "border-success focus-visible:ring-success",
|
|
13
13
|
},
|
|
14
14
|
inputSize: {
|
|
15
15
|
default: "h-9 px-3 py-1",
|