uifolio 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "uifolio",
3
- "version": "0.3.0",
3
+ "version": "0.5.1",
4
4
  "description": "CLI qui copie des composants React (calendrier, lecteurs vidéo/audio, OTP, éditeur, upload) et leurs helpers dans ton projet, avec des tokens Tailwind CSS 4.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Barronakn <jeanbarronalokpon@gmail.com>",
8
- "homepage": "https://github.com/Barronakn/UIfolio#readme",
8
+ "homepage": "https://ui.folio.barrondev.site",
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/Barronakn/UIfolio.git"
@@ -14,17 +14,32 @@
14
14
  "url": "https://github.com/Barronakn/UIfolio/issues"
15
15
  },
16
16
  "keywords": [
17
- "uifolio",
18
17
  "react",
19
- "components",
20
- "ui",
21
- "cli",
22
- "tailwind",
18
+ "react-component",
23
19
  "tailwindcss",
20
+ "ui-components",
21
+ "component-library",
22
+ "cli",
23
+ "uifolio",
24
24
  "calendar",
25
25
  "video-player",
26
+ "audio-player",
26
27
  "otp",
27
- "shadcn"
28
+ "color-picker",
29
+ "file-upload",
30
+ "rich-text-editor",
31
+ "command-menu",
32
+ "resizable-panels",
33
+ "time-picker",
34
+ "cadence",
35
+ "storytelling",
36
+ "accordion",
37
+ "text-reveal",
38
+ "hls.js",
39
+ "vtt",
40
+ "shadcn",
41
+ "shadcn-clone",
42
+ "shadcn-like"
28
43
  ],
29
44
  "engines": {
30
45
  "node": ">=18"
@@ -38,9 +53,11 @@
38
53
  "files": [
39
54
  "cli",
40
55
  "src/components/ui",
56
+ "!src/components/ui/stories-tokens.ts",
41
57
  "src/lib/color.ts",
42
58
  "src/lib/date.ts",
43
59
  "src/lib/uploader.ts",
60
+ "src/lib/use-controllable.ts",
44
61
  "src/lib/use-hotkeys.ts",
45
62
  "src/lib/use-reduced-motion.ts",
46
63
  "src/lib/use-theme.ts",
@@ -30,7 +30,7 @@ export function AudioPlayer({
30
30
  variant = 'card',
31
31
  loop = false,
32
32
  className,
33
- }: AudioPlayerProps) {
33
+ }: Readonly<AudioPlayerProps>) {
34
34
  const audioRef = useRef<HTMLAudioElement>(null)
35
35
  const [playing, setPlaying] = useState(false)
36
36
  const [currentTime, setCurrentTime] = useState(0)
@@ -54,7 +54,7 @@ export function Calendar({
54
54
  disabled,
55
55
  manualInput = true,
56
56
  className,
57
- }: CalendarProps) {
57
+ }: Readonly<CalendarProps>) {
58
58
  const [internalValue, setInternalValue] = useState<CalendarValue>(defaultValue ?? null)
59
59
  const currentValue = value !== undefined ? value : internalValue
60
60
 
@@ -30,7 +30,7 @@ export function Choice({
30
30
  name,
31
31
  value,
32
32
  className,
33
- }: ChoiceProps) {
33
+ }: Readonly<ChoiceProps>) {
34
34
  const id = useId()
35
35
  const padding = Math.max(2, Math.round(size * 0.1))
36
36
  const thumb = Math.max(6, size - padding * 2)
@@ -43,12 +43,12 @@ function ChannelField({
43
43
  value,
44
44
  disabled,
45
45
  onChange,
46
- }: {
46
+ }: Readonly<{
47
47
  label: string
48
48
  value: number
49
49
  disabled: boolean
50
50
  onChange: (value: number) => void
51
- }) {
51
+ }>) {
52
52
  const [draft, setDraft] = useState<string | null>(null)
53
53
 
54
54
  return (
@@ -123,7 +123,7 @@ export function ColorPicker({
123
123
  open,
124
124
  defaultOpen = false,
125
125
  onOpenChange,
126
- }: ColorPickerProps) {
126
+ }: Readonly<ColorPickerProps>) {
127
127
  const generatedId = useId()
128
128
  const inputId = id ?? generatedId
129
129
  const panelId = `${inputId}-panel`
@@ -37,7 +37,7 @@ export function CommandMenu({
37
37
  emptyText = 'Aucun résultat.',
38
38
  emptyState,
39
39
  className,
40
- }: CommandMenuProps) {
40
+ }: Readonly<CommandMenuProps>) {
41
41
  const [internalOpen, setInternalOpen] = useState(defaultOpen)
42
42
  const isOpen = open ?? internalOpen
43
43
  const [query, setQuery] = useState('')
@@ -43,7 +43,7 @@ export function FileUpload({
43
43
  uploader,
44
44
  onChange,
45
45
  className,
46
- }: FileUploadProps) {
46
+ }: Readonly<FileUploadProps>) {
47
47
  const inputId = useId()
48
48
  const [items, setItems] = useState<UploadItem[]>([])
49
49
  const [dragging, setDragging] = useState(false)
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useRef, useState } from 'react'
2
2
  import { Search, X, Check, ChevronDown } from 'lucide-react'
3
3
  import { cn } from '@/lib/utils'
4
4
 
@@ -126,11 +126,11 @@ function IconSvg({
126
126
  data,
127
127
  color,
128
128
  className,
129
- }: {
129
+ }: Readonly<{
130
130
  data: IconData | null
131
131
  color: string
132
132
  className?: string
133
- }) {
133
+ }>) {
134
134
  if (!data) {
135
135
  return <span className={cn('inline-block rounded bg-muted', className)} />
136
136
  }
@@ -149,26 +149,31 @@ export function IconGlyph({
149
149
  icon,
150
150
  color = 'currentColor',
151
151
  className,
152
- }: {
152
+ }: Readonly<{
153
153
  icon: string
154
154
  color?: string
155
155
  className?: string
156
- }) {
156
+ }>) {
157
157
  const [data, setData] = useState<IconData | null>(() => iconCache.get(icon) ?? null)
158
+ const [prevIcon, setPrevIcon] = useState(icon)
159
+ const loadRef = useRef<Promise<void> | null>(null)
160
+
161
+ // Ajustement pendant le rendu : une icône déjà en cache s'applique sans effet.
162
+ if (prevIcon !== icon) {
163
+ setPrevIcon(icon)
164
+ setData(iconCache.get(icon) ?? null)
165
+ }
158
166
 
159
167
  useEffect(() => {
160
- const cached = iconCache.get(icon)
161
- if (cached) {
162
- setData(cached)
163
- return
164
- }
165
- let cancelled = false
166
- setData(null)
167
- loadIcons([icon]).then((icons) => {
168
- if (!cancelled) setData(icons[icon] ?? null)
168
+ if (iconCache.get(icon)) return
169
+ const current = loadIcons([icon]).then((icons) => {
170
+ if (loadRef.current === current) {
171
+ setData(icons[icon] ?? null)
172
+ }
169
173
  })
174
+ loadRef.current = current
170
175
  return () => {
171
- cancelled = true
176
+ loadRef.current = null
172
177
  }
173
178
  }, [icon])
174
179
 
@@ -181,12 +186,13 @@ export function IconPicker({
181
186
  onChange,
182
187
  placeholder = 'Choisir une icône',
183
188
  className,
184
- }: IconPickerProps) {
189
+ }: Readonly<IconPickerProps>) {
185
190
  const [open, setOpen] = useState(false)
186
191
  const [search, setSearch] = useState('')
187
192
  const [icons, setIcons] = useState<string[]>([])
188
193
  const [bodies, setBodies] = useState<Record<string, IconData>>({})
189
194
  const [loading, setLoading] = useState(false)
195
+ const loadingRef = useRef<Promise<void> | null>(null)
190
196
 
191
197
  useEffect(() => {
192
198
  if (!open) return
@@ -221,12 +227,14 @@ export function IconPicker({
221
227
 
222
228
  useEffect(() => {
223
229
  if (icons.length === 0) return
224
- let cancelled = false
225
- loadIcons(icons).then((loaded) => {
226
- if (!cancelled) setBodies((prev) => ({ ...prev, ...loaded }))
230
+ const current = loadIcons(icons).then((loaded) => {
231
+ if (loadingRef.current === current) {
232
+ setBodies((prev) => ({ ...prev, ...loaded }))
233
+ }
227
234
  })
235
+ loadingRef.current = current
228
236
  return () => {
229
- cancelled = true
237
+ loadingRef.current = null
230
238
  }
231
239
  }, [icons])
232
240
 
@@ -21,7 +21,7 @@ export function FadeIn({
21
21
  duration = 520,
22
22
  className,
23
23
  style,
24
- }: MotionProps) {
24
+ }: Readonly<MotionProps>) {
25
25
  const reduced = useReducedMotion()
26
26
  return (
27
27
  <div
@@ -45,10 +45,12 @@ export function SlideIn({
45
45
  style,
46
46
  direction = 'up',
47
47
  distance = 20,
48
- }: MotionProps & {
49
- direction?: 'up' | 'down' | 'left' | 'right'
50
- distance?: number
51
- }) {
48
+ }: Readonly<
49
+ MotionProps & {
50
+ direction?: 'up' | 'down' | 'left' | 'right'
51
+ distance?: number
52
+ }
53
+ >) {
52
54
  const reduced = useReducedMotion()
53
55
  const offset = {
54
56
  up: `translate3d(0, ${distance}px, 0)`,
@@ -82,7 +84,7 @@ export function ScaleIn({
82
84
  className,
83
85
  style,
84
86
  from = 0.92,
85
- }: MotionProps & { from?: number }) {
87
+ }: Readonly<MotionProps & { from?: number }>) {
86
88
  const reduced = useReducedMotion()
87
89
  return (
88
90
  <div
@@ -108,13 +110,13 @@ export function Stagger({
108
110
  duration = 460,
109
111
  className,
110
112
  itemClassName,
111
- }: {
113
+ }: Readonly<{
112
114
  children: ReactNode
113
115
  delay?: number
114
116
  duration?: number
115
117
  className?: string
116
118
  itemClassName?: string
117
- }) {
119
+ }>) {
118
120
  return (
119
121
  <div className={className}>
120
122
  {Children.map(children, (child, index) => (
@@ -39,14 +39,13 @@ export function OtpInput({
39
39
  error = false,
40
40
  className,
41
41
  inputClassName,
42
- }: OtpInputProps) {
42
+ }: Readonly<OtpInputProps>) {
43
43
  const [internalValue, setInternalValue] = useState(defaultValue)
44
44
  const current = value ?? internalValue
45
- const [phase, setPhase] = useState<'idle' | 'success'>('idle')
45
+ const complete = current.length === length && /^\d+$/.test(current)
46
+ const phase = complete && !error ? 'success' : 'idle'
46
47
 
47
48
  const inputs = useRef<Array<HTMLInputElement | null>>([])
48
- const completed = useRef(false)
49
- const completedValue = useRef<string | null>(null)
50
49
  const onChangeRef = useRef(onChange)
51
50
  const onCompleteRef = useRef(onComplete)
52
51
 
@@ -55,39 +54,16 @@ export function OtpInput({
55
54
  onCompleteRef.current = onComplete
56
55
  })
57
56
 
57
+ useEffect(() => {
58
+ if (autoFocus) inputs.current[0]?.focus()
59
+ }, [autoFocus])
60
+
58
61
  const commit = (next: string) => {
59
62
  const cleaned = next.replace(/\D/g, '').slice(0, length)
60
63
  if (value === undefined) setInternalValue(cleaned)
61
64
  onChangeRef.current?.(cleaned)
62
65
  }
63
66
 
64
- const complete = current.length === length && /^\d+$/.test(current)
65
-
66
- useEffect(() => {
67
- if (!complete) {
68
- completed.current = false
69
- completedValue.current = null
70
- setPhase('idle')
71
- return
72
- }
73
- if (completed.current && completedValue.current === current) return
74
- completed.current = true
75
- completedValue.current = current
76
- setPhase('success')
77
- onCompleteRef.current?.(current)
78
- }, [complete, current, length])
79
-
80
- useEffect(() => {
81
- if (!error) return
82
- completed.current = false
83
- completedValue.current = null
84
- setPhase('idle')
85
- }, [error])
86
-
87
- useEffect(() => {
88
- if (autoFocus) inputs.current[0]?.focus()
89
- }, [autoFocus])
90
-
91
67
  const focusInput = (index: number) => {
92
68
  inputs.current[Math.max(0, Math.min(length - 1, index))]?.focus()
93
69
  inputs.current[Math.max(0, Math.min(length - 1, index))]?.select()
@@ -10,11 +10,11 @@ export function ResizablePanelGroup({
10
10
  direction = 'horizontal',
11
11
  className,
12
12
  children,
13
- }: {
13
+ }: Readonly<{
14
14
  direction?: ResizableDirection
15
15
  className?: string
16
16
  children: ReactNode
17
- }) {
17
+ }>) {
18
18
  return (
19
19
  <div
20
20
  data-resizable-group
@@ -35,12 +35,12 @@ export function ResizablePanel({
35
35
  minSize = 10,
36
36
  className,
37
37
  children,
38
- }: {
38
+ }: Readonly<{
39
39
  defaultSize?: number
40
40
  minSize?: number
41
41
  className?: string
42
42
  children: ReactNode
43
- }) {
43
+ }>) {
44
44
  return (
45
45
  <div
46
46
  data-resizable-panel
@@ -54,7 +54,7 @@ export function ResizablePanel({
54
54
  )
55
55
  }
56
56
 
57
- export function ResizableHandle({ className }: { className?: string }) {
57
+ export function ResizableHandle({ className }: Readonly<{ className?: string }>) {
58
58
  const ref = useRef<HTMLDivElement>(null)
59
59
  const [orientation, setOrientation] = useState<'horizontal' | 'vertical'>('vertical')
60
60
 
@@ -43,7 +43,7 @@ export function RichTextEditor({
43
43
  toolbar = true,
44
44
  minHeight = 220,
45
45
  className,
46
- }: RichTextEditorProps) {
46
+ }: Readonly<RichTextEditorProps>) {
47
47
  const editorRef = useRef<HTMLDivElement>(null)
48
48
  const [internalValue, setInternalValue] = useState(defaultValue)
49
49
  const html = value ?? internalValue
@@ -254,12 +254,12 @@ function ToolButton({
254
254
  active,
255
255
  onClick,
256
256
  children,
257
- }: {
257
+ }: Readonly<{
258
258
  label: string
259
259
  active?: boolean
260
260
  onClick: () => void
261
261
  children: ReactNode
262
- }) {
262
+ }>) {
263
263
  return (
264
264
  <button
265
265
  type="button"
@@ -37,7 +37,7 @@ export function Stories({
37
37
  duration = 5000,
38
38
  className,
39
39
  textBackground = 'linear-gradient(160deg, color-mix(in oklab, var(--primary) 35%, #000) 0%, var(--primary) 55%, color-mix(in oklab, var(--primary) 65%, #fff) 100%)',
40
- }: StoriesProps) {
40
+ }: Readonly<StoriesProps>) {
41
41
  const [internalOpen, setInternalOpen] = useState(defaultOpen)
42
42
  const isOpen = open ?? internalOpen
43
43
  const [index, setIndex] = useState(initialIndex)
@@ -50,6 +50,11 @@ export function Stories({
50
50
 
51
51
  const setOpen = useCallback(
52
52
  (next: boolean) => {
53
+ if (next) {
54
+ // Réinitialise la lecture à l'ouverture plutôt que par un effet.
55
+ elapsedRef.current = 0
56
+ setPaused(false)
57
+ }
53
58
  if (open === undefined) setInternalOpen(next)
54
59
  onOpenChange?.(next)
55
60
  },
@@ -76,13 +81,6 @@ export function Stories({
76
81
 
77
82
  const close = useCallback(() => setOpen(false), [setOpen])
78
83
 
79
- useEffect(() => {
80
- if (!isOpen) return
81
- setIndex(initialIndex)
82
- setPaused(false)
83
- elapsedRef.current = 0
84
- }, [initialIndex, isOpen])
85
-
86
84
  useEffect(() => {
87
85
  if (!isOpen) return
88
86
  const previous = document.body.style.overflow
@@ -115,14 +113,19 @@ export function Stories({
115
113
  else void video.play().catch(() => {})
116
114
  }, [index, isOpen, paused])
117
115
 
116
+ const mainLoopRef = useRef<{ startedAt: number; total: number } | null>(null)
117
+
118
118
  useEffect(() => {
119
119
  if (!isOpen || paused) return
120
120
  if (barRef.current) barRef.current.style.transform = 'scaleX(0)'
121
121
 
122
- const total = items[index]?.duration ?? duration
122
+ const item = items[index]
123
+ const total = item?.duration ?? duration
123
124
  const startedAt = performance.now() - elapsedRef.current
124
- let frame = 0
125
125
 
126
+ mainLoopRef.current = { startedAt, total }
127
+
128
+ let frame = 0
126
129
  const tick = (now: number) => {
127
130
  const elapsed = now - startedAt
128
131
  elapsedRef.current = elapsed
@@ -45,7 +45,7 @@ export function TimePicker({
45
45
  disabled = false,
46
46
  label,
47
47
  className,
48
- }: TimePickerProps) {
48
+ }: Readonly<TimePickerProps>) {
49
49
  const [internalValue, setInternalValue] = useState(defaultValue)
50
50
  const current = value ?? internalValue
51
51
  const inputValue = toInputValue(current, withSeconds)