@design-edito/tools 0.5.6 → 0.5.8

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 (97) hide show
  1. package/agnostic/arrays/index.d.ts +2 -2
  2. package/agnostic/arrays/index.js +2 -2
  3. package/agnostic/colors/index.d.ts +2 -2
  4. package/agnostic/colors/index.js +2 -2
  5. package/agnostic/css/index.d.ts +1 -1
  6. package/agnostic/css/index.js +1 -1
  7. package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +10 -10
  8. package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +10 -10
  9. package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +2 -2
  10. package/agnostic/html/hyper-json/smart-tags/isolated/index.js +2 -2
  11. package/agnostic/html/index.d.ts +1 -1
  12. package/agnostic/html/index.js +1 -1
  13. package/agnostic/index.d.ts +5 -4
  14. package/agnostic/index.js +5 -4
  15. package/agnostic/misc/index.d.ts +3 -3
  16. package/agnostic/misc/index.js +3 -3
  17. package/agnostic/numbers/index.d.ts +1 -1
  18. package/agnostic/numbers/index.js +1 -1
  19. package/agnostic/objects/index.d.ts +2 -2
  20. package/agnostic/objects/index.js +2 -2
  21. package/agnostic/strings/index.d.ts +1 -1
  22. package/agnostic/strings/index.js +1 -1
  23. package/agnostic/subtitles/index.d.ts +2 -0
  24. package/agnostic/subtitles/index.js +2 -0
  25. package/agnostic/subtitles/parse-srt/index.d.ts +17 -0
  26. package/agnostic/subtitles/parse-srt/index.js +67 -0
  27. package/agnostic/subtitles/parse-srt/index.test.d.ts +1 -0
  28. package/agnostic/subtitles/parse-srt/index.test.js +94 -0
  29. package/agnostic/subtitles/parse-srt/types.d.ts +22 -0
  30. package/agnostic/subtitles/parse-srt/types.js +1 -0
  31. package/agnostic/subtitles/srt-timecode-to-ms/index.d.ts +14 -0
  32. package/agnostic/subtitles/srt-timecode-to-ms/index.js +36 -0
  33. package/agnostic/subtitles/srt-timecode-to-ms/index.test.d.ts +1 -0
  34. package/agnostic/subtitles/srt-timecode-to-ms/index.test.js +44 -0
  35. package/components/BeforeAfter/index.controlled.d.ts +5 -4
  36. package/components/BeforeAfter/index.controlled.js +6 -6
  37. package/components/Drawer/index.d.ts +3 -1
  38. package/components/Drawer/index.js +5 -2
  39. package/components/Iframe/index.d.ts +4 -0
  40. package/components/Iframe/index.js +8 -1
  41. package/components/Input/index.d.ts +4 -0
  42. package/components/Input/index.js +9 -1
  43. package/components/ListLoader/index.controlled.d.ts +11 -3
  44. package/components/ListLoader/index.controlled.js +6 -3
  45. package/components/ListLoader/index.d.ts +1 -1
  46. package/components/ListLoader/index.js +2 -2
  47. package/components/ResizeObserver/index.d.ts +6 -1
  48. package/components/ResizeObserver/index.js +7 -2
  49. package/components/Scrllgngn/index.d.ts +37 -7
  50. package/components/Scrllgngn/index.js +81 -24
  51. package/components/Scrllgngn/styles.module.css +30 -5
  52. package/components/Scrllgngn/utils.d.ts +87 -4
  53. package/components/Scrllgngn/utils.js +142 -8
  54. package/components/ScrollListener/index.d.ts +12 -3
  55. package/components/ScrollListener/index.js +12 -3
  56. package/components/Select/index.d.ts +4 -0
  57. package/components/Select/index.js +9 -1
  58. package/components/Sequencer/index.d.ts +10 -1
  59. package/components/Sequencer/index.js +7 -3
  60. package/components/ShadowRoot/index.d.ts +4 -0
  61. package/components/ShadowRoot/index.js +5 -1
  62. package/components/Subtitles/index.d.ts +53 -62
  63. package/components/Subtitles/index.js +90 -158
  64. package/components/Subtitles/utils.d.ts +60 -0
  65. package/components/Subtitles/utils.js +76 -0
  66. package/components/Textarea/index.d.ts +4 -0
  67. package/components/Textarea/index.js +9 -1
  68. package/components/Video/index.controlled.d.ts +13 -2
  69. package/components/Video/index.controlled.js +37 -6
  70. package/components/Video/index.d.ts +47 -9
  71. package/components/Video/index.js +71 -20
  72. package/components/Video/utils.d.ts +14 -0
  73. package/components/Video/utils.js +18 -0
  74. package/components/index.d.ts +5 -5
  75. package/components/index.js +5 -5
  76. package/components/utils/types.d.ts +32 -0
  77. package/index.d.ts +1 -1
  78. package/index.js +1 -1
  79. package/node/@aws-s3/storage/directory/index.d.ts +1 -1
  80. package/node/@aws-s3/storage/directory/index.js +1 -1
  81. package/node/@aws-s3/storage/file/index.d.ts +1 -1
  82. package/node/@aws-s3/storage/file/index.js +1 -1
  83. package/node/@google-cloud/storage/directory/index.d.ts +1 -1
  84. package/node/@google-cloud/storage/directory/index.js +1 -1
  85. package/node/@google-cloud/storage/file/index.d.ts +3 -3
  86. package/node/@google-cloud/storage/file/index.js +3 -3
  87. package/node/cloud-storage/operations/index.d.ts +2 -2
  88. package/node/cloud-storage/operations/index.js +2 -2
  89. package/node/images/index.d.ts +1 -1
  90. package/node/images/index.js +1 -1
  91. package/node/images/transform/operations/index.d.ts +5 -5
  92. package/node/images/transform/operations/index.js +5 -5
  93. package/node/index.d.ts +1 -1
  94. package/node/index.js +1 -1
  95. package/node/sftp/file/index.d.ts +1 -1
  96. package/node/sftp/file/index.js +1 -1
  97. package/package.json +26 -1
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Puts a consumer's group boundaries in a state the group builder can use.
3
+ *
4
+ * Out-of-range, duplicate and unordered boundaries all produce overlapping or
5
+ * inverted groups downstream — the same cue rendered twice, colliding keys. Rather
6
+ * than demand a sorted array and break quietly when it isn't, normalise here.
7
+ *
8
+ * @param boundaries - The positions a consumer asked to split on, in any order.
9
+ * @param lastPos - Position of the last cue.
10
+ * @returns The usable boundaries, ascending, without duplicates, and without the
11
+ * last position — which ends the final group anyway.
12
+ */
13
+ export function normalizeGroupBoundaries(boundaries, lastPos) {
14
+ if (boundaries === undefined)
15
+ return [];
16
+ const usable = boundaries.filter(pos => Number.isInteger(pos) && pos >= 0 && pos < lastPos);
17
+ return [...new Set(usable)].sort((a, b) => a - b);
18
+ }
19
+ /**
20
+ * Cuts the cue array into groups.
21
+ *
22
+ * @param boundaries - Normalised boundaries, each the position of a group's last cue.
23
+ * See {@link normalizeGroupBoundaries}.
24
+ * @param lastPos - Position of the last cue.
25
+ * @returns One group per section, in order. A single group spanning everything when
26
+ * no boundary is given, and nothing at all when there is no cue to group.
27
+ */
28
+ export function toSubGroups(boundaries, lastPos) {
29
+ if (lastPos < 0)
30
+ return [];
31
+ const groups = [];
32
+ let startPos = 0;
33
+ for (const endPos of boundaries) {
34
+ groups.push({ startPos, endPos });
35
+ startPos = endPos + 1;
36
+ }
37
+ groups.push({ startPos, endPos: lastPos });
38
+ return groups;
39
+ }
40
+ /**
41
+ * Finds the last cue the playhead has already reached.
42
+ *
43
+ * @param cues - The parsed cues, in order.
44
+ * @param timecodeMs - Current media position, absent while nothing plays.
45
+ * @returns Its position, or `-1` when the playhead sits before the first cue. One
46
+ * number, computed once: `--prev` on a cue and the current group both read from it,
47
+ * and deriving them separately lets them disagree on a file with duplicate or
48
+ * out-of-order start times.
49
+ */
50
+ export function lastElapsedPos(cues, timecodeMs) {
51
+ if (timecodeMs === undefined)
52
+ return -1;
53
+ let found = -1;
54
+ cues.forEach((cue, pos) => {
55
+ if (cue.start < timecodeMs)
56
+ found = pos;
57
+ });
58
+ return found;
59
+ }
60
+ /**
61
+ * Picks the group the reader is in.
62
+ *
63
+ * @param groups - The groups, in order.
64
+ * @param lastElapsedPos - Position of the last elapsed cue, `-1` before the first.
65
+ * @param isEnded - Whether playback is over.
66
+ * @returns The group holding that cue. Once playback has ended, the last group
67
+ * whatever the timecode says — which is what `isEnded` is for. The first group
68
+ * before anything has elapsed.
69
+ */
70
+ export function toCurrentGroup(groups, lastElapsedPos, isEnded) {
71
+ if (isEnded === true)
72
+ return groups[groups.length - 1];
73
+ if (lastElapsedPos < 0)
74
+ return groups[0];
75
+ return groups.find(group => group.startPos <= lastElapsedPos && lastElapsedPos <= group.endPos);
76
+ }
@@ -26,6 +26,10 @@ export type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & WithClassName<
26
26
  * A stable auto-generated `id` is created on mount and used to associate the
27
27
  * rendered label through the `htmlFor` attribute.
28
28
  *
29
+ * ### Root element modifiers
30
+ * - `--controlled` — the value comes from the parent, not from inside.
31
+ * - `--empty` / `--filled` — whether the current value is the empty string.
32
+ *
29
33
  * ### CSS elements
30
34
  * - `label`
31
35
  * - `error`
@@ -15,6 +15,10 @@ import cssModule from './styles.module.css';
15
15
  * A stable auto-generated `id` is created on mount and used to associate the
16
16
  * rendered label through the `htmlFor` attribute.
17
17
  *
18
+ * ### Root element modifiers
19
+ * - `--controlled` — the value comes from the parent, not from inside.
20
+ * - `--empty` / `--filled` — whether the current value is the empty string.
21
+ *
18
22
  * ### CSS elements
19
23
  * - `label`
20
24
  * - `error`
@@ -53,6 +57,10 @@ export const Textarea = ({ label, error, autoHeight = false, value, defaultValue
53
57
  };
54
58
  // Rendering
55
59
  const c = clss(publicClassName, { cssModule });
56
- const rootClss = mergeClassNames(c(), className);
60
+ const rootClss = mergeClassNames(c(null, {
61
+ controlled: isControlled,
62
+ empty: currentValue === '',
63
+ filled: currentValue !== ''
64
+ }), className);
57
65
  return _jsxs(_Fragment, { children: [isNotFalsy(label) && _jsx("label", { className: c('label'), htmlFor: id, children: label }), _jsx("textarea", { ...rest, ref: textareaRef, id: id, className: rootClss, value: currentValue, onChange: handleChange }), isNotFalsy(error) && _jsx("span", { className: c('error'), children: error })] });
58
66
  };
@@ -69,6 +69,8 @@ type TrackData = {
69
69
  * seeks to the target right after, unless the time is controlled.
70
70
  * @property onIsPlayingChanged - Called once the playback state has changed.
71
71
  * @property onIsFullscreenChanged - Called once the fullscreen state has changed.
72
+ * @property onIsEndedChanged - Called after playback reached the end, and again
73
+ * once it left it — a seek back or a new play. Never on mount.
72
74
  * @property onIsLoudChanged - Called once the mute state has changed (`true` = unmuted).
73
75
  * @property onVolumeChanged - Called once the volume has changed (`0` to `1`).
74
76
  * @property onPlaybackRateChanged - Called once the playback rate has changed.
@@ -109,6 +111,7 @@ export type Props = PropsWithChildren<WithClassName<{
109
111
  onIsPlayingChanged?: (isPlaying: boolean) => void;
110
112
  onIsFullscreenChanged?: (isFullscreen: boolean) => void;
111
113
  onIsLoudChanged?: (isLoud: boolean) => void;
114
+ onIsEndedChanged?: (isEnded: boolean) => void;
112
115
  onVolumeChanged?: (volume: number) => void;
113
116
  onPlaybackRateChanged?: (playbackRate: number) => void;
114
117
  onCurrentTimeMsChanged?: (currentTimeMs: number) => void;
@@ -125,10 +128,12 @@ export type Props = PropsWithChildren<WithClassName<{
125
128
  * - `--play-on` / `--play-off` — reflects current playback state.
126
129
  * - `--fullscreen-on` / `--fullscreen-off` — reflects fullscreen state.
127
130
  * - `--loud` / `--muted` — reflects mute state.
131
+ * - `--ended` — playback reached the end and has not left it.
128
132
  *
129
133
  * ### Data attributes on the root element
130
134
  * - `data-play-on` — present (empty string) when playing.
131
135
  * - `data-play-off` — present (empty string) when paused.
136
+ * - `data-ended` — present (empty string) once playback reached the end.
132
137
  * - `data-fullscreen-on` — present (empty string) when in fullscreen.
133
138
  * - `data-fullscreen-off` — present (empty string) when not in fullscreen.
134
139
  * - `data-loud` — present (empty string) when unmuted.
@@ -141,8 +146,14 @@ export type Props = PropsWithChildren<WithClassName<{
141
146
  * - `data-total-time-ms` — total duration in milliseconds.
142
147
  *
143
148
  * ### CSS custom properties on the root element
144
- * - `--video-current-time-ratio` current / total ratio, fixed to 8 decimals.
145
- * Useful for driving progress-bar animations purely in CSS.
149
+ * Durations come as a `ms` time under the bare name and as a plain number under a
150
+ * `-raw` twin; ratios are unitless and have no twin.
151
+ * - `--lm-video-current-time` / `--lm-video-current-time-raw`
152
+ * - `--lm-video-total-time` / `--lm-video-total-time-raw`
153
+ * - `--lm-video-current-time-ratio` — how far through the media the playhead is.
154
+ * What a progress bar animates on, with no JavaScript of its own.
155
+ * - `--lm-video-volume-ratio`
156
+ * - `--lm-video-playback-rate` — a multiplier, not a ratio: `1` is normal speed.
146
157
  *
147
158
  * @param props - Component properties.
148
159
  * @see {@link Props}
@@ -17,10 +17,12 @@ import { forceExitFullscreen, forceFullscreen, forceLoud, forceMute, forcePause,
17
17
  * - `--play-on` / `--play-off` — reflects current playback state.
18
18
  * - `--fullscreen-on` / `--fullscreen-off` — reflects fullscreen state.
19
19
  * - `--loud` / `--muted` — reflects mute state.
20
+ * - `--ended` — playback reached the end and has not left it.
20
21
  *
21
22
  * ### Data attributes on the root element
22
23
  * - `data-play-on` — present (empty string) when playing.
23
24
  * - `data-play-off` — present (empty string) when paused.
25
+ * - `data-ended` — present (empty string) once playback reached the end.
24
26
  * - `data-fullscreen-on` — present (empty string) when in fullscreen.
25
27
  * - `data-fullscreen-off` — present (empty string) when not in fullscreen.
26
28
  * - `data-loud` — present (empty string) when unmuted.
@@ -33,19 +35,30 @@ import { forceExitFullscreen, forceFullscreen, forceLoud, forceMute, forcePause,
33
35
  * - `data-total-time-ms` — total duration in milliseconds.
34
36
  *
35
37
  * ### CSS custom properties on the root element
36
- * - `--video-current-time-ratio` current / total ratio, fixed to 8 decimals.
37
- * Useful for driving progress-bar animations purely in CSS.
38
+ * Durations come as a `ms` time under the bare name and as a plain number under a
39
+ * `-raw` twin; ratios are unitless and have no twin.
40
+ * - `--lm-video-current-time` / `--lm-video-current-time-raw`
41
+ * - `--lm-video-total-time` / `--lm-video-total-time-raw`
42
+ * - `--lm-video-current-time-ratio` — how far through the media the playhead is.
43
+ * What a progress bar animates on, with no JavaScript of its own.
44
+ * - `--lm-video-volume-ratio`
45
+ * - `--lm-video-playback-rate` — a multiplier, not a ratio: `1` is normal speed.
38
46
  *
39
47
  * @param props - Component properties.
40
48
  * @see {@link Props}
41
49
  * @returns A `<figure>` element containing the video, its controls, optional
42
50
  * subtitles.
43
51
  */
44
- export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pauseBtnContent, loudBtnContent, muteBtnContent, fullscreenBtnContent, play, fullscreen, mute, muted, volume = 1, playbackRate = 1, currentTimeMs: givenCurrentTimeMs, onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onTimelineClicked, onIsPlayingChanged, onIsFullscreenChanged, onIsLoudChanged, onVolumeChanged, onPlaybackRateChanged, onCurrentTimeMsChanged, onFullscreenChange, children, className, ...intrinsicVideoAttributes }) => {
52
+ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pauseBtnContent, loudBtnContent, muteBtnContent, fullscreenBtnContent, play, fullscreen, mute, muted, volume = 1, playbackRate = 1, currentTimeMs: givenCurrentTimeMs, onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, onTimelineClicked, onIsPlayingChanged, onIsFullscreenChanged, onIsLoudChanged, onIsEndedChanged, onVolumeChanged, onPlaybackRateChanged, onCurrentTimeMsChanged, onFullscreenChange, children, className, ...intrinsicVideoAttributes }) => {
45
53
  const videoRef = useRef(null);
46
54
  const [totalTime, setTotalTime] = useState(0);
47
55
  const totalTimeMs = useMemo(() => secondsToMs(totalTime), [totalTime]);
48
56
  const [internalCurrentTimeMs, setInternalCurrentTimeMs] = useState(0);
57
+ // Unlike play, mute and fullscreen, this one isn't a prop: nothing can *set* a
58
+ // media to ended, it is something the element reports. So it is read from the
59
+ // element, and the two events that turn it on and off are joined by a sync on
60
+ // every time update, which covers a seek away from the end.
61
+ const [isEnded, setIsEnded] = useState(false);
49
62
  const isTimeControlled = givenCurrentTimeMs !== undefined;
50
63
  // The parent owns the time as soon as it provides one, so that is what gets
51
64
  // displayed — not what the element reported one render later.
@@ -73,9 +86,18 @@ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pa
73
86
  const video = e.currentTarget;
74
87
  const newTimeMs = secondsToMs(video.currentTime);
75
88
  setInternalCurrentTimeMs(newTimeMs);
89
+ setIsEnded(video.ended);
76
90
  if (intrinsicVideoAttributes.onTimeUpdate !== undefined)
77
91
  intrinsicVideoAttributes.onTimeUpdate(e);
78
92
  }, [intrinsicVideoAttributes.onTimeUpdate]);
93
+ const handleEndedEvent = useCallback((e) => {
94
+ setIsEnded(true);
95
+ intrinsicVideoAttributes.onEnded?.(e);
96
+ }, [intrinsicVideoAttributes.onEnded]);
97
+ const handlePlayEvent = useCallback((e) => {
98
+ setIsEnded(false);
99
+ intrinsicVideoAttributes.onPlay?.(e);
100
+ }, [intrinsicVideoAttributes.onPlay]);
79
101
  // Custom action handlers
80
102
  const handlePlayButtonClick = useCallback((e) => {
81
103
  const wasPlaying = videoRef.current?.paused === false;
@@ -118,6 +140,7 @@ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pa
118
140
  const rootClss = mergeClassNames(c(null, {
119
141
  'play-on': isPlaying,
120
142
  'play-off': !isPlaying,
143
+ ended: isEnded,
121
144
  'fullscreen-on': isFullscreen,
122
145
  'fullscreen-off': !isFullscreen,
123
146
  'loud': isLoud,
@@ -129,6 +152,7 @@ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pa
129
152
  const rootAttributes = {
130
153
  'data-play-on': isPlaying ? '' : undefined,
131
154
  'data-play-off': !isPlaying ? '' : undefined,
155
+ 'data-ended': isEnded ? '' : undefined,
132
156
  'data-fullscreen-on': isFullscreen ? '' : undefined,
133
157
  'data-fullscreen-off': !isFullscreen ? '' : undefined,
134
158
  'data-loud': isLoud ? '' : undefined,
@@ -141,7 +165,13 @@ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pa
141
165
  'data-total-time-ms': totalTimeMs
142
166
  };
143
167
  const rootStyles = {
144
- [`--${publicClassName}-current-time-ratio`]: currentTimeRatio.toFixed(8)
168
+ '--lm-video-current-time': `${currentTimeMs}ms`,
169
+ '--lm-video-current-time-raw': `${currentTimeMs}`,
170
+ '--lm-video-total-time': `${totalTimeMs}ms`,
171
+ '--lm-video-total-time-raw': `${totalTimeMs}`,
172
+ '--lm-video-current-time-ratio': `${currentTimeRatio}`,
173
+ '--lm-video-volume-ratio': `${volume}`,
174
+ '--lm-video-playback-rate': `${playbackRate}`
145
175
  };
146
176
  const parsedSources = useMemo(() => {
147
177
  if (sources === undefined)
@@ -249,11 +279,12 @@ export const ControlledVideo = ({ sources, tracks, subtitles, playBtnContent, pa
249
279
  useChangeDispatch(isPlaying, onIsPlayingChanged);
250
280
  useChangeDispatch(isFullscreen, onIsFullscreenChanged);
251
281
  useChangeDispatch(isLoud, onIsLoudChanged);
282
+ useChangeDispatch(isEnded, onIsEndedChanged);
252
283
  useChangeDispatch(volume, onVolumeChanged);
253
284
  useChangeDispatch(playbackRate, onPlaybackRateChanged);
254
- 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, children: [parsedSources.map((source, index) => typeof source === 'string'
285
+ 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, children: [parsedSources.map((source, index) => typeof source === 'string'
255
286
  ? _jsx("source", { src: source }, index)
256
287
  : _jsx("source", { src: source.src, type: source.type }, index)), parsedTracks.map((track, index) => typeof track === 'string'
257
288
  ? _jsx("track", { src: track }, index)
258
- : _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 }), _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: formatTime(currentTimeMs, 'mm:ss:ms') }), _jsx("span", { className: totalTimeClss, children: formatTime(totalTimeMs, 'mm:ss:ms') }), _jsx("div", { className: timelineClss, onClick: handleTimelineClick })] }), subtitles !== undefined && _jsx(Subtitles, { ...subtitles, timecodeMs: currentTimeMs })] });
289
+ : _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 }), _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: formatTime(currentTimeMs, 'mm:ss:ms') }), _jsx("span", { className: totalTimeClss, children: formatTime(totalTimeMs, 'mm:ss:ms') }), _jsx("div", { className: timelineClss, onClick: handleTimelineClick })] }), subtitles !== undefined && _jsx(Subtitles, { ...subtitles, timecodeMs: currentTimeMs, isEnded: isEnded })] });
259
290
  };
@@ -1,17 +1,29 @@
1
1
  import { type FunctionComponent } from 'react';
2
+ import type { WithViewportObservation } from '../utils/types.js';
2
3
  import { type Props as ControlledProps } from './index.controlled.js';
3
4
  /**
4
5
  * Props for the {@link Video} component.
5
6
  *
6
7
  * Extends all ControlledVideo props except play, mute, fullscreen, volume, playbackRate, and their associated event handlers
7
- * @property autoPlayWhenVisible - When `true`, triggers playback the first time the
8
- * component intersects the viewport.
9
- * @property autoPauseWhenHidden - When `true`, pauses playback whenever the component
10
- * leaves the viewport.
11
- * @property autoLoudWhenVisible - When `true`, unmutes the video the first time the
12
- * component intersects the viewport.
13
- * @property autoMuteWhenHidden - When `true`, mutes the video whenever the component
14
- * leaves the viewport.
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.
15
27
  * @property currentTimeMs - When provided, hands ownership of the current time
16
28
  * (in milliseconds) to the parent, which is then responsible for updating it —
17
29
  * typically to scrub the video from scroll position. A controlled time implies a
@@ -23,11 +35,15 @@ import { type Props as ControlledProps } from './index.controlled.js';
23
35
  * @property children - React children rendered inside the `<video>` element itself
24
36
  * (e.g. fallback content).
25
37
  */
26
- export type Props = Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mute' | 'playbackRate'> & {
38
+ export type Props = WithViewportObservation<Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mute' | 'playbackRate'>> & {
27
39
  autoPlayWhenVisible?: boolean;
40
+ autoPlayOnceVisible?: boolean;
28
41
  autoPauseWhenHidden?: boolean;
42
+ autoPauseOnceHidden?: boolean;
29
43
  autoLoudWhenVisible?: boolean;
44
+ autoLoudOnceVisible?: boolean;
30
45
  autoMuteWhenHidden?: boolean;
46
+ autoMuteOnceHidden?: boolean;
31
47
  wrapperClassName?: string;
32
48
  };
33
49
  /**
@@ -39,5 +55,27 @@ export type Props = Omit<ControlledProps, 'play' | 'fullscreen' | 'volume' | 'mu
39
55
  * @see {@link Props}
40
56
  * @returns A `<figure>` element containing the video, its controls and optional
41
57
  * subtitles.
58
+ *
59
+ * @remarks
60
+ * **Audio-only media belongs here too**, carried by the same `<video>` element.
61
+ * Everything this component drives — play, mute, volume, rate, time, `ended` — is
62
+ * `HTMLMediaElement`, shared with `<audio>`; only fullscreen is video's own. And an
63
+ * `<audio>` tag can't be autoplayed: the muted-autoplay exemption browsers grant is
64
+ * for video, so an `<audio>` waits for a user gesture whatever its muted state.
65
+ * `autoPlayWhenVisible` on an audio file therefore needs `muted` and `playsInline`
66
+ * on a `<video>`, which is what this is. The one thing lost is the assistive-tech
67
+ * label: a screen reader announces a video player. Worth a `tag` prop the day that
68
+ * matters more than autoplay — not before.
69
+ *
70
+ * Each viewport-driven behaviour comes in two flavours: `…When…` fires on every
71
+ * crossing, `…Once…` only on the first one. A `…Once…` flag is armed by its own
72
+ * automatic trigger and by nothing else — pressing play does not spend the one
73
+ * automatic play the component still owed. Setting both flavours of the same
74
+ * behaviour is the same as setting the `…When…` one alone.
75
+ *
76
+ * Browsers refuse an unmuted `play()` outside a user gesture, so pairing an
77
+ * `autoLoud…` with an `autoPlay…` will usually have the playback rejected: the
78
+ * element stays paused while the controls believe otherwise. Autoplay muted, and
79
+ * leave unmuting to the reader.
42
80
  */
43
81
  export declare const Video: FunctionComponent<Props>;
@@ -4,7 +4,7 @@ import { clss } from '../../agnostic/css/clss/index.js';
4
4
  import { IntersectionObserverComponent } from '../IntersectionObserver/index.js';
5
5
  import { mergeClassNames } from '../utils/index.js';
6
6
  import { videoWrapper as publicClassName } from '../public-classnames.js';
7
- import { muteAttributeWorkaround } from './utils.js';
7
+ import { muteAttributeWorkaround, shouldRunAutoBehaviour } from './utils.js';
8
8
  import cssModule from './styles.module.css';
9
9
  import { ControlledVideo } from './index.controlled.js';
10
10
  /**
@@ -16,34 +16,72 @@ import { ControlledVideo } from './index.controlled.js';
16
16
  * @see {@link Props}
17
17
  * @returns A `<figure>` element containing the video, its controls and optional
18
18
  * subtitles.
19
+ *
20
+ * @remarks
21
+ * **Audio-only media belongs here too**, carried by the same `<video>` element.
22
+ * Everything this component drives — play, mute, volume, rate, time, `ended` — is
23
+ * `HTMLMediaElement`, shared with `<audio>`; only fullscreen is video's own. And an
24
+ * `<audio>` tag can't be autoplayed: the muted-autoplay exemption browsers grant is
25
+ * for video, so an `<audio>` waits for a user gesture whatever its muted state.
26
+ * `autoPlayWhenVisible` on an audio file therefore needs `muted` and `playsInline`
27
+ * on a `<video>`, which is what this is. The one thing lost is the assistive-tech
28
+ * label: a screen reader announces a video player. Worth a `tag` prop the day that
29
+ * matters more than autoplay — not before.
30
+ *
31
+ * Each viewport-driven behaviour comes in two flavours: `…When…` fires on every
32
+ * crossing, `…Once…` only on the first one. A `…Once…` flag is armed by its own
33
+ * automatic trigger and by nothing else — pressing play does not spend the one
34
+ * automatic play the component still owed. Setting both flavours of the same
35
+ * behaviour is the same as setting the `…When…` one alone.
36
+ *
37
+ * Browsers refuse an unmuted `play()` outside a user gesture, so pairing an
38
+ * `autoLoud…` with an `autoPlay…` will usually have the playback rejected: the
39
+ * element stays paused while the controls believe otherwise. Autoplay muted, and
40
+ * leave unmuting to the reader.
19
41
  */
20
- export const Video = ({ loop, autoPlayWhenVisible, autoPauseWhenHidden, autoMuteWhenHidden, autoLoudWhenVisible, wrapperClassName, onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, ...controlledProps }) => {
42
+ export const Video = ({ loop, autoPlayWhenVisible, autoPlayOnceVisible, autoPauseWhenHidden, autoPauseOnceHidden, autoMuteWhenHidden, autoMuteOnceHidden, autoLoudWhenVisible, autoLoudOnceVisible, threshold, root, rootMargin, onVisibilityChanged, wrapperClassName, onPlayButtonClicked, onPauseButtonClicked, onLoudButtonClicked, onMuteButtonClicked, onVolumeRangeChanged, onRateRangeChanged, onFullscreenButtonClicked, ...controlledProps }) => {
21
43
  // State & refs
22
44
  const [play, setPlay] = useState(false);
23
45
  const [volume, setVolume] = useState(1);
24
46
  const [mute, setMute] = useState(false);
25
47
  const [playbackRate, setPlaybackRate] = useState(1);
26
48
  const [fullscreen, setFullscreen] = useState(false);
27
- const hasBeenAutoPlayed = useRef(false);
49
+ // One flag per `…Once…` behaviour, and each is armed by that behaviour's own
50
+ // automatic trigger. A single shared flag conflated four questions, and being set
51
+ // on any `play` event — a user click included — meant the first press cancelled
52
+ // behaviours that had nothing to do with playback.
53
+ const hasAutoPlayedOnce = useRef(false);
54
+ const hasAutoPausedOnce = useRef(false);
55
+ const hasAutoLoudedOnce = useRef(false);
56
+ const hasAutoMutedOnce = useRef(false);
28
57
  // Several paths below ask for playback — the play button, autoPlayWhenVisible,
29
58
  // autoPlay itself. None of them may win over a parent-owned time, so the
30
59
  // invariant is applied where the state is forwarded rather than guarded at each
31
60
  // of those call sites.
32
61
  const isTimeControlled = controlledProps.currentTimeMs !== undefined;
33
- const needsObserve = useMemo(() => autoLoudWhenVisible === true
62
+ const needsObserve = useMemo(() => onVisibilityChanged !== undefined
63
+ || autoLoudWhenVisible === true
64
+ || autoLoudOnceVisible === true
34
65
  || autoMuteWhenHidden === true
66
+ || autoMuteOnceHidden === true
35
67
  || autoPlayWhenVisible === true
36
- || autoPauseWhenHidden === true, [
68
+ || autoPlayOnceVisible === true
69
+ || autoPauseWhenHidden === true
70
+ || autoPauseOnceHidden === true, [
37
71
  autoLoudWhenVisible,
72
+ autoLoudOnceVisible,
38
73
  autoMuteWhenHidden,
74
+ autoMuteOnceHidden,
39
75
  autoPlayWhenVisible,
40
- autoPauseWhenHidden
76
+ autoPlayOnceVisible,
77
+ autoPauseWhenHidden,
78
+ autoPauseOnceHidden,
79
+ onVisibilityChanged
41
80
  ]);
42
81
  // Intrisic event handlers
43
82
  const handleOnPlayEvent = useCallback((e) => {
44
83
  controlledProps.onPlay?.(e);
45
84
  setPlay(true);
46
- hasBeenAutoPlayed.current = true;
47
85
  }, [controlledProps.onPlay]);
48
86
  const handleOnPauseEvent = useCallback((e) => {
49
87
  setPlay(false);
@@ -100,28 +138,41 @@ export const Video = ({ loop, autoPlayWhenVisible, autoPauseWhenHidden, autoMute
100
138
  if (ioEntry === undefined)
101
139
  return;
102
140
  const { isIntersecting } = ioEntry;
103
- if (autoPauseWhenHidden === true && !isIntersecting)
141
+ onVisibilityChanged?.(isIntersecting);
142
+ if (isIntersecting) {
143
+ if (shouldRunAutoBehaviour(autoPlayWhenVisible, autoPlayOnceVisible, hasAutoPlayedOnce.current)) {
144
+ hasAutoPlayedOnce.current = true;
145
+ setPlay(true);
146
+ }
147
+ if (shouldRunAutoBehaviour(autoLoudWhenVisible, autoLoudOnceVisible, hasAutoLoudedOnce.current)) {
148
+ hasAutoLoudedOnce.current = true;
149
+ setMute(false);
150
+ }
151
+ return;
152
+ }
153
+ if (shouldRunAutoBehaviour(autoPauseWhenHidden, autoPauseOnceHidden, hasAutoPausedOnce.current)) {
154
+ hasAutoPausedOnce.current = true;
104
155
  setPlay(false);
105
- if (autoLoudWhenVisible === true && isIntersecting)
106
- setMute(false);
107
- if (autoPlayWhenVisible === true
108
- && !hasBeenAutoPlayed.current
109
- && isIntersecting)
110
- setPlay(true);
111
- if (autoMuteWhenHidden === true
112
- && !hasBeenAutoPlayed.current
113
- && !isIntersecting)
156
+ }
157
+ if (shouldRunAutoBehaviour(autoMuteWhenHidden, autoMuteOnceHidden, hasAutoMutedOnce.current)) {
158
+ hasAutoMutedOnce.current = true;
114
159
  setMute(true);
160
+ }
115
161
  }, [
116
162
  autoPlayWhenVisible,
163
+ autoPlayOnceVisible,
117
164
  autoPauseWhenHidden,
165
+ autoPauseOnceHidden,
118
166
  autoMuteWhenHidden,
119
- autoLoudWhenVisible
167
+ autoMuteOnceHidden,
168
+ autoLoudWhenVisible,
169
+ autoLoudOnceVisible,
170
+ onVisibilityChanged
120
171
  ]);
121
172
  // `autoPlay` is forwarded to the element, but the play state is owned here, so
122
173
  // it has to be seeded once on mount for the controls to agree with the element.
123
174
  useEffect(() => {
124
- if (controlledProps.autoPlay === true && !hasBeenAutoPlayed.current)
175
+ if (controlledProps.autoPlay === true)
125
176
  setPlay(true);
126
177
  }, []);
127
178
  // Render
@@ -129,6 +180,6 @@ export const Video = ({ loop, autoPlayWhenVisible, autoPauseWhenHidden, autoMute
129
180
  const rootClss = mergeClassNames(c(), wrapperClassName);
130
181
  const videoContent = _jsx(ControlledVideo, { ...controlledProps, play: play && !isTimeControlled, volume: volume, mute: mute, playbackRate: playbackRate, fullscreen: fullscreen, onPlay: handleOnPlayEvent, onPause: handleOnPauseEvent, onVolumeChange: handleOnVolumeChangeEvent, onRateChange: handleOnRateChangeEvent, onLoadedMetadata: handleOnLoadedMetadataEvent, onFullscreenChange: handleFullscreenChange, onPlayButtonClicked: handlePlayButtonClick, onPauseButtonClicked: handlePauseButtonClick, onLoudButtonClicked: handleLoudButtonClick, onMuteButtonClicked: handleMuteButtonClick, onVolumeRangeChanged: handleVolumeRangeChange, onRateRangeChanged: handleRateRangeChange, onFullscreenButtonClicked: handleFullscreenButtonClick });
131
182
  return _jsx("div", { className: rootClss, children: needsObserve
132
- ? _jsx(IntersectionObserverComponent, { onIntersected: onIntersected, children: videoContent })
183
+ ? _jsx(IntersectionObserverComponent, { threshold: threshold, root: root, rootMargin: rootMargin, onIntersected: onIntersected, children: videoContent })
133
184
  : videoContent });
134
185
  };
@@ -45,3 +45,17 @@ export declare function formatTime(ms: number, format: string, fps?: number): st
45
45
  * @returns A ratio between `0` and `1`, clamped to the element's own bounds.
46
46
  */
47
47
  export declare const getTimelineClickProgress: (event: React.MouseEvent<HTMLDivElement>) => number;
48
+ /**
49
+ * Whether a viewport-driven behaviour should run on this crossing.
50
+ *
51
+ * Each behaviour comes in two flavours: `…When…` on every crossing, `…Once…` on the
52
+ * first one only. Setting both is the same as setting `…When…` alone.
53
+ *
54
+ * @param whenCrossed - The `…When…` prop.
55
+ * @param onceOnly - The `…Once…` prop.
56
+ * @param hasFired - Whether this behaviour's own automatic trigger already ran. It
57
+ * tracks that behaviour and nothing else: a user pressing play must not spend an
58
+ * automatic mute the component still owed.
59
+ * @returns Whether to apply it now.
60
+ */
61
+ export declare const shouldRunAutoBehaviour: (whenCrossed: boolean | undefined, onceOnly: boolean | undefined, hasFired: boolean) => boolean;
@@ -160,3 +160,21 @@ export const getTimelineClickProgress = (event) => {
160
160
  const position = event.clientX - timelineRect.left;
161
161
  return Math.min(1, Math.max(0, position / timelineRect.width));
162
162
  };
163
+ /**
164
+ * Whether a viewport-driven behaviour should run on this crossing.
165
+ *
166
+ * Each behaviour comes in two flavours: `…When…` on every crossing, `…Once…` on the
167
+ * first one only. Setting both is the same as setting `…When…` alone.
168
+ *
169
+ * @param whenCrossed - The `…When…` prop.
170
+ * @param onceOnly - The `…Once…` prop.
171
+ * @param hasFired - Whether this behaviour's own automatic trigger already ran. It
172
+ * tracks that behaviour and nothing else: a user pressing play must not spend an
173
+ * automatic mute the component still owed.
174
+ * @returns Whether to apply it now.
175
+ */
176
+ export const shouldRunAutoBehaviour = (whenCrossed, onceOnly, hasFired) => {
177
+ if (whenCrossed === true)
178
+ return true;
179
+ return onceOnly === true && !hasFired;
180
+ };
@@ -10,16 +10,16 @@ export * as image from './Image/index.js'
10
10
  export * as input from './Input/index.js'
11
11
  export * as intersectionObserver from './IntersectionObserver/index.js'
12
12
  export * as jsonEditor from './JsonEditor/index.js'
13
- export * as listLoader from './ListLoader/index.js'
14
13
  export * as overlayer from './Overlayer/index.js'
15
- export * as paginator from './Paginator/index.js'
16
- export * as resizeObserver from './ResizeObserver/index.js'
17
- export * as scrllgngn from './Scrllgngn/index.js'
14
+ export * as listLoader from './ListLoader/index.js'
18
15
  export * as scrollListener from './ScrollListener/index.js'
19
- export * as select from './Select/index.js'
16
+ export * as paginator from './Paginator/index.js'
20
17
  export * as sequencer from './Sequencer/index.js'
18
+ export * as select from './Select/index.js'
19
+ export * as scrllgngn from './Scrllgngn/index.js'
21
20
  export * as shadowRoot from './ShadowRoot/index.js'
22
21
  export * as subtitles from './Subtitles/index.js'
22
+ export * as resizeObserver from './ResizeObserver/index.js'
23
23
  export * as textarea from './Textarea/index.js'
24
24
  export * as theatre from './Theatre/index.js'
25
25
  export * as uiModule from './UIModule/index.js'
@@ -10,16 +10,16 @@ export * as image from './Image/index.js'
10
10
  export * as input from './Input/index.js'
11
11
  export * as intersectionObserver from './IntersectionObserver/index.js'
12
12
  export * as jsonEditor from './JsonEditor/index.js'
13
- export * as listLoader from './ListLoader/index.js'
14
13
  export * as overlayer from './Overlayer/index.js'
15
- export * as paginator from './Paginator/index.js'
16
- export * as resizeObserver from './ResizeObserver/index.js'
17
- export * as scrllgngn from './Scrllgngn/index.js'
14
+ export * as listLoader from './ListLoader/index.js'
18
15
  export * as scrollListener from './ScrollListener/index.js'
19
- export * as select from './Select/index.js'
16
+ export * as paginator from './Paginator/index.js'
20
17
  export * as sequencer from './Sequencer/index.js'
18
+ export * as select from './Select/index.js'
19
+ export * as scrllgngn from './Scrllgngn/index.js'
21
20
  export * as shadowRoot from './ShadowRoot/index.js'
22
21
  export * as subtitles from './Subtitles/index.js'
22
+ export * as resizeObserver from './ResizeObserver/index.js'
23
23
  export * as textarea from './Textarea/index.js'
24
24
  export * as theatre from './Theatre/index.js'
25
25
  export * as uiModule from './UIModule/index.js'
@@ -6,3 +6,35 @@
6
6
  export type WithClassName<T extends Record<string, unknown>> = T & {
7
7
  className?: string | Array<string | null | undefined>;
8
8
  };
9
+ /**
10
+ * The settings of an {@link IntersectionObserver} a component mounts internally.
11
+ *
12
+ * A component that offers viewport-driven behaviour has to offer its dial too:
13
+ * `autoPlayWhenVisible` means little if "visible" can't be told to mean *thirty
14
+ * percent* visible, and an infinite list wants to start loading before its sentinel
15
+ * shows, which is `rootMargin`'s job.
16
+ *
17
+ * They mirror `ObserverOptions` of the `IntersectionObserver` component, where they
18
+ * end up. Restated here rather than imported from it: that component reads
19
+ * {@link WithClassName} from this very file, and the two shouldn't import each other.
20
+ */
21
+ export type ViewportObserverOptions = {
22
+ threshold?: number | number[];
23
+ root?: HTMLElement;
24
+ rootMargin?: string;
25
+ };
26
+ /**
27
+ * Extends a type with an internal observer's settings and with what it sees.
28
+ *
29
+ * For a component observing **one** element — itself, usually. A component with
30
+ * several observers takes {@link ViewportObserverOptions} alone, since a single
31
+ * visibility handler couldn't say which of them crossed.
32
+ *
33
+ * `Scrllgngn` deliberately stays out of both: its observers take a `rootMargin`
34
+ * derived from its `viewportOffset*` props, which are its own version of the dial.
35
+ *
36
+ * @template T - The base type to extend.
37
+ */
38
+ export type WithViewportObservation<T extends Record<string, unknown>> = T & ViewportObserverOptions & {
39
+ onVisibilityChanged?: (isVisible: boolean) => void;
40
+ };
package/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export * as agnostic from './agnostic/index.js'
2
- export * as components from './components/index.js'
3
2
  export * as node from './node/index.js'
3
+ export * as components from './components/index.js'