11import type { ScrollToInterface } from "@fluentui-contrib/react-virtualizer" ;
2- import type { TreeItemValue , TreeOpenChangeData , TreeOpenChangeEvent } from "@fluentui/react-components" ;
2+ import type { MenuCheckedValueChangeData , MenuCheckedValueChangeEvent , TreeItemValue , TreeOpenChangeData , TreeOpenChangeEvent } from "@fluentui/react-components" ;
33import type { FluentIcon } from "@fluentui/react-icons" ;
44import type { ComponentType , FunctionComponent } from "react" ;
55
@@ -14,7 +14,9 @@ import {
1414 FlatTreeItem ,
1515 makeStyles ,
1616 Menu ,
17+ MenuDivider ,
1718 MenuItem ,
19+ MenuItemCheckbox ,
1820 MenuList ,
1921 MenuPopover ,
2022 MenuTrigger ,
@@ -25,7 +27,7 @@ import {
2527 TreeItemLayout ,
2628 treeItemLevelToken ,
2729} from "@fluentui/react-components" ;
28- import { ArrowExpandAllRegular , createFluentIcon , FilterRegular , GlobeRegular } from "@fluentui/react-icons" ;
30+ import { ArrowCollapseAllRegular , ArrowExpandAllRegular , createFluentIcon , FilterRegular , GlobeRegular } from "@fluentui/react-icons" ;
2931import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
3032
3133import { ToggleButton } from "shared-ui-components/fluent/primitives/toggleButton" ;
@@ -103,25 +105,33 @@ export type SceneExplorerSection<T extends EntityBase> = Readonly<{
103105 getEntityMovedObservables ?: ( ) => readonly IReadonlyObservable < T > [ ] ;
104106} > ;
105107
106- type Command = Partial < IDisposable > &
107- Readonly < {
108- /**
109- * The display name of the command (e.g. "Delete", "Rename", etc.).
110- */
111- displayName : string ;
108+ type InlineCommand = {
109+ /**
110+ * An icon component to render for the command. Required for inline commands.
111+ */
112+ icon : ComponentType ;
112113
113- /**
114- * An icon component to render for the command.
115- */
116- icon : ComponentType ;
114+ /**
115+ * The mode of the command. Inline commands are shown directly in the tree item layout. Inline by default.
116+ */
117+ mode ?: "inline" ;
118+ } ;
117119
118- /**
119- * An observable that notifies when the command state changes.
120- */
121- onChange ?: IReadonlyObservable < unknown > ;
122- } > ;
120+ type ContextMenuCommand = {
121+ /**
122+ * An icon component to render for the command. Optional for context menu commands.
123+ */
124+ icon ?: ComponentType ;
125+
126+ /**
127+ * The mode of the command. Context menu commands are shown in the context menu for the tree item.
128+ */
129+ mode : "contextMenu" ;
130+ } ;
123131
124- type ActionCommand = Command & {
132+ type CommandMode = ( InlineCommand | ContextMenuCommand ) [ "mode" ] ;
133+
134+ type ActionCommand = {
125135 readonly type : "action" ;
126136
127137 /**
@@ -130,7 +140,7 @@ type ActionCommand = Command & {
130140 execute ( ) : void ;
131141} ;
132142
133- type ToggleCommand = Command & {
143+ type ToggleCommand = {
134144 readonly type : "toggle" ;
135145
136146 /**
@@ -139,7 +149,22 @@ type ToggleCommand = Command & {
139149 isEnabled : boolean ;
140150} ;
141151
142- export type SceneExplorerCommand = ActionCommand | ToggleCommand ;
152+ type CommandType = ( ActionCommand | ToggleCommand ) [ "type" ] ;
153+
154+ export type SceneExplorerCommand < ModeT extends CommandMode = CommandMode , TypeT extends CommandType = CommandType > = Partial < IDisposable > &
155+ Readonly < {
156+ /**
157+ * The display name of the command (e.g. "Delete", "Rename", etc.).
158+ */
159+ displayName : string ;
160+
161+ /**
162+ * An observable that notifies when the command state changes.
163+ */
164+ onChange ?: IReadonlyObservable < unknown > ;
165+ } > &
166+ ( ModeT extends "inline" ? InlineCommand : ContextMenuCommand ) &
167+ ( TypeT extends "action" ? ActionCommand : ToggleCommand ) ;
143168
144169export type SceneExplorerCommandProvider < T extends EntityBase > = Readonly < {
145170 /**
@@ -224,7 +249,7 @@ const useStyles = makeStyles({
224249 } ,
225250} ) ;
226251
227- const ActionCommand : FunctionComponent < { command : ActionCommand } > = ( props ) => {
252+ const ActionCommand : FunctionComponent < { command : SceneExplorerCommand < "inline" , "action" > } > = ( props ) => {
228253 const { command } = props ;
229254
230255 // eslint-disable-next-line @typescript-eslint/naming-convention
@@ -240,7 +265,7 @@ const ActionCommand: FunctionComponent<{ command: ActionCommand }> = (props) =>
240265 ) ;
241266} ;
242267
243- const ToggleCommand : FunctionComponent < { command : ToggleCommand } > = ( props ) => {
268+ const ToggleCommand : FunctionComponent < { command : SceneExplorerCommand < "inline" , "toggle" > } > = ( props ) => {
244269 const { command } = props ;
245270
246271 // eslint-disable-next-line @typescript-eslint/naming-convention
@@ -256,7 +281,7 @@ const ToggleCommand: FunctionComponent<{ command: ToggleCommand }> = (props) =>
256281// This "placeholder" command has a blank icon and is a no-op. It is used for aside
257282// alignment when some toggle commands are enabled. See more details on the commands
258283// for setting the aside state.
259- const PlaceHolderCommand : ActionCommand = {
284+ const PlaceHolderCommand : SceneExplorerCommand < "inline" , "action" > = {
260285 type : "action" ,
261286 displayName : "" ,
262287 icon : createFluentIcon ( "Placeholder" , "1em" , "" ) ,
@@ -265,7 +290,7 @@ const PlaceHolderCommand: ActionCommand = {
265290 } ,
266291} ;
267292
268- function MakeCommandElement ( command : SceneExplorerCommand , isPlaceholder : boolean ) : JSX . Element {
293+ function MakeInlineCommandElement ( command : SceneExplorerCommand < "inline" > , isPlaceholder : boolean ) : JSX . Element {
269294 if ( isPlaceholder ) {
270295 // Placeholders are not visible and not interacted with, so they are always ActionCommand
271296 // components, just to ensure the exact right amount of space is taken up.
@@ -409,9 +434,11 @@ const EntityTreeItem: FunctionComponent<{
409434 } , [ entityItem . entity , commandProviders ] )
410435 ) ;
411436
437+ const inlineCommands = useMemo ( ( ) => commands . filter ( ( command ) : command is SceneExplorerCommand < "inline" > => command . mode !== "contextMenu" ) , [ commands ] ) ;
438+
412439 // TreeItemLayout actions (totally unrelated to "Action" type commands) are only visible when the item is focused or has pointer hover.
413440 const actions = useMemo ( ( ) => {
414- const defaultCommands : SceneExplorerCommand [ ] = [ ] ;
441+ const defaultCommands : SceneExplorerCommand < "inline" > [ ] = [ ] ;
415442 if ( hasChildren ) {
416443 defaultCommands . push ( {
417444 type : "action" ,
@@ -421,8 +448,8 @@ const EntityTreeItem: FunctionComponent<{
421448 } ) ;
422449 }
423450
424- return [ ...defaultCommands , ...commands ] . map ( ( command ) => MakeCommandElement ( command , false ) ) ;
425- } , [ commands , hasChildren , expandAll ] ) ;
451+ return [ ...defaultCommands , ...inlineCommands ] . map ( ( command ) => MakeInlineCommandElement ( command , false ) ) ;
452+ } , [ inlineCommands , hasChildren , expandAll ] ) ;
426453
427454 // TreeItemLayout asides are always visible.
428455 const [ aside , setAside ] = useState < readonly JSX . Element [ ] > ( [ ] ) ;
@@ -433,18 +460,18 @@ const EntityTreeItem: FunctionComponent<{
433460 const updateAside = ( ) => {
434461 let isAnyCommandEnabled = false ;
435462 const aside : JSX . Element [ ] = [ ] ;
436- for ( const command of commands ) {
463+ for ( const command of inlineCommands ) {
437464 isAnyCommandEnabled ||= command . type === "toggle" && command . isEnabled ;
438465 if ( isAnyCommandEnabled ) {
439- aside . push ( MakeCommandElement ( command , command . type !== "toggle" || ! command . isEnabled ) ) ;
466+ aside . push ( MakeInlineCommandElement ( command , command . type !== "toggle" || ! command . isEnabled ) ) ;
440467 }
441468 }
442469 setAside ( aside ) ;
443470 } ;
444471
445472 updateAside ( ) ;
446473
447- const observers = commands
474+ const observers = inlineCommands
448475 . map ( ( command ) => command . onChange )
449476 . filter ( ( onChange ) => ! ! onChange )
450477 . map ( ( onChange ) => onChange . add ( updateAside ) ) ;
@@ -454,10 +481,50 @@ const EntityTreeItem: FunctionComponent<{
454481 observer . remove ( ) ;
455482 }
456483 } ;
457- } , [ commands ] ) ;
484+ } , [ inlineCommands ] ) ;
485+
486+ const contextMenuCommands = useMemo ( ( ) => commands . filter ( ( command ) : command is SceneExplorerCommand < "contextMenu" > => command . mode === "contextMenu" ) , [ commands ] ) ;
487+
488+ const [ checkedContextMenuItems , setCheckedContextMenuItems ] = useState ( { toggleCommands : [ ] as string [ ] } ) ;
489+
490+ useEffect ( ( ) => {
491+ const updateCheckedItems = ( ) => {
492+ const checkedItems : string [ ] = [ ] ;
493+ for ( const command of contextMenuCommands ) {
494+ if ( command . type === "toggle" && command . isEnabled ) {
495+ checkedItems . push ( command . displayName ) ;
496+ }
497+ }
498+ setCheckedContextMenuItems ( { toggleCommands : checkedItems } ) ;
499+ } ;
500+
501+ updateCheckedItems ( ) ;
502+
503+ const observers = contextMenuCommands
504+ . map ( ( command ) => command . onChange )
505+ . filter ( ( onChange ) => ! ! onChange )
506+ . map ( ( onChange ) => onChange . add ( updateCheckedItems ) ) ;
507+
508+ return ( ) => {
509+ for ( const observer of observers ) {
510+ observer . remove ( ) ;
511+ }
512+ } ;
513+ } , [ contextMenuCommands ] ) ;
514+
515+ const onContextMenuCheckedValueChange = useCallback (
516+ ( e : MenuCheckedValueChangeEvent , data : MenuCheckedValueChangeData ) => {
517+ for ( const command of contextMenuCommands ) {
518+ if ( command . type === "toggle" ) {
519+ command . isEnabled = data . checkedItems . includes ( command . displayName ) ;
520+ }
521+ }
522+ } ,
523+ [ contextMenuCommands ]
524+ ) ;
458525
459526 return (
460- < Menu openOnContext >
527+ < Menu openOnContext checkedValues = { checkedContextMenuItems } onCheckedValueChange = { onContextMenuCheckedValueChange } >
461528 < MenuTrigger disableButtonEnhancement >
462529 < FlatTreeItem
463530 key = { entityItem . entity . uniqueId }
@@ -492,14 +559,37 @@ const EntityTreeItem: FunctionComponent<{
492559 </ TreeItemLayout >
493560 </ FlatTreeItem >
494561 </ MenuTrigger >
495- < MenuPopover hidden = { ! hasChildren } >
562+ < MenuPopover hidden = { ! hasChildren && contextMenuCommands . length === 0 } >
496563 < MenuList >
497- < MenuItem onClick = { expandAll } >
498- < Body1 > Expand All</ Body1 >
499- </ MenuItem >
500- < MenuItem onClick = { collapseAll } >
501- < Body1 > Collapse All</ Body1 >
502- </ MenuItem >
564+ { hasChildren && (
565+ < >
566+ < MenuItem icon = { < ArrowExpandAllRegular /> } onClick = { expandAll } >
567+ < Body1 > Expand All</ Body1 >
568+ </ MenuItem >
569+ < MenuItem icon = { < ArrowCollapseAllRegular /> } onClick = { collapseAll } >
570+ < Body1 > Collapse All</ Body1 >
571+ </ MenuItem >
572+ </ >
573+ ) }
574+ { hasChildren && contextMenuCommands . length > 0 && < MenuDivider /> }
575+ { contextMenuCommands . map ( ( command ) =>
576+ command . type === "action" ? (
577+ < MenuItem key = { command . displayName } icon = { command . icon ? < command . icon /> : undefined } onClick = { ( ) => command . execute ( ) } >
578+ { command . displayName }
579+ </ MenuItem >
580+ ) : (
581+ < MenuItemCheckbox
582+ key = { command . displayName }
583+ // Don't show both a checkmark and an icon. null means no checkmark, undefined means default (checkmark).
584+ checkmark = { command . icon ? null : undefined }
585+ icon = { command . icon ? < command . icon /> : undefined }
586+ name = "toggleCommands"
587+ value = { command . displayName }
588+ >
589+ { command . displayName }
590+ </ MenuItemCheckbox >
591+ )
592+ ) }
503593 </ MenuList >
504594 </ MenuPopover >
505595 </ Menu >
0 commit comments