NumberInput.test.tsx 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. /**
  2. * NumberInput keeps what was typed as a draft, so a number field can be
  3. * cleared and retyped (#3182). Clamping inside onChange used to snap an
  4. * emptied field straight back to its minimum.
  5. */
  6. import { useState } from 'react';
  7. import { describe, it, expect, vi } from 'vitest';
  8. import { render, screen } from '@testing-library/react';
  9. import userEvent from '@testing-library/user-event';
  10. import { NumberInput } from '../../components/NumberInput';
  11. function Harness({
  12. initial,
  13. onCommit,
  14. ...props
  15. }: {
  16. initial: number;
  17. onCommit?: (v: number) => void;
  18. min?: number;
  19. max?: number;
  20. fallback?: number;
  21. integer?: boolean;
  22. }) {
  23. const [value, setValue] = useState(initial);
  24. return (
  25. <>
  26. <NumberInput
  27. aria-label="qty"
  28. value={value}
  29. onChange={(v) => {
  30. setValue(v);
  31. onCommit?.(v);
  32. }}
  33. {...props}
  34. />
  35. <output data-testid="committed">{value}</output>
  36. </>
  37. );
  38. }
  39. const input = () => screen.getByLabelText('qty') as HTMLInputElement;
  40. const committed = () => screen.getByTestId('committed').textContent;
  41. describe('NumberInput', () => {
  42. it('can be emptied and retyped without snapping back to the minimum', async () => {
  43. const user = userEvent.setup();
  44. render(<Harness initial={1} min={1} max={999} fallback={1} />);
  45. await user.click(input());
  46. await user.keyboard('{Backspace}');
  47. expect(input().value).toBe('');
  48. expect(committed()).toBe('1');
  49. await user.keyboard('6');
  50. expect(input().value).toBe('6');
  51. expect(committed()).toBe('6');
  52. });
  53. it('passes nothing up while the draft is below the minimum, then the finished number', async () => {
  54. const user = userEvent.setup();
  55. const onCommit = vi.fn();
  56. render(<Harness initial={45} min={45} max={90} onCommit={onCommit} />);
  57. await user.clear(input());
  58. await user.keyboard('6');
  59. expect(input().value).toBe('6');
  60. expect(onCommit).not.toHaveBeenCalled();
  61. await user.keyboard('0');
  62. expect(input().value).toBe('60');
  63. expect(onCommit).toHaveBeenCalledTimes(1);
  64. expect(onCommit).toHaveBeenLastCalledWith(60);
  65. });
  66. it('clamps an out-of-range draft into range on blur', async () => {
  67. const user = userEvent.setup();
  68. render(<Harness initial={50} min={45} max={90} />);
  69. await user.clear(input());
  70. await user.keyboard('120');
  71. await user.tab();
  72. expect(input().value).toBe('90');
  73. expect(committed()).toBe('90');
  74. await user.clear(input());
  75. await user.keyboard('3');
  76. await user.tab();
  77. expect(input().value).toBe('45');
  78. expect(committed()).toBe('45');
  79. });
  80. it('commits the fallback when left empty', async () => {
  81. const user = userEvent.setup();
  82. render(<Harness initial={30} min={1} max={365} fallback={7} />);
  83. await user.clear(input());
  84. await user.tab();
  85. expect(input().value).toBe('7');
  86. expect(committed()).toBe('7');
  87. });
  88. it('reverts to the current value when left empty without a fallback', async () => {
  89. const user = userEvent.setup();
  90. const onCommit = vi.fn();
  91. render(<Harness initial={40} min={30} max={65} onCommit={onCommit} />);
  92. await user.clear(input());
  93. await user.tab();
  94. expect(input().value).toBe('40');
  95. expect(onCommit).not.toHaveBeenCalled();
  96. });
  97. it('accepts 0 where the range allows it', async () => {
  98. const user = userEvent.setup();
  99. render(<Harness initial={10} min={0} max={120} fallback={10} />);
  100. await user.clear(input());
  101. await user.keyboard('0');
  102. await user.tab();
  103. expect(input().value).toBe('0');
  104. expect(committed()).toBe('0');
  105. });
  106. it('keeps fractions when integer is false', async () => {
  107. const user = userEvent.setup();
  108. render(<Harness initial={28} min={0} max={60} integer={false} />);
  109. await user.clear(input());
  110. await user.keyboard('27.5');
  111. await user.tab();
  112. expect(input().value).toBe('27.5');
  113. expect(committed()).toBe('27.5');
  114. });
  115. it('follows a value changed from outside while not being edited', () => {
  116. const { rerender } = render(<NumberInput aria-label="qty" value={3} onChange={() => {}} />);
  117. expect(input().value).toBe('3');
  118. rerender(<NumberInput aria-label="qty" value={8} onChange={() => {}} />);
  119. expect(input().value).toBe('8');
  120. });
  121. it('still calls a caller-supplied onBlur', async () => {
  122. const user = userEvent.setup();
  123. const onBlur = vi.fn();
  124. render(<NumberInput aria-label="qty" value={3} onChange={() => {}} onBlur={onBlur} />);
  125. await user.click(input());
  126. await user.tab();
  127. expect(onBlur).toHaveBeenCalledTimes(1);
  128. });
  129. });