= {
return html`
= {
?hasLabelIcon=${args.hasLabelIcon}
.labelPlacement=${args.labelPlacement ??
ObcNumberInputFieldPlacement.Left}
+ .decimalSeparator=${args.decimalSeparator ?? '.'}
+ .groupSeparator=${args.groupSeparator ?? ','}
+ .minFractionDigits=${args.minFractionDigits ?? 0}
+ .maxFractionDigits=${args.maxFractionDigits ?? undefined}
@input=${console.log}
@change=${console.log}
>
@@ -144,14 +148,14 @@ type Story = StoryObj;
export const Default: Story = {
args: {
- value: '123.45',
+ value: 123.45,
unit: 'bar',
},
};
export const WithPlaceholder: Story = {
args: {
- value: '',
+ value: NaN,
unit: 'kg',
placeholder: '0.0',
},
@@ -159,7 +163,7 @@ export const WithPlaceholder: Story = {
export const WithLeadingIcon: Story = {
args: {
- value: '18.5',
+ value: 18.5,
unit: 'knots',
hasLeadingIcon: true,
},
@@ -171,7 +175,7 @@ export const WithLeadingIcon: Story = {
export const AlignRight: Story = {
args: {
- value: '456.78',
+ value: 456.78,
unit: 'bar',
textAlign: ObcNumberInputFieldTextAlign.Right,
},
@@ -179,7 +183,7 @@ export const AlignRight: Story = {
export const AlignCenter: Story = {
args: {
- value: '22.5',
+ value: 22.5,
unit: '°C',
textAlign: ObcNumberInputFieldTextAlign.Center,
},
@@ -187,19 +191,34 @@ export const AlignCenter: Story = {
export const AlignRightUnitOutside: Story = {
args: {
- value: '1500',
+ value: 1500,
unit: 'RPM',
textAlign: ObcNumberInputFieldTextAlign.RightUnitOutside,
},
};
+// =============================================================================
+// DECIMAL SEPARATOR
+// =============================================================================
+
+export const Formatting: Story = {
+ args: {
+ value: 12312.45,
+ unit: 'bar',
+ decimalSeparator: '.',
+ groupSeparator: ',',
+ minFractionDigits: 3,
+ maxFractionDigits: 3,
+ },
+};
+
// =============================================================================
// SIZE
// =============================================================================
export const SizeRegular: Story = {
args: {
- value: '123.45',
+ value: 123.45,
unit: 'bar',
size: ObcNumberInputFieldSize.Regular,
},
@@ -207,7 +226,7 @@ export const SizeRegular: Story = {
export const SizeLarge: Story = {
args: {
- value: '123.45',
+ value: 123.45,
unit: 'bar',
size: ObcNumberInputFieldSize.Large,
},
@@ -219,7 +238,7 @@ export const SizeLarge: Story = {
export const WithLabel: Story = {
args: {
- value: '75',
+ value: 75,
unit: '%',
label: 'Engine Load',
},
@@ -227,7 +246,7 @@ export const WithLabel: Story = {
export const WithLabelRequired: Story = {
args: {
- value: '',
+ value: NaN,
unit: 'bar',
label: 'Oil Pressure',
required: true,
@@ -236,7 +255,7 @@ export const WithLabelRequired: Story = {
export const WithLabelIcon: Story = {
args: {
- value: '25.5',
+ value: 25.5,
unit: '°C',
label: 'Temperature',
hasLabelIcon: true,
@@ -245,7 +264,7 @@ export const WithLabelIcon: Story = {
export const LabelPlacementLeft: Story = {
args: {
- value: '42',
+ value: 42,
unit: 'kg',
label: 'Weight',
labelPlacement: ObcNumberInputFieldPlacement.Left,
@@ -254,7 +273,7 @@ export const LabelPlacementLeft: Story = {
export const LabelPlacementCenter: Story = {
args: {
- value: '50',
+ value: 50,
unit: '%',
label: 'Progress',
labelPlacement: ObcNumberInputFieldPlacement.Center,
@@ -264,7 +283,7 @@ export const LabelPlacementCenter: Story = {
export const LabelPlacementRight: Story = {
args: {
- value: '42',
+ value: 42,
unit: 'kg',
label: 'Weight',
labelPlacement: ObcNumberInputFieldPlacement.Right,
@@ -277,7 +296,7 @@ export const LabelPlacementRight: Story = {
export const WithHelperText: Story = {
args: {
- value: '3.5',
+ value: 3.5,
unit: 'bar',
helperText: 'Normal range: 3.0 - 4.5 bar',
},
@@ -285,7 +304,7 @@ export const WithHelperText: Story = {
export const WithHelperTextIcon: Story = {
args: {
- value: '25',
+ value: 25,
unit: '°C',
helperText: 'Optimal temperature',
hasHelperIcon: true,
@@ -294,7 +313,7 @@ export const WithHelperTextIcon: Story = {
export const HelperPlacementLeft: Story = {
args: {
- value: '180',
+ value: 180,
unit: 'cm',
helperText: 'Height measurement',
helperPlacement: ObcNumberInputFieldPlacement.Left,
@@ -303,7 +322,7 @@ export const HelperPlacementLeft: Story = {
export const HelperPlacementCenter: Story = {
args: {
- value: '100',
+ value: 100,
unit: '%',
helperText: 'Progress complete',
helperPlacement: ObcNumberInputFieldPlacement.Center,
@@ -313,7 +332,7 @@ export const HelperPlacementCenter: Story = {
export const HelperPlacementRight: Story = {
args: {
- value: '180',
+ value: 180,
unit: 'cm',
helperText: 'Height measurement',
helperPlacement: ObcNumberInputFieldPlacement.Right,
@@ -326,7 +345,7 @@ export const HelperPlacementRight: Story = {
export const Disabled: Story = {
args: {
- value: '50',
+ value: 50,
unit: '%',
label: 'Throttle Position',
disabled: true,
@@ -335,7 +354,7 @@ export const Disabled: Story = {
export const Error: Story = {
args: {
- value: '999',
+ value: 999,
unit: 'kPa',
error: true,
errorText: 'Value exceeds maximum limit',
@@ -344,7 +363,7 @@ export const Error: Story = {
export const ErrorWithLabel: Story = {
args: {
- value: '',
+ value: NaN,
unit: 'kg',
label: 'Weight',
required: true,
@@ -355,7 +374,7 @@ export const ErrorWithLabel: Story = {
export const ErrorWithIcon: Story = {
args: {
- value: '999',
+ value: 999,
unit: 'kPa',
error: true,
errorText: 'Value exceeds maximum limit',
@@ -369,7 +388,7 @@ export const ErrorWithIcon: Story = {
export const Complete: Story = {
args: {
- value: '85.5',
+ value: 85.5,
unit: 'kW',
label: 'Power Output',
required: true,
@@ -380,7 +399,7 @@ export const Complete: Story = {
export const CompleteCenter: Story = {
args: {
- value: '42',
+ value: 42,
unit: 'Hz',
label: 'Frequency',
required: true,
@@ -394,7 +413,7 @@ export const CompleteCenter: Story = {
export const CompleteDisabled: Story = {
args: {
- value: '100',
+ value: 100,
unit: '%',
label: 'System Load',
hasLeadingIcon: true,
@@ -405,7 +424,7 @@ export const CompleteDisabled: Story = {
export const CompleteError: Story = {
args: {
- value: '150',
+ value: 150,
unit: 'bar',
label: 'Pressure Alert',
required: true,
diff --git a/packages/openbridge-webcomponents/src/components/number-input-field/number-input-field.ts b/packages/openbridge-webcomponents/src/components/number-input-field/number-input-field.ts
index 3eab2bed0..09299b583 100644
--- a/packages/openbridge-webcomponents/src/components/number-input-field/number-input-field.ts
+++ b/packages/openbridge-webcomponents/src/components/number-input-field/number-input-field.ts
@@ -11,6 +11,17 @@ import {ifDefined} from 'lit/directives/if-defined.js';
import componentStyle from './number-input-field.css?inline';
import {classMap} from 'lit/directives/class-map.js';
import {customElement} from '../../decorator.js';
+import {
+ formatNumberForDisplay,
+ NumberInputFormatOptions,
+ parseNumberInput,
+ removeGroupingFromDisplay,
+ valuesEqual,
+} from './number-input-format.js';
+import {getCssVariableValue} from '../../charthelpers/colors.js';
+
+export type ObcNumberInputFieldInputEvent = CustomEvent<{value: number}>;
+export type ObcNumberInputFieldChangeEvent = CustomEvent<{value: number}>;
export enum ObcNumberInputFieldTextAlign {
Center = 'center',
@@ -29,17 +40,23 @@ export enum ObcNumberInputFieldPlacement {
Right = 'right',
}
+const characterWidth = 9.15199279785156;
+const symbolWidth = 4.287994384765653;
+const baseFontSize = 16;
/**
* `` – A specialized input field for numerical values with optional unit display.
*
* @slot leading-icon - Icon displayed before the input value (when `hasLeadingIcon` is true)
* @slot label-icon - Icon displayed before the label text (when `hasLabelIcon` is true)
* @slot helper-icon - Icon displayed before helper or error text (when `hasHelperIcon` is true)
- * @fires input - Standard input event on value change
+ * @fires input {CustomEvent<{value: number}>} When the numeric value changes during editing
+ * @fires change {CustomEvent<{value: number}>} When the value is committed on blur
*/
@customElement('obc-number-input-field')
export class ObcNumberInputField extends LitElement {
- @property({type: String}) value = '';
+ private static readonly centerAlignInputMinWidth = 10;
+
+ @property({type: Number}) value = NaN;
@property({type: String}) unit = '';
@property({type: String}) placeholder = '';
@property({type: String}) textAlign: ObcNumberInputFieldTextAlign =
@@ -87,60 +104,213 @@ export class ObcNumberInputField extends LitElement {
/** Internal property for squared corners, used when input is used in stepper-box */
@property({type: Boolean}) squared = false;
+ /**
+ * Optional display text override for controlled consumers (e.g. keyboard-numeric)
+ * that manage formatted strings while the committed value may be NaN.
+ */
+ @property({type: String, attribute: false}) displayOverride = '';
+
+ @property({type: String}) decimalSeparator?: string;
+ @property({type: String}) groupSeparator?: string;
+ @property({type: Number}) minFractionDigits = 0;
+ @property({type: Number}) maxFractionDigits?: number | undefined;
+
@state() private hasFocus = false;
- @state() private previousValue = '';
- @state() private previousInputElementValue = '';
+ @state() private displayText = '';
+ @state() private previousValue = NaN;
+ @state() private previousDisplayText = '';
+ @state() private lastCommittedValue = NaN;
@query('.value-input') private inputElement?: HTMLInputElement;
+ get displayValue(): string {
+ return this.displayText;
+ }
+
+ private getFormatOptions(): NumberInputFormatOptions {
+ return {
+ decimalSeparator: this.decimalSeparator,
+ groupSeparator: this.groupSeparator,
+ minFractionDigits: this.minFractionDigits,
+ maxFractionDigits: this.maxFractionDigits,
+ };
+ }
+
+ private formatValueForDisplay(value: number): string {
+ return formatNumberForDisplay(value, this.getFormatOptions());
+ }
+
private onInput(e: Event) {
- this.value = (e.target as HTMLInputElement).value;
- this.previousInputElementValue = this.value;
+ e.stopPropagation();
+ const raw = (e.target as HTMLInputElement).value;
+ this.displayText = raw;
+ this.displayOverride = '';
+ const parsed = parseNumberInput(raw);
+ this.value = parsed;
+ this.previousDisplayText = raw;
+ this.updateCenterAlignedInputWidth(raw);
+ this.dispatchInput();
}
private onFocus() {
this.hasFocus = true;
+ const source = this.displayOverride || this.displayText;
+ this.displayText = removeGroupingFromDisplay(
+ source,
+ this.getFormatOptions()
+ );
+ this.displayOverride = '';
}
private onBlur() {
this.hasFocus = false;
+ this.commitDisplay();
+ if (!valuesEqual(this.value, this.lastCommittedValue)) {
+ this.lastCommittedValue = this.value;
+ this.dispatchChange();
+ }
+ }
+
+ private commitDisplay() {
+ const trimmed = this.displayText.trim();
+ if (trimmed === '') {
+ this.value = NaN;
+ this.displayText = '';
+ this.displayOverride = '';
+ return;
+ }
+
+ const forCommit = trimmed.replace(/[.,]$/, '');
+ const parsed = parseNumberInput(forCommit);
+
+ if (Number.isFinite(parsed)) {
+ this.value = parsed;
+ this.displayText = this.formatValueForDisplay(parsed);
+ } else {
+ this.value = NaN;
+ }
+ this.displayOverride = '';
+ }
+
+ private dispatchInput() {
+ this.dispatchEvent(
+ new CustomEvent('input', {
+ detail: {value: this.value},
+ })
+ );
+ }
+
+ private dispatchChange() {
+ this.dispatchEvent(
+ new CustomEvent('change', {
+ detail: {value: this.value},
+ })
+ );
}
private get shouldUpdateValue(): boolean {
if (this.rejectUpdates) return false;
if (this.rejectUpdatesOnFocus && this.hasFocus) return false;
- if (this.rejectDuplicateUpdates && this.value === this.previousValue) {
+ if (
+ this.rejectDuplicateUpdates &&
+ valuesEqual(this.value, this.previousValue)
+ ) {
return false;
}
return true;
}
+ private getEffectiveDisplay(): string {
+ if (this.displayOverride) {
+ return this.displayOverride;
+ }
+ if (!this.shouldUpdateValue && this.inputElement) {
+ return this.inputElement.value;
+ }
+ return this.displayText;
+ }
+
+ private updateCenterAlignedInputWidth(value: string) {
+ if (this.textAlign !== ObcNumberInputFieldTextAlign.Center) {
+ this.style.removeProperty('--obc-number-input-center-width');
+ return;
+ }
+
+ const fontSize = Number.parseFloat(
+ getCssVariableValue(
+ this,
+ '--global-typography-instrument-value-regular-font-size'
+ )
+ );
+
+ const characters = value.replaceAll(/[.,]/g, '');
+ const symbols = value.length - characters.length;
+ // These values are based on the font size of 16px
+
+ const calculatedWidth =
+ characters.length * characterWidth + symbols * symbolWidth;
+
+ const measuredWidth = Math.ceil(
+ (calculatedWidth * fontSize) / baseFontSize
+ );
+ this.style.setProperty(
+ '--obc-number-input-center-width',
+ `${measuredWidth}px`
+ );
+ }
+
+ override firstUpdated() {
+ if (!this.displayText && !this.displayOverride) {
+ this.displayText = this.formatValueForDisplay(this.value);
+ }
+ this.lastCommittedValue = this.value;
+ this.updateCenterAlignedInputWidth(this.getEffectiveDisplay());
+ }
+
private get isEmpty(): boolean {
- const currentValue =
- !this.shouldUpdateValue && this.inputElement
- ? this.inputElement.value
- : this.value;
- return currentValue.trim().length === 0;
+ if (this.inputElement) {
+ return this.inputElement.value.trim().length === 0;
+ }
+ return isNaN(this.value);
}
override willUpdate(changedProperties: PropertyValues) {
+ const formatPropsChanged =
+ changedProperties.has('decimalSeparator') ||
+ changedProperties.has('groupSeparator') ||
+ changedProperties.has('minFractionDigits') ||
+ changedProperties.has('maxFractionDigits');
+
+ if (changedProperties.has('value') && this.shouldUpdateValue) {
+ if (!this.hasFocus) {
+ this.displayText = this.formatValueForDisplay(this.value);
+ this.displayOverride = '';
+ this.lastCommittedValue = this.value;
+ }
+ } else if (formatPropsChanged && !this.hasFocus && this.shouldUpdateValue) {
+ this.displayText = this.formatValueForDisplay(this.value);
+ this.displayOverride = '';
+ }
+
if (
changedProperties.has('value') &&
!this.shouldUpdateValue &&
this.inputElement
) {
- this.value = this.inputElement.value;
+ this.value = parseNumberInput(this.inputElement.value);
}
}
override updated() {
if (
this.rejectDuplicateUpdates &&
- this.value !== this.previousValue &&
- (this.previousInputElementValue !== this.value || !this.hasFocus)
+ !valuesEqual(this.value, this.previousValue) &&
+ (this.previousDisplayText !== this.displayText || !this.hasFocus)
) {
this.previousValue = this.value;
}
+
+ this.updateCenterAlignedInputWidth(this.getEffectiveDisplay());
}
private renderFooterText(
@@ -162,6 +332,47 @@ export class ObcNumberInputField extends LitElement {
`;
}
+ private onPointerDown(e: PointerEvent) {
+ if (this.disabled) return;
+ if (this.readonly) return;
+ if (this.inputElement) {
+ e.stopPropagation();
+ e.preventDefault();
+ this.inputElement.focus();
+ const inputBox = this.inputElement.getBoundingClientRect();
+ let selectionStart: number | undefined;
+ if (e.clientX < inputBox.left) {
+ // set marker at the left edge of the input
+ selectionStart = 0;
+ } else if (e.clientX > inputBox.right) {
+ // set marker at the right edge of the input
+ selectionStart = this.inputElement.value.length;
+ } else {
+ // set marker at the mouse position
+ // Start from the right side of the input and move left until the mouse position is found
+ // This is done to set the marker also at label touch.
+ // Starts from the right since the label is right aligned.
+ const mouseX = e.clientX;
+ let inputX = inputBox.right;
+ const text = this.inputElement.value;
+ for (let i = text.length - 1; i >= 0; i--) {
+ const char = text[i];
+ const isSymbol = char.match(/[.,]/);
+ const width = isSymbol ? symbolWidth : characterWidth;
+ if (mouseX > inputX - width / 2) {
+ selectionStart = i + 1;
+ break;
+ }
+ inputX -= width;
+ }
+ if (selectionStart === undefined) {
+ selectionStart = 0;
+ }
+ }
+ this.inputElement.setSelectionRange(selectionStart, selectionStart);
+ }
+ }
+
override render() {
const hasHelperOrError =
Boolean(this.helperText) || Boolean(this.error && this.errorText);
@@ -172,11 +383,7 @@ export class ObcNumberInputField extends LitElement {
this.unit &&
this.textAlign === ObcNumberInputFieldTextAlign.RightUnitOutside;
- let value = this.value;
-
- if (!this.shouldUpdateValue && this.inputElement) {
- value = this.inputElement.value;
- }
+ const display = this.getEffectiveDisplay();
return html`
${this.label
? html` {
+ it('returns NaN for empty input', () => {
+ expect(Number.isNaN(parseNumberInput(''))).toBe(true);
+ });
+
+ it('parses finite numbers', () => {
+ expect(parseNumberInput('10.2')).toBe(10.2);
+ expect(parseNumberInput('10,2')).toBe(10.2);
+ });
+
+ it('parses trailing decimal separator as the numeric value', () => {
+ expect(parseNumberInput('10.')).toBe(10);
+ expect(parseNumberInput('10,')).toBe(10);
+ });
+
+ it('returns NaN for sign-only input', () => {
+ expect(Number.isNaN(parseNumberInput('-'))).toBe(true);
+ expect(Number.isNaN(parseNumberInput('+'))).toBe(true);
+ });
+});
+
+describe('getLocaleNumberSeparators', () => {
+ it('returns separators for en-US', () => {
+ expect(getLocaleNumberSeparators('en-US')).toEqual({
+ decimalSeparator: '.',
+ groupSeparator: ',',
+ });
+ });
+
+ it('returns separators for de-DE', () => {
+ expect(getLocaleNumberSeparators('de-DE')).toEqual({
+ decimalSeparator: ',',
+ groupSeparator: '.',
+ });
+ });
+});
+
+describe('formatNumberForDisplay', () => {
+ it('returns empty string for NaN', () => {
+ expect(formatNumberForDisplay(NaN)).toBe('');
+ });
+
+ it('formats finite numbers using locale defaults without options', () => {
+ expect(formatNumberForDisplay(10.21212)).toBe(
+ new Intl.NumberFormat(undefined, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 20,
+ }).format(10.21212)
+ );
+ });
+
+ it('applies group and decimal separators', () => {
+ expect(
+ formatNumberForDisplay(1234.5, {
+ groupSeparator: ' ',
+ decimalSeparator: ',',
+ minFractionDigits: 1,
+ maxFractionDigits: 1,
+ })
+ ).toBe('1 234,5');
+ });
+
+ it('pads to minFractionDigits', () => {
+ expect(
+ formatNumberForDisplay(10, {
+ minFractionDigits: 2,
+ maxFractionDigits: 2,
+ })
+ ).toBe(
+ new Intl.NumberFormat(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(10)
+ );
+ });
+
+ it('limits to maxFractionDigits', () => {
+ expect(
+ formatNumberForDisplay(10.256, {
+ maxFractionDigits: 2,
+ })
+ ).toBe(
+ new Intl.NumberFormat(undefined, {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ }).format(10.256)
+ );
+ });
+
+ it('formats negative numbers with grouping', () => {
+ expect(
+ formatNumberForDisplay(-1234.5, {
+ groupSeparator: "'",
+ decimalSeparator: ',',
+ minFractionDigits: 1,
+ maxFractionDigits: 1,
+ })
+ ).toBe("-1'234,5");
+ });
+});
+
+describe('removeGroupingFromDisplay', () => {
+ it('removes space group separator', () => {
+ expect(
+ removeGroupingFromDisplay('1 234,50', {
+ groupSeparator: ' ',
+ decimalSeparator: ',',
+ })
+ ).toBe('1234,50');
+ });
+
+ it('removes apostrophe group separator', () => {
+ expect(
+ removeGroupingFromDisplay("1'234,50", {
+ groupSeparator: "'",
+ decimalSeparator: ',',
+ })
+ ).toBe('1234,50');
+ });
+
+ it('removes dot group separator', () => {
+ expect(
+ removeGroupingFromDisplay('1.234,50', {
+ groupSeparator: '.',
+ decimalSeparator: ',',
+ })
+ ).toBe('1234,50');
+ });
+
+ it('returns display unchanged when group separator is empty', () => {
+ expect(removeGroupingFromDisplay('1234.50', {groupSeparator: ''})).toBe(
+ '1234.50'
+ );
+ });
+});
+
+describe('valuesEqual', () => {
+ it('treats NaN as equal to NaN', () => {
+ expect(valuesEqual(NaN, NaN)).toBe(true);
+ });
+
+ it('compares finite numbers', () => {
+ expect(valuesEqual(1, 1)).toBe(true);
+ expect(valuesEqual(1, 2)).toBe(false);
+ });
+});
diff --git a/packages/openbridge-webcomponents/src/components/number-input-field/number-input-format.ts b/packages/openbridge-webcomponents/src/components/number-input-field/number-input-format.ts
new file mode 100644
index 000000000..504b32254
--- /dev/null
+++ b/packages/openbridge-webcomponents/src/components/number-input-field/number-input-format.ts
@@ -0,0 +1,119 @@
+export type NumberInputFormatOptions = {
+ decimalSeparator?: string;
+ groupSeparator?: string;
+ minFractionDigits?: number;
+ maxFractionDigits?: number;
+};
+
+export type LocaleNumberSeparators = {
+ decimalSeparator: string;
+ groupSeparator: string;
+};
+
+export function getLocaleNumberSeparators(
+ locale?: string | string[]
+): LocaleNumberSeparators {
+ const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
+ return {
+ decimalSeparator: parts.find((p) => p.type === 'decimal')?.value ?? '.',
+ groupSeparator: parts.find((p) => p.type === 'group')?.value ?? '',
+ };
+}
+
+export function parseNumberInput(display: string): number {
+ const trimmed = display.trim();
+ if (trimmed === '' || trimmed === '-' || trimmed === '+') {
+ return NaN;
+ }
+
+ const normalized = trimmed.replace(',', '.');
+
+ const n = Number(normalized);
+ return Number.isFinite(n) ? n : NaN;
+}
+
+function clampFractionDigits(
+ minFractionDigits: number | undefined,
+ maxFractionDigits: number | undefined
+): {min: number; max: number | undefined} {
+ const min = Math.max(0, minFractionDigits ?? 0);
+ const max =
+ maxFractionDigits == undefined
+ ? undefined
+ : Math.max(min, maxFractionDigits);
+ return {min, max};
+}
+
+export function formatNumberForDisplay(
+ value: number,
+ options: NumberInputFormatOptions = {}
+): string {
+ if (Number.isNaN(value)) {
+ return '';
+ }
+
+ const localeSeparators = getLocaleNumberSeparators();
+ const decimalSeparator =
+ options.decimalSeparator ?? localeSeparators.decimalSeparator;
+ const groupSeparator =
+ options.groupSeparator ?? localeSeparators.groupSeparator;
+ const {min: minFractionDigits, max: maxFractionDigits} = clampFractionDigits(
+ options.minFractionDigits ?? 0,
+ options.maxFractionDigits
+ );
+ const effectiveMaxFractionDigits = maxFractionDigits ?? 20;
+
+ const usesLocaleSeparators =
+ options.decimalSeparator === undefined &&
+ options.groupSeparator === undefined;
+
+ if (usesLocaleSeparators) {
+ return new Intl.NumberFormat(undefined, {
+ minimumFractionDigits: minFractionDigits,
+ maximumFractionDigits: effectiveMaxFractionDigits,
+ useGrouping: Boolean(groupSeparator),
+ }).format(value);
+ }
+
+ const formatted = new Intl.NumberFormat('en-US', {
+ minimumFractionDigits: minFractionDigits,
+ maximumFractionDigits: effectiveMaxFractionDigits,
+ useGrouping: Boolean(groupSeparator),
+ }).format(value);
+
+ if (decimalSeparator === '.' && groupSeparator === ',') {
+ return formatted;
+ }
+
+ let result = formatted;
+ if (groupSeparator) {
+ result = result.replace(/,/g, groupSeparator);
+ } else {
+ result = result.replace(/,/g, '');
+ }
+ if (decimalSeparator !== '.') {
+ result = result.replace('.', decimalSeparator);
+ }
+
+ return result;
+}
+
+export function removeGroupingFromDisplay(
+ display: string,
+ options: NumberInputFormatOptions = {}
+): string {
+ const localeSeparators = getLocaleNumberSeparators();
+ const groupSeparator =
+ options.groupSeparator ?? localeSeparators.groupSeparator;
+
+ if (!groupSeparator) {
+ return display;
+ }
+
+ const escaped = groupSeparator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ return display.replace(new RegExp(escaped, 'g'), '');
+}
+
+export function valuesEqual(a: number, b: number): boolean {
+ return Object.is(a, b);
+}
diff --git a/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.css b/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.css
index 16b285809..0145ec98a 100644
--- a/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.css
+++ b/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.css
@@ -3,7 +3,7 @@
}
:host {
- display: inline-flex;
+ display: block;
}
.wrapper {
@@ -19,41 +19,12 @@
min-width: 0;
width: 100px;
max-width: 100%;
+ flex: 1;
& obc-number-input-field {
display: block;
min-width: 0;
- margin-left: -1px;
- margin-right: -1px;
- }
-
- &.has-unit-slot {
- & obc-number-input-field::part(input-field-container) {
- border-right: none;
- }
-
- & .unit-slot {
- display: flex;
- align-items: center;
- align-self: center;
- flex: 0 0 auto;
- height: var(
- --ui-components-input-fields-number-input-field-visual-size
- );
- color: var(--element-inactive-color);
- @mixin font-instrument-unit;
- padding-left: 2px;
- padding-right: var(
- --ui-components-input-fields-text-input-field-padding-horizontal
- );
- margin-left: -1px;
- margin-right: -1px;
- border-top: var(--input-field-stroke-weight-top) solid
- var(--normal-enabled-border-color);
- border-bottom: var(--input-field-stroke-weight-bottom) solid
- var(--normal-enabled-border-color);
- background: var(--normal-enabled-background-color);
- }
+ width: 100%;
}
}
diff --git a/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.stories.ts b/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.stories.ts
index 474132d02..217a8950e 100644
--- a/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.stories.ts
+++ b/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.stories.ts
@@ -1,7 +1,7 @@
import type {Meta, StoryObj} from '@storybook/web-components-vite';
import {ObcStepperBoxType} from './stepper-box.js';
import './stepper-box.js';
-import {html, nothing} from 'lit';
+import {html} from 'lit';
type StepperBoxArgs = {
type: ObcStepperBoxType;
@@ -63,9 +63,8 @@ const meta = {
.helperText=${args.helperText}
.placeholder=${args.placeholder}
.readonly=${args.readonly}
- >
- ${args.unit ? html`
${args.unit}
` : nothing}
-
+ .unit=${args.unit}
+ >
`,
} satisfies Meta;
diff --git a/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.ts b/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.ts
index ef506bd7c..831517db3 100644
--- a/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.ts
+++ b/packages/openbridge-webcomponents/src/components/stepper-box/stepper-box.ts
@@ -1,5 +1,5 @@
import {LitElement, html, nothing, unsafeCSS, PropertyValues} from 'lit';
-import {property, queryAssignedElements} from 'lit/decorators.js';
+import {property} from 'lit/decorators.js';
import compentStyle from './stepper-box.css?inline';
import '../../icons/icon-down-iec.js';
import '../icon-button/icon-button.js';
@@ -9,7 +9,6 @@ import '../../icons/icon-chevron-down-google.js';
import '../../icons/icon-chevron-right-google.js';
import '../../icons/icon-chevron-left-google.js';
import {customElement} from '../../decorator.js';
-import {classMap} from 'lit/directives/class-map.js';
import '../number-input-field/number-input-field.js';
import {ObcNumberInputFieldTextAlign} from '../number-input-field/number-input-field.js';
@@ -139,15 +138,6 @@ export class ObcStepperBox extends LitElement {
*/
@property({type: Boolean}) readonly = false;
- @queryAssignedElements({slot: 'unit'})
- private unitSlotElements!: Element[];
-
- private lastRawInput = '';
-
- private get hasUnitSlotContent(): boolean {
- return this.unitSlotElements.length > 0;
- }
-
private get downDisabled(): boolean {
return (
this.disabled ||
@@ -166,26 +156,12 @@ export class ObcStepperBox extends LitElement {
);
}
- private get fieldTextAlign(): ObcNumberInputFieldTextAlign {
- if (this.hasUnitSlotContent) {
- return ObcNumberInputFieldTextAlign.RightUnitOutside;
- }
- if (this.unit) {
- return ObcNumberInputFieldTextAlign.Right;
- }
- return ObcNumberInputFieldTextAlign.Center;
- }
-
override connectedCallback() {
super.connectedCallback();
- this.lastRawInput = this.value == null ? '' : String(this.value);
this.syncDisabledAccessibility();
}
override updated(changedProperties: PropertyValues) {
- if (changedProperties.has('value')) {
- this.lastRawInput = this.value == null ? '' : String(this.value);
- }
if (changedProperties.has('disabled')) {
this.syncDisabledAccessibility();
}
@@ -206,10 +182,6 @@ export class ObcStepperBox extends LitElement {
);
}
- private onUnitSlotChange = () => {
- this.requestUpdate();
- };
-
private normalizedStep(step: number): number {
if (!Number.isFinite(step) || step <= 0) {
return 1;
@@ -238,11 +210,6 @@ export class ObcStepperBox extends LitElement {
}
override render() {
- const fieldWrapperClasses = {
- 'field-wrapper': true,
- 'has-unit-slot': this.hasUnitSlotContent,
- };
-
const showHelper = Boolean(this.helperText);
return html`
@@ -250,35 +217,27 @@ export class ObcStepperBox extends LitElement {
this.down()}
>
${this.leftIcon}
-
+
- ${this.hasUnitSlotContent
- ? html`
-
-
`
- : html`
`}
this.up()}
>
@@ -295,7 +254,6 @@ export class ObcStepperBox extends LitElement {
private onNumberFieldInput(e: Event) {
const input = e.target as HTMLInputElement;
const raw = input.value;
- this.lastRawInput = raw;
this.dispatchEvent(
new CustomEvent('input', {
detail: {value: raw},
@@ -320,33 +278,6 @@ export class ObcStepperBox extends LitElement {
}
}
- private onNumberFieldBlur(e: FocusEvent) {
- const next = e.relatedTarget;
- if (next instanceof Node && this.contains(next)) {
- return;
- }
-
- const raw = this.lastRawInput.trim();
- const parsed = Number(this.lastRawInput);
- const isValidInput = raw !== '' && Number.isFinite(parsed);
- const nextValue = isValidInput
- ? this.clamp(parsed)
- : this.value == null
- ? null
- : this.clamp(this.value);
-
- if (nextValue !== this.value) {
- this.value = nextValue;
- this.dispatchChange(this.value);
- }
-
- const normalizedDisplay = this.value == null ? '' : String(this.value);
- if (this.lastRawInput !== normalizedDisplay) {
- this.lastRawInput = normalizedDisplay;
- this.requestUpdate();
- }
- }
-
private dispatchChange(value: number | null) {
this.dispatchEvent(
new CustomEvent('change', {
diff --git a/packages/openbridge-webcomponents/vitest.browser.config.ts b/packages/openbridge-webcomponents/vitest.browser.config.ts
index d3e6e5438..f153d56bd 100644
--- a/packages/openbridge-webcomponents/vitest.browser.config.ts
+++ b/packages/openbridge-webcomponents/vitest.browser.config.ts
@@ -4,7 +4,7 @@ import {playwright} from '@vitest/browser-playwright';
export default defineConfig({
test: {
// ignore files in dist folder
- exclude: ['dist/**'],
+ exclude: ['dist/**', 'node_modules/**'],
browser: {
enabled: true,
provider: playwright(),