- {props.linearHex ? (
- This color picker is attached to an entity whose color is stored in gamma space, so we are showing linear hex in disabled view >
- ) : (
- <>
- This color picker is attached to an entity whose color is stored in linear space (ex: PBR Material), and Babylon converts the color to gamma space
- before rendering on screen because the human eye is best at processing colors in gamma space. We thus also want to display the color picker in gamma
- space so that the color chosen here will match the color seen in your entity.
-
- If you want to copy/paste the HEX into your code, you can either use
- Color3.FromHexString(LINEAR_HEX)
-
- or
-
- Color3.FromHexString(GAMMA_HEX).toLinearSpace()
-
-
- Read more in our docs!
- >
- )
- }
- >
- {label}
-
- ) : (
-
- )}
- val != "" && HEX_REGEX.test(val)}
+ onChange={(val) => (linearHex ? onChange(Color3.FromHexString(val).toGammaSpace()) : onChange(Color3.FromHexString(val)))}
+ infoLabel={
+ title
+ ? {
+ label: title,
+ // If not representing a linearHex, no info is needed.
+ info: !props.linearHex ? undefined : !isLinearMode ? ( // If representing a linear hex but we are in gammaMode, simple message explaining why linearHex is disabled
+ <> This color picker is attached to an entity whose color is stored in gamma space, so we are showing linear hex in disabled view >
+ ) : (
+ // If representing a linear hex and we are in linearMode, give information about how to use these hex values
+ <>
+ This color picker is attached to an entity whose color is stored in linear space (ex: PBR Material), and Babylon converts the color to gamma
+ space before rendering on screen because the human eye is best at processing colors in gamma space. We thus also want to display the color
+ picker in gamma space so that the color chosen here will match the color seen in your entity.
+
+ If you want to copy/paste the HEX into your code, you can either use
+ Color3.FromHexString(LINEAR_HEX)
+
+ or
+
+ Color3.FromHexString(GAMMA_HEX).toLinearSpace()
+
+
+ Read more in our docs!
+ >
+ ),
+ }
+ : undefined
+ }
/>
-
- {color instanceof Color3 && (
-
- Because this color picker is representing a Color3, we do not permit modifying alpha from the color picker. You can however modify the material's
- alpha property directly, either in code via material.alpha OR via inspector's transparency section.
- >
- }
- >
- )}
-
= (props) => {
className={classes.spinButton}
value={color instanceof Color3 ? 1 : color.a}
step={0.01}
- onChange={onChange}
- id={id}
+ onChange={handleChange}
+ infoLabel={{
+ label: "Alpha",
+ info:
+ color instanceof Color3 ? (
+ <>
+ Because this color picker is representing a Color3, we do not permit modifying alpha from the color picker. You can however modify the entity's
+ alpha property directly, either in code via entity.alpha OR via inspector's transparency section.
+ >
+ ) : undefined,
+ }}
/>
);
};
-
-const NUMBER_REGEX = /^\d+$/;
-
-function rgbaToHsv(color: { r: number; g: number; b: number; a?: number }): { h: number; s: number; v: number; a?: number } {
- const c = new Color3(color.r, color.g, color.b);
- const hsv = c.toHSV();
- return { h: hsv.r, s: hsv.g, v: hsv.b, a: color.a };
-}
diff --git a/packages/dev/sharedUiComponents/src/fluent/primitives/infoLabel.tsx b/packages/dev/sharedUiComponents/src/fluent/primitives/infoLabel.tsx
new file mode 100644
index 00000000000..457120054cd
--- /dev/null
+++ b/packages/dev/sharedUiComponents/src/fluent/primitives/infoLabel.tsx
@@ -0,0 +1,22 @@
+import type { FunctionComponent } from "react";
+import { InfoLabel as FluentInfoLabel } from "@fluentui/react-components";
+
+export type InfoLabelProps = {
+ htmlFor: string; // required ID of the element whose label we are applying
+ info?: JSX.Element;
+ label: string;
+};
+export type InfoLabelParentProps = Omit;
+
+/**
+ * Renders a label with an optional popup containing more info
+ * @param props
+ * @returns
+ */
+export const InfoLabel: FunctionComponent = (props) => {
+ return (
+
+ {props.label}
+
+ );
+};
diff --git a/packages/dev/sharedUiComponents/src/fluent/primitives/input.tsx b/packages/dev/sharedUiComponents/src/fluent/primitives/input.tsx
deleted file mode 100644
index 5eccd89cdde..00000000000
--- a/packages/dev/sharedUiComponents/src/fluent/primitives/input.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import type { FunctionComponent, KeyboardEvent, ChangeEvent } from "react";
-import { useEffect, useState } from "react";
-
-import { Input as FluentInput, makeStyles } from "@fluentui/react-components";
-import type { PrimitiveProps } from "./primitive";
-
-const useInputStyles = makeStyles({
- text: {
- height: "auto",
- textAlign: "right",
- minWidth: "100px", // Min width for text input
- },
- number: {
- height: "auto",
- minWidth: "40px", // Min width for number input
- },
-});
-
-export type InputProps = PrimitiveProps & {
- step?: number;
- placeholder?: string;
- min?: number;
- max?: number;
-};
-/**
- * This is an input text box that stops propagation of change events and sets its width based on the type of input (text or number)
- * @param props
- * @returns
- */
-const Input: FunctionComponent & { type: "text" | "number" }> = (props) => {
- const classes = useInputStyles();
- const [value, setValue] = useState(props.value ?? "");
-
- useEffect(() => {
- setValue(props.value ?? ""); // Update local state when props.value changes
- }, [props.value]);
-
- const handleChange = (event: ChangeEvent, _: unknown) => {
- event.stopPropagation(); // Prevent event propagation
- const value = props.type === "number" ? Number(event.target.value) : String(event.target.value);
- props.onChange(value); // Call the original onChange handler passed as prop
- setValue(value); // Update local state with the new value
- };
-
- const handleKeyDown = (event: KeyboardEvent) => {
- event.stopPropagation(); // Prevent event propagation
- };
-
- return (
-
- );
-};
-
-const NumberInputCast = Input as FunctionComponent & { type: "number" }>;
-const TextInputCast = Input as FunctionComponent & { type: "text" }>;
-
-export const NumberInput: FunctionComponent> = (props) => ;
-export const TextInput: FunctionComponent> = (props) => ;
diff --git a/packages/dev/sharedUiComponents/src/fluent/primitives/primitive.ts b/packages/dev/sharedUiComponents/src/fluent/primitives/primitive.ts
index 29ac8bb876b..dd0dbaec144 100644
--- a/packages/dev/sharedUiComponents/src/fluent/primitives/primitive.ts
+++ b/packages/dev/sharedUiComponents/src/fluent/primitives/primitive.ts
@@ -1,3 +1,5 @@
+import type { InfoLabelParentProps } from "./infoLabel";
+
export type ImmutablePrimitiveProps = {
/**
* The value of the property to be displayed and modified.
@@ -17,6 +19,11 @@ export type ImmutablePrimitiveProps = {
* Optional title for the component, used for tooltips or accessibility.
*/
title?: string;
+
+ /**
+ * Optional information to display as an infoLabel popup aside the component.
+ */
+ infoLabel?: InfoLabelParentProps;
};
export type PrimitiveProps = ImmutablePrimitiveProps & {
diff --git a/packages/dev/sharedUiComponents/src/fluent/primitives/spinButton.tsx b/packages/dev/sharedUiComponents/src/fluent/primitives/spinButton.tsx
index 1151fabf419..e985654794f 100644
--- a/packages/dev/sharedUiComponents/src/fluent/primitives/spinButton.tsx
+++ b/packages/dev/sharedUiComponents/src/fluent/primitives/spinButton.tsx
@@ -1,48 +1,111 @@
-import { makeStyles, SpinButton as FluentSpinButton } from "@fluentui/react-components";
+import { makeStyles, SpinButton as FluentSpinButton, useId } from "@fluentui/react-components";
import type { SpinButtonOnChangeData, SpinButtonChangeEvent } from "@fluentui/react-components";
-import type { FunctionComponent } from "react";
-import { useCallback, useState } from "react";
+import type { FunctionComponent, KeyboardEvent, FocusEvent } from "react";
+import { useEffect, useState, useRef } from "react";
import type { PrimitiveProps } from "./primitive";
+import { InfoLabel } from "./infoLabel";
const useSpinStyles = makeStyles({
base: {
display: "flex",
flexDirection: "column",
+ minWidth: "55px",
},
});
export type SpinButtonProps = PrimitiveProps & {
- precision?: number; // Optional precision for the spin button
- step?: number; // Optional step value for the spin button
min?: number;
max?: number;
+ step?: number;
+ unit?: string;
+ forceInt?: boolean;
+ validator?: (value: number) => boolean;
};
export const SpinButton: FunctionComponent = (props) => {
const classes = useSpinStyles();
+ const { min, max } = props;
- const [spinButtonValue, setSpinButtonValue] = useState(props.value);
-
- const onSpinButtonChange = useCallback(
- (_ev: SpinButtonChangeEvent, data: SpinButtonOnChangeData) => {
- // Stop propagation of the event to prevent it from bubbling up
- _ev.stopPropagation();
-
- if (data.value != null) {
- setSpinButtonValue(data.value);
- } else if (data.displayValue !== undefined) {
- const newValue = parseFloat(data.displayValue);
- if (!Number.isNaN(newValue)) {
- setSpinButtonValue(newValue);
- }
- }
- },
- [setSpinButtonValue]
- );
+ const [value, setValue] = useState(props.value);
+ const lastCommittedValue = useRef(props.value);
+ // step and forceInt are not mutually exclusive since there could be cases where you want to forceInt but have spinButton jump >1 int per spin
+ const step = props.step != undefined ? props.step : props.forceInt ? 1 : 2;
+
+ useEffect(() => {
+ if (props.value != lastCommittedValue.current) {
+ lastCommittedValue.current = props.value;
+ setValue(props.value); // Update local state when props.value changes
+ }
+ }, [props.value]);
+
+ const validateValue = (numericValue: number): boolean => {
+ const outOfBounds = (min !== undefined && numericValue < min) || (max !== undefined && numericValue > max);
+ const failsValidator = props.validator && !props.validator(numericValue);
+ const failsIntCheck = props.forceInt ? !Number.isInteger(numericValue) : false;
+ const invalid = !!outOfBounds || !!failsValidator || isNaN(numericValue) || !!failsIntCheck;
+ return !invalid;
+ };
+
+ const tryCommitValue = (currVal: number) => {
+ // Only commit if valid and different from last committed value
+ if (validateValue(currVal) && currVal !== lastCommittedValue.current) {
+ lastCommittedValue.current = currVal;
+ props.onChange(currVal);
+ }
+ };
+
+ const handleChange = (event: SpinButtonChangeEvent, data: SpinButtonOnChangeData) => {
+ event.stopPropagation(); // Prevent event propagation
+ if (data.value != null && !Number.isNaN(data.value)) {
+ setValue(data.value); // Update local state. Do not notify parent
+ tryCommitValue(data.value);
+ }
+ };
+
+ const handleKeyUp = (event: KeyboardEvent) => {
+ event.stopPropagation(); // Prevent event propagation
+
+ if (event.key !== "Enter") {
+ // Update local state and try to commit the value if valid, applying styling if not
+ const currVal = parseFloat((event.target as any).value);
+ setValue(currVal);
+ tryCommitValue(currVal);
+ }
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ event.stopPropagation(); // Prevent event propagation
+
+ // Prevent Enter key from causing form submission or value reversion
+ if (event.key === "Enter") {
+ event.preventDefault();
+ }
+ };
+
+ const handleOnBlur = (event: FocusEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+ };
+
+ const invalidStyle = !validateValue(value) ? { backgroundColor: "#fdeaea" } : {};
+ const id = useId("spin-button");
return (
-
+ {props.infoLabel && }
+
);
};
diff --git a/packages/dev/sharedUiComponents/src/fluent/primitives/syncedSlider.tsx b/packages/dev/sharedUiComponents/src/fluent/primitives/syncedSlider.tsx
index 94f3fc818e1..259af3425ae 100644
--- a/packages/dev/sharedUiComponents/src/fluent/primitives/syncedSlider.tsx
+++ b/packages/dev/sharedUiComponents/src/fluent/primitives/syncedSlider.tsx
@@ -1,6 +1,6 @@
import type { SliderOnChangeData } from "@fluentui/react-components";
import { makeStyles, Slider, tokens } from "@fluentui/react-components";
-import { NumberInput } from "./input";
+import { SpinButton } from "./spinButton";
import type { ChangeEvent, FunctionComponent } from "react";
import { useEffect, useState, useRef } from "react";
import type { PrimitiveProps } from "./primitive";
@@ -16,9 +16,8 @@ const useSyncedSliderStyles = makeStyles({
flexGrow: 1, // Let slider grow
minWidth: "40px", // Minimum width for slider to remain usable
},
- input: {
- width: "40px", // Fixed width for input - always 40px
- flexShrink: 0,
+ spinButton: {
+ width: "60px",
},
});
@@ -29,6 +28,8 @@ export type SyncedSliderProps = PrimitiveProps & {
max?: number;
/** Step size for the slider */
step?: number;
+ /** Displayed in the ux to indicate unit of measurement */
+ unit?: string;
/** When true, onChange is only called when the user releases the slider, not during drag */
notifyOnlyOnRelease?: boolean;
};
@@ -80,12 +81,9 @@ export const SyncedSliderInput: FunctionComponent = (props) =
isDraggingRef.current = false;
};
- const handleInputChange = (value: string | number) => {
- const newValue = Number(value);
- if (!isNaN(newValue)) {
- setValue(newValue);
- props.onChange(newValue); // Input always updates immediately
- }
+ const handleInputChange = (value: number) => {
+ setValue(value);
+ props.onChange(value); // Input always updates immediately
};
return (
@@ -104,7 +102,7 @@ export const SyncedSliderInput: FunctionComponent = (props) =
onPointerUp={handleSliderPointerUp}
/>
)}
-
+
);
};
diff --git a/packages/dev/sharedUiComponents/src/fluent/primitives/textInput.tsx b/packages/dev/sharedUiComponents/src/fluent/primitives/textInput.tsx
new file mode 100644
index 00000000000..fd3d06bf9a6
--- /dev/null
+++ b/packages/dev/sharedUiComponents/src/fluent/primitives/textInput.tsx
@@ -0,0 +1,94 @@
+import type { FunctionComponent, KeyboardEvent, ChangeEvent, FocusEvent } from "react";
+import { useEffect, useRef, useState } from "react";
+import type { InputOnChangeData } from "@fluentui/react-components";
+import { Input as FluentInput, makeStyles, useId } from "@fluentui/react-components";
+import type { PrimitiveProps } from "./primitive";
+import { InfoLabel } from "./infoLabel";
+
+const useInputStyles = makeStyles({
+ base: {
+ display: "flex",
+ flexDirection: "column",
+ width: "100px",
+ },
+});
+
+export type TextInputProps = PrimitiveProps & {
+ validator?: (value: string) => boolean;
+};
+
+export const TextInput: FunctionComponent = (props) => {
+ const classes = useInputStyles();
+
+ const [value, setValue] = useState(props.value);
+ const lastCommittedValue = useRef(props.value);
+
+ useEffect(() => {
+ if (props.value != lastCommittedValue.current) {
+ setValue(props.value); // Update local state when props.value changes
+ lastCommittedValue.current = props.value;
+ }
+ }, [props.value]);
+
+ const validateValue = (val: string): boolean => {
+ const failsValidator = props.validator && !props.validator(val);
+ return !failsValidator;
+ };
+
+ const tryCommitValue = (currVal: string) => {
+ // Only commit if valid and different from last committed value
+ if (validateValue(currVal) && currVal !== lastCommittedValue.current) {
+ lastCommittedValue.current = currVal;
+ props.onChange(currVal);
+ }
+ };
+
+ const handleChange = (event: ChangeEvent, data: InputOnChangeData) => {
+ event.stopPropagation(); // Prevent event propagation
+ setValue(data.value); // Update local state. Do not notify parent
+ tryCommitValue(data.value);
+ };
+
+ const handleKeyUp = (event: KeyboardEvent) => {
+ event.stopPropagation(); // Prevent event propagation
+
+ if (event.key !== "Enter") {
+ // Update local state and try to commit the value if valid, applying styling if not
+ setValue((event.target as any).value);
+ tryCommitValue((event.target as any).value);
+ }
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ event.stopPropagation(); // Prevent event propagation
+
+ // Prevent Enter key from causing form submission or value reversion
+ if (event.key === "Enter") {
+ event.preventDefault();
+ }
+ };
+
+ const handleOnBlur = (event: FocusEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+ };
+ const invalidStyle = !validateValue(value) ? { backgroundColor: "#fdeaea" } : {};
+
+ const id = useId("input-button");
+ return (
+
+ {props.infoLabel && }
+
+
+ );
+};
diff --git a/packages/dev/sharedUiComponents/src/lines/textInputLineComponent.tsx b/packages/dev/sharedUiComponents/src/lines/textInputLineComponent.tsx
index 789a3a5a16e..b0779b65172 100644
--- a/packages/dev/sharedUiComponents/src/lines/textInputLineComponent.tsx
+++ b/packages/dev/sharedUiComponents/src/lines/textInputLineComponent.tsx
@@ -5,9 +5,8 @@ import type { PropertyChangedEvent } from "../propertyChangedEvent";
import type { LockObject } from "../tabs/propertyGrids/lockObject";
import { conflictingValuesPlaceholder } from "./targetsProxy";
import { InputArrowsComponent } from "./inputArrowsComponent";
-import { PropertyLine } from "../fluent/hoc/propertyLines/propertyLine";
-import { Textarea } from "../fluent/primitives/textarea";
-import { TextInput, NumberInput } from "../fluent/primitives/input";
+import { TextInputPropertyLine, NumberInputPropertyLine } from "../fluent/hoc/propertyLines/inputPropertyLine";
+import { TextAreaPropertyLine } from "../fluent/hoc/propertyLines/textAreaPropertyLine";
import { ToolContext } from "../fluent/hoc/fluentToolWrapper";
export interface ITextInputLineComponentProps {
@@ -197,16 +196,18 @@ export class TextInputLineComponent extends Component
- {this.props.multilines ? (
-