From fb5eb76d4d50a5233bb3f7fdc0c159ed8ee8cd7a Mon Sep 17 00:00:00 2001 From: ChristianBusshoff <christian.busshoff@mail.schwarz> Date: Thu, 26 Dec 2024 12:45:24 +0100 Subject: [PATCH 1/8] update Stepper API --- .../components/OnyxStepper/OnyxStepper.ct.tsx | 62 ++---------------- .../components/OnyxStepper/OnyxStepper.vue | 65 ++++++++++--------- .../src/components/OnyxStepper/types.ts | 28 ++++++-- .../src/composables/useCustomValidity.ts | 4 +- 4 files changed, 63 insertions(+), 96 deletions(-) diff --git a/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.ct.tsx b/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.ct.tsx index afed0a705c..eb1fa372d9 100644 --- a/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.ct.tsx +++ b/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.ct.tsx @@ -336,53 +336,6 @@ test("should increment/decrement value by one on counter button click", async ({ await expect(input).toHaveValue("0"); }); -test("should increment/decrement value by step on counter button click", async ({ - mount, - makeAxeBuilder, -}) => { - // ARRANGE - const on = { - "update:modelValue": (newValue) => { - component.update({ - props: { - modelValue: newValue, - }, - on, - }); - }, - }; - - const component = await mount(OnyxStepper, { - props: { - label: "Test label", - style: "width: 12rem;", - stepSize: 2, - }, - on, - }); - - const input = component.getByLabel("Test label"); - const addButton = component.getByLabel("Increment"); - const substractButton = component.getByLabel("Decrement by 2"); - - // ACT - const accessibilityScanResults = await makeAxeBuilder().analyze(); - - // ASSERT - expect(accessibilityScanResults.violations).toEqual([]); - await expect(component.getByLabel("Test label")).toBeAttached(); - - await input.click(); - await input.fill("0"); - await expect(input).toHaveValue("0"); - - await addButton.click(); - await expect(input).toHaveValue("2"); - - await substractButton.click(); - await expect(input).toHaveValue("0"); -}); - test("should not allow entering value over the max value that has been set", async ({ mount, makeAxeBuilder, @@ -476,7 +429,7 @@ test("should not allow entering value lower the min value that has been set", as await expect(substractButton).toBeDisabled(); }); -test("Should display the same number of decimal places as the smallest possible step", async ({ +test("Should correctly display decimal places according to the defined precision", async ({ mount, makeAxeBuilder, }) => { @@ -496,7 +449,7 @@ test("Should display the same number of decimal places as the smallest possible props: { label: "Test label", style: "width: 12rem;", - precision: 0.01, + precision: 2, }, on, }); @@ -514,7 +467,7 @@ test("Should display the same number of decimal places as the smallest possible await expect(input).toHaveValue("1.00"); }); -test("Should display an error if the value is not a multiple of the precision", async ({ +test("Should display an error if the value is not a multiple of validStepSize", async ({ page, mount, makeAxeBuilder, @@ -536,7 +489,7 @@ test("Should display an error if the value is not a multiple of the precision", label: "Test label", style: "width: 12rem;", modelValue: 1, - precision: 0.5, + validStepSize: 0.5, }, on, }); @@ -554,7 +507,6 @@ test("Should display an error if the value is not a multiple of the precision", await page.keyboard.press("Enter"); await expect(errorMessage).toBeHidden(); - await page.keyboard.press("Enter"); await input.fill("3.6"); await page.keyboard.press("Enter"); @@ -582,7 +534,7 @@ test("Should revert to the last valid input if the current input is invalid in s props: { label: "Test label", style: "width: 12rem;", - precision: 0.5, + validStepSize: 0.5, stripStep: true, }, on, @@ -592,9 +544,9 @@ test("Should revert to the last valid input if the current input is invalid in s await input.fill("1"); await page.keyboard.press("Enter"); - await expect(input).toHaveValue("1.0"); + await expect(input).toHaveValue("1"); await page.keyboard.press("Enter"); await input.fill("1.6"); await page.keyboard.press("Enter"); - await expect(input).toHaveValue("1.0"); + await expect(input).toHaveValue("1"); }); diff --git a/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.vue b/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.vue index 5c2855be10..ed3c08d5ba 100644 --- a/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.vue +++ b/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.vue @@ -15,7 +15,9 @@ import OnyxLoadingIndicator from "../OnyxLoadingIndicator/OnyxLoadingIndicator.v import OnyxSkeleton from "../OnyxSkeleton/OnyxSkeleton.vue"; import type { OnyxStepperProps } from "./types"; const props = withDefaults(defineProps<OnyxStepperProps>(), { - precision: 1, + precision: undefined, + stepSize: 1, + validStepSize: undefined, stripStep: false, readonly: false, loading: false, @@ -50,47 +52,48 @@ const modelValue = defineModel<number>(); */ const inputValue = ref<string>(); -const decimalPlaces = computed(() => { - const precision = props.precision; - const precisionStr = precision.toString(); - if (precisionStr.includes(".")) { - return precisionStr.split(".")[1].length; - } - return -Math.floor(Math.log10(precision)); -}); - watch( modelValue, - () => (inputValue.value = roundToPrecision(modelValue.value, decimalPlaces.value)), + () => { + if (props.precision) { + inputValue.value = roundToPrecision(modelValue.value, props.precision); + } else { + inputValue.value = modelValue.value?.toString(); + } + }, { immediate: true, }, ); -// * stepSize must be precision or bigger -// * use precision as fallback -const determinedStepSize = computed(() => - Math.max(props.stepSize ?? props.precision, props.precision), -); - const handleClick = (direction: "stepUp" | "stepDown") => { if (!inputRef.value) return; wasTouched.value = true; const currentValue = modelValue.value || 0; - const stepValue = (direction === "stepUp" ? 1 : -1) * determinedStepSize.value; + const stepValue = (direction === "stepUp" ? 1 : -1) * props.stepSize; const newValue = currentValue + stepValue; - const roundedValue = Math.round(newValue / props.precision) * props.precision; - - modelValue.value = applyLimits(roundedValue, props.min, props.max); + // correcting invalid inputs to the next valid number + const roundedValue = props.validStepSize + ? Math.round((newValue - (props.min || 0)) / props.validStepSize) * props.validStepSize + + (props.min || 0) + : newValue; + const precisionAdjustedValue = props.precision + ? parseFloat(roundToPrecision(roundedValue, props.precision)) + : roundedValue; + modelValue.value = applyLimits(precisionAdjustedValue, props.min, props.max); }; const handleChange = () => { if (!inputRef.value) return; wasTouched.value = true; const newValue = parseFloat(inputValue.value ?? ""); - const rounded = parseFloat(roundToPrecision(newValue, decimalPlaces.value)); + const rounded = props.precision + ? parseFloat(roundToPrecision(newValue, props.precision)) + : newValue; // reset input - inputValue.value = roundToPrecision(newValue, decimalPlaces.value); + inputValue.value = props.precision + ? roundToPrecision(newValue, props.precision) + : newValue.toString(); if (!newValue || isNaN(newValue)) { modelValue.value = undefined; @@ -99,22 +102,20 @@ const handleChange = () => { if ( props.stripStep && - (!isDivisible(newValue, props.precision) || + ((props.validStepSize && !isDivisible(newValue, props.validStepSize)) || (props.min !== undefined && props.min > newValue) || (props.max !== undefined && props.max < newValue)) ) { - inputValue.value = roundToPrecision(modelValue.value, decimalPlaces.value); + inputValue.value = props.precision + ? roundToPrecision(modelValue.value, props.precision) + : modelValue.value?.toString(); return; } modelValue.value = rounded; }; -const incrementLabel = computed(() => - t.value("stepper.increment", { stepSize: determinedStepSize.value }), -); -const decrementLabel = computed(() => - t.value("stepper.decrement", { stepSize: determinedStepSize.value }), -); +const incrementLabel = computed(() => t.value("stepper.increment", { stepSize: props.stepSize })); +const decrementLabel = computed(() => t.value("stepper.decrement", { stepSize: props.stepSize })); </script> <template> @@ -163,7 +164,7 @@ const decrementLabel = computed(() => :placeholder="props.placeholder" :readonly="props.readonly" :required="props.required" - :step="props.precision" + :step="props.validStepSize ?? 'any'" :title="props.hideLabel ? props.label : undefined" @change="handleChange" @keydown.up.prevent="handleClick('stepUp')" diff --git a/packages/sit-onyx/src/components/OnyxStepper/types.ts b/packages/sit-onyx/src/components/OnyxStepper/types.ts index b95a939b57..f635df43e8 100644 --- a/packages/sit-onyx/src/components/OnyxStepper/types.ts +++ b/packages/sit-onyx/src/components/OnyxStepper/types.ts @@ -33,18 +33,32 @@ export type OnyxStepperProps = FormInjectedProps & step?: number; // step-mismatch + step-increment /** - * The smallest allowed value and rounded precision + * Number of decimal places to show. Can also be negative. Value will be rounded if needed + * to match the specified precision. + * + * @example For `precision=2` with `modelValue=5`, "5.00" will be displayed. + * @example For `precision=-1` with `modelValue=60`, "100" will be displayed. + * @default undefined */ - precision?: number; // step-mismatch => uses :step="props.precision" for the validation + precision?: number; + /** - * The increment number - * @default precision is the default stepSize + * Defines how much the value is adjusted when clicking the +/- button. + * + * @default 1 */ - stepSize?: number; // step-increment => number which is used for increment/decrement - + stepSize?: number; /** - * Ensure no wrong number can be inputed + * Defines step size f0r valid/allowed values. Can be independent of the `stepSize` property. + * Uses the `min` property as base so if defining min=3 with validStepSize=2, only odd numbers will + * + * @example For `validStepSize` 0.01, only multiples of 0.01 are valid (useful for currencies) + * @example For `stepSize=4` `validStepSize=2`, only even numbers are valid and the user can adjust the value by 4 when clicking the +/- button. + * @example For `min=3` and `validStepSize=2`, only odd numbers will be valid + * @default undefined */ + validStepSize?: number; + stripStep?: boolean; /** diff --git a/packages/sit-onyx/src/composables/useCustomValidity.ts b/packages/sit-onyx/src/composables/useCustomValidity.ts index 01309d410f..e3094747e4 100644 --- a/packages/sit-onyx/src/composables/useCustomValidity.ts +++ b/packages/sit-onyx/src/composables/useCustomValidity.ts @@ -28,7 +28,7 @@ export type UseCustomValidityOptions = { minlength?: number; min?: DateValue; max?: DateValue; - precision?: number; + validStepSize?: number; } & Pick<BaseSelectOption, "hideLabel" | "label">; /** * Component emit as defined with `const emit = defineEmits()` @@ -205,7 +205,7 @@ export const useCustomValidity = (options: UseCustomValidityOptions) => { maxLength: options.props.maxlength, min: formatMinMax(locale.value, options.props.type, options.props.min), max: formatMinMax(locale.value, options.props.type, options.props.max), - step: options.props.precision, + step: options.props.validStepSize, }; return { From f5b11693985282c19bd7e3e796e46e23c1573536 Mon Sep 17 00:00:00 2001 From: ChristianBusshoff <christian.busshoff@mail.schwarz> Date: Fri, 27 Dec 2024 11:01:54 +0100 Subject: [PATCH 2/8] fix: type discriptions --- .../sit-onyx/src/components/OnyxStepper/types.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/sit-onyx/src/components/OnyxStepper/types.ts b/packages/sit-onyx/src/components/OnyxStepper/types.ts index f635df43e8..e515dd8fd6 100644 --- a/packages/sit-onyx/src/components/OnyxStepper/types.ts +++ b/packages/sit-onyx/src/components/OnyxStepper/types.ts @@ -24,9 +24,8 @@ export type OnyxStepperProps = FormInjectedProps & */ max?: number; /** - * Incremental step. - */ - /** + * Ensure no wrong number can be inputed + */ /** * Incremental step. * @deprecated */ @@ -37,7 +36,7 @@ export type OnyxStepperProps = FormInjectedProps & * to match the specified precision. * * @example For `precision=2` with `modelValue=5`, "5.00" will be displayed. - * @example For `precision=-1` with `modelValue=60`, "100" will be displayed. + * @example For `precision=-2` with `modelValue=60`, "100" will be displayed. * @default undefined */ precision?: number; @@ -49,7 +48,7 @@ export type OnyxStepperProps = FormInjectedProps & */ stepSize?: number; /** - * Defines step size f0r valid/allowed values. Can be independent of the `stepSize` property. + * Defines step size for valid/allowed values. Can be independent of the `stepSize` property. * Uses the `min` property as base so if defining min=3 with validStepSize=2, only odd numbers will * * @example For `validStepSize` 0.01, only multiples of 0.01 are valid (useful for currencies) @@ -58,7 +57,9 @@ export type OnyxStepperProps = FormInjectedProps & * @default undefined */ validStepSize?: number; - + /** + * Ensure no wrong number can be inputed + */ stripStep?: boolean; /** From 7a039eeac636a5e3e074f50200a2a4a518937837 Mon Sep 17 00:00:00 2001 From: ChristianBusshoff <christian.busshoff@mail.schwarz> Date: Fri, 27 Dec 2024 16:54:19 +0100 Subject: [PATCH 3/8] change --- packages/sit-onyx/src/components/OnyxStepper/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/sit-onyx/src/components/OnyxStepper/types.ts b/packages/sit-onyx/src/components/OnyxStepper/types.ts index e515dd8fd6..635c274f66 100644 --- a/packages/sit-onyx/src/components/OnyxStepper/types.ts +++ b/packages/sit-onyx/src/components/OnyxStepper/types.ts @@ -24,8 +24,6 @@ export type OnyxStepperProps = FormInjectedProps & */ max?: number; /** - * Ensure no wrong number can be inputed - */ /** * Incremental step. * @deprecated */ From 939ea387b306f5c73cd9de87b25b296af7fd201c Mon Sep 17 00:00:00 2001 From: ChristianBusshoff <christian.busshoff@mail.schwarz> Date: Thu, 2 Jan 2025 08:40:28 +0100 Subject: [PATCH 4/8] docs(changeset): feat(OnyxStepper): update StepperAPI --- .changeset/unlucky-mice-ring.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/unlucky-mice-ring.md diff --git a/.changeset/unlucky-mice-ring.md b/.changeset/unlucky-mice-ring.md new file mode 100644 index 0000000000..7b2b03d48d --- /dev/null +++ b/.changeset/unlucky-mice-ring.md @@ -0,0 +1,5 @@ +--- +"sit-onyx": minor +--- + +feat(OnyxStepper): update StepperAPI From 4d2220f7291fb1aa1b33daa8987b2c0877db8a60 Mon Sep 17 00:00:00 2001 From: ChristianBusshoff <christian.busshoff@mail.schwarz> Date: Thu, 2 Jan 2025 08:43:01 +0100 Subject: [PATCH 5/8] add changeset description --- .changeset/unlucky-mice-ring.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.changeset/unlucky-mice-ring.md b/.changeset/unlucky-mice-ring.md index 7b2b03d48d..97b6029146 100644 --- a/.changeset/unlucky-mice-ring.md +++ b/.changeset/unlucky-mice-ring.md @@ -3,3 +3,7 @@ --- feat(OnyxStepper): update StepperAPI + +- stepSize: Defines how much the value is adjusted when clicking the +/- button. +- precision: Number of decimal places to show. +- validStepSize: Defines step size fir valid/allowed values. From e44608c82e56950b72c86e8f9b5b988097988853 Mon Sep 17 00:00:00 2001 From: Lars Rickert <lars.rickert@mail.schwarz> Date: Thu, 2 Jan 2025 14:45:36 +0100 Subject: [PATCH 6/8] implement PR review feedback for #2416 (#2428) PR review feedback/changes for #2416 Changes: - update changeset - update tests - remove unused `step` and `stripStep` property as well as code to no longer modify the user entered value when `validStepSize` is set --- .changeset/unlucky-mice-ring.md | 11 +- .../src/components/form-demo/FormDemo.vue | 9 +- .../components/OnyxStepper/OnyxStepper.ct.tsx | 175 +++++++----------- .../OnyxStepper/OnyxStepper.stories.ts | 11 ++ .../components/OnyxStepper/OnyxStepper.vue | 78 +++----- .../src/components/OnyxStepper/types.ts | 29 +-- .../EditGridElementDialog.vue | 2 + packages/sit-onyx/src/utils/numbers.spec.ts | 26 +-- packages/sit-onyx/src/utils/numbers.ts | 23 +-- 9 files changed, 120 insertions(+), 244 deletions(-) diff --git a/.changeset/unlucky-mice-ring.md b/.changeset/unlucky-mice-ring.md index 97b6029146..74543e16b5 100644 --- a/.changeset/unlucky-mice-ring.md +++ b/.changeset/unlucky-mice-ring.md @@ -1,9 +1,10 @@ --- -"sit-onyx": minor +"sit-onyx": major --- -feat(OnyxStepper): update StepperAPI +feat(OnyxStepper): update OnyxStepper API -- stepSize: Defines how much the value is adjusted when clicking the +/- button. -- precision: Number of decimal places to show. -- validStepSize: Defines step size fir valid/allowed values. +- remove property `step`, use `stepSize` instead +- remove property `stripStep`, use `validStepSize` instead. User inputs will no longer be manipulated, instead an error will be shown +- changed logic of `precision` property. Now determined numbers of decimal places to show. Is no longer the default value of `stepSize` property. +- fix bug that decimal value is not displayed correctly when `precision` is not set diff --git a/apps/demo-app/src/components/form-demo/FormDemo.vue b/apps/demo-app/src/components/form-demo/FormDemo.vue index da8fb78c5b..6741dea560 100644 --- a/apps/demo-app/src/components/form-demo/FormDemo.vue +++ b/apps/demo-app/src/components/form-demo/FormDemo.vue @@ -127,7 +127,7 @@ const radioOptions: RadioButtonOption[] = [ :minlength="5" required /> - <OnyxStepper v-model="formState.defaultStepper" class="onyx-grid-span-4" label="Delault" /> + <OnyxStepper v-model="formState.defaultStepper" class="onyx-grid-span-4" label="Default" /> <OnyxStepper v-model="formState.requiredStepper" class="onyx-grid-span-4" @@ -142,13 +142,6 @@ const radioOptions: RadioButtonOption[] = [ :max="20" /> - <OnyxStepper - v-model="formState.stripStepStepper" - class="onyx-grid-span-4" - label="Strip Step" - strip-step - /> - <OnyxSwitch v-model="formState.switch" class="onyx-grid-span-4" label="Switch" required /> <OnyxCheckboxGroup diff --git a/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.ct.tsx b/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.ct.tsx index eb1fa372d9..49a2a652f7 100644 --- a/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.ct.tsx +++ b/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.ct.tsx @@ -236,9 +236,9 @@ test.describe("Screenshot tests", () => { }); }); -test("should emit events", async ({ mount, makeAxeBuilder }) => { +test("should emit events", async ({ mount }) => { const events = { - updateModelValue: [] as string[], + updateModelValue: [] as number[], }; // ARRANGE @@ -254,12 +254,6 @@ test("should emit events", async ({ mount, makeAxeBuilder }) => { updateModelValue: [], }); - // ACT - const accessibilityScanResults = await makeAxeBuilder().analyze(); - - // ASSERT - expect(accessibilityScanResults.violations).toEqual([]); - const inputElement = component.getByLabel("Label"); // ACT @@ -290,13 +284,10 @@ test("should have aria-label if label is hidden", async ({ mount, makeAxeBuilder await expect(component.getByLabel("Test label")).toBeAttached(); }); -test("should increment/decrement value by one on counter button click", async ({ - mount, - makeAxeBuilder, -}) => { +test("should increment/decrement value on counter button click", async ({ mount }) => { // ARRANGE const on = { - "update:modelValue": (newValue) => { + "update:modelValue": (newValue?: number) => { component.update({ props: { modelValue: newValue, @@ -310,39 +301,29 @@ test("should increment/decrement value by one on counter button click", async ({ props: { label: "Test label", style: "width: 12rem;", + stepSize: 2, }, on, }); const input = component.getByLabel("Test label"); - const incrementButton = component.getByLabel("Increment by 1"); - const decrementButton = component.getByLabel("Decrement by 1"); - - // ACT - const accessibilityScanResults = await makeAxeBuilder().analyze(); + const incrementButton = component.getByLabel("Increment by 2"); + const decrementButton = component.getByLabel("Decrement by 2"); // ASSERT - expect(accessibilityScanResults.violations).toEqual([]); - await expect(component.getByLabel("Test label")).toBeAttached(); - - await input.click(); - await input.fill("0"); - await expect(input).toHaveValue("0"); + await expect(input).toHaveValue(""); await incrementButton.click(); - await expect(input).toHaveValue("1"); + await expect(input).toHaveValue("2"); await decrementButton.click(); await expect(input).toHaveValue("0"); }); -test("should not allow entering value over the max value that has been set", async ({ - mount, - makeAxeBuilder, -}) => { +test("should not allow entering value over the max value that has been set", async ({ mount }) => { // ARRANGE const on = { - "update:modelValue": (newValue) => { + "update:modelValue": (newValue?: number) => { component.update({ props: { modelValue: newValue, @@ -356,41 +337,28 @@ test("should not allow entering value over the max value that has been set", asy props: { label: "Test label", style: "width: 12rem;", - max: 2, + max: 3, + stepSize: 2, }, on, }); const input = component.getByLabel("Test label"); - const addButton = component.getByLabel("Increment by 1"); - - // ACT - const accessibilityScanResults = await makeAxeBuilder().analyze(); + const addButton = component.getByLabel("Increment by 2"); // ASSERT - expect(accessibilityScanResults.violations).toEqual([]); - await expect(component.getByLabel("Test label")).toBeAttached(); - - await input.click(); - await input.fill("0"); - await expect(input).toHaveValue("0"); - - await addButton.click(); - await expect(input).toHaveValue("1"); - await addButton.click(); await expect(input).toHaveValue("2"); + await addButton.click(); + await expect(input).toHaveValue("3"); await expect(addButton).toBeDisabled(); }); -test("should not allow entering value lower the min value that has been set", async ({ - mount, - makeAxeBuilder, -}) => { +test("should not allow entering value lower the min value that has been set", async ({ mount }) => { // ARRANGE const on = { - "update:modelValue": (newValue) => { + "update:modelValue": (newValue?: number) => { component.update({ props: { modelValue: newValue, @@ -405,37 +373,33 @@ test("should not allow entering value lower the min value that has been set", as label: "Test label", style: "width: 12rem;", min: 2, - modelValue: 4, + stepSize: 2, + modelValue: 5, }, on, }); const input = component.getByLabel("Test label"); - const substractButton = component.getByLabel("Decrement by 1"); - - // ACT - const accessibilityScanResults = await makeAxeBuilder().analyze(); + const substractButton = component.getByLabel("Decrement by 2"); // ASSERT - expect(accessibilityScanResults.violations).toEqual([]); - await expect(component.getByLabel("Test label")).toBeAttached(); - await substractButton.click(); await expect(input).toHaveValue("3"); await substractButton.click(); await expect(input).toHaveValue("2"); - await expect(substractButton).toBeDisabled(); }); test("Should correctly display decimal places according to the defined precision", async ({ mount, - makeAxeBuilder, }) => { + const modelValueUpdates = [] as (number | undefined)[]; + // ARRANGE const on = { - "update:modelValue": (newValue) => { + "update:modelValue": (newValue?: number) => { + modelValueUpdates.push(newValue); component.update({ props: { modelValue: newValue, @@ -450,31 +414,55 @@ test("Should correctly display decimal places according to the defined precision label: "Test label", style: "width: 12rem;", precision: 2, + modelValue: 1, }, on, }); - const input = component.locator("input"); + const input = component.getByLabel("Test label"); + + // ASSERT + await expect(input).toHaveValue("1.00"); // ACT - const accessibilityScanResults = await makeAxeBuilder().analyze(); + await input.fill("3.1"); + await input.blur(); // ASSERT - expect(accessibilityScanResults.violations).toEqual([]); + await expect(input).toHaveValue("3.10"); + expect(modelValueUpdates).toStrictEqual([3.1]); - await input.fill("1"); - await input.dispatchEvent("change"); - await expect(input).toHaveValue("1.00"); + // ACT + await input.fill("3.106"); + await input.blur(); + + // ASSERT + await expect(input).toHaveValue("3.11"); + expect(modelValueUpdates).toStrictEqual([3.1, 3.11]); + + // ACT + await component.update({ props: { precision: 1 }, on }); + + // ASSERT + await expect(input).toHaveValue("3.1"); + + // ACT + await component.update({ props: { precision: -1 }, on }); + await input.fill("6"); + await input.blur(); + + // ASSERT + await expect(input).toHaveValue("10"); + expect(modelValueUpdates).toStrictEqual([3.1, 3.11, 10]); }); test("Should display an error if the value is not a multiple of validStepSize", async ({ page, mount, - makeAxeBuilder, }) => { // ARRANGE const on = { - "update:modelValue": (newValue: number) => { + "update:modelValue": (newValue?: number) => { component.update({ props: { modelValue: newValue, @@ -498,55 +486,20 @@ test("Should display an error if the value is not a multiple of validStepSize", const errorMessage = component.locator(".onyx-form-element__error-message"); // ACT - const accessibilityScanResults = await makeAxeBuilder().analyze(); - - // ASSERT - expect(accessibilityScanResults.violations).toEqual([]); - await input.fill("1"); await page.keyboard.press("Enter"); + // ASSERT await expect(errorMessage).toBeHidden(); + // ACT await input.fill("3.6"); await page.keyboard.press("Enter"); + // ASSERT await expect(errorMessage).toBeVisible(); -}); - -test("Should revert to the last valid input if the current input is invalid in stripStep mode", async ({ - page, - mount, -}) => { - // ARRANGE - const on = { - "update:modelValue": (newValue: number) => { - component.update({ - props: { - modelValue: newValue, - }, - on, - }); - }, - }; - - const component = await mount(OnyxStepper, { - props: { - label: "Test label", - style: "width: 12rem;", - validStepSize: 0.5, - stripStep: true, - }, - on, - }); - - const input = component.locator("input"); - - await input.fill("1"); - await page.keyboard.press("Enter"); - await expect(input).toHaveValue("1"); - await page.keyboard.press("Enter"); - await input.fill("1.6"); - await page.keyboard.press("Enter"); - await expect(input).toHaveValue("1"); + await expect(errorMessage).toContainText("Invalid number"); + await expect(errorMessage).toContainText( + "Please enter a valid number, that is a multiple of 0.5.", + ); }); diff --git a/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.stories.ts b/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.stories.ts index 815d97a4e1..1584deffce 100644 --- a/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.stories.ts +++ b/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.stories.ts @@ -28,6 +28,17 @@ export const Default = { }, } satisfies Story; +/** + * This example shows a stepper with precision two always show two decimal places. + */ +export const Precision = { + args: { + label: "Currency", + modelValue: 5, + precision: 2, + }, +} satisfies Story; + /** * This example shows the stepper with a placeholder. */ diff --git a/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.vue b/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.vue index ed3c08d5ba..cdb11020b0 100644 --- a/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.vue +++ b/packages/sit-onyx/src/components/OnyxStepper/OnyxStepper.vue @@ -1,38 +1,38 @@ <script lang="ts" setup> import minus from "@sit-onyx/icons/minus.svg?raw"; import plus from "@sit-onyx/icons/plus.svg?raw"; -import { computed, ref, watch } from "vue"; +import { computed, ref, watchEffect } from "vue"; import { useDensity } from "../../composables/density"; import { getFormMessages, useCustomValidity } from "../../composables/useCustomValidity"; import { useErrorClass } from "../../composables/useErrorClass"; import { SKELETON_INJECTED_SYMBOL, useSkeletonContext } from "../../composables/useSkeletonState"; import { injectI18n } from "../../i18n"; -import { applyLimits, isDivisible, roundToPrecision } from "../../utils/numbers"; +import { applyLimits, roundToPrecision } from "../../utils/numbers"; import { FORM_INJECTED_SYMBOL, useFormContext } from "../OnyxForm/OnyxForm.core"; import OnyxFormElement from "../OnyxFormElement/OnyxFormElement.vue"; import OnyxIcon from "../OnyxIcon/OnyxIcon.vue"; import OnyxLoadingIndicator from "../OnyxLoadingIndicator/OnyxLoadingIndicator.vue"; import OnyxSkeleton from "../OnyxSkeleton/OnyxSkeleton.vue"; import type { OnyxStepperProps } from "./types"; + const props = withDefaults(defineProps<OnyxStepperProps>(), { - precision: undefined, stepSize: 1, - validStepSize: undefined, - stripStep: false, readonly: false, loading: false, skeleton: SKELETON_INJECTED_SYMBOL, disabled: FORM_INJECTED_SYMBOL, showError: FORM_INJECTED_SYMBOL, }); -const { t } = injectI18n(); -const inputRef = ref<HTMLInputElement>(); + const emit = defineEmits<{ /** * Emitted when the validity state of the input changes. */ validityChange: [validity: ValidityState]; }>(); + +const { t } = injectI18n(); + const { disabled, showError } = useFormContext(props); const skeleton = useSkeletonContext(props); const errorClass = useErrorClass(showError); @@ -40,78 +40,51 @@ const { densityClass } = useDensity(props); const { vCustomValidity, errorMessages } = useCustomValidity({ props, emit }); const successMessages = computed(() => getFormMessages(props.success)); const messages = computed(() => getFormMessages(props.message)); + /** * Used to detect user interaction to simulate the behavior of :user-invalid for the native input * because the native browser :user-invalid does not trigger when the value is changed via Arrow up/down or increase/decrease buttons */ const wasTouched = ref(false); const modelValue = defineModel<number>(); + /** * Used for syncing the actual input value. * We use string to be able to control the number of decimal places. */ const inputValue = ref<string>(); -watch( - modelValue, - () => { - if (props.precision) { - inputValue.value = roundToPrecision(modelValue.value, props.precision); +const getFormattedValue = computed(() => { + return (value?: number) => { + if (props.precision !== undefined && value !== undefined) { + return roundToPrecision(value, props.precision); } else { - inputValue.value = modelValue.value?.toString(); + return value?.toString() ?? ""; } - }, - { - immediate: true, - }, -); + }; +}); + +watchEffect(() => { + inputValue.value = getFormattedValue.value(modelValue.value); +}); const handleClick = (direction: "stepUp" | "stepDown") => { - if (!inputRef.value) return; wasTouched.value = true; const currentValue = modelValue.value || 0; const stepValue = (direction === "stepUp" ? 1 : -1) * props.stepSize; - const newValue = currentValue + stepValue; - // correcting invalid inputs to the next valid number - const roundedValue = props.validStepSize - ? Math.round((newValue - (props.min || 0)) / props.validStepSize) * props.validStepSize + - (props.min || 0) - : newValue; - const precisionAdjustedValue = props.precision - ? parseFloat(roundToPrecision(roundedValue, props.precision)) - : roundedValue; - modelValue.value = applyLimits(precisionAdjustedValue, props.min, props.max); + const newValue = parseFloat(getFormattedValue.value(currentValue + stepValue)); + modelValue.value = applyLimits(newValue, props.min, props.max); }; const handleChange = () => { - if (!inputRef.value) return; wasTouched.value = true; - const newValue = parseFloat(inputValue.value ?? ""); - const rounded = props.precision - ? parseFloat(roundToPrecision(newValue, props.precision)) - : newValue; - // reset input - inputValue.value = props.precision - ? roundToPrecision(newValue, props.precision) - : newValue.toString(); - - if (!newValue || isNaN(newValue)) { + if (!inputValue.value) { modelValue.value = undefined; return; } - if ( - props.stripStep && - ((props.validStepSize && !isDivisible(newValue, props.validStepSize)) || - (props.min !== undefined && props.min > newValue) || - (props.max !== undefined && props.max < newValue)) - ) { - inputValue.value = props.precision - ? roundToPrecision(modelValue.value, props.precision) - : modelValue.value?.toString(); - return; - } - modelValue.value = rounded; + inputValue.value = getFormattedValue.value(parseFloat(inputValue.value)); + modelValue.value = parseFloat(inputValue.value); }; const incrementLabel = computed(() => t.value("stepper.increment", { stepSize: props.stepSize })); @@ -149,7 +122,6 @@ const decrementLabel = computed(() => t.value("stepper.decrement", { stepSize: p <OnyxLoadingIndicator v-if="props.loading" class="onyx-stepper__loading" type="circle" /> <input v-else - ref="inputRef" v-model="inputValue" v-custom-validity class="onyx-stepper__native" diff --git a/packages/sit-onyx/src/components/OnyxStepper/types.ts b/packages/sit-onyx/src/components/OnyxStepper/types.ts index 635c274f66..9fb9eebbb8 100644 --- a/packages/sit-onyx/src/components/OnyxStepper/types.ts +++ b/packages/sit-onyx/src/components/OnyxStepper/types.ts @@ -24,42 +24,25 @@ export type OnyxStepperProps = FormInjectedProps & */ max?: number; /** - * Incremental step. - * @deprecated + * Defines how much the value is adjusted when clicking the +/- button. */ - step?: number; // step-mismatch + step-increment - + stepSize?: number; /** - * Number of decimal places to show. Can also be negative. Value will be rounded if needed - * to match the specified precision. + * Number of decimal places to show. Can also be negative. Value will be rounded if needed to match the specified precision. * * @example For `precision=2` with `modelValue=5`, "5.00" will be displayed. * @example For `precision=-2` with `modelValue=60`, "100" will be displayed. - * @default undefined + * @example For `precision=0`, only full numbers without decimals will be displayed. */ precision?: number; - - /** - * Defines how much the value is adjusted when clicking the +/- button. - * - * @default 1 - */ - stepSize?: number; /** - * Defines step size for valid/allowed values. Can be independent of the `stepSize` property. - * Uses the `min` property as base so if defining min=3 with validStepSize=2, only odd numbers will + * Defines step size for valid/allowed values. Will show an error if invalid value is entered. + * Can be independent of the `stepSize` property. * * @example For `validStepSize` 0.01, only multiples of 0.01 are valid (useful for currencies) * @example For `stepSize=4` `validStepSize=2`, only even numbers are valid and the user can adjust the value by 4 when clicking the +/- button. - * @example For `min=3` and `validStepSize=2`, only odd numbers will be valid - * @default undefined */ validStepSize?: number; - /** - * Ensure no wrong number can be inputed - */ - stripStep?: boolean; - /** * Specify how to provide automated assistance in filling out the input. * Some autocomplete values might required specific browser permissions to be allowed by the user. diff --git a/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.vue b/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.vue index a2177cfa87..9918861b97 100644 --- a/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.vue +++ b/packages/sit-onyx/src/components/examples/GridPlayground/EditGridElementDialog/EditGridElementDialog.vue @@ -77,6 +77,7 @@ const handleCheckboxChange = (isChecked: boolean, breakpoint: OnyxBreakpoint) => label="Default number of columns" placeholder="Default number of columns" v-bind="STEPPER_VALIDATIONS" + :precision="0" autofocus required /> @@ -107,6 +108,7 @@ const handleCheckboxChange = (isChecked: boolean, breakpoint: OnyxBreakpoint) => v-model="state.breakpoints[breakpoint]" :label="`Number of columns for breakpoint ${breakpoint}`" v-bind="STEPPER_VALIDATIONS" + :precision="0" hide-label :disabled="!state.columnCount" /> diff --git a/packages/sit-onyx/src/utils/numbers.spec.ts b/packages/sit-onyx/src/utils/numbers.spec.ts index 052f00461d..7c6a9eb318 100644 --- a/packages/sit-onyx/src/utils/numbers.spec.ts +++ b/packages/sit-onyx/src/utils/numbers.spec.ts @@ -1,29 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyLimits, isDivisible, roundToPrecision } from "./numbers"; - -// Tests for isDivisible function -describe("isDivisible", () => { - it("returns true if number is divisible by precision", () => { - expect(isDivisible(10, 2)).toBe(true); - expect(isDivisible(15, 5)).toBe(true); - expect(isDivisible(0.4, 0.2)).toBe(true); - }); - - it("returns false if number is not divisible by precision", () => { - expect(isDivisible(10, 3)).toBe(false); - expect(isDivisible(15, 4)).toBe(false); - expect(isDivisible(0.5, 0.3)).toBe(false); - }); - - it("returns true if number is 0 and precision is non-zero", () => { - expect(isDivisible(0, 1)).toBe(true); - expect(isDivisible(0, 0.1)).toBe(true); - }); - - it("returns false if precision is 0 (division by zero)", () => { - expect(isDivisible(10, 0)).toBe(false); - }); -}); +import { applyLimits, roundToPrecision } from "./numbers"; // Tests for applyLimits function describe("applyLimits", () => { diff --git a/packages/sit-onyx/src/utils/numbers.ts b/packages/sit-onyx/src/utils/numbers.ts index fdad893f00..0a5d9da537 100644 --- a/packages/sit-onyx/src/utils/numbers.ts +++ b/packages/sit-onyx/src/utils/numbers.ts @@ -1,14 +1,3 @@ -/** - * Checks if a given number is divisible by a specified precision. - * - * @param number - The number to check for divisibility. - * @param precision - The precision (step size) to check divisibility against. - * @returns `true` if `number` is divisible by `precision`, otherwise `false`. - */ -export const isDivisible = (number: number, precision: number): boolean => { - const quotient = number / precision; - return quotient % 1 === 0; -}; /** * Applies minimum and maximum limits to a given number. * @@ -32,15 +21,11 @@ export const applyLimits = ( * Supports both decimal and whole-number rounding based on the precision provided. * * @param value - The number to round, or `undefined` to return an empty string. - * @param precision - The number of decimal places for rounding (e.g., 0.01 for 2 decimals). + * @param precision - The number of decimal places for rounding (e.g., 0.01 for 2 decimals). Can also be negative. * @returns The rounded number as a string. Returns an empty string if `value` is `undefined`. */ -export const roundToPrecision = (value: number | undefined, precision: number): string => { - if (value === undefined) return ""; - if (precision > 0) { - return value.toFixed(precision); - } - const places = precision; - const factor = Math.pow(10, places); +export const roundToPrecision = (value: number, precision: number): string => { + if (precision >= 0) return value.toFixed(precision); + const factor = Math.pow(10, precision); return (Math.round(value * factor) / factor).toString(); }; From f3dea412134c6e113c75f4135c6e6d85ff66b1ba Mon Sep 17 00:00:00 2001 From: ChristianBusshoff <christian.busshoff@mail.schwarz> Date: Fri, 3 Jan 2025 11:25:21 +0100 Subject: [PATCH 7/8] fix test --- packages/sit-onyx/src/utils/numbers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sit-onyx/src/utils/numbers.ts b/packages/sit-onyx/src/utils/numbers.ts index 0a5d9da537..fa3ea61824 100644 --- a/packages/sit-onyx/src/utils/numbers.ts +++ b/packages/sit-onyx/src/utils/numbers.ts @@ -25,7 +25,7 @@ export const applyLimits = ( * @returns The rounded number as a string. Returns an empty string if `value` is `undefined`. */ export const roundToPrecision = (value: number, precision: number): string => { - if (precision >= 0) return value.toFixed(precision); + if (value && precision >= 0) return value.toFixed(precision); const factor = Math.pow(10, precision); return (Math.round(value * factor) / factor).toString(); }; From b58c5389da2f5393a6c37d2b4fdd4d3af65673bd Mon Sep 17 00:00:00 2001 From: ChristianBusshoff <christian.busshoff@mail.schwarz> Date: Fri, 3 Jan 2025 11:30:41 +0100 Subject: [PATCH 8/8] changes --- packages/sit-onyx/src/utils/numbers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sit-onyx/src/utils/numbers.ts b/packages/sit-onyx/src/utils/numbers.ts index fa3ea61824..3a7adb9a58 100644 --- a/packages/sit-onyx/src/utils/numbers.ts +++ b/packages/sit-onyx/src/utils/numbers.ts @@ -25,7 +25,8 @@ export const applyLimits = ( * @returns The rounded number as a string. Returns an empty string if `value` is `undefined`. */ export const roundToPrecision = (value: number, precision: number): string => { - if (value && precision >= 0) return value.toFixed(precision); + if (!value) return ""; + if (precision >= 0) return value.toFixed(precision); const factor = Math.pow(10, precision); return (Math.round(value * factor) / factor).toString(); };