@smicolon/fasla-ui 0.4.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
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/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)
|