From f39a6f1ff28a6efeeebce66f26a02b5521b4d2fa Mon Sep 17 00:00:00 2001 From: Dimitris Giannaris Date: Tue, 12 Nov 2024 14:48:38 +0200 Subject: [PATCH] Introduce NumberField component to Symbiosis (#20) * test(Charts): clarify test case for date handling with timezones The test case for handling dates with timezone information has been updated to explicitly state that the current implementation does not handle timezone information correctly. This change provides clearer expectations for the behavior of the calculateCountForDataOnDates function and serves as a reminder that timezone handling is a known limitation or area for potential improvement in the future. * feat(ui): add symbiosis-plus icon Adds a new SVG icon 'symbiosis-plus' to the UI package and updates the icon type definitions to include this new icon. This enhancement expands the available icon set, providing more options for visual elements in the user interface. * feat(ui): add NumberField component Introduces a new NumberField component to the UI package, providing a customizable input for numeric values. This component includes features such as increment/decrement buttons, min/max value constraints, and error handling. It enhances the form input options available in the UI library, allowing for more precise numeric data entry and validation. * style(tailwind.css): add utility class to hide input spinner buttons Adds a new utility class 'hide-internal-input-elements' to remove the default spinner buttons from number input fields across different browsers. This improves the consistency of input field appearance and allows for more control over the UI design. * feat(storybook): add NumberField component stories Introduces a new set of stories for the NumberField component in Storybook. This addition enhances the documentation and testing capabilities for the NumberField, showcasing various configurations and use cases. The stories include examples of default usage, min/max constraints, error states, custom step values, disabled state, different sizes, controlled component behavior, and custom styling options. * chore: refine testcase calculateCountForDataOnDates to make it showcase current functionality --- .../src/stories/NumberField.stories.tsx | 233 ++++++++++++++++++ .../ui/internal-assets/symbiosis-plus.svg | 3 + .../calculateCountForDataOnDates.test.ts | 6 +- .../ui/src/components/Icon/iconTypes.d.ts | 3 +- .../ui/src/components/NumberField/index.tsx | 193 +++++++++++++++ .../ui/src/components/NumberField/styles.ts | 82 ++++++ .../ui/src/components/NumberField/types.ts | 23 ++ packages/ui/src/index.ts | 4 + packages/ui/src/tailwind.css | 11 + 9 files changed, 554 insertions(+), 4 deletions(-) create mode 100644 examples/storybook/src/stories/NumberField.stories.tsx create mode 100644 packages/ui/internal-assets/symbiosis-plus.svg create mode 100644 packages/ui/src/components/NumberField/index.tsx create mode 100644 packages/ui/src/components/NumberField/styles.ts create mode 100644 packages/ui/src/components/NumberField/types.ts 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; + } + } }