@boyernick/standard-ui-react 0.1.1-canary.11 → 0.1.1-canary.13
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 +1 -1
- package/src/icons.tsx +10 -0
- package/src/index.ts +5 -0
- package/src/kbd.tsx +4 -1
- package/src/toast.tsx +34 -14
- package/src/tooltip.tsx +27 -10
- package/src/video-player.tsx +140 -105
package/package.json
CHANGED
package/src/icons.tsx
CHANGED
|
@@ -16,15 +16,20 @@ import { IconDotGrid1x3Horizontal as IconDotGrid1x3HorizontalBase } from "@centr
|
|
|
16
16
|
import { IconExclamationCircle as IconExclamationCircleBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconExclamationCircle"
|
|
17
17
|
import { IconExclamationTriangle as IconExclamationTriangleBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconExclamationTriangle"
|
|
18
18
|
import { IconHome as IconHomeBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconHome"
|
|
19
|
+
import { IconFullScreen as IconFullScreenBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconFullScreen"
|
|
19
20
|
import { IconMagnifyingGlass as IconMagnifyingGlassBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMagnifyingGlass"
|
|
20
21
|
import { IconMinusMedium as IconMinusBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMinusMedium"
|
|
22
|
+
import { IconPause as IconPauseBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPause"
|
|
21
23
|
import { IconMoon as IconMoonBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconMoon"
|
|
22
24
|
import { IconPeople as IconPeopleBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPeople"
|
|
25
|
+
import { IconPlay as IconPlayBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPlay"
|
|
23
26
|
import { IconPlusMedium as IconPlusBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconPlusMedium"
|
|
24
27
|
import { IconSettingsGear1 as IconSettingsGear1Base } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSettingsGear1"
|
|
25
28
|
import { IconSquareBehindSquare6 as IconSquareBehindSquare6Base } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSquareBehindSquare6"
|
|
26
29
|
import { IconStar as IconStarBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconStar"
|
|
27
30
|
import { IconSun as IconSunBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconSun"
|
|
31
|
+
import { IconVolumeFull as IconVolumeFullBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconVolumeFull"
|
|
32
|
+
import { IconVolumeOff as IconVolumeOffBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconVolumeOff"
|
|
28
33
|
import { IconX as IconXBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconX"
|
|
29
34
|
import type { ComponentType } from "react"
|
|
30
35
|
|
|
@@ -73,13 +78,16 @@ export const IconExclamationCircle = withCentralIconDefaults(
|
|
|
73
78
|
export const IconExclamationTriangle = withCentralIconDefaults(
|
|
74
79
|
IconExclamationTriangleBase,
|
|
75
80
|
)
|
|
81
|
+
export const IconFullScreen = withCentralIconDefaults(IconFullScreenBase)
|
|
76
82
|
export const IconHome = withCentralIconDefaults(IconHomeBase)
|
|
77
83
|
export const IconMagnifyingGlass = withCentralIconDefaults(
|
|
78
84
|
IconMagnifyingGlassBase,
|
|
79
85
|
)
|
|
80
86
|
export const IconMinus = withCentralIconDefaults(IconMinusBase)
|
|
87
|
+
export const IconPause = withCentralIconDefaults(IconPauseBase)
|
|
81
88
|
export const IconMoon = withCentralIconDefaults(IconMoonBase)
|
|
82
89
|
export const IconPeople = withCentralIconDefaults(IconPeopleBase)
|
|
90
|
+
export const IconPlay = withCentralIconDefaults(IconPlayBase)
|
|
83
91
|
export const IconPlus = withCentralIconDefaults(IconPlusBase)
|
|
84
92
|
export const IconSettingsGear1 = withCentralIconDefaults(IconSettingsGear1Base)
|
|
85
93
|
export const IconSquareBehindSquare6 = withCentralIconDefaults(
|
|
@@ -87,6 +95,8 @@ export const IconSquareBehindSquare6 = withCentralIconDefaults(
|
|
|
87
95
|
)
|
|
88
96
|
export const IconStar = withCentralIconDefaults(IconStarBase)
|
|
89
97
|
export const IconSun = withCentralIconDefaults(IconSunBase)
|
|
98
|
+
export const IconVolumeFull = withCentralIconDefaults(IconVolumeFullBase)
|
|
99
|
+
export const IconVolumeOff = withCentralIconDefaults(IconVolumeOffBase)
|
|
90
100
|
export const IconX = withCentralIconDefaults(IconXBase)
|
|
91
101
|
|
|
92
102
|
export const iconGallery = [
|
package/src/index.ts
CHANGED
|
@@ -817,16 +817,21 @@ export {
|
|
|
817
817
|
IconDotGrid1x3Horizontal,
|
|
818
818
|
IconExclamationCircle,
|
|
819
819
|
IconExclamationTriangle,
|
|
820
|
+
IconFullScreen,
|
|
820
821
|
IconHome,
|
|
821
822
|
IconMagnifyingGlass,
|
|
822
823
|
IconMinus,
|
|
823
824
|
IconMoon,
|
|
825
|
+
IconPause,
|
|
824
826
|
IconPeople,
|
|
827
|
+
IconPlay,
|
|
825
828
|
IconPlus,
|
|
826
829
|
IconSettingsGear1,
|
|
827
830
|
IconSquareBehindSquare6,
|
|
828
831
|
IconStar,
|
|
829
832
|
IconSun,
|
|
833
|
+
IconVolumeFull,
|
|
834
|
+
IconVolumeOff,
|
|
830
835
|
IconX,
|
|
831
836
|
type CentralIconProps,
|
|
832
837
|
type CentralIconComponent,
|
package/src/kbd.tsx
CHANGED
|
@@ -3,10 +3,13 @@ import type { ComponentProps } from "react"
|
|
|
3
3
|
import { cn } from "./lib/cn"
|
|
4
4
|
|
|
5
5
|
const kbdVariants = cva(
|
|
6
|
+
// `text-xs` carries the size, line-height and tracking; the weight is set
|
|
7
|
+
// separately because 600 sits above the 400/500 the type scale defines, so
|
|
8
|
+
// there is no `-strong` utility to reach for.
|
|
6
9
|
// The border is bottom-only on purpose: it reads as the lip of a physical
|
|
7
10
|
// key without boxing the glyph in on all four sides, which at 12px turns a
|
|
8
11
|
// keycap into a cramped badge.
|
|
9
|
-
"inline-flex shrink-0 items-center justify-center border-b px-1 font-sans text-xs whitespace-nowrap select-none",
|
|
12
|
+
"inline-flex shrink-0 items-center justify-center border-b px-1 font-sans text-xs font-semibold whitespace-nowrap select-none",
|
|
10
13
|
{
|
|
11
14
|
variants: {
|
|
12
15
|
variant: {
|
package/src/toast.tsx
CHANGED
|
@@ -28,25 +28,45 @@ export const ToastPortal = (props: ToastPortalProps) => (
|
|
|
28
28
|
|
|
29
29
|
export const ToastViewport = ({ className, ...props }: ToastViewportProps) => (
|
|
30
30
|
<BaseToast.Viewport
|
|
31
|
+
// No flex column: the toasts inside are absolutely positioned so they pile
|
|
32
|
+
// up as a stack. In flow they would march down past the bottom edge.
|
|
31
33
|
className={cn(
|
|
32
|
-
"fixed right-4 bottom-4 z-[100]
|
|
34
|
+
"fixed right-4 bottom-4 z-[100] w-[min(100vw-2rem,24rem)] outline-none",
|
|
33
35
|
className,
|
|
34
36
|
)}
|
|
35
37
|
{...props}
|
|
36
38
|
/>
|
|
37
39
|
)
|
|
38
40
|
|
|
41
|
+
/** Each toast is pinned to the bottom of the viewport and lifted by its own
|
|
42
|
+
* index, so the stack reads as a pile of cards rather than a list: the one
|
|
43
|
+
* behind peeks out by `--peek` and sits a step smaller. Hovering the viewport
|
|
44
|
+
* sets `data-expanded`, which fans them out to their real heights. */
|
|
39
45
|
export const ToastRoot = ({ className, ...props }: ToastRootProps) => (
|
|
40
46
|
<BaseToast.Root
|
|
41
47
|
className={cn(
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
"absolute right-0 bottom-0 left-auto z-[calc(1000-var(--toast-index))] box-border w-full origin-bottom",
|
|
49
|
+
"[--gap:0.625rem] [--peek:0.75rem]",
|
|
50
|
+
"[--scale:calc(max(0,1-(var(--toast-index)*0.05)))] [--shrink:calc(1-var(--scale))]",
|
|
51
|
+
"[--height:var(--toast-frontmost-height,var(--toast-height))]",
|
|
52
|
+
"[--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y,0px))]",
|
|
53
|
+
// No border: `shadow-lg` composes `--elevation-hairline`, the same edge the
|
|
54
|
+
// dropdowns carry — theme-aware and 0.5px on hi-dpi. A border on top of it
|
|
55
|
+
// stacked a second, darker line. The error type tints that edge with a
|
|
56
|
+
// ring, which also sits outside the box model so the height never shifts.
|
|
57
|
+
"rounded-xl bg-surface px-3.5 py-3 shadow-lg outline-none select-none",
|
|
58
|
+
// Collapsed, every toast behind the front one is scaled down and shifted
|
|
59
|
+
// up; `--shrink` cancels the gap that scaling opens at the bottom edge.
|
|
60
|
+
"h-[var(--height)] data-expanded:h-[var(--toast-height)]",
|
|
61
|
+
"[transform:translateX(var(--toast-swipe-movement-x,0px))_translateY(calc(var(--toast-swipe-movement-y,0px)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))]",
|
|
62
|
+
"data-expanded:[transform:translateX(var(--toast-swipe-movement-x,0px))_translateY(var(--offset-y))]",
|
|
63
|
+
// Bridges the gap between cards so crossing it does not collapse the fan.
|
|
64
|
+
"after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
|
|
65
|
+
"transition-[transform,opacity,height] duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
|
|
66
|
+
"data-starting-style:[transform:translateY(150%)]",
|
|
67
|
+
"data-ending-style:opacity-0 data-limited:opacity-0",
|
|
68
|
+
"[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
|
|
69
|
+
"data-[type=error]:ring-1 data-[type=error]:ring-destructive/40",
|
|
50
70
|
className,
|
|
51
71
|
)}
|
|
52
72
|
{...props}
|
|
@@ -56,7 +76,7 @@ export const ToastRoot = ({ className, ...props }: ToastRootProps) => (
|
|
|
56
76
|
export const ToastContent = ({ className, ...props }: ToastContentProps) => (
|
|
57
77
|
<BaseToast.Content
|
|
58
78
|
className={cn(
|
|
59
|
-
"flex flex-col gap-
|
|
79
|
+
"flex flex-col gap-0.5 overflow-hidden transition-opacity duration-[var(--duration-sm)]",
|
|
60
80
|
"data-behind:pointer-events-none data-behind:opacity-0 data-expanded:data-behind:opacity-100",
|
|
61
81
|
className,
|
|
62
82
|
)}
|
|
@@ -66,7 +86,7 @@ export const ToastContent = ({ className, ...props }: ToastContentProps) => (
|
|
|
66
86
|
|
|
67
87
|
export const ToastTitle = ({ className, ...props }: ToastTitleProps) => (
|
|
68
88
|
<BaseToast.Title
|
|
69
|
-
className={cn("text-
|
|
89
|
+
className={cn("text-xs-strong pr-10 text-fg-primary", className)}
|
|
70
90
|
{...props}
|
|
71
91
|
/>
|
|
72
92
|
)
|
|
@@ -76,7 +96,7 @@ export const ToastDescription = ({
|
|
|
76
96
|
...props
|
|
77
97
|
}: ToastDescriptionProps) => (
|
|
78
98
|
<BaseToast.Description
|
|
79
|
-
className={cn("text-
|
|
99
|
+
className={cn("text-xs text-fg-secondary", className)}
|
|
80
100
|
{...props}
|
|
81
101
|
/>
|
|
82
102
|
)
|
|
@@ -100,7 +120,7 @@ export const ToastClose = ({
|
|
|
100
120
|
}: ToastCloseProps) => (
|
|
101
121
|
<BaseToast.Close
|
|
102
122
|
className={cn(
|
|
103
|
-
"absolute top-
|
|
123
|
+
"absolute top-2.5 right-2.5 inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-fg-tertiary outline-none",
|
|
104
124
|
motion.colors,
|
|
105
125
|
"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
126
|
className,
|
|
@@ -108,7 +128,7 @@ export const ToastClose = ({
|
|
|
108
128
|
{...props}
|
|
109
129
|
>
|
|
110
130
|
{children ?? (
|
|
111
|
-
<IconCrossSmall size={
|
|
131
|
+
<IconCrossSmall size={18} className="size-4.5" aria-hidden />
|
|
112
132
|
)}
|
|
113
133
|
</BaseToast.Close>
|
|
114
134
|
)
|
package/src/tooltip.tsx
CHANGED
|
@@ -45,22 +45,35 @@ export const TooltipPositioner = ({
|
|
|
45
45
|
/>
|
|
46
46
|
)
|
|
47
47
|
|
|
48
|
-
// `shadow-md` already composes
|
|
49
|
-
//
|
|
48
|
+
// Neither variant carries a border — `shadow-md` already composes
|
|
49
|
+
// `--elevation-hairline`, so one would stack a second edge. That hairline is
|
|
50
|
+
// what gives the default popup its edge against a same-coloured page.
|
|
50
51
|
const tooltipPopupVariants = cva(
|
|
51
|
-
|
|
52
|
+
cn(
|
|
53
|
+
"z-50 max-w-xs rounded-md px-2.5 py-1 text-xs shadow-md outline-none",
|
|
54
|
+
// A trailing keycap already carries its own inset, so the full 10px beside
|
|
55
|
+
// it reads as a gap rather than padding — but sitting flush on the vertical
|
|
56
|
+
// inset crowds the corner, so it gets a touch more than that.
|
|
57
|
+
//
|
|
58
|
+
// Laying the row out as flex is what centres it. On a text line the keycap
|
|
59
|
+
// is an inline-flex aligned `middle`, which sets its centre against the
|
|
60
|
+
// baseline plus half an x-height rather than the line's centre — at 16px it
|
|
61
|
+
// hung 1.18px low, leaving 6.36px above and 4px below. Scoped to `has-[kbd]`
|
|
62
|
+
// because making every popup a flex container would turn a multi-child
|
|
63
|
+
// tooltip from stacked blocks into a row.
|
|
64
|
+
"has-[kbd]:flex has-[kbd]:items-center has-[kbd]:pr-1.5",
|
|
65
|
+
),
|
|
52
66
|
{
|
|
53
67
|
variants: {
|
|
54
68
|
variant: {
|
|
55
|
-
//
|
|
56
|
-
|
|
69
|
+
// Sits on the page's own surface: white in light mode, dark in dark.
|
|
70
|
+
default: "bg-surface text-fg-primary",
|
|
71
|
+
// Flipped against the page — dark in light mode, light in dark — for
|
|
72
|
+
// a hint that should read as separate from what it floats over.
|
|
57
73
|
inverted: "bg-surface-inverted text-fg-inverted",
|
|
58
|
-
// For tooltips over dark or busy surfaces, where an inverted popup
|
|
59
|
-
// would sink into its background instead of lifting off it.
|
|
60
|
-
light: "bg-surface text-fg-primary",
|
|
61
74
|
},
|
|
62
75
|
},
|
|
63
|
-
defaultVariants: { variant: "
|
|
76
|
+
defaultVariants: { variant: "default" },
|
|
64
77
|
},
|
|
65
78
|
)
|
|
66
79
|
|
|
@@ -70,7 +83,11 @@ export const TooltipPopup = ({
|
|
|
70
83
|
...props
|
|
71
84
|
}: TooltipPopupProps) => (
|
|
72
85
|
<BaseTooltip.Popup
|
|
73
|
-
className={cn(
|
|
86
|
+
className={cn(
|
|
87
|
+
tooltipPopupVariants({ variant }),
|
|
88
|
+
motion.popupAnchor,
|
|
89
|
+
className,
|
|
90
|
+
)}
|
|
74
91
|
{...props}
|
|
75
92
|
/>
|
|
76
93
|
)
|
package/src/video-player.tsx
CHANGED
|
@@ -8,7 +8,13 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
type ComponentProps,
|
|
10
10
|
} from "react"
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
IconFullScreen,
|
|
13
|
+
IconPause,
|
|
14
|
+
IconPlay,
|
|
15
|
+
IconVolumeFull,
|
|
16
|
+
IconVolumeOff,
|
|
17
|
+
} from "./icons"
|
|
12
18
|
import { cn } from "./lib/cn"
|
|
13
19
|
|
|
14
20
|
const formatTime = (seconds: number) => {
|
|
@@ -18,6 +24,13 @@ const formatTime = (seconds: number) => {
|
|
|
18
24
|
return `${mins}:${secs.toString().padStart(2, "0")}`
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
// The media surface is black in both themes, so everything sitting on it is
|
|
28
|
+
// light regardless of the theme — semantic foreground tokens would invert and
|
|
29
|
+
// disappear against the video. This is the same reasoning as the dialog
|
|
30
|
+
// backdrops, and the opposite of a keycap, which sits on a themed surface.
|
|
31
|
+
const overlayControl =
|
|
32
|
+
"inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-white outline-none transition-colors duration-[var(--duration-sm)] ease-enter hover:bg-white/15 focus-visible:ring-2 focus-visible:ring-white/70 motion-reduce:transition-none"
|
|
33
|
+
|
|
21
34
|
export type VideoPlayerProps = ComponentProps<"div"> & {
|
|
22
35
|
src: string
|
|
23
36
|
poster?: string
|
|
@@ -40,6 +53,7 @@ export const VideoPlayer = ({
|
|
|
40
53
|
const [muted, setMuted] = useState(false)
|
|
41
54
|
const [current, setCurrent] = useState(0)
|
|
42
55
|
const [duration, setDuration] = useState(0)
|
|
56
|
+
const [buffered, setBuffered] = useState(0)
|
|
43
57
|
|
|
44
58
|
const handleTogglePlay = useCallback(() => {
|
|
45
59
|
const video = videoRef.current
|
|
@@ -79,163 +93,184 @@ export const VideoPlayer = ({
|
|
|
79
93
|
const video = videoRef.current
|
|
80
94
|
if (!video) return
|
|
81
95
|
|
|
96
|
+
const readBuffered = () => {
|
|
97
|
+
if (video.buffered.length === 0) return
|
|
98
|
+
setBuffered(video.buffered.end(video.buffered.length - 1))
|
|
99
|
+
}
|
|
82
100
|
const handlePlay = () => setPlaying(true)
|
|
83
101
|
const handlePause = () => setPlaying(false)
|
|
84
102
|
const handleTime = () => setCurrent(video.currentTime)
|
|
85
103
|
const handleMeta = () => setDuration(video.duration || 0)
|
|
104
|
+
const handleVolume = () => setMuted(video.muted)
|
|
105
|
+
|
|
106
|
+
// A cached video can be ready before this effect runs, in which case
|
|
107
|
+
// `loadedmetadata` has already fired and the readout would sit at 0:00
|
|
108
|
+
// forever. Seed from the element, then keep up with the events.
|
|
109
|
+
handleMeta()
|
|
110
|
+
handleTime()
|
|
111
|
+
handleVolume()
|
|
112
|
+
readBuffered()
|
|
113
|
+
setPlaying(!video.paused)
|
|
86
114
|
|
|
87
115
|
video.addEventListener("play", handlePlay)
|
|
88
116
|
video.addEventListener("pause", handlePause)
|
|
89
117
|
video.addEventListener("timeupdate", handleTime)
|
|
90
118
|
video.addEventListener("loadedmetadata", handleMeta)
|
|
119
|
+
video.addEventListener("durationchange", handleMeta)
|
|
120
|
+
video.addEventListener("volumechange", handleVolume)
|
|
121
|
+
video.addEventListener("progress", readBuffered)
|
|
91
122
|
|
|
92
123
|
return () => {
|
|
93
124
|
video.removeEventListener("play", handlePlay)
|
|
94
125
|
video.removeEventListener("pause", handlePause)
|
|
95
126
|
video.removeEventListener("timeupdate", handleTime)
|
|
96
127
|
video.removeEventListener("loadedmetadata", handleMeta)
|
|
128
|
+
video.removeEventListener("durationchange", handleMeta)
|
|
129
|
+
video.removeEventListener("volumechange", handleVolume)
|
|
130
|
+
video.removeEventListener("progress", readBuffered)
|
|
97
131
|
}
|
|
98
132
|
}, [])
|
|
99
133
|
|
|
100
|
-
const
|
|
134
|
+
const played = duration > 0 ? (current / duration) * 100 : 0
|
|
135
|
+
const loaded = duration > 0 ? (buffered / duration) * 100 : 0
|
|
101
136
|
|
|
102
137
|
return (
|
|
103
138
|
<div
|
|
104
139
|
data-slot="video-player"
|
|
140
|
+
data-playing={playing || undefined}
|
|
141
|
+
// `group` drives the chrome: it hides while playing and comes back on
|
|
142
|
+
// hover or when anything inside takes focus.
|
|
105
143
|
className={cn(
|
|
106
|
-
"overflow-hidden rounded-xl
|
|
144
|
+
"group relative isolate overflow-hidden rounded-xl bg-black shadow-sm",
|
|
107
145
|
className,
|
|
108
146
|
)}
|
|
109
147
|
{...props}
|
|
110
148
|
>
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
149
|
+
<video
|
|
150
|
+
ref={videoRef}
|
|
151
|
+
src={src}
|
|
152
|
+
poster={poster}
|
|
153
|
+
playsInline
|
|
154
|
+
className="aspect-video w-full cursor-pointer object-cover"
|
|
155
|
+
aria-label={ariaLabel ?? title ?? "Video"}
|
|
156
|
+
onClick={handleTogglePlay}
|
|
157
|
+
/>
|
|
158
|
+
|
|
159
|
+
{/* Centred affordance while paused. It is the video's own click target
|
|
160
|
+
underneath, so the badge itself stays inert. */}
|
|
161
|
+
<div
|
|
162
|
+
className={cn(
|
|
163
|
+
"pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
|
|
164
|
+
playing ? "opacity-0" : "opacity-100",
|
|
165
|
+
)}
|
|
166
|
+
>
|
|
167
|
+
<span className="flex size-14 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm">
|
|
168
|
+
<IconPlay size={24} className="ml-0.5 size-6" aria-hidden />
|
|
169
|
+
</span>
|
|
128
170
|
</div>
|
|
129
171
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
172
|
+
{title ? (
|
|
173
|
+
<div
|
|
174
|
+
className={cn(
|
|
175
|
+
"pointer-events-none absolute inset-x-0 top-0 bg-gradient-to-b from-black/60 to-transparent px-4 pt-3 pb-8 transition-opacity duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
|
|
176
|
+
"group-hover:opacity-100 group-focus-within:opacity-100",
|
|
177
|
+
// Nothing hovers on a touch screen, so the chrome would never come
|
|
178
|
+
// back once playback started. Keep it up there instead.
|
|
179
|
+
"[@media(hover:none)]:opacity-100",
|
|
180
|
+
playing ? "opacity-0" : "opacity-100",
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
<p className="text-sm-strong truncate text-white">{title}</p>
|
|
184
|
+
</div>
|
|
185
|
+
) : null}
|
|
134
186
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
187
|
+
<div
|
|
188
|
+
className={cn(
|
|
189
|
+
"absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-gradient-to-t from-black/75 via-black/45 to-transparent px-3 pt-10 pb-2 transition-opacity duration-[var(--duration-md)] ease-enter motion-reduce:transition-none",
|
|
190
|
+
"group-hover:opacity-100 group-focus-within:opacity-100",
|
|
191
|
+
// Nothing hovers on a touch screen, so the chrome would never come
|
|
192
|
+
// back once playback started. Keep it up there instead.
|
|
193
|
+
"[@media(hover:none)]:opacity-100",
|
|
194
|
+
playing ? "opacity-0" : "opacity-100",
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
197
|
+
<label className="sr-only" htmlFor={seekId}>
|
|
198
|
+
Seek
|
|
199
|
+
</label>
|
|
200
|
+
<div className="relative flex h-4 items-center">
|
|
201
|
+
{/* Track, buffered fill and played fill sit under the input, which
|
|
202
|
+
stays transparent so its thumb is the only thing it paints. */}
|
|
203
|
+
<span
|
|
204
|
+
aria-hidden
|
|
205
|
+
className="pointer-events-none absolute inset-x-0 h-1 rounded-full bg-white/25"
|
|
206
|
+
/>
|
|
207
|
+
<span
|
|
208
|
+
aria-hidden
|
|
209
|
+
className="pointer-events-none absolute left-0 h-1 rounded-full bg-white/35"
|
|
210
|
+
style={{ width: `${loaded}%` }}
|
|
211
|
+
/>
|
|
212
|
+
<span
|
|
213
|
+
aria-hidden
|
|
214
|
+
className="pointer-events-none absolute left-0 h-1 rounded-full bg-white"
|
|
215
|
+
style={{ width: `${played}%` }}
|
|
216
|
+
/>
|
|
139
217
|
<input
|
|
140
218
|
id={seekId}
|
|
141
219
|
type="range"
|
|
142
220
|
min={0}
|
|
143
221
|
max={duration || 0}
|
|
144
|
-
step={0.
|
|
222
|
+
step={0.01}
|
|
145
223
|
value={current}
|
|
146
224
|
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
225
|
aria-valuetext={`${formatTime(current)} of ${formatTime(duration)}`}
|
|
226
|
+
className={cn(
|
|
227
|
+
"relative m-0 h-4 w-full cursor-pointer appearance-none bg-transparent outline-none",
|
|
228
|
+
// Both vendor thumbs, or Firefox falls back to a default one.
|
|
229
|
+
"[&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-sm",
|
|
230
|
+
"[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white",
|
|
231
|
+
"focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-white/70",
|
|
232
|
+
)}
|
|
152
233
|
/>
|
|
153
234
|
</div>
|
|
154
235
|
|
|
155
|
-
<div className="flex items-center gap-
|
|
156
|
-
<
|
|
236
|
+
<div className="flex items-center gap-1">
|
|
237
|
+
<button
|
|
157
238
|
type="button"
|
|
158
|
-
|
|
159
|
-
size="sm"
|
|
160
|
-
iconOnly
|
|
161
|
-
rounded
|
|
239
|
+
className={overlayControl}
|
|
162
240
|
aria-label={playing ? "Pause" : "Play"}
|
|
163
241
|
onClick={handleTogglePlay}
|
|
164
242
|
>
|
|
165
|
-
{playing ?
|
|
166
|
-
|
|
167
|
-
|
|
243
|
+
{playing ? (
|
|
244
|
+
<IconPause size={18} className="size-4.5" aria-hidden />
|
|
245
|
+
) : (
|
|
246
|
+
<IconPlay size={18} className="size-4.5" aria-hidden />
|
|
247
|
+
)}
|
|
248
|
+
</button>
|
|
249
|
+
<button
|
|
168
250
|
type="button"
|
|
169
|
-
|
|
170
|
-
size="sm"
|
|
171
|
-
iconOnly
|
|
172
|
-
rounded
|
|
251
|
+
className={overlayControl}
|
|
173
252
|
aria-label={muted ? "Unmute" : "Mute"}
|
|
174
253
|
onClick={handleToggleMute}
|
|
175
254
|
>
|
|
176
|
-
{muted ?
|
|
177
|
-
|
|
178
|
-
|
|
255
|
+
{muted ? (
|
|
256
|
+
<IconVolumeOff size={18} className="size-4.5" aria-hidden />
|
|
257
|
+
) : (
|
|
258
|
+
<IconVolumeFull size={18} className="size-4.5" aria-hidden />
|
|
259
|
+
)}
|
|
260
|
+
</button>
|
|
261
|
+
<span className="text-xs ml-1 font-mono text-white/80 tabular-nums">
|
|
179
262
|
{formatTime(current)} / {formatTime(duration)}
|
|
180
263
|
</span>
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
onClick={handleFullscreen}
|
|
190
|
-
>
|
|
191
|
-
<FullscreenIcon />
|
|
192
|
-
</Button>
|
|
193
|
-
</div>
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
className={cn(overlayControl, "ml-auto")}
|
|
267
|
+
aria-label="Fullscreen"
|
|
268
|
+
onClick={handleFullscreen}
|
|
269
|
+
>
|
|
270
|
+
<IconFullScreen size={18} className="size-4.5" aria-hidden />
|
|
271
|
+
</button>
|
|
194
272
|
</div>
|
|
195
273
|
</div>
|
|
196
274
|
</div>
|
|
197
275
|
)
|
|
198
276
|
}
|
|
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
|
-
)
|