Skip to content

Commit 891d391

Browse files
committed
feat(welcome): make Create/Open file rows open a Signal/Image picker
1 parent 7dc1acc commit 891d391

3 files changed

Lines changed: 317 additions & 116 deletions

File tree

‎src/components/welcome/WelcomeView.tsx‎

Lines changed: 206 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,31 @@
1010
* business logic.
1111
*/
1212

13-
import { useCallback } from "react";
13+
import {
14+
useCallback,
15+
useEffect,
16+
useLayoutEffect,
17+
useRef,
18+
useState,
19+
} from "react";
1420

1521
import { getRootIconUrl } from "../../assets/rootIcons";
1622
import { getIoIconUrl } from "../../assets/ioIcons";
1723
import { getHelpIconUrl } from "../../assets/helpIcons";
1824
import { getH5IconUrl } from "../../assets/h5Icons";
1925

26+
type Kind = "signal" | "image";
27+
28+
const KIND_LABEL: Record<Kind, string> = {
29+
signal: "Signal",
30+
image: "Image",
31+
};
32+
33+
const KIND_ICON: Record<Kind, string> = {
34+
signal: "signal.svg",
35+
image: "image.svg",
36+
};
37+
2038
const SHOW_ON_STARTUP_KEY = "datalab-web.welcome.showOnStartup";
2139

2240
export interface WelcomeViewProps {
@@ -28,9 +46,9 @@ export interface WelcomeViewProps {
2846
* Welcome view explicitly on a non-empty workspace. */
2947
onDismiss?: () => void;
3048
/** Switch to ``kind`` panel and open the Create menu. */
31-
onCreateKind: (kind: "signal" | "image") => void;
49+
onCreateKind: (kind: Kind) => void;
3250
/** Switch to ``kind`` panel and open the file picker (kind-aware). */
33-
onOpenFileKind: (kind: "signal" | "image") => void;
51+
onOpenFileKind: (kind: Kind) => void;
3452
/** Open the HDF5 browser dialog (flagship feature). */
3553
onBrowseHdf5: () => void;
3654
onOpenWorkspaceHdf5: () => void;
@@ -39,19 +57,15 @@ export interface WelcomeViewProps {
3957
onOpenUserGuide: () => void;
4058
}
4159

42-
interface KindChip {
43-
kind: "signal" | "image";
44-
label: string;
45-
iconUrl: string | undefined;
46-
onClick: () => void;
47-
}
48-
4960
interface QuickAction {
5061
iconUrl: string | undefined;
5162
label: string;
5263
description?: string;
5364
onClick?: () => void;
54-
chips?: KindChip[];
65+
/** When set, clicking the row opens a small popover letting the user
66+
* choose between Signal and Image. ``onKindSelect`` receives the
67+
* picked kind. */
68+
onKindSelect?: (kind: Kind) => void;
5569
}
5670

5771
/** Read/write helper for the "show welcome on startup" preference. */
@@ -85,37 +99,18 @@ export function WelcomeView({
8599
onStartTour,
86100
onOpenUserGuide,
87101
}: WelcomeViewProps) {
88-
const signalChip: KindChip = {
89-
kind: "signal",
90-
label: "Signal",
91-
iconUrl: getRootIconUrl("signal.svg"),
92-
onClick: () => {},
93-
};
94-
const imageChip: KindChip = {
95-
kind: "image",
96-
label: "Image",
97-
iconUrl: getRootIconUrl("image.svg"),
98-
onClick: () => {},
99-
};
100-
101102
const startActions: QuickAction[] = [
102103
{
103104
iconUrl: getRootIconUrl("signal.svg"),
104105
label: "Create…",
105106
description: "Generate a 1D signal or 2D image from a Sigima template.",
106-
chips: [
107-
{ ...signalChip, onClick: () => onCreateKind("signal") },
108-
{ ...imageChip, onClick: () => onCreateKind("image") },
109-
],
107+
onKindSelect: onCreateKind,
110108
},
111109
{
112110
iconUrl: getIoIconUrl("fileopen_sig.svg"),
113111
label: "Open file…",
114112
description: "Load a signal or image from your computer.",
115-
chips: [
116-
{ ...signalChip, onClick: () => onOpenFileKind("signal") },
117-
{ ...imageChip, onClick: () => onOpenFileKind("image") },
118-
],
113+
onKindSelect: onOpenFileKind,
119114
},
120115
{
121116
iconUrl: getH5IconUrl("h5browser.svg"),
@@ -239,66 +234,189 @@ function WelcomeActionRow({
239234
variant === "card"
240235
? "welcome-action welcome-action-card"
241236
: "welcome-action";
242-
const hasChips = action.chips && action.chips.length > 0;
243-
const content = (
244-
<>
245-
{action.iconUrl && (
246-
<img
247-
src={action.iconUrl}
248-
alt=""
249-
aria-hidden="true"
250-
className="welcome-action-icon"
251-
/>
252-
)}
253-
<span className="welcome-action-text">
254-
<span className="welcome-action-label">{action.label}</span>
255-
{action.description && (
256-
<span className="welcome-action-description">
257-
{action.description}
258-
</span>
259-
)}
260-
</span>
261-
{hasChips && (
262-
<span className="welcome-action-chips">
263-
{action.chips!.map((chip) => (
264-
<button
265-
key={chip.kind}
266-
type="button"
267-
className="welcome-action-chip"
268-
onClick={(e) => {
269-
e.stopPropagation();
270-
chip.onClick();
271-
}}
272-
title={`${action.label.replace(/…$/, "")} ${chip.label}`}
273-
>
274-
{chip.iconUrl && (
275-
<img
276-
src={chip.iconUrl}
277-
alt=""
278-
aria-hidden="true"
279-
className="welcome-action-chip-icon"
280-
/>
281-
)}
282-
<span>{chip.label}</span>
283-
</button>
284-
))}
285-
</span>
286-
)}
287-
</>
237+
const buttonRef = useRef<HTMLButtonElement | null>(null);
238+
const [pickerOpen, setPickerOpen] = useState(false);
239+
const hasKindPicker = !!action.onKindSelect;
240+
const handleClick = useCallback(() => {
241+
if (hasKindPicker) {
242+
setPickerOpen((open) => !open);
243+
} else {
244+
action.onClick?.();
245+
}
246+
}, [action, hasKindPicker]);
247+
const handleKindPick = useCallback(
248+
(kind: Kind) => {
249+
setPickerOpen(false);
250+
action.onKindSelect?.(kind);
251+
},
252+
[action],
288253
);
289-
if (hasChips) {
290-
// No outer button — chips are the interactive elements.
291-
return (
292-
<li>
293-
<div className={className + " welcome-action-static"}>{content}</div>
294-
</li>
295-
);
296-
}
297254
return (
298255
<li>
299-
<button type="button" className={className} onClick={action.onClick}>
300-
{content}
256+
<button
257+
ref={buttonRef}
258+
type="button"
259+
className={className}
260+
onClick={handleClick}
261+
aria-haspopup={hasKindPicker ? "menu" : undefined}
262+
aria-expanded={hasKindPicker ? pickerOpen : undefined}
263+
>
264+
{action.iconUrl && (
265+
<img
266+
src={action.iconUrl}
267+
alt=""
268+
aria-hidden="true"
269+
className="welcome-action-icon"
270+
/>
271+
)}
272+
<span className="welcome-action-text">
273+
<span className="welcome-action-label">{action.label}</span>
274+
{action.description && (
275+
<span className="welcome-action-description">
276+
{action.description}
277+
</span>
278+
)}
279+
</span>
280+
{hasKindPicker && (
281+
<span className="welcome-action-caret" aria-hidden="true">
282+
▾
283+
</span>
284+
)}
301285
</button>
286+
{hasKindPicker && pickerOpen && (
287+
<WelcomeKindPicker
288+
anchorRef={buttonRef}
289+
actionLabel={action.label}
290+
onPick={handleKindPick}
291+
onClose={() => setPickerOpen(false)}
292+
/>
293+
)}
302294
</li>
303295
);
304296
}
297+
298+
/**
299+
* Small floating popover anchored under a Welcome action row, letting
300+
* the user pick between **Signal** and **Image** for kind-aware actions
301+
* (Create…, Open file…).
302+
*
303+
* Closes on outside click, ``Escape``, scroll or window resize — same
304+
* dismissal contract as the app's :class:`ContextMenu`.
305+
*/
306+
function WelcomeKindPicker({
307+
anchorRef,
308+
actionLabel,
309+
onPick,
310+
onClose,
311+
}: {
312+
anchorRef: React.RefObject<HTMLElement | null>;
313+
actionLabel: string;
314+
onPick: (kind: Kind) => void;
315+
onClose: () => void;
316+
}) {
317+
const popoverRef = useRef<HTMLDivElement | null>(null);
318+
const [pos, setPos] = useState<{
319+
top: number;
320+
left: number;
321+
width: number;
322+
} | null>(null);
323+
const firstItemRef = useRef<HTMLButtonElement | null>(null);
324+
325+
// Anchor under the row's lower-left corner and match its width so the
326+
// popover spans the same clickable area as the row. Clamp inside the
327+
// viewport after the popover has been laid out.
328+
useLayoutEffect(() => {
329+
const anchor = anchorRef.current;
330+
if (!anchor) return;
331+
const rect = anchor.getBoundingClientRect();
332+
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
333+
}, [anchorRef]);
334+
335+
useLayoutEffect(() => {
336+
const el = popoverRef.current;
337+
if (!el || !pos) return;
338+
const rect = el.getBoundingClientRect();
339+
const margin = 4;
340+
let { top, left } = pos;
341+
if (left + rect.width > window.innerWidth - margin) {
342+
left = Math.max(margin, window.innerWidth - rect.width - margin);
343+
}
344+
if (top + rect.height > window.innerHeight - margin) {
345+
const anchorRect = anchorRef.current?.getBoundingClientRect();
346+
if (anchorRect) {
347+
top = Math.max(margin, anchorRect.top - rect.height - 4);
348+
} else {
349+
top = Math.max(margin, window.innerHeight - rect.height - margin);
350+
}
351+
}
352+
if (top !== pos.top || left !== pos.left) {
353+
setPos({ top, left, width: pos.width });
354+
}
355+
}, [pos, anchorRef]);
356+
357+
useEffect(() => {
358+
firstItemRef.current?.focus();
359+
}, []);
360+
361+
useEffect(() => {
362+
const handleDown = (event: MouseEvent) => {
363+
const target = event.target as Node;
364+
if (popoverRef.current?.contains(target)) return;
365+
if (anchorRef.current?.contains(target)) return;
366+
onClose();
367+
};
368+
const handleKey = (event: KeyboardEvent) => {
369+
if (event.key === "Escape") onClose();
370+
};
371+
const handleScrollOrResize = () => onClose();
372+
document.addEventListener("mousedown", handleDown);
373+
document.addEventListener("keydown", handleKey);
374+
window.addEventListener("resize", handleScrollOrResize);
375+
window.addEventListener("scroll", handleScrollOrResize, true);
376+
return () => {
377+
document.removeEventListener("mousedown", handleDown);
378+
document.removeEventListener("keydown", handleKey);
379+
window.removeEventListener("resize", handleScrollOrResize);
380+
window.removeEventListener("scroll", handleScrollOrResize, true);
381+
};
382+
}, [anchorRef, onClose]);
383+
384+
const cleanLabel = actionLabel.replace(/…$/, "");
385+
const kinds: Kind[] = ["signal", "image"];
386+
return (
387+
<div
388+
ref={popoverRef}
389+
className="welcome-kind-picker"
390+
role="menu"
391+
aria-label={cleanLabel}
392+
style={{
393+
position: "fixed",
394+
top: pos?.top ?? -9999,
395+
left: pos?.left ?? -9999,
396+
width: pos?.width,
397+
visibility: pos ? "visible" : "hidden",
398+
zIndex: 1000,
399+
}}
400+
>
401+
{kinds.map((kind, idx) => (
402+
<button
403+
key={kind}
404+
ref={idx === 0 ? firstItemRef : undefined}
405+
type="button"
406+
className="welcome-kind-picker-item"
407+
role="menuitem"
408+
onClick={() => onPick(kind)}
409+
title={`${cleanLabel} ${KIND_LABEL[kind]}`}
410+
>
411+
<img
412+
src={getRootIconUrl(KIND_ICON[kind])}
413+
alt=""
414+
aria-hidden="true"
415+
className="welcome-kind-picker-icon"
416+
/>
417+
<span>{KIND_LABEL[kind]}</span>
418+
</button>
419+
))}
420+
</div>
421+
);
422+
}

0 commit comments

Comments
 (0)