@boyernick/standard-ui-react 0.1.0

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 (67) hide show
  1. package/README.md +9 -0
  2. package/package.json +46 -0
  3. package/src/accordion.tsx +84 -0
  4. package/src/alert-dialog.tsx +101 -0
  5. package/src/attachment.tsx +138 -0
  6. package/src/autocomplete.tsx +281 -0
  7. package/src/avatar.tsx +56 -0
  8. package/src/badge.tsx +28 -0
  9. package/src/brand.tsx +70 -0
  10. package/src/breadcrumb.tsx +81 -0
  11. package/src/button.tsx +129 -0
  12. package/src/calendar.tsx +84 -0
  13. package/src/card.tsx +54 -0
  14. package/src/carousel.tsx +253 -0
  15. package/src/chart.tsx +185 -0
  16. package/src/checkbox-group.tsx +14 -0
  17. package/src/checkbox.tsx +31 -0
  18. package/src/code-block.tsx +124 -0
  19. package/src/collapsible.tsx +61 -0
  20. package/src/combobox.tsx +324 -0
  21. package/src/command.tsx +377 -0
  22. package/src/context-menu.tsx +250 -0
  23. package/src/dialog.tsx +78 -0
  24. package/src/drawer.tsx +156 -0
  25. package/src/empty.tsx +52 -0
  26. package/src/field.tsx +75 -0
  27. package/src/fieldset.tsx +25 -0
  28. package/src/form.tsx +14 -0
  29. package/src/icons.tsx +91 -0
  30. package/src/illustrations.tsx +167 -0
  31. package/src/image-modal.tsx +110 -0
  32. package/src/index.ts +833 -0
  33. package/src/input.tsx +68 -0
  34. package/src/lib/cn.ts +3 -0
  35. package/src/lib/motion.ts +23 -0
  36. package/src/markdown-editor.tsx +302 -0
  37. package/src/menu.tsx +205 -0
  38. package/src/menubar.tsx +22 -0
  39. package/src/meter.tsx +61 -0
  40. package/src/navigation-menu.tsx +217 -0
  41. package/src/number-field.tsx +119 -0
  42. package/src/orb.tsx +33 -0
  43. package/src/otp-field.tsx +45 -0
  44. package/src/pagination.tsx +91 -0
  45. package/src/popover.tsx +92 -0
  46. package/src/preview-card.tsx +103 -0
  47. package/src/progress.tsx +60 -0
  48. package/src/radio.tsx +43 -0
  49. package/src/scroll-area.tsx +67 -0
  50. package/src/select.tsx +161 -0
  51. package/src/separator.tsx +17 -0
  52. package/src/sidebar.tsx +82 -0
  53. package/src/skeleton.tsx +32 -0
  54. package/src/slider.tsx +56 -0
  55. package/src/sounds.tsx +270 -0
  56. package/src/spinner.tsx +45 -0
  57. package/src/switch.tsx +19 -0
  58. package/src/table.tsx +81 -0
  59. package/src/tabs.tsx +62 -0
  60. package/src/text-animate.tsx +155 -0
  61. package/src/textarea.tsx +58 -0
  62. package/src/ticker.tsx +74 -0
  63. package/src/toast.tsx +142 -0
  64. package/src/toggle.tsx +32 -0
  65. package/src/toolbar.tsx +75 -0
  66. package/src/tooltip.tsx +55 -0
  67. package/src/video-player.tsx +241 -0
package/src/ticker.tsx ADDED
@@ -0,0 +1,74 @@
1
+ "use client"
2
+
3
+ import type { ComponentProps, CSSProperties } from "react"
4
+ import { cn } from "./lib/cn"
5
+
6
+ export type TickerProps = ComponentProps<"div"> & {
7
+ /** Animation duration for one full loop. */
8
+ duration?: number
9
+ /** Pause scrolling while hovered or focused. */
10
+ pauseOnHover?: boolean
11
+ /** Reverse scroll direction. */
12
+ reverse?: boolean
13
+ }
14
+
15
+ export type TickerItemProps = ComponentProps<"div">
16
+
17
+ export const Ticker = ({
18
+ className,
19
+ children,
20
+ duration = 28,
21
+ pauseOnHover = true,
22
+ reverse = false,
23
+ style,
24
+ ...props
25
+ }: TickerProps) => {
26
+ const mergedStyle = {
27
+ ...style,
28
+ "--ticker-duration": `${duration}s`,
29
+ } as CSSProperties
30
+
31
+ return (
32
+ <div
33
+ data-slot="ticker"
34
+ role="marquee"
35
+ aria-label="Scrolling updates"
36
+ className={cn(
37
+ "group relative overflow-hidden rounded-xl border border-border-primary bg-surface",
38
+ className,
39
+ )}
40
+ style={mergedStyle}
41
+ {...props}
42
+ >
43
+ <div
44
+ className={cn(
45
+ "flex w-max gap-8 py-3 whitespace-nowrap",
46
+ "animate-[ticker-scroll_var(--ticker-duration)_linear_infinite]",
47
+ "motion-reduce:animate-none",
48
+ reverse && "[animation-direction:reverse]",
49
+ pauseOnHover &&
50
+ "group-hover:[animation-play-state:paused] group-focus-within:[animation-play-state:paused]",
51
+ )}
52
+ >
53
+ <div className="flex shrink-0 items-center gap-8 px-4">{children}</div>
54
+ <div
55
+ className="flex shrink-0 items-center gap-8 px-4"
56
+ aria-hidden
57
+ >
58
+ {children}
59
+ </div>
60
+ </div>
61
+ </div>
62
+ )
63
+ }
64
+
65
+ export const TickerItem = ({ className, ...props }: TickerItemProps) => (
66
+ <div
67
+ data-slot="ticker-item"
68
+ className={cn(
69
+ "inline-flex items-center gap-2 text-sm text-fg-secondary",
70
+ className,
71
+ )}
72
+ {...props}
73
+ />
74
+ )
package/src/toast.tsx ADDED
@@ -0,0 +1,142 @@
1
+ "use client"
2
+
3
+ import { Toast as BaseToast } from "@base-ui/react/toast"
4
+ import type { ComponentProps } from "react"
5
+ import { IconCrossSmall } from "./icons"
6
+ import { cn } from "./lib/cn"
7
+ import { motion } from "./lib/motion"
8
+
9
+ export type ToastProviderProps = ComponentProps<typeof BaseToast.Provider>
10
+ export type ToastPortalProps = ComponentProps<typeof BaseToast.Portal>
11
+ export type ToastViewportProps = ComponentProps<typeof BaseToast.Viewport>
12
+ export type ToastRootProps = ComponentProps<typeof BaseToast.Root>
13
+ export type ToastContentProps = ComponentProps<typeof BaseToast.Content>
14
+ export type ToastTitleProps = ComponentProps<typeof BaseToast.Title>
15
+ export type ToastDescriptionProps = ComponentProps<typeof BaseToast.Description>
16
+ export type ToastActionProps = ComponentProps<typeof BaseToast.Action>
17
+ export type ToastCloseProps = ComponentProps<typeof BaseToast.Close>
18
+ export type ToastPositionerProps = ComponentProps<typeof BaseToast.Positioner>
19
+ export type ToastArrowProps = ComponentProps<typeof BaseToast.Arrow>
20
+
21
+ export const ToastProvider = (props: ToastProviderProps) => (
22
+ <BaseToast.Provider {...props} />
23
+ )
24
+
25
+ export const ToastPortal = (props: ToastPortalProps) => (
26
+ <BaseToast.Portal {...props} />
27
+ )
28
+
29
+ export const ToastViewport = ({ className, ...props }: ToastViewportProps) => (
30
+ <BaseToast.Viewport
31
+ className={cn(
32
+ "fixed right-4 bottom-4 z-[100] flex w-[min(100vw-2rem,24rem)] flex-col outline-none",
33
+ className,
34
+ )}
35
+ {...props}
36
+ />
37
+ )
38
+
39
+ export const ToastRoot = ({ className, ...props }: ToastRootProps) => (
40
+ <BaseToast.Root
41
+ className={cn(
42
+ "relative z-[calc(1000-var(--toast-index))] mb-2 box-border w-full rounded-xl border border-border-primary bg-surface p-4 shadow-lg outline-none",
43
+ "h-[var(--toast-frontmost-height,var(--toast-height))]",
44
+ "transition-[transform,opacity] duration-200 ease-out motion-reduce:transition-none",
45
+ "data-starting-style:opacity-0 data-starting-style:translate-y-2",
46
+ "data-ending-style:opacity-0 data-ending-style:translate-y-2",
47
+ "data-expanded:h-[var(--toast-height)] data-expanded:translate-y-[var(--toast-offset-y)]",
48
+ "data-[type=error]:border-destructive/40",
49
+ "data-[type=success]:border-border-primary",
50
+ className,
51
+ )}
52
+ {...props}
53
+ />
54
+ )
55
+
56
+ export const ToastContent = ({ className, ...props }: ToastContentProps) => (
57
+ <BaseToast.Content
58
+ className={cn(
59
+ "flex flex-col gap-1 overflow-hidden transition-opacity duration-150",
60
+ "data-behind:pointer-events-none data-behind:opacity-0 data-expanded:data-behind:opacity-100",
61
+ className,
62
+ )}
63
+ {...props}
64
+ />
65
+ )
66
+
67
+ export const ToastTitle = ({ className, ...props }: ToastTitleProps) => (
68
+ <BaseToast.Title
69
+ className={cn("text-sm-strong pr-8 text-fg-primary", className)}
70
+ {...props}
71
+ />
72
+ )
73
+
74
+ export const ToastDescription = ({
75
+ className,
76
+ ...props
77
+ }: ToastDescriptionProps) => (
78
+ <BaseToast.Description
79
+ className={cn("text-sm leading-relaxed text-fg-secondary", className)}
80
+ {...props}
81
+ />
82
+ )
83
+
84
+ export const ToastAction = ({ className, ...props }: ToastActionProps) => (
85
+ <BaseToast.Action
86
+ className={cn(
87
+ "mt-2 inline-flex h-8 cursor-pointer items-center justify-center rounded-md px-2.5 text-sm text-fg-primary outline-none",
88
+ motion.colors,
89
+ "hover:bg-background-tertiary outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
90
+ className,
91
+ )}
92
+ {...props}
93
+ />
94
+ )
95
+
96
+ export const ToastClose = ({
97
+ className,
98
+ children,
99
+ ...props
100
+ }: ToastCloseProps) => (
101
+ <BaseToast.Close
102
+ className={cn(
103
+ "absolute top-3 right-3 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-fg-tertiary outline-none",
104
+ motion.colors,
105
+ "hover:bg-background-tertiary hover:text-fg-primary outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
106
+ className,
107
+ )}
108
+ {...props}
109
+ >
110
+ {children ?? (
111
+ <IconCrossSmall size={14} className="size-3.5" aria-hidden />
112
+ )}
113
+ </BaseToast.Close>
114
+ )
115
+
116
+ export const ToastPositioner = ({
117
+ className,
118
+ ...props
119
+ }: ToastPositionerProps) => (
120
+ <BaseToast.Positioner
121
+ className={cn("z-[100] outline-none", className)}
122
+ {...props}
123
+ />
124
+ )
125
+
126
+ export const ToastArrow = ({ className, ...props }: ToastArrowProps) => (
127
+ <BaseToast.Arrow
128
+ className={cn(
129
+ "data-[side=bottom]:top-[-6px] data-[side=left]:right-[-6px] data-[side=right]:left-[-6px] data-[side=top]:bottom-[-6px]",
130
+ "size-2.5 rotate-45 border border-border-primary bg-surface",
131
+ "data-[side=bottom]:border-r-0 data-[side=bottom]:border-b-0",
132
+ "data-[side=top]:border-t-0 data-[side=top]:border-l-0",
133
+ "data-[side=left]:border-b-0 data-[side=left]:border-l-0",
134
+ "data-[side=right]:border-t-0 data-[side=right]:border-r-0",
135
+ className,
136
+ )}
137
+ {...props}
138
+ />
139
+ )
140
+
141
+ export const useToastManager = BaseToast.useToastManager
142
+ export const createToastManager = BaseToast.createToastManager
package/src/toggle.tsx ADDED
@@ -0,0 +1,32 @@
1
+ "use client"
2
+
3
+ import { Toggle as BaseToggle } from "@base-ui/react/toggle"
4
+ import { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group"
5
+ import type { ComponentProps } from "react"
6
+ import { cn } from "./lib/cn"
7
+ import { motion } from "./lib/motion"
8
+
9
+ export type ToggleProps = ComponentProps<typeof BaseToggle>
10
+ export type ToggleGroupProps = ComponentProps<typeof BaseToggleGroup>
11
+
12
+ export const Toggle = ({ className, ...props }: ToggleProps) => (
13
+ <BaseToggle
14
+ className={cn(
15
+ "inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-2.5 text-sm text-fg-secondary outline-none",
16
+ motion.colors,
17
+ "hover:bg-background-tertiary focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-pressed:bg-background-tertiary data-pressed:text-fg-primary",
18
+ className,
19
+ )}
20
+ {...props}
21
+ />
22
+ )
23
+
24
+ export const ToggleGroup = ({ className, ...props }: ToggleGroupProps) => (
25
+ <BaseToggleGroup
26
+ className={cn(
27
+ "inline-flex items-center gap-0.5 rounded-lg border border-border-primary bg-background-secondary p-0.5",
28
+ className,
29
+ )}
30
+ {...props}
31
+ />
32
+ )
@@ -0,0 +1,75 @@
1
+ "use client"
2
+
3
+ import { Toolbar as BaseToolbar } from "@base-ui/react/toolbar"
4
+ import type { ComponentProps } from "react"
5
+ import { cn } from "./lib/cn"
6
+ import { motion } from "./lib/motion"
7
+
8
+ export type ToolbarProps = ComponentProps<typeof BaseToolbar.Root>
9
+ export type ToolbarGroupProps = ComponentProps<typeof BaseToolbar.Group>
10
+ export type ToolbarButtonProps = ComponentProps<typeof BaseToolbar.Button>
11
+ export type ToolbarLinkProps = ComponentProps<typeof BaseToolbar.Link>
12
+ export type ToolbarInputProps = ComponentProps<typeof BaseToolbar.Input>
13
+ export type ToolbarSeparatorProps = ComponentProps<typeof BaseToolbar.Separator>
14
+
15
+ export const Toolbar = ({ className, ...props }: ToolbarProps) => (
16
+ <BaseToolbar.Root
17
+ className={cn(
18
+ "inline-flex items-center gap-0.5 rounded-lg border border-border-primary bg-background-secondary p-0.5",
19
+ className,
20
+ )}
21
+ {...props}
22
+ />
23
+ )
24
+
25
+ export const ToolbarGroup = ({ className, ...props }: ToolbarGroupProps) => (
26
+ <BaseToolbar.Group
27
+ className={cn("flex items-center gap-0.5", className)}
28
+ {...props}
29
+ />
30
+ )
31
+
32
+ export const ToolbarButton = ({ className, ...props }: ToolbarButtonProps) => (
33
+ <BaseToolbar.Button
34
+ className={cn(
35
+ "inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2.5 text-sm text-fg-secondary outline-none",
36
+ motion.colors,
37
+ "hover:bg-background-tertiary hover:text-fg-primary outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-pressed:bg-background-tertiary data-pressed:text-fg-primary",
38
+ className,
39
+ )}
40
+ {...props}
41
+ />
42
+ )
43
+
44
+ export const ToolbarLink = ({ className, ...props }: ToolbarLinkProps) => (
45
+ <BaseToolbar.Link
46
+ className={cn(
47
+ "inline-flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2.5 text-sm text-fg-secondary outline-none",
48
+ motion.colors,
49
+ "hover:bg-background-tertiary hover:text-fg-primary outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
50
+ className,
51
+ )}
52
+ {...props}
53
+ />
54
+ )
55
+
56
+ export const ToolbarInput = ({ className, ...props }: ToolbarInputProps) => (
57
+ <BaseToolbar.Input
58
+ className={cn(
59
+ "h-8 min-w-28 cursor-text rounded-md border border-border-secondary bg-surface px-2.5 text-sm text-fg-primary outline-none placeholder:text-fg-quaternary",
60
+ "outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-offset-1 focus-visible:ring-offset-background-primary focus-visible:ring-ring/20",
61
+ className,
62
+ )}
63
+ {...props}
64
+ />
65
+ )
66
+
67
+ export const ToolbarSeparator = ({
68
+ className,
69
+ ...props
70
+ }: ToolbarSeparatorProps) => (
71
+ <BaseToolbar.Separator
72
+ className={cn("mx-0.5 h-5 w-px bg-border-primary", className)}
73
+ {...props}
74
+ />
75
+ )
@@ -0,0 +1,55 @@
1
+ "use client"
2
+
3
+ import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip"
4
+ import type { ComponentProps } from "react"
5
+ import { cn } from "./lib/cn"
6
+ import { motion } from "./lib/motion"
7
+
8
+ export type TooltipProviderProps = ComponentProps<typeof BaseTooltip.Provider>
9
+ export type TooltipProps = ComponentProps<typeof BaseTooltip.Root>
10
+ export type TooltipTriggerProps = ComponentProps<typeof BaseTooltip.Trigger>
11
+ export type TooltipPortalProps = ComponentProps<typeof BaseTooltip.Portal>
12
+ export type TooltipPositionerProps = ComponentProps<
13
+ typeof BaseTooltip.Positioner
14
+ >
15
+ export type TooltipPopupProps = ComponentProps<typeof BaseTooltip.Popup>
16
+
17
+ export const TooltipProvider = (props: TooltipProviderProps) => (
18
+ <BaseTooltip.Provider {...props} />
19
+ )
20
+
21
+ export const Tooltip = (props: TooltipProps) => <BaseTooltip.Root {...props} />
22
+
23
+ export const TooltipTrigger = ({
24
+ className,
25
+ ...props
26
+ }: TooltipTriggerProps) => (
27
+ <BaseTooltip.Trigger className={cn(className)} {...props} />
28
+ )
29
+
30
+ export const TooltipPortal = (props: TooltipPortalProps) => (
31
+ <BaseTooltip.Portal {...props} />
32
+ )
33
+
34
+ export const TooltipPositioner = ({
35
+ sideOffset = 6,
36
+ className,
37
+ ...props
38
+ }: TooltipPositionerProps) => (
39
+ <BaseTooltip.Positioner
40
+ sideOffset={sideOffset}
41
+ className={cn("z-50 outline-none", className)}
42
+ {...props}
43
+ />
44
+ )
45
+
46
+ export const TooltipPopup = ({ className, ...props }: TooltipPopupProps) => (
47
+ <BaseTooltip.Popup
48
+ className={cn(
49
+ "z-50 max-w-xs rounded-md bg-surface-inverted px-2.5 py-1 text-xs text-fg-inverted shadow-md outline-none",
50
+ motion.popupAnchor,
51
+ className,
52
+ )}
53
+ {...props}
54
+ />
55
+ )
@@ -0,0 +1,241 @@
1
+ "use client"
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useRef,
8
+ useState,
9
+ type ComponentProps,
10
+ } from "react"
11
+ import { Button } from "./button"
12
+ import { cn } from "./lib/cn"
13
+
14
+ const formatTime = (seconds: number) => {
15
+ if (!Number.isFinite(seconds) || seconds < 0) return "0:00"
16
+ const mins = Math.floor(seconds / 60)
17
+ const secs = Math.floor(seconds % 60)
18
+ return `${mins}:${secs.toString().padStart(2, "0")}`
19
+ }
20
+
21
+ export type VideoPlayerProps = ComponentProps<"div"> & {
22
+ src: string
23
+ poster?: string
24
+ title?: string
25
+ /** Accessible name for the video. Defaults to title. */
26
+ "aria-label"?: string
27
+ }
28
+
29
+ export const VideoPlayer = ({
30
+ src,
31
+ poster,
32
+ title,
33
+ className,
34
+ "aria-label": ariaLabel,
35
+ ...props
36
+ }: VideoPlayerProps) => {
37
+ const videoRef = useRef<HTMLVideoElement>(null)
38
+ const seekId = useId()
39
+ const [playing, setPlaying] = useState(false)
40
+ const [muted, setMuted] = useState(false)
41
+ const [current, setCurrent] = useState(0)
42
+ const [duration, setDuration] = useState(0)
43
+
44
+ const handleTogglePlay = useCallback(() => {
45
+ const video = videoRef.current
46
+ if (!video) return
47
+ if (video.paused) {
48
+ void video.play()
49
+ } else {
50
+ video.pause()
51
+ }
52
+ }, [])
53
+
54
+ const handleToggleMute = useCallback(() => {
55
+ const video = videoRef.current
56
+ if (!video) return
57
+ video.muted = !video.muted
58
+ setMuted(video.muted)
59
+ }, [])
60
+
61
+ const handleSeek = useCallback((value: number) => {
62
+ const video = videoRef.current
63
+ if (!video) return
64
+ video.currentTime = value
65
+ setCurrent(value)
66
+ }, [])
67
+
68
+ const handleFullscreen = useCallback(() => {
69
+ const video = videoRef.current
70
+ if (!video) return
71
+ if (document.fullscreenElement) {
72
+ void document.exitFullscreen()
73
+ return
74
+ }
75
+ void video.requestFullscreen?.()
76
+ }, [])
77
+
78
+ useEffect(() => {
79
+ const video = videoRef.current
80
+ if (!video) return
81
+
82
+ const handlePlay = () => setPlaying(true)
83
+ const handlePause = () => setPlaying(false)
84
+ const handleTime = () => setCurrent(video.currentTime)
85
+ const handleMeta = () => setDuration(video.duration || 0)
86
+
87
+ video.addEventListener("play", handlePlay)
88
+ video.addEventListener("pause", handlePause)
89
+ video.addEventListener("timeupdate", handleTime)
90
+ video.addEventListener("loadedmetadata", handleMeta)
91
+
92
+ return () => {
93
+ video.removeEventListener("play", handlePlay)
94
+ video.removeEventListener("pause", handlePause)
95
+ video.removeEventListener("timeupdate", handleTime)
96
+ video.removeEventListener("loadedmetadata", handleMeta)
97
+ }
98
+ }, [])
99
+
100
+ const progress = duration > 0 ? (current / duration) * 100 : 0
101
+
102
+ return (
103
+ <div
104
+ data-slot="video-player"
105
+ className={cn(
106
+ "overflow-hidden rounded-xl border border-border-primary bg-surface",
107
+ className,
108
+ )}
109
+ {...props}
110
+ >
111
+ <div className="relative bg-gray-1000">
112
+ <video
113
+ ref={videoRef}
114
+ src={src}
115
+ poster={poster}
116
+ playsInline
117
+ className="aspect-video w-full cursor-pointer object-cover"
118
+ aria-label={ariaLabel ?? title ?? "Video"}
119
+ onClick={handleTogglePlay}
120
+ />
121
+ {!playing ? (
122
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/20">
123
+ <span className="flex size-12 items-center justify-center rounded-full bg-surface/90 text-fg-primary shadow-md">
124
+ <PlayIcon />
125
+ </span>
126
+ </div>
127
+ ) : null}
128
+ </div>
129
+
130
+ <div className="flex flex-col gap-3 border-t border-border-primary bg-surface p-3">
131
+ {title ? (
132
+ <p className="text-sm-strong truncate text-fg-primary">{title}</p>
133
+ ) : null}
134
+
135
+ <div className="flex items-center gap-3">
136
+ <label className="sr-only" htmlFor={seekId}>
137
+ Seek
138
+ </label>
139
+ <input
140
+ id={seekId}
141
+ type="range"
142
+ min={0}
143
+ max={duration || 0}
144
+ step={0.1}
145
+ value={current}
146
+ onChange={(event) => handleSeek(Number(event.target.value))}
147
+ className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-background-quaternary accent-[var(--brand-primary)] [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-brand-primary"
148
+ style={{
149
+ background: `linear-gradient(to right, var(--brand-primary) ${progress}%, var(--background-quaternary) ${progress}%)`,
150
+ }}
151
+ aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
152
+ />
153
+ </div>
154
+
155
+ <div className="flex items-center gap-2">
156
+ <Button
157
+ type="button"
158
+ variant="ghost"
159
+ size="sm"
160
+ iconOnly
161
+ rounded
162
+ aria-label={playing ? "Pause" : "Play"}
163
+ onClick={handleTogglePlay}
164
+ >
165
+ {playing ? <PauseIcon /> : <PlayIcon />}
166
+ </Button>
167
+ <Button
168
+ type="button"
169
+ variant="ghost"
170
+ size="sm"
171
+ iconOnly
172
+ rounded
173
+ aria-label={muted ? "Unmute" : "Mute"}
174
+ onClick={handleToggleMute}
175
+ >
176
+ {muted ? <MuteIcon /> : <VolumeIcon />}
177
+ </Button>
178
+ <span className="text-xs font-mono text-fg-tertiary">
179
+ {formatTime(current)} / {formatTime(duration)}
180
+ </span>
181
+ <div className="ml-auto">
182
+ <Button
183
+ type="button"
184
+ variant="ghost"
185
+ size="sm"
186
+ iconOnly
187
+ rounded
188
+ aria-label="Fullscreen"
189
+ onClick={handleFullscreen}
190
+ >
191
+ <FullscreenIcon />
192
+ </Button>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ )
198
+ }
199
+
200
+ const PlayIcon = () => (
201
+ <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
202
+ <path d="M9 7.5v9l8-4.5-8-4.5Z" fill="currentColor" />
203
+ </svg>
204
+ )
205
+
206
+ const PauseIcon = () => (
207
+ <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
208
+ <path d="M8 7h3v10H8V7Zm5 0h3v10h-3V7Z" fill="currentColor" />
209
+ </svg>
210
+ )
211
+
212
+ const VolumeIcon = () => (
213
+ <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
214
+ <path
215
+ d="M4 10v4h3l4 3V7L7 10H4Zm11.5 2a2.5 2.5 0 0 0-1.5-2.3v4.6A2.5 2.5 0 0 0 15.5 12Zm0-5.5v1.6a4 4 0 0 1 0 7.8v1.6a5.5 5.5 0 0 0 0-11Z"
216
+ fill="currentColor"
217
+ />
218
+ </svg>
219
+ )
220
+
221
+ const MuteIcon = () => (
222
+ <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
223
+ <path
224
+ d="M4 10v4h3l4 3V7L7 10H4Zm11.2-.8 1.4-1.4 1.4 1.4 1.4-1.4 1.4 1.4-1.4 1.4 1.4 1.4-1.4 1.4-1.4-1.4-1.4 1.4-1.4-1.4 1.4-1.4-1.4-1.4Z"
225
+ fill="currentColor"
226
+ />
227
+ </svg>
228
+ )
229
+
230
+ const FullscreenIcon = () => (
231
+ <svg viewBox="0 0 24 24" fill="none" className="size-4" aria-hidden>
232
+ <path
233
+ d="M7 9V7h3v2H7Zm7 0V7h3v2h-3ZM7 17v-2h3v2H7Zm7 0v-2h3v2h-3Z"
234
+ fill="currentColor"
235
+ />
236
+ <path
237
+ d="M5 5h5v2H7v3H5V5Zm9 0h5v5h-2V7h-3V5ZM5 14h2v3h3v2H5v-5Zm12 3h-3v2h5v-5h-2v3Z"
238
+ fill="currentColor"
239
+ />
240
+ </svg>
241
+ )