mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-008
|
||||
// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-014
|
||||
//
|
||||
// #1513: tapping a pre-populated numeric field put the caret at the end, so the first
|
||||
// digit typed was appended rather than replacing the value — quantity 1 + "6" = "16".
|
||||
@@ -16,9 +16,9 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
function Harness({ initial, mode }: { initial: string; mode?: NumericMode }) {
|
||||
function Harness({ initial, mode, signToggleLabel }: { initial: string; mode?: NumericMode; signToggleLabel?: string }) {
|
||||
const [v, setV] = useState(initial)
|
||||
return <NumericInput value={v} onValueChange={setV} mode={mode} aria-label="field" />
|
||||
return <NumericInput value={v} onValueChange={setV} mode={mode} signToggleLabel={signToggleLabel} aria-label="field" />
|
||||
}
|
||||
|
||||
describe('NumericInput', () => {
|
||||
@@ -115,6 +115,47 @@ describe('NumericInput', () => {
|
||||
expect((screen.getByLabelText('field') as HTMLInputElement).inputMode).toBe('decimal')
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-012: the sign toggle flips the sign of what is already there (#2176)', () => {
|
||||
// iOS renders inputMode="decimal" as a pad of digits, separator and backspace, with
|
||||
// no minus key, so a refund is untypeable on a phone without this button.
|
||||
render(<Harness initial="" mode="signed-decimal" signToggleLabel="Toggle sign" />)
|
||||
const input = screen.getByLabelText('field') as HTMLInputElement
|
||||
const toggle = screen.getByRole('button', { name: 'Toggle sign' })
|
||||
|
||||
fireEvent.change(input, { target: { value: '12,50' } })
|
||||
fireEvent.click(toggle)
|
||||
expect(input.value).toBe('-12,50')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(input.value).toBe('12,50')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
// The button must not take the focus, or the phone keypad closes mid-entry.
|
||||
expect(fireEvent.mouseDown(toggle)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-013: an empty field toggles to a bare minus so the digits follow', () => {
|
||||
render(<Harness initial="" mode="signed-decimal" signToggleLabel="Toggle sign" />)
|
||||
const input = screen.getByLabelText('field') as HTMLInputElement
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle sign' }))
|
||||
expect(input.value).toBe('-')
|
||||
|
||||
// The caret stays in the field, so the next keystroke appends to the sign.
|
||||
fireEvent.change(input, { target: { value: '-1' } })
|
||||
expect(input.value).toBe('-1')
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-014: there is no toggle without a label, and none on an unsigned field', () => {
|
||||
const { unmount } = render(<Harness initial="" mode="signed-decimal" />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
unmount()
|
||||
|
||||
render(<Harness initial="" mode="decimal" signToggleLabel="Toggle sign" />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-009: typing inside the one-frame window is not swallowed by the deferred select', async () => {
|
||||
// The deferred select() exists for WebKit, but it must not fire *after* the user has
|
||||
// started typing: it would select the half-typed value and the next character would
|
||||
|
||||
@@ -24,6 +24,8 @@ const SANITIZERS: Record<NumericMode, (raw: string) => string> = {
|
||||
},
|
||||
}
|
||||
|
||||
const flipSign = (raw: string) => (raw.startsWith('-') ? raw.slice(1) : `-${raw}`)
|
||||
|
||||
type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | 'value'> & {
|
||||
value: string | number | null | undefined
|
||||
/** Receives the sanitized raw string. Callers keep their own state and commit logic. */
|
||||
@@ -31,6 +33,13 @@ type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | '
|
||||
mode?: NumericMode
|
||||
/** Escape hatch for a field that must not steal the caret (none today). */
|
||||
selectOnFocus?: boolean
|
||||
/**
|
||||
* Accessible name for a sign toggle rendered beside the field. Set it on a signed
|
||||
* money field: iOS draws inputMode="decimal" as a pad with digits, separator and
|
||||
* backspace only, so a refund (#2176) has no minus key to type. Ignored on the
|
||||
* unsigned modes, where there is no sign to flip.
|
||||
*/
|
||||
signToggleLabel?: string
|
||||
ref?: Ref<HTMLInputElement>
|
||||
}
|
||||
|
||||
@@ -62,34 +71,54 @@ type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | '
|
||||
* others on blur. This owns only the part that was uniformly broken.
|
||||
*/
|
||||
export function NumericInput({
|
||||
value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, ref, ...rest
|
||||
value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, signToggleLabel, ref, ...rest
|
||||
}: Props) {
|
||||
// Set while a deferred select() is queued; any input in that window cancels it.
|
||||
const selectPending = useRef(false)
|
||||
const raw = String(value ?? '')
|
||||
const signable = mode === 'signed' || mode === 'signed-decimal'
|
||||
|
||||
return (
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="text"
|
||||
inputMode={inputMode ?? (mode === 'integer' ? 'numeric' : 'decimal')}
|
||||
value={value ?? ''}
|
||||
onChange={e => {
|
||||
selectPending.current = false
|
||||
onValueChange(SANITIZERS[mode](e.target.value))
|
||||
}}
|
||||
onFocus={e => {
|
||||
if (selectOnFocus) {
|
||||
const el = e.currentTarget
|
||||
el.select()
|
||||
selectPending.current = true
|
||||
requestAnimationFrame(() => {
|
||||
if (selectPending.current && document.activeElement === el) el.select()
|
||||
selectPending.current = false
|
||||
})
|
||||
}
|
||||
onFocus?.(e)
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="text"
|
||||
inputMode={inputMode ?? (mode === 'integer' ? 'numeric' : 'decimal')}
|
||||
value={value ?? ''}
|
||||
onChange={e => {
|
||||
selectPending.current = false
|
||||
onValueChange(SANITIZERS[mode](e.target.value))
|
||||
}}
|
||||
onFocus={e => {
|
||||
if (selectOnFocus) {
|
||||
const el = e.currentTarget
|
||||
el.select()
|
||||
selectPending.current = true
|
||||
requestAnimationFrame(() => {
|
||||
if (selectPending.current && document.activeElement === el) el.select()
|
||||
selectPending.current = false
|
||||
})
|
||||
}
|
||||
onFocus?.(e)
|
||||
}}
|
||||
/>
|
||||
{signToggleLabel && signable && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={signToggleLabel}
|
||||
aria-pressed={raw.startsWith('-')}
|
||||
disabled={rest.disabled}
|
||||
// Keeps the caret (and on a phone the keypad) in the field, so the sign can
|
||||
// be flipped mid-entry and typing carries on where it left off.
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => onValueChange(SANITIZERS[mode](flipSign(raw)))}
|
||||
className="text-content-faint"
|
||||
style={{ border: 0, background: 'none', padding: '0 2px', font: 'inherit', lineHeight: 1, cursor: 'pointer' }}
|
||||
>
|
||||
±
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user