@djangocfg/ui-tools 2.1.416 → 2.1.418

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 (66) hide show
  1. package/dist/audio-player/index.cjs +2098 -0
  2. package/dist/audio-player/index.cjs.map +1 -0
  3. package/dist/audio-player/index.css +65 -0
  4. package/dist/audio-player/index.css.map +1 -0
  5. package/dist/audio-player/index.d.cts +166 -0
  6. package/dist/audio-player/index.d.ts +166 -0
  7. package/dist/audio-player/index.mjs +2075 -0
  8. package/dist/audio-player/index.mjs.map +1 -0
  9. package/dist/composer-registry/index.cjs +45 -0
  10. package/dist/composer-registry/index.cjs.map +1 -0
  11. package/dist/composer-registry/index.d.cts +73 -0
  12. package/dist/composer-registry/index.d.ts +73 -0
  13. package/dist/composer-registry/index.mjs +39 -0
  14. package/dist/composer-registry/index.mjs.map +1 -0
  15. package/dist/tree/index.cjs +82 -63
  16. package/dist/tree/index.cjs.map +1 -1
  17. package/dist/tree/index.d.cts +15 -1
  18. package/dist/tree/index.d.ts +15 -1
  19. package/dist/tree/index.mjs +83 -64
  20. package/dist/tree/index.mjs.map +1 -1
  21. package/package.json +38 -17
  22. package/src/tools/chat/composer/Composer.tsx +8 -8
  23. package/src/tools/chat/context/ChatProvider.tsx +13 -78
  24. package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
  25. package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
  26. package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
  27. package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
  28. package/src/tools/data/Tree/TreeRoot.tsx +33 -109
  29. package/src/tools/data/Tree/context/TreeContext.tsx +22 -3
  30. package/src/tools/data/Tree/context/menu/index.ts +1 -0
  31. package/src/tools/data/Tree/context/menu/render.tsx +75 -0
  32. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +16 -2
  33. package/src/tools/data/Tree/index.tsx +1 -0
  34. package/src/tools/data/Tree/types/index.ts +1 -1
  35. package/src/tools/data/Tree/types/root-props.ts +16 -0
  36. package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
  37. package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
  38. package/src/tools/forms/CodeEditor/components/Editor.tsx +19 -0
  39. package/src/tools/forms/CodeEditor/types/index.ts +7 -0
  40. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +40 -0
  41. package/src/tools/forms/MarkdownEditor/styles.css +174 -21
  42. package/src/tools/forms/NotionEditor/CustomKeymap.ts +48 -0
  43. package/src/tools/forms/NotionEditor/LinkDialog.tsx +133 -0
  44. package/src/tools/forms/NotionEditor/NotionEditor.tsx +304 -0
  45. package/src/tools/forms/NotionEditor/SlashExtension.ts +32 -0
  46. package/src/tools/forms/NotionEditor/SlashList.tsx +136 -0
  47. package/src/tools/forms/NotionEditor/TaskItemView.tsx +41 -0
  48. package/src/tools/forms/NotionEditor/createSlashSuggestion.ts +121 -0
  49. package/src/tools/forms/NotionEditor/extensions.ts +105 -0
  50. package/src/tools/forms/NotionEditor/index.ts +1 -0
  51. package/src/tools/forms/NotionEditor/lazy.tsx +44 -0
  52. package/src/tools/forms/NotionEditor/slashItems.ts +159 -0
  53. package/src/tools/forms/NotionEditor/styles.css +478 -0
  54. package/src/tools/forms/NotionEditor/types.ts +28 -0
  55. package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
  56. package/src/tools/integration/ComposerRegistry/index.ts +105 -0
  57. package/src/tools/media/AudioPlayer/Player.tsx +2 -0
  58. package/src/tools/media/AudioPlayer/PlayerShell.tsx +29 -22
  59. package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
  60. package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
  61. package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
  62. package/src/tools/media/AudioPlayer/types.ts +8 -0
  63. package/src/tools/media/ImageViewer/components/ImageViewer.tsx +8 -0
  64. package/src/tools/media/ImageViewer/types.ts +4 -0
  65. package/src/tools/media/VideoPlayer/VideoPlayer.tsx +20 -1
  66. package/src/tools/media/VideoPlayer/types.ts +4 -0
@@ -4,7 +4,6 @@
4
4
  // keyboard shortcuts and MediaSession wiring; renders the picked layout.
5
5
 
6
6
  import { useCallback, useEffect, useImperativeHandle, useState } from 'react';
7
- import { TooltipProvider } from '@djangocfg/ui-core/components';
8
7
  import { useIsPhone } from '@djangocfg/ui-core/hooks';
9
8
  import { usePlayerAudio, usePlayerControls, usePlayerMeta } from './context/selectors';
10
9
  import { useElementWidth } from './hooks/useResizeObserver';
@@ -26,6 +25,7 @@ type Props = Pick<
26
25
  | 'enableKeyboardShortcuts'
27
26
  | 'ariaLabel'
28
27
  | 'seekStartsPlayback'
28
+ | 'autoFocus'
29
29
  > & {
30
30
  handleRef?: React.Ref<PlayerHandle>;
31
31
  };
@@ -40,6 +40,7 @@ export function PlayerShell({
40
40
  enableKeyboardShortcuts = true,
41
41
  ariaLabel,
42
42
  seekStartsPlayback = true,
43
+ autoFocus = false,
43
44
  handleRef,
44
45
  }: Props) {
45
46
  const [container, setContainer] = useState<HTMLDivElement | null>(null);
@@ -87,8 +88,9 @@ export function PlayerShell({
87
88
  seek: (s: number) => controls.seek(s),
88
89
  getCurrentTime: () => audio.currentTime,
89
90
  getDuration: () => (Number.isFinite(audio.duration) ? audio.duration : 0),
91
+ focus: () => container?.focus(),
90
92
  }),
91
- [audio, controls],
93
+ [audio, controls, container],
92
94
  );
93
95
 
94
96
  // Keyboard shortcuts work only when the container can take focus.
@@ -97,26 +99,31 @@ export function PlayerShell({
97
99
  container.setAttribute('tabindex', '0');
98
100
  }, [container]);
99
101
 
102
+ // Declarative autoFocus: focus the container once the DOM node is ready.
103
+ // Parents that want a *fresh* focus per source remount us via `key={src}`.
104
+ useEffect(() => {
105
+ if (!autoFocus || !container) return;
106
+ container.focus({ preventScroll: true });
107
+ }, [autoFocus, container]);
108
+
100
109
  return (
101
- <TooltipProvider delayDuration={400}>
102
- <div
103
- ref={setRootRef}
104
- role="group"
105
- aria-label={ariaLabel ?? 'Audio player'}
106
- className={`audioplayer @container/player rounded-lg border border-border/60 bg-card text-foreground shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 ${className}`}
107
- >
108
- {resolvedVariant === 'compact' ? (
109
- <CompactLayout waveform={waveform} seekStartsPlayback={seekStartsPlayback} />
110
- ) : (
111
- <DefaultLayout
112
- waveform={waveform}
113
- reactiveCover={reactiveCover}
114
- onPrev={onPrev}
115
- onNext={onNext}
116
- seekStartsPlayback={seekStartsPlayback}
117
- />
118
- )}
119
- </div>
120
- </TooltipProvider>
110
+ <div
111
+ ref={setRootRef}
112
+ role="group"
113
+ aria-label={ariaLabel ?? 'Audio player'}
114
+ className={`audioplayer @container/player rounded-lg border border-border/60 bg-card text-foreground shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 ${className}`}
115
+ >
116
+ {resolvedVariant === 'compact' ? (
117
+ <CompactLayout waveform={waveform} seekStartsPlayback={seekStartsPlayback} />
118
+ ) : (
119
+ <DefaultLayout
120
+ waveform={waveform}
121
+ reactiveCover={reactiveCover}
122
+ onPrev={onPrev}
123
+ onNext={onNext}
124
+ seekStartsPlayback={seekStartsPlayback}
125
+ />
126
+ )}
127
+ </div>
121
128
  );
122
129
  }
@@ -3,31 +3,41 @@
3
3
  /**
4
4
  * `@djangocfg/ui-tools/audio-player` subpath entrypoint.
5
5
  *
6
- * `LazyPlayer` is exported as a direct (synchronous) alias of `Player`. We
7
- * intentionally avoid `React.lazy` + `import('./Player')` here: under bundlers
8
- * that pre-bundle subpath entries (Vite optimizeDeps in Next.js/Vite/SB), the
9
- * dynamic import creates a second chunk that re-instantiates the React
10
- * Contexts (AudioRefCtx/ControlsCtx/MetaCtx/StateCtx/LevelsCtx). The slot
11
- * components and selector hooks re-exported below would then read from a
12
- * different context instance than `<PlayerProvider>` writes to, which made
13
- * `usePlayerAudio` throw "must be used inside <PlayerProvider>".
6
+ * We deliberately keep this surface narrow:
14
7
  *
15
- * Heavy audio-decoding work (peaks, AudioContext) already happens lazily at
16
- * runtime via effects inside `PlayerProvider`/`PlayerShell` — there is no
17
- * benefit to splitting the React shell behind a second chunk.
8
+ * - `Player` / `LazyPlayer` the only React component you should
9
+ * import. Already wraps `<PlayerProvider>` + `<PlayerShell>`.
10
+ * - types
11
+ * - `PlayerProvider` + selector hooks — for the rare consumer that
12
+ * wants to render a fully custom layout. They must own the
13
+ * provider themselves.
14
+ * - cross-instance store helpers (active player, preferences).
15
+ *
16
+ * Slot components (Cover/Title/Waveform/Controls/Layout) and internal
17
+ * peak-cache helpers are NOT re-exported from this entrypoint. Earlier
18
+ * versions did re-export them, which produced a second module-graph
19
+ * entry into the `context/*` files under Vite's `optimizeDeps`
20
+ * pre-bundling — the slot files import the same selectors as
21
+ * `PlayerShell`, but via a different URL key, so `<PlayerProvider>`
22
+ * (one context instance) and `usePlayerAudio` inside a slot read
23
+ * (another context instance) ended up on different `createContext()`
24
+ * objects → "usePlayerAudio must be used inside <PlayerProvider>".
25
+ *
26
+ * If you need raw slots, import from a deeper path (e.g.
27
+ * `@djangocfg/ui-tools/src/tools/media/AudioPlayer/parts/Cover`) and
28
+ * accept that you must own a single `<PlayerProvider>` boundary.
18
29
  */
19
30
 
20
31
  // ============================================================================
21
- // Player component (synchronous; previously lazy — see note above)
32
+ // Player component (synchronous; previously lazy)
22
33
  // ============================================================================
23
34
 
24
35
  export { Player, Player as LazyPlayer } from './Player';
25
36
 
26
37
  // ============================================================================
27
- // Light surface — types, store, context, slot components, hooks
38
+ // Types
28
39
  // ============================================================================
29
40
 
30
- // Types
31
41
  export type {
32
42
  PlayerProps,
33
43
  PlayerState,
@@ -41,7 +51,10 @@ export type {
41
51
  PlayerErrorReason,
42
52
  } from './types';
43
53
 
54
+ // ============================================================================
44
55
  // Context provider + selector hooks (no UI)
56
+ // ============================================================================
57
+
45
58
  export {
46
59
  PlayerProvider,
47
60
  usePlayerAudio,
@@ -53,7 +66,10 @@ export {
53
66
  usePlayerState,
54
67
  } from './context';
55
68
 
69
+ // ============================================================================
56
70
  // Cross-instance store (active player, preferences)
71
+ // ============================================================================
72
+
57
73
  export {
58
74
  setActivePlayer,
59
75
  getActivePlayer,
@@ -66,37 +82,9 @@ export {
66
82
  type PlayerPreferences,
67
83
  } from './store';
68
84
 
69
- // Store-backed hooks
70
85
  export {
71
86
  useActivePlayer,
72
87
  useLastActivePlayer,
73
88
  useIsActivePlayer,
74
89
  } from './hooks/useActivePlayer';
75
90
  export { usePlayerPreferences } from './hooks/usePlayerPreferences';
76
-
77
- // Peak cache helpers
78
- export { clearPeaksCache, setPeaks } from './audio';
79
-
80
- // Slot components — presentational, read from PlayerContext. Safe to
81
- // re-export synchronously: they don't import the heavy Player tree
82
- // (audio decoding, layouts, shell) — only context selectors and types.
83
- export { Cover, CoverPlaceholder, ReactivePulse } from './parts/Cover';
84
- export { Title, Artist, TimeDisplay } from './parts/Meta';
85
- export {
86
- PlayButton,
87
- SkipButton,
88
- VolumeControl,
89
- LoopButton,
90
- ControlsRow,
91
- IconButton,
92
- } from './parts/Controls';
93
- export {
94
- Waveform,
95
- PeaksWaveform,
96
- LiveWaveform,
97
- BarsWaveform,
98
- ProgressBar,
99
- WaveformSkeleton,
100
- } from './parts/Waveform';
101
- export { ErrorState } from './parts/ErrorState';
102
- export { DefaultLayout, CompactLayout } from './parts/Layout';
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import type { ButtonHTMLAttributes, ReactNode } from 'react';
3
+ import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
4
4
  import {
5
5
  Tooltip,
6
6
  TooltipContent,
@@ -19,21 +19,20 @@ type Props = {
19
19
  noTooltip?: boolean;
20
20
  } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'aria-label'>;
21
21
 
22
- export function IconButton({
23
- label,
24
- shortcut,
25
- active,
26
- children,
27
- noTooltip,
28
- className = '',
29
- ...rest
30
- }: Props) {
22
+ // `forwardRef` is mandatory: this button is used as `<PopoverTrigger asChild>`
23
+ // in VolumeControl. Without ref forwarding, Radix can't anchor the popper to
24
+ // the trigger, and `<PopoverContent>` renders with zero rect (invisible).
25
+ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButton(
26
+ { label, shortcut, active, children, noTooltip, className = '', ...rest },
27
+ ref,
28
+ ) {
31
29
  // Active state uses a tinted primary surface — readable, on-brand, not loud.
32
30
  const stateClasses = active
33
31
  ? 'bg-primary/10 text-primary hover:bg-primary/15'
34
32
  : 'text-muted-foreground hover:bg-accent hover:text-foreground';
35
33
  const button = (
36
34
  <button
35
+ ref={ref}
37
36
  type="button"
38
37
  aria-label={label}
39
38
  aria-pressed={active}
@@ -59,4 +58,4 @@ export function IconButton({
59
58
  </TooltipContent>
60
59
  </Tooltip>
61
60
  );
62
- }
61
+ });
@@ -1,13 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { Volume2, VolumeX } from 'lucide-react';
4
- import { useEffect, useRef, useState } from 'react';
5
- import { useMediaQuery } from '@djangocfg/ui-core/hooks';
4
+ import { useEffect, useState } from 'react';
5
+ import { Popover, PopoverContent, PopoverTrigger, Slider } from '@djangocfg/ui-core/components';
6
6
  import { usePlayerAudio, usePlayerControls } from '../../context/selectors';
7
7
  import { IconButton } from './IconButton';
8
8
 
9
- const CLOSE_DELAY_MS = 120;
10
-
11
9
  // `audio.volume` is read-only on iOS Safari (controlled by hardware buttons),
12
10
  // so a JS slider does nothing useful there. Detect once at module load.
13
11
  function isIosSafari(): boolean {
@@ -19,16 +17,22 @@ function isIosSafari(): boolean {
19
17
  }
20
18
  const HIDE_VOLUME = isIosSafari();
21
19
 
20
+ /**
21
+ * Volume control — click-to-open Popover with a vertical `<Slider>`.
22
+ *
23
+ * Click on the trigger toggles the popover (not mute). Mute lives as a
24
+ * dedicated icon button inside the popover. This avoids the classic
25
+ * Radix `<Tooltip>` + `<Popover>` collision on the same trigger:
26
+ * hovering opened the tooltip, click toggled the popover — but the
27
+ * tooltip's pointer-events fought the popover's outside-click logic.
28
+ * One trigger, one job: open the popover; volume + mute live inside.
29
+ */
22
30
  export function VolumeControl() {
23
31
  const audio = usePlayerAudio();
24
32
  const { setVolume, toggleMute } = usePlayerControls();
25
33
  const [volume, setVol] = useState(audio.volume);
26
34
  const [muted, setMuted] = useState(audio.muted);
27
35
  const [isOpen, setOpen] = useState(false);
28
- const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
29
-
30
- // Touch devices have no hover — open the popover on click instead.
31
- const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
32
36
 
33
37
  useEffect(() => {
34
38
  const sync = () => {
@@ -39,26 +43,7 @@ export function VolumeControl() {
39
43
  return () => audio.removeEventListener('volumechange', sync);
40
44
  }, [audio]);
41
45
 
42
- useEffect(() => {
43
- return () => {
44
- if (closeTimer.current) clearTimeout(closeTimer.current);
45
- };
46
- }, []);
47
-
48
- // Close on outside-click in touch mode.
49
- const containerRef = useRef<HTMLDivElement | null>(null);
50
- useEffect(() => {
51
- if (!isOpen || !isTouch) return;
52
- const onDown = (e: PointerEvent) => {
53
- if (!containerRef.current) return;
54
- if (!containerRef.current.contains(e.target as Node)) setOpen(false);
55
- };
56
- document.addEventListener('pointerdown', onDown);
57
- return () => document.removeEventListener('pointerdown', onDown);
58
- }, [isOpen, isTouch]);
59
-
60
46
  if (HIDE_VOLUME) {
61
- // iOS Safari can't change volume via JS — keep mute toggle only.
62
47
  return (
63
48
  <IconButton
64
49
  label={muted ? 'Unmute' : 'Mute'}
@@ -74,98 +59,50 @@ export function VolumeControl() {
74
59
  );
75
60
  }
76
61
 
77
- const cancelClose = () => {
78
- if (closeTimer.current) {
79
- clearTimeout(closeTimer.current);
80
- closeTimer.current = null;
81
- }
82
- };
83
- const scheduleClose = () => {
84
- cancelClose();
85
- closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS);
86
- };
87
- const open = () => {
88
- cancelClose();
89
- setOpen(true);
90
- };
91
-
92
62
  const Icon = muted || volume === 0 ? VolumeX : Volume2;
93
63
 
94
- // Hover bindings only apply on devices with real hover. On touch the popover
95
- // is toggled by the icon click.
96
- const hoverHandlers = isTouch
97
- ? {}
98
- : {
99
- onPointerEnter: open,
100
- onPointerLeave: scheduleClose,
101
- onFocusCapture: open,
102
- onBlurCapture: (e: React.FocusEvent) => {
103
- if (!e.currentTarget.contains(e.relatedTarget as Node | null)) scheduleClose();
104
- },
105
- };
64
+ const handleChange = (v: number) => {
65
+ setVolume(v);
66
+ setVol(v);
67
+ if (v > 0) setMuted(false);
68
+ };
106
69
 
107
70
  return (
108
- <div ref={containerRef} className="relative" {...hoverHandlers}>
109
- <IconButton
110
- label={isOpen ? 'Close volume' : muted ? 'Unmute' : 'Volume'}
111
- shortcut={isTouch ? undefined : 'M'}
112
- active={muted}
113
- // Inside the popover wrapper disable IconButton's tooltip when the
114
- // popover is open (the popover already shows controls; nesting Radix
115
- // tooltip+popover content fights for focus/dismiss).
116
- noTooltip={isOpen}
117
- onClick={() => {
118
- if (isTouch) {
119
- setOpen((v) => !v);
120
- return;
121
- }
122
- toggleMute();
123
- setMuted(audio.muted);
124
- }}
125
- >
126
- <Icon size={16} strokeWidth={1.75} />
127
- </IconButton>
128
- {isOpen && (
129
- <div
130
- className="absolute bottom-full left-1/2 z-20 -translate-x-1/2 pb-2"
131
- onPointerEnter={isTouch ? undefined : open}
132
- onPointerLeave={isTouch ? undefined : scheduleClose}
71
+ <Popover open={isOpen} onOpenChange={setOpen}>
72
+ <PopoverTrigger asChild>
73
+ <IconButton
74
+ label={isOpen ? 'Close volume' : 'Volume'}
75
+ // Tooltip while open would race the popover for pointer/focus
76
+ // suppress it as soon as the popover opens.
77
+ noTooltip={isOpen}
78
+ active={muted}
133
79
  >
134
- <div className="flex items-center gap-2 rounded-md border border-border/60 bg-card px-3 py-2 shadow-sm">
135
- <input
136
- type="range"
137
- min={0}
138
- max={1}
139
- step={0.01}
140
- value={muted ? 0 : volume}
141
- onChange={(e) => {
142
- const v = parseFloat(e.target.value);
143
- setVolume(v);
144
- setVol(v);
145
- if (v > 0) setMuted(false);
146
- }}
147
- className="h-1 w-32 appearance-none rounded-full bg-muted accent-foreground"
148
- aria-label="Volume"
149
- />
150
- <span className="w-8 tabular-nums text-right text-[10px] text-muted-foreground">
151
- {Math.round((muted ? 0 : volume) * 100)}
152
- </span>
153
- {isTouch && (
154
- <button
155
- type="button"
156
- aria-label={muted ? 'Unmute' : 'Mute'}
157
- className="audioplayer-press grid h-7 w-7 place-items-center rounded text-muted-foreground hover:bg-accent"
158
- onClick={() => {
159
- toggleMute();
160
- setMuted(audio.muted);
161
- }}
162
- >
163
- {muted ? <VolumeX size={14} strokeWidth={1.75} /> : <Volume2 size={14} strokeWidth={1.75} />}
164
- </button>
165
- )}
166
- </div>
167
- </div>
168
- )}
169
- </div>
80
+ <Icon size={16} strokeWidth={1.75} />
81
+ </IconButton>
82
+ </PopoverTrigger>
83
+ <PopoverContent
84
+ side="top"
85
+ align="center"
86
+ sideOffset={6}
87
+ className="flex w-14! flex-col items-center gap-2 p-3!"
88
+ // Keep the focus ring on the trigger; the slider still accepts
89
+ // keyboard once tabbed into.
90
+ onOpenAutoFocus={(e) => e.preventDefault()}
91
+ >
92
+ <span className="w-full text-center tabular-nums text-[10px] text-muted-foreground">
93
+ {Math.round((muted ? 0 : volume) * 100)}
94
+ </span>
95
+ <Slider
96
+ orientation="vertical"
97
+ min={0}
98
+ max={1}
99
+ step={0.01}
100
+ value={[muted ? 0 : volume]}
101
+ onValueChange={([v]) => handleChange(v)}
102
+ aria-label="Volume"
103
+ className="h-32"
104
+ />
105
+ </PopoverContent>
106
+ </Popover>
170
107
  );
171
108
  }
@@ -52,6 +52,11 @@ export type PlayerProps = {
52
52
  ariaLabel?: string;
53
53
  enableKeyboardShortcuts?: boolean;
54
54
 
55
+ /** Focus the player container on mount so its keyboard scope is active
56
+ * immediately. Pair with `key={src}` upstream when the parent wants a
57
+ * fresh focus on every source change (file-browser inspector pattern). */
58
+ autoFocus?: boolean;
59
+
55
60
  // When the user clicks on the waveform while paused, also start playback.
56
61
  // Default true — clicking on a time mark almost always means "play here".
57
62
  // Set false for embeds where stray clicks shouldn't trigger sound.
@@ -92,4 +97,7 @@ export type PlayerHandle = {
92
97
  seek: (seconds: number) => void;
93
98
  getCurrentTime: () => number;
94
99
  getDuration: () => number;
100
+ /** Move keyboard focus to the player container so its hotkey scope
101
+ * (Space=play/pause, ←→=seek, ↑↓=volume, M=mute) becomes active. */
102
+ focus: () => void;
95
103
  };
@@ -38,6 +38,7 @@ export function ImageViewer({
38
38
  images,
39
39
  initialIndex = 0,
40
40
  inDialog = false,
41
+ autoFocus = false,
41
42
  }: ImageViewerProps) {
42
43
  const t = useAppT();
43
44
 
@@ -134,6 +135,13 @@ export function ImageViewer({
134
135
  return true;
135
136
  }, []);
136
137
 
138
+ // Declarative autoFocus: focus the container once on mount. Pair with
139
+ // `key={src}` upstream for per-source focus reset.
140
+ useEffect(() => {
141
+ if (!autoFocus) return;
142
+ containerRef.current?.focus({ preventScroll: true });
143
+ }, [autoFocus]);
144
+
137
145
  // Keyboard: zoom / rotate / pan (only when container focused)
138
146
  useEffect(() => {
139
147
  const handleKeyDown = (e: KeyboardEvent) => {
@@ -43,6 +43,10 @@ export interface ImageViewerProps {
43
43
  initialIndex?: number;
44
44
  /** Hide expand button when already in dialog */
45
45
  inDialog?: boolean;
46
+ /** Focus the viewer container on mount so its keyboard scope is active
47
+ * immediately (zoom/rotate/gallery hotkeys). Pair with `key={src}`
48
+ * upstream when the parent wants a fresh focus per source change. */
49
+ autoFocus?: boolean;
46
50
  }
47
51
 
48
52
  export interface ImageToolbarProps {
@@ -12,7 +12,7 @@
12
12
  * iframe sources where the embed renders its own UI).
13
13
  */
14
14
 
15
- import { useMemo, type CSSProperties } from 'react';
15
+ import { useEffect, useMemo, useRef, type CSSProperties } from 'react';
16
16
  import { MediaController } from 'media-chrome/react';
17
17
  import { cn } from '@djangocfg/ui-core/lib';
18
18
  import './styles/video-player.css';
@@ -42,6 +42,7 @@ export function VideoPlayer({
42
42
  aspectRatio = 16 / 9,
43
43
  className,
44
44
  children,
45
+ autoFocus = false,
45
46
  ...settings
46
47
  }: VideoPlayerProps) {
47
48
  const normalized = useMemo(
@@ -54,13 +55,31 @@ export function VideoPlayer({
54
55
  // control bar to avoid a non-functional UI.
55
56
  const showControls = controls && !isIframe;
56
57
 
58
+ // MediaController is a custom element; without `tabindex` it cannot take
59
+ // focus, so its built-in keyboard shortcuts (space/arrows/f) never fire.
60
+ // We type the ref through the element interface (HTMLElement methods are
61
+ // all we use) — media-chrome's full MediaController type pulls private
62
+ // fields we don't need to see.
63
+ const controllerRef = useRef<HTMLElement | null>(null);
64
+ useEffect(() => {
65
+ if (!autoFocus) return;
66
+ const el = controllerRef.current;
67
+ if (!el) return;
68
+ if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
69
+ el.focus({ preventScroll: true });
70
+ }, [autoFocus]);
71
+
57
72
  return (
58
73
  <MediaController
74
+ ref={(el) => {
75
+ controllerRef.current = el as unknown as HTMLElement | null;
76
+ }}
59
77
  // Fade controls + scrim after 2.5s of inactivity while playing;
60
78
  // they reappear on mousemove / pause / focus (media-chrome built-in).
61
79
  autohide="2.5"
62
80
  className={cn(
63
81
  'video-player relative block w-full overflow-hidden rounded-lg bg-black',
82
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
64
83
  className,
65
84
  )}
66
85
  style={aspectRatioStyle(aspectRatio)}
@@ -79,4 +79,8 @@ export interface VideoPlayerProps extends VideoPlayerSettings {
79
79
  readonly className?: string;
80
80
  /** Custom children replace the default control bar entirely. */
81
81
  readonly children?: ReactNode;
82
+ /** Focus the player container on mount so media-chrome keyboard shortcuts
83
+ * (space=play/pause, f=fullscreen, arrows=seek/volume) are active
84
+ * immediately. Pair with `key={src}` upstream for per-source focus reset. */
85
+ readonly autoFocus?: boolean;
82
86
  }