diff --git a/.changeset/sd-input-formatted-number.md b/.changeset/sd-input-formatted-number.md new file mode 100644 index 0000000000..6845779f4c --- /dev/null +++ b/.changeset/sd-input-formatted-number.md @@ -0,0 +1,15 @@ +--- +'@solid-design-system/components': minor +'@solid-design-system/styles': minor +'@solid-design-system/tokens': minor +'@solid-design-system/docs': patch +--- + +Added `type="formatted-number"` variant to `sd-input` for locale-aware numeric formatting: + +- Formats the displayed value using `Intl.NumberFormat` on blur; restores the raw value for editing on focus +- Raw numeric string is always stored in `value`; formatted output is shown to the user +- Added `numberFormatOptions` property (`number-format-options` attribute) accepting an `Intl.NumberFormatOptions` object for custom formatting (currency, decimal precision, percentages, etc.) +- `valueAsNumber` getter and setter work correctly for `type="formatted-number"` +- `stepUp()` and `stepDown()` increment/decrement the raw numeric value and re-format on call +- Spin buttons (`spin-buttons`) are supported with `type="formatted-number"` diff --git a/packages/components/src/components/input/input.test.ts b/packages/components/src/components/input/input.test.ts index e1c48b1371..6587290373 100644 --- a/packages/components/src/components/input/input.test.ts +++ b/packages/components/src/components/input/input.test.ts @@ -669,6 +669,578 @@ describe('', () => { }); }); + describe('when type="formatted-number"', () => { + describe('rendering', () => { + it('should render the internal input as type="text"', async () => { + const el = await fixture(html` `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.type).to.equal('text'); + }); + + it('should set inputmode to "decimal" by default', async () => { + const el = await fixture(html` `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.inputMode).to.equal('decimal'); + }); + + it('should allow overriding inputmode', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.inputMode).to.equal('numeric'); + }); + + it('should pass accessibility tests', async () => { + const el = await fixture(html` + + `); + await expect(el).to.be.accessible(); + }); + }); + + describe('formatting and display', () => { + it('should format the value using the resolved locale on initial render', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.value).to.equal('1.234,56'); + }); + + it('should format using English locale', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.value).to.equal('1,234.56'); + }); + + it('should apply numberFormatOptions', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.value).to.equal('1,234.00'); + }); + + it('should show raw value on focus and formatted value on blur', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + await el.updateComplete; + + el.focus(); + await el.updateComplete; + expect(input.value).to.equal('1000'); + + el.blur(); + await el.updateComplete; + expect(input.value).to.equal('1,000'); + }); + + it('should show locale-decimal raw value on focus for German locale (not JS dot-decimal)', async () => { + // A German user formatting 1234.56 sees "1.234,56" at rest. + // On focus they should see "1234,56" — locale decimal separator, no grouping. + // They should NOT see the JS-internal "1234.56". + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + await el.updateComplete; + expect(input.value).to.equal('1.234,56'); + + el.focus(); + await el.updateComplete; + expect(input.value).to.equal('1234,56'); + + el.blur(); + await el.updateComplete; + expect(input.value).to.equal('1.234,56'); + }); + + it('should use localize.lang() (document locale) when no lang attribute is set on the element', async () => { + // When lang is set on the document element, the component should format/parse using it. + const originalLang = document.documentElement.lang; + document.documentElement.lang = 'de'; + try { + const el = await fixture(html` + + `); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + // Should format as German even without lang on the element itself + expect(input.value).to.equal('1.234,56'); + + el.focus(); + await el.updateComplete; + // On focus should show locale-decimal form, not JS dot-decimal + expect(input.value).to.equal('1234,56'); + } finally { + document.documentElement.lang = originalLang; + } + }); + + it('should update display when lang changes', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.value).to.equal('1,234.56'); + + el.lang = 'de'; + await el.updateComplete; + expect(input.value).to.equal('1.234,56'); + }); + + it('should update display when numberFormatOptions changes', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.value).to.equal('1,234.5'); + + el.numberFormatOptions = { minimumFractionDigits: 2, maximumFractionDigits: 2 }; + await el.updateComplete; + expect(input.value).to.equal('1,234.50'); + }); + + it('should display empty string when value is empty', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.value).to.equal(''); + }); + }); + + describe('raw value', () => { + it('should keep the raw numeric string as the value property', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.value).to.equal('1.234,56'); + expect(el.value).to.equal('1234.56'); + }); + + it('should parse "3.000,00" correctly even in an English input', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + // User typed German-style into English field — both separators present, comma is last → decimal + input.value = '3.000,00'; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + expect(el.value).to.equal('3000'); + }); + + it('should parse a locale-formatted value typed by the user on blur', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + input.value = '1.000,30'; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + expect(el.value).to.equal('1000.3'); + }); + + it('should parse an English-formatted value typed by the user on blur', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + input.value = '1,000.30'; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + expect(el.value).to.equal('1000.3'); + }); + + it('should treat "1,000" as 1.000 (decimal) when lang="de"', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + input.value = '1,000'; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + // In German locale: comma = decimal → 1,000 = 1.000 = 1 + expect(el.value).to.equal('1'); + }); + + it('should treat "1,000" as 1000 (thousands) when lang="en"', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + input.value = '1,000'; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + expect(el.value).to.equal('1000'); + }); + + it('should treat a lone comma with fewer than 3 trailing digits as decimal (lang="de")', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + input.value = '323323,23'; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + expect(el.value).to.equal('323323.23'); + }); + + // With lang="de": comma=decimal, dot=thousands — locale always wins + for (const [typed, expected] of [ + ['3,0', '3'], // decimal comma → 3.0 + ['3,00', '3'], // decimal comma → 3.00 + ['3,000', '3'], // decimal comma → 3.000 (German: 3,000 = 3 with 3 decimal zeros) + ['3,0000', '3'], // decimal comma → 3.0000 + ['3,00000', '3'] // decimal comma → 3.00000 + ] as const) { + it(`should parse "${typed}" as ${expected} (lang="de", comma=decimal)`, async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + input.value = typed; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + expect(el.value).to.equal(expected); + }); + } + + // With lang="en": comma=thousands, dot=decimal — locale always wins + for (const [typed, expected] of [ + ['3,0', '30'], // comma=thousands (strip) → 30 + ['3,00', '300'], // comma=thousands (strip) → 300 + ['3,000', '3000'], // comma=thousands (strip) → 3000 + ['3,0000', '30000'], // comma=thousands (strip) → 30000 + ['3.0', '3'], // dot=decimal → 3.0 + ['3.000', '3'], // dot=decimal, single → 3.000 + ['3.00000', '3'] // dot=decimal → 3.00000 + ] as const) { + it(`should parse "${typed}" as ${expected} (lang="en")`, async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + input.value = typed; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + expect(el.value).to.equal(expected); + }); + } + + it('should treat a lone comma followed by exactly 3 digits as thousands separator (no lang, heuristic)', async () => { + const el = await fixture(html` `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + input.value = '1,000'; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + // Heuristic: 3 trailing digits after lone comma → thousands + expect(el.value).to.equal('1000'); + }); + + it('should handle negative numbers correctly', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + input.value = '-1,234.56'; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + expect(el.value).to.equal('-1234.56'); + }); + + it('should strip currency symbols and parse correctly on blur', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + // Simulate user clicking in and blurring without editing (round-trip) + el.focus(); + await el.updateComplete; + el.blur(); + await el.updateComplete; + expect(el.value).to.equal('9999.99'); + }); + + it('should store empty string when input contains no parseable number', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + el.focus(); + await el.updateComplete; + input.value = 'abc'; + input.dispatchEvent(new Event('input')); + el.blur(); + await el.updateComplete; + expect(el.value).to.equal(''); + }); + }); + + describe('valueAsNumber', () => { + it('should return the numeric value', async () => { + const el = await fixture(html` + + `); + expect(el.valueAsNumber).to.equal(42.5); + }); + + it('should return NaN for empty value', async () => { + const el = await fixture(html` `); + expect(isNaN(el.valueAsNumber)).to.be.true; + }); + + it('should set value and update display when setting valueAsNumber', async () => { + const el = await fixture(html` + + `); + el.valueAsNumber = 9876.54; + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(el.value).to.equal('9876.54'); + expect(input.value).to.equal('9,876.54'); + }); + }); + + describe('stepping', () => { + it('should increment value with stepUp()', async () => { + const el = await fixture(html` + + `); + el.stepUp(); + await el.updateComplete; + expect(el.value).to.equal('15'); + }); + + it('should decrement value with stepDown()', async () => { + const el = await fixture(html` + + `); + el.stepDown(); + await el.updateComplete; + expect(el.value).to.equal('5'); + }); + + it('should default to step=1 when step is not set', async () => { + const el = await fixture(html` + + `); + el.stepUp(); + await el.updateComplete; + expect(el.value).to.equal('11'); + }); + + it('should not exceed max when stepping up', async () => { + const el = await fixture(html` + + `); + el.stepUp(); + await el.updateComplete; + expect(el.value).to.equal('10'); + }); + + it('should not go below min when stepping down', async () => { + const el = await fixture(html` + + `); + el.stepDown(); + await el.updateComplete; + expect(el.value).to.equal('0'); + }); + + it('should start from 0 when stepping up from empty value', async () => { + const el = await fixture(html` + + `); + el.stepUp(); + await el.updateComplete; + expect(el.value).to.equal('5'); + }); + + it('should update formatted display after stepping', async () => { + const el = await fixture(html` + + `); + el.stepUp(); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(el.value).to.equal('1000'); + expect(input.value).to.equal('1,000'); + }); + + it('should increment on ArrowUp keydown', async () => { + const el = await fixture(html` + + `); + el.focus(); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await el.updateComplete; + expect(el.value).to.equal('11'); + }); + + it('should decrement on ArrowDown keydown', async () => { + const el = await fixture(html` + + `); + el.focus(); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await el.updateComplete; + expect(el.value).to.equal('9'); + }); + + it('should not step beyond max on ArrowUp', async () => { + const el = await fixture(html` + + `); + el.focus(); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await el.updateComplete; + expect(el.value).to.equal('10'); + }); + + it('should not step below min on ArrowDown', async () => { + const el = await fixture(html` + + `); + el.focus(); + await el.updateComplete; + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await el.updateComplete; + expect(el.value).to.equal('0'); + }); + }); + + describe('spin buttons', () => { + it('should show spin buttons when spin-buttons attribute is set', async () => { + const el = await fixture(html` + + `); + const decrementButton = el.shadowRoot!.querySelector('button[part^="decrement-number-stepper"]')!; + const incrementButton = el.shadowRoot!.querySelector('button[part^="increment-number-stepper"]')!; + expect(decrementButton).to.exist; + expect(incrementButton).to.exist; + }); + + it('should add role="spinbutton" and aria attributes when spin-buttons is set', async () => { + const el = await fixture(html` + + `); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.getAttribute('role')).to.equal('spinbutton'); + expect(input.getAttribute('aria-valuenow')).to.equal('50'); + expect(input.getAttribute('aria-valuemin')).to.equal('0'); + expect(input.getAttribute('aria-valuemax')).to.equal('100'); + }); + + it('should accumulate value correctly on rapid repeated steps (long-press regression)', async () => { + // Regression: handleStep() was calling handleInput() which read the stale (not-yet-rendered) + // input.value and overwrote _displayValue, making rapid spin appear to have no effect. + const el = await fixture(html` + + `); + await el.updateComplete; + + const clock = sinon.useFakeTimers(); + try { + // Simulate what longPress interval does: call handleStepUp repeatedly without awaiting + // (mirrors the setInterval at 50ms cadence inside the longPress directive) + for (let i = 0; i < 30; i++) { + el['handleStepUp'](); + } + clock.tick(0); // flush microtasks + } finally { + clock.restore(); + } + + await el.updateComplete; + + expect(Number(el.value)).to.equal(3000); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.value).to.equal('3,000'); + }); + }); + + describe('clear button', () => { + it('should clear value and display when clear button is clicked', async () => { + const el = await fixture(html` + + `); + await el.updateComplete; + const clearButton = el.shadowRoot!.querySelector('[part~="clear-button"]')!; + expect(clearButton).to.exist; + clearButton.click(); + await el.updateComplete; + expect(el.value).to.equal(''); + const input = el.shadowRoot!.querySelector('[part~="input"]')!; + expect(input.value).to.equal(''); + }); + }); + }); + describe('when using the getFormControls() function', () => { it('should return both native and Solid form controls in the correct DOM order', async () => { const el = await fixture(html` diff --git a/packages/components/src/components/input/input.ts b/packages/components/src/components/input/input.ts index 99a83175ac..add494bc9f 100644 --- a/packages/components/src/components/input/input.ts +++ b/packages/components/src/components/input/input.ts @@ -93,6 +93,9 @@ export default class SdInput extends SolidElement implements SolidFormControl { /** @internal */ @state() hasFocus = false; + + /** @internal */ + @state() private _displayValue = ''; /** * Indicates whether or not the user input is valid after the user has interacted with the component. These states are activated when the attribute "data-user-valid" or "data-user-invalid" are set on the component via the form controller. They are different than the native input validity state which is always either `true` or `false`. * @internal @@ -107,12 +110,13 @@ export default class SdInput extends SolidElement implements SolidFormControl { /** * The type of input. Works the same as a native `` element, but only a subset of types are supported. Defaults - * to `text`. + * to `text`. Use `formatted-number` to auto-format numeric values using `Intl.NumberFormat`. */ @property({ type: String, reflect: true }) type: | 'date' | 'datetime-local' | 'email' + | 'formatted-number' | 'number' | 'password' | 'search' @@ -121,6 +125,18 @@ export default class SdInput extends SolidElement implements SolidFormControl { | 'time' | 'url' = 'text'; + /** + * Options passed to `Intl.NumberFormat` when `type="formatted-number"`. Accepts an `Intl.NumberFormatOptions` object + * or a JSON string representation of one. + * + * Not all `Intl.NumberFormatOptions` are supported — options that apply a numeric transform (e.g. `style: "percent"`, + * `notation: "compact"/"scientific"/"engineering"`, `currencySign: "accounting"`) break the raw↔display round-trip + * and must not be used. Values are also limited to ~15–16 significant digits (IEEE 754 double precision). + * + * See the formatted-number story in Storybook for supported options, examples, and known limitations. + */ + @property({ attribute: 'number-format-options', type: Object }) numberFormatOptions: Intl.NumberFormatOptions = {}; + /** The input's size. */ @property({ type: String, reflect: true }) size: 'lg' | 'md' | 'sm' = 'lg'; @@ -275,10 +291,18 @@ export default class SdInput extends SolidElement implements SolidFormControl { /** Gets or sets the current value as a number. Returns `NaN` if the value can't be converted. */ get valueAsNumber() { + if (this.type === 'formatted-number') { + return parseFloat(this.value); + } return this.input?.valueAsNumber ?? parseFloat(this.value); } set valueAsNumber(newValue: number) { + if (this.type === 'formatted-number') { + this.value = isNaN(newValue) ? '' : String(newValue); + this._displayValue = this._formatNumber(this.value); + return; + } // We use an in-memory input instead of the one in the template because the property can be set before render const input = document.createElement('input'); input.type = 'number'; @@ -292,21 +316,132 @@ export default class SdInput extends SolidElement implements SolidFormControl { } firstUpdated() { + if (this.type === 'formatted-number') { + this._displayValue = this._formatNumber(this.value); + } this.formControlController.updateValidity(); } + private _formatNumber(rawValue: string): string { + const num = parseFloat(rawValue); + if (isNaN(num)) return rawValue; + try { + return new Intl.NumberFormat(this.localize.lang(), this.numberFormatOptions).format(num); + } catch { + return rawValue; + } + } + + /** Returns the locale decimal and group separators using localize.lang(). */ + private _getFormatSeparators(): { decimal: string; group: string } { + try { + const parts = new Intl.NumberFormat(this.localize.lang(), this.numberFormatOptions).formatToParts(1234567.89); + const decimal = parts.find(p => p.type === 'decimal')?.value ?? '.'; + const group = parts.find(p => p.type === 'group')?.value ?? ''; + return { decimal, group }; + } catch { + return { decimal: '.', group: '' }; + } + } + + /** + * Returns the raw value formatted with locale decimal separator but NO grouping — + * the edit-friendly representation shown on focus. + */ + private _formatNumberForEditing(rawValue: string): string { + const num = parseFloat(rawValue); + if (isNaN(num) || rawValue === '') return rawValue; + const { decimal } = this._getFormatSeparators(); + // Format without grouping so the user edits a clean number + const formatted = new Intl.NumberFormat(this.localize.lang(), { + ...this.numberFormatOptions, + useGrouping: false, + style: 'decimal' + }).format(num); + return formatted; + } + + private _parseLocaleNumber(displayValue: string): string { + if (!displayValue) return ''; + + // Strip non-numeric except digits, . , and leading - + const stripped = displayValue.replace(/[^0-9.,\-]/g, '').replace(/(?!^)-/g, ''); + if (!stripped) return ''; + + const dotCount = (stripped.match(/\./g) ?? []).length; + const commaCount = (stripped.match(/,/g) ?? []).length; + + let normalized: string; + + if (dotCount === 0 && commaCount === 0) { + // Plain integer — no separators + normalized = stripped; + } else if (dotCount > 0 && commaCount > 0) { + // Both separators present: positional rule — the one appearing last is the decimal. + // This is unambiguous regardless of locale, and handles cross-locale typing gracefully. + const lastDot = stripped.lastIndexOf('.'); + const lastComma = stripped.lastIndexOf(','); + normalized = + lastComma > lastDot + ? stripped.replace(/\./g, '').replace(',', '.') // comma = decimal (e.g. 3.000,00) + : stripped.replace(/,/g, ''); // dot = decimal (e.g. 3,000.00) + } else { + // Only one separator type — use locale to disambiguate + const { decimal: localeDecimal } = this._getFormatSeparators(); + + if (dotCount > 0) { + if (localeDecimal === ',') { + normalized = stripped.replace(/\./g, ''); + } else if (localeDecimal === '.') { + normalized = dotCount > 1 ? stripped.replace(/\./g, '') : stripped; + } else { + const afterLastDot = stripped.slice(stripped.lastIndexOf('.') + 1); + normalized = dotCount > 1 || afterLastDot.length === 3 ? stripped.replace(/\./g, '') : stripped; + } + } else { + if (localeDecimal === '.') { + normalized = stripped.replace(/,/g, ''); + } else if (localeDecimal === ',') { + normalized = commaCount > 1 ? stripped.replace(/,/g, '') : stripped.replace(',', '.'); + } else { + const afterLastComma = stripped.slice(stripped.lastIndexOf(',') + 1); + normalized = + commaCount > 1 || afterLastComma.length === 3 ? stripped.replace(/,/g, '') : stripped.replace(',', '.'); + } + } + } + + const num = parseFloat(normalized); + return isNaN(num) ? '' : String(num); + } + private handleBlur() { this.hasFocus = false; + if (this.type === 'formatted-number') { + // Parse user-typed display value back to raw — always, since displayValue is now + // locale-formatted (e.g. "1234,56") not JS-raw (e.g. "1234.56") + const parsed = this._parseLocaleNumber(this._displayValue); + if (parsed !== '' || this._displayValue === '') { + this.value = parsed; + } + } this.emit('sd-blur'); } private handleChange() { - this.value = this.input.value; + if (this.type === 'formatted-number') { + this.value = this._parseLocaleNumber(this.input.value); + } else { + this.value = this.input.value; + } this.emit('sd-change'); } private handleClearClick(event: MouseEvent) { this.value = ''; + if (this.type === 'formatted-number') { + this._displayValue = ''; + } this.emit('sd-clear'); this.emit('sd-input'); this.emit('sd-change'); @@ -322,16 +457,24 @@ export default class SdInput extends SolidElement implements SolidFormControl { private handleFocus() { this.hasFocus = true; + if (this.type === 'formatted-number') { + // Show the value with locale decimal separator but no grouping — easier to edit + this._displayValue = this._formatNumberForEditing(this.value); + } this.emit('sd-focus'); } private handleInput() { if (this.visuallyDisabled) { - this.input.value = this.value; + this.input.value = this.type === 'formatted-number' ? this._displayValue : this.value; return; } - this.value = this.input.value; + if (this.type === 'formatted-number') { + this._displayValue = this.input.value; + } else { + this.value = this.input.value; + } this.formControlController.updateValidity(); this.emit('sd-input'); } @@ -345,6 +488,19 @@ export default class SdInput extends SolidElement implements SolidFormControl { private handleKeyDown(event: KeyboardEvent) { const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + if (this.type === 'formatted-number' && !hasModifier) { + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.stepUp(); + return; + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.stepDown(); + return; + } + } + // Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before // submitting to allow users to cancel the keydown event if they need to if (event.key === 'Enter' && !hasModifier) { @@ -401,7 +557,15 @@ export default class SdInput extends SolidElement implements SolidFormControl { } private handleStep() { - this.handleInput(); + if (this.type === 'formatted-number') { + // stepUp/stepDown already set this.value and this._displayValue directly. + // Calling handleInput() here would read the stale (not-yet-re-rendered) input.value + // and overwrite _displayValue back to the old formatted string, breaking fast-spin. + this.formControlController.updateValidity(); + this.emit('sd-input'); + } else { + this.handleInput(); + } this.input.focus(); } @@ -432,6 +596,9 @@ export default class SdInput extends SolidElement implements SolidFormControl { @watch('value', { waitUntilFirstUpdate: true }) async handleValueChange() { await this.updateComplete; + if (this.type === 'formatted-number' && !this.hasFocus) { + this._displayValue = this._formatNumber(this.value); + } this.formControlController.updateValidity(); } @@ -489,6 +656,18 @@ export default class SdInput extends SolidElement implements SolidFormControl { /** Increments the value of a numeric input type by the value of the step attribute. */ stepUp() { + if (this.type === 'formatted-number') { + const step = this.step === undefined || this.step === null || this.step === 'any' ? 1 : Number(this.step); + const current = parseFloat(this.value) || 0; + let newValue = current + step; + if (this.max !== undefined && this.max !== null) { + const max = typeof this.max === 'string' ? parseFloat(this.max) : this.max; + newValue = Math.min(newValue, max); + } + this.value = String(newValue); + this._displayValue = this._formatNumber(this.value); + return; + } this.input.stepUp(); if (this.value !== this.input.value) { this.value = this.input.value; @@ -497,6 +676,18 @@ export default class SdInput extends SolidElement implements SolidFormControl { /** Decrements the value of a numeric input type by the value of the step attribute. */ stepDown() { + if (this.type === 'formatted-number') { + const step = this.step === undefined || this.step === null || this.step === 'any' ? 1 : Number(this.step); + const current = parseFloat(this.value) || 0; + let newValue = current - step; + if (this.min !== undefined && this.min !== null) { + const min = typeof this.min === 'string' ? parseFloat(this.min) : this.min; + newValue = Math.max(newValue, min); + } + this.value = String(newValue); + this._displayValue = this._formatNumber(this.value); + return; + } this.input.stepDown(); if (this.value !== this.input.value) { this.value = this.input.value; @@ -664,7 +855,11 @@ export default class SdInput extends SolidElement implements SolidFormControl { textSize, isFloatingLabelActive && 'leading-none mt-4' )} - type=${this.type === 'password' && this.passwordVisible ? 'text' : this.type} + type=${this.type === 'password' && this.passwordVisible + ? 'text' + : this.type === 'formatted-number' + ? 'text' + : this.type} title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */} name=${ifDefined(this.name)} ?disabled=${this.disabled} @@ -676,7 +871,13 @@ export default class SdInput extends SolidElement implements SolidFormControl { min=${ifDefined(this.min)} max=${ifDefined(this.max)} step=${ifDefined(this.step as number)} - .value=${live(this.value)} + .value=${live( + this.type === 'formatted-number' + ? this.hasFocus + ? this._displayValue + : this._formatNumber(this.value) + : this.value + )} autocapitalize=${ifDefined(this.type === 'password' ? 'off' : this.autocapitalize)} autocomplete=${ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -687,7 +888,19 @@ export default class SdInput extends SolidElement implements SolidFormControl { spellcheck=${this.spellcheck} pattern=${ifDefined(this.pattern)} enterkeyhint=${ifDefined(this.enterkeyhint)} - inputmode=${ifDefined(this.inputmode)} + inputmode=${ifDefined(this.type === 'formatted-number' ? (this.inputmode ?? 'decimal') : this.inputmode)} + role=${ifDefined(this.type === 'formatted-number' && this.spinButtons ? 'spinbutton' : undefined)} + aria-valuenow=${ifDefined( + this.type === 'formatted-number' && this.spinButtons && this.value !== '' + ? parseFloat(this.value) + : undefined + )} + aria-valuemin=${ifDefined( + this.type === 'formatted-number' && this.spinButtons && this.min !== undefined ? this.min : undefined + )} + aria-valuemax=${ifDefined( + this.type === 'formatted-number' && this.spinButtons && this.max !== undefined ? this.max : undefined + )} aria-describedby="help-text invalid-message" aria-disabled=${this.visuallyDisabled || this.disabled ? 'true' : 'false'} aria-invalid=${this.showInvalidStyle} @@ -849,7 +1062,7 @@ export default class SdInput extends SolidElement implements SolidFormControl { class=${cx('inline-flex', iconColor, iconMarginLeft, iconSize)} >` : ''} - ${this.type === 'number' && this.spinButtons + ${(this.type === 'number' || this.type === 'formatted-number') && this.spinButtons ? html`