@nexxtmove/ui 1.53.0 → 2.0.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.
@@ -1,316 +1,350 @@
1
- <script lang="ts" setup>
2
- import { useEventListener, useFullscreen, useMediaControls } from '@vueuse/core'
3
- import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
4
- import NexxtIcon from '../../atoms/Icon/Icon.vue'
1
+ <script lang="ts">
2
+ import type { NexxtVideoControlsMessages } from '../VideoControls/VideoControls.vue'
3
+
4
+ /**
5
+ * `full`: the NexxtVideoControls bar; `toggle`: only its round play/pause button;
6
+ * `none`: nothing visible, and a click on the video is left to the parent.
7
+ */
8
+ export type NexxtVideoPlayerControls = 'full' | 'toggle' | 'none'
9
+
10
+ /**
11
+ * Overridable copy: the player's own keys plus every key of NexxtVideoControlsMessages, which
12
+ * are passed on to the controls. Every key is optional; the Dutch defaults are merged per key.
13
+ */
14
+ export interface NexxtVideoPlayerMessages extends NexxtVideoControlsMessages {
15
+ /** Shown when the video cannot be loaded. Defaults to `'De video kan niet worden geladen'`. */
16
+ error?: string
17
+ /** Accessible name of the video when no `name` is given. Defaults to `'Video'`. */
18
+ video?: string
19
+ }
5
20
 
6
- const props = defineProps<{
21
+ export interface NexxtVideoPlayerProps {
22
+ /** URL of the video. */
7
23
  src: string
24
+ /**
25
+ * Image shown before the video plays. With a poster nothing is preloaded (`preload="none"`);
26
+ * without one only the metadata is, so the first frame and the duration are there.
27
+ */
28
+ poster?: string
29
+ /** The video's name: its accessible name and the name of the controls. */
30
+ name?: string
31
+ /** Start playing on mount. Browsers only allow that without sound, so pair it with `muted`. */
8
32
  autoplay?: boolean
33
+ /** Start without sound. Applied before an autoplay starts. */
9
34
  muted?: boolean
10
- showControls?: boolean
35
+ /** Start again at the end. */
11
36
  loop?: boolean
12
- labels?: {
13
- error?: string
14
- play?: string
15
- pause?: string
16
- mute?: string
17
- unmute?: string
18
- enterFullscreen?: string
19
- exitFullscreen?: string
20
- seek?: string
21
- video?: string
22
- }
23
- }>()
24
-
25
- const labels = computed(() => ({
26
- error: 'Video could not be loaded',
27
- play: 'Play',
28
- pause: 'Pause',
29
- mute: 'Mute',
30
- unmute: 'Unmute',
31
- enterFullscreen: 'Enter fullscreen',
32
- exitFullscreen: 'Exit fullscreen',
33
- seek: 'Seek',
34
- video: 'Video player',
35
- ...props.labels,
36
- }))
37
+ /** Which controls to show. Defaults to `full`. */
38
+ controls?: NexxtVideoPlayerControls
39
+ /** Copy of the player and its controls. Defaults are Dutch and are merged per key. */
40
+ messages?: NexxtVideoPlayerMessages
41
+ }
42
+ </script>
37
43
 
38
- const videoRef = useTemplateRef('videoRef')
44
+ <script setup lang="ts">
45
+ import { useEventListener } from '@vueuse/core'
46
+ import { computed, nextTick, onMounted, ref, useAttrs, useTemplateRef, watch } from 'vue'
47
+ import NexxtIcon from '../../atoms/Icon/Icon.vue'
48
+ import NexxtVideoControls from '../VideoControls/VideoControls.vue'
49
+
50
+ // The attrs go to the root by hand, so the removed props below never land on it as HTML
51
+ defineOptions({ name: 'NexxtVideoPlayer', inheritAttrs: false })
52
+
53
+ const {
54
+ src,
55
+ poster,
56
+ name = '',
57
+ autoplay = false,
58
+ muted: startMuted = false,
59
+ loop = false,
60
+ controls = 'full',
61
+ messages,
62
+ } = defineProps<NexxtVideoPlayerProps>()
63
+
64
+ const DEFAULT_MESSAGES = {
65
+ error: 'De video kan niet worden geladen',
66
+ video: 'Video',
67
+ }
39
68
 
40
- const { playing, buffered, currentTime, duration, muted, seeking } = useMediaControls(videoRef, {
41
- src: computed(() => props.src),
42
- })
43
- const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(videoRef)
69
+ /** Props removed in the major that introduced `controls`, with what replaces them. */
70
+ const REMOVED_PROPS: Record<string, string> = {
71
+ showControls:
72
+ '`showControls` is removed: use `controls="none"` (was false) or `controls="full"` (was true).',
73
+ 'show-controls':
74
+ '`showControls` is removed: use `controls="none"` (was false) or `controls="full"` (was true).',
75
+ labels: '`labels` is removed: use `messages` (Dutch defaults, merged per key).',
76
+ }
44
77
 
45
- // Overlay visibility: show when paused OR when mouse is over the player
46
- const isHovering = ref(false)
47
- const overlayVisible = computed(() => !playing.value || isHovering.value)
78
+ const attrs = useAttrs()
48
79
 
49
- // Error state — reset whenever src changes
50
- const hasError = ref(false)
51
- watch(
52
- () => props.src,
53
- () => {
54
- hasError.value = false
55
- },
80
+ const rootAttrs = computed(() =>
81
+ Object.fromEntries(Object.entries(attrs).filter(([key]) => !Object.hasOwn(REMOVED_PROPS, key))),
56
82
  )
57
83
 
58
- // Center play/pause flash icon (like YouTube)
59
- const flashIcon = ref<'play' | 'pause' | null>(null)
60
- let flashTimer: ReturnType<typeof setTimeout> | null = null
61
-
62
- const triggerFlash = (icon: 'play' | 'pause') => {
63
- if (flashTimer) clearTimeout(flashTimer)
64
- flashIcon.value = icon
65
- flashTimer = setTimeout(() => {
66
- flashIcon.value = null
67
- }, 600)
84
+ // An old prop would otherwise fall through silently and bring the full bar back (the media
85
+ // library passed `show-controls="false"`). `process.env.NODE_ENV` stays in the library build,
86
+ // so the consumer's bundler decides whether this runs (as in NexxtTabs)
87
+ if (process.env.NODE_ENV !== 'production') {
88
+ const removed = [
89
+ ...new Set(
90
+ Object.keys(attrs)
91
+ .filter((key) => Object.hasOwn(REMOVED_PROPS, key))
92
+ .map((key) => REMOVED_PROPS[key]),
93
+ ),
94
+ ]
95
+ if (removed.length) console.warn(`[NexxtVideoPlayer] ${removed.join(' ')}`)
68
96
  }
69
97
 
70
- const endBuffer = computed(() =>
71
- buffered.value.length > 0 ? (buffered.value[buffered.value.length - 1]?.[1] ?? 0) : 0,
72
- )
73
-
74
- const bufferedPercent = computed(() =>
75
- duration.value ? (endBuffer.value / duration.value) * 100 : 0,
76
- )
98
+ const root = useTemplateRef<HTMLElement>('root')
99
+ const videoEl = useTemplateRef<HTMLVideoElement>('videoEl')
100
+ const controlsRef = useTemplateRef<InstanceType<typeof NexxtVideoControls>>('controlsRef')
101
+ const startButton = useTemplateRef<HTMLButtonElement>('startButton')
77
102
 
78
- const formatTime = (seconds: number): string => {
79
- const h = Math.floor(seconds / 3600)
80
- const m = Math.floor((seconds % 3600) / 60)
81
- const s = Math.floor(seconds % 60)
82
- if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
83
- return `${m}:${String(s).padStart(2, '0')}`
84
- }
103
+ const copy = computed(() => ({
104
+ error: messages?.error ?? DEFAULT_MESSAGES.error,
105
+ video: messages?.video ?? DEFAULT_MESSAGES.video,
106
+ }))
85
107
 
86
- const togglePlay = () => {
87
- playing.value = !playing.value
88
- triggerFlash(playing.value ? 'play' : 'pause')
89
- }
90
- const toggleMute = () => {
91
- muted.value = !muted.value
92
- }
108
+ const videoLabel = computed(() => name || copy.value.video)
93
109
 
94
- // Scrubber — dragRatio drives UI while dragging AND while the browser seek is in flight.
95
- // We clear it only when the browser confirms the seek is done (VueUse's `seeking` goes false).
96
- const scrubberRef = useTemplateRef('scrubberRef')
97
- const dragRatio = ref<number | null>(null)
98
- let pendingSeek = false
99
-
100
- const progressPct = computed(() =>
101
- dragRatio.value !== null
102
- ? dragRatio.value * 100
103
- : duration.value
104
- ? (currentTime.value / duration.value) * 100
105
- : 0,
106
- )
110
+ // With a poster there is nothing to show from the file until it plays; without one the
111
+ // metadata brings the first frame and the duration
112
+ const preload = computed(() => (poster ? 'none' : 'metadata'))
107
113
 
108
- const displayTime = computed(() =>
109
- dragRatio.value !== null ? dragRatio.value * (duration.value || 0) : currentTime.value,
114
+ const hasError = ref(false)
115
+ const isPlaying = ref(false)
116
+ const isMuted = ref(startMuted)
117
+ const controlsVisible = ref(true)
118
+ /** The video has played at least once; the big play button is gone for good then. */
119
+ const hasStarted = ref(false)
120
+
121
+ /**
122
+ * Before the first play, with controls but no autoplay: a big play button over the poster. The
123
+ * controls wait behind it, hidden and inert, so it is the one tab stop. Never with `none`: the
124
+ * media library plays those on hover.
125
+ */
126
+ const showStart = computed(
127
+ () => controls !== 'none' && !autoplay && !hasStarted.value && !hasError.value,
110
128
  )
111
129
 
112
- // Release dragRatio only once the browser has finished seeking
113
- watch(seeking, (isSeeking) => {
114
- if (!isSeeking && pendingSeek) {
115
- pendingSeek = false
116
- dragRatio.value = null
117
- }
130
+ const startLabel = computed(() => {
131
+ const label = messages?.play ?? 'Afspelen'
132
+ return name ? `${label}: ${name}` : label
118
133
  })
119
134
 
120
- const getRatio = (e: MouseEvent | TouchEvent): number => {
121
- if (!scrubberRef.value) return 0
122
- const rect = scrubberRef.value.getBoundingClientRect()
123
- const clientX =
124
- 'touches' in e ? ((e as TouchEvent).touches[0]?.clientX ?? 0) : (e as MouseEvent).clientX
125
- return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
135
+ /**
136
+ * The controls unmount with the error without saying they are visible again, so the bar's state
137
+ * is reset here: otherwise a bar hidden at that moment leaves the cursor hidden over the message.
138
+ */
139
+ const onError = () => {
140
+ hasError.value = true
141
+ controlsVisible.value = true
126
142
  }
127
143
 
128
- const startSeeking = (e: MouseEvent | TouchEvent) => {
129
- dragRatio.value = getRatio(e)
130
- }
144
+ // The cursor goes with the bar while the video plays, and comes back on the first movement
145
+ const rootClasses = computed(() => ({
146
+ 'cursor-none': controls !== 'none' && !controlsVisible.value,
147
+ }))
131
148
 
132
- const seekBySeconds = (secs: number) => {
133
- if (!duration.value) return
134
- const next = Math.max(0, Math.min(duration.value, currentTime.value + secs))
135
- pendingSeek = true
136
- currentTime.value = next
137
- }
149
+ /** Play, and swallow a refusal (autoplay with sound, an interrupted load): the events tell the rest. */
150
+ const play = () => {
151
+ const video = videoEl.value
152
+ if (!video) return
138
153
 
139
- const onScrubberKeydown = (e: KeyboardEvent) => {
140
- if (e.key === 'ArrowLeft') {
141
- e.preventDefault()
142
- seekBySeconds(-5)
143
- } else if (e.key === 'ArrowRight') {
144
- e.preventDefault()
145
- seekBySeconds(5)
146
- } else if (e.key === 'Home') {
147
- e.preventDefault()
148
- seekBySeconds(-Infinity)
149
- } else if (e.key === 'End') {
150
- e.preventDefault()
151
- seekBySeconds(Infinity)
152
- }
154
+ isPlaying.value = true
155
+ // `hasStarted` waits for the play event: a refused play must leave the big button standing
156
+ video.play().catch(() => {
157
+ isPlaying.value = !video.paused
158
+ })
153
159
  }
154
160
 
155
- const commitSeek = () => {
156
- if (dragRatio.value !== null && duration.value) {
157
- pendingSeek = true
158
- currentTime.value = dragRatio.value * duration.value
159
- // dragRatio cleared by the seeking watcher once the browser fires `seeked`
160
- } else {
161
- dragRatio.value = null
162
- }
163
- }
161
+ /**
162
+ * Whether the video plays. Writable: `true` plays, `false` pauses. Reads the intent at once,
163
+ * then follows the video's own events.
164
+ */
165
+ const playing = computed({
166
+ get: () => isPlaying.value,
167
+ set: (value: boolean) => {
168
+ if (value) {
169
+ play()
170
+ return
171
+ }
172
+ // Reads false at once, as `true` reads true at once; the pause event confirms it
173
+ isPlaying.value = false
174
+ videoEl.value?.pause()
175
+ },
176
+ })
164
177
 
165
- useEventListener(document, 'mousemove', (e: MouseEvent) => {
166
- if (dragRatio.value !== null) dragRatio.value = getRatio(e)
178
+ /** Whether the sound is off. Writable. */
179
+ const mutedState = computed({
180
+ get: () => isMuted.value,
181
+ set: (value: boolean) => {
182
+ isMuted.value = value
183
+ if (videoEl.value) videoEl.value.muted = value
184
+ },
167
185
  })
168
- useEventListener(document, 'mouseup', () => {
169
- if (dragRatio.value !== null) commitSeek()
186
+
187
+ useEventListener(videoEl, 'play', () => {
188
+ isPlaying.value = true
189
+ hasStarted.value = true
170
190
  })
171
- useEventListener(document, 'touchend', () => {
172
- if (dragRatio.value !== null) commitSeek()
191
+ useEventListener(videoEl, ['pause', 'ended', 'emptied'], () => {
192
+ isPlaying.value = Boolean(videoEl.value && !videoEl.value.paused)
173
193
  })
194
+ useEventListener(videoEl, 'volumechange', () => {
195
+ isMuted.value = videoEl.value?.muted ?? isMuted.value
196
+ })
197
+
198
+ watch(
199
+ () => startMuted,
200
+ (value) => {
201
+ mutedState.value = value
202
+ },
203
+ )
204
+
205
+ /** Muted first, then play: a browser only allows an autoplay without sound. */
206
+ const start = () => {
207
+ if (videoEl.value) videoEl.value.muted = isMuted.value
208
+ if (autoplay) play()
209
+ }
174
210
 
175
- onMounted(() => {
176
- if (props.autoplay) playing.value = true
177
- if (props.muted) muted.value = true
211
+ watch(
212
+ () => src,
213
+ async () => {
214
+ hasError.value = false
215
+ isPlaying.value = false
216
+ // A new video starts from its own begin state
217
+ hasStarted.value = false
218
+ await nextTick()
219
+ start()
220
+ },
221
+ )
222
+
223
+ onMounted(start)
224
+
225
+ /** How the big play button was used, if it was: `true` keyboard, `false` pointer. */
226
+ let startedByKeyboard: boolean | undefined
227
+
228
+ /**
229
+ * The big play button: play. From the keyboard (a click without a pointer, `detail` 0) the
230
+ * focus that follows is keyboard focus and the bar stays; after a mouse click it is not, so the
231
+ * bar hides as usual.
232
+ */
233
+ const onStart = (event: MouseEvent) => {
234
+ startedByKeyboard = event.detail === 0
235
+ play()
236
+ }
237
+
238
+ /**
239
+ * When the video starts while the big button has the focus (by the button, by the parent via
240
+ * `playing`, by a click on the video), the focus goes to the controls' play button rather than
241
+ * to the body when the button leaves.
242
+ */
243
+ watch(hasStarted, async (started) => {
244
+ const button = startButton.value
245
+ const byKeyboard = startedByKeyboard
246
+ startedByKeyboard = undefined
247
+ if (!started || !button || document.activeElement !== button) return
248
+
249
+ const focusVisible = byKeyboard ?? button.matches(':focus-visible')
250
+ // The controls stop being inert with this render; only then can they take the focus
251
+ await nextTick()
252
+ controlsRef.value?.focus({ focusVisible })
178
253
  })
179
254
 
180
- defineExpose({ playing, muted })
255
+ defineExpose({ playing, muted: mutedState })
181
256
  </script>
182
257
 
183
258
  <template>
184
259
  <div
185
- class="group/player relative isolate overflow-hidden rounded-lg"
186
- @mouseenter="isHovering = true"
187
- @mouseleave="isHovering = false"
260
+ v-bind="rootAttrs"
261
+ ref="root"
262
+ data-video-player
263
+ class="relative isolate overflow-hidden"
264
+ :class="rootClasses"
188
265
  >
189
266
  <video
190
- ref="videoRef"
267
+ ref="videoEl"
191
268
  :src="src"
269
+ :poster="poster"
270
+ :preload="preload"
192
271
  :loop="loop"
193
- class="w-full"
272
+ playsinline
273
+ class="block w-full"
194
274
  :class="{ invisible: hasError }"
195
- :aria-label="labels.video"
196
- @error="hasError = true"
197
- @click="showControls !== false ? togglePlay() : undefined"
275
+ :aria-label="videoLabel"
276
+ @error="onError"
198
277
  />
199
278
 
200
279
  <div
201
280
  v-if="hasError"
202
281
  role="alert"
203
- class="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-lg bg-gray-900 text-white"
282
+ class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-gray-900 px-4 text-center text-white"
204
283
  >
205
284
  <NexxtIcon name="circle-exclamation" class="text-3xl text-red-400" aria-hidden="true" />
206
- <p class="heading-4">{{ labels.error }}</p>
207
- <p class="max-w-xs truncate extra-small-normal text-white/50">{{ src }}</p>
285
+ <p class="heading-4">{{ copy.error }}</p>
208
286
  </div>
209
287
 
288
+ <NexxtVideoControls
289
+ v-else-if="controls !== 'none'"
290
+ ref="controlsRef"
291
+ :inert="showStart || undefined"
292
+ :class="{ invisible: showStart }"
293
+ :target="videoEl"
294
+ :fullscreen-target="root"
295
+ :variant="controls"
296
+ :name="name"
297
+ :messages="messages"
298
+ @visibility-change="controlsVisible = $event"
299
+ />
300
+
210
301
  <Transition
211
- enter-active-class="transition-opacity duration-100 ease-out"
212
- leave-active-class="transition-opacity duration-500 ease-in"
213
- enter-from-class="opacity-0"
302
+ leave-active-class="transition-opacity duration-300 ease-in motion-reduce:transition-none"
214
303
  leave-to-class="opacity-0"
215
304
  >
216
305
  <div
217
- v-if="showControls !== false && flashIcon && !hasError"
218
- class="pointer-events-none absolute inset-0 flex items-center justify-center"
306
+ v-if="showStart"
307
+ class="@container pointer-events-none absolute inset-0 flex items-center justify-center print:hidden"
219
308
  >
220
- <div
221
- class="flex size-16 items-center justify-center rounded-full bg-black/50 text-white"
222
- aria-hidden="true"
223
- >
224
- <NexxtIcon :name="flashIcon" class="text-2xl" />
225
- </div>
226
- </div>
227
- </Transition>
228
-
229
- <div
230
- v-if="showControls !== false && !hasError"
231
- class="absolute inset-x-0 bottom-0 flex flex-col gap-2 bg-linear-to-t from-black/70 via-black/40 to-transparent mask-[linear-gradient(to_top,black_30%,transparent)] px-3 pt-16 pb-2 backdrop-blur-sm transition-transform duration-300 ease-in-out"
232
- :style="{ transform: overlayVisible ? 'translateY(0)' : 'translateY(100%)' }"
233
- >
234
- <div
235
- ref="scrubberRef"
236
- role="slider"
237
- :aria-label="labels.seek"
238
- :aria-valuemin="0"
239
- :aria-valuemax="duration || 0"
240
- :aria-valuenow="Math.round(displayTime)"
241
- :aria-valuetext="formatTime(displayTime)"
242
- tabindex="0"
243
- class="relative h-1.5 cursor-pointer rounded-full bg-white/20"
244
- @mousedown="startSeeking"
245
- @touchstart.prevent="startSeeking"
246
- @keydown="onScrubberKeydown"
247
- @touchmove.prevent="
248
- (e) => {
249
- if (dragRatio !== null) dragRatio = getRatio(e)
250
- }
251
- "
252
- @touchend="
253
- () => {
254
- if (dragRatio !== null) commitSeek()
255
- }
256
- "
257
- >
258
- <div
259
- class="absolute inset-y-0 left-0 rounded-full bg-white/20"
260
- :style="{ width: `${bufferedPercent}%` }"
261
- />
262
- <div
263
- class="absolute inset-y-0 left-0 rounded-full bg-cornflower-blue-500"
264
- :style="{ width: `${progressPct}%` }"
265
- />
266
- <div
267
- class="absolute top-1/2 size-3 -translate-1/2 rounded-full bg-sapphire-100 opacity-0 shadow-sm transition-opacity group-hover/player:opacity-100"
268
- :style="{ left: `${progressPct}%` }"
269
- />
270
- </div>
271
-
272
- <div class="flex items-center gap-2">
273
309
  <button
274
310
  type="button"
275
- :aria-label="playing ? labels.pause : labels.play"
276
- class="flex size-8 cursor-pointer items-center justify-center rounded-full text-white transition hover:bg-white/10 hover:text-cornflower-blue-400"
277
- @click="togglePlay"
311
+ ref="startButton"
312
+ data-video-start
313
+ :aria-label="startLabel"
314
+ class="pointer-events-auto flex size-16 cursor-pointer items-center justify-center rounded-full bg-black/50 heading-2 text-white ring-1 ring-white/30 backdrop-blur-xs transition-transform duration-150 ease-out hover:scale-105 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 motion-reduce:transition-none motion-reduce:hover:scale-100 @max-[15rem]:size-12 @max-[15rem]:heading-4"
315
+ @click="onStart"
278
316
  >
279
- <NexxtIcon :name="playing ? 'pause' : 'play'" class="text-sm" aria-hidden="true" />
280
- </button>
281
-
282
- <span class="extra-small-normal text-white/70 tabular-nums">
283
- {{ formatTime(displayTime) }} / {{ formatTime(duration) }}
284
- </span>
285
-
286
- <div class="flex-1" />
287
-
288
- <button
289
- type="button"
290
- :aria-label="muted ? labels.unmute : labels.mute"
291
- class="flex size-8 cursor-pointer items-center justify-center rounded-full text-white transition hover:bg-white/10 hover:text-cornflower-blue-400"
292
- @click="toggleMute"
293
- >
294
- <NexxtIcon
295
- :name="muted ? 'volume-xmark' : 'volume-high'"
296
- class="text-sm"
297
- aria-hidden="true"
298
- />
299
- </button>
300
-
301
- <button
302
- type="button"
303
- :aria-label="isFullscreen ? labels.exitFullscreen : labels.enterFullscreen"
304
- class="flex size-8 cursor-pointer items-center justify-center rounded-full text-white transition hover:bg-white/10 hover:text-cornflower-blue-400"
305
- @click="toggleFullscreen"
306
- >
307
- <NexxtIcon
308
- :name="isFullscreen ? 'compress' : 'expand'"
309
- class="text-sm"
310
- aria-hidden="true"
311
- />
317
+ <!-- The triangle's weight sits left of its box: nudged right to look centred -->
318
+ <NexxtIcon name="play" type="solid" class="translate-x-[0.0625em]" />
312
319
  </button>
313
320
  </div>
314
- </div>
321
+ </Transition>
315
322
  </div>
316
323
  </template>
324
+
325
+ <style scoped>
326
+ @reference '../../../index.css';
327
+
328
+ /*
329
+ * The corner radius is a default, not a rule: it sits in the components layer, below Tailwind's
330
+ * utilities, so a `rounded-*` class on the player wins without `!`. The root is matched by its
331
+ * data attribute, as a class name trips the Tailwind lint.
332
+ */
333
+ @layer components {
334
+ [data-video-player] {
335
+ @apply rounded-lg;
336
+ }
337
+ }
338
+
339
+ /*
340
+ * Full screen takes the root, so the controls come along: square corners, black bars and the
341
+ * whole picture. Unlayered on purpose, so it beats a consumer's sizing classes while it lasts.
342
+ */
343
+ [data-video-player]:fullscreen {
344
+ @apply rounded-none bg-black;
345
+ }
346
+
347
+ [data-video-player]:fullscreen > video {
348
+ @apply h-full object-contain;
349
+ }
350
+ </style>
@@ -72,6 +72,7 @@
72
72
  "NexxtTimelinePhaseblock": "components/organisms/TimelinePhaseblock/TimelinePhaseblock.vue",
73
73
  "NexxtTimelineTrack": "components/organisms/TimelineTrack/TimelineTrack.vue",
74
74
  "NexxtToaster": "components/organisms/Toaster/Toaster.vue",
75
+ "NexxtVideoControls": "components/organisms/VideoControls/VideoControls.vue",
75
76
  "NexxtVideoPlayer": "components/organisms/VideoPlayer/VideoPlayer.vue",
76
77
  "NexxtEditorDimensionField": "components/editor/atoms/EditorDimensionField/EditorDimensionField.vue",
77
78
  "NexxtEditorSettingsBlock": "components/editor/atoms/EditorSettingsBlock/EditorSettingsBlock.vue",
package/src/index.ts CHANGED
@@ -9,10 +9,21 @@ export type {
9
9
  TimelinePhase,
10
10
  TimelineResponse,
11
11
  } from './components/molecules/TimelineEvent/TimelineTypes'
12
+ export { ENERGY_LABEL_COLORS } from './components/atoms/EnergyLabel/EnergyLabel.vue'
12
13
  export type { EnergyLabelLetter } from './components/atoms/EnergyLabel/EnergyLabel.vue'
13
14
  export type { IconType, NexxtIconName } from './components/atoms/Icon/Icon.vue'
14
15
  export type { CustomIconName } from './components/atoms/Icon/customIcons'
15
16
  export type { NexxtTabItem } from './components/molecules/Tabs/Tabs.vue'
17
+ export type {
18
+ NexxtVideoControlsMessages,
19
+ NexxtVideoControlsProps,
20
+ NexxtVideoControlsVariant,
21
+ } from './components/organisms/VideoControls/VideoControls.vue'
22
+ export type {
23
+ NexxtVideoPlayerControls,
24
+ NexxtVideoPlayerMessages,
25
+ NexxtVideoPlayerProps,
26
+ } from './components/organisms/VideoPlayer/VideoPlayer.vue'
16
27
  export type { NexxtComboboxGroup } from './components/organisms/ComboboxPanel/combobox-types'
17
28
  export type {
18
29
  NexxtPageCardProps,
@@ -300,4 +311,5 @@ export { default as NexxtToaster } from './components/organisms/Toaster/Toaster.
300
311
  export { default as NexxtToggleGroup } from './components/molecules/ToggleGroup/ToggleGroup.vue'
301
312
  export { default as NexxtTooltip } from './components/atoms/Tooltip/Tooltip.vue'
302
313
  export { default as NexxtTree } from './components/molecules/Tree/Tree.vue'
314
+ export { default as NexxtVideoControls } from './components/organisms/VideoControls/VideoControls.vue'
303
315
  export { default as NexxtVideoPlayer } from './components/organisms/VideoPlayer/VideoPlayer.vue'
package/src/nuxt.ts CHANGED
@@ -144,8 +144,12 @@ const module: NuxtModule<ModuleOptions> = defineNuxtModule<ModuleOptions>({
144
144
 
145
145
  // Compiled component `<style>` blocks (scoped rules, keyframes). Not
146
146
  // covered by the Tailwind entry above — that only scans the .vue source
147
- // for class names. Pushed last so it stays unlayered and wins over
148
- // Tailwind's layers.
147
+ // for class names. Pushed after the Tailwind entry on purpose. Most rules
148
+ // are unlayered and beat Tailwind's layers, but EnergyLabel puts its
149
+ // sizing defaults in `@layer components`, so that classes passed to the
150
+ // component can override them. Because `@import "tailwindcss"` loads
151
+ // first, the layer order is already theme, base, components, utilities,
152
+ // and utilities win.
149
153
  const componentCSS = resolve('./ui.css')
150
154
 
151
155
  if (existsSync(componentCSS) && !hasComponentCSS(nuxt.options.css, componentCSS)) {