@djangocfg/ui-tools 2.1.416 → 2.1.417

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 (46) hide show
  1. package/dist/audio-player/index.cjs +2099 -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 +174 -0
  6. package/dist/audio-player/index.d.ts +174 -0
  7. package/dist/audio-player/index.mjs +2076 -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 +85 -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 +86 -64
  20. package/dist/tree/index.mjs.map +1 -1
  21. package/package.json +14 -9
  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/components/TreeRow.tsx +11 -0
  30. package/src/tools/data/Tree/context/TreeContext.tsx +22 -3
  31. package/src/tools/data/Tree/context/menu/index.ts +1 -0
  32. package/src/tools/data/Tree/context/menu/render.tsx +75 -0
  33. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +16 -2
  34. package/src/tools/data/Tree/index.tsx +1 -0
  35. package/src/tools/data/Tree/types/index.ts +1 -1
  36. package/src/tools/data/Tree/types/root-props.ts +16 -0
  37. package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
  38. package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
  39. package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
  40. package/src/tools/integration/ComposerRegistry/index.ts +105 -0
  41. package/src/tools/media/AudioPlayer/Player.tsx +2 -0
  42. package/src/tools/media/AudioPlayer/PlayerShell.tsx +37 -22
  43. package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
  44. package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
  45. package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
  46. package/src/tools/media/AudioPlayer/types.ts +15 -0
@@ -10,6 +10,7 @@ import {
10
10
  DEFAULT_TREE_LABELS,
11
11
  type FlatRow,
12
12
  type TreeActivateOptions,
13
+ type TreeContextMenuSlot,
13
14
  type TreeItemId,
14
15
  type TreeLabels,
15
16
  type TreeNode,
@@ -22,7 +23,7 @@ import { useExpansion } from './expansion';
22
23
  import { useSelection } from './selection';
23
24
  import { useRename } from './rename';
24
25
  import { useClipboard } from './clipboard';
25
- import { useResolvedMenu } from './menu';
26
+ import { useResolvedMenu, renderItemsAsContextMenu, tidyMenuItems } from './menu';
26
27
  import { useDnd, type UseDndReturn } from './dnd';
27
28
  import { usePersistSync } from './persist';
28
29
  import type { TreeContextValue } from './TreeContextValue';
@@ -282,6 +283,24 @@ export function TreeProvider<T>(props: TreeProviderProps<T>) {
282
283
  pasteFromClipboard: clipboard.pasteFromClipboard,
283
284
  });
284
285
 
286
+ // Translate the declarative resolver into a slot-form
287
+ // `renderContextMenu` so <TreeRow> doesn't need to know about it.
288
+ // Explicit slot prop wins (escape-hatch for full custom menus).
289
+ const finalRenderContextMenu = useMemo<TreeContextMenuSlot<T> | undefined>(
290
+ () => {
291
+ if (renderContextMenu) return renderContextMenu;
292
+ const resolve = resolvedContextMenuActions;
293
+ if (!resolve) return undefined;
294
+ return (rowProps, trigger) => {
295
+ const items = resolve(rowProps);
296
+ const cleaned = items ? tidyMenuItems(items) : null;
297
+ if (!cleaned || cleaned.length === 0) return trigger;
298
+ return renderItemsAsContextMenu(rowProps, cleaned, trigger);
299
+ };
300
+ },
301
+ [renderContextMenu, resolvedContextMenuActions],
302
+ );
303
+
285
304
  // ---- Final value --------------------------------------------------
286
305
 
287
306
  const value = useMemo<TreeContextValue<T>>(
@@ -333,7 +352,7 @@ export function TreeProvider<T>(props: TreeProviderProps<T>) {
333
352
  renderIcon,
334
353
  renderLabel,
335
354
  renderActions,
336
- renderContextMenu,
355
+ renderContextMenu: finalRenderContextMenu,
337
356
 
338
357
  adapter,
339
358
  resolvedContextMenuActions,
@@ -371,7 +390,7 @@ export function TreeProvider<T>(props: TreeProviderProps<T>) {
371
390
  renderIcon,
372
391
  renderLabel,
373
392
  renderActions,
374
- renderContextMenu,
393
+ finalRenderContextMenu,
375
394
  adapter,
376
395
  resolvedContextMenuActions,
377
396
  nodeById,
@@ -8,3 +8,4 @@ export {
8
8
  export type { BuiltinActionContext } from './builtin-actions';
9
9
  export { useResolvedMenu } from './use-resolved-menu';
10
10
  export type { UseResolvedMenuOptions } from './use-resolved-menu';
11
+ export { renderItemsAsContextMenu, tidyMenuItems } from './render';
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+
3
+ import {
4
+ ContextMenu,
5
+ ContextMenuContent,
6
+ ContextMenuItem,
7
+ ContextMenuSeparator,
8
+ ContextMenuShortcut,
9
+ ContextMenuTrigger,
10
+ } from '@djangocfg/ui-core/components';
11
+
12
+ import type {
13
+ TreeContextMenuItem,
14
+ TreeRowRenderProps,
15
+ } from '../../types';
16
+
17
+ /**
18
+ * Render an array of declarative menu items as a themed `<ContextMenu>`
19
+ * wrapped around the supplied trigger element. Pure presentational layer
20
+ * — the caller resolves and merges items.
21
+ */
22
+ export function renderItemsAsContextMenu<T>(
23
+ rowProps: TreeRowRenderProps<T>,
24
+ items: TreeContextMenuItem<T>[],
25
+ trigger: React.ReactNode,
26
+ ): React.ReactNode {
27
+ return (
28
+ <ContextMenu>
29
+ <ContextMenuTrigger asChild>{trigger}</ContextMenuTrigger>
30
+ <ContextMenuContent>
31
+ {items.map((item, idx) => {
32
+ if (item === 'separator') {
33
+ return <ContextMenuSeparator key={`sep-${idx}`} />;
34
+ }
35
+ const Icon = item.icon;
36
+ return (
37
+ <ContextMenuItem
38
+ key={item.id}
39
+ disabled={item.disabled}
40
+ variant={item.destructive ? 'destructive' : undefined}
41
+ onSelect={() => item.onSelect(rowProps)}
42
+ >
43
+ {Icon ? <Icon /> : null}
44
+ {item.label}
45
+ {item.shortcut ? (
46
+ <ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>
47
+ ) : null}
48
+ </ContextMenuItem>
49
+ );
50
+ })}
51
+ </ContextMenuContent>
52
+ </ContextMenu>
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Drop trailing / leading / duplicate separators so a merged menu never
58
+ * shows a separator next to a section header or another separator.
59
+ */
60
+ export function tidyMenuItems<T>(
61
+ items: TreeContextMenuItem<T>[],
62
+ ): TreeContextMenuItem<T>[] {
63
+ const out: TreeContextMenuItem<T>[] = [];
64
+ for (const it of items) {
65
+ if (it === 'separator') {
66
+ if (out.length === 0) continue;
67
+ if (out[out.length - 1] === 'separator') continue;
68
+ out.push(it);
69
+ } else {
70
+ out.push(it);
71
+ }
72
+ }
73
+ while (out.length > 0 && out[out.length - 1] === 'separator') out.pop();
74
+ return out;
75
+ }
@@ -7,16 +7,30 @@ import type {
7
7
  TreeAdapter,
8
8
  TreeBuiltinAction,
9
9
  TreeContextMenuActionsResolver,
10
+ TreeContextMenuItem,
10
11
  TreeItemId,
11
12
  TreeLabels,
12
13
  TreeMovePosition,
13
14
  TreeNode,
15
+ TreeRowRenderProps,
14
16
  } from '../../types';
17
+
18
+ export type { TreeContextMenuActionsResolver };
15
19
  import {
16
20
  buildDefaultMenuItems,
17
21
  type BuiltinActionContext,
18
22
  } from './builtin-actions';
19
23
 
24
+ /**
25
+ * Internal row-driven resolver — same shape as
26
+ * `TreeContextMenuActionsResolver` but takes a plain `TreeRowRenderProps`
27
+ * instead of the context-with-selection. The provider injects
28
+ * `selectedNodes` itself.
29
+ */
30
+ export type ResolvedMenuResolver<T> = (
31
+ row: TreeRowRenderProps<T>,
32
+ ) => TreeContextMenuItem<T>[] | null | undefined;
33
+
20
34
  export interface UseResolvedMenuOptions<T> {
21
35
  adapter?: TreeAdapter<T>;
22
36
  contextMenuActions?: TreeContextMenuActionsResolver<T>;
@@ -50,7 +64,7 @@ export interface UseResolvedMenuOptions<T> {
50
64
  */
51
65
  export function useResolvedMenu<T>(
52
66
  opts: UseResolvedMenuOptions<T>,
53
- ): TreeContextMenuActionsResolver<T> | undefined {
67
+ ): ResolvedMenuResolver<T> | undefined {
54
68
  const {
55
69
  adapter,
56
70
  contextMenuActions,
@@ -67,7 +81,7 @@ export function useResolvedMenu<T>(
67
81
  pasteFromClipboard,
68
82
  } = opts;
69
83
 
70
- return useMemo<TreeContextMenuActionsResolver<T> | undefined>(() => {
84
+ return useMemo<ResolvedMenuResolver<T> | undefined>(() => {
71
85
  if (!adapter && !contextMenuActions) return undefined;
72
86
 
73
87
  return (rowProps) => {
@@ -105,6 +105,7 @@ export type {
105
105
 
106
106
  export { DEFAULT_TREE_LABELS } from './types';
107
107
  export type {
108
+ TreeActionsHandle,
108
109
  TreeRootProps,
109
110
  TreeNode,
110
111
  TreeItemId,
@@ -24,4 +24,4 @@ export type {
24
24
  TreeAdapter,
25
25
  TreeBuiltinAction,
26
26
  } from './adapter';
27
- export type { TreeRootProps } from './root-props';
27
+ export type { TreeRootProps, TreeActionsHandle } from './root-props';
@@ -137,6 +137,22 @@ export interface TreeRootProps<T> {
137
137
  */
138
138
  defaultMenuItems?: TreeBuiltinAction[];
139
139
 
140
+ /**
141
+ * Imperative handle for outer code. The provided ref receives a
142
+ * stable handle to `useTreeActions` once Tree mounts. Lets host
143
+ * components trigger `refresh(id)` / `refreshAll()` from outside
144
+ * Tree (e.g. after a transport-level mutation completes).
145
+ */
146
+ actionsRef?: React.MutableRefObject<TreeActionsHandle | null>;
147
+
140
148
  className?: string;
141
149
  style?: CSSProperties;
142
150
  }
151
+
152
+ /** Subset of `useTreeActions()` exposed via `<TreeRoot actionsRef={…}>`. */
153
+ export interface TreeActionsHandle {
154
+ refresh: (id: string) => Promise<void>;
155
+ refreshAll: () => Promise<void>;
156
+ expandAll: () => void;
157
+ collapseAll: () => void;
158
+ }
@@ -12,7 +12,6 @@ import {
12
12
  Tooltip,
13
13
  TooltipContent,
14
14
  TooltipTrigger,
15
- SafeTooltipProvider,
16
15
  } from '@djangocfg/ui-core/components';
17
16
  import { cn } from '@djangocfg/ui-core/lib';
18
17
 
@@ -119,13 +118,11 @@ export function MetaActions({ anchor, presentSections }: MetaActionsProps) {
119
118
  ) : null;
120
119
 
121
120
  return (
122
- <SafeTooltipProvider delayDuration={200}>
123
- <div className="flex items-center gap-0.5">
124
- <IconButton label={linkLabel} onClick={copyLink} active={linkCopied}>
125
- {linkIcon}
126
- </IconButton>
127
- {toggleAllNode}
128
- </div>
129
- </SafeTooltipProvider>
121
+ <div className="flex items-center gap-0.5">
122
+ <IconButton label={linkLabel} onClick={copyLink} active={linkCopied}>
123
+ {linkIcon}
124
+ </IconButton>
125
+ {toggleAllNode}
126
+ </div>
130
127
  );
131
128
  }
@@ -3,7 +3,7 @@
3
3
  import React, { useCallback, useMemo, useRef, useState } from 'react';
4
4
  import { keyBy } from 'lodash-es';
5
5
 
6
- import { Skeleton, TooltipProvider } from '@djangocfg/ui-core/components';
6
+ import { Skeleton } from '@djangocfg/ui-core/components';
7
7
  import { useMediaQuery } from '@djangocfg/ui-core/hooks';
8
8
 
9
9
  import useOpenApiSchema from '../../hooks/useOpenApiSchema';
@@ -234,8 +234,7 @@ export const DocsLayout: React.FC = () => {
234
234
  // ─── Desktop ──────────────────────────────────────────────────────────
235
235
 
236
236
  return (
237
- <TooltipProvider delayDuration={350}>
238
- <div className="grid grid-cols-[260px_minmax(0,1fr)] items-start">
237
+ <div className="grid grid-cols-[260px_minmax(0,1fr)] items-start">
239
238
  <EndpointDraftSync schemaId={currentSchema?.id ?? null} />
240
239
  <div
241
240
  className="sticky top-[var(--navbar-height,64px)]"
@@ -283,6 +282,5 @@ export const DocsLayout: React.FC = () => {
283
282
  above the whole layout (sidebar + navbar included). */}
284
283
  <SlideInPlayground open={slideOpen} onClose={handleCloseSlide} />
285
284
  </div>
286
- </TooltipProvider>
287
285
  );
288
286
  };
@@ -6,8 +6,7 @@ import { AlertCircle, Loader2, Mic } from 'lucide-react';
6
6
 
7
7
  import { useCountdownFromSeconds, useNotificationSounds } from '@djangocfg/ui-core/hooks';
8
8
  import { cn } from '@djangocfg/ui-core/lib';
9
-
10
- import { useChatContextOptional } from '../../../chat/context';
9
+ import { useActiveComposer } from '@djangocfg/ui-tools/composer-registry';
11
10
  import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
12
11
  import { useVoiceSupport } from '../hooks/useVoiceSupport';
13
12
  import { getSpeechLogger } from '../core/logger';
@@ -105,24 +104,24 @@ export function VoiceComposerSlot({
105
104
  }: VoiceComposerSlotProps): React.ReactElement | null {
106
105
  const support = useVoiceSupport(engine);
107
106
 
108
- // Read the composer handle from chat context — works transparently
109
- // for the built-in `<Composer>` (registers itself) and for TipTap
110
- // hosts that call `useRegisterComposer({ getValue, setValue, focus,
111
- // moveCursorToEnd })`. Falls back to a no-op when mounted outside of
112
- // a chat.
113
- const chatCtx = useChatContextOptional();
114
- const composerHandleRef = useRef(chatCtx?.composer ?? null);
115
- composerHandleRef.current = chatCtx?.composer ?? null;
107
+ // Read the active composer handle from the cross-tool registry
108
+ // (`@djangocfg/ui-tools/composer-registry`). The built-in
109
+ // `<Composer>` (and TipTap hosts via `useRegisterComposer`) publish
110
+ // their handle to this registry on mount. Falls back to a no-op
111
+ // when nothing is registered (no composer in the tree).
112
+ const activeComposer = useActiveComposer();
113
+ const composerHandleRef = useRef(activeComposer);
114
+ composerHandleRef.current = activeComposer;
116
115
 
117
116
  useEffect(() => {
118
117
  log.slot.debug('mount', {
119
118
  supported: support.supported,
120
119
  reason: support.reason,
121
- hasComposerHandle: !!chatCtx?.composer,
120
+ hasComposerHandle: !!activeComposer,
122
121
  hasExplicitValue: value !== undefined,
123
122
  hasOnChange: !!onChange,
124
123
  });
125
- }, [support.supported, support.reason, chatCtx?.composer, value, onChange]);
124
+ }, [support.supported, support.reason, activeComposer, value, onChange]);
126
125
 
127
126
  // Resolve value/onChange: prop wins; otherwise pull from the
128
127
  // registered composer handle. The slot can therefore be dropped into
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useSyncExternalStore } from 'react';
4
+
5
+ /**
6
+ * Minimal imperative handle every text-editor surface implements so
7
+ * an external tool (voice dictation, command palette, AI suggestion)
8
+ * can read/write its text content without traversing React.
9
+ *
10
+ * Methods are optional so a host can register a partial handle
11
+ * (e.g. only `getValue` + `setValue`), and the caller checks before use.
12
+ */
13
+ export interface ComposerHandle {
14
+ /** Move keyboard focus into the composer's editable surface. */
15
+ focus: () => void;
16
+ /** Move the caret to the very end of the input. */
17
+ moveCursorToEnd?: () => void;
18
+ /** Read the current draft text. Voice dictation anchors partial
19
+ * transcripts onto the user's already-typed prefix via this. */
20
+ getValue?: () => string;
21
+ /** Replace the current draft text. Voice dictation pushes interim
22
+ * and final transcripts through this without owning a controlled
23
+ * binding. */
24
+ setValue?: (value: string) => void;
25
+ }
26
+
27
+ /**
28
+ * `@djangocfg/ui-tools/composer-registry`
29
+ *
30
+ * Cross-tool bridge: the currently-active text composer's handle.
31
+ *
32
+ * Producer side (`@djangocfg/ui-tools/chat` and TipTap hosts):
33
+ * register their composer's imperative handle via `attachComposer`.
34
+ *
35
+ * Consumer side (`@djangocfg/ui-tools/speech-recognition`):
36
+ * reads the active handle via `useActiveComposer`/`getActiveComposer`
37
+ * and pipes voice transcripts into it.
38
+ *
39
+ * Why this lives in its own subpath (not inside `chat`)
40
+ * ----------------------------------------------------
41
+ * `chat` and `speech-recognition` are sibling subpath exports. If the
42
+ * registry lived inside `chat`, then `speech-recognition` would have
43
+ * to reach into it via a cross-tool relative import — and under Vite
44
+ * dev's dependency optimizer that file ends up loaded TWICE (once via
45
+ * the `./chat` URL, once via the `./speech-recognition` relative-up
46
+ * URL), giving the producer and the consumer two separate `let active`
47
+ * slots. The active handle registered by chat would be invisible to
48
+ * speech-recognition (and vice versa).
49
+ *
50
+ * Putting the registry in its own dedicated subpath (a single tool
51
+ * that NEITHER chat nor speech-recognition cross-import — they both
52
+ * import this one as their dependency) means Vite resolves it from a
53
+ * single URL across the whole graph. One module instance, one shared
54
+ * `active` slot.
55
+ *
56
+ * Semantics: one active composer per realm. The most recent
57
+ * `registerComposer(handle)` wins; `registerComposer(null)` clears it.
58
+ */
59
+
60
+ type Listener = (handle: ComposerHandle | null) => void;
61
+
62
+ let active: ComposerHandle | null = null;
63
+ const listeners = new Set<Listener>();
64
+
65
+ /** Set or replace the active composer handle. Pass `null` to clear. */
66
+ export function registerComposer(handle: ComposerHandle | null): void {
67
+ active = handle;
68
+ for (const fn of listeners) fn(active);
69
+ }
70
+
71
+ /**
72
+ * Convenience for components: register on mount, unregister on
73
+ * unmount. Returns a cleanup function suitable for `useEffect`.
74
+ */
75
+ export function attachComposer(handle: ComposerHandle): () => void {
76
+ registerComposer(handle);
77
+ return () => {
78
+ if (active === handle) registerComposer(null);
79
+ };
80
+ }
81
+
82
+ /** Read the current active handle (no subscription). */
83
+ export function getActiveComposer(): ComposerHandle | null {
84
+ return active;
85
+ }
86
+
87
+ /** Subscribe to handle changes; returns an unsubscribe fn. */
88
+ export function subscribeComposer(listener: Listener): () => void {
89
+ listeners.add(listener);
90
+ return () => {
91
+ listeners.delete(listener);
92
+ };
93
+ }
94
+
95
+ /**
96
+ * React hook: re-renders the caller whenever the active composer
97
+ * changes. Built on `useSyncExternalStore` so concurrent rendering,
98
+ * SSR, and dev-mode strict-effects all behave correctly.
99
+ */
100
+ export function useActiveComposer(): ComposerHandle | null {
101
+ const subscribe = useCallback((onChange: () => void) => {
102
+ return subscribeComposer(onChange);
103
+ }, []);
104
+ return useSyncExternalStore(subscribe, getActiveComposer, () => null);
105
+ }
@@ -34,6 +34,7 @@ export const Player = forwardRef<PlayerHandle, PlayerProps>(function Player(prop
34
34
  ariaLabel,
35
35
  enableKeyboardShortcuts,
36
36
  seekStartsPlayback,
37
+ autoFocus,
37
38
  } = props;
38
39
 
39
40
  // onTimeUpdate is intentionally not wired in the provider — we expose it via
@@ -71,6 +72,7 @@ export const Player = forwardRef<PlayerHandle, PlayerProps>(function Player(prop
71
72
  enableKeyboardShortcuts={enableKeyboardShortcuts}
72
73
  ariaLabel={ariaLabel}
73
74
  seekStartsPlayback={seekStartsPlayback}
75
+ autoFocus={autoFocus}
74
76
  handleRef={ref}
75
77
  />
76
78
  </PlayerProvider>
@@ -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,39 @@ export function PlayerShell({
97
99
  container.setAttribute('tabindex', '0');
98
100
  }, [container]);
99
101
 
102
+ // `autoFocus` opts the player into pulling keyboard focus on mount
103
+ // (and whenever the container ref is established). Once focused,
104
+ // the hotkey scope (Space=play/pause, ←→=seek, ↑↓=volume, M=mute)
105
+ // is immediately live.
106
+ //
107
+ // Deferred via a 0-timeout so the tree row's native focus event
108
+ // (fired by the click that triggered the mount) lands first; we
109
+ // then steal focus here. rAF was racy — the row's focus event can
110
+ // fire AFTER the next animation frame.
111
+ useEffect(() => {
112
+ if (!autoFocus || !container) return;
113
+ const id = setTimeout(() => container.focus(), 0);
114
+ return () => clearTimeout(id);
115
+ }, [autoFocus, container]);
116
+
100
117
  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>
118
+ <div
119
+ ref={setRootRef}
120
+ role="group"
121
+ aria-label={ariaLabel ?? 'Audio player'}
122
+ 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}`}
123
+ >
124
+ {resolvedVariant === 'compact' ? (
125
+ <CompactLayout waveform={waveform} seekStartsPlayback={seekStartsPlayback} />
126
+ ) : (
127
+ <DefaultLayout
128
+ waveform={waveform}
129
+ reactiveCover={reactiveCover}
130
+ onPrev={onPrev}
131
+ onNext={onNext}
132
+ seekStartsPlayback={seekStartsPlayback}
133
+ />
134
+ )}
135
+ </div>
121
136
  );
122
137
  }
@@ -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';