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
1521import { getRootIconUrl } from "../../assets/rootIcons" ;
1622import { getIoIconUrl } from "../../assets/ioIcons" ;
1723import { getHelpIconUrl } from "../../assets/helpIcons" ;
1824import { 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+
2038const SHOW_ON_STARTUP_KEY = "datalab-web.welcome.showOnStartup" ;
2139
2240export 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-
4960interface 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