vidply 1.2.0 → 1.2.3

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 (167) hide show
  1. package/README.md +974 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
  23. package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
  24. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
  25. package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
  26. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
  27. package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
  28. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
  30. package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
  32. package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
  34. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
  35. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
  36. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
  37. package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
  38. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
  39. package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
  41. package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
  43. package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +648 -35
  45. package/dist/dev/vidply.esm.js.map +3 -3
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
  47. package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
  49. package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
  50. package/dist/legacy/vidply.js +1062 -34
  51. package/dist/legacy/vidply.js.map +3 -3
  52. package/dist/legacy/vidply.min.js +2 -2
  53. package/dist/legacy/vidply.min.meta.json +127 -55
  54. package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
  66. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
  67. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
  69. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
  72. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
  75. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
  81. package/dist/vidply.css +5852 -5796
  82. package/dist/vidply.esm.min.meta.json +204 -149
  83. package/dist/vidply.min.css +1 -1
  84. package/package.json +11 -11
  85. package/src/controls/CaptionManager.ts +598 -598
  86. package/src/controls/ControlBar.ts +4191 -4159
  87. package/src/controls/KeyboardHelp.ts +326 -0
  88. package/src/controls/KeyboardManager.ts +387 -383
  89. package/src/controls/SettingsDialog.ts +488 -488
  90. package/src/controls/TranscriptManager.ts +2426 -2426
  91. package/src/core/AudioDescriptionManager.ts +863 -774
  92. package/src/core/DescriptionSpeechManager.ts +232 -0
  93. package/src/core/FloatingPlayerManager.ts +674 -674
  94. package/src/core/LazyInit.ts +80 -80
  95. package/src/core/MediaSessionManager.ts +341 -0
  96. package/src/core/MetadataAlertsManager.ts +514 -514
  97. package/src/core/Player.ts +2973 -2872
  98. package/src/core/PosterManager.ts +169 -169
  99. package/src/core/PseudoFullscreen.ts +179 -179
  100. package/src/core/ResponsiveManager.ts +231 -231
  101. package/src/core/ResumeManager.ts +236 -236
  102. package/src/core/SignLanguageManager.ts +1308 -1308
  103. package/src/core/ThemeManager.ts +157 -157
  104. package/src/features/PlaylistManager.ts +1838 -1827
  105. package/src/i18n/i18n.ts +223 -223
  106. package/src/i18n/languages/de.ts +244 -217
  107. package/src/i18n/languages/en.ts +245 -218
  108. package/src/i18n/languages/es.ts +241 -214
  109. package/src/i18n/languages/fr.ts +241 -214
  110. package/src/i18n/languages/ja.ts +241 -214
  111. package/src/icons/Icons.ts +168 -167
  112. package/src/index.ts +196 -193
  113. package/src/renderers/DASHRenderer.ts +1077 -1077
  114. package/src/renderers/HLSRenderer.ts +944 -944
  115. package/src/renderers/HTML5Renderer.ts +389 -389
  116. package/src/renderers/SoundCloudRenderer.ts +437 -437
  117. package/src/renderers/VimeoRenderer.ts +298 -298
  118. package/src/renderers/YouTubeRenderer.ts +290 -290
  119. package/src/styles/vidply.css +5852 -5796
  120. package/src/types/events.ts +128 -122
  121. package/src/types/globals.d.ts +313 -313
  122. package/src/types/options.ts +199 -182
  123. package/src/types/renderer.ts +58 -58
  124. package/src/utils/DOMUtils.ts +281 -281
  125. package/src/utils/DownloadInfo.ts +171 -171
  126. package/src/utils/DraggablePanel.ts +539 -539
  127. package/src/utils/DraggablePanelMenu.ts +195 -195
  128. package/src/utils/DraggableResizable.ts +997 -997
  129. package/src/utils/EventEmitter.ts +59 -59
  130. package/src/utils/FormUtils.ts +95 -95
  131. package/src/utils/MenuFactory.ts +483 -483
  132. package/src/utils/Sanitize.ts +70 -70
  133. package/src/utils/SettingsMenuFactory.ts +289 -289
  134. package/src/utils/StorageManager.ts +287 -287
  135. package/src/utils/TrackLabelUtils.ts +52 -52
  136. package/src/utils/UrlSafe.ts +69 -69
  137. package/src/utils/VideoFrameCapture.ts +114 -114
  138. package/src/utils/WindowComponents.ts +250 -250
  139. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  140. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  141. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  142. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  143. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  144. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  145. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  146. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  147. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  148. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  149. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  151. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  152. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  153. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  154. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  155. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  156. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  157. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  158. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  159. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  160. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  161. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  162. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
  163. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
  165. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
  166. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
  167. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-PRFNESYA.js.map} +0 -0
@@ -1,539 +1,539 @@
1
- /**
2
- * Owns the settings-menu lifecycle for a `DraggableResizable`-powered
3
- * floating panel: create, show, hide, outside-click dismissal, keyboard
4
- * navigation, viewport-aware positioning, and the drag-mode / resize-
5
- * mode toggle items.
6
- *
7
- * `SignLanguageManager` and `TranscriptManager` both present the same
8
- * pattern: a settings button that opens a menu containing a "Keyboard
9
- * drag mode" toggle, a "Resize mode" toggle, manager-specific extras,
10
- * and a close item. This class captures that pattern so each manager
11
- * only has to describe:
12
- * - how to resolve its `DraggableResizable` (for state readback),
13
- * - which i18n keys to use on the toggle items,
14
- * - where to place the menu in its own layout (via `getMenuParent`),
15
- * - how to toggle each mode (`onDragItemClick` / `onResizeItemClick`
16
- * run the manager's side effects — announcements, badges, focus),
17
- * - any extra menu items (`buildExtraItems`, e.g. style dialog,
18
- * timestamp toggle for TranscriptManager).
19
- *
20
- * The manager's own public API (`showSettingsMenu`, `toggleKeyboardDragMode`,
21
- * `hideSettingsMenu`, ...) is preserved by forwarding to this panel.
22
- * External code that reads `settingsMenuVisible` or the option-button
23
- * elements keeps working via getters on the manager that proxy the
24
- * panel's state.
25
- *
26
- * Event listeners attached to `document` are tied to the player's
27
- * `lifecycleSignal` so they are torn down automatically when the
28
- * player is destroyed.
29
- */
30
-
31
- import { DOMUtils } from './DOMUtils.js';
32
- import { i18n } from '../i18n/i18n.js';
33
- import {
34
- attachMenuKeyboardNavigation,
35
- createMenuItem,
36
- focusFirstMenuItem,
37
- } from './MenuUtils.js';
38
- import {
39
- positionSettingsMenu,
40
- positionSettingsMenuDeferred,
41
- updateToggleMenuItem,
42
- } from './DraggablePanelMenu.js';
43
-
44
- import type { Player } from '../core/Player.js';
45
- import type { DraggableResizable } from './DraggableResizable.js';
46
-
47
- /**
48
- * i18n keys used for the drag-mode and resize-mode toggle items and
49
- * the "Close" item. All values must be translation keys (passed
50
- * through `i18n.t()` by `createMenuItem`/`updateToggleMenuItem`).
51
- */
52
- export interface DraggablePanelI18nKeys {
53
- enableDrag: string;
54
- disableDrag: string;
55
- enableDragAria: string;
56
- disableDragAria: string;
57
- enableResize: string;
58
- disableResize: string;
59
- enableResizeAria: string;
60
- disableResizeAria: string;
61
- closeMenu: string;
62
- }
63
-
64
- export interface DraggablePanelExtrasContext {
65
- menu: HTMLElement;
66
- itemClass: string;
67
- classPrefix: string;
68
- /**
69
- * Strip the tooltip and duplicate `button-text` nodes from a menu
70
- * item. `createMenuItem` is shared with toolbar buttons that want a
71
- * hover tooltip; settings-menu rows already show the same text
72
- * inline, so the tooltip would cause screen readers to read the
73
- * label twice. Exposed here so manager-provided extras can reuse
74
- * the same stripping logic the panel applies to its own items.
75
- */
76
- stripInlineTooltip: (item: HTMLElement) => void;
77
- }
78
-
79
- export interface DraggablePanelOptions {
80
- player: Player;
81
- /**
82
- * Namespace for CSS classes, e.g. `'sign-language'` or `'transcript'`.
83
- * The final menu class is `{classPrefix}-{namespace}-settings-menu`
84
- * and item class is `{classPrefix}-{namespace}-settings-item`.
85
- */
86
- namespace: string;
87
- /** The button that opens the menu. */
88
- settingsButton: HTMLElement;
89
- /** Lazy draggable lookup (it may be re-created by the manager). */
90
- getDraggable: () => DraggableResizable | null;
91
- i18nKeys: DraggablePanelI18nKeys;
92
- /**
93
- * Horizontal anchoring of the menu relative to the settings button.
94
- * - `'left'` : align left edge (Transcript — header-row button).
95
- * - `'center'` : centre horizontally with viewport clamping (Sign
96
- * language — floating near viewport edges).
97
- */
98
- menuAlign: 'left' | 'center';
99
- /** Where to attach the menu node when first created. */
100
- getMenuParent: () => HTMLElement | null;
101
- /**
102
- * Called when the drag-mode item is clicked. The manager is
103
- * expected to toggle drag mode and run any side effects
104
- * (announcements, badges, focus). After this runs the panel will
105
- * refresh menu item state.
106
- */
107
- onDragItemClick: (panel: DraggablePanel) => void;
108
- /** Same for the resize-mode item. */
109
- onResizeItemClick: (panel: DraggablePanel) => void;
110
- /**
111
- * Insert manager-specific items (style dialog, timestamps, ...)
112
- * between the resize item and the close item.
113
- */
114
- buildExtraItems?: (ctx: DraggablePanelExtrasContext) => void;
115
- /** Optional: vertical gap (px) between button and menu. */
116
- menuGap?: number;
117
- /** Optional: breathing room (px) before the menu flips above. */
118
- menuSpaceReserve?: number;
119
- /**
120
- * Where to attach the mode-feedback badge while keyboard drag /
121
- * pointer resize mode is active. If omitted, {@link DraggablePanel.showBadge}
122
- * is a no-op (the manager effectively opts out of the badge).
123
- */
124
- getBadgeHost?: () => HTMLElement | null;
125
- /**
126
- * CSS class for the badge element. Defaults to
127
- * `{classPrefix}-{namespace}-mode-badge`. Sign-language passes a
128
- * shorter class (`vidply-sign-mode-badge`) to preserve existing
129
- * CSS. Transcript uses the default.
130
- */
131
- badgeClass?: string;
132
- }
133
-
134
- export class DraggablePanel {
135
- private readonly opts: DraggablePanelOptions;
136
-
137
- /** Populated lazily on first `show()`. */
138
- settingsMenu: HTMLElement | null = null;
139
- settingsMenuVisible = false;
140
- dragOptionButton: HTMLElement | null = null;
141
- dragOptionText: Element | null = null;
142
- resizeOptionButton: HTMLElement | null = null;
143
- resizeOptionText: Element | null = null;
144
-
145
- private _justOpened = false;
146
- private _justOpenedTimer: ReturnType<typeof setTimeout> | null = null;
147
- private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
148
- private _documentClick: ((e: MouseEvent) => void) | null = null;
149
- private _documentClickAdded = false;
150
- private _modeBadge: HTMLElement | null = null;
151
-
152
- constructor(opts: DraggablePanelOptions) {
153
- this.opts = opts;
154
- }
155
-
156
- /** True while the just-opened debounce window (prevents the same
157
- * click that opened the menu from also closing it via document
158
- * `mousedown` / `click`). */
159
- get justOpened(): boolean {
160
- return this._justOpened;
161
- }
162
-
163
- get classPrefix(): string {
164
- return this.opts.player.options.classPrefix;
165
- }
166
-
167
- get menuClass(): string {
168
- return `${this.classPrefix}-${this.opts.namespace}-settings-menu`;
169
- }
170
-
171
- get itemClass(): string {
172
- return `${this.classPrefix}-${this.opts.namespace}-settings-item`;
173
- }
174
-
175
- /**
176
- * Show the menu. First call creates the DOM; subsequent calls reuse
177
- * it. Refreshes menu item state from the current draggable.
178
- */
179
- show(): void {
180
- this._markJustOpened(350);
181
- this._ensureDocumentClickHandler();
182
-
183
- if (this.settingsMenu) {
184
- this.settingsMenu.style.display = 'block';
185
- this.settingsMenuVisible = true;
186
- this.opts.settingsButton.setAttribute('aria-expanded', 'true');
187
- this._attachKeyboardNavigation();
188
- this._positionImmediate();
189
- this.refreshState();
190
- focusFirstMenuItem(this.settingsMenu, `.${this.itemClass}`);
191
- return;
192
- }
193
-
194
- this._createMenu();
195
- }
196
-
197
- /**
198
- * Hide the menu. By default returns focus to the settings button;
199
- * callers can opt out when the next interaction should land
200
- * elsewhere (e.g. on the wrapper after enabling drag mode).
201
- */
202
- hide({ focusButton = true }: { focusButton?: boolean } = {}): void {
203
- if (!this.settingsMenu) return;
204
-
205
- this.settingsMenu.style.display = 'none';
206
- this.settingsMenuVisible = false;
207
- this._clearJustOpened();
208
-
209
- if (this._keyHandler) {
210
- this.settingsMenu.removeEventListener('keydown', this._keyHandler, true);
211
- this._keyHandler = null;
212
- }
213
-
214
- const items = this.settingsMenu.querySelectorAll<HTMLElement>(`.${this.itemClass}`);
215
- items.forEach((item) => item.setAttribute('tabindex', '-1'));
216
-
217
- const { settingsButton } = this.opts;
218
- settingsButton.setAttribute('aria-expanded', 'false');
219
- if (focusButton) {
220
- settingsButton.focus({ preventScroll: true });
221
- }
222
- }
223
-
224
- toggle(): void {
225
- if (this.settingsMenuVisible) {
226
- this.hide();
227
- } else {
228
- this.show();
229
- }
230
- }
231
-
232
- /** Set a short "just opened" guard so the document-click handler
233
- * attached for outside-dismissal ignores the originating click. */
234
- markJustOpenedForClick(): void {
235
- if (this._documentClick) {
236
- this._markJustOpened(100);
237
- }
238
- }
239
-
240
- /** Refresh the drag and resize toggle item state from the draggable. */
241
- refreshState(): void {
242
- this.refreshDragState();
243
- this.refreshResizeState();
244
- }
245
-
246
- refreshDragState(): void {
247
- const draggable = this.opts.getDraggable();
248
- updateToggleMenuItem(this.dragOptionButton, this.dragOptionText, {
249
- enabled: Boolean(draggable?.keyboardDragMode),
250
- enabledText: i18n.t(this.opts.i18nKeys.disableDrag),
251
- disabledText: i18n.t(this.opts.i18nKeys.enableDrag),
252
- enabledAria: i18n.t(this.opts.i18nKeys.disableDragAria),
253
- disabledAria: i18n.t(this.opts.i18nKeys.enableDragAria),
254
- });
255
- }
256
-
257
- refreshResizeState(): void {
258
- const draggable = this.opts.getDraggable();
259
- updateToggleMenuItem(this.resizeOptionButton, this.resizeOptionText, {
260
- enabled: Boolean(draggable?.pointerResizeMode),
261
- enabledText: i18n.t(this.opts.i18nKeys.disableResize),
262
- disabledText: i18n.t(this.opts.i18nKeys.enableResize),
263
- enabledAria: i18n.t(this.opts.i18nKeys.disableResizeAria),
264
- disabledAria: i18n.t(this.opts.i18nKeys.enableResizeAria),
265
- });
266
- }
267
-
268
- /**
269
- * Show a persistent mode-feedback badge (e.g. "Drag mode: arrow
270
- * keys to move, Esc to exit") anchored to the host element
271
- * returned by `getBadgeHost`. Replaces any previous badge. The
272
- * badge is a real DOM element (not a CSS pseudo-element) so its
273
- * text is translatable, selectable, visible under high-contrast
274
- * themes, and reflected in browser translation overlays.
275
- *
276
- * Marked `aria-hidden` because the accompanying live-region
277
- * announcement (the manager's responsibility) already conveys the
278
- * state change to assistive tech.
279
- */
280
- showBadge(text: string): void {
281
- const host = this.opts.getBadgeHost?.();
282
- if (!host) return;
283
- this.hideBadge();
284
-
285
- const className =
286
- this.opts.badgeClass ??
287
- `${this.classPrefix}-${this.opts.namespace}-mode-badge`;
288
- const badge = DOMUtils.createElement('span', {
289
- className,
290
- textContent: text,
291
- attributes: { 'aria-hidden': 'true' },
292
- });
293
- host.appendChild(badge);
294
- this._modeBadge = badge;
295
- }
296
-
297
- /** Remove the mode-feedback badge if one is showing. */
298
- hideBadge(): void {
299
- if (this._modeBadge && this._modeBadge.parentNode) {
300
- this._modeBadge.remove();
301
- }
302
- this._modeBadge = null;
303
- }
304
-
305
- /** RAF-deferred reposition (e.g. after a panel resize). */
306
- reposition(): void {
307
- positionSettingsMenuDeferred(this.settingsMenu, this.opts.settingsButton, {
308
- align: this.opts.menuAlign,
309
- gap: this.opts.menuGap ?? 4,
310
- spaceReserve: this.opts.menuSpaceReserve ?? 20,
311
- });
312
- }
313
-
314
- /**
315
- * Tear down any DOM/listeners owned by this panel. Safe to call
316
- * multiple times. Callers must still drop their own references.
317
- */
318
- destroy(): void {
319
- if (this._justOpenedTimer) {
320
- clearTimeout(this._justOpenedTimer);
321
- this._justOpenedTimer = null;
322
- }
323
- this._justOpened = false;
324
-
325
- this.hideBadge();
326
-
327
- if (this.settingsMenu) {
328
- if (this._keyHandler) {
329
- this.settingsMenu.removeEventListener('keydown', this._keyHandler, true);
330
- }
331
- this.settingsMenu.remove();
332
- this.settingsMenu = null;
333
- }
334
-
335
- this._keyHandler = null;
336
- this.settingsMenuVisible = false;
337
- this.dragOptionButton = null;
338
- this.dragOptionText = null;
339
- this.resizeOptionButton = null;
340
- this.resizeOptionText = null;
341
-
342
- // The document-click listener was attached with the player's
343
- // lifecycleSignal, so it is cleaned up automatically. We clear
344
- // the local reference so a later show() re-installs.
345
- this._documentClick = null;
346
- this._documentClickAdded = false;
347
- }
348
-
349
- private _createMenu(): void {
350
- const { player, settingsButton, i18nKeys } = this.opts;
351
-
352
- const menu = DOMUtils.createElement('div', {
353
- className: this.menuClass,
354
- attributes: { role: 'menu' },
355
- });
356
- this.settingsMenu = menu;
357
-
358
- const dragOption = createMenuItem({
359
- classPrefix: this.classPrefix,
360
- itemClass: this.itemClass,
361
- icon: 'move',
362
- label: i18nKeys.enableDrag,
363
- hasTextClass: true,
364
- onClick: (event) => {
365
- event.preventDefault();
366
- event.stopPropagation();
367
- this.opts.onDragItemClick(this);
368
- this.refreshState();
369
- },
370
- });
371
- dragOption.setAttribute('role', 'switch');
372
- dragOption.setAttribute('aria-checked', 'false');
373
- dragOption.setAttribute('data-setting', 'keyboard-drag');
374
- this._stripInlineTooltip(dragOption);
375
- this.dragOptionButton = dragOption;
376
- this.dragOptionText = dragOption.querySelector(`.${this.classPrefix}-settings-text`);
377
-
378
- const resizeOption = createMenuItem({
379
- classPrefix: this.classPrefix,
380
- itemClass: this.itemClass,
381
- icon: 'resize',
382
- label: i18nKeys.enableResize,
383
- hasTextClass: true,
384
- onClick: (event) => {
385
- event.preventDefault();
386
- event.stopPropagation();
387
- this.opts.onResizeItemClick(this);
388
- this.refreshState();
389
- },
390
- });
391
- resizeOption.setAttribute('role', 'switch');
392
- resizeOption.setAttribute('aria-checked', 'false');
393
- this._stripInlineTooltip(resizeOption);
394
- this.resizeOptionButton = resizeOption;
395
- this.resizeOptionText = resizeOption.querySelector(`.${this.classPrefix}-settings-text`);
396
-
397
- menu.appendChild(dragOption);
398
- menu.appendChild(resizeOption);
399
-
400
- if (this.opts.buildExtraItems) {
401
- this.opts.buildExtraItems({
402
- menu,
403
- itemClass: this.itemClass,
404
- classPrefix: this.classPrefix,
405
- stripInlineTooltip: (item) => this._stripInlineTooltip(item),
406
- });
407
- }
408
-
409
- const closeOption = createMenuItem({
410
- classPrefix: this.classPrefix,
411
- itemClass: this.itemClass,
412
- icon: 'close',
413
- label: i18nKeys.closeMenu,
414
- onClick: () => this.hide(),
415
- });
416
- this._stripInlineTooltip(closeOption);
417
- menu.appendChild(closeOption);
418
-
419
- menu.style.visibility = 'hidden';
420
- menu.style.display = 'block';
421
-
422
- const parent = this.opts.getMenuParent();
423
- if (settingsButton.parentNode) {
424
- settingsButton.insertAdjacentElement('afterend', menu);
425
- } else if (parent) {
426
- parent.appendChild(menu);
427
- }
428
-
429
- this._positionImmediate();
430
-
431
- requestAnimationFrame(() => {
432
- if (this.settingsMenu) {
433
- this.settingsMenu.style.visibility = 'visible';
434
- }
435
- });
436
-
437
- this._attachKeyboardNavigation();
438
-
439
- this.settingsMenuVisible = true;
440
- settingsButton.setAttribute('aria-expanded', 'true');
441
- this.refreshState();
442
-
443
- focusFirstMenuItem(menu, `.${this.itemClass}`);
444
-
445
- // Don't reveal the fact that we created the menu to callers that
446
- // didn't trigger show() explicitly — but this is always driven
447
- // by a show() so the `player` reference is just for
448
- // documentation clarity here.
449
- void player;
450
- }
451
-
452
- private _attachKeyboardNavigation(): void {
453
- const menu = this.settingsMenu;
454
- if (!menu) return;
455
- if (this._keyHandler) {
456
- menu.removeEventListener('keydown', this._keyHandler, true);
457
- }
458
- const handler = attachMenuKeyboardNavigation(
459
- menu,
460
- this.opts.settingsButton,
461
- `.${this.itemClass}`,
462
- () => this.hide({ focusButton: true })
463
- );
464
- this._keyHandler = handler ?? null;
465
- }
466
-
467
- private _positionImmediate(): void {
468
- positionSettingsMenu(this.settingsMenu, this.opts.settingsButton, {
469
- align: this.opts.menuAlign,
470
- gap: this.opts.menuGap ?? 4,
471
- spaceReserve: this.opts.menuSpaceReserve ?? 20,
472
- });
473
- }
474
-
475
- /**
476
- * Remove tooltip and duplicate button-text nodes from a menu item.
477
- * `createMenuItem` is used both for toolbar buttons (which want a
478
- * tooltip) and for settings-menu rows (which show the same text
479
- * inline). This strips the duplicated pieces so screen readers
480
- * don't read the label twice.
481
- */
482
- private _stripInlineTooltip(item: HTMLElement): void {
483
- const tooltip = item.querySelector(`.${this.classPrefix}-tooltip`);
484
- if (tooltip) tooltip.remove();
485
- const buttonText = item.querySelector(`.${this.classPrefix}-button-text`);
486
- if (buttonText) buttonText.remove();
487
- }
488
-
489
- private _markJustOpened(durationMs: number): void {
490
- this._justOpened = true;
491
- if (this._justOpenedTimer) {
492
- clearTimeout(this._justOpenedTimer);
493
- }
494
- this._justOpenedTimer = setTimeout(() => {
495
- this._justOpened = false;
496
- this._justOpenedTimer = null;
497
- }, durationMs);
498
- }
499
-
500
- private _clearJustOpened(): void {
501
- this._justOpened = false;
502
- if (this._justOpenedTimer) {
503
- clearTimeout(this._justOpenedTimer);
504
- this._justOpenedTimer = null;
505
- }
506
- }
507
-
508
- private _ensureDocumentClickHandler(): void {
509
- if (this._documentClickAdded) return;
510
-
511
- this._documentClick = (event: MouseEvent) => {
512
- if (this._justOpened) return;
513
- const target = event.target as Node | null;
514
- const { settingsButton } = this.opts;
515
-
516
- if (settingsButton === target || (target && settingsButton.contains(target))) {
517
- return;
518
- }
519
- if (this.settingsMenu && target && this.settingsMenu.contains(target)) {
520
- return;
521
- }
522
- if (this.settingsMenuVisible) {
523
- this.hide();
524
- }
525
- };
526
-
527
- // Delay attach so the click that opened the menu doesn't instantly
528
- // close it even if the justOpened flag somehow races.
529
- setTimeout(() => {
530
- const handler = this._documentClick;
531
- if (!handler) return;
532
- document.addEventListener('mousedown', handler, {
533
- capture: true,
534
- signal: this.opts.player.lifecycleSignal,
535
- });
536
- this._documentClickAdded = true;
537
- }, 300);
538
- }
539
- }
1
+ /**
2
+ * Owns the settings-menu lifecycle for a `DraggableResizable`-powered
3
+ * floating panel: create, show, hide, outside-click dismissal, keyboard
4
+ * navigation, viewport-aware positioning, and the drag-mode / resize-
5
+ * mode toggle items.
6
+ *
7
+ * `SignLanguageManager` and `TranscriptManager` both present the same
8
+ * pattern: a settings button that opens a menu containing a "Keyboard
9
+ * drag mode" toggle, a "Resize mode" toggle, manager-specific extras,
10
+ * and a close item. This class captures that pattern so each manager
11
+ * only has to describe:
12
+ * - how to resolve its `DraggableResizable` (for state readback),
13
+ * - which i18n keys to use on the toggle items,
14
+ * - where to place the menu in its own layout (via `getMenuParent`),
15
+ * - how to toggle each mode (`onDragItemClick` / `onResizeItemClick`
16
+ * run the manager's side effects — announcements, badges, focus),
17
+ * - any extra menu items (`buildExtraItems`, e.g. style dialog,
18
+ * timestamp toggle for TranscriptManager).
19
+ *
20
+ * The manager's own public API (`showSettingsMenu`, `toggleKeyboardDragMode`,
21
+ * `hideSettingsMenu`, ...) is preserved by forwarding to this panel.
22
+ * External code that reads `settingsMenuVisible` or the option-button
23
+ * elements keeps working via getters on the manager that proxy the
24
+ * panel's state.
25
+ *
26
+ * Event listeners attached to `document` are tied to the player's
27
+ * `lifecycleSignal` so they are torn down automatically when the
28
+ * player is destroyed.
29
+ */
30
+
31
+ import { DOMUtils } from './DOMUtils.js';
32
+ import { i18n } from '../i18n/i18n.js';
33
+ import {
34
+ attachMenuKeyboardNavigation,
35
+ createMenuItem,
36
+ focusFirstMenuItem,
37
+ } from './MenuUtils.js';
38
+ import {
39
+ positionSettingsMenu,
40
+ positionSettingsMenuDeferred,
41
+ updateToggleMenuItem,
42
+ } from './DraggablePanelMenu.js';
43
+
44
+ import type { Player } from '../core/Player.js';
45
+ import type { DraggableResizable } from './DraggableResizable.js';
46
+
47
+ /**
48
+ * i18n keys used for the drag-mode and resize-mode toggle items and
49
+ * the "Close" item. All values must be translation keys (passed
50
+ * through `i18n.t()` by `createMenuItem`/`updateToggleMenuItem`).
51
+ */
52
+ export interface DraggablePanelI18nKeys {
53
+ enableDrag: string;
54
+ disableDrag: string;
55
+ enableDragAria: string;
56
+ disableDragAria: string;
57
+ enableResize: string;
58
+ disableResize: string;
59
+ enableResizeAria: string;
60
+ disableResizeAria: string;
61
+ closeMenu: string;
62
+ }
63
+
64
+ export interface DraggablePanelExtrasContext {
65
+ menu: HTMLElement;
66
+ itemClass: string;
67
+ classPrefix: string;
68
+ /**
69
+ * Strip the tooltip and duplicate `button-text` nodes from a menu
70
+ * item. `createMenuItem` is shared with toolbar buttons that want a
71
+ * hover tooltip; settings-menu rows already show the same text
72
+ * inline, so the tooltip would cause screen readers to read the
73
+ * label twice. Exposed here so manager-provided extras can reuse
74
+ * the same stripping logic the panel applies to its own items.
75
+ */
76
+ stripInlineTooltip: (item: HTMLElement) => void;
77
+ }
78
+
79
+ export interface DraggablePanelOptions {
80
+ player: Player;
81
+ /**
82
+ * Namespace for CSS classes, e.g. `'sign-language'` or `'transcript'`.
83
+ * The final menu class is `{classPrefix}-{namespace}-settings-menu`
84
+ * and item class is `{classPrefix}-{namespace}-settings-item`.
85
+ */
86
+ namespace: string;
87
+ /** The button that opens the menu. */
88
+ settingsButton: HTMLElement;
89
+ /** Lazy draggable lookup (it may be re-created by the manager). */
90
+ getDraggable: () => DraggableResizable | null;
91
+ i18nKeys: DraggablePanelI18nKeys;
92
+ /**
93
+ * Horizontal anchoring of the menu relative to the settings button.
94
+ * - `'left'` : align left edge (Transcript — header-row button).
95
+ * - `'center'` : centre horizontally with viewport clamping (Sign
96
+ * language — floating near viewport edges).
97
+ */
98
+ menuAlign: 'left' | 'center';
99
+ /** Where to attach the menu node when first created. */
100
+ getMenuParent: () => HTMLElement | null;
101
+ /**
102
+ * Called when the drag-mode item is clicked. The manager is
103
+ * expected to toggle drag mode and run any side effects
104
+ * (announcements, badges, focus). After this runs the panel will
105
+ * refresh menu item state.
106
+ */
107
+ onDragItemClick: (panel: DraggablePanel) => void;
108
+ /** Same for the resize-mode item. */
109
+ onResizeItemClick: (panel: DraggablePanel) => void;
110
+ /**
111
+ * Insert manager-specific items (style dialog, timestamps, ...)
112
+ * between the resize item and the close item.
113
+ */
114
+ buildExtraItems?: (ctx: DraggablePanelExtrasContext) => void;
115
+ /** Optional: vertical gap (px) between button and menu. */
116
+ menuGap?: number;
117
+ /** Optional: breathing room (px) before the menu flips above. */
118
+ menuSpaceReserve?: number;
119
+ /**
120
+ * Where to attach the mode-feedback badge while keyboard drag /
121
+ * pointer resize mode is active. If omitted, {@link DraggablePanel.showBadge}
122
+ * is a no-op (the manager effectively opts out of the badge).
123
+ */
124
+ getBadgeHost?: () => HTMLElement | null;
125
+ /**
126
+ * CSS class for the badge element. Defaults to
127
+ * `{classPrefix}-{namespace}-mode-badge`. Sign-language passes a
128
+ * shorter class (`vidply-sign-mode-badge`) to preserve existing
129
+ * CSS. Transcript uses the default.
130
+ */
131
+ badgeClass?: string;
132
+ }
133
+
134
+ export class DraggablePanel {
135
+ private readonly opts: DraggablePanelOptions;
136
+
137
+ /** Populated lazily on first `show()`. */
138
+ settingsMenu: HTMLElement | null = null;
139
+ settingsMenuVisible = false;
140
+ dragOptionButton: HTMLElement | null = null;
141
+ dragOptionText: Element | null = null;
142
+ resizeOptionButton: HTMLElement | null = null;
143
+ resizeOptionText: Element | null = null;
144
+
145
+ private _justOpened = false;
146
+ private _justOpenedTimer: ReturnType<typeof setTimeout> | null = null;
147
+ private _keyHandler: ((e: KeyboardEvent) => void) | null = null;
148
+ private _documentClick: ((e: MouseEvent) => void) | null = null;
149
+ private _documentClickAdded = false;
150
+ private _modeBadge: HTMLElement | null = null;
151
+
152
+ constructor(opts: DraggablePanelOptions) {
153
+ this.opts = opts;
154
+ }
155
+
156
+ /** True while the just-opened debounce window (prevents the same
157
+ * click that opened the menu from also closing it via document
158
+ * `mousedown` / `click`). */
159
+ get justOpened(): boolean {
160
+ return this._justOpened;
161
+ }
162
+
163
+ get classPrefix(): string {
164
+ return this.opts.player.options.classPrefix;
165
+ }
166
+
167
+ get menuClass(): string {
168
+ return `${this.classPrefix}-${this.opts.namespace}-settings-menu`;
169
+ }
170
+
171
+ get itemClass(): string {
172
+ return `${this.classPrefix}-${this.opts.namespace}-settings-item`;
173
+ }
174
+
175
+ /**
176
+ * Show the menu. First call creates the DOM; subsequent calls reuse
177
+ * it. Refreshes menu item state from the current draggable.
178
+ */
179
+ show(): void {
180
+ this._markJustOpened(350);
181
+ this._ensureDocumentClickHandler();
182
+
183
+ if (this.settingsMenu) {
184
+ this.settingsMenu.style.display = 'block';
185
+ this.settingsMenuVisible = true;
186
+ this.opts.settingsButton.setAttribute('aria-expanded', 'true');
187
+ this._attachKeyboardNavigation();
188
+ this._positionImmediate();
189
+ this.refreshState();
190
+ focusFirstMenuItem(this.settingsMenu, `.${this.itemClass}`);
191
+ return;
192
+ }
193
+
194
+ this._createMenu();
195
+ }
196
+
197
+ /**
198
+ * Hide the menu. By default returns focus to the settings button;
199
+ * callers can opt out when the next interaction should land
200
+ * elsewhere (e.g. on the wrapper after enabling drag mode).
201
+ */
202
+ hide({ focusButton = true }: { focusButton?: boolean } = {}): void {
203
+ if (!this.settingsMenu) return;
204
+
205
+ this.settingsMenu.style.display = 'none';
206
+ this.settingsMenuVisible = false;
207
+ this._clearJustOpened();
208
+
209
+ if (this._keyHandler) {
210
+ this.settingsMenu.removeEventListener('keydown', this._keyHandler, true);
211
+ this._keyHandler = null;
212
+ }
213
+
214
+ const items = this.settingsMenu.querySelectorAll<HTMLElement>(`.${this.itemClass}`);
215
+ items.forEach((item) => item.setAttribute('tabindex', '-1'));
216
+
217
+ const { settingsButton } = this.opts;
218
+ settingsButton.setAttribute('aria-expanded', 'false');
219
+ if (focusButton) {
220
+ settingsButton.focus({ preventScroll: true });
221
+ }
222
+ }
223
+
224
+ toggle(): void {
225
+ if (this.settingsMenuVisible) {
226
+ this.hide();
227
+ } else {
228
+ this.show();
229
+ }
230
+ }
231
+
232
+ /** Set a short "just opened" guard so the document-click handler
233
+ * attached for outside-dismissal ignores the originating click. */
234
+ markJustOpenedForClick(): void {
235
+ if (this._documentClick) {
236
+ this._markJustOpened(100);
237
+ }
238
+ }
239
+
240
+ /** Refresh the drag and resize toggle item state from the draggable. */
241
+ refreshState(): void {
242
+ this.refreshDragState();
243
+ this.refreshResizeState();
244
+ }
245
+
246
+ refreshDragState(): void {
247
+ const draggable = this.opts.getDraggable();
248
+ updateToggleMenuItem(this.dragOptionButton, this.dragOptionText, {
249
+ enabled: Boolean(draggable?.keyboardDragMode),
250
+ enabledText: i18n.t(this.opts.i18nKeys.disableDrag),
251
+ disabledText: i18n.t(this.opts.i18nKeys.enableDrag),
252
+ enabledAria: i18n.t(this.opts.i18nKeys.disableDragAria),
253
+ disabledAria: i18n.t(this.opts.i18nKeys.enableDragAria),
254
+ });
255
+ }
256
+
257
+ refreshResizeState(): void {
258
+ const draggable = this.opts.getDraggable();
259
+ updateToggleMenuItem(this.resizeOptionButton, this.resizeOptionText, {
260
+ enabled: Boolean(draggable?.pointerResizeMode),
261
+ enabledText: i18n.t(this.opts.i18nKeys.disableResize),
262
+ disabledText: i18n.t(this.opts.i18nKeys.enableResize),
263
+ enabledAria: i18n.t(this.opts.i18nKeys.disableResizeAria),
264
+ disabledAria: i18n.t(this.opts.i18nKeys.enableResizeAria),
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Show a persistent mode-feedback badge (e.g. "Drag mode: arrow
270
+ * keys to move, Esc to exit") anchored to the host element
271
+ * returned by `getBadgeHost`. Replaces any previous badge. The
272
+ * badge is a real DOM element (not a CSS pseudo-element) so its
273
+ * text is translatable, selectable, visible under high-contrast
274
+ * themes, and reflected in browser translation overlays.
275
+ *
276
+ * Marked `aria-hidden` because the accompanying live-region
277
+ * announcement (the manager's responsibility) already conveys the
278
+ * state change to assistive tech.
279
+ */
280
+ showBadge(text: string): void {
281
+ const host = this.opts.getBadgeHost?.();
282
+ if (!host) return;
283
+ this.hideBadge();
284
+
285
+ const className =
286
+ this.opts.badgeClass ??
287
+ `${this.classPrefix}-${this.opts.namespace}-mode-badge`;
288
+ const badge = DOMUtils.createElement('span', {
289
+ className,
290
+ textContent: text,
291
+ attributes: { 'aria-hidden': 'true' },
292
+ });
293
+ host.appendChild(badge);
294
+ this._modeBadge = badge;
295
+ }
296
+
297
+ /** Remove the mode-feedback badge if one is showing. */
298
+ hideBadge(): void {
299
+ if (this._modeBadge && this._modeBadge.parentNode) {
300
+ this._modeBadge.remove();
301
+ }
302
+ this._modeBadge = null;
303
+ }
304
+
305
+ /** RAF-deferred reposition (e.g. after a panel resize). */
306
+ reposition(): void {
307
+ positionSettingsMenuDeferred(this.settingsMenu, this.opts.settingsButton, {
308
+ align: this.opts.menuAlign,
309
+ gap: this.opts.menuGap ?? 4,
310
+ spaceReserve: this.opts.menuSpaceReserve ?? 20,
311
+ });
312
+ }
313
+
314
+ /**
315
+ * Tear down any DOM/listeners owned by this panel. Safe to call
316
+ * multiple times. Callers must still drop their own references.
317
+ */
318
+ destroy(): void {
319
+ if (this._justOpenedTimer) {
320
+ clearTimeout(this._justOpenedTimer);
321
+ this._justOpenedTimer = null;
322
+ }
323
+ this._justOpened = false;
324
+
325
+ this.hideBadge();
326
+
327
+ if (this.settingsMenu) {
328
+ if (this._keyHandler) {
329
+ this.settingsMenu.removeEventListener('keydown', this._keyHandler, true);
330
+ }
331
+ this.settingsMenu.remove();
332
+ this.settingsMenu = null;
333
+ }
334
+
335
+ this._keyHandler = null;
336
+ this.settingsMenuVisible = false;
337
+ this.dragOptionButton = null;
338
+ this.dragOptionText = null;
339
+ this.resizeOptionButton = null;
340
+ this.resizeOptionText = null;
341
+
342
+ // The document-click listener was attached with the player's
343
+ // lifecycleSignal, so it is cleaned up automatically. We clear
344
+ // the local reference so a later show() re-installs.
345
+ this._documentClick = null;
346
+ this._documentClickAdded = false;
347
+ }
348
+
349
+ private _createMenu(): void {
350
+ const { player, settingsButton, i18nKeys } = this.opts;
351
+
352
+ const menu = DOMUtils.createElement('div', {
353
+ className: this.menuClass,
354
+ attributes: { role: 'menu' },
355
+ });
356
+ this.settingsMenu = menu;
357
+
358
+ const dragOption = createMenuItem({
359
+ classPrefix: this.classPrefix,
360
+ itemClass: this.itemClass,
361
+ icon: 'move',
362
+ label: i18nKeys.enableDrag,
363
+ hasTextClass: true,
364
+ onClick: (event) => {
365
+ event.preventDefault();
366
+ event.stopPropagation();
367
+ this.opts.onDragItemClick(this);
368
+ this.refreshState();
369
+ },
370
+ });
371
+ dragOption.setAttribute('role', 'switch');
372
+ dragOption.setAttribute('aria-checked', 'false');
373
+ dragOption.setAttribute('data-setting', 'keyboard-drag');
374
+ this._stripInlineTooltip(dragOption);
375
+ this.dragOptionButton = dragOption;
376
+ this.dragOptionText = dragOption.querySelector(`.${this.classPrefix}-settings-text`);
377
+
378
+ const resizeOption = createMenuItem({
379
+ classPrefix: this.classPrefix,
380
+ itemClass: this.itemClass,
381
+ icon: 'resize',
382
+ label: i18nKeys.enableResize,
383
+ hasTextClass: true,
384
+ onClick: (event) => {
385
+ event.preventDefault();
386
+ event.stopPropagation();
387
+ this.opts.onResizeItemClick(this);
388
+ this.refreshState();
389
+ },
390
+ });
391
+ resizeOption.setAttribute('role', 'switch');
392
+ resizeOption.setAttribute('aria-checked', 'false');
393
+ this._stripInlineTooltip(resizeOption);
394
+ this.resizeOptionButton = resizeOption;
395
+ this.resizeOptionText = resizeOption.querySelector(`.${this.classPrefix}-settings-text`);
396
+
397
+ menu.appendChild(dragOption);
398
+ menu.appendChild(resizeOption);
399
+
400
+ if (this.opts.buildExtraItems) {
401
+ this.opts.buildExtraItems({
402
+ menu,
403
+ itemClass: this.itemClass,
404
+ classPrefix: this.classPrefix,
405
+ stripInlineTooltip: (item) => this._stripInlineTooltip(item),
406
+ });
407
+ }
408
+
409
+ const closeOption = createMenuItem({
410
+ classPrefix: this.classPrefix,
411
+ itemClass: this.itemClass,
412
+ icon: 'close',
413
+ label: i18nKeys.closeMenu,
414
+ onClick: () => this.hide(),
415
+ });
416
+ this._stripInlineTooltip(closeOption);
417
+ menu.appendChild(closeOption);
418
+
419
+ menu.style.visibility = 'hidden';
420
+ menu.style.display = 'block';
421
+
422
+ const parent = this.opts.getMenuParent();
423
+ if (settingsButton.parentNode) {
424
+ settingsButton.insertAdjacentElement('afterend', menu);
425
+ } else if (parent) {
426
+ parent.appendChild(menu);
427
+ }
428
+
429
+ this._positionImmediate();
430
+
431
+ requestAnimationFrame(() => {
432
+ if (this.settingsMenu) {
433
+ this.settingsMenu.style.visibility = 'visible';
434
+ }
435
+ });
436
+
437
+ this._attachKeyboardNavigation();
438
+
439
+ this.settingsMenuVisible = true;
440
+ settingsButton.setAttribute('aria-expanded', 'true');
441
+ this.refreshState();
442
+
443
+ focusFirstMenuItem(menu, `.${this.itemClass}`);
444
+
445
+ // Don't reveal the fact that we created the menu to callers that
446
+ // didn't trigger show() explicitly — but this is always driven
447
+ // by a show() so the `player` reference is just for
448
+ // documentation clarity here.
449
+ void player;
450
+ }
451
+
452
+ private _attachKeyboardNavigation(): void {
453
+ const menu = this.settingsMenu;
454
+ if (!menu) return;
455
+ if (this._keyHandler) {
456
+ menu.removeEventListener('keydown', this._keyHandler, true);
457
+ }
458
+ const handler = attachMenuKeyboardNavigation(
459
+ menu,
460
+ this.opts.settingsButton,
461
+ `.${this.itemClass}`,
462
+ () => this.hide({ focusButton: true })
463
+ );
464
+ this._keyHandler = handler ?? null;
465
+ }
466
+
467
+ private _positionImmediate(): void {
468
+ positionSettingsMenu(this.settingsMenu, this.opts.settingsButton, {
469
+ align: this.opts.menuAlign,
470
+ gap: this.opts.menuGap ?? 4,
471
+ spaceReserve: this.opts.menuSpaceReserve ?? 20,
472
+ });
473
+ }
474
+
475
+ /**
476
+ * Remove tooltip and duplicate button-text nodes from a menu item.
477
+ * `createMenuItem` is used both for toolbar buttons (which want a
478
+ * tooltip) and for settings-menu rows (which show the same text
479
+ * inline). This strips the duplicated pieces so screen readers
480
+ * don't read the label twice.
481
+ */
482
+ private _stripInlineTooltip(item: HTMLElement): void {
483
+ const tooltip = item.querySelector(`.${this.classPrefix}-tooltip`);
484
+ if (tooltip) tooltip.remove();
485
+ const buttonText = item.querySelector(`.${this.classPrefix}-button-text`);
486
+ if (buttonText) buttonText.remove();
487
+ }
488
+
489
+ private _markJustOpened(durationMs: number): void {
490
+ this._justOpened = true;
491
+ if (this._justOpenedTimer) {
492
+ clearTimeout(this._justOpenedTimer);
493
+ }
494
+ this._justOpenedTimer = setTimeout(() => {
495
+ this._justOpened = false;
496
+ this._justOpenedTimer = null;
497
+ }, durationMs);
498
+ }
499
+
500
+ private _clearJustOpened(): void {
501
+ this._justOpened = false;
502
+ if (this._justOpenedTimer) {
503
+ clearTimeout(this._justOpenedTimer);
504
+ this._justOpenedTimer = null;
505
+ }
506
+ }
507
+
508
+ private _ensureDocumentClickHandler(): void {
509
+ if (this._documentClickAdded) return;
510
+
511
+ this._documentClick = (event: MouseEvent) => {
512
+ if (this._justOpened) return;
513
+ const target = event.target as Node | null;
514
+ const { settingsButton } = this.opts;
515
+
516
+ if (settingsButton === target || (target && settingsButton.contains(target))) {
517
+ return;
518
+ }
519
+ if (this.settingsMenu && target && this.settingsMenu.contains(target)) {
520
+ return;
521
+ }
522
+ if (this.settingsMenuVisible) {
523
+ this.hide();
524
+ }
525
+ };
526
+
527
+ // Delay attach so the click that opened the menu doesn't instantly
528
+ // close it even if the justOpened flag somehow races.
529
+ setTimeout(() => {
530
+ const handler = this._documentClick;
531
+ if (!handler) return;
532
+ document.addEventListener('mousedown', handler, {
533
+ capture: true,
534
+ signal: this.opts.player.lifecycleSignal,
535
+ });
536
+ this._documentClickAdded = true;
537
+ }, 300);
538
+ }
539
+ }