diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f861f55..5bdc82d87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## v2.0.12 + +- Removes the `CurrencyInput` component +- Add the `NumberInput` component + ## v2.0.11 - Update linting to understand Vue globals diff --git a/package-lock.json b/package-lock.json index 69d6e3abb..6570d438a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lob/ui-components", - "version": "2.0.11", + "version": "2.0.12", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@lob/ui-components", - "version": "2.0.11", + "version": "2.0.12", "dependencies": { "date-fns": "^2.29.3", "date-fns-holiday-us": "^0.3.1", diff --git a/package.json b/package.json index 970a54d0d..09fd01440 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lob/ui-components", - "version": "2.0.11", + "version": "2.0.12", "engines": { "node": ">=20.2.0", "npm": ">=10.2.0" diff --git a/src/components/CurrencyInput/CurrencyInput.mdx b/src/components/CurrencyInput/CurrencyInput.mdx deleted file mode 100644 index 1289c5596..000000000 --- a/src/components/CurrencyInput/CurrencyInput.mdx +++ /dev/null @@ -1,17 +0,0 @@ -import { Canvas, Story, ArgTypes, PRIMARY_STORY } from '@storybook/addon-docs'; -import { Primary } from './CurrencyInput.stories'; - -# Currency Input - -A currency input component for billing form workflows. - - - -## How to Use - -Exposes a similar props interface as a `TextInput` with `type="number"` - i.e. a numeric `v-model` binding, as well as `min` and `max` fields. You can pass all supported props to the contained `TextInput` component via `inputProps`. -Icons are available for the left, right, or both sides of the input via `iconLeft` and `iconRight` named slots. - -## Props - - diff --git a/src/components/CurrencyInput/CurrencyInput.stories.js b/src/components/CurrencyInput/CurrencyInput.stories.js deleted file mode 100644 index b94989acc..000000000 --- a/src/components/CurrencyInput/CurrencyInput.stories.js +++ /dev/null @@ -1,54 +0,0 @@ -import CurrencyInput from './CurrencyInput.vue'; -import mdx from './CurrencyInput.mdx'; - -export default { - title: 'Components/Currency Input', - component: CurrencyInput, - parameters: { - docs: { - page: mdx - } - }, - argTypes: { - 'v-model': { - control: { - type: null - } - } - } -}; - -const currencyInputVModel = 5000; - -const PrimaryTemplate = (args, { argTypes }) => ({ - props: Object.keys(argTypes), - components: { CurrencyInput }, - setup: () => ({ args }), - data: () => ({ currencyInputVModel }), - template: '' -}); - -export const Primary = PrimaryTemplate.bind({}); -Primary.args = { - id: 'currency-input', - label: 'Currency Input', - helperText: 'Helper text', - placeholder: 'Amount' -}; - -const WithMinAndMaxTemplate = (args, { argTypes }) => ({ - props: Object.keys(argTypes), - components: { CurrencyInput }, - setup: () => ({ args }), - data: () => ({ currencyInputVModel }), - template: - '' -}); - -export const WithMinAndMax = WithMinAndMaxTemplate.bind({}); -WithMinAndMax.args = { - id: 'currency-input', - label: 'Currency Input', - min: 0, - max: 100000 -}; diff --git a/src/components/CurrencyInput/CurrencyInput.vue b/src/components/CurrencyInput/CurrencyInput.vue deleted file mode 100644 index d51fa8c20..000000000 --- a/src/components/CurrencyInput/CurrencyInput.vue +++ /dev/null @@ -1,211 +0,0 @@ - - diff --git a/src/components/CurrencyInput/__tests__/CurrencyInput.spec.js b/src/components/CurrencyInput/__tests__/CurrencyInput.spec.js deleted file mode 100644 index 7b460b1ba..000000000 --- a/src/components/CurrencyInput/__tests__/CurrencyInput.spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import '@testing-library/jest-dom'; -import { render, fireEvent } from '@testing-library/vue'; -import CurrencyInput from '../CurrencyInput.vue'; - -describe('Currency input', () => { - const initialProps = { - id: 'test', - 'v-model': 50, - inputProps: { - name: 'test', - label: 'label' - } - }; - - it('renders correctly', () => { - const props = initialProps; - const { getByLabelText } = render(CurrencyInput, { - props - }); - const currencyInput = getByLabelText(props.inputProps.label); - - expect(currencyInput).toBeInTheDocument(); - }); - - it('updates the input value on text input', async () => { - const props = initialProps; - const { getByLabelText } = render(CurrencyInput, { - props - }); - const currencyInput = getByLabelText(props.inputProps.label); - - await fireEvent.update(currencyInput, '$6.00'); - expect(currencyInput.value).toEqual('$6.00'); - }); - - it('fires the input event with a numeric value on text input', async () => { - const props = initialProps; - const { getByLabelText, emitted } = render(CurrencyInput, { - props - }); - const currencyInput = getByLabelText(props.inputProps.label); - - const updatedValue = '$6.00'; - await fireEvent.update(currencyInput, updatedValue); - - const emittedEvent = emitted(); - expect(emittedEvent).toHaveProperty('input'); - expect(emittedEvent).toHaveProperty('change'); - expect(emittedEvent.input[0]).toEqual([6]); - }); - - it('renders the helper text correctly when passed as an input prop', () => { - const props = { - ...initialProps, - inputProps: { - helperText: 'Helper text' - } - }; - const { getByText } = render(CurrencyInput, { - props - }); - - const helperText = getByText('Helper text'); - expect(helperText).toBeInTheDocument(); - }); - - describe('if show dollar sign is false', () => { - it('does not show the dollar sign', async () => { - const props = { ...initialProps, showDollarSign: false }; - const { getByLabelText } = render(CurrencyInput, { - props - }); - const currencyInput = getByLabelText(props.inputProps.label); - - await fireEvent.update(currencyInput, '6.00'); - expect(currencyInput.value).toEqual('6.00'); - }); - }); -}); diff --git a/src/components/CurrencyInput/__tests__/currencyFormatter.spec.js b/src/components/CurrencyInput/__tests__/currencyFormatter.spec.js deleted file mode 100644 index 201516b05..000000000 --- a/src/components/CurrencyInput/__tests__/currencyFormatter.spec.js +++ /dev/null @@ -1,89 +0,0 @@ -import CurrencyFormatter from '../currencyFormatter'; - -describe('Currency Formatter', () => { - const formatter = new CurrencyFormatter({ - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }); - - describe('parse', () => { - it('returns 0 for a null value or fully invalid currency string', () => { - expect(formatter.parse('abc')).toEqual(0); - expect(formatter.parse('')).toEqual(0); - }); - - it('correctly handles bad formatting', () => { - expect(formatter.parse('1,1.11')).toEqual(11.11); - }); - - it('correctly parses a positive integer', () => { - expect(formatter.parse('123')).toEqual(123); - }); - - it('correctly parses a negative integer', () => { - expect(formatter.parse('-123')).toEqual(-123); - }); - - it('correctly parses a positive float', () => { - expect(formatter.parse('123.45')).toEqual(123.45); - }); - - it('correctly parses a negative float', () => { - expect(formatter.parse('-123.45')).toEqual(-123.45); - }); - - it('correctly parses a positive integer with a comma', () => { - expect(formatter.parse('12,345')).toEqual(12345); - }); - - it('correctly parses a negative integer with a comma', () => { - expect(formatter.parse('-12,345')).toEqual(-12345); - }); - - it('correctly parses a positive float with a comma', () => { - expect(formatter.parse('12,345.67')).toEqual(12345.67); - }); - - it('correctly parses a negative float with a comma', () => { - expect(formatter.parse('-12,345.67')).toEqual(-12345.67); - }); - - it('correctly parses a positive integer with a currency symbol', () => { - expect(formatter.parse('$123')).toEqual(123); - }); - - it('correctly parses a negative integer with a currency symbol', () => { - expect(formatter.parse('-$123')).toEqual(-123); - }); - - it('correctly parses a positive float with a currency symbol', () => { - expect(formatter.parse('$123.45')).toEqual(123.45); - }); - - it('correctly parses a negative float with a currency symbol', () => { - expect(formatter.parse('-$123.45')).toEqual(-123.45); - }); - }); - - describe('format', () => { - it('returns an empty string for null', () => { - expect(formatter.format(null)).toEqual(''); - }); - - it('returns an empty string for undefined', () => { - expect(formatter.format(undefined)).toEqual(''); - }); - - it('returns an empty string for an invalid number', () => { - expect(formatter.format(NaN)).toEqual(''); - }); - - it('correctly formats a positive integer', () => { - expect(formatter.format(123)).toEqual('$123.00'); - }); - - it('correctly formats a negative integer', () => { - expect(formatter.format(-123)).toEqual('-$123.00'); - }); - }); -}); diff --git a/src/components/CurrencyInput/currencyFormatter.js b/src/components/CurrencyInput/currencyFormatter.js deleted file mode 100644 index 2801bde6d..000000000 --- a/src/components/CurrencyInput/currencyFormatter.js +++ /dev/null @@ -1,75 +0,0 @@ -export const DECIMAL_SEPARATORS = [',', '.']; -export const INT_PATTERN = '(0|[1-9]\\d*)'; - -/** - * Helper to check whether or not a string is a valid number. - * @param {string} str - * @returns {boolean} - */ -function isValidNumber(str) { - return /^-?\d+(\.\d+)?$/.test(str); -} - -export default class CurrencyFormatter { - /** - * @param {Intl.NumberFormatOptions} options - */ - constructor(options) { - this.formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 2, - minimumFractionDigits: 2, - ...options - }).format; - } - - /** - * Best effort parser for currency input strings. - * @param {string} str - A string in the format of a number or valid USD currency value. e.g. 210000, 21,000.00 or $21,000.00 - * @returns {number} The parsed numeric value, or 0 if passed an empty string or nullish value. - */ - parse(str) { - if (!str) { - return 0; - } - - const negative = this.isNegative(str); - - let [int, frac] = str.split('.'); - int = this.stripNonNumerics(int); - frac = this.stripNonNumerics(frac); - - str = `${int || 0}.${frac || 0}`; - - if (!isValidNumber(str)) { - return null; - } - return Number(`${negative ? '-' : ''}${str}`); - } - - /** - * @param {number|null} value - The numeric value to format. - * @returns {string} The formatted value. - */ - format(value) { - return value !== null && !isNaN(value) ? this.formatter(value) : ''; - } - - /** - * @param {string} str A string in the format of either a number or valid en-US USD currency value. e.g. 210000, 21,000.00 or $21,000.00 - * @returns {boolean} - */ - isNegative(str) { - return str?.startsWith('-'); - } - - /** - * Removes all non-numeric characters from a string. - * @param {string} str - * @returns {string} - */ - stripNonNumerics(str = '') { - return str.replace(/[^0-9]/g, ''); - } -} diff --git a/src/components/NumberInput/NumberInput.mdx b/src/components/NumberInput/NumberInput.mdx new file mode 100644 index 000000000..c06c8e048 --- /dev/null +++ b/src/components/NumberInput/NumberInput.mdx @@ -0,0 +1,16 @@ +import { Canvas, ArgTypes, PRIMARY_STORY } from '@storybook/addon-docs'; +import { Primary, Currency } from './NumberInput.stories'; + +# Input Number + +## Primary + + + +## Currency + + + +## Props + + diff --git a/src/components/NumberInput/NumberInput.stories.js b/src/components/NumberInput/NumberInput.stories.js new file mode 100644 index 000000000..33210c598 --- /dev/null +++ b/src/components/NumberInput/NumberInput.stories.js @@ -0,0 +1,84 @@ +import NumberInput from './NumberInput.vue'; +import mdx from './NumberInput.mdx'; +import { NumberInputMode } from './constants'; + +export default { + title: 'Components/Input Number', + component: NumberInput, + parameters: { + docs: { + page: mdx + } + }, + argTypes: { + disabled: { + control: 'boolean' + }, + id: { + control: 'text' + }, + max: { + control: 'number' + }, + maxFractionDigits: { + control: 'number' + }, + min: { + control: 'number' + }, + minFractionDigits: { + control: 'number' + }, + mode: { + options: Object.values(NumberInputMode), + control: { + type: 'select' + } + }, + placeholder: { + control: 'text' + }, + readonly: { + control: 'boolean' + } + } +}; + +const numberInputModel = 5000; + +const PrimaryTemplate = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { NumberInput }, + setup: () => ({ args }), + data: () => ({ numberInputModel }), + template: '' +}); + +export const Primary = PrimaryTemplate.bind({}); +Primary.args = { + id: 'input-number', + name: 'input-number', + label: 'Input Number', + helperText: 'Helper text', + placeholder: 'Amount', + mode: NumberInputMode.DECIMAL +}; + +const currencyInputModel = 5000; + +const CurrencyTemplate = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { NumberInput }, + setup: () => ({ args }), + data: () => ({ currencyInputModel }), + template: '' +}); + +export const Currency = CurrencyTemplate.bind({}); +Currency.args = { + id: 'input-currency', + name: 'input-currency', + label: 'Input Currency', + placeholder: 'Amount', + mode: NumberInputMode.CURRENCY +}; diff --git a/src/components/NumberInput/NumberInput.vue b/src/components/NumberInput/NumberInput.vue new file mode 100644 index 000000000..2e1771459 --- /dev/null +++ b/src/components/NumberInput/NumberInput.vue @@ -0,0 +1,192 @@ + + + + + + +NumberInputModeNumberInputModeNumberInputModeNumberInputModeNumberInputModeNumberInputModeNumberInputModeNumberInputMode diff --git a/src/components/NumberInput/__tests__/InputNumber.spec.ts b/src/components/NumberInput/__tests__/InputNumber.spec.ts new file mode 100644 index 000000000..75f782706 --- /dev/null +++ b/src/components/NumberInput/__tests__/InputNumber.spec.ts @@ -0,0 +1,57 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render } from '@testing-library/vue'; +import NumberInput from '../NumberInput.vue'; +import { ExtractPropTypes } from 'vue'; + +describe('NumberInput', () => { + const DEFAULT_PROPS: ExtractPropTypes = { + label: 'Test Input Number', + id: 'test-input-number', + name: 'test', + 'data-testid': 'uic-input-number', + helperText: 'Test helper text' + }; + + it('renders', () => { + const { getByTestId } = render(NumberInput, { props: DEFAULT_PROPS }); + expect(getByTestId('uic-input-number-container')).toBeVisible(); + const label = getByTestId('uic-input-number-label'); + expect(label).toBeVisible(); + expect(label.textContent).toContain(DEFAULT_PROPS.label); + expect(getByTestId('uic-input-number')).toBeVisible(); + const helperText = getByTestId('uic-input-number-helper'); + expect(helperText).toBeVisible(); + expect(helperText.textContent).toContain(DEFAULT_PROPS.helperText); + }); + + it('updates', () => { + const { getByTestId } = render(NumberInput, { + props: { ...DEFAULT_PROPS, modelValue: 50 } + }); + const numberInput = getByTestId('uic-input-number'); + + expect(numberInput).toHaveValue('50'); + fireEvent.update(numberInput, '123'); + expect(numberInput).toHaveValue('123'); + }); + + it('emits focus', () => { + const { getByTestId, emitted } = render(NumberInput, { + props: DEFAULT_PROPS + }); + const numberInput = getByTestId('uic-input-number'); + + fireEvent.focus(numberInput); + expect(emitted()).toHaveProperty('focus'); + }); + + it('emits blur', () => { + const { getByTestId, emitted } = render(NumberInput, { + props: DEFAULT_PROPS + }); + const numberInput = getByTestId('uic-input-number'); + + fireEvent.blur(numberInput); + expect(emitted()).toHaveProperty('blur'); + }); +}); diff --git a/src/components/NumberInput/constants.ts b/src/components/NumberInput/constants.ts new file mode 100644 index 000000000..e67db791a --- /dev/null +++ b/src/components/NumberInput/constants.ts @@ -0,0 +1,6 @@ +export const NumberInputMode = { + CURRENCY: 'currency', + DECIMAL: 'decimal' +} as const; +export type NumberInputMode = + (typeof NumberInputMode)[keyof typeof NumberInputMode]; diff --git a/src/components/index.js b/src/components/index.js index b3e7e652e..82c321c4d 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -23,6 +23,7 @@ export { default as MainNavigationChildItem } from './MainNavigation/MainNavigat export { default as MegaButton } from './MegaButton/MegaButton'; export { default as Modal } from './Modal/Modal.vue'; export { default as Multiselect } from './Multiselect/Multiselect'; +export { default as NumberInput } from './NumberInput/NumberInput.vue'; export { default as Pagination } from './Pagination/Pagination'; export { default as ProgressBar } from './ProgressBar/ProgressBar'; export { default as RadioButton } from './RadioButton/RadioButton'; @@ -49,4 +50,3 @@ export { default as Stepper } from './Stepper/Stepper'; export { default as StepperItem } from './Stepper/StepperItem'; export { default as StepperVertical } from './StepperVertical/StepperVertical'; export { default as Dropzone } from './Dropzone/Dropzone'; -export { default as CurrencyInput } from './CurrencyInput/CurrencyInput';