@playkit-js/hotspots 3.2.35 → 3.2.36-canary.0-5dda03a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -36,7 +36,10 @@ type Props = {
|
|
|
36
36
|
pauseVideo(): void;
|
|
37
37
|
seekTo(time: number): void;
|
|
38
38
|
sendAnalytics(event: AnalyticsEvents): void;
|
|
39
|
-
dispatcher(name: string, payload?: any): void
|
|
39
|
+
dispatcher(name: string, payload?: any): void;
|
|
40
|
+
tabIndex?: number;
|
|
41
|
+
onKeyDown?(e: KeyboardEvent): void;
|
|
42
|
+
onFocus?(): void;
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
type State = {
|
|
@@ -166,6 +169,14 @@ export default class Hotspot extends Component<Props, State> {
|
|
|
166
169
|
}
|
|
167
170
|
};
|
|
168
171
|
|
|
172
|
+
handleInnerKeyDown = (e: KeyboardEvent) => {
|
|
173
|
+
// A11yWrapper overwrites onKeyDown and only handles Arrow keys, Enter, and Space,
|
|
174
|
+
// so this is attached via onKeyDownCapture to keep Home and End working.
|
|
175
|
+
if ((e.key === 'Home' || e.key === 'End') && this.props.onKeyDown) {
|
|
176
|
+
this.props.onKeyDown(e);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
169
180
|
render() {
|
|
170
181
|
const {hotspot} = this.props;
|
|
171
182
|
const {layout, label} = hotspot;
|
|
@@ -192,9 +203,11 @@ export default class Hotspot extends Component<Props, State> {
|
|
|
192
203
|
borderRadius: `${hotspot.relativeStyle.radiusBorder}px`,
|
|
193
204
|
};
|
|
194
205
|
|
|
206
|
+
const tabIndex = typeof this.props.tabIndex === 'number' ? this.props.tabIndex : 0;
|
|
207
|
+
|
|
195
208
|
return (
|
|
196
|
-
<A11yWrapper onClick={this.handleClick}>
|
|
197
|
-
<div ref={(ref) => (this.hotspotRef = ref)} tabIndex={
|
|
209
|
+
<A11yWrapper onClick={this.handleClick} onUpKeyPressed={this.props.onKeyDown} onDownKeyPressed={this.props.onKeyDown} onLeftKeyPressed={this.props.onKeyDown} onRightKeyPressed={this.props.onKeyDown}>
|
|
210
|
+
<div ref={(ref) => (this.hotspotRef = ref)} tabIndex={tabIndex} role="button" aria-label={label} aria-disabled={disableClick} style={containerStyles} data-testid="hotspots_hotspot" data-hotspot-id={hotspot.id} onKeyDownCapture={this.handleInnerKeyDown} onFocus={this.props.onFocus}>
|
|
198
211
|
<div style={buttonStyles}>{label}</div>
|
|
199
212
|
</div>
|
|
200
213
|
</A11yWrapper>
|
|
@@ -15,6 +15,16 @@ const translates = {
|
|
|
15
15
|
<Text id="hotspots.hotspot_added" fields={{ hotspotLabel: '' }}>
|
|
16
16
|
{`{hotspotLabel} hotspot added`}
|
|
17
17
|
</Text>
|
|
18
|
+
),
|
|
19
|
+
shortcutHint: (
|
|
20
|
+
<Text id="hotspots.hotspots_shortcut_hint">
|
|
21
|
+
{'Press Shift+Alt+H to jump to hotspots'}
|
|
22
|
+
</Text>
|
|
23
|
+
),
|
|
24
|
+
toolbarLabel: (
|
|
25
|
+
<Text id="hotspots.hotspots_toolbar_label">
|
|
26
|
+
{'Interactive hotspots'}
|
|
27
|
+
</Text>
|
|
18
28
|
)
|
|
19
29
|
};
|
|
20
30
|
|
|
@@ -36,28 +46,407 @@ export interface Props {
|
|
|
36
46
|
dispatcher(name: string, payload?: any): void;
|
|
37
47
|
hotspotRemoved?: string;
|
|
38
48
|
hotspotAdded?: string;
|
|
49
|
+
shortcutHint?: string;
|
|
50
|
+
toolbarLabel?: string;
|
|
39
51
|
player?: any;
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
interface State {
|
|
55
|
+
activeIndex: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
@withPlayer
|
|
43
59
|
@withText(translates)
|
|
44
|
-
export default class HotspotWrapper extends Component<Props> {
|
|
60
|
+
export default class HotspotWrapper extends Component<Props, State> {
|
|
61
|
+
state: State = {
|
|
62
|
+
activeIndex: 0
|
|
63
|
+
};
|
|
45
64
|
|
|
46
65
|
private liveRegionRef = createRef<HTMLDivElement>();
|
|
66
|
+
private containerRef = createRef<HTMLDivElement>();
|
|
47
67
|
private previousHotspotMap: Map<string, string> = new Map();
|
|
68
|
+
private lastFocusedHotspotId: string | null = null;
|
|
69
|
+
private previouslyFocusedElement: HTMLElement | null = null;
|
|
70
|
+
private wasHotspotFocused: boolean = false;
|
|
71
|
+
private focusTimers: number[] = [];
|
|
72
|
+
private blurCheckTimer: number | null = null;
|
|
73
|
+
private focusRequestOrigin: Element | null = null;
|
|
74
|
+
|
|
75
|
+
componentDidMount() {
|
|
76
|
+
window.addEventListener('keydown', this.handleGlobalKeyDown);
|
|
77
|
+
if (this.props.player) {
|
|
78
|
+
const endedEvent = this.props.player.Event?.ENDED || 'ended';
|
|
79
|
+
this.props.player.addEventListener?.(endedEvent, this.onPlayerEnded);
|
|
80
|
+
}
|
|
81
|
+
const container = this.containerRef.current;
|
|
82
|
+
if (container) {
|
|
83
|
+
container.addEventListener('focusin', this.handleContainerFocusIn);
|
|
84
|
+
container.addEventListener('focusout', this.handleContainerFocusOut);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
componentWillUnmount() {
|
|
89
|
+
window.removeEventListener('keydown', this.handleGlobalKeyDown);
|
|
90
|
+
if (this.props.player) {
|
|
91
|
+
const endedEvent = this.props.player.Event?.ENDED || 'ended';
|
|
92
|
+
this.props.player.removeEventListener?.(endedEvent, this.onPlayerEnded);
|
|
93
|
+
}
|
|
94
|
+
const container = this.containerRef.current;
|
|
95
|
+
if (container) {
|
|
96
|
+
container.removeEventListener('focusin', this.handleContainerFocusIn);
|
|
97
|
+
container.removeEventListener('focusout', this.handleContainerFocusOut);
|
|
98
|
+
}
|
|
99
|
+
this.clearFocusTimers();
|
|
100
|
+
if (this.blurCheckTimer !== null) {
|
|
101
|
+
clearTimeout(this.blurCheckTimer);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private clearFocusTimers() {
|
|
106
|
+
this.focusTimers.forEach(id => clearTimeout(id));
|
|
107
|
+
this.focusTimers = [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private beginFocusRequest() {
|
|
111
|
+
this.clearFocusTimers();
|
|
112
|
+
this.focusRequestOrigin = document.activeElement;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Retries may only run while focus is where the request started, on body (element removed), or inside the hotspots.
|
|
116
|
+
private canContinueFocusRetry(): boolean {
|
|
117
|
+
const active = document.activeElement;
|
|
118
|
+
return (
|
|
119
|
+
!active ||
|
|
120
|
+
active === this.focusRequestOrigin ||
|
|
121
|
+
active === document.body ||
|
|
122
|
+
active === document.documentElement ||
|
|
123
|
+
!!this.containerRef.current?.contains(active)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private releaseFocusRestoration() {
|
|
128
|
+
this.wasHotspotFocused = false;
|
|
129
|
+
this.lastFocusedHotspotId = null;
|
|
130
|
+
this.previouslyFocusedElement = null;
|
|
131
|
+
this.clearFocusTimers();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private scheduleFocusTimer(callback: () => void, delay: number) {
|
|
135
|
+
const id = window.setTimeout(() => {
|
|
136
|
+
this.focusTimers = this.focusTimers.filter(t => t !== id);
|
|
137
|
+
callback();
|
|
138
|
+
}, delay);
|
|
139
|
+
this.focusTimers.push(id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
shouldComponentUpdate(nextProps: Props, nextState: State) {
|
|
143
|
+
return (
|
|
144
|
+
this.state.activeIndex !== nextState.activeIndex ||
|
|
145
|
+
this.props.hotspotAdded !== nextProps.hotspotAdded ||
|
|
146
|
+
this.props.hotspotRemoved !== nextProps.hotspotRemoved ||
|
|
147
|
+
this.props.shortcutHint !== nextProps.shortcutHint ||
|
|
148
|
+
this.props.toolbarLabel !== nextProps.toolbarLabel ||
|
|
149
|
+
!shallowCompareHotspots(this.props.hotspots, nextProps.hotspots)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private onPlayerEnded = () => {
|
|
154
|
+
if (this.wasHotspotFocused) {
|
|
155
|
+
this.wasHotspotFocused = false;
|
|
156
|
+
this.lastFocusedHotspotId = null;
|
|
157
|
+
this.previouslyFocusedElement = null;
|
|
158
|
+
this.returnFocusToPlayPause(true);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
private getPlayerContainer(): HTMLElement | null {
|
|
163
|
+
const targetId = this.props.player?.config?.targetId;
|
|
164
|
+
return targetId ? document.getElementById(targetId) : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
168
|
+
const isHKey = e.key === 'H' || e.key === 'h' || e.code === 'KeyH';
|
|
169
|
+
if (!e.shiftKey || !e.altKey || !isHKey) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const activeEl = document.activeElement as HTMLElement | null;
|
|
174
|
+
if (!activeEl) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const playerContainer = this.getPlayerContainer();
|
|
179
|
+
if (!playerContainer || !playerContainer.contains(activeEl)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const isEditable =
|
|
184
|
+
activeEl.tagName === 'INPUT' ||
|
|
185
|
+
activeEl.tagName === 'TEXTAREA' ||
|
|
186
|
+
activeEl.tagName === 'SELECT' ||
|
|
187
|
+
activeEl.isContentEditable;
|
|
48
188
|
|
|
49
|
-
|
|
50
|
-
|
|
189
|
+
if (isEditable) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const {hotspots} = this.props;
|
|
194
|
+
if (!hotspots || hotspots.length === 0) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
this.props.player?.ui?.store?.dispatch?.({type: 'shell/UPDATE_PLAYER_NAV_STATE', nav: true});
|
|
202
|
+
this.props.player?.ui?.store?.dispatch?.({type: 'shell/UPDATE_PLAYER_HOVER_STATE', hover: true});
|
|
203
|
+
} catch (err) {
|
|
204
|
+
// ignore if store is unavailable
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const targetIndex = this.state.activeIndex >= 0 && this.state.activeIndex < hotspots.length ? this.state.activeIndex : 0;
|
|
208
|
+
this.focusHotspotByIndex(targetIndex);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
private focusHotspotByIndex(index: number, retryCount = 0) {
|
|
212
|
+
if (retryCount === 0) {
|
|
213
|
+
this.beginFocusRequest();
|
|
214
|
+
} else if (!this.canContinueFocusRetry()) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const container = this.containerRef.current;
|
|
218
|
+
if (!container) return;
|
|
219
|
+
const targetId = this.props.hotspots?.[index]?.id;
|
|
220
|
+
if (!targetId) return;
|
|
221
|
+
const hotspotEl = Array.from(container.querySelectorAll<HTMLElement>('[data-testid="hotspots_hotspot"]')).find(
|
|
222
|
+
el => el.dataset.hotspotId === targetId
|
|
223
|
+
);
|
|
224
|
+
if (hotspotEl) {
|
|
225
|
+
this.setState({ activeIndex: index }, () => {
|
|
226
|
+
hotspotEl.focus();
|
|
227
|
+
});
|
|
228
|
+
} else if (retryCount < 5) {
|
|
229
|
+
// Hotspot components mount asynchronously before setting isReady: true.
|
|
230
|
+
// Retry in the next frames/tick if the replacement DOM element is not yet rendered.
|
|
231
|
+
this.scheduleFocusTimer(() => this.focusHotspotByIndex(index, retryCount + 1), 30);
|
|
232
|
+
}
|
|
51
233
|
}
|
|
52
|
-
|
|
234
|
+
|
|
235
|
+
private handleContainerFocusIn = (e: FocusEvent) => {
|
|
236
|
+
const fromEl = e.relatedTarget as HTMLElement | null;
|
|
237
|
+
const container = this.containerRef.current;
|
|
238
|
+
if (fromEl && container && !container.contains(fromEl) && fromEl !== document.body) {
|
|
239
|
+
this.previouslyFocusedElement = fromEl;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const target = e.target as HTMLElement | null;
|
|
243
|
+
if (!target) return;
|
|
244
|
+
const hotspotEl = target.closest<HTMLElement>('[data-testid="hotspots_hotspot"]');
|
|
245
|
+
if (hotspotEl && this.containerRef.current) {
|
|
246
|
+
const id = hotspotEl.dataset.hotspotId;
|
|
247
|
+
const index = id && this.props.hotspots ? this.props.hotspots.findIndex(h => h.id === id) : -1;
|
|
248
|
+
if (index !== -1) {
|
|
249
|
+
this.lastFocusedHotspotId = id!;
|
|
250
|
+
this.wasHotspotFocused = true;
|
|
251
|
+
if (this.state.activeIndex !== index) {
|
|
252
|
+
this.setState({ activeIndex: index });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
private handleContainerFocusOut = (e: FocusEvent) => {
|
|
259
|
+
const related = e.relatedTarget as HTMLElement | null;
|
|
260
|
+
const container = this.containerRef.current;
|
|
261
|
+
// Clear stored focus state if focus intentionally moved to any real element outside the hotspot container.
|
|
262
|
+
// Retain wasHotspotFocused only when related is null or document.body (which happens on DOM removal).
|
|
263
|
+
if (related && related !== document.body && related !== document.documentElement && container && !container.contains(related)) {
|
|
264
|
+
this.releaseFocusRestoration();
|
|
265
|
+
} else if (!related || related === document.body || related === document.documentElement) {
|
|
266
|
+
// A null relatedTarget is also sent when a still-mounted hotspot is blurred by clicking non-focusable content.
|
|
267
|
+
// Defer so DOM removal (element detached) can be told apart from an intentional blur.
|
|
268
|
+
const target = e.target as Node;
|
|
269
|
+
if (this.blurCheckTimer !== null) {
|
|
270
|
+
clearTimeout(this.blurCheckTimer);
|
|
271
|
+
}
|
|
272
|
+
this.blurCheckTimer = window.setTimeout(() => {
|
|
273
|
+
this.blurCheckTimer = null;
|
|
274
|
+
const active = document.activeElement;
|
|
275
|
+
const focusInContainer = !!active && !!this.containerRef.current?.contains(active);
|
|
276
|
+
if (target.isConnected && !focusInContainer) {
|
|
277
|
+
this.releaseFocusRestoration();
|
|
278
|
+
}
|
|
279
|
+
}, 0);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
private handleHotspotKeyDown = (e: KeyboardEvent, index: number) => {
|
|
284
|
+
const {hotspots} = this.props;
|
|
285
|
+
if (!hotspots || hotspots.length === 0) return;
|
|
286
|
+
|
|
287
|
+
let nextIndex = index;
|
|
288
|
+
|
|
289
|
+
switch (e.key) {
|
|
290
|
+
case 'ArrowRight':
|
|
291
|
+
case 'ArrowDown':
|
|
292
|
+
nextIndex = (index + 1) % hotspots.length;
|
|
293
|
+
break;
|
|
294
|
+
case 'ArrowLeft':
|
|
295
|
+
case 'ArrowUp':
|
|
296
|
+
nextIndex = (index - 1 + hotspots.length) % hotspots.length;
|
|
297
|
+
break;
|
|
298
|
+
case 'Home':
|
|
299
|
+
nextIndex = 0;
|
|
300
|
+
break;
|
|
301
|
+
case 'End':
|
|
302
|
+
nextIndex = hotspots.length - 1;
|
|
303
|
+
break;
|
|
304
|
+
default:
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
e.stopPropagation();
|
|
310
|
+
this.focusHotspotByIndex(nextIndex);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
private handleHotspotFocus = (id: string, index: number) => {
|
|
314
|
+
this.lastFocusedHotspotId = id;
|
|
315
|
+
this.wasHotspotFocused = true;
|
|
316
|
+
if (this.state.activeIndex !== index) {
|
|
317
|
+
this.setState({ activeIndex: index });
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
private findReplayOrPlayButton(playerContainer: HTMLElement): HTMLElement | null {
|
|
322
|
+
// 1. Center play/replay button (shown on start or after video end)
|
|
323
|
+
const centerBtn = playerContainer.querySelector<HTMLElement>(
|
|
324
|
+
'.playkit-pre-playback-play-button button, .playkit-pre-playback-play-button, [data-testid="pre_playback_play_button"]'
|
|
325
|
+
);
|
|
326
|
+
if (centerBtn) {
|
|
327
|
+
return centerBtn;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 2. Control bar play/pause button (standard in Playkit UI)
|
|
331
|
+
const playPauseBtn = playerContainer.querySelector<HTMLElement>(
|
|
332
|
+
'.playkit-control-play-pause button, .playkit-control-play-pause [tabindex="0"]'
|
|
333
|
+
);
|
|
334
|
+
if (playPauseBtn) {
|
|
335
|
+
return playPauseBtn;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 3. Fallback: First visible button in bottom bar or player container
|
|
339
|
+
const fallback = playerContainer.querySelector<HTMLElement>(
|
|
340
|
+
'.playkit-bottom-bar button, button'
|
|
341
|
+
);
|
|
342
|
+
return fallback;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private returnFocusToPlayPause(forceReplay = false) {
|
|
346
|
+
this.beginFocusRequest();
|
|
347
|
+
const isPlaybackEnded = forceReplay || !!this.props.player?.ended;
|
|
348
|
+
if (isPlaybackEnded) {
|
|
349
|
+
this.previouslyFocusedElement = null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const attemptFocus = () => {
|
|
353
|
+
const playerContainer = this.getPlayerContainer();
|
|
354
|
+
|
|
355
|
+
// 1. If playback is NOT ended and we have a previously focused element still attached inside the player, restore focus to it
|
|
356
|
+
if (!isPlaybackEnded && this.previouslyFocusedElement && playerContainer?.contains(this.previouslyFocusedElement)) {
|
|
357
|
+
const prev = this.previouslyFocusedElement;
|
|
358
|
+
this.previouslyFocusedElement = null;
|
|
359
|
+
prev.focus();
|
|
360
|
+
if (document.activeElement === prev) {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 2. Otherwise find the Replay / Play-Pause button in the player
|
|
366
|
+
if (!playerContainer) return false;
|
|
367
|
+
|
|
368
|
+
const btn = this.findReplayOrPlayButton(playerContainer);
|
|
369
|
+
if (btn) {
|
|
370
|
+
// Trigger hover on player so control bar becomes visible if hidden
|
|
371
|
+
try {
|
|
372
|
+
this.props.player?.ui?.store?.dispatch?.({type: 'shell/UPDATE_PLAYER_HOVER_STATE', hover: true});
|
|
373
|
+
} catch (e) {
|
|
374
|
+
// ignore if store is unavailable
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
btn.focus();
|
|
378
|
+
return document.activeElement === btn;
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
if (!attemptFocus()) {
|
|
384
|
+
this.scheduleFocusTimer(() => {
|
|
385
|
+
if (!this.canContinueFocusRetry()) return;
|
|
386
|
+
if (!attemptFocus()) {
|
|
387
|
+
this.scheduleFocusTimer(() => {
|
|
388
|
+
if (!this.canContinueFocusRetry()) return;
|
|
389
|
+
if (!attemptFocus()) {
|
|
390
|
+
this.scheduleFocusTimer(() => {
|
|
391
|
+
if (this.canContinueFocusRetry()) attemptFocus();
|
|
392
|
+
}, 150);
|
|
393
|
+
}
|
|
394
|
+
}, 70);
|
|
395
|
+
}
|
|
396
|
+
}, 30);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
componentDidUpdate(prevProps: Props) {
|
|
401
|
+
const currentHotspots = this.props.hotspots || [];
|
|
402
|
+
const prevHotspots = prevProps.hotspots || [];
|
|
403
|
+
|
|
53
404
|
const currentMap = new Map<string, string>(
|
|
54
|
-
|
|
405
|
+
currentHotspots
|
|
55
406
|
.filter(h => typeof h.label === 'string')
|
|
56
407
|
.map(h => [h.id, h.label!])
|
|
57
408
|
);
|
|
409
|
+
const currentIds = new Set<string>(currentHotspots.map(h => h.id));
|
|
410
|
+
|
|
411
|
+
// Focus Return on Dismissal/Expiration
|
|
412
|
+
const activeEl = document.activeElement as HTMLElement | null;
|
|
413
|
+
const container = this.containerRef.current;
|
|
414
|
+
const isFocusCurrentlyInHotspots = container && activeEl ? container.contains(activeEl) : false;
|
|
415
|
+
|
|
416
|
+
if (this.wasHotspotFocused || isFocusCurrentlyInHotspots) {
|
|
417
|
+
const focusedId = this.lastFocusedHotspotId;
|
|
418
|
+
const isFocusedHotspotGone = focusedId ? !currentIds.has(focusedId) : false;
|
|
419
|
+
|
|
420
|
+
if (isFocusedHotspotGone || currentHotspots.length === 0) {
|
|
421
|
+
if (currentHotspots.length > 0) {
|
|
422
|
+
const fallbackIndex = Math.min(this.state.activeIndex, currentHotspots.length - 1);
|
|
423
|
+
this.focusHotspotByIndex(Math.max(0, fallbackIndex));
|
|
424
|
+
} else {
|
|
425
|
+
this.lastFocusedHotspotId = null;
|
|
426
|
+
this.wasHotspotFocused = false;
|
|
427
|
+
const player = this.props.player;
|
|
428
|
+
const isPlaybackEnded = !!player?.ended || (typeof player?.duration === 'number' && player?.duration > 0 && player?.currentTime >= player?.duration - 0.5);
|
|
429
|
+
if (isPlaybackEnded) {
|
|
430
|
+
this.previouslyFocusedElement = null;
|
|
431
|
+
}
|
|
432
|
+
this.returnFocusToPlayPause(isPlaybackEnded);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Preserve the active hotspot by ID when the collection changes (e.g. earlier cue removed or inserted)
|
|
438
|
+
if (this.lastFocusedHotspotId && currentIds.has(this.lastFocusedHotspotId)) {
|
|
439
|
+
const newIndex = currentHotspots.findIndex(h => h.id === this.lastFocusedHotspotId);
|
|
440
|
+
if (newIndex !== -1 && newIndex !== this.state.activeIndex) {
|
|
441
|
+
this.setState({ activeIndex: newIndex });
|
|
442
|
+
}
|
|
443
|
+
} else if (currentHotspots.length > 0 && this.state.activeIndex >= currentHotspots.length) {
|
|
444
|
+
// Ensure activeIndex stays in valid bounds
|
|
445
|
+
this.setState({ activeIndex: currentHotspots.length - 1 });
|
|
446
|
+
}
|
|
58
447
|
|
|
59
448
|
let announced = false;
|
|
60
|
-
|
|
449
|
+
|
|
61
450
|
// Announce removed hotspots
|
|
62
451
|
this.previousHotspotMap.forEach((label, id) => {
|
|
63
452
|
if (!currentMap.has(id) && !announced) {
|
|
@@ -74,7 +463,8 @@ export default class HotspotWrapper extends Component<Props> {
|
|
|
74
463
|
currentMap.forEach((label, id) => {
|
|
75
464
|
if (!this.previousHotspotMap.has(id) && !announced) {
|
|
76
465
|
if (this.props.hotspotAdded) {
|
|
77
|
-
const
|
|
466
|
+
const baseMessage = this.props.hotspotAdded.replace('{hotspotLabel}', label);
|
|
467
|
+
const message = this.props.shortcutHint ? `${baseMessage}. ${this.props.shortcutHint}` : baseMessage;
|
|
78
468
|
this.announceHotspotChange(message);
|
|
79
469
|
}
|
|
80
470
|
announced = true;
|
|
@@ -102,8 +492,10 @@ export default class HotspotWrapper extends Component<Props> {
|
|
|
102
492
|
}
|
|
103
493
|
|
|
104
494
|
const {seekTo, pauseVideo, sendAnalytics, dispatcher} = this.props;
|
|
105
|
-
|
|
106
|
-
|
|
495
|
+
const {activeIndex} = this.state;
|
|
496
|
+
|
|
497
|
+
return visualHotspot.map((hotspotData, index) => (
|
|
498
|
+
<Hotspot dispatcher={dispatcher} pauseVideo={pauseVideo} seekTo={seekTo} key={hotspotData.id} visible={true} hotspot={hotspotData} sendAnalytics={sendAnalytics} tabIndex={index === activeIndex ? 0 : -1} onKeyDown={(e) => this.handleHotspotKeyDown(e, index)} onFocus={() => this.handleHotspotFocus(hotspotData.id, index)} />
|
|
107
499
|
));
|
|
108
500
|
};
|
|
109
501
|
|
|
@@ -112,9 +504,10 @@ export default class HotspotWrapper extends Component<Props> {
|
|
|
112
504
|
const hotspotsElements = this.renderHotspots(hotspots);
|
|
113
505
|
const targetId = this.props.player?.config?.targetId;
|
|
114
506
|
const liveRegionId = `hotspot-liveRegion-${targetId}`;
|
|
507
|
+
const hasHotspots = hotspots && hotspots.length > 0;
|
|
115
508
|
|
|
116
509
|
return (
|
|
117
|
-
<div style={hotspotsContainerStyles} data-testid="hotspots_hotspotsContainer">
|
|
510
|
+
<div ref={this.containerRef} style={hotspotsContainerStyles} data-testid="hotspots_hotspotsContainer" role={hasHotspots ? 'toolbar' : undefined} aria-label={hasHotspots ? this.props.toolbarLabel : undefined}>
|
|
118
511
|
{hotspotsElements}
|
|
119
512
|
<div
|
|
120
513
|
ref={this.liveRegionRef}
|
package/src/hotspots-plugin.tsx
CHANGED
|
@@ -206,7 +206,7 @@ export class HotspotsPlugin extends KalturaPlayer.core.BasePlugin {
|
|
|
206
206
|
this._hotspots = this._recalculateCuepointLayout(this._hotspots);
|
|
207
207
|
}
|
|
208
208
|
return (
|
|
209
|
-
<HotspotWrapper
|
|
209
|
+
<HotspotWrapper player={this._player} dispatcher={(eventType, payload) => this.dispatchEvent(eventType, payload)} key={'hotspotWrapper'} hotspots={this._hotspots} pauseVideo={this._pauseVideo} seekTo={this._seekTo} sendAnalytics={() => {}} />
|
|
210
210
|
);
|
|
211
211
|
};
|
|
212
212
|
|