From f5c16836bdcea71478b129d4dd4e37ad7bc8db7a Mon Sep 17 00:00:00 2001 From: Georgina Date: Thu, 7 Aug 2025 11:37:38 -0400 Subject: [PATCH 1/9] message bar UX fix --- .../dev/sharedUiComponents/src/fluent/primitives/messageBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dev/sharedUiComponents/src/fluent/primitives/messageBar.tsx b/packages/dev/sharedUiComponents/src/fluent/primitives/messageBar.tsx index e25145d3eed..a6fe41e6aa6 100644 --- a/packages/dev/sharedUiComponents/src/fluent/primitives/messageBar.tsx +++ b/packages/dev/sharedUiComponents/src/fluent/primitives/messageBar.tsx @@ -22,7 +22,7 @@ export const MessageBar: FunctionComponent = (props) => { return (
- + {header} {message} From 9d5e5bab68214564aaca0ff4f63cd675677cce80 Mon Sep 17 00:00:00 2001 From: Georgina Date: Fri, 8 Aug 2025 12:44:29 -0400 Subject: [PATCH 2/9] there is still a bug! --- .../animation/animationGroupProperties.tsx | 3 +- .../hoc/propertyLines/inputPropertyLine.tsx | 24 +- .../src/fluent/primitives/colorPicker.tsx | 247 ++++++++---------- .../src/fluent/primitives/infoLabel.tsx | 22 ++ .../src/fluent/primitives/input.tsx | 65 ----- .../src/fluent/primitives/primitive.ts | 7 + .../src/fluent/primitives/spinButton.tsx | 92 +++++-- .../src/fluent/primitives/syncedSlider.tsx | 14 +- .../src/fluent/primitives/textInput.tsx | 87 ++++++ .../src/lines/textInputLineComponent.tsx | 27 +- 10 files changed, 328 insertions(+), 260 deletions(-) create mode 100644 packages/dev/sharedUiComponents/src/fluent/primitives/infoLabel.tsx delete mode 100644 packages/dev/sharedUiComponents/src/fluent/primitives/input.tsx create mode 100644 packages/dev/sharedUiComponents/src/fluent/primitives/textInput.tsx diff --git a/packages/dev/inspector-v2/src/components/properties/animation/animationGroupProperties.tsx b/packages/dev/inspector-v2/src/components/properties/animation/animationGroupProperties.tsx index 6f762f44e9e..9fbea852076 100644 --- a/packages/dev/inspector-v2/src/components/properties/animation/animationGroupProperties.tsx +++ b/packages/dev/inspector-v2/src/components/properties/animation/animationGroupProperties.tsx @@ -78,8 +78,7 @@ export const AnimationGroupControlProperties: FunctionComponent<{ animationGroup /> - - {/* TODO: Hey georgie : should be integer (even when typing)*/} + diff --git a/packages/dev/sharedUiComponents/src/fluent/hoc/propertyLines/inputPropertyLine.tsx b/packages/dev/sharedUiComponents/src/fluent/hoc/propertyLines/inputPropertyLine.tsx index 6c92b3e30af..834b6074e28 100644 --- a/packages/dev/sharedUiComponents/src/fluent/hoc/propertyLines/inputPropertyLine.tsx +++ b/packages/dev/sharedUiComponents/src/fluent/hoc/propertyLines/inputPropertyLine.tsx @@ -1,15 +1,16 @@ import { PropertyLine } from "./propertyLine"; import type { PropertyLineProps } from "./propertyLine"; import type { FunctionComponent } from "react"; -import { NumberInput, TextInput } from "../../primitives/input"; -import type { InputProps } from "../../primitives/input"; - +import type { TextInputProps } from "../../primitives/textInput"; +import { TextInput } from "../../primitives/textInput"; +import type { SpinButtonProps } from "../../primitives/spinButton"; +import { SpinButton } from "../../primitives/spinButton"; /** * Wraps a text input in a property line * @param props - PropertyLineProps and InputProps * @returns property-line wrapped input component */ -export const TextInputPropertyLine: FunctionComponent & PropertyLineProps> = (props) => ( +export const TextInputPropertyLine: FunctionComponent> = (props) => ( @@ -17,11 +18,16 @@ export const TextInputPropertyLine: FunctionComponent & Prope /** * Wraps a number input in a property line + * To force integer values, use forceInt param (this is distinct from the 'step' param, which will still allow submitting an integer value. forceInt will not) * @param props - PropertyLineProps and InputProps * @returns property-line wrapped input component */ -export const NumberInputPropertyLine: FunctionComponent & PropertyLineProps> = (props) => ( - - - -); +export const NumberInputPropertyLine: FunctionComponent & { forceInt?: boolean }> = (props) => { + const { forceInt, ...rest } = props; + const propsToAdd = forceInt ? { step: 0, validator: Number.isInteger } : {}; + return ( + + + + ); +}; diff --git a/packages/dev/sharedUiComponents/src/fluent/primitives/colorPicker.tsx b/packages/dev/sharedUiComponents/src/fluent/primitives/colorPicker.tsx index 103796eb6eb..111317f0a2e 100644 --- a/packages/dev/sharedUiComponents/src/fluent/primitives/colorPicker.tsx +++ b/packages/dev/sharedUiComponents/src/fluent/primitives/colorPicker.tsx @@ -1,17 +1,12 @@ /* eslint-disable jsdoc/require-returns */ /* eslint-disable @typescript-eslint/naming-convention */ -import { useState, useEffect, useCallback } from "react"; -import type { FunctionComponent, ChangeEvent } from "react"; +import { useState, useEffect } from "react"; +import type { FunctionComponent } from "react"; import { - Input, - Label, - SpinButton, - useId, - ColorPicker, + ColorPicker as FluentColorPicker, ColorSlider, ColorArea, AlphaSlider, - InfoLabel, Link, makeStyles, Popover, @@ -21,9 +16,11 @@ import { Body1Strong, ColorSwatch, } from "@fluentui/react-components"; -import type { SpinButtonChangeEvent, SpinButtonOnChangeData, ColorPickerProps as FluentColorPickerProps, InputOnChangeData } from "@fluentui/react-components"; +import type { ColorPickerProps as FluentColorPickerProps } from "@fluentui/react-components"; import { Color3, Color4 } from "core/Maths/math.color"; import type { PrimitiveProps } from "./primitive"; +import { SpinButton } from "./spinButton"; +import { TextInput } from "./textInput"; const useColorPickerStyles = makeStyles({ colorPickerContainer: { @@ -110,32 +107,32 @@ export const ColorPickerPopup: FunctionComponent
- + {color instanceof Color4 && } - +
{/* Top Row: Preview, Gamma Hex, Linear Hex */}
- - + +
{/* Middle Row: Red, Green, Blue, Alpha */}
- - - + + +
{/* Bottom Row: Hue, Saturation, Value */}
- - - + + +
@@ -144,9 +141,9 @@ export const ColorPickerPopup: FunctionComponent & { - label?: string; linearHex?: boolean; isLinearMode?: boolean; }; @@ -159,102 +156,90 @@ export type InputHexProps = PrimitiveProps & { * @returns */ export const InputHexField: FunctionComponent = (props) => { - const id = useId("hex-input"); const styles = useColorPickerStyles(); - const { label, value, onChange, linearHex, isLinearMode } = props; + const { title, value, onChange, linearHex, isLinearMode } = props; - const handleChange = (e: ChangeEvent, _: InputOnChangeData) => { - // If linearHint (aka PBR material, ensure the other values are displayed in gamma even if linear hex changes) - const value = e.target.value; - if (value != "" && /^[0-9A-Fa-f]+$/g.test(value) == false) { - return; - } - onChange(Color3.FromHexString(value).toGammaSpace()); - }; return (
- {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) => onChange(Color3.FromHexString(val).toGammaSpace())} + 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 + } />
); }; type RgbKey = "r" | "g" | "b"; -type InputRgbFieldProps = { - color: Color3 | Color4; - label: string; +type InputRgbFieldProps = PrimitiveProps & { rgbKey: RgbKey; - onChange: (color: Color3 | Color4) => void; }; const InputRgbField: FunctionComponent = (props) => { - const { color, onChange, label, rgbKey } = props; - const id = useId(`${label.toLowerCase()}-input`); + const { value, onChange, title, rgbKey } = props; const classes = useColorPickerStyles(); - const handleChange = useCallback( - (_: SpinButtonChangeEvent, data: SpinButtonOnChangeData) => { - const val = data.value ?? parseFloat(data.displayValue ?? ""); - - if (val === null || Number.isNaN(val) || !NUMBER_REGEX.test(val.toString())) { - return; - } - - const newColor = color.clone(); - newColor[rgbKey] = val / 255.0; // Convert to 0-1 range - onChange(newColor); - }, - [color] - ); + const handleChange = (val: number) => { + const newColor = value.clone(); + newColor[rgbKey] = val / 255.0; // Convert to 0-1 range + onChange(newColor); + }; return (
- - +
); }; -type InputHsvFieldProps = { - color: Color3 | Color4; - label: string; +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 }; +} + +type HsvKey = "h" | "s" | "v"; +type InputHsvFieldProps = PrimitiveProps & { hsvKey: HsvKey; - onChange: (color: Color3 | Color4) => void; max: number; scale?: number; }; @@ -266,35 +251,33 @@ type InputHsvFieldProps = { * @param props - The properties for the InputHsvField component. */ export const InputHsvField: FunctionComponent = (props) => { - const { color, label, hsvKey, max, scale = 1 } = props; + const { value, title, hsvKey, max, scale = 1 } = props; - const id = useId(`${label.toLowerCase()}-input`); const classes = useColorPickerStyles(); - const handleChange = useCallback( - (_: SpinButtonChangeEvent, data: SpinButtonOnChangeData) => { - const val = data.value ?? parseFloat(data.displayValue ?? ""); - - if (val === null || Number.isNaN(val) || !NUMBER_REGEX.test(val.toString())) { - return; - } - - // Convert current color to HSV, update the new hsv value, then call onChange prop - const hsv = rgbaToHsv(color); - hsv[hsvKey] = val / scale; - let newColor: Color3 | Color4 = Color3.FromHSV(hsv.h, hsv.s, hsv.v); - if (color instanceof Color4) { - newColor = Color4.FromColor3(newColor, color.a ?? 1); - } - props.onChange(newColor); - }, - [color] - ); + const handleChange = (val: number) => { + // Convert current color to HSV, update the new hsv value, then call onChange prop + const hsv = rgbaToHsv(value); + hsv[hsvKey] = val / scale; + let newColor: Color3 | Color4 = Color3.FromHSV(hsv.h, hsv.s, hsv.v); + if (value instanceof Color4) { + newColor = Color4.FromColor3(newColor, value.a ?? 1); + } + props.onChange(newColor); + }; return (
- - +
); }; @@ -311,12 +294,9 @@ type InputAlphaProps = { */ const InputAlphaField: FunctionComponent = (props) => { const classes = useColorPickerStyles(); - const id = useId("alpha-input"); const { color } = props; - const onChange = (_: SpinButtonChangeEvent, data: SpinButtonOnChangeData) => { - const value = data.value ?? parseFloat(data.displayValue ?? ""); - + const onChange = (value: number) => { if (Number.isNaN(value) || value < 0 || value > 1) { return; } @@ -332,20 +312,6 @@ const InputAlphaField: FunctionComponent = (props) => { return (
-
- - {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) => { value={color instanceof Color3 ? 1 : color.a} step={0.01} onChange={onChange} - id={id} + 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..795b5be890b 100644 --- a/packages/dev/sharedUiComponents/src/fluent/primitives/spinButton.tsx +++ b/packages/dev/sharedUiComponents/src/fluent/primitives/spinButton.tsx @@ -1,13 +1,15 @@ -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", + width: "100px", }, }); @@ -16,33 +18,79 @@ export type SpinButtonProps = PrimitiveProps & { step?: number; // Optional step value for the spin button min?: number; max?: number; + 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); + + useEffect(() => { + if (props.value != lastCommittedValue.current) { + setValue(props.value); // Update local state when props.value changes + lastCommittedValue.current = props.value; + } + }, [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 invalid = !!outOfBounds || !!failsValidator || isNaN(numericValue); + 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) { + setValue(data.value); // Update local state. Do not notify parent + } + }; + + 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(); + + // Update local state and try to commit the value if valid + const currVal = parseFloat((event.target as any).value); + setValue(currVal); + tryCommitValue(currVal); + } + }; + + const handleBlur = (event: FocusEvent) => { + event.stopPropagation(); // Prevent event propagation + // Try to commit the current value when losing focus + const currVal = parseFloat(event.target.value); + setValue(currVal); + tryCommitValue(currVal); + }; + + const invalidStyle = !validateValue(value) + ? { + backgroundColor: "#fdeaea", + borderColor: "#d13438", + } + : {}; + 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..7d45280303e 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"; @@ -17,7 +17,6 @@ const useSyncedSliderStyles = makeStyles({ minWidth: "40px", // Minimum width for slider to remain usable }, input: { - width: "40px", // Fixed width for input - always 40px flexShrink: 0, }, }); @@ -80,12 +79,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 +100,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..e60d8ac6b4d --- /dev/null +++ b/packages/dev/sharedUiComponents/src/fluent/primitives/textInput.tsx @@ -0,0 +1,87 @@ +import type { FunctionComponent, FocusEvent, KeyboardEvent, ChangeEvent } from "react"; +import { useEffect, useRef, useState } from "react"; + +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, _: unknown) => { + event.stopPropagation(); // Prevent event propagation + setValue(event.target.value); // Update local state. Do not notify parent + }; + + 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(); + + // Update local state and try to commit the value if valid + const currVal = (event.target as any).value; + setValue(currVal); + tryCommitValue(currVal); + } + }; + + const handleBlur = (event: FocusEvent) => { + event.stopPropagation(); // Prevent event propagation + // Update local state and try to commit the value if valid + const currVal = (event.target as any).value; + setValue(currVal); + tryCommitValue(currVal); + }; + + const invalidStyle = !validateValue(value) + ? { + backgroundColor: "#fdeaea", + borderColor: "#d13438", + } + : {}; + + 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 ? ( -