diff --git a/examples/index.js b/examples/index.js index ad0f656..c408b1f 100644 --- a/examples/index.js +++ b/examples/index.js @@ -69,7 +69,7 @@ const ExampleForm = ({
Disable selection handling
- {/* Value
*/} + Value
, // Track previous props to detect changes }; type SelectionSnapshot = { @@ -124,22 +125,23 @@ class CurrencyInput extends React.Component): CurrencyInputState { + static prepareProps(props: Readonly): CurrencyInputState { + const { + onChangeEvent, + value: propValue, + decimalSeparator, + thousandSeparator, + precision, + inputType, + allowNegative, + allowEmpty, + prefix, + suffix, + selectAllOnFocus, + autoFocus, + disableSelectionHandling: propDisableSelectionHandling, + ...customProps + } = props; let initialValue = propValue; if (initialValue === null) { initialValue = allowEmpty ? null : ''; @@ -166,7 +168,7 @@ class CurrencyInput extends React.Component, prevState: Readonly) { - const props = { ...nextProps }; - if (nextProps.value !== prevState.value) { - props.value = prevState.value; + const previousProps = prevState.previousProps || nextProps; // First call uses the initial props snapshot + + // Check if the VALUE prop itself changed (parent is controlling the input) + const valueChanged = nextProps.value !== previousProps.value; + + // Check if allowNegative changed (affects whether negative values are allowed) + const allowNegativeChanged = nextProps.allowNegative !== previousProps.allowNegative; + + // Check if separators or display formatting changed (these require reformatting the current value) + const formatChanged = + nextProps.decimalSeparator !== previousProps.decimalSeparator || + nextProps.thousandSeparator !== previousProps.thousandSeparator || + nextProps.precision !== previousProps.precision || + nextProps.prefix !== previousProps.prefix || + nextProps.suffix !== previousProps.suffix || + allowNegativeChanged; + + if (valueChanged) { + // Parent changed the value prop - use the new value + const newState = CurrencyInput.prepareProps(nextProps); + return { ...newState, previousProps: nextProps }; } - return CurrencyInput.prepareProps(props); + + if (formatChanged) { + // Display formatting or allowNegative changed - reformat the current value + // First, determine the value to use. Start with the current state value, + // which may be adjusted below if allowNegative was disabled and value is negative. + let valueToFormat = prevState.value; + + if (allowNegativeChanged && !nextProps.allowNegative) { + // allowNegative was disabled - if current value is negative, make it positive + const parsedValue = typeof prevState.value === 'number' + ? prevState.value + : prevState.value === null + ? 0 + : CurrencyInput.stringValueToFloat(String(prevState.value), nextProps.thousandSeparator, nextProps.decimalSeparator); + + if (parsedValue < 0) { + valueToFormat = Math.abs(parsedValue); + } + } + + // Reformat with new formatting and potentially adjusted value + const propsWithCurrentValue = { ...nextProps, value: valueToFormat }; + const newState = CurrencyInput.prepareProps(propsWithCurrentValue); + return { ...newState, previousProps: nextProps }; + } + + // Other props changed but value and display formatting didn't + // Just update the previousProps reference and preserve current state + return { ...prevState, previousProps: nextProps }; } /** diff --git a/tests/base.spec.ts b/tests/base.spec.ts index 875c8d2..3e4cad7 100644 --- a/tests/base.spec.ts +++ b/tests/base.spec.ts @@ -11,7 +11,7 @@ test.describe('base tests', () => { }); test('sanity startup', async ({ page }) => { - const currencyInput = await page.locator('#currency-input'); + const currencyInput = page.locator('#currency-input'); await expect(currencyInput).toHaveValue('$0.00 USD'); }); @@ -21,4 +21,346 @@ test.describe('base tests', () => { }); }); -// TODO: add tests for each of the possible parameters +test.describe('component parameters', () => { + test.beforeEach(async ({ page }) => { + await page.goto(fileUrl); + }); + + test.describe('prefix and suffix', () => { + test('renders with default prefix $ and suffix USD', async ({ page }) => { + const currencyInput = await page.locator('#currency-input'); + await expect(currencyInput).toHaveValue('$0.00 USD'); + }); + + test('renders with custom prefix', async ({ page }) => { + const prefixInput = page.locator('[name=prefix]'); + const suffixInput = page.locator('[name=suffix]'); + const applyBtn = page.locator('[name=apply]'); + + await prefixInput.fill('£'); + await suffixInput.fill(''); + await applyBtn.click(); + + const currencyInput = page.locator('#currency-input'); + await expect(currencyInput).toHaveValue('£0.00'); + }); + + test('renders with custom suffix', async ({ page }) => { + const prefixInput = page.locator('[name=prefix]'); + const suffixInput = page.locator('[name=suffix]'); + const applyBtn = page.locator('[name=apply]'); + + await prefixInput.fill(''); + await suffixInput.fill(' EUR'); + await applyBtn.click(); + + const currencyInput = page.locator('#currency-input'); + await expect(currencyInput).toHaveValue('0.00 EUR'); + }); + + test('renders with both custom prefix and suffix', async ({ page }) => { + const prefixInput = page.locator('[name=prefix]'); + const suffixInput = page.locator('[name=suffix]'); + const applyBtn = page.locator('[name=apply]'); + + await prefixInput.fill('¥'); + await suffixInput.fill(' JPY'); + await applyBtn.click(); + + const currencyInput = page.locator('#currency-input'); + await expect(currencyInput).toHaveValue('¥0.00 JPY'); + }); + }); + + test.describe('separators and precision', () => { + test('default separators are comma thousand and period decimal', async ({ page }) => { + const currencyInput = page.locator('#currency-input'); + const decimalInput = page.locator('[name=decimalSeparator]'); + const thousandInput = page.locator('[name=thousandSeparator]'); + + // Verify defaults + const decimalValue = await decimalInput.inputValue(); + const thousandValue = await thousandInput.inputValue(); + expect(decimalValue).toBe('.'); + expect(thousandValue).toBe(','); + }); + + test('custom decimal separator', async ({ page }) => { + const decimalInput = page.locator('[name=decimalSeparator]'); + const prefixInput = page.locator('[name=prefix]'); + const suffixInput = page.locator('[name=suffix]'); + const applyBtn = page.locator('[name=apply]'); + + await prefixInput.fill(''); + await suffixInput.fill(''); + await decimalInput.fill(','); + await applyBtn.click(); + + const currencyInput = page.locator('#currency-input'); + await expect(currencyInput).toHaveValue('0,00'); + await currencyInput.focus(); + await currencyInput.fill(''); + await currencyInput.type('12345'); + + await expect(currencyInput).toHaveValue('123,45'); + }); + + test('custom thousand separator', async ({ page }) => { + const thousandInput = page.locator('[name=thousandSeparator]'); + const precisionInput = page.locator('[name=precision]'); + const prefixInput = page.locator('[name=prefix]'); + const suffixInput = page.locator('[name=suffix]'); + const decimalInput = page.locator('[name=decimalSeparator]'); + const applyBtn = page.locator('[name=apply]'); + + await prefixInput.fill(''); + await suffixInput.fill(''); + await decimalInput.fill(','); + await thousandInput.fill('.'); + await precisionInput.fill('2'); + await applyBtn.click(); + + const currencyInput = page.locator('#currency-input'); + await expect(currencyInput).toHaveValue('0,00'); + await currencyInput.focus(); + await currencyInput.fill(''); + await currencyInput.type('1234567'); + + // With custom separators, should format as 1.234.567,00 + const value = await currencyInput.inputValue(); + expect(value).toContain('.'); + expect(value).toContain(','); + }); + + test('precision 0', async ({ page }) => { + const precisionInput = page.locator('[name=precision]'); + const prefixInput = page.locator('[name=prefix]'); + const suffixInput = page.locator('[name=suffix]'); + const decimalInput = page.locator('[name=decimalSeparator]'); + const applyBtn = page.locator('[name=apply]'); + + await prefixInput.fill(''); + await suffixInput.fill(''); + await decimalInput.fill('.'); + await precisionInput.fill('0'); + await applyBtn.click(); + + const currencyInput = page.locator('#currency-input'); + await expect(currencyInput).toHaveValue('0'); + await currencyInput.focus(); + await currencyInput.fill(''); + await currencyInput.type('12345'); + + await expect(currencyInput).toHaveValue('12,345'); + }); + + test('precision 3', async ({ page }) => { + const precisionInput = page.locator('[name=precision]'); + const prefixInput = page.locator('[name=prefix]'); + const suffixInput = page.locator('[name=suffix]'); + const decimalInput = page.locator('[name=decimalSeparator]'); + const applyBtn = page.locator('[name=apply]'); + + await prefixInput.fill(''); + await suffixInput.fill(''); + await decimalInput.fill('.'); + await precisionInput.fill('3'); + await applyBtn.click(); + + const currencyInput = page.locator('#currency-input'); + await expect(currencyInput).toHaveValue('0.000'); + await currencyInput.focus(); + await currencyInput.fill(''); + await currencyInput.type('12345'); + + await expect(currencyInput).toHaveValue('12.345'); + }); + }); + + test.describe('allowNegative', () => { + test('rejects negative input when allowNegative is false', async ({ page }) => { + // allowNegative is false by default, so minus signs should be rejected + const currencyInput = page.locator('#currency-input'); + await currencyInput.focus(); + await currencyInput.selectText(); + await currencyInput.pressSequentially('-100'); + + // Should not contain minus sign since allowNegative is false by default + const value = await currencyInput.inputValue(); + expect(value).not.toContain('-'); + }); + + test('accepts negative numbers when allowNegative is true', async ({ page }) => { + // Enable allowNegative via form control + const allowNegativeCheckbox = page.locator('[name=allowNegative]'); + const applyBtn = page.locator('[name=apply]'); + const currencyInput = page.locator('#currency-input'); + + await allowNegativeCheckbox.check(); + await applyBtn.click(); + + await expect(currencyInput).toHaveValue('$0.00 USD'); + await currencyInput.focus(); + await currencyInput.selectText(); + + // First input a number (can't have negative zero) + await currencyInput.pressSequentially('50'); + + let value = await currencyInput.inputValue(); + expect(value).toContain('0.50'); + + // Now add the minus sign - should toggle the number to negative + await currencyInput.press('Minus'); + + // Should now contain minus sign + value = await currencyInput.inputValue(); + expect(value).toContain('-'); + }); + + test('removes negative sign when allowNegative is disabled', async ({ page }) => { + // First enable allowNegative + const allowNegativeCheckbox = page.locator('[name=allowNegative]'); + const applyBtn = page.locator('[name=apply]'); + const currencyInput = page.locator('#currency-input'); + + await allowNegativeCheckbox.check(); + await applyBtn.click(); + await expect(currencyInput).toHaveValue('$0.00 USD'); + await currencyInput.focus(); + await currencyInput.selectText(); + + // Input a negative number + await currencyInput.pressSequentially('50'); + await currencyInput.press('Minus'); + + let value = await currencyInput.inputValue(); + expect(value).toContain('-'); + + // Now disable allowNegative + await allowNegativeCheckbox.uncheck(); + await applyBtn.click(); + + // Value should now be positive + value = await currencyInput.inputValue(); + expect(value).not.toContain('-'); + }); + + test('removes negative sign when allowNegative is disabled and format changes simultaneously', async ({ page }) => { + // First enable allowNegative + const allowNegativeCheckbox = page.locator('[name=allowNegative]'); + const prefixInput = page.locator('[name=prefix]'); + const applyBtn = page.locator('[name=apply]'); + const currencyInput = page.locator('#currency-input'); + + await allowNegativeCheckbox.check(); + await applyBtn.click(); + await expect(currencyInput).toHaveValue('$0.00 USD'); + await currencyInput.focus(); + await currencyInput.selectText(); + + // Input a negative number + await currencyInput.pressSequentially('50'); + await currencyInput.press('Minus'); + + let value = await currencyInput.inputValue(); + expect(value).toContain('-'); + + // Now disable allowNegative AND change prefix at the same time + await allowNegativeCheckbox.uncheck(); + await prefixInput.fill('€'); + await applyBtn.click(); + + // Value should now be positive AND have the new prefix + value = await currencyInput.inputValue(); + expect(value).not.toContain('-'); + expect(value).toContain('€'); + expect(value).toContain('50'); + }); + }); + + test.describe('allowEmpty', () => { + test('maintains default value after clearing', async ({ page }) => { + const currencyInput = page.locator('#currency-input'); + await currencyInput.focus(); + await currencyInput.fill(''); + await currencyInput.type('100'); + + // Type something first + const valueBefore = await currencyInput.inputValue(); + expect(valueBefore).toContain('1'); + + // Clear and it should go back to 0 + await currencyInput.fill(''); + await expect(currencyInput).toHaveValue('$0.00 USD'); + }); + }); + + test.describe('selectAllOnFocus', () => { + test('caret position is managed at end of input content', async ({ page }) => { + const selectAllCheckbox = page.locator('[name=selectAllOnFocus]'); + const applyBtn = page.locator('[name=apply]'); + + await selectAllCheckbox.check(); + await applyBtn.click(); + + const currencyInput = page.locator('#currency-input'); + await currencyInput.focus(); + + await expect.poll(async () => { + const selectionStart = await currencyInput.evaluate((el: HTMLInputElement) => el.selectionStart ?? 0); + const selectionEnd = await currencyInput.evaluate((el: HTMLInputElement) => el.selectionEnd ?? 0); + return selectionEnd - selectionStart; + }).toBeGreaterThan(0); + }); + }); + + test.describe('basic input and formatting', () => { + test('typing numbers formats correctly', async ({ page }) => { + const currencyInput = page.locator('#currency-input'); + await currencyInput.focus(); + await currencyInput.fill(''); + await currencyInput.type('100'); + + // With precision 2, "100" becomes $1.00 + await expect(currencyInput).toHaveValue('$1.00 USD'); + }); + + test('backspace removes digits correctly', async ({ page }) => { + const currencyInput = page.locator('#currency-input'); + await currencyInput.focus(); + await currencyInput.fill(''); + await currencyInput.type('100'); + + await expect(currencyInput).toHaveValue('$1.00 USD'); + + await currencyInput.press('Backspace'); + await expect(currencyInput).toHaveValue('$0.10 USD'); + }); + + test('decimal point insertion respects precision', async ({ page }) => { + const currencyInput = page.locator('#currency-input'); + await currencyInput.focus(); + await currencyInput.fill(''); + await currencyInput.type('123'); + + // With precision 2, 123 should be $1.23 + await expect(currencyInput).toHaveValue('$1.23 USD'); + }); + }); + + test.describe('element attributes', () => { + test('has correct id attribute', async ({ page }) => { + const currencyInput = page.locator('#currency-input'); + const id = await currencyInput.getAttribute('id'); + + expect(id).toBe('currency-input'); + }); + + test('null-input-test element has correct id', async ({ page }) => { + const nullInputTest = page.locator('#null-input-test'); + const id = await nullInputTest.getAttribute('id'); + + expect(id).toBe('null-input-test'); + }); + }); +}); diff --git a/tests/controlled-value.spec.ts b/tests/controlled-value.spec.ts new file mode 100644 index 0000000..0d771f2 --- /dev/null +++ b/tests/controlled-value.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '@playwright/test'; +import * as path from 'path'; + +const projectDir = path.resolve(__dirname, '../'); +const filePath = path.join(projectDir, 'examples/index.html'); +const fileUrl = `file://${filePath}`; + +test.describe('controlled component (value prop updates)', () => { + test.beforeEach(async ({ page }) => { + await page.goto(fileUrl); + }); + + test('should update input value when value prop changes externally', async ({ page }) => { + // Simulate a parent-driven prop change by updating the form-controlled value field. + + const currencyInput = page.locator('#currency-input'); + + // Initial state: should show $0.00 USD + await expect(currencyInput).toHaveValue('$0.00 USD'); + + // Simulate typing into the input to set it to a different value + // Note: Using pressSequentially instead of fill+type to properly trigger React events + await currencyInput.focus(); + await currencyInput.selectText(); + await currencyInput.pressSequentially('5000'); + + // Now it should show $50.00 USD (with precision 2) + await expect(currencyInput).toHaveValue('$50.00 USD'); + + // Now change the value via the form control (simulating external prop change) + const valueInput = page.locator('[name=value]'); + const applyBtn = page.locator('[name=apply]'); + + // Simulate a parent setting the controlled value to 22.22 + await valueInput.fill('22.22'); + await applyBtn.click(); + + // Input should reflect the externally provided value (22.22) + await expect(currencyInput).toHaveValue('$22.22 USD'); + }); +}); diff --git a/tests/input-type.spec.ts b/tests/input-type.spec.ts new file mode 100644 index 0000000..f8e9e7d --- /dev/null +++ b/tests/input-type.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; +import * as path from 'path'; + +const projectDir = path.resolve(__dirname, '../'); +const filePath = path.join(projectDir, 'examples/index.html'); +const fileUrl = `file://${filePath}`; + +test.describe('input type variations', () => { + test.beforeEach(async ({ page }) => { + await page.goto(fileUrl); + }); + + test('default input type is text', async ({ page }) => { + const currencyInput = page.locator('#currency-input'); + await expect(currencyInput).toHaveAttribute('type', 'text'); + }); + + test('set input type to tel for mobile keypad', async ({ page }) => { + const inputTypeField = page.locator('[name=inputType]'); + const applyBtn = page.locator('[name=apply]'); + + await inputTypeField.fill('tel'); + await applyBtn.click(); + + const currencyInput = page.locator('#currency-input'); + await expect(currencyInput).toHaveAttribute('type', 'tel'); + + // Ensure formatting still works + await currencyInput.focus(); + await currencyInput.fill(''); + await currencyInput.type('123'); + await expect(currencyInput).toHaveValue('$1.23 USD'); + }); + + test('set input type to number (native numeric)', async ({ page }) => { + const inputTypeField = page.locator('[name=inputType]'); + const applyBtn = page.locator('[name=apply]'); + + await inputTypeField.fill('number'); + await applyBtn.click(); + + const currencyInput = page.locator('#currency-input'); + await expect(currencyInput).toHaveAttribute('type', 'number'); + + // Many browsers disallow arbitrary characters and formatting in native number inputs. + // Just verify that switching to number does not throw, then switch back to text. + await inputTypeField.fill('text'); + await applyBtn.click(); + await expect(currencyInput).toHaveAttribute('type', 'text'); + }); + + test('set input type to email (stress case) then back to text', async ({ page }) => { + const inputTypeField = page.locator('[name=inputType]'); + const applyBtn = page.locator('[name=apply]'); + + await inputTypeField.fill('email'); + await applyBtn.click(); + await expect(page.locator('#currency-input')).toHaveAttribute('type', 'email'); + + await inputTypeField.fill('text'); + await applyBtn.click(); + await expect(page.locator('#currency-input')).toHaveAttribute('type', 'text'); + }); +});