@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.
- package/dist/audio-player/index.cjs +2098 -0
- package/dist/audio-player/index.cjs.map +1 -0
- package/dist/audio-player/index.css +65 -0
- package/dist/audio-player/index.css.map +1 -0
- package/dist/audio-player/index.d.cts +166 -0
- package/dist/audio-player/index.d.ts +166 -0
- package/dist/audio-player/index.mjs +2075 -0
- package/dist/audio-player/index.mjs.map +1 -0
- package/dist/composer-registry/index.cjs +45 -0
- package/dist/composer-registry/index.cjs.map +1 -0
- package/dist/composer-registry/index.d.cts +73 -0
- package/dist/composer-registry/index.d.ts +73 -0
- package/dist/composer-registry/index.mjs +39 -0
- package/dist/composer-registry/index.mjs.map +1 -0
- package/dist/tree/index.cjs +82 -63
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +15 -1
- package/dist/tree/index.d.ts +15 -1
- package/dist/tree/index.mjs +83 -64
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +38 -17
- package/src/tools/chat/composer/Composer.tsx +8 -8
- package/src/tools/chat/context/ChatProvider.tsx +13 -78
- package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
- package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
- package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
- package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
- package/src/tools/data/Tree/TreeRoot.tsx +33 -109
- package/src/tools/data/Tree/context/TreeContext.tsx +22 -3
- package/src/tools/data/Tree/context/menu/index.ts +1 -0
- package/src/tools/data/Tree/context/menu/render.tsx +75 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +16 -2
- package/src/tools/data/Tree/index.tsx +1 -0
- package/src/tools/data/Tree/types/index.ts +1 -1
- package/src/tools/data/Tree/types/root-props.ts +16 -0
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
- package/src/tools/forms/CodeEditor/components/Editor.tsx +19 -0
- package/src/tools/forms/CodeEditor/types/index.ts +7 -0
- package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +40 -0
- package/src/tools/forms/MarkdownEditor/styles.css +174 -21
- package/src/tools/forms/NotionEditor/CustomKeymap.ts +48 -0
- package/src/tools/forms/NotionEditor/LinkDialog.tsx +133 -0
- package/src/tools/forms/NotionEditor/NotionEditor.tsx +304 -0
- package/src/tools/forms/NotionEditor/SlashExtension.ts +32 -0
- package/src/tools/forms/NotionEditor/SlashList.tsx +136 -0
- package/src/tools/forms/NotionEditor/TaskItemView.tsx +41 -0
- package/src/tools/forms/NotionEditor/createSlashSuggestion.ts +121 -0
- package/src/tools/forms/NotionEditor/extensions.ts +105 -0
- package/src/tools/forms/NotionEditor/index.ts +1 -0
- package/src/tools/forms/NotionEditor/lazy.tsx +44 -0
- package/src/tools/forms/NotionEditor/slashItems.ts +159 -0
- package/src/tools/forms/NotionEditor/styles.css +478 -0
- package/src/tools/forms/NotionEditor/types.ts +28 -0
- package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
- package/src/tools/integration/ComposerRegistry/index.ts +105 -0
- package/src/tools/media/AudioPlayer/Player.tsx +2 -0
- package/src/tools/media/AudioPlayer/PlayerShell.tsx +29 -22
- package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
- package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
- package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
- package/src/tools/media/AudioPlayer/types.ts +8 -0
- package/src/tools/media/ImageViewer/components/ImageViewer.tsx +8 -0
- package/src/tools/media/ImageViewer/types.ts +4 -0
- package/src/tools/media/VideoPlayer/VideoPlayer.tsx +20 -1
- 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
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
{
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
32
|
+
// Player component (synchronous; previously lazy)
|
|
22
33
|
// ============================================================================
|
|
23
34
|
|
|
24
35
|
export { Player, Player as LazyPlayer } from './Player';
|
|
25
36
|
|
|
26
37
|
// ============================================================================
|
|
27
|
-
//
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
children,
|
|
27
|
-
|
|
28
|
-
|
|
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,
|
|
5
|
-
import {
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
<
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|