Skip to content

Commit d08d14c

Browse files
committed
improvement(emcn): drop the number-stepper reset for plain text numeric fields
The stepper was suppressed with a vendor-pseudo-element class string inside `ChipInput`. Removing browser chrome with custom CSS is the wrong end of the problem: the fields never wanted a stepper in the first place. They are text fields with a numeric input mode now — the choice the retry settings field already documents ("the native spinner is all that buys, and it does not fit the field chrome"). No CSS, the numeric keypad is unchanged, and `ChipModalField` gained an `inputMode` prop so a modal field can say the same thing without asking for the stepper. This also fixes a real defect in the credit-limit field. A number input reports `''` for anything the browser considers invalid, so a typo arrived indistinguishable from a cleared field and saved as "no limit"; as text it reaches the `Number.isInteger` check and is refused. The usage-limit field's `min` attribute went the same way — the minimum is enforced on commit, where it can explain itself, rather than silently by the browser.
1 parent 5d1fbb7 commit d08d14c

6 files changed

Lines changed: 28 additions & 39 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,14 @@ export function UsageLimitField({
121121

122122
return (
123123
<SettingsSection label='Usage limit' headerAccessory={USAGE_LIMIT_INFO}>
124+
{/*
125+
Text with a numeric input mode rather than `type='number'`: the native stepper
126+
is all that type buys and it does not fit the chip chrome. The minimum is
127+
enforced on commit below, where it can explain itself, rather than by a `min`
128+
attribute the browser enforces silently.
129+
*/}
124130
<ChipInput
125-
type='number'
126131
inputMode='numeric'
127-
min={dollarsToCredits(minimumLimit)}
128132
value={draft}
129133
onChange={(e) => setDraft(e.target.value)}
130134
placeholder={

apps/sim/app/workspace/[workspaceId]/settings/components/manage-credits-modal/manage-credits-modal.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,17 @@ export function ManageCreditsModal({
109109
value={isLoading ? 'Loading…' : creditsUsed}
110110
copyLabel='Copy credits used'
111111
/>
112+
{/*
113+
Text with a numeric input mode, not `inputType='number'` — the same choice
114+
the retry settings field documents. The native stepper is all the number
115+
type buys, and it paints browser chrome inside a flat chip surface. It also
116+
reports `''` for anything the browser considers invalid, so a typo arrived
117+
here indistinguishable from a cleared field and saved as "no limit"; as text
118+
it reaches the `Number.isInteger` check below and is refused.
119+
*/}
112120
<ChipModalField
113121
type='input'
114-
inputType='number'
122+
inputMode='numeric'
115123
title={
116124
<span className='inline-flex items-center gap-1.5'>
117125
Credit limit

apps/sim/ee/session-policy/components/session-policy-settings.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ function HourField({ id, title, hint, value, onChange }: HourFieldProps) {
4040
</Label>
4141
<ChipInput
4242
id={id}
43-
type='number'
4443
inputMode='numeric'
4544
value={value}
4645
onChange={(event) => onChange(event.target.value)}

packages/emcn/src/components/chip-input/chip-input.test.tsx

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import { ChipInput } from './chip-input'
99
let root: Root | null = null
1010
let container: HTMLDivElement | null = null
1111

12-
function mount(type?: string): HTMLInputElement {
12+
function mount(): HTMLInputElement {
1313
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
1414
container = document.createElement('div')
1515
document.body.appendChild(container)
1616
root = createRoot(container)
17-
act(() => root?.render(<ChipInput aria-label='Search' type={type} />))
17+
act(() => root?.render(<ChipInput aria-label='Search' />))
1818

1919
const input = container.querySelector<HTMLInputElement>('input')
2020
if (!input) throw new Error('ChipInput did not render an input')
@@ -35,21 +35,4 @@ describe('ChipInput', () => {
3535
expect(input.className).toContain('-ml-1')
3636
expect(input.className).toContain('indent-1')
3737
})
38-
39-
/**
40-
* A chip field owns its chrome, and the browser's number stepper paints a bordered
41-
* double-arrow inside it that belongs to no design token. Suppressing it lives on
42-
* the component so no caller re-derives the three vendor rules by hand.
43-
*/
44-
it('suppresses the native number spinner on a number field', () => {
45-
const input = mount('number')
46-
47-
expect(input.className).toContain('[appearance:textfield]')
48-
expect(input.className).toContain('[&::-webkit-inner-spin-button]:appearance-none')
49-
expect(input.className).toContain('[&::-webkit-outer-spin-button]:appearance-none')
50-
})
51-
52-
it('leaves a text field untouched, since it has no stepper to hide', () => {
53-
expect(mount().className).not.toContain('[appearance:textfield]')
54-
})
5538
})

packages/emcn/src/components/chip-input/chip-input.tsx

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,6 @@ import * as React from 'react'
3030
import { cn } from '../../lib/cn'
3131
import { chipFieldSurfaceClass, chipFieldTextClass, chipGeometryClass } from '../chip/chip-chrome'
3232

33-
/**
34-
* Suppresses the native number spinner.
35-
*
36-
* A `type='number'` field is still a chip field: it owns a flat 30px surface with
37-
* no focus ring, and the browser's stepper paints its own chrome inside it —
38-
* a bordered double-arrow that belongs to no design token and shifts the value's
39-
* right edge on hover. Removing it belongs to the component rather than to every
40-
* caller, or each one re-derives the same three vendor rules by hand.
41-
*
42-
* `appearance: textfield` covers Firefox; the WebKit pseudo-elements cover
43-
* Chrome and Safari, which ignore it.
44-
*/
45-
const NUMBER_SPINNER_RESET =
46-
'[appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none'
47-
4833
type ChipInputIcon = React.ComponentType<{ className?: string }>
4934

5035
export interface ChipInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
@@ -96,7 +81,6 @@ export const ChipInput = React.forwardRef<HTMLInputElement, ChipInputProps>(
9681
className={cn(
9782
'-ml-1 h-full w-full bg-transparent indent-1 disabled:cursor-not-allowed',
9883
chipFieldTextClass,
99-
type === 'number' && NUMBER_SPINNER_RESET,
10084
inputClassName
10185
)}
10286
{...props}

packages/emcn/src/components/chip-modal/chip-modal.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,16 @@ interface ChipModalInputFieldProps extends ChipModalFieldBaseProps, ChipModalSin
556556
* native password input. See {@link ChipModalPasswordControl}.
557557
*/
558558
inputType?: 'text' | 'password' | 'url' | 'tel' | 'search' | 'number'
559+
/**
560+
* Virtual-keyboard hint, independent of {@link inputType}.
561+
*
562+
* A field holding a number usually wants `inputMode='numeric'` on a `'text'` input
563+
* rather than `inputType='number'`: the numeric type renders the browser's stepper,
564+
* which paints its own chrome inside a flat chip surface, and reports `''` for any
565+
* value it considers invalid — so the caller cannot tell an empty field from a
566+
* rejected keystroke.
567+
*/
568+
inputMode?: 'numeric' | 'decimal' | 'tel'
559569
/**
560570
* Renders the value in the monospace stack (`font-mono`). Use for
561571
* code-like values (identifiers, keys, snippets) where the proportional
@@ -814,6 +824,7 @@ function renderChipModalControl(
814824
<ChipInput
815825
id={id}
816826
type={props.type === 'email' ? 'email' : (props.inputType ?? 'text')}
827+
inputMode={props.type === 'input' ? props.inputMode : undefined}
817828
value={props.value}
818829
onChange={(event) => props.onChange(event.target.value)}
819830
onKeyDown={onKeyDown}

0 commit comments

Comments
 (0)