From f32b87ba120c5a2c705cff0950b094a414e7486d Mon Sep 17 00:00:00 2001 From: Gaurav Tewari Date: Mon, 25 May 2026 09:13:54 +0530 Subject: [PATCH 1/7] feat: add input number comp --- README.md | 1 + apps/docs/stories/input-number.stories.tsx | 780 ++++++++++++++++++ apps/docs/stories/intro.mdx | 1 + packages/ui/package.json | 10 + packages/ui/src/index.ts | 1 + packages/ui/src/input-number/index.ts | 2 + .../input-number.forward-ref.test.tsx | 123 +++ .../src/input-number/input-number.module.scss | 324 ++++++++ packages/ui/src/input-number/input-number.tsx | 483 +++++++++++ packages/ui/vite.config.ts | 1 + 10 files changed, 1726 insertions(+) create mode 100644 apps/docs/stories/input-number.stories.tsx create mode 100644 packages/ui/src/input-number/index.ts create mode 100644 packages/ui/src/input-number/input-number.forward-ref.test.tsx create mode 100644 packages/ui/src/input-number/input-number.module.scss create mode 100644 packages/ui/src/input-number/input-number.tsx diff --git a/README.md b/README.md index f8b97416..27c225c2 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ import { Dialog } from '@signozhq/ui'; import { Drawer } from '@signozhq/ui'; import { DropdownMenu } from '@signozhq/ui'; import { Input } from '@signozhq/ui'; +import { InputNumber } from '@signozhq/ui'; import { Kbd } from '@signozhq/ui'; import { Pagination } from '@signozhq/ui'; import { Progress } from '@signozhq/ui'; diff --git a/apps/docs/stories/input-number.stories.tsx b/apps/docs/stories/input-number.stories.tsx new file mode 100644 index 00000000..06e6bab0 --- /dev/null +++ b/apps/docs/stories/input-number.stories.tsx @@ -0,0 +1,780 @@ +import { + InputNumber, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@signozhq/ui'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; + +const meta: Meta = { + title: 'Components/InputNumber', + component: InputNumber, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + "Numeric input with prefix/suffix, addonBefore/addonAfter, optional spinner controls, formatter/parser, sizes, variants, and status. API mirrors Ant Design's `InputNumber` for a drop-in migration path.", + }, + }, + }, + argTypes: { + value: { control: 'number', table: { category: 'Form' } }, + defaultValue: { control: 'number', table: { category: 'Form' } }, + min: { control: 'number', table: { category: 'Behavior' } }, + max: { control: 'number', table: { category: 'Behavior' } }, + step: { control: 'number', table: { category: 'Behavior' } }, + precision: { control: 'number', table: { category: 'Behavior' } }, + placeholder: { control: 'text', table: { category: 'Form' } }, + disabled: { control: 'boolean', table: { category: 'Behavior' } }, + readOnly: { control: 'boolean', table: { category: 'Behavior' } }, + controls: { control: 'boolean', table: { category: 'Behavior' } }, + mode: { + control: 'inline-radio', + options: ['input', 'spinner'], + table: { category: 'Behavior' }, + }, + variant: { + control: 'inline-radio', + options: ['outlined', 'filled', 'borderless', 'underlined'], + table: { category: 'Appearance' }, + }, + size: { + control: 'inline-radio', + options: ['small', 'middle', 'large'], + table: { category: 'Appearance' }, + }, + status: { + control: 'inline-radio', + options: [undefined, 'error', 'warning'], + table: { category: 'Appearance' }, + }, + keyboard: { control: 'boolean', table: { category: 'Behavior' } }, + changeOnWheel: { control: 'boolean', table: { category: 'Behavior' } }, + changeOnBlur: { control: 'boolean', table: { category: 'Behavior' } }, + prefix: { control: false, table: { category: 'Appearance' } }, + suffix: { control: false, table: { category: 'Appearance' } }, + addonBefore: { control: false, table: { category: 'Appearance' } }, + addonAfter: { control: false, table: { category: 'Appearance' } }, + onChange: { control: false, table: { category: 'Events' } }, + onStep: { control: false, table: { category: 'Events' } }, + onPressEnter: { control: false, table: { category: 'Events' } }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/* -------------------------------------------------------------------------- */ +/* Shared layout helpers */ +/* -------------------------------------------------------------------------- */ + +function Section({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+
{children}
+
+ ); +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function Output({ value }: { value: number | null }) { + return ( +

+ onChange: {value === null ? 'null' : value} +

+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Primary */ +/* -------------------------------------------------------------------------- */ + +/** Playground — explore every prop. */ +export const Playground: Story = { + args: { + defaultValue: 3, + min: 0, + max: 100, + step: 1, + placeholder: 'Enter a number', + }, + render: (args) => ( +
+ +
+ ), +}; + +/* -------------------------------------------------------------------------- */ +/* Core props */ +/* -------------------------------------------------------------------------- */ + +/** States — basic, disabled, readonly. */ +export const States: Story = { + parameters: { + docs: { + description: { + story: 'Three interactive states stacked for comparison.', + }, + }, + }, + render: () => ( +
+
+ + + + + + + + + +
+
+ ), +}; + +/** Spinner controls — opt in via `controls`, choose layout via `mode`. */ +export const Spinner: Story = { + parameters: { + docs: { + description: { + story: + '`controls` toggles the up/down buttons; `mode` chooses their layout. The buttons disable automatically at `min` / `max`.', + }, + }, + }, + render: () => ( +
+
+ + + + + + + + + +
+
+ ), +}; + +/** Formatter / parser — custom display formatting. */ +export const Formatter: Story = { + parameters: { + docs: { + description: { + story: + '`formatter` controls how the value renders; `parser` extracts the numeric part of what the user types. Common cases: currency, percentage, units.', + }, + }, + }, + render: () => { + const [currency, setCurrency] = useState(1000); + const [percent, setPercent] = useState(80); + const [bytes, setBytes] = useState(1024); + return ( +
+
+ + `$ ${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + parser={(v) => v.replace(/\$\s?|(,*)/g, '')} + /> + + + + `${v}%`} + parser={(v) => v.replace('%', '')} + /> + + + + `${v} B`} + parser={(v) => v.replace(/\s?B/g, '')} + /> + + +
+
+ ); + }, +}; + +/** Precision — round to a fixed number of decimals. */ +export const Precision: Story = { + parameters: { + docs: { + description: { + story: + '`precision` rounds the emitted value AND pads the display with trailing zeros so the field always shows a consistent shape.', + }, + }, + }, + render: () => { + const [value, setValue] = useState(1.234); + return ( +
+ + + + +
+ ); + }, +}; + +/* -------------------------------------------------------------------------- */ +/* Slots */ +/* -------------------------------------------------------------------------- */ + +/** Prefix / suffix — inline content inside the input border. */ +export const PrefixAndSuffix: Story = { + parameters: { + docs: { + description: { + story: + 'Use for short labels, units, or icons. Renders inline inside the bordered input — no extra outer box.', + }, + }, + }, + render: () => ( +
+
+ + + + + + + + + +
+
+ ), +}; + +/** Addons — bordered outer slots that merge into the input. */ +export const Addons: Story = { + parameters: { + docs: { + description: { + story: + '`addonBefore` / `addonAfter` are bordered outer slots that visually merge with the input wrapper. Common pattern: text labels on either side, or an embedded `Select` for unit choice.', + }, + }, + }, + render: () => { + const [unit, setUnit] = useState('GiB'); + return ( +
+
+ + + + + setUnit(v as string)}> + + + + + MiB + GiB + TiB + + + } + /> + + + + + + + + GiB + + + } + /> + +
+
+ ); + }, +}; + +/* -------------------------------------------------------------------------- */ +/* Validation */ +/* -------------------------------------------------------------------------- */ + +/** Status & out-of-range. */ +export const Status: Story = { + parameters: { + docs: { + description: { + story: + '`status` paints an error or warning border. When the value drifts outside `[min, max]`, the wrapper picks up a warning border automatically (unless `status` is set explicitly).', + }, + }, + }, + render: () => ( +
+
+ + + + + + + + + +
+
+ ), +}; + +/** Clamping — value rounds back into range on blur. */ +export const ClampOnBlur: Story = { + parameters: { + docs: { + description: { + story: + '`changeOnBlur` (default `true`) re-emits a clamped, precision-rounded value when the input loses focus. Useful when you want users to be able to type freely but settle on a valid value.', + }, + }, + }, + render: () => { + const [value, setValue] = useState(5); + return ( +
+ + + + +
+ ); + }, +}; + +/* -------------------------------------------------------------------------- */ +/* Appearance */ +/* -------------------------------------------------------------------------- */ + +/** Sizes — small (24px), middle (32px), large (40px). */ +export const Sizes: Story = { + render: () => ( +
+
+ + + + + + + + + +
+
+ ), +}; + +/** Variants — outlined, filled, borderless, underlined. */ +export const Variants: Story = { + render: () => ( +
+
+ + + + + + + + + + + + +
+
+ ), +}; + +/* -------------------------------------------------------------------------- */ +/* Interaction */ +/* -------------------------------------------------------------------------- */ + +/** Keyboard & wheel. */ +export const KeyboardAndWheel: Story = { + parameters: { + docs: { + description: { + story: + '`keyboard` controls ↑/↓ stepping. `changeOnWheel` enables mouse-wheel stepping when the input is focused (focus required to avoid accidental edits while scrolling the page).', + }, + }, + }, + render: () => { + const [keyboardEnabled, setKeyboardEnabled] = useState(true); + const [wheelEnabled, setWheelEnabled] = useState(true); + return ( +
+
+ + +
+ + + +
+ ); + }, +}; + +/** onPressEnter — submit on Enter. */ +export const PressEnter: Story = { + parameters: { + docs: { + description: { + story: + '`onPressEnter` fires when the user hits Enter while the input is focused. Pair with a controlled value to build "type-and-submit" flows.', + }, + }, + }, + render: () => { + const [submitted, setSubmitted] = useState(null); + const [value, setValue] = useState(null); + return ( +
+ + setSubmitted(value)} + placeholder="e.g. 42" + /> + +
+ ); + }, +}; + +/* -------------------------------------------------------------------------- */ +/* Real-world examples — mirrored from SigNoz PR #11378 */ +/* -------------------------------------------------------------------------- */ + +/** Threshold field — alert rule threshold with text prefix. */ +export const ThresholdField: Story = { + parameters: { + docs: { + description: { + story: + 'Adapted from `FormAlertRules/RuleOptions.tsx`. A labelled threshold field — the label sits in the prefix slot so it stays right next to the value.', + }, + }, + }, + render: () => { + const [value, setValue] = useState(null); + return ( +
+ + + + +
+ ); + }, +}; + +/** Ingestion limits — value + unit dropdown. */ +export const IngestionLimits: Story = { + parameters: { + docs: { + description: { + story: + 'Adapted from `IngestionSettings/MultiIngestionSettings.tsx`. A daily-limit field paired with a unit `Select` in the `addonAfter` slot.', + }, + }, + }, + render: () => { + const [dailyLimit, setDailyLimit] = useState(null); + const [unit, setUnit] = useState('GiB'); + const [samples, setSamples] = useState(null); + const [samplesUnit, setSamplesUnit] = useState('million'); + return ( +
+
+ + setUnit(v as string)}> + + + + + MiB + GiB + TiB + + + } + /> + + + setSamplesUnit(v as string)} + > + + + + + thousand + million + billion + + + } + /> + +
+
+ ); + }, +}; + +/** Histogram buckets — integer count + decimal width. */ +export const HistogramBuckets: Story = { + parameters: { + docs: { + description: { + story: + 'Adapted from `NewWidget/HistogramBucketsSection.tsx`. A pair of bucket-config inputs with different precisions and steps.', + }, + }, + }, + render: () => { + const [count, setCount] = useState(null); + const [width, setWidth] = useState(null); + return ( +
+
+ + + + + + +
+
+ ); + }, +}; + +/** Soft axes — paired min/max range. */ +export const SoftAxesRange: Story = { + parameters: { + docs: { + description: { + story: + 'Adapted from `NewWidget/AxesSection.tsx`. Paired soft-min / soft-max inputs in a horizontal field layout.', + }, + }, + }, + render: () => { + const [softMin, setSoftMin] = useState(null); + const [softMax, setSoftMax] = useState(null); + return ( +
+
+
+ Soft Min + + Soft Max + +
+
+
+ ); + }, +}; + +/** Query limit — min=1, optionally disabled. */ +export const QueryLimit: Story = { + parameters: { + docs: { + description: { + story: + 'Adapted from `QueryBuilder/filters/LimitFilter`. A row-limit field that is min-clamped to 1 and can be disabled when the query isn\'t configured to use a limit.', + }, + }, + }, + render: () => { + const [limit, setLimit] = useState(10); + const [disabled, setDisabled] = useState(false); + return ( +
+
+ + + + + +
+
+ ); + }, +}; + +/** Max lines per row — compact spinner. */ +export const MaxLinesField: Story = { + parameters: { + docs: { + description: { + story: + 'Adapted from `OptionsMenu/MaxLinesField`. A compact, small-sized input with spinner controls for dense option menus.', + }, + }, + }, + render: () => { + const [value, setValue] = useState(5); + return ( +
+ + + + +
+ ); + }, +}; diff --git a/apps/docs/stories/intro.mdx b/apps/docs/stories/intro.mdx index df8970eb..91fa7b4c 100644 --- a/apps/docs/stories/intro.mdx +++ b/apps/docs/stories/intro.mdx @@ -76,6 +76,7 @@ import { Dialog } from '@signozhq/ui'; import { Drawer } from '@signozhq/ui'; import { DropdownMenu } from '@signozhq/ui'; import { Input } from '@signozhq/ui'; +import { InputNumber } from '@signozhq/ui'; import { Kbd } from '@signozhq/ui'; import { Pagination } from '@signozhq/ui'; import { Progress } from '@signozhq/ui'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 5cc034a8..73fcca88 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -167,6 +167,16 @@ "require": "./dist/input/index.cjs" } }, + "./input-number": { + "import": { + "types": "./dist/input-number/index.d.ts", + "import": "./dist/input-number/index.mjs" + }, + "require": { + "types": "./dist/input-number/index.d.cts", + "require": "./dist/input-number/index.cjs" + } + }, "./kbd": { "import": { "types": "./dist/kbd/index.d.ts", diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index b0cf178d..cf2703aa 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,6 +13,7 @@ export * from './dialog/index.js'; export * from './drawer/index.js'; export * from './dropdown-menu/index.js'; export * from './input/index.js'; +export * from './input-number/index.js'; export * from './kbd/index.js'; export * from './pagination/index.js'; export * from './pin-list/index.js'; diff --git a/packages/ui/src/input-number/index.ts b/packages/ui/src/input-number/index.ts new file mode 100644 index 00000000..1ef73a8b --- /dev/null +++ b/packages/ui/src/input-number/index.ts @@ -0,0 +1,2 @@ +export type * from './input-number.js'; +export { InputNumber } from './input-number.js'; diff --git a/packages/ui/src/input-number/input-number.forward-ref.test.tsx b/packages/ui/src/input-number/input-number.forward-ref.test.tsx new file mode 100644 index 00000000..5ef83225 --- /dev/null +++ b/packages/ui/src/input-number/input-number.forward-ref.test.tsx @@ -0,0 +1,123 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { InputNumber, type InputNumberRef } from './index.js'; + +describe('InputNumber', () => { + it('forwards an imperative ref with focus/blur/nativeElement', () => { + const ref = createRef(); + render(); + expect(ref.current).not.toBeNull(); + expect(ref.current?.nativeElement).toBeInstanceOf(HTMLInputElement); + ref.current?.focus(); + expect(document.activeElement).toBe(ref.current?.nativeElement); + ref.current?.blur(); + expect(document.activeElement).not.toBe(ref.current?.nativeElement); + }); + + it('emits parsed numeric values via onChange', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByTestId('num') as HTMLInputElement; + fireEvent.change(input, { target: { value: '42' } }); + expect(onChange).toHaveBeenLastCalledWith(42); + fireEvent.change(input, { target: { value: '' } }); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it('rounds to the configured precision', () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('num'), { target: { value: '1.234' } }); + expect(onChange).toHaveBeenLastCalledWith(1.23); + }); + + it('clamps to min/max on blur when changeOnBlur is true', () => { + const onChange = vi.fn(); + render(); + const input = screen.getByTestId('num') as HTMLInputElement; + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '50' } }); + expect(onChange).toHaveBeenLastCalledWith(50); + fireEvent.blur(input); + expect(onChange).toHaveBeenLastCalledWith(10); + }); + + it('steps the value with the keyboard', () => { + const onChange = vi.fn(); + const onStep = vi.fn(); + render( + + ); + const input = screen.getByTestId('num'); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + expect(onChange).toHaveBeenLastCalledWith(6); + expect(onStep).toHaveBeenLastCalledWith(6, expect.objectContaining({ type: 'up', emitter: 'keydown' })); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(onChange).toHaveBeenLastCalledWith(5); + }); + + it('fires onPressEnter on Enter and respects keyboard=false', () => { + const onPressEnter = vi.fn(); + const onChange = vi.fn(); + render( + + ); + const input = screen.getByTestId('num'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onPressEnter).toHaveBeenCalledTimes(1); + fireEvent.keyDown(input, { key: 'ArrowUp' }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('applies formatter for display and parser for input', () => { + const onChange = vi.fn(); + render( + `$ ${v}`} + parser={(v) => v.replace(/[^\d.-]/g, '')} + testId="num" + /> + ); + const input = screen.getByTestId('num') as HTMLInputElement; + expect(input.value).toBe('$ 1000'); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '$ 1234' } }); + expect(onChange).toHaveBeenLastCalledWith(1234); + }); + + it('renders controls and steps via click', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText('Increase value')); + expect(onChange).toHaveBeenLastCalledWith(4); + fireEvent.click(screen.getByLabelText('Decrease value')); + expect(onChange).toHaveBeenLastCalledWith(3); + }); + + it('disables the up control at max and the down control at min', () => { + render(); + expect(screen.getByLabelText('Increase value')).toBeDisabled(); + expect(screen.getByLabelText('Decrease value')).not.toBeDisabled(); + }); + + it('renders addonBefore and addonAfter wrappers', () => { + render( + unit} + addonAfter={GiB} + /> + ); + expect(screen.getByTestId('addon-before')).toBeInTheDocument(); + expect(screen.getByTestId('addon-after')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/input-number/input-number.module.scss b/packages/ui/src/input-number/input-number.module.scss new file mode 100644 index 00000000..6cf6bf25 --- /dev/null +++ b/packages/ui/src/input-number/input-number.module.scss @@ -0,0 +1,324 @@ +/* =========================================================================== + * InputNumber + * Structure: + * .input-number-root // only present when addonBefore/addonAfter set + * .input-number-addon[before] + * .input-number-wrapper // bordered wrapper around prefix + input + suffix + actions + * .input-number-prefix + * .input-number-input + * .input-number-suffix + * .input-number-actions + * .input-number-action + * .input-number-addon[after] + * =========================================================================== */ + +/* ---------------- Sizes (height + horizontal padding) -------------------- */ +$sizes: ( + small: ( + height: 1.5rem, + padding-x: var(--spacing-4, 0.5rem), + font-size: 12px, + ), + middle: ( + height: 2rem, + padding-x: var(--spacing-6, 0.75rem), + font-size: var(--periscope-font-size-base, 13px), + ), + large: ( + height: 2.5rem, + padding-x: var(--spacing-6, 0.75rem), + font-size: 14px, + ), +); + +/* ---------------- Root (addon container) --------------------------------- */ +.input-number-root { + display: inline-flex; + width: 100%; + align-items: stretch; + border-radius: var(--input-border-radius, calc(var(--radius) - 2px)); + + @each $name, $cfg in $sizes { + &[data-size='#{$name}'] { + height: map-get($cfg, height); + } + } + + &[data-disabled='true'] { + cursor: not-allowed; + opacity: var(--input-disabled-opacity, 0.5); + } +} + +/* ---------------- Wrapper (the bordered input box) ----------------------- */ +.input-number-wrapper { + display: inline-flex; + width: 100%; + align-items: center; + box-sizing: border-box; + border-radius: var(--input-border-radius, calc(var(--radius) - 2px)); + border: var(--input-border-width, 1px) var(--input-border-style, solid) + var(--input-border-color, var(--border)); + background-color: var(--input-background, transparent); + color: var(--input-foreground, inherit); + box-shadow: var(--input-box-shadow, 0 1px 2px 0 rgba(0, 0, 0, 0.05)); + transition: color, background-color, border-color, box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1); + gap: 0; + + @each $name, $cfg in $sizes { + &[data-size='#{$name}'] { + height: map-get($cfg, height); + font-size: map-get($cfg, font-size); + padding: 0 map-get($cfg, padding-x); + } + } + + /* Embedded inside an addon container — let the root own the height */ + .input-number-root & { + flex: 1 1 auto; + min-width: 0; + border-radius: 0; + height: 100%; + } + + &:hover:not([data-disabled='true']):not([data-readonly='true']) { + border-color: var(--input-hover-border-color, var(--border)); + } + + &[data-focused='true'] { + outline: var(--input-focus-outline-width, 2px) solid + var(--input-focus-outline-color, var(--ring)); + outline-offset: var(--input-focus-outline-offset, 2px); + border-color: var(--input-focus-border-color, var(--border)); + } + + &[data-disabled='true'] { + cursor: not-allowed; + opacity: var(--input-disabled-opacity, 0.5); + } + + /* Variants */ + &[data-variant='filled'] { + background-color: var(--input-filled-background, var(--muted, hsl(0 0% 96%))); + border-color: transparent; + } + &[data-variant='borderless'] { + border-color: transparent; + box-shadow: none; + } + &[data-variant='underlined'] { + border-radius: 0; + border-left-color: transparent; + border-right-color: transparent; + border-top-color: transparent; + box-shadow: none; + } + + /* Status */ + &[data-status='error'] { + border-color: var(--input-error-border-color, hsl(0 84% 60%)); + &[data-focused='true'] { + outline-color: var(--input-error-border-color, hsl(0 84% 60%)); + } + } + &[data-status='warning'] { + border-color: var(--input-warning-border-color, hsl(38 92% 50%)); + &[data-focused='true'] { + outline-color: var(--input-warning-border-color, hsl(38 92% 50%)); + } + } + + /* Out of range — surface warning border automatically */ + &[data-out-of-range='true']:not([data-status='error']):not([data-status='warning']) { + border-color: var(--input-warning-border-color, hsl(38 92% 50%)); + } + + /* Tighten padding when there's a prefix/suffix slot, so the slot contents + own their padding instead. Matches the existing Input wrapper behavior. */ + &[data-has-prefix='true'] { + padding-left: 0; + } + &[data-has-suffix='true'] { + padding-right: 0; + } + &[data-has-controls='true'] { + padding-right: 0; + } +} + +/* ---------------- Input element ----------------------------------------- */ +.input-number-input { + flex: 1 1 auto; + min-width: 0; + height: 100%; + background-color: transparent; + border: 0; + outline: none; + padding: 0; + color: inherit; + font: inherit; + + &::placeholder { + color: var(--input-placeholder-color, var(--muted-foreground)); + } + + &:disabled { + cursor: not-allowed; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + appearance: none; + margin: 0; + } +} + +/* ---------------- Prefix / Suffix --------------------------------------- */ +.input-number-prefix, +.input-number-suffix { + display: inline-flex; + align-items: center; + flex-shrink: 0; + height: 100%; + font-size: inherit; + color: inherit; + gap: var(--spacing-2, 0.5rem); +} + +.input-number-prefix { + padding-left: var(--spacing-6, 0.75rem); + padding-right: var(--spacing-2, 0.5rem); +} + +.input-number-suffix { + padding-left: var(--spacing-2, 0.5rem); + padding-right: var(--spacing-6, 0.75rem); +} + +/* ---------------- Actions (spinner controls) ---------------------------- */ +.input-number-actions { + display: inline-flex; + flex-direction: column; + align-self: stretch; + border-left: 1px solid var(--input-border-color, var(--border)); + flex-shrink: 0; + + &[data-mode='spinner'] { + flex-direction: row; + } +} + +.input-number-action { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1; + min-width: 1.5rem; + padding: 0 0.25rem; + background: transparent; + border: 0; + color: var(--muted-foreground, inherit); + cursor: pointer; + transition: background-color 100ms ease, color 100ms ease; + + &:hover:not(:disabled) { + background-color: var(--input-action-hover-background, rgba(0, 0, 0, 0.04)); + color: inherit; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + svg { + width: 0.875rem; + height: 0.875rem; + } +} + +.input-number-action-up { + border-bottom: 1px solid var(--input-border-color, var(--border)); + + .input-number-actions[data-mode='spinner'] & { + border-bottom: 0; + border-left: 1px solid var(--input-border-color, var(--border)); + order: 2; + } +} + +.input-number-action-down { + .input-number-actions[data-mode='spinner'] & { + order: 1; + } +} + +/* ---------------- Addons ------------------------------------------------ */ +.input-number-addon { + display: inline-flex; + align-items: center; + flex-shrink: 0; + padding: 0 var(--spacing-6, 0.75rem); + background-color: var(--input-addon-background, var(--muted, hsl(0 0% 96%))); + color: var(--input-addon-color, inherit); + border: var(--input-border-width, 1px) var(--input-border-style, solid) + var(--input-border-color, var(--border)); + white-space: nowrap; + + &[data-position='before'] { + border-right: 0; + border-top-left-radius: var(--input-border-radius, calc(var(--radius) - 2px)); + border-bottom-left-radius: var(--input-border-radius, calc(var(--radius) - 2px)); + } + + &[data-position='after'] { + border-left: 0; + border-top-right-radius: var(--input-border-radius, calc(var(--radius) - 2px)); + border-bottom-right-radius: var(--input-border-radius, calc(var(--radius) - 2px)); + } + + /* When an addon hosts a Select / Combobox / Button-like control, strip its + own border/background so it visually merges into the addon slot. */ + :global(.ant-select), + :global(.ant-select-selector), + [role='combobox'], + [data-radix-select-trigger], + [data-slot='select-trigger'], + [data-slot='combobox-trigger'] { + border: 0 !important; + background: transparent !important; + box-shadow: none !important; + height: 100% !important; + } +} + +/* When the wrapper is inside the addon root, drop its border on the addon side + so the seam between the addon and the input is a single line. */ +.input-number-root .input-number-wrapper { + border-radius: 0; +} +.input-number-root:has(.input-number-addon[data-position='before']) .input-number-wrapper { + border-left: 0; +} +.input-number-root:has(.input-number-addon[data-position='after']) .input-number-wrapper { + border-right: 0; +} + +/* Style for embedded form controls inside prefix/suffix (e.g. Select) so they + blend visually with the input. */ +.input-number-prefix, +.input-number-suffix { + :global(.ant-select), + :global(.ant-select-selector), + [role='combobox'], + [data-radix-select-trigger], + [data-slot='select-trigger'], + [data-slot='combobox-trigger'] { + border: 0 !important; + background: transparent !important; + box-shadow: none !important; + height: 100% !important; + min-width: 0; + } +} diff --git a/packages/ui/src/input-number/input-number.tsx b/packages/ui/src/input-number/input-number.tsx new file mode 100644 index 00000000..3f28ff65 --- /dev/null +++ b/packages/ui/src/input-number/input-number.tsx @@ -0,0 +1,483 @@ +import { ChevronDown, ChevronUp } from '@signozhq/icons'; +import * as React from 'react'; +import { cn, type Simplify } from '../lib/utils.js'; +import styles from './input-number.module.scss'; + +export type InputNumberValue = number | null; + +export type InputNumberSize = 'small' | 'middle' | 'large'; +export type InputNumberVariant = 'outlined' | 'filled' | 'borderless' | 'underlined'; +export type InputNumberStatus = 'error' | 'warning'; +export type InputNumberMode = 'input' | 'spinner'; + +export type InputNumberControls = + | boolean + | { upIcon?: React.ReactNode; downIcon?: React.ReactNode }; + +export type InputNumberRef = { + focus: (options?: { + preventScroll?: boolean; + cursor?: 'start' | 'end' | 'all'; + }) => void; + blur: () => void; + nativeElement: HTMLInputElement | null; +}; + +export type InputNumberStepInfo = { + offset: number; + type: 'up' | 'down'; + emitter: 'handler' | 'keydown' | 'wheel'; +}; + +type BaseProps = { + value?: InputNumberValue; + defaultValue?: InputNumberValue; + onChange?: (value: InputNumberValue) => void; + + min?: number; + max?: number; + step?: number | string; + precision?: number; + decimalSeparator?: string; + + formatter?: ( + value: number | string, + info: { userTyping: boolean; input: string } + ) => string; + parser?: (displayValue: string) => string; + + prefix?: React.ReactNode; + suffix?: React.ReactNode; + addonBefore?: React.ReactNode; + addonAfter?: React.ReactNode; + + controls?: InputNumberControls; + mode?: InputNumberMode; + + variant?: InputNumberVariant; + size?: InputNumberSize; + status?: InputNumberStatus; + + changeOnBlur?: boolean; + changeOnWheel?: boolean; + keyboard?: boolean; + + readOnly?: boolean; + disabled?: boolean; + autoFocus?: boolean; + + placeholder?: string; + className?: string; + rootClassName?: string; + style?: React.CSSProperties; + id?: string; + name?: string; + testId?: string; + + onPressEnter?: (event: React.KeyboardEvent) => void; + onStep?: (value: number, info: InputNumberStepInfo) => void; + onBlur?: React.FocusEventHandler; + onFocus?: React.FocusEventHandler; + onKeyDown?: React.KeyboardEventHandler; + 'aria-label'?: string; +}; + +export type InputNumberProps = Simplify; + +const DEFAULT_STEP = 1; + +const isNumberLike = (value: unknown): value is number => + typeof value === 'number' && !Number.isNaN(value); + +const clamp = (value: number, min: number | undefined, max: number | undefined): number => { + let next = value; + if (max !== undefined && next > max) next = max; + if (min !== undefined && next < min) next = min; + return next; +}; + +const isOutOfRange = ( + value: InputNumberValue, + min: number | undefined, + max: number | undefined +): boolean => { + if (!isNumberLike(value)) return false; + if (max !== undefined && value > max) return true; + if (min !== undefined && value < min) return true; + return false; +}; + +const roundToPrecision = (value: number, precision: number | undefined): number => { + if (precision === undefined) return value; + const factor = 10 ** precision; + return Math.round(value * factor) / factor; +}; + +const formatForDisplay = ( + value: InputNumberValue, + precision: number | undefined, + decimalSeparator: string | undefined, + formatter: InputNumberProps['formatter'] +): string => { + if (value === null || value === undefined) return ''; + let display: string = + precision !== undefined ? value.toFixed(precision) : String(value); + if (decimalSeparator) { + display = display.replace('.', decimalSeparator); + } + if (formatter) { + display = formatter(value, { userTyping: false, input: display }); + } + return display; +}; + +const parseFromInput = ( + raw: string, + decimalSeparator: string | undefined, + parser: InputNumberProps['parser'] +): InputNumberValue => { + let work = raw; + if (parser) work = parser(work); + if (decimalSeparator) work = work.replaceAll(decimalSeparator, '.'); + if (work === '' || work === '-' || work === '.') return null; + const parsed = Number(work); + return Number.isNaN(parsed) ? null : parsed; +}; + +const resolveControls = ( + controls: InputNumberControls | undefined +): { enabled: boolean; upIcon: React.ReactNode; downIcon: React.ReactNode } => { + if (controls === false) { + return { enabled: false, upIcon: null, downIcon: null }; + } + if (controls && typeof controls === 'object') { + return { + enabled: true, + upIcon: controls.upIcon ??