@design-edito/tools 0.5.16 → 0.5.18

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 (63) hide show
  1. package/agnostic/colors/index.d.ts +4 -4
  2. package/agnostic/colors/index.js +4 -4
  3. package/agnostic/css/index.d.ts +1 -1
  4. package/agnostic/css/index.js +1 -1
  5. package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +10 -10
  6. package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +10 -10
  7. package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +6 -6
  8. package/agnostic/html/hyper-json/smart-tags/isolated/index.js +6 -6
  9. package/agnostic/html/index.d.ts +2 -2
  10. package/agnostic/html/index.js +2 -2
  11. package/agnostic/index.d.ts +5 -5
  12. package/agnostic/index.js +5 -5
  13. package/agnostic/misc/index.d.ts +5 -5
  14. package/agnostic/misc/index.js +5 -5
  15. package/agnostic/numbers/index.d.ts +2 -2
  16. package/agnostic/numbers/index.js +2 -2
  17. package/agnostic/objects/index.d.ts +2 -2
  18. package/agnostic/objects/index.js +2 -2
  19. package/agnostic/sanitization/index.d.ts +1 -1
  20. package/agnostic/sanitization/index.js +1 -1
  21. package/agnostic/subtitles/index.d.ts +1 -1
  22. package/agnostic/subtitles/index.js +1 -1
  23. package/agnostic/time/index.d.ts +2 -2
  24. package/agnostic/time/index.js +2 -2
  25. package/components/Image/index.d.ts +6 -0
  26. package/components/Image/index.js +3 -18
  27. package/components/IntersectionObserver/index.d.ts +28 -3
  28. package/components/IntersectionObserver/index.js +51 -20
  29. package/components/Video/index.controlled.d.ts +6 -22
  30. package/components/Video/index.controlled.js +28 -42
  31. package/components/Video/index.d.ts +28 -39
  32. package/components/Video/index.js +90 -92
  33. package/components/Video/types.d.ts +26 -0
  34. package/components/Video/types.js +1 -0
  35. package/components/Video/utils.d.ts +38 -1
  36. package/components/Video/utils.js +23 -4
  37. package/components/index.d.ts +5 -5
  38. package/components/index.js +5 -5
  39. package/components/public-classnames.d.ts +0 -1
  40. package/components/public-classnames.js +0 -1
  41. package/components/utils/index.d.ts +21 -0
  42. package/components/utils/index.js +40 -0
  43. package/components/utils/viewport-behaviours/index.d.ts +54 -0
  44. package/components/utils/viewport-behaviours/index.js +163 -0
  45. package/components/utils/viewport-behaviours/types.d.ts +109 -0
  46. package/components/utils/viewport-behaviours/types.js +14 -0
  47. package/components/utils/viewport-behaviours/utils.d.ts +26 -0
  48. package/components/utils/viewport-behaviours/utils.js +55 -0
  49. package/node/@aws-s3/storage/file/index.d.ts +2 -2
  50. package/node/@aws-s3/storage/file/index.js +2 -2
  51. package/node/@google-cloud/storage/file/index.d.ts +2 -2
  52. package/node/@google-cloud/storage/file/index.js +2 -2
  53. package/node/cloud-storage/operations/index.d.ts +1 -1
  54. package/node/cloud-storage/operations/index.js +1 -1
  55. package/node/files/index.d.ts +1 -1
  56. package/node/files/index.js +1 -1
  57. package/node/images/index.d.ts +1 -1
  58. package/node/images/index.js +1 -1
  59. package/node/images/transform/operations/index.d.ts +5 -5
  60. package/node/images/transform/operations/index.js +5 -5
  61. package/node/process/index.d.ts +1 -1
  62. package/node/process/index.js +1 -1
  63. package/package.json +16 -1
@@ -5,23 +5,29 @@ import { mergeClassNames } from '../utils/index.js';
5
5
  import { intersectionObserver as publicClassName } from '../public-classnames.js';
6
6
  import cssModule from './styles.module.css';
7
7
  /**
8
- * Component that observes its root element using the IntersectionObserver API
9
- * and notifies consumers about visibility changes.
8
+ * Observes an element a caller already renders, rather than one wrapped in a div of
9
+ * our own.
10
10
  *
11
- * @param props - Component properties.
12
- * @see {@link Props}
11
+ * This is the whole of the behaviour; {@link IntersectionObserverComponent} is this
12
+ * hook plus a div to hang it on. Reach for the hook when the element to watch is
13
+ * already in the tree — a component's own root, typically — and for the component
14
+ * when there is nothing to watch yet and a box has to be created.
13
15
  *
14
- * @returns A div element wrapping `children`, observed for intersection changes.
16
+ * @param targetRef - The element to observe. Nothing happens until it is attached.
17
+ * @param options - See {@link ObserverOptions}.
18
+ * @param onIntersected - Called on every intersection change.
19
+ * @param enabled - `false` skips the observer entirely, for a caller whose need for
20
+ * it depends on its own props. A hook cannot be called conditionally; this is how
21
+ * the condition is expressed.
22
+ *
23
+ * @returns The latest {@link IntersectionObserverEntry}, or `null` before the first.
15
24
  *
16
25
  * @remarks
17
26
  * - Automatically creates and disconnects the {@link IntersectionObserver} instance.
18
27
  * - Re-observes the element shortly after mount to handle late layout changes.
19
- * - Adds an `is-intersecting` modifier class when the element is intersecting.
20
28
  */
21
- export const IntersectionObserverComponent = ({ onIntersected, root, rootMargin, threshold, className, children }) => {
22
- // Refs, handlers and effects
29
+ export function useIntersectionObserver(targetRef, { root, rootMargin, threshold }, onIntersected, enabled = true) {
23
30
  const [ioEntry, setIoEntry] = useState(null);
24
- const rootRef = useRef(null);
25
31
  const observerRef = useRef(null);
26
32
  const observation = useCallback((entries, observer) => {
27
33
  const thisEntry = entries[0];
@@ -31,33 +37,58 @@ export const IntersectionObserverComponent = ({ onIntersected, root, rootMargin,
31
37
  setIoEntry(thisEntry);
32
38
  }, [onIntersected]);
33
39
  const forceObservation = useCallback(() => {
34
- const rootEl = rootRef.current;
40
+ const targetEl = targetRef.current;
35
41
  const observer = observerRef.current;
36
- if (rootEl === null || observer === null)
42
+ if (targetEl === null || observer === null)
37
43
  return;
38
- observer.unobserve(rootEl);
39
- observer.observe(rootEl);
40
- }, []);
44
+ observer.unobserve(targetEl);
45
+ observer.observe(targetEl);
46
+ }, [targetRef]);
41
47
  useEffect(() => {
42
- const rootEl = rootRef.current;
43
- if (rootEl === null) {
48
+ if (!enabled)
49
+ return;
50
+ const targetEl = targetRef.current;
51
+ if (targetEl === null) {
44
52
  // eslint-disable-next-line no-console
45
- console.warn('rootRef.current should not be null');
53
+ console.warn('targetRef.current should not be null');
46
54
  return;
47
55
  }
48
56
  const observer = new IntersectionObserver(observation, { root, rootMargin, threshold });
49
57
  observerRef.current = observer;
50
- observer.observe(rootEl);
58
+ observer.observe(targetEl);
51
59
  return () => observer.disconnect();
52
- }, [root, rootMargin, threshold, observation]);
60
+ }, [enabled, targetRef, root, rootMargin, threshold, observation]);
53
61
  useEffect(() => {
62
+ if (!enabled)
63
+ return;
54
64
  const timeout1 = window.setTimeout(forceObservation, 100);
55
65
  const timeout2 = window.setTimeout(forceObservation, 500);
56
66
  return () => {
57
67
  window.clearTimeout(timeout1);
58
68
  window.clearTimeout(timeout2);
59
69
  };
60
- }, [forceObservation]);
70
+ }, [enabled, forceObservation]);
71
+ return ioEntry;
72
+ }
73
+ /**
74
+ * Component that observes its root element using the IntersectionObserver API
75
+ * and notifies consumers about visibility changes.
76
+ *
77
+ * A div and {@link useIntersectionObserver}, and nothing else. A component that
78
+ * already renders the element it wants watched should use the hook and keep its own
79
+ * root, rather than gain a wrapper it has no other use for.
80
+ *
81
+ * @param props - Component properties.
82
+ * @see {@link Props}
83
+ *
84
+ * @returns A div element wrapping `children`, observed for intersection changes.
85
+ *
86
+ * @remarks
87
+ * - Adds an `is-intersecting` modifier class when the element is intersecting.
88
+ */
89
+ export const IntersectionObserverComponent = ({ onIntersected, root, rootMargin, threshold, className, children }) => {
90
+ const rootRef = useRef(null);
91
+ const ioEntry = useIntersectionObserver(rootRef, { root, rootMargin, threshold }, onIntersected);
61
92
  // Rendering
62
93
  const c = clss(publicClassName, { cssModule });
63
94
  const isIntersecting = ioEntry?.isIntersecting ?? false;
@@ -1,32 +1,13 @@
1
- import { type FunctionComponent, type PropsWithChildren, type VideoHTMLAttributes } from 'react';
1
+ import { type FunctionComponent, type PropsWithChildren, type Ref, type RefObject, type VideoHTMLAttributes } from 'react';
2
2
  import type { WithClassName } from '../utils/types.js';
3
3
  import { type Props as SubsProps } from '../Subtitles/index.js';
4
+ import { type SourceData, type TrackData } from './utils.js';
4
5
  /**
5
6
  * Describes a single video source.
6
7
  *
7
8
  * @property src - URL of the video file.
8
9
  * @property type - MIME type of the source (e.g. `'video/mp4'`).
9
10
  */
10
- type SourceData = {
11
- src?: string;
12
- type?: string;
13
- };
14
- /**
15
- * Describes a single text track (subtitles, captions, chapters, etc.).
16
- *
17
- * @property src - URL of the track file.
18
- * @property kind - Track type, maps directly to the `<track>` `kind` attribute.
19
- * @property srclang - Language of the track content (e.g. `'fr'`, `'en'`).
20
- * @property label - Human-readable label shown in the browser's track selector.
21
- * @property default - When `true`, marks this track as the default selection.
22
- */
23
- type TrackData = {
24
- src?: string;
25
- kind?: 'subtitles' | 'captions' | 'descriptions' | 'chapters' | 'metadata';
26
- srclang?: string;
27
- label?: string;
28
- default?: boolean;
29
- };
30
11
  /**
31
12
  * Props for the ControlledVideo component.
32
13
  *
@@ -113,6 +94,9 @@ type TrackData = {
113
94
  * Also inherits all standard HTML props for a <video> element.
114
95
  */
115
96
  export type Props = PropsWithChildren<WithClassName<{
97
+ rootRef?: Ref<HTMLElement>;
98
+ videoRef?: RefObject<HTMLVideoElement | null>;
99
+ togglePlayOnClick?: boolean;
116
100
  sources?: string | string[] | SourceData[];
117
101
  tracks?: string | string[] | TrackData[];
118
102
  subtitles?: SubsProps;
@@ -139,6 +123,7 @@ export type Props = PropsWithChildren<WithClassName<{
139
123
  onFullscreenButtonClicked?: (e: React.MouseEvent<HTMLButtonElement>, isFullscreen: boolean, video: HTMLVideoElement | null) => void;
140
124
  onSubtitlesButtonClicked?: (e: React.MouseEvent<HTMLButtonElement>, isSubtitlesOn: boolean, video: HTMLVideoElement | null) => void;
141
125
  onTimelineClicked?: (e: React.MouseEvent<HTMLDivElement>, targetTime: number, currentTime: number, video: HTMLVideoElement | null) => void;
126
+ onVideoClicked?: (e: React.MouseEvent<HTMLVideoElement>, isPlaying: boolean, video: HTMLVideoElement | null) => void;
142
127
  onIsPlayingChanged?: (isPlaying: boolean) => void;
143
128
  onIsFullscreenChanged?: (isFullscreen: boolean) => void;
144
129
  onIsLoudChanged?: (isLoud: boolean) => void;
@@ -193,4 +178,3 @@ export type Props = PropsWithChildren<WithClassName<{
193
178
  * subtitles.
194
179
  */
195
180
  export declare const ControlledVideo: FunctionComponent<Props>;
196
- export {};
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useRef, useState, useCallback, useEffect } from 'react';
3
3
  import { clss } from '../../agnostic/css/clss/index.js';
4
4
  import { formatDuration } from '../../agnostic/time/duration/format-duration/index.js';
5
- import { mergeClassNames, useChangeDispatch } from '../utils/index.js';
5
+ import { mergeClassNames, parseSourceList, useChangeDispatch } from '../utils/index.js';
6
6
  import cssModule from './styles.module.css';
7
7
  import { video as publicClassName } from '../public-classnames.js';
8
8
  import { Subtitles } from '../Subtitles/index.js';
@@ -57,8 +57,9 @@ subtitlesOn = true, mute, muted, volume = 1, playbackRate = 1, currentTimeMs: gi
57
57
  // Milliseconds by default, which is what this component has always rendered — a
58
58
  // default that changes under a consumer is a change nobody asked for. An article
59
59
  // usually wants `'{{mm}}:{{ss}}'` and says so.
60
- timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onSubtitlesButtonClicked, onTimelineClicked, onIsPlayingChanged, onIsFullscreenChanged, onIsLoudChanged, onIsSubtitlesOnChanged, onIsEndedChanged, onVolumeChanged, onPlaybackRateChanged, onCurrentTimeMsChanged, onFullscreenChange, children, className, ...intrinsicVideoAttributes }) => {
61
- const videoRef = useRef(null);
60
+ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onSubtitlesButtonClicked, onTimelineClicked, onVideoClicked, togglePlayOnClick, onIsPlayingChanged, onIsFullscreenChanged, onIsLoudChanged, onIsSubtitlesOnChanged, onIsEndedChanged, onVolumeChanged, onPlaybackRateChanged, onCurrentTimeMsChanged, onFullscreenChange, children, className, rootRef, videoRef: givenVideoRef, ...intrinsicVideoAttributes }) => {
61
+ const ownVideoRef = useRef(null);
62
+ const videoRef = givenVideoRef ?? ownVideoRef;
62
63
  const [totalTime, setTotalTime] = useState(0);
63
64
  const totalTimeMs = useMemo(() => secondsToMs(totalTime), [totalTime]);
64
65
  const [internalCurrentTimeMs, setInternalCurrentTimeMs] = useState(0);
@@ -121,6 +122,20 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
121
122
  const wasPlaying = videoRef.current?.paused === false;
122
123
  onPlayButtonClicked?.(e, wasPlaying, videoRef.current);
123
124
  }, [onPlayButtonClicked]);
125
+ // The picture as a play/pause surface. It reports and nothing more, like every other
126
+ // control here: the uncontrolled layer above owns the play state.
127
+ //
128
+ // The listener sits on the element and not on the `<figure>`, so the controls painted
129
+ // over it are not part of the surface — a click on pause would otherwise toggle twice
130
+ // and cancel itself. No `role` and no `tabIndex` either: this is a **second** way to
131
+ // do what the play button already does, and giving it a focus stop would put the same
132
+ // action twice in the tab order.
133
+ const handleVideoClick = useCallback((e) => {
134
+ if (togglePlayOnClick !== true)
135
+ return;
136
+ const wasPlaying = videoRef.current?.paused === false;
137
+ onVideoClicked?.(e, wasPlaying, videoRef.current);
138
+ }, [togglePlayOnClick, onVideoClicked]);
124
139
  const handlePauseButtonClick = useCallback((e) => {
125
140
  const wasPlaying = videoRef.current?.paused === false;
126
141
  onPauseButtonClicked?.(e, wasPlaying, videoRef.current);
@@ -170,7 +185,11 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
170
185
  // caché, c'est l'absence d'un état. Une feuille qui veut viser ce cas le reconnaît à
171
186
  // ce qu'aucun des deux modifieurs n'est là.
172
187
  'subtitles-on': subtitles !== undefined && subtitlesOn,
173
- 'subtitles-off': subtitles !== undefined && !subtitlesOn
188
+ 'subtitles-off': subtitles !== undefined && !subtitlesOn,
189
+ // A capability rather than a state, and it is here for the same reason the lightbox
190
+ // carries `--open-on-click`: a surface that answers a click has to be able to say so
191
+ // with a cursor.
192
+ 'toggle-play-on-click': togglePlayOnClick === true
174
193
  }), className);
175
194
  // Guarded: the duration is unknown until the metadata lands, and an unguarded
176
195
  // division would expose the string 'NaN' on every render until then.
@@ -190,7 +209,8 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
190
209
  'data-playback-rate': playbackRate,
191
210
  'data-current-time-ms': currentTimeMs.toFixed(2),
192
211
  'data-current-time-ratio': currentTimeRatio.toFixed(8),
193
- 'data-total-time-ms': totalTimeMs
212
+ 'data-total-time-ms': totalTimeMs,
213
+ 'data-toggle-play-on-click': togglePlayOnClick === true ? '' : undefined
194
214
  };
195
215
  const rootStyles = {
196
216
  '--lm-video-current-time': `${currentTimeMs}ms`,
@@ -201,38 +221,8 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
201
221
  '--lm-video-volume-ratio': `${volume}`,
202
222
  '--lm-video-playback-rate': `${playbackRate}`
203
223
  };
204
- const parsedSources = useMemo(() => {
205
- if (sources === undefined)
206
- return [];
207
- if (typeof sources === 'string')
208
- return [{ src: sources }];
209
- if (Array.isArray(sources)) {
210
- if (sources.length === 0)
211
- return [];
212
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- first element sampled just above; array is expected to be homogeneous
213
- if (typeof sources[0] === 'string')
214
- return sources.map(src => ({ src }));
215
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- first element was checked not to be a string just above; array is expected to be homogeneous
216
- return sources;
217
- }
218
- return [];
219
- }, [sources]);
220
- const parsedTracks = useMemo(() => {
221
- if (tracks === undefined)
222
- return [];
223
- if (typeof tracks === 'string')
224
- return [{ src: tracks }];
225
- if (Array.isArray(tracks)) {
226
- if (tracks.length === 0)
227
- return [];
228
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- first element sampled just above; array is expected to be homogeneous
229
- if (typeof tracks[0] === 'string')
230
- return tracks.map(src => ({ src }));
231
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- first element was checked not to be a string just above; array is expected to be homogeneous
232
- return tracks;
233
- }
234
- return [];
235
- }, [tracks]);
224
+ const parsedSources = useMemo(() => parseSourceList(sources, 'src'), [sources]);
225
+ const parsedTracks = useMemo(() => parseSourceList(tracks, 'src'), [tracks]);
236
226
  const videoClss = c('video');
237
227
  const videoControlsClss = c('video-controls');
238
228
  const playBtnClss = c('play-btn');
@@ -318,9 +308,5 @@ timeFormat = '{{mm}}:{{ss}}:{{ms}}', onPlayButtonClicked, onPauseButtonClicked,
318
308
  useChangeDispatch(isEnded, onIsEndedChanged);
319
309
  useChangeDispatch(volume, onVolumeChanged);
320
310
  useChangeDispatch(playbackRate, onPlaybackRateChanged);
321
- return _jsxs("figure", { className: rootClss, style: rootStyles, ...rootAttributes, children: [_jsxs("video", { ref: videoRef, className: videoClss, ...intrinsicVideoAttributes, autoPlay: isTimeControlled ? false : intrinsicVideoAttributes.autoPlay, onLoadedMetadata: handleMetadataLoadEvent, onTimeUpdate: handleOnTimeUpdateEvent, onEnded: handleEndedEvent, onPlay: handlePlayEvent, onPause: handlePauseEvent, children: [parsedSources.map((source, index) => typeof source === 'string'
322
- ? _jsx("source", { src: source }, index)
323
- : _jsx("source", { src: source.src, type: source.type }, index)), parsedTracks.map((track, index) => typeof track === 'string'
324
- ? _jsx("track", { src: track }, index)
325
- : _jsx("track", { src: track.src, kind: track.kind, srcLang: track.srclang, label: track.label, default: track.default }, index)), children] }), _jsxs("div", { className: videoControlsClss, children: [_jsx("button", { className: playBtnClss, onClick: handlePlayButtonClick, children: playBtnContent }), _jsx("button", { className: pauseBtnClss, onClick: handlePauseButtonClick, children: pauseBtnContent }), _jsx("button", { className: loudBtnClss, onClick: handleLoudButtonClick, children: loudBtnContent }), _jsx("button", { className: muteBtnClss, onClick: handleMuteButtonClick, children: muteBtnContent }), _jsx("input", { type: 'range', className: volumeRangeClss, value: volumePercent, onChange: handleVolumeRangeChange, min: 0, max: 100, step: 1 }), _jsx("span", { className: volumePcntClss, children: Math.round(volumePercent) }), _jsx("button", { className: fullscreenBtnClss, onClick: handleFullscreenButtonClick, children: fullscreenBtnContent }), subtitles !== undefined && _jsx("button", { className: subtitlesBtnClss, onClick: handleSubtitlesButtonClick, children: subtitlesBtnContent }), _jsx("input", { type: 'range', className: playbackRateRangeClss, value: playbackRate, onChange: handleRateRangeChange, min: 0.25, max: 4, step: 0.25 }), _jsx("span", { className: playbackRateClss, children: playbackRate })] }), _jsxs("div", { className: timeControlsClss, children: [_jsx("span", { className: currentTimeClss, children: formatDuration(currentTimeMs, timeFormat) }), _jsx("span", { className: totalTimeClss, children: formatDuration(totalTimeMs, timeFormat) }), _jsx("div", { className: timelineClss, onClick: handleTimelineClick })] }), subtitles !== undefined && _jsx(Subtitles, { ...subtitles, timecodeMs: currentTimeMs, isEnded: isEnded })] });
311
+ return _jsxs("figure", { ref: rootRef, className: rootClss, style: rootStyles, ...rootAttributes, children: [_jsxs("video", { ref: videoRef, className: videoClss, ...intrinsicVideoAttributes, autoPlay: isTimeControlled ? false : intrinsicVideoAttributes.autoPlay, onClick: handleVideoClick, onLoadedMetadata: handleMetadataLoadEvent, onTimeUpdate: handleOnTimeUpdateEvent, onEnded: handleEndedEvent, onPlay: handlePlayEvent, onPause: handlePauseEvent, children: [parsedSources.map((source, index) => _jsx("source", { src: source.src, type: source.type }, index)), parsedTracks.map((track, index) => _jsx("track", { src: track.src, kind: track.kind, srcLang: track.srclang, label: track.label, default: track.default }, index)), children] }), _jsxs("div", { className: videoControlsClss, children: [_jsx("button", { className: playBtnClss, onClick: handlePlayButtonClick, children: playBtnContent }), _jsx("button", { className: pauseBtnClss, onClick: handlePauseButtonClick, children: pauseBtnContent }), _jsx("button", { className: loudBtnClss, onClick: handleLoudButtonClick, children: loudBtnContent }), _jsx("button", { className: muteBtnClss, onClick: handleMuteButtonClick, children: muteBtnContent }), _jsx("input", { type: 'range', className: volumeRangeClss, value: volumePercent, onChange: handleVolumeRangeChange, min: 0, max: 100, step: 1 }), _jsx("span", { className: volumePcntClss, children: Math.round(volumePercent) }), _jsx("button", { className: fullscreenBtnClss, onClick: handleFullscreenButtonClick, children: fullscreenBtnContent }), subtitles !== undefined && _jsx("button", { className: subtitlesBtnClss, onClick: handleSubtitlesButtonClick, children: subtitlesBtnContent }), _jsx("input", { type: 'range', className: playbackRateRangeClss, value: playbackRate, onChange: handleRateRangeChange, min: 0.25, max: 4, step: 0.25 }), _jsx("span", { className: playbackRateClss, children: playbackRate })] }), _jsxs("div", { className: timeControlsClss, children: [_jsx("span", { className: currentTimeClss, children: formatDuration(currentTimeMs, timeFormat) }), _jsx("span", { className: totalTimeClss, children: formatDuration(totalTimeMs, timeFormat) }), _jsx("div", { className: timelineClss, onClick: handleTimelineClick })] }), subtitles !== undefined && _jsx(Subtitles, { ...subtitles, timecodeMs: currentTimeMs, isEnded: isEnded })] });
326
312
  };
@@ -1,36 +1,27 @@
1
1
  import { type FunctionComponent } from 'react';
2
- import type { WithViewportObservation } from '../utils/types.js';
2
+ import { type ViewportBehaviours } from '../utils/viewport-behaviours/index.js';
3
+ import type { VideoAction } from './types.js';
3
4
  import { type Props as ControlledProps } from './index.controlled.js';
4
5
  /**
5
6
  * Props for the {@link Video} component.
6
7
  *
7
8
  * Extends all ControlledVideo props except play, mute, fullscreen, volume, playbackRate, and their associated event handlers
8
- * @property autoPlayWhenVisible - When `true`, starts playback every time the
9
- * component enters the viewport.
10
- * @property autoPlayOnceVisible - Same, but only the first time it does.
11
- * @property autoPauseWhenHidden - When `true`, pauses playback every time the
12
- * component leaves the viewport.
13
- * @property autoPauseOnceHidden - Same, but only the first time it does.
14
- * @property autoLoudWhenVisible - When `true`, unmutes every time the component
15
- * enters the viewport.
16
- * @property autoLoudOnceVisible - Same, but only the first time it does.
17
- * @property autoMuteWhenHidden - When `true`, mutes every time the component leaves
18
- * the viewport.
19
- * @property autoMuteOnceHidden - Same, but only the first time it does.
20
- * @property threshold - How much of the component has to be in view before it
21
- * counts as visible, forwarded to the internal {@link IntersectionObserver}. `0.3`
22
- * to start on a third of it; omitted, a single pixel is enough.
23
- * @property root - The observer's root. Defaults to the viewport.
24
- * @property rootMargin - Grows or shrinks that root before measuring.
25
- * @property onVisibilityChanged - Called on every crossing with the new value,
26
- * whether or not an `auto…` behaviour is bound to it. Never on mount.
9
+ * @property visibilityThreshold - How much of the video has to be on screen to count as
10
+ * seen. See {@link VisibilityOptions} for this and the four that follow it.
11
+ * @property whenVisible - What to do each time it becomes visible.
12
+ * @property whenHidden - The same, on the way out.
13
+ * @property onVisibilityChanged - Called with the new value once it has settled — the
14
+ * delays included, so this and the instructions are told the same story. Never on mount.
27
15
  * @property currentTimeMs - When provided, hands ownership of the current time
28
16
  * (in milliseconds) to the parent, which is then responsible for updating it —
29
17
  * typically to scrub the video from scroll position. A controlled time implies a
30
18
  * stopped video, since a playing element would advance a value it does not own:
31
- * `autoPlay`, `autoPlayWhenVisible` and the play button have no effect for as
19
+ * `autoPlay`, a `'play'` instruction and the play button have no effect for as
32
20
  * long as this prop is provided.
33
- * @property wrapperClassName - Optional additional class name(s) applied to the root wrapper element.
21
+ * @property togglePlayOnClick - When `true`, a click on the picture plays or pauses it.
22
+ * A **second** way to reach what the play and pause buttons already do, never the only
23
+ * one: the element takes no focus stop, so the keyboard keeps using the buttons. The
24
+ * controls painted over the picture are not part of the surface.
34
25
  * @property defaultSubtitlesOn - Whether the subtitles start shown. `true` by default —
35
26
  * an article that supplies cues means them to be read. The reader's button takes it from
36
27
  * there, so this is a starting point and not a setting: `default…` and never `initial…`,
@@ -39,17 +30,8 @@ import { type Props as ControlledProps } from './index.controlled.js';
39
30
  * @property children - React children rendered inside the `<video>` element itself
40
31
  * (e.g. fallback content).
41
32
  */
42
- export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mute' | 'playbackRate' | 'subtitlesOn'>> & {
33
+ export type Props = Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mute' | 'playbackRate' | 'subtitlesOn' | 'rootRef' | 'videoRef'> & ViewportBehaviours<VideoAction> & {
43
34
  defaultSubtitlesOn?: boolean;
44
- autoPlayWhenVisible?: boolean;
45
- autoPlayOnceVisible?: boolean;
46
- autoPauseWhenHidden?: boolean;
47
- autoPauseOnceHidden?: boolean;
48
- autoLoudWhenVisible?: boolean;
49
- autoLoudOnceVisible?: boolean;
50
- autoMuteWhenHidden?: boolean;
51
- autoMuteOnceHidden?: boolean;
52
- wrapperClassName?: string;
53
35
  };
54
36
  /**
55
37
  * Full-featured video player component. Wraps a native `<video>` element with
@@ -72,11 +54,18 @@ export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'full
72
54
  * label: a screen reader announces a video player. Worth a `tag` prop the day that
73
55
  * matters more than autoplay — not before.
74
56
  *
75
- * Each viewport-driven behaviour comes in two flavours: `…When…` fires on every
76
- * crossing, `…Once…` only on the first one. A `…Once…` flag is armed by its own
77
- * automatic trigger and by nothing else — pressing play does not spend the one
78
- * automatic play the component still owed. Setting both flavours of the same
79
- * behaviour is the same as setting the `…When…` one alone.
57
+ * **Viewport-driven behaviour is declared, not named by a prop.** `whenVisible` and
58
+ * `whenHidden` take a verb or a list of them, out of {@link VideoAction} — `'play'`,
59
+ * `'mute'`, `'jump-to:500'` — each optionally suffixed by `':once'` and `':force'`. See
60
+ * `components/utils/viewport-behaviours` for the grammar and its rules, and the
61
+ * `visibility…` props for what « visible » means and how long
62
+ * it has to have been true.
63
+ *
64
+ * **An instruction yields to the reader by default.** Touching a control surrenders its
65
+ * domain for the rest of the mount: playback covers the play and pause buttons, the
66
+ * picture when it toggles, and the timeline, since seeking is saying where one wants to
67
+ * be. Sound covers the two sound buttons and the volume slider. Fullscreen and playback
68
+ * rate surrender nothing, being neither. `':force'` opts out.
80
69
  *
81
70
  * **`loop` reaches the element untouched**, like every other native media attribute
82
71
  * this component does not drive itself. It was once destructured out of the props and
@@ -85,8 +74,8 @@ export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'full
85
74
  * consequence worth knowing: a looping element never fires `ended`, so the `--ended`
86
75
  * modifier and the `isEnded` it feeds to `Subtitles` simply never come.
87
76
  *
88
- * Browsers refuse an unmuted `play()` outside a user gesture, so pairing an
89
- * `autoLoud…` with an `autoPlay…` will usually have the playback rejected. The
77
+ * Browsers refuse an unmuted `play()` outside a user gesture, so pairing a `'loud'`
78
+ * instruction with a `'play'` one will usually have the playback rejected. The
90
79
  * refusal is caught rather than ignored — the element is read back once the attempt
91
80
  * settles, and the play state follows what it says — so the controls stay truthful.
92
81
  * The media still won't play, though: autoplay muted, and leave unmuting to the