diff --git a/app/packages/components/src/components/Loading/LoadingSpinner.tsx b/app/packages/components/src/components/Loading/LoadingSpinner.tsx
new file mode 100644
index 00000000000..b14e99daf37
--- /dev/null
+++ b/app/packages/components/src/components/Loading/LoadingSpinner.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+
+import { CircularProgress } from "@mui/material";
+
+const LoadingSpinner = ({
+ color = "base",
+ size = "medium",
+}: {
+ color?: string;
+ size?: string;
+}) => {
+ const COLORS: { [key: string]: string } = {
+ base: "#FFC59B",
+ primary: "primary",
+ secondary: "secondary",
+ error: "error",
+ warning: "warning",
+ info: "info",
+ success: "success",
+ };
+ const SIZES: { [key: string]: string } = {
+ small: "1rem",
+ medium: "2rem",
+ large: "3rem",
+ };
+ return ;
+};
+
+export default LoadingSpinner;
diff --git a/app/packages/components/src/components/ModalBase/DisplayTags.tsx b/app/packages/components/src/components/ModalBase/DisplayTags.tsx
new file mode 100644
index 00000000000..935b23e18b6
--- /dev/null
+++ b/app/packages/components/src/components/ModalBase/DisplayTags.tsx
@@ -0,0 +1,88 @@
+import React, { useState } from "react";
+import { Box, Chip, TextField, IconButton } from "@mui/material";
+import AddIcon from "@mui/icons-material/Add";
+
+// Define the props interface for the DisplayTags component
+interface DisplayTagsProps {
+ saveTags: (tags: string[]) => void; // saveTags is a function that accepts an array of strings and returns void
+}
+
+const DisplayTags: React.FC = ({ saveTags }) => {
+ const [chips, setChips] = useState([]); // chips is an array of strings
+ const [inputValue, setInputValue] = useState(""); // inputValue is a string
+
+ const handleKeyPress = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ handleAddChip();
+ }
+ };
+
+ const handleAddChip = () => {
+ if (inputValue.trim() !== "") {
+ const updatedChips = [...chips, inputValue];
+ setChips(updatedChips);
+ setInputValue("");
+ saveTags(updatedChips); // Call the saveTags function to save the new list of chips
+ }
+ };
+
+ const handleDeleteChip = (chipToDelete: string) => {
+ const updatedChips = chips.filter((chip) => chip !== chipToDelete);
+ setChips(updatedChips);
+ saveTags(updatedChips); // Call the saveTags function to save the updated list of chips
+ };
+
+ return (
+
+
+ setInputValue(e.target.value)}
+ onKeyDown={handleKeyPress}
+ fullWidth // Make TextField take up the remaining width
+ />
+
+
+
+
+
+
+ {chips.map((chip, index) => (
+ handleDeleteChip(chip)}
+ />
+ ))}
+
+
+ );
+};
+
+export default DisplayTags;
diff --git a/app/packages/components/src/components/ModalBase/ModalBase.tsx b/app/packages/components/src/components/ModalBase/ModalBase.tsx
new file mode 100644
index 00000000000..ea2857f3366
--- /dev/null
+++ b/app/packages/components/src/components/ModalBase/ModalBase.tsx
@@ -0,0 +1,270 @@
+import React, { useCallback, useEffect, useState } from "react";
+import ButtonView from "@fiftyone/core/src/plugins/SchemaIO/components/ButtonView";
+import { Box, Modal, Typography } from "@mui/material";
+import DisplayTags from "./DisplayTags";
+import { MuiIconFont } from "../index";
+
+interface ModalBaseProps {
+ modal: {
+ icon?: string;
+ iconVariant?: "outlined" | "filled" | "rounded" | "sharp" | undefined;
+ title: string;
+ subtitle: string;
+ body: string;
+ textAlign?: string | { [key: string]: string };
+ };
+ primaryButton?: {
+ href?: any;
+ prompt?: any;
+ params?: any;
+ operator?: any;
+ align?: string;
+ width?: string;
+ onClick?: any;
+ disabled?: boolean;
+ primaryText: string;
+ primaryColor: string;
+ };
+ secondaryButton?: {
+ href?: any;
+ prompt?: any;
+ params?: any;
+ operator?: any;
+ align?: string;
+ width?: string;
+ onClick?: any;
+ disabled?: boolean;
+ secondaryText: string;
+ secondaryColor: string;
+ };
+ functionality?: string;
+ primaryCallback?: () => void;
+ secondaryCallback?: () => void;
+ props: any;
+}
+
+interface ModalButtonView {
+ variant: string;
+ label: string;
+ icon?: string;
+ iconPosition?: string;
+ componentsProps: any;
+}
+
+const ModalBase: React.FC = ({
+ modal,
+ primaryButton,
+ secondaryButton,
+ primaryCallback,
+ secondaryCallback,
+ functionality = "none",
+ props,
+}) => {
+ const { title, subtitle, body } = modal;
+
+ const defaultAlign = "left";
+
+ let titleAlign = defaultAlign;
+ let subtitleAlign = defaultAlign;
+ let bodyAlign = defaultAlign;
+
+ const [open, setOpen] = useState(false);
+ const handleOpen = () => setOpen(true);
+ const handleClose = () => {
+ if (!secondaryCallback) {
+ setOpen(false);
+ }
+ };
+
+ if (typeof modal?.textAlign === "string") {
+ titleAlign = subtitleAlign = bodyAlign = modal.textAlign;
+ } else {
+ titleAlign = modal?.textAlign?.title ?? defaultAlign;
+ subtitleAlign = modal?.textAlign?.subtitle ?? defaultAlign;
+ bodyAlign = modal?.textAlign?.body ?? defaultAlign;
+ }
+
+ const modalStyle = {
+ position: "absolute",
+ top: "50%",
+ left: "50%",
+ transform: "translate(-50%, -50%)",
+ width: 600,
+ bgcolor: "background.paper",
+ border: "2px solid #000",
+ boxShadow: 24,
+ p: 6, // Padding for inner spacing
+ display: "flex",
+ flexDirection: "column", // Stack items vertically
+ justifyContent: "center", // Vertically center the content
+ };
+
+ const modalButtonView: ModalButtonView = {
+ variant: props?.variant || "outlined",
+ label: props?.label || "Open Modal",
+ componentsProps: {
+ button: {
+ sx: {
+ height: props?.height || "100%",
+ width: props?.width || "100%",
+ padding: 1,
+ },
+ },
+ },
+ };
+
+ if (Object.keys(props).includes("icon")) {
+ modalButtonView["icon"] = props["icon"];
+ modalButtonView["iconPosition"] = props?.iconPosition || "left";
+ }
+
+ const [primaryButtonView, setPrimaryButtonView] = useState({
+ variant: "contained",
+ color: primaryButton?.primaryColor,
+ label: primaryButton?.primaryText,
+ onClick: primaryButton?.onClick,
+ operator: primaryCallback || primaryButton?.operator,
+ params: primaryButton?.params,
+ href: primaryButton?.href,
+ prompt: primaryButton?.prompt,
+ disabled: primaryButton?.disabled,
+ componentsProps: {
+ button: {
+ sx: {
+ width: primaryButton?.width || "100%",
+ justifyContent: primaryButton?.align || "center",
+ ...primaryButton,
+ },
+ },
+ },
+ });
+
+ const [secondaryButtonView, setSecondaryButtonView] = useState({
+ variant: "outlined",
+ color: secondaryButton?.secondaryColor,
+ label: secondaryButton?.secondaryText,
+ onClick: secondaryButton?.onClick,
+ operator: secondaryCallback || secondaryButton?.operator,
+ params: secondaryButton?.params,
+ href: secondaryButton?.href,
+ prompt: secondaryButton?.prompt,
+ disabled: secondaryButton?.disabled,
+ componentsProps: {
+ button: {
+ sx: {
+ width: primaryButton?.width || "100%",
+ justifyContent: primaryButton?.align || "center",
+ ...secondaryButton,
+ },
+ },
+ },
+ });
+
+ // State options for functionality based on user input
+
+ {
+ /* TAGGING FUNCTIONALITY */
+ }
+ useEffect(() => {
+ if (
+ (functionality === "tagging" || functionality === "Tagging") &&
+ (!primaryButtonView.params ||
+ !primaryButtonView.params.tags ||
+ primaryButtonView.params.tags.length === 0)
+ ) {
+ setPrimaryButtonView({ ...primaryButtonView, disabled: true });
+ } else {
+ setPrimaryButtonView({ ...primaryButtonView, disabled: false });
+ }
+ }, [primaryButtonView.params]);
+
+ const handleSaveTags = useCallback((tags: string[]) => {
+ setPrimaryButtonView((prevButtonView) => ({
+ ...prevButtonView,
+ params: { ...prevButtonView.params, tags }, // Add tags to existing params
+ }));
+ }, []);
+
+ return (
+ <>
+
+
+
+
+
+
+ {modal?.icon && (
+
+ {modal.icon}
+
+ )}{" "}
+ {title}
+
+
+ {subtitle}
+
+
+ {body}
+
+ {(functionality === "tagging" || functionality === "Tagging") && (
+
+ )}
+
+ {secondaryButton && (
+
+
+
+ )}
+ {primaryButton && (
+
+
+
+ )}
+
+
+
+ >
+ );
+};
+export default ModalBase;
diff --git a/app/packages/components/src/components/ModalBase/index.ts b/app/packages/components/src/components/ModalBase/index.ts
new file mode 100644
index 00000000000..71cee8fb7d6
--- /dev/null
+++ b/app/packages/components/src/components/ModalBase/index.ts
@@ -0,0 +1 @@
+export { default as ModalBase } from "./ModalBase";
diff --git a/app/packages/components/src/components/MuiIconFont/index.tsx b/app/packages/components/src/components/MuiIconFont/index.tsx
index 50cbcf175f4..3f3e83467a7 100644
--- a/app/packages/components/src/components/MuiIconFont/index.tsx
+++ b/app/packages/components/src/components/MuiIconFont/index.tsx
@@ -2,12 +2,30 @@ import { Icon, IconProps } from "@mui/material";
import "material-icons/iconfont/material-icons.css";
import React from "react";
-// Available Icons: https://github.com/marella/material-icons?tab=readme-ov-file#available-icons
-export default function MuiIconFont(props: MuiIconFontProps) {
- const { name, ...iconProps } = props;
- return {name};
-}
-
type MuiIconFontProps = IconProps & {
name: string;
+ variant?: "filled" | "outlined" | "rounded" | "sharp";
};
+
+const defaultProps = { variant: "filled" as const };
+
+// Available Icons: https://github.com/marella/material-icons?tab=readme-ov-file#available-icons
+const MuiIconFont = React.memo(function MuiIconFont(props: MuiIconFontProps) {
+ const { name, variant = defaultProps.variant, ...iconProps } = props;
+
+ const variantClassMap = {
+ filled: "",
+ outlined: "material-icons-outlined",
+ rounded: "material-icons-rounded",
+ sharp: "material-icons-sharp",
+ };
+
+ const className = variant ? variantClassMap[variant] : "";
+
+ return (
+
+ {name}
+
+ );
+});
+export default MuiIconFont;
diff --git a/app/packages/components/src/components/PillBadge/PillBadge.tsx b/app/packages/components/src/components/PillBadge/PillBadge.tsx
new file mode 100644
index 00000000000..d9fc4f4b328
--- /dev/null
+++ b/app/packages/components/src/components/PillBadge/PillBadge.tsx
@@ -0,0 +1,175 @@
+import React, { useState } from "react";
+import CircleIcon from "@mui/icons-material/Circle";
+import { Chip, FormControl, MenuItem, Select } from "@mui/material";
+import { usePanelEvent } from "@fiftyone/operators";
+import { usePanelId } from "@fiftyone/spaces";
+
+const PillBadge = ({
+ text,
+ color = "default",
+ variant = "filled",
+ showIcon = true,
+ operator,
+}: {
+ text: string | string[] | [string, string][];
+ color?: string;
+ variant?: "filled" | "outlined";
+ showIcon?: boolean;
+ operator?: () => void;
+}) => {
+ const getInitialChipSelection = (
+ text: string | string[] | [string, string][]
+ ) => {
+ if (typeof text === "string") return text;
+ if (Array.isArray(text)) {
+ if (text.length === 0) return "";
+ if (Array.isArray(text[0])) return text[0][0];
+ return text[0];
+ }
+ return "";
+ };
+
+ const getInitialChipColor = (
+ text: string | string[] | [string, string][],
+ color?: string
+ ) => {
+ if (typeof text === "string") return color;
+ if (Array.isArray(text)) {
+ if (text.length === 0) return "default";
+ if (Array.isArray(text[0])) return text[0][1];
+ return color || "default";
+ }
+ return "default";
+ };
+
+ const [chipSelection, setChipSelection] = useState(
+ getInitialChipSelection(text)
+ );
+ const [chipColor, setChipColor] = useState(getInitialChipColor(text, color));
+
+ const COLORS: { [key: string]: string } = {
+ default: "#999999",
+ primary: "#FFB682",
+ error: "error",
+ warning: "warning",
+ info: "info",
+ success: "#8BC18D",
+ };
+
+ const chipStyle: { [key: string]: string | number } = {
+ color: COLORS[chipColor || "default"] || COLORS.default,
+ fontSize: 14,
+ fontWeight: 500,
+ paddingLeft: 1,
+ };
+
+ const panelId = usePanelId();
+ const handleClick = usePanelEvent();
+
+ return (
+
+ {typeof text === "string" ? (
+
+ ) : undefined
+ }
+ label={text}
+ sx={{
+ ...chipStyle,
+ "& .MuiChip-icon": {
+ marginRight: "-7px",
+ },
+ "& .MuiChip-label": {
+ marginBottom: "1px",
+ },
+ }}
+ variant={variant as "filled" | "outlined" | undefined}
+ />
+ ) : (
+
+
+ ) : undefined
+ }
+ label={
+ Array.isArray(text) &&
+ text.length > 0 &&
+ Array.isArray(text[0]) ? (
+
+ ) : (
+
+ )
+ }
+ sx={{
+ ...chipStyle,
+ "& .MuiChip-icon": {
+ marginRight: "-7px",
+ },
+ "& .MuiChip-label": {
+ marginBottom: "1px",
+ },
+ "& .MuiInput-input:focus": {
+ backgroundColor: "inherit",
+ },
+ }}
+ variant={variant as "filled" | "outlined" | undefined}
+ >
+
+ )}
+
+ );
+};
+
+export default PillBadge;
diff --git a/app/packages/components/src/components/PillBadge/index.ts b/app/packages/components/src/components/PillBadge/index.ts
new file mode 100644
index 00000000000..45ed630ea28
--- /dev/null
+++ b/app/packages/components/src/components/PillBadge/index.ts
@@ -0,0 +1 @@
+export { default as PillBadge } from "./PillBadge";
diff --git a/app/packages/core/src/plugins/SchemaIO/components/ButtonView.tsx b/app/packages/core/src/plugins/SchemaIO/components/ButtonView.tsx
index ae4b7b2becc..5778fa96ac1 100644
--- a/app/packages/core/src/plugins/SchemaIO/components/ButtonView.tsx
+++ b/app/packages/core/src/plugins/SchemaIO/components/ButtonView.tsx
@@ -1,10 +1,10 @@
+import React from "react";
import { MuiIconFont } from "@fiftyone/components";
import { usePanelEvent } from "@fiftyone/operators";
import { usePanelId } from "@fiftyone/spaces";
import { isNullish } from "@fiftyone/utilities";
import { Box, ButtonProps, Typography } from "@mui/material";
-import React from "react";
-import { getColorByCode, getComponentProps } from "../utils";
+import { getColorByCode, getComponentProps, getDisabledColors } from "../utils";
import { ViewPropsType } from "../utils/types";
import Button from "./Button";
import TooltipProvider from "./TooltipProvider";
@@ -22,6 +22,7 @@ export default function ButtonView(props: ViewPropsType) {
params = {},
prompt,
title,
+ disabled = false,
} = view;
const panelId = usePanelId();
const handleClick = usePanelEvent();
@@ -37,8 +38,12 @@ export default function ButtonView(props: ViewPropsType) {
return (
-
+