Browse Source

Merge pull request #1469 from Avaiga/feature/#1429-number-step-attribute

Feature/#1429 number step attribute
Nam Nguyen 10 months ago
parent
commit
e42f1c489a

+ 25 - 0
doc/gui/examples/controls/number-min-max.py

@@ -0,0 +1,25 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+value = 50
+
+page = """
+<|{value}|number|min=10|max=60|>
+"""
+
+Gui(page).run()
+

+ 25 - 0
doc/gui/examples/controls/number-step.py

@@ -0,0 +1,25 @@
+# Copyright 2021-2024 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from taipy.gui import Gui
+
+value = 50
+
+page = """
+<|{value}|number|step=2|>
+"""
+
+Gui(page).run()
+

+ 74 - 11
frontend/taipy-gui/src/components/Taipy/Input.spec.tsx

@@ -28,14 +28,14 @@ describe("Input Component", () => {
     });
     it("displays the right info for string", async () => {
         const { getByDisplayValue } = render(
-            <Input value="toto" type="text" defaultValue="titi" className="taipy-input" />
+            <Input value="toto" type="text" defaultValue="titi" className="taipy-input" />,
         );
         const elt = getByDisplayValue("toto");
         expect(elt.parentElement?.parentElement).toHaveClass("taipy-input");
     });
     it("displays the default value", async () => {
         const { getByDisplayValue } = render(
-            <Input defaultValue="titi" value={undefined as unknown as string} type="text" />
+            <Input defaultValue="titi" value={undefined as unknown as string} type="text" />,
         );
         getByDisplayValue("titi");
     });
@@ -60,7 +60,7 @@ describe("Input Component", () => {
         const { getByDisplayValue } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
                 <Input value="Val" type="text" updateVarName="varname" />
-            </TaipyContext.Provider>
+            </TaipyContext.Provider>,
         );
         const elt = getByDisplayValue("Val");
         await userEvent.clear(elt);
@@ -78,7 +78,7 @@ describe("Input Component", () => {
         const { getByDisplayValue } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
                 <Input value="Val" type="text" updateVarName="varname" onAction="on_action" />
-            </TaipyContext.Provider>
+            </TaipyContext.Provider>,
         );
         const elt = getByDisplayValue("Val");
         await userEvent.click(elt);
@@ -96,7 +96,7 @@ describe("Input Component", () => {
         const { getByDisplayValue } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
                 <Input value="Val" type="text" updateVarName="varname" changeDelay={-1} />
-            </TaipyContext.Provider>
+            </TaipyContext.Provider>,
         );
         const elt = getByDisplayValue("Val");
         await userEvent.click(elt);
@@ -115,7 +115,7 @@ describe("Input Component", () => {
         const { getByDisplayValue } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
                 <Input value="Val" type="text" updateVarName="varname" onAction="on_action" />
-            </TaipyContext.Provider>
+            </TaipyContext.Provider>,
         );
         const elt = getByDisplayValue("Val");
         await userEvent.click(elt);
@@ -138,14 +138,14 @@ describe("Number Component", () => {
     });
     it("displays the right info for string", async () => {
         const { getByDisplayValue } = render(
-            <Input value="12" type="number" defaultValue="1" className="taipy-number" />
+            <Input value="12" type="number" defaultValue="1" className="taipy-number" />,
         );
         const elt = getByDisplayValue(12);
         expect(elt.parentElement?.parentElement).toHaveClass("taipy-number");
     });
     it("displays the default value", async () => {
         const { getByDisplayValue } = render(
-            <Input defaultValue="1" value={undefined as unknown as string} type="number" />
+            <Input defaultValue="1" value={undefined as unknown as string} type="number" />,
         );
         getByDisplayValue("1");
     });
@@ -170,7 +170,7 @@ describe("Number Component", () => {
         const { getByDisplayValue } = render(
             <TaipyContext.Provider value={{ state, dispatch }}>
                 <Input value={"33"} type="number" updateVarName="varname" />
-            </TaipyContext.Provider>
+            </TaipyContext.Provider>,
         );
         const elt = getByDisplayValue("33");
         await userEvent.clear(elt);
@@ -184,8 +184,8 @@ describe("Number Component", () => {
         });
     });
     xit("shows 0", async () => {
-    //not working cf. https://github.com/testing-library/user-event/issues/1066
-    const { getByDisplayValue, rerender } = render(<Input value={"0"} type="number" />);
+        //not working cf. https://github.com/testing-library/user-event/issues/1066
+        const { getByDisplayValue, rerender } = render(<Input value={"0"} type="number" />);
         const elt = getByDisplayValue("0") as HTMLInputElement;
         expect(elt).toBeInTheDocument();
         await userEvent.type(elt, "{ArrowUp}");
@@ -193,4 +193,67 @@ describe("Number Component", () => {
         await userEvent.type(elt, "{ArrowDown}");
         expect(elt.value).toBe("0");
     });
+    it("Validates increment by step value on up click", async () => {
+        const { getByDisplayValue, getByTestId } = render(<Input value={"0"} type="number" step={2} />);
+        const upSpinner = getByTestId("stepper-up-spinner");
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await userEvent.click(upSpinner);
+        expect(elt.value).toBe("2");
+    });
+    it("Validates decrement by step value on down click", async () => {
+        const { getByDisplayValue, getByTestId } = render(<Input value={"0"} type="number" step={2} />);
+        const downSpinner = getByTestId("stepper-down-spinner");
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await userEvent.click(downSpinner);
+        expect(elt.value).toBe("-2");
+    });
+    it("Validates increment when holding shift key and clicking up", async () => {
+        const user = userEvent.setup();
+        const { getByDisplayValue, getByTestId } = render(<Input value={"0"} type="number" step={2} />);
+        const upSpinner = getByTestId("stepper-up-spinner");
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await user.keyboard("[ShiftLeft>]");
+        await user.click(upSpinner);
+        expect(elt.value).toBe("20");
+    });
+    it("Validates decrement when holding shift key and clicking down", async () => {
+        const user = userEvent.setup();
+        const { getByDisplayValue, getByTestId } = render(<Input value={"0"} type="number" step={2} />);
+        const downSpinner = getByTestId("stepper-down-spinner");
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await user.keyboard("[ShiftLeft>]");
+        await user.click(downSpinner);
+        expect(elt.value).toBe("-20");
+    });
+    it("Validate increment when holding shift key and arrow up", async () => {
+        const user = userEvent.setup();
+        const { getByDisplayValue } = render(<Input value={"0"} type="number" step={2} />);
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await user.click(elt);
+        await user.keyboard("[ShiftLeft>]");
+        await user.keyboard("[ArrowUp]");
+        expect(elt.value).toBe("20");
+    });
+    it("Validate value when reaching max value", async () => {
+        const user = userEvent.setup();
+        const { getByDisplayValue } = render(<Input value={"0"} type="number" step={2} max={20} />);
+        const elt = getByDisplayValue("0") as HTMLInputElement;
+        await user.click(elt);
+        await user.keyboard("[ShiftLeft>]");
+        // Press the arrow up twice to validate that the value will not exceed the maximum value when reached
+        await user.keyboard("[ArrowUp]");
+        await user.keyboard("[ArrowUp]");
+        expect(elt.value).toBe("20");
+    });
+    it("Validate value when reaching min value", async () => {
+        const user = userEvent.setup();
+        const { getByDisplayValue } = render(<Input value={"20"} type="number" step={2} min={0} />);
+        const elt = getByDisplayValue("20") as HTMLInputElement;
+        await user.click(elt);
+        await user.keyboard("[ShiftLeft>]");
+        // Press the arrow down twice to validate that the value will not exceed the minimum value when reached
+        await user.keyboard("[ArrowDown]");
+        await user.keyboard("[ArrowDown]");
+        expect(elt.value).toBe("0");
+    });
 });

+ 145 - 5
frontend/taipy-gui/src/components/Taipy/Input.tsx

@@ -11,9 +11,12 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { useState, useEffect, useCallback, useRef, KeyboardEvent } from "react";
+import React, { useState, useEffect, useCallback, useRef, KeyboardEvent, useMemo } from "react";
 import TextField from "@mui/material/TextField";
 import Tooltip from "@mui/material/Tooltip";
+import IconButton from "@mui/material/IconButton";
+import ArrowDropUpIcon from "@mui/icons-material/ArrowDropUp";
+import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
 
 import { createSendActionNameAction, createSendUpdateAction } from "../../context/taipyReducers";
 import { TaipyInputProps } from "./utils";
@@ -55,6 +58,10 @@ const Input = (props: TaipyInputProps) => {
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
+    const step = useDynamicProperty(props.step, props.defaultStep, 1);
+    const stepMultiplier = useDynamicProperty(props.stepMultiplier, props.defaultStepMultiplier, 10);
+    const min = useDynamicProperty(props.min, props.defaultMin, undefined);
+    const max = useDynamicProperty(props.max, props.defaultMax, undefined);
 
     const handleInput = useCallback(
         (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -74,12 +81,32 @@ const Input = (props: TaipyInputProps) => {
                 }, changeDelay);
             }
         },
-        [updateVarName, dispatch, propagate, onChange, changeDelay, module]
+        [updateVarName, dispatch, propagate, onChange, changeDelay, module],
     );
 
     const handleAction = useCallback(
         (evt: KeyboardEvent<HTMLDivElement>) => {
-            if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && actionKeys.includes(evt.key)) {
+            if (evt.shiftKey && type === "number") {
+                if (evt.key === "ArrowUp") {
+                    let val =
+                        Number(evt.currentTarget.querySelector("input")?.value || 0) +
+                        (step || 1) * (stepMultiplier || 10);
+                    if (max !== undefined && val > max) {
+                        val = max;
+                    }
+                    setValue(val.toString());
+                    evt.preventDefault();
+                } else if (evt.key === "ArrowDown") {
+                    let val =
+                        Number(evt.currentTarget.querySelector("input")?.value || 0) -
+                        (step || 1) * (stepMultiplier || 10);
+                    if (min !== undefined && val < min) {
+                        val = min;
+                    }
+                    setValue(val.toString());
+                    evt.preventDefault();
+                }
+            } else if (!evt.shiftKey && !evt.ctrlKey && !evt.altKey && actionKeys.includes(evt.key)) {
                 const val = evt.currentTarget.querySelector("input")?.value;
                 if (changeDelay > 0 && delayCall.current > 0) {
                     clearTimeout(delayCall.current);
@@ -92,7 +119,73 @@ const Input = (props: TaipyInputProps) => {
                 evt.preventDefault();
             }
         },
-        [actionKeys, updateVarName, onAction, id, dispatch, onChange, changeDelay, propagate, module]
+        [
+            type,
+            actionKeys,
+            step,
+            stepMultiplier,
+            max,
+            min,
+            changeDelay,
+            onAction,
+            dispatch,
+            id,
+            module,
+            updateVarName,
+            onChange,
+            propagate,
+        ],
+    );
+
+    const roundBasedOnStep = useMemo(() => {
+        const stepString = (step || 1).toString();
+        const decimalPlaces = stepString.includes(".") ? stepString.split(".")[1].length : 0;
+        const multiplier = Math.pow(10, decimalPlaces);
+        return (value: number) => Math.round(value * multiplier) / multiplier;
+    }, [step]);
+
+    const calculateNewValue = useMemo(() => {
+        return (prevValue: string, step: number, stepMultiplier: number, shiftKey: boolean, increment: boolean) => {
+            const multiplier = shiftKey ? stepMultiplier : 1;
+            const change = step * multiplier * (increment ? 1 : -1);
+            return roundBasedOnStep(Number(prevValue) + change).toString();
+        };
+    }, [roundBasedOnStep]);
+
+    const handleStepperMouseDown = useCallback(
+        (event: React.MouseEvent<HTMLButtonElement>, increment: boolean) => {
+            setValue((prevValue) => {
+                const newValue = calculateNewValue(
+                    prevValue,
+                    step || 1,
+                    stepMultiplier || 10,
+                    event.shiftKey,
+                    increment,
+                );
+                if (min !== undefined && Number(newValue) < min) {
+                    return min.toString();
+                }
+                if (max !== undefined && Number(newValue) > max) {
+                    return max.toString();
+                }
+                return newValue;
+            });
+        },
+        [min, max, step, stepMultiplier, calculateNewValue],
+    );
+
+    const handleUpStepperMouseDown = useCallback(
+        (event: React.MouseEvent<HTMLButtonElement>) => {
+            handleStepperMouseDown(event, true);
+        },
+        [handleStepperMouseDown],
+    );
+
+    const handleDownStepperMouseDown = useCallback(
+        (event: React.MouseEvent<HTMLButtonElement>) => {
+            handleStepperMouseDown(event, false);
+        },
+        [handleStepperMouseDown],
     );
 
     useEffect(() => {
@@ -104,12 +197,60 @@ const Input = (props: TaipyInputProps) => {
     return (
         <Tooltip title={hover || ""}>
             <TextField
+                sx={{
+                    "& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button":
+                        {
+                            display: "none",
+                        },
+                    "& input[type=number]": {
+                        MozAppearance: "textfield",
+                    },
+                }}
                 margin="dense"
                 hiddenLabel
                 value={value ?? ""}
                 className={className}
                 type={type}
                 id={id}
+                inputProps={
+                    type !== "text"
+                        ? {
+                              step: step ? step : 1,
+                              min: min,
+                              max: max,
+                          }
+                        : {}
+                }
+                InputProps={
+                    type !== "text"
+                        ? {
+                              endAdornment: (
+                                  <div
+                                      style={{
+                                          display: "flex",
+                                          flexDirection: "column",
+                                          gap: 0,
+                                      }}
+                                  >
+                                      <IconButton
+                                          data-testid="stepper-up-spinner"
+                                          size="small"
+                                          onMouseDown={handleUpStepperMouseDown}
+                                      >
+                                          <ArrowDropUpIcon fontSize="inherit" />
+                                      </IconButton>
+                                      <IconButton
+                                          data-testid="stepper-down-spinner"
+                                          size="small"
+                                          onMouseDown={handleDownStepperMouseDown}
+                                      >
+                                          <ArrowDropDownIcon fontSize="inherit" />
+                                      </IconButton>
+                                  </div>
+                              ),
+                          }
+                        : {}
+                }
                 label={props.label}
                 onChange={handleInput}
                 disabled={!active}
@@ -120,5 +261,4 @@ const Input = (props: TaipyInputProps) => {
         </Tooltip>
     );
 };
-
 export default Input;

+ 8 - 0
frontend/taipy-gui/src/components/Taipy/utils.ts

@@ -48,6 +48,14 @@ export interface TaipyInputProps extends TaipyActiveProps, TaipyChangeProps, Tai
     type: string;
     value: string;
     defaultValue?: string;
+    step?: number;
+    defaultStep?: number;
+    stepMultiplier?: number;
+    defaultStepMultiplier?: number;
+    min?: number;
+    defaultMin?: number;
+    max?: number;
+    defaultMax?: number;
     changeDelay?: number;
     onAction?: string;
     actionKeys?: string;

+ 4 - 0
taipy/gui/_renderers/factory.py

@@ -386,6 +386,10 @@ class _Factory:
         .set_attributes(
             [
                 ("active", PropertyType.dynamic_boolean, True),
+                ("step", PropertyType.dynamic_number, 1),
+                ("step_multiplier", PropertyType.dynamic_number, 10),
+                ("min", PropertyType.dynamic_number),
+                ("max", PropertyType.dynamic_number),
                 ("hover_text", PropertyType.dynamic_string),
                 ("on_change", PropertyType.function),
                 ("on_action", PropertyType.function),

+ 22 - 0
taipy/gui/viselements.json

@@ -133,6 +133,28 @@
                         "type": "str",
                         "default_value": "None",
                         "doc": "The label associated with the input."
+                    },
+                    {
+                        "name": "step",
+                        "type": "dynamic(int|float)",
+                        "default_value": "1",
+                        "doc": "The amount by which the value is incremented or decremented when the user clicks one of the arrow buttons."
+                    },
+                    {
+                        "name": "step_multiplier",
+                        "type": "dynamic(int|float)",
+                        "default_value": "10",
+                        "doc": "A factor that multiplies <i>step</i> when the user presses the Shift key while clicking one of the arrow buttons."
+                    },
+                    {
+                        "name": "min",
+                        "type": "dynamic(int|float)",
+                        "doc": "The minimum value to accept for this input."
+                    },
+                    {
+                        "name": "max",
+                        "type": "dynamic(int|float)",
+                        "doc": "The maximum value to accept for this input."
                     }
                 ]
             }