Skip to content

[Fluent] Add floating Search Box to graph canvas #16911

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/dev/sharedUiComponents/src/fluent/primitives/comboBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { FunctionComponent } from "react";
import { useState } from "react";
import { Combobox as FluentComboBox, makeStyles, useComboboxFilter, useId } from "@fluentui/react-components";
import type { OptionOnSelectData, SelectionEvents } from "@fluentui/react-components";
const useStyles = makeStyles({
root: {
// Stack the label above the field with a gap
display: "grid",
gridTemplateRows: "repeat(1fr)",
justifyItems: "start",
gap: "2px",
maxWidth: "400px",
},
});

export type ComboBoxProps = {
label: string;
value: string[];
onChange: (value: string) => void;
};
/**
* Wrapper around a Fluent ComboBox that allows for filtering options
* @param props
* @returns
*/
export const ComboBox: FunctionComponent<ComboBoxProps> = (props) => {
const comboId = useId();
const styles = useStyles();

const [query, setQuery] = useState("");
const children = useComboboxFilter(query, props.value, {
noOptionsMessage: "No items match your search.",
});
const onOptionSelect = (_e: SelectionEvents, data: OptionOnSelectData) => {
setQuery(data.optionText ?? "");
data.optionText && props.onChange(data.optionText);
};

return (
<div className={styles.root}>
<label id={comboId}>{props.label}</label>
<FluentComboBox onOptionSelect={onOptionSelect} aria-labelledby={comboId} placeholder="Search.." onChange={(ev) => setQuery(ev.target.value)} value={query}>
{children}
</FluentComboBox>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { makeStyles, tokens } from "@fluentui/react-components";
import { DeleteFilled } from "@fluentui/react-icons";
import { LineContainer } from "../hoc/propertyLines/propertyLine";

export type DraggableLineProps = {
format: string;
Expand All @@ -12,25 +11,52 @@ export type DraggableLineProps = {

const useDraggableStyles = makeStyles({
draggable: {
display: "inline-flex",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
columnGap: tokens.spacingHorizontalS,
cursor: "grab",
textAlign: "center",
boxSizing: "border-box",
borderBottom: "black",
margin: `${tokens.spacingVerticalXS} 0px`,
padding: `${tokens.spacingVerticalSNudge} ${tokens.spacingHorizontalMNudge}`,

// Button-like styling
backgroundColor: tokens.colorNeutralBackground1,
border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`,
borderRadius: tokens.borderRadiusMedium,
color: tokens.colorNeutralForeground1,
fontSize: tokens.fontSizeBase300,
fontFamily: tokens.fontFamilyBase,
fontWeight: tokens.fontWeightRegular,
lineHeight: tokens.lineHeightBase300,
minHeight: "32px",

// eslint-disable-next-line @typescript-eslint/naming-convention
":hover": {
backgroundColor: tokens.colorBrandBackground2Hover,
backgroundColor: tokens.colorNeutralBackground1Hover,
},

// eslint-disable-next-line @typescript-eslint/naming-convention
":active": {
backgroundColor: tokens.colorNeutralBackground1Pressed,
},
},
icon: {
pointerEvents: "auto", // re‑enable interaction
display: "flex",
alignItems: "center",
position: "absolute",
right: tokens.spacingHorizontalSNudge,
color: tokens.colorNeutralForeground2,
cursor: "pointer",
fontSize: tokens.fontSizeBase400,

// eslint-disable-next-line @typescript-eslint/naming-convention
":hover": {
color: tokens.colorNeutralForeground2Hover,
},
},
});

Expand All @@ -45,10 +71,8 @@ export const DraggableLine: React.FunctionComponent<DraggableLineProps> = (props
event.dataTransfer.setData(props.format, props.data);
}}
>
<LineContainer>
{props.label}
{props.onDelete && <DeleteFilled className={classes.icon} onClick={props.onDelete} />}
</LineContainer>
{props.label}
{props.onDelete && <DeleteFilled className={classes.icon} onClick={props.onDelete} />}
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Popover, PopoverSurface, PopoverTrigger } from "@fluentui/react-components";
import type { OnOpenChangeData, OpenPopoverEvents } from "@fluentui/react-components";
import type { FunctionComponent, PropsWithChildren } from "react";
import { useState, useEffect } from "react";

type PositionedPopoverProps = {
x: number;
y: number;
visible: boolean;
hide: () => void;
};

/**
* PositionedPopover component that shows a popover at specific coordinates
* @param props - The component props
* @returns The positioned popover component
*/
export const PositionedPopover: FunctionComponent<PropsWithChildren<PositionedPopoverProps>> = (props) => {
const [open, setOpen] = useState(false);

useEffect(() => {
setOpen(props.visible);
}, [props.visible, props.x, props.y]);

const handleOpenChange = (_: OpenPopoverEvents, data: OnOpenChangeData) => {
setOpen(data.open);

if (!data.open) {
props.hide();
}
};

return (
<Popover
open={open}
onOpenChange={handleOpenChange}
positioning={{
position: "below", // Places the popover directly below the trigger element
align: "center", // Centers the popover horizontally relative to the trigger element
autoSize: "height-always", //Automatically adjusts the popover height to fit within the viewport
fallbackPositions: ["above", "after", "before"], //If the primary position doesn't fit, automatically tries these positions in order
}}
withArrow={false} // Removes arrow that points to trigger element
>
<PopoverTrigger>
{/* Use the invisible div as the trigger location*/}
<div
style={{
position: "absolute",
left: `${props.x}px`,
top: `${props.y}px`,
width: 1,
height: 1,
pointerEvents: "none", // so it's invisible to interaction
}}
/>
</PopoverTrigger>
<PopoverSurface>{props.children}</PopoverSurface>
</Popover>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Field, SearchBox as FluentSearchBox, makeStyles } from "@fluentui/react-components";
import type { InputOnChangeData, SearchBoxChangeEvent } from "@fluentui/react-components";
import { forwardRef } from "react";

type SearchProps = {
onChange: (val: string) => void;
placeholder?: string;
};
const useStyles = makeStyles({
search: {
minWidth: "50px",
},
});

export const SearchBar = forwardRef<HTMLInputElement, SearchProps>((props, ref) => {
const classes = useStyles();
const onChange: (ev: SearchBoxChangeEvent, data: InputOnChangeData) => void = (_, data) => {
props.onChange(data.value);
};

return (
<Field>
<FluentSearchBox ref={ref} className={classes.search} placeholder={props.placeholder} onChange={onChange} />
</Field>
);
});
137 changes: 121 additions & 16 deletions packages/dev/sharedUiComponents/src/fluent/primitives/searchBox.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,130 @@
import { Field, SearchBox as FluentSearchBox, makeStyles } from "@fluentui/react-components";
import type { InputOnChangeData } from "@fluentui/react-components";
import type { SearchBoxChangeEvent } from "@fluentui/react-components";
import { makeStyles, tokens } from "@fluentui/react-components";
import { SearchBar } from "./searchBar";
import type { FunctionComponent } from "react";
import { useState, useEffect } from "react";

type SearchProps = {
onChange: (val: string) => void;
placeholder?: string;
type SearchBoxProps = {
items: string[];
onItemSelected: (item: string) => void;
title?: string;
};
const useStyles = makeStyles({
search: {
minWidth: "50px",

const useSearchBoxStyles = makeStyles({
searchBox: {
width: "300px",
height: "400px",
backgroundColor: tokens.colorNeutralBackground1,
border: `${tokens.strokeWidthThick} solid ${tokens.colorNeutralStroke1}`,
borderRadius: tokens.borderRadiusMedium,
boxShadow: tokens.shadow16,
display: "grid",
gridTemplateRows: "auto auto 1fr",
overflow: "hidden", // Prevent content overflow
},
title: {
borderBottom: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`,
margin: tokens.spacingVerticalXS,
paddingBottom: tokens.spacingVerticalXS,
color: tokens.colorNeutralForeground1,
gridRow: "1",
fontSize: tokens.fontSizeBase300,
fontWeight: tokens.fontWeightSemibold,
},
filterContainer: {
margin: tokens.spacingVerticalXS,
paddingBottom: tokens.spacingVerticalXS,
gridRow: "2",
},
list: {
gridRow: "3",
overflowY: "auto",
display: "flex",
flexDirection: "column",
maxHeight: "100%",
},
listItem: {
marginLeft: tokens.spacingHorizontalXS,
marginRight: tokens.spacingHorizontalXS,
cursor: "pointer",
color: tokens.colorNeutralForeground1,
marginTop: tokens.spacingVerticalXXS,
marginBottom: tokens.spacingVerticalXXS,
padding: tokens.spacingVerticalXS,
borderRadius: tokens.borderRadiusSmall,

// eslint-disable-next-line @typescript-eslint/naming-convention
":hover": {
backgroundColor: tokens.colorNeutralBackground2Hover,
},
},
listItemSelected: {
backgroundColor: tokens.colorBrandBackground,
color: tokens.colorNeutralForegroundOnBrand,

// eslint-disable-next-line @typescript-eslint/naming-convention
":hover": {
backgroundColor: tokens.colorBrandBackgroundHover,
},
},
});
export const SearchBox = (props: SearchProps) => {
const classes = useStyles();
const onChange: (ev: SearchBoxChangeEvent, data: InputOnChangeData) => void = (_, data) => {
props.onChange(data.value);

/**
* SearchBox component that displays a popup with search functionality
* @param props - The component props
* @returns The search box component
*/
export const SearchBox: FunctionComponent<SearchBoxProps> = (props) => {
const classes = useSearchBoxStyles();
const [selectedIndex, setSelectedIndex] = useState(0);
const [items, setItems] = useState(props.items);
// In future could replace this with a fluent component like menuList or comboBox depending on desired UX
const onKeyDown = (evt: React.KeyboardEvent) => {
if (items.length === 0) {
return;
}
if (evt.code === "Enter") {
props.onItemSelected(items[selectedIndex]);
return;
}

if (evt.code === "ArrowDown") {
setSelectedIndex((prev) => Math.min(prev + 1, items.length - 1));
return;
}

if (evt.code === "ArrowUp") {
setSelectedIndex((prev) => Math.max(prev - 1, 0));
return;
}
};

const onFilterChange = (filter: string) => {
const filteredItems = props.items.filter((item) => item.toLowerCase().includes(filter.toLowerCase()));
setItems(filteredItems);
};

useEffect(() => {
setItems(props.items);
}, [props.items]);

return (
<Field>
<FluentSearchBox className={classes.search} placeholder={props.placeholder} onChange={onChange} />
</Field>
<div className={classes.searchBox} onKeyDown={onKeyDown}>
{props.title ? <div className={classes.title}>{props.title}</div> : null}
<div className={classes.filterContainer}>
<SearchBar onChange={onFilterChange} placeholder="Search..." />
</div>
<div role="listbox" className={classes.list}>
{items.map((item, index) => (
<div
role="option"
key={item}
className={`${classes.listItem} ${index === selectedIndex ? classes.listItemSelected : ""}`}
onClick={() => props.onItemSelected(item)}
>
{item}
</div>
))}
</div>
</div>
);
};
Loading