diff --git a/examples/storybook/src/stories/NumberField.stories.tsx b/examples/storybook/src/stories/NumberField.stories.tsx new file mode 100644 index 0000000..3a510cf --- /dev/null +++ b/examples/storybook/src/stories/NumberField.stories.tsx @@ -0,0 +1,233 @@ +import * as React from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { NumberField, type NumberFieldProps } from "@synopsisapp/symbiosis-ui"; + +const meta: Meta = { + title: "Components/NumberField", + component: NumberField, + tags: ["autodocs"], + argTypes: { + label: { + control: { + type: "text", + }, + description: "Label for the NumberField", + table: { + type: { + summary: "string", + }, + }, + }, + error: { + control: { + type: "text", + }, + description: "Error message for the input field", + table: { + type: { + summary: "string", + }, + }, + }, + required: { + control: { + type: "boolean", + }, + description: "Whether the input field is required", + table: { + type: { + summary: "boolean", + }, + }, + }, + value: { + control: { + type: "number", + }, + description: "Value of the NumberField", + table: { + type: { + summary: "number", + }, + }, + }, + hint: { + control: { + type: "text", + }, + description: "Hint text for the NumberField", + table: { + type: { + summary: "string", + }, + }, + }, + placeholder: { + control: { + type: "text", + }, + description: "Placeholder text for the NumberField", + table: { + type: { + summary: "string", + }, + }, + }, + disabled: { + control: { + type: "boolean", + }, + description: "Whether the NumberField is disabled", + table: { + type: { + summary: "boolean", + }, + }, + }, + min: { + control: { + type: "number", + }, + description: "Minimum value allowed", + table: { + type: { + summary: "number", + }, + }, + }, + max: { + control: { + type: "number", + }, + description: "Maximum value allowed", + table: { + type: { + summary: "number", + }, + }, + }, + step: { + control: { + type: "number", + }, + description: "Step value for increments/decrements", + table: { + type: { + summary: "number", + }, + defaultValue: { summary: "1" }, + }, + }, + size: { + control: { + type: "select", + options: ["small-100", "small-200", "base", "large-100"], + }, + description: "Size of the NumberField", + table: { + defaultValue: { summary: "base" }, + type: { + summary: "small-100 | small-200 | base | large-100", + }, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => , + args: { + label: "Default NumberField", + placeholder: "Enter number", + }, +}; + +export const WithMinMax: Story = { + render: (args) => , + args: { + label: "NumberField with Min/Max", + min: 0, + max: 100, + hint: "Value must be between 0 and 100", + }, +}; + +export const WithError: Story = { + render: (args) => , + args: { + label: "NumberField with Error", + error: "This field is required", + required: true, + }, +}; + +export const CustomStep: Story = { + render: (args) => , + args: { + label: "Custom Step NumberField", + step: 0.5, + hint: "Increments/decrements by 0.5", + }, +}; + +export const Disabled: Story = { + render: (args) => , + args: { + label: "Disabled NumberField", + disabled: true, + value: 42, + }, +}; + +export const DifferentSizes: Story = { + render: (args) => ( + <> + + + + + + ), + args: { + placeholder: "Enter number", + }, +}; + +export const Controlled: Story = { + render: (args) => { + const [value, setValue] = React.useState(0); + + return setValue(value ?? 0)} />; + }, + args: { + label: "Controlled NumberField", + placeholder: "Enter number", + }, + parameters: { + docs: { + source: { + code: ` +const ControlledNumberField = () => { + const [value, setValue] = React.useState(0); + + return ; +} +`, + language: "tsx", + type: "code", + }, + }, + }, +}; + +export const CustomStyled: Story = { + render: (args) => , + args: { + label: "Custom Styled NumberField", + placeholder: "Enter number", + hint: "This is a custom styled hint", + }, +}; diff --git a/packages/ui/internal-assets/symbiosis-plus.svg b/packages/ui/internal-assets/symbiosis-plus.svg new file mode 100644 index 0000000..7a275f8 --- /dev/null +++ b/packages/ui/internal-assets/symbiosis-plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/components/Charts/helpers/calculateCountForDataOnDates.test.ts b/packages/ui/src/components/Charts/helpers/calculateCountForDataOnDates.test.ts index f9b20d2..13b725b 100644 --- a/packages/ui/src/components/Charts/helpers/calculateCountForDataOnDates.test.ts +++ b/packages/ui/src/components/Charts/helpers/calculateCountForDataOnDates.test.ts @@ -172,7 +172,7 @@ describe("calculateCountForDataOnDates", () => { expect(result[61]).toEqual({ date: "2023-03-01T00:00:00.000Z", model1: 4, model2: 4 }); }); - it("handles(?) dates with timezone information", () => { + it("treats all dates as UTC, ignoring timezone information", () => { const dataWithTimezones = { model1: ["2023-01-01T12:00:00+02:00", "2023-01-02T00:00:00Z", "2023-01-03T15:30:00-05:00"], model2: ["2023-01-01T23:59:59+01:00", "2023-01-02T01:00:00-08:00", "2023-01-03T00:00:00Z"], @@ -186,8 +186,8 @@ describe("calculateCountForDataOnDates", () => { }); expect(result).toEqual([ - { date: "2023-01-01T00:00:00.000Z", model1: 1, model2: 1 }, - { date: "2023-01-02T00:00:00.000Z", model1: 1, model2: 1 }, + { date: "2023-01-01T00:00:00.000Z", model1: 1, model2: 0 }, + { date: "2023-01-02T00:00:00.000Z", model1: 1, model2: 2 }, { date: "2023-01-03T00:00:00.000Z", model1: 1, model2: 1 }, ]); }); diff --git a/packages/ui/src/components/Icon/iconTypes.d.ts b/packages/ui/src/components/Icon/iconTypes.d.ts index 05da947..15e6c61 100644 --- a/packages/ui/src/components/Icon/iconTypes.d.ts +++ b/packages/ui/src/components/Icon/iconTypes.d.ts @@ -14,5 +14,6 @@ declare namespace SymbiosisUI { | "symbiosis-minus" | "symbiosis-check" | "symbiosis-exclamation-circle" - | "symbiosis-help-circle"; + | "symbiosis-help-circle" + | "symbiosis-plus"; } diff --git a/packages/ui/src/components/NumberField/index.tsx b/packages/ui/src/components/NumberField/index.tsx new file mode 100644 index 0000000..5723726 --- /dev/null +++ b/packages/ui/src/components/NumberField/index.tsx @@ -0,0 +1,193 @@ +import * as React from "react"; +import { Icon } from "../Icon"; +import { IconButton } from "../IconButton"; +import { cn } from "../../utils/cn"; +import { Text } from "../Text"; +import { input, inputWrapper, inputLabel } from "./styles"; +import type { NumberFieldProps } from "./types"; + +export const NumberField = ({ + error, + required, + value, + hint, + placeholder, + disabled, + onChange, + min, + max, + step = 1, + icon, + defaultValue, + label, + name, + id, + onBlur, + className, +}: NumberFieldProps) => { + const formId = id ?? label ?? ""; + const ref = React.useRef(null); + + const increment = () => { + if (disabled) { + return; + } + + if (ref.current) { + const newValue = Number(ref.current.value) + step; + + if (max !== undefined && newValue > max) { + return; + } + + onChange?.(newValue); + ref.current.value = String(newValue); + } + }; + + const decrement = () => { + if (disabled) { + return; + } + + if (ref.current) { + const newValue = Number(ref.current.value) - step; + if (min !== undefined && newValue < min) { + return; + } + + onChange?.(newValue); + ref.current.value = String(newValue); + } + }; + + const hasError = Boolean(error); + + return ( +
{ + const value = ref.current?.value ? Number(ref.current?.value) : undefined; + + if (onBlur && value !== undefined) { + onBlur(value); + } + }} + > + {label && ( + + )} +
+
+
+ decrement()} + variant="ghost" + tone="monochrome-dark" + size="small-100" + isDisabled={disabled} + className="focus:before:border-none" + /> + {icon && ( + + )} + { + const number = Number(e.target.value); + + if (Number.isNaN(number)) { + return; + } + + if (min !== undefined && number < min) { + onChange?.(min); + e.target.value = String(min); + return; + } + + if (max !== undefined && number > max) { + onChange?.(max); + e.target.value = String(max); + return; + } + + onChange?.(number); + }} + /> + increment()} + variant="ghost" + tone="monochrome-dark" + size="small-100" + isDisabled={disabled} + className="focus:before:border-none" + /> +
+
+
+ {hasError && ( +
+ + + {error} + +
+ )} + {!hasError && hint && ( +
+ + + {hint} + +
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/NumberField/styles.ts b/packages/ui/src/components/NumberField/styles.ts new file mode 100644 index 0000000..7fa4067 --- /dev/null +++ b/packages/ui/src/components/NumberField/styles.ts @@ -0,0 +1,82 @@ +import { cn } from "../../utils/cn"; +import { cva, type VariantProps } from "class-variance-authority"; +import { buttonHeightSizing } from "../Button/styles"; +import { text } from "../Text/styles"; + +export interface InputWrapperVariants extends VariantProps {} +export const inputWrapper = ({ variant = "default", ...rest }: InputWrapperVariants) => + cn(inputWrapperCva({ variant, ...rest })); +const inputWrapperCva = cva( + ["relative m-0 outline-none transition-all", "focus-within:ring-2 focus-within:ring-offset-1 rounded-md"], + { + variants: { + size: { + "small-200": [buttonHeightSizing({ size: "small-200" }), text({ variant: "body-small-200" }), "my-0"], + "small-100": [buttonHeightSizing({ size: "small-100" }), text({ variant: "body-small-100" }), "my-0"], + base: [buttonHeightSizing({ size: "base" }), text({ variant: "body-base" }), "my-0"], + "large-100": [buttonHeightSizing({ size: "large-100" }), text({ variant: "body-large-100" }), "my-0"], + }, + variant: { + default: ["focus-within:ring-mainColors-base"], + error: ["focus-within:ring-red-500", "text-red-500"], + }, + }, + }, +); + +export const input = ({ variant = "default", ...rest }: InputVariants) => cn(inputCva({ variant, ...rest })); +export interface InputVariants extends VariantProps {} +const inputCva = cva( + [ + "justify-center p-3 rounded-md items-center border-1 bg-inherit", + "relative", + "!m-0 flex items-center outline-none", + "border border-solid", + ], + { + variants: { + variant: { + default: ["border-gray-400", "group-focus-within:border-mainColors-base"], + error: ["border-red-500", "text-red-500"], + }, + size: { + "small-200": [buttonHeightSizing({ size: "small-200" }), "p-2"], + "small-100": [buttonHeightSizing({ size: "small-100" })], + base: [buttonHeightSizing({ size: "base" })], + "large-100": [buttonHeightSizing({ size: "large-100" })], + }, + }, + compoundVariants: [ + { + variant: "default", + size: ["small-200"], + className: [text({ variant: "body-small-200" })], + }, + { + variant: "default", + size: ["small-100", "base"], + className: [text({ variant: "body-small-100" })], + }, + { + variant: "default", + size: ["large-100"], + className: [text({ variant: "body-base" })], + }, + ], + }, +); + +export interface InputLabelVariants extends VariantProps {} +const inputLabelCva = cva([], { + variants: { + size: { + "small-200": [text({ variant: "body-small-200" })], + "small-100": [text({ variant: "body-small-100" })], + base: [text({ variant: "body-base" })], + "large-100": [text({ variant: "body-large-100" })], + }, + }, +}); + +export const inputLabel = ({ size = "small-100", ...rest }: InputLabelVariants) => cn(inputLabelCva({ size, ...rest })); + diff --git a/packages/ui/src/components/NumberField/types.ts b/packages/ui/src/components/NumberField/types.ts new file mode 100644 index 0000000..1e1fb72 --- /dev/null +++ b/packages/ui/src/components/NumberField/types.ts @@ -0,0 +1,23 @@ +import type { IconProps } from "../Icon/types"; +import type { Sizes } from "../../designSystemTokens"; + +export type NumberFieldProps = { + label?: string; + error?: string; + required?: boolean; + value?: number; + hint?: string; + placeholder?: string; + icon?: IconProps["name"]; + disabled?: boolean; + onChange?: (value?: number) => void; + onBlur?: (value?: number) => void; + name?: string; + defaultValue?: number; + size?: Sizes; + id?: string; + className?: string; + min?: number; + max?: number; + step?: number; +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 4c19a63..3b82e3a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -81,3 +81,7 @@ export type { SymbiosisAreaChartProps } from "./components/SymbiosisAreaChart/ty export { TextField } from "./components/TextField"; export * from "./components/TextField/types"; export type { TextFieldProps } from "./components/TextField/types"; + +export { NumberField } from "./components/NumberField"; +export * from "./components/NumberField/types"; +export type { NumberFieldProps } from "./components/NumberField/types"; diff --git a/packages/ui/src/tailwind.css b/packages/ui/src/tailwind.css index 879d1f4..f092d4e 100644 --- a/packages/ui/src/tailwind.css +++ b/packages/ui/src/tailwind.css @@ -10,4 +10,15 @@ @apply bg-background text-foreground; font-feature-settings: "rlig" 1, "calt" 1; } + .hide-internal-input-elements { + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + } + + /* Works for Firefox */ + &[type="number"] { + -moz-appearance: textfield; + } + } }