Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions etc/blocks/monetization-test/ui/src/pages/MainPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ function onProductKeyInput(key: string) {
}
}
function validateProductKey(key: string): boolean | string {
function validateProductKey(key: string): string | undefined {
if (key.length > 0 && key.length !== PRODUCT_KEY_LENGTH) {
return "Invalid product key";
}
return true;
return undefined;
}
const dropdownOptions: ListOption<string>[] = [
Expand Down Expand Up @@ -142,7 +142,7 @@ const productOptions = [
:model-value="app.model.args.productKey"
label="or enter product key"
clearable
:rules="[validateProductKey]"
:validate="validateProductKey"
@update:model-value="onProductKeyInput"
/>
</PlContainer>
Expand Down
6 changes: 3 additions & 3 deletions etc/blocks/ui-examples/ui/src/pages/ErrorsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ const data = reactive({
progressDurationMs: "10000",
});

function positiveNumberRule(v: string): boolean | string {
function positiveNumberRule(v: string): string | undefined {
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) return "Must be a positive number";
return true;
return undefined;
}

const numbers = computed({
Expand Down Expand Up @@ -64,7 +64,7 @@ const numbers = computed({
<PlTextField
v-model="data.progressDurationMs"
label="Progress duration (ms)"
:rules="[positiveNumberRule]"
:validate="positiveNumberRule"
/>
</PlRow>
</PlBlockPage>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const options = listToOptions([
:compact="data.compactBtnGroup"
/>
<PlCheckbox v-model="data.compactBtnGroup">Compact btn group component</PlCheckbox>
<PlTextField v-model="data.text" label="PlTextField" clearable />
<PlTextField v-model="data.text" label="PlTextField" :clearable="() => undefined" />
<PlTextField v-model="data.text" label="PlTextField (password)" type="password" clearable />
<PlSearchField v-model="data.text">
<template #helper>
Expand Down
31 changes: 17 additions & 14 deletions etc/blocks/ui-examples/ui/src/pages/PlNumberFieldPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,26 @@ import { PlRow, PlContainer, PlCheckbox, PlNumberField, PlBlockPage } from "@pla
import { reactive } from "vue";

const data = reactive({
useIncrementButtons: true,
updateOnEnterOrClickOutside: false,
number: 100,
disableSteps: false,
});

function updateNumber(value: number) {
if (data.number == null || Number.isNaN(data.number)) {
data.number = 0;
}
data.number += value;
}
</script>

<template>
<PlBlockPage>
<template #title>PlNumberField</template>
<pre>number: {{ data.number }} {{ typeof data.number }}</pre>
<PlRow>
<PlCheckbox v-model="data.useIncrementButtons">Use increment buttons</PlCheckbox>
<PlCheckbox v-model="data.updateOnEnterOrClickOutside"
>Update on enter or click outside</PlCheckbox
>
<button @click="data.number += 1">Increment number</button>
<button @click="data.number -= 1">Decrement number</button>
<PlCheckbox v-model="data.disableSteps">Disable increment buttons</PlCheckbox>
<button @click="updateNumber(1)">Increment number</button>
<button @click="updateNumber(-1)">Decrement number</button>
<button @click="data.number = NaN">Set NaN</button>
<button @click="data.number = 102">Set 102</button>
<button @click="data.number = '102.2aaaa' as unknown as number">Set '102.2aaaa'</button>
Expand All @@ -31,24 +34,24 @@ const data = reactive({
:min-value="10"
:max-value="100"
label="PlNumberField (min: 10, max: 100)"
:update-on-enter-or-click-outside="data.updateOnEnterOrClickOutside"
:use-increment-buttons="data.useIncrementButtons"
clearable
:disable-steps="data.disableSteps"
/>
<PlNumberField
v-model="data.number"
:min-value="-5"
:max-value="200"
label="PlNumberField (min: -5, max: 200)"
:update-on-enter-or-click-outside="data.updateOnEnterOrClickOutside"
:use-increment-buttons="data.useIncrementButtons"
:clearable="() => undefined"
:disable-steps="data.disableSteps"
/>
<PlNumberField
v-model="data.number"
:max-value="100"
label="PlNumberField (max: 100 and validate is even)"
:validate="(v) => (v % 2 === 0 ? undefined : 'Value must be even')"
:update-on-enter-or-click-outside="data.updateOnEnterOrClickOutside"
:use-increment-buttons="data.useIncrementButtons"
:clearable="() => 33"
:disable-steps="data.disableSteps"
/>
</PlContainer>
</PlRow>
Expand Down
10 changes: 5 additions & 5 deletions etc/blocks/ui-examples/ui/src/pages/PlTextFieldPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ const data = reactive({
optionalNum: "" as string,
});

function numberRule(v: string): boolean | string {
if (v === "") return true;
function numberRule(v: string): string | undefined {
if (v === "") return undefined;
const parsed = Number(v);
if (!Number.isFinite(parsed)) return "Not a number";
return true;
return undefined;
}
</script>

Expand Down Expand Up @@ -42,13 +42,13 @@ function numberRule(v: string): boolean | string {
/>

<div>Number (string) + clearable</div>
<PlTextField v-model="data.num" placeholder="Number" :rules="[numberRule]" clearable />
<PlTextField v-model="data.num" placeholder="Number" :validate="numberRule" clearable />

<div>Optional number (string)</div>
<PlTextField
v-model="data.optionalNum"
placeholder="Number"
:rules="[numberRule]"
:validate="numberRule"
clearable
/>

Expand Down
90 changes: 50 additions & 40 deletions lib/ui/uikit/src/components/PlNumberField/PlNumberField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,19 @@ export default {
};
</script>

<script setup lang="ts">
<script setup lang="ts" generic="C extends undefined | number = undefined | number">
import "./pl-number-field.scss";
import DoubleContour from "../../utils/DoubleContour.vue";
import { useLabelNotch } from "../../utils/useLabelNotch";
import { computed, ref, useSlots, watch } from "vue";
import { PlTooltip } from "../PlTooltip";
import { parseNumber } from "./parseNumber";
import { PlIcon16 } from "../PlIcon16";
import { parseNumber, normalizeNumberString } from "./parseNumber";

const modelValue = defineModel<undefined | number>({ required: true });

const props = withDefaults(
defineProps<{
/** Input is disabled if true */
disabled?: boolean;
/** Label on the top border of the field, empty by default */
label?: string;
/** Input placeholder, empty by default */
Expand All @@ -40,14 +41,16 @@ const props = withDefaults(
minValue?: number;
/** If defined - show an error if value is higher */
maxValue?: number;
/** If false - remove buttons on the right */
useIncrementButtons?: boolean;
/** If true - changes do not apply immediately, they apply only by removing focus from the input (by click enter or by click outside) */
updateOnEnterOrClickOutside?: boolean;
/** Input is disabled if true */
disabled?: boolean;
/** If true - remove buttons on the right */
disableSteps?: boolean;
/** Error message that shows always when it's provided, without other checks */
errorMessage?: string;
/** Additional validity check for input value that must return an error text if failed */
validate?: (v: number) => string | undefined;
/** If `true`, shows a clear button that resets value to `undefined`. If a function, calls it to get the reset value. */
clearable?: boolean | (() => C);
/** Makes some of corners not rounded */
groupPosition?:
| "top"
Expand All @@ -66,16 +69,14 @@ const props = withDefaults(
placeholder: undefined,
minValue: undefined,
maxValue: undefined,
useIncrementButtons: true,
updateOnEnter: false,
errorMessage: undefined,
validate: undefined,
clearable: false,
groupPosition: undefined,
disableSteps: false,
validate: undefined,
errorMessage: undefined,
},
);

const modelValue = defineModel<number | undefined>({ required: true });

const slots = useSlots();

const rootRef = ref<HTMLElement>();
Expand Down Expand Up @@ -109,19 +110,30 @@ const inputValue = computed({

cachedValue.value = r.cleanInput;

if (r.error || props.updateOnEnterOrClickOutside) {
if (r.error) {
inputRef.value!.value = r.cleanInput;
} else {
modelValue.value = r.value;
modelValue.value = r.value as C;
}
},
});

const focused = ref(false);
const canShowClearable = computed(
() => props.clearable && modelValue.value !== undefined && !props.disabled,
);

function clear() {
if (typeof props.clearable === "function") {
modelValue.value = props.clearable();
} else {
modelValue.value = undefined as C;
}
resetCachedValue();
}

function applyChanges() {
if (parsedResult.value.error === undefined) {
modelValue.value = parsedResult.value.value;
modelValue.value = parsedResult.value.value as C;
}
}

Expand Down Expand Up @@ -205,16 +217,6 @@ function decrement() {
}

function handleKeyPress(e: { code: string; preventDefault(): void }) {
if (props.updateOnEnterOrClickOutside) {
if (e.code === "Escape") {
inputValue.value = modelToString(modelValue.value);
inputRef.value?.blur();
}
if (e.code === "Enter") {
inputRef.value?.blur();
}
}

if (e.code === "Enter") {
inputValue.value = String(modelValue.value); // to make .1 => 0.1, 10.00 => 10, remove leading zeros etc
}
Expand All @@ -223,15 +225,23 @@ function handleKeyPress(e: { code: string; preventDefault(): void }) {
e.preventDefault();
}

if (props.useIncrementButtons && e.code === "ArrowUp") {
if (props.disableSteps !== true && e.code === "ArrowUp") {
increment();
}

if (props.useIncrementButtons && e.code === "ArrowDown") {
if (props.disableSteps !== true && e.code === "ArrowDown") {
decrement();
}
}

function handlePaste(e: ClipboardEvent) {
const pasted = e.clipboardData?.getData("text");
if (pasted) {
e.preventDefault();
inputValue.value = normalizeNumberString(pasted);
}
}

// https://stackoverflow.com/questions/880512/prevent-text-selection-after-double-click#:~:text=If%20you%20encounter%20a%20situation,none%3B%20to%20the%20summary%20element.
// this prevents selecting of more than input content in some cases,
// but also disable selecting input content by double-click (useful feature)
Expand All @@ -251,10 +261,7 @@ const onMousedown = (ev: MouseEvent) => {
>
<div class="pl-number-field__main-wrapper d-flex">
<DoubleContour class="pl-number-field__contour" :group-position="groupPosition" />
<div
class="pl-number-field__wrapper flex-grow d-flex flex-align-center"
:class="{ withoutArrows: !useIncrementButtons }"
>
<div class="pl-number-field__wrapper flex-grow d-flex flex-align-center">
<label v-if="label" class="text-description">
{{ label }}
<PlTooltip v-if="slots.tooltip" class="info" position="top">
Expand All @@ -269,15 +276,18 @@ const onMousedown = (ev: MouseEvent) => {
:disabled="disabled"
:placeholder="placeholder"
class="text-s flex-grow"
@focusin="focused = true"
@focusout="
focused = false;
applyChanges();
"
@focusout="applyChanges"
@paste="handlePaste"
/>
<PlIcon16
v-if="canShowClearable"
class="pl-number-field__clearable"
name="delete-clear"
@click.stop="clear"
/>
</div>
<div
v-if="useIncrementButtons"
v-if="!props.disableSteps"
class="pl-number-field__icons d-flex-column"
@mousedown="onMousedown"
>
Expand Down
47 changes: 47 additions & 0 deletions lib/ui/uikit/src/components/PlNumberField/parseNumber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,53 @@ function isPartial(v: string) {
return v === "." || v === "," || v === "-";
}

/**
* Normalizes a pasted number string by detecting the format and removing thousand separators.
*
* Supported formats:
* 1.222.333,0053 (EU: dot = thousands, comma = decimal)
* 1,222,333.0053 (US: comma = thousands, dot = decimal)
* 1 222 333,0053 (space = thousands, comma = decimal)
* 1 222 333.0053 (space = thousands, dot = decimal)
*/
export function normalizeNumberString(v: string): string {
v = v.trim();
v = v.replace(/\s/g, ""); // remove spaces / nbsp (thousand separators)
v = v.replace("−", "-");
v = v.replace("–", "-");
v = v.replace("+", "");

const dots = (v.match(/\./g) || []).length;
const commas = (v.match(/,/g) || []).length;

if (dots > 1 && commas <= 1) {
// EU: 1.222.333,0053 — dots are thousand separators, comma is decimal
v = v.replace(/\./g, "");
v = v.replace(",", ".");
} else if (commas > 1 && dots <= 1) {
// US: 1,222,333.0053 — commas are thousand separators, dot is decimal
v = v.replace(/,/g, "");
} else if (dots === 1 && commas === 1) {
// Ambiguous with one of each — last one is the decimal separator
const lastDot = v.lastIndexOf(".");
const lastComma = v.lastIndexOf(",");
if (lastComma > lastDot) {
// EU: 1.222,05
v = v.replace(".", "");
v = v.replace(",", ".");
} else {
// US: 1,222.05
v = v.replace(",", "");
}
} else if (commas === 1 && dots === 0) {
// Single comma — treat as decimal separator
v = v.replace(",", ".");
}
// dots === 1 && commas === 0 — already fine

return v;
}

function clearNumericValue(v: string) {
v = v.trim();
v = v.replace(",", ".");
Expand Down
Loading
Loading