diff --git a/packages/ui/package.json b/packages/ui/package.json index 48802c4ee..38506f5ec 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", "clsx": "^2.1.1", diff --git a/packages/ui/src/Slider/index.stories.tsx b/packages/ui/src/Slider/index.stories.tsx new file mode 100644 index 000000000..b82f3196a --- /dev/null +++ b/packages/ui/src/Slider/index.stories.tsx @@ -0,0 +1,24 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { Slider } from './index'; + +const meta: Meta = { + component: Slider, + tags: ['autodocs', '!dev'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + min: 0, + max: 10, + step: 1, + defaultValue: 5, + leftLabel: 'label', + rightLabel: 'label', + showValue: true, + showFill: true, + }, +}; diff --git a/packages/ui/src/Slider/index.test.tsx b/packages/ui/src/Slider/index.test.tsx new file mode 100644 index 000000000..3c6f9fe43 --- /dev/null +++ b/packages/ui/src/Slider/index.test.tsx @@ -0,0 +1,34 @@ +import { render, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { Slider } from '.'; + +window.ResizeObserver = vi.fn().mockImplementation(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn(), +})); + +describe('', () => { + it('renders correctly', () => { + const { container } = render( + , + ); + + expect(container).toHaveTextContent('left'); + expect(container).toHaveTextContent('right'); + }); + + it('handles onChange correctly', () => { + const onChange = vi.fn(); + + const { container } = render( + , + ); + + const slider = container.querySelector('[role="slider"]')!; + fireEvent.focus(slider); + fireEvent.keyDown(slider, { key: 'ArrowRight' }); + + expect(onChange).toHaveBeenCalledWith(6); + }); +}); diff --git a/packages/ui/src/Slider/index.tsx b/packages/ui/src/Slider/index.tsx new file mode 100644 index 000000000..1c285eadd --- /dev/null +++ b/packages/ui/src/Slider/index.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import * as RadixSlider from '@radix-ui/react-slider'; +import { ThemeColor, getThemeColorClass } from '../utils/color'; +import cn from 'clsx'; + +interface SliderProps { + min?: number; + max?: number; + step?: number; + defaultValue?: number; + onChange?: (value: number) => void; + leftLabel?: string; + rightLabel?: string; + showValue?: boolean; + valueDetails?: string; + focusedOutlineColor?: ThemeColor; + showTrackGaps?: boolean; + trackGapBackground?: ThemeColor; + showFill?: boolean; + fontSize?: string; + disabled?: boolean; +} + +export const Slider: React.FC = ({ + min = 0, + max = 100, + step = 1, + defaultValue = 0, + onChange, + leftLabel, + rightLabel, + showValue = true, + showFill = false, + showTrackGaps = true, + trackGapBackground = 'base.black', + focusedOutlineColor = 'action.neutralFocusOutline', + valueDetails, + fontSize = 'textXs', + disabled = false, +}) => { + const [value, setValue] = useState(defaultValue); + const handleValueChange = (newValue: number[]) => { + const updatedValue = newValue[0] ?? defaultValue; + setValue(updatedValue); + onChange?.(updatedValue); + }; + + const totalSteps = (max - min) / step; + + return ( +
+ {(!!leftLabel || !!rightLabel) && ( +
+
{leftLabel}
+
{rightLabel}
+
+ )} + + + {showFill && ( + + )} +
+ {showTrackGaps && + Array.from({ length: totalSteps + 1 }) + .map((_, i): number => (i / totalSteps) * 100) + .map(left => { + return ( +
+ ); + })} +
+ + + + {showValue && ( +
+
{value}
+ {valueDetails &&
ยท {valueDetails}
} +
+ )} +
+ ); +}; diff --git a/packages/ui/src/utils/color.ts b/packages/ui/src/utils/color.ts index b7e955c55..45d23d15d 100644 --- a/packages/ui/src/utils/color.ts +++ b/packages/ui/src/utils/color.ts @@ -30,81 +30,71 @@ export const getThemeColor = (color: ThemeColor): string => { } }; +/** Helper function to generate class names based on a consistent pattern. */ +const generateClassNames = (base: string): [string, string, string] => { + return [`text-${base}`, `bg-${base}`, `outline-${base}`]; +}; + /** This mapper class is needed to help Tailwind statically analyze the classes that could * be produced from the `getThemeColorClass` function. */ -export const COLOR_CLASS_MAP: Record = { - 'neutral.main': ['text-neutral-main', 'bg-neutral-main'], - 'neutral.light': ['text-neutral-light', 'bg-neutral-light'], - 'neutral.dark': ['text-neutral-dark', 'bg-neutral-dark'], - 'neutral.contrast': ['text-neutral-contrast', 'bg-neutral-contrast'], - 'primary.main': ['text-primary-main', 'bg-primary-main'], - 'primary.light': ['text-primary-light', 'bg-primary-light'], - 'primary.dark': ['text-primary-dark', 'bg-primary-dark'], - 'primary.contrast': ['text-primary-contrast', 'bg-primary-contrast'], - 'secondary.main': ['text-secondary-main', 'bg-secondary-main'], - 'secondary.light': ['text-secondary-light', 'bg-secondary-light'], - 'secondary.dark': ['text-secondary-dark', 'bg-secondary-dark'], - 'secondary.contrast': ['text-secondary-contrast', 'bg-secondary-contrast'], - 'unshield.main': ['text-unshield-main', 'bg-unshield-main'], - 'unshield.light': ['text-unshield-light', 'bg-unshield-light'], - 'unshield.dark': ['text-unshield-dark', 'bg-unshield-dark'], - 'unshield.contrast': ['text-unshield-contrast', 'bg-unshield-contrast'], - 'destructive.main': ['text-destructive-main', 'bg-destructive-main'], - 'destructive.light': ['text-destructive-light', 'bg-destructive-light'], - 'destructive.dark': ['text-destructive-dark', 'bg-destructive-dark'], - 'destructive.contrast': ['text-destructive-contrast', 'bg-destructive-contrast'], - 'caution.main': ['text-caution-main', 'bg-caution-main'], - 'caution.light': ['text-caution-light', 'bg-caution-light'], - 'caution.dark': ['text-caution-dark', 'bg-caution-dark'], - 'caution.contrast': ['text-caution-contrast', 'bg-caution-contrast'], - 'success.main': ['text-success-main', 'bg-success-main'], - 'success.light': ['text-success-light', 'bg-success-light'], - 'success.dark': ['text-success-dark', 'bg-success-dark'], - 'success.contrast': ['text-success-contrast', 'bg-success-contrast'], - 'base.black': ['text-base-black', 'bg-base-black'], - 'base.white': ['text-base-white', 'bg-base-white'], - 'base.transparent': ['text-base-transparent', 'bg-base-transparent'], - 'text.primary': ['text-text-primary', 'bg-text-primary'], - 'text.secondary': ['text-text-secondary', 'bg-text-secondary'], - 'text.muted': ['text-text-muted', 'bg-text-muted'], - 'text.special': ['text-text-special', 'bg-text-special'], - 'action.hoverOverlay': ['text-action-hoverOverlay', 'bg-action-hoverOverlay'], - 'action.activeOverlay': ['text-action-activeOverlay', 'bg-action-activeOverlay'], - 'action.disabledOverlay': ['text-action-disabledOverlay', 'bg-action-disabledOverlay'], - 'action.primaryFocusOutline': [ - 'text-action-primaryFocusOutline', - 'bg-action-primaryFocusOutline', - ], - 'action.secondaryFocusOutline': [ - 'text-action-secondaryFocusOutline', - 'bg-action-secondaryFocusOutline', - ], - 'action.unshieldFocusOutline': [ - 'text-action-unshieldFocusOutline', - 'bg-action-unshieldFocusOutline', - ], - 'action.neutralFocusOutline': [ - 'text-action-neutralFocusOutline', - 'bg-action-neutralFocusOutline', - ], - 'action.destructiveFocusOutline': [ - 'text-action-destructiveFocusOutline', - 'bg-action-destructiveFocusOutline', - ], - 'other.tonalStroke': ['text-other-tonalStroke', 'bg-other-tonalStroke'], - 'other.tonalFill5': ['text-other-tonalFill5', 'bg-other-tonalFill5'], - 'other.tonalFill10': ['text-other-tonalFill10', 'bg-other-tonalFill10'], - 'other.solidStroke': ['text-other-solidStroke', 'bg-other-solidStroke'], - 'other.dialogBackground': ['text-other-dialogBackground', 'bg-other-dialogBackground'], - 'other.overlay': ['text-other-overlay', 'bg-other-overlay'], +export const COLOR_CLASS_MAP: Record = { + 'neutral.main': generateClassNames('neutral-main'), + 'neutral.light': generateClassNames('neutral-light'), + 'neutral.dark': generateClassNames('neutral-dark'), + 'neutral.contrast': generateClassNames('neutral-contrast'), + 'primary.main': generateClassNames('primary-main'), + 'primary.light': generateClassNames('primary-light'), + 'primary.dark': generateClassNames('primary-dark'), + 'primary.contrast': generateClassNames('primary-contrast'), + 'secondary.main': generateClassNames('secondary-main'), + 'secondary.light': generateClassNames('secondary-light'), + 'secondary.dark': generateClassNames('secondary-dark'), + 'secondary.contrast': generateClassNames('secondary-contrast'), + 'unshield.main': generateClassNames('unshield-main'), + 'unshield.light': generateClassNames('unshield-light'), + 'unshield.dark': generateClassNames('unshield-dark'), + 'unshield.contrast': generateClassNames('unshield-contrast'), + 'destructive.main': generateClassNames('destructive-main'), + 'destructive.light': generateClassNames('destructive-light'), + 'destructive.dark': generateClassNames('destructive-dark'), + 'destructive.contrast': generateClassNames('destructive-contrast'), + 'caution.main': generateClassNames('caution-main'), + 'caution.light': generateClassNames('caution-light'), + 'caution.dark': generateClassNames('caution-dark'), + 'caution.contrast': generateClassNames('caution-contrast'), + 'success.main': generateClassNames('success-main'), + 'success.light': generateClassNames('success-light'), + 'success.dark': generateClassNames('success-dark'), + 'success.contrast': generateClassNames('success-contrast'), + 'base.black': generateClassNames('base-black'), + 'base.white': generateClassNames('base-white'), + 'base.transparent': generateClassNames('base-transparent'), + 'text.primary': generateClassNames('text-primary'), + 'text.secondary': generateClassNames('text-secondary'), + 'text.muted': generateClassNames('text-muted'), + 'text.special': generateClassNames('text-special'), + 'action.hoverOverlay': generateClassNames('action-hoverOverlay'), + 'action.activeOverlay': generateClassNames('action-activeOverlay'), + 'action.disabledOverlay': generateClassNames('action-disabledOverlay'), + 'action.primaryFocusOutline': generateClassNames('action-primaryFocusOutline'), + 'action.secondaryFocusOutline': generateClassNames('action-secondaryFocusOutline'), + 'action.unshieldFocusOutline': generateClassNames('action-unshieldFocusOutline'), + 'action.neutralFocusOutline': generateClassNames('action-neutralFocusOutline'), + 'action.destructiveFocusOutline': generateClassNames('action-destructiveFocusOutline'), + 'other.tonalStroke': generateClassNames('other-tonalStroke'), + 'other.tonalFill5': generateClassNames('other-tonalFill5'), + 'other.tonalFill10': generateClassNames('other-tonalFill10'), + 'other.solidStroke': generateClassNames('other-solidStroke'), + 'other.dialogBackground': generateClassNames('other-dialogBackground'), + 'other.overlay': generateClassNames('other-overlay'), }; /** * Takes a color string in the format of `primary.light` and - * returns the tailwind classes for text and background. + * returns the tailwind classes for text, background, and outline. */ export const getThemeColorClass = (color: ThemeColor) => { - const mapped = COLOR_CLASS_MAP[color] as [string, string] | undefined; + const mapped = COLOR_CLASS_MAP[color] as [string, string, string] | undefined; if (!mapped) { throw new Error(`Color "${color}" does not exist`); } @@ -112,5 +102,6 @@ export const getThemeColorClass = (color: ThemeColor) => { return { text: mapped[0], bg: mapped[1], + outline: mapped[2], }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5521af9d7..a77548699 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -644,6 +644,9 @@ importers: '@radix-ui/react-radio-group': specifier: ^1.2.0 version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': + specifier: ^1.1.2 + version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tabs': specifier: ^1.0.4 version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -918,7 +921,7 @@ importers: version: 8.1.11(react@18.3.1) '@storybook/addon-postcss': specifier: ^2.0.0 - version: 2.0.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)) + version: 2.0.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))) '@storybook/blocks': specifier: ^8.4.2 version: 8.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.4.2(bufferutil@4.0.8)(prettier@3.3.3)(utf-8-validate@5.0.10)) @@ -14732,13 +14735,13 @@ snapshots: storybook: 8.4.2(bufferutil@4.0.8)(prettier@3.3.3)(utf-8-validate@5.0.10) ts-dedent: 2.2.0 - '@storybook/addon-postcss@2.0.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5))': + '@storybook/addon-postcss@2.0.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11)))': dependencies: '@storybook/node-logger': 6.5.16 - css-loader: 3.6.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)) + css-loader: 3.6.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))) postcss: 7.0.39 - postcss-loader: 4.3.0(postcss@7.0.39)(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)) - style-loader: 1.3.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)) + postcss-loader: 4.3.0(postcss@7.0.39)(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))) + style-loader: 1.3.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))) transitivePeerDependencies: - webpack @@ -16990,7 +16993,7 @@ snapshots: css-color-keywords@1.0.0: {} - css-loader@3.6.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)): + css-loader@3.6.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))): dependencies: camelcase: 5.3.1 cssesc: 3.0.0 @@ -17005,7 +17008,7 @@ snapshots: postcss-value-parser: 4.2.0 schema-utils: 2.7.1 semver: 6.3.1 - webpack: 5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5) + webpack: 5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11)) css-to-react-native@3.2.0: dependencies: @@ -19417,7 +19420,7 @@ snapshots: postcss: 8.4.39 ts-node: 10.9.2(@swc/core@1.6.13(@swc/helpers@0.5.11))(@types/node@22.8.6)(typescript@5.5.3) - postcss-loader@4.3.0(postcss@7.0.39)(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)): + postcss-loader@4.3.0(postcss@7.0.39)(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))): dependencies: cosmiconfig: 7.1.0 klona: 2.0.6 @@ -19425,7 +19428,7 @@ snapshots: postcss: 7.0.39 schema-utils: 3.3.0 semver: 7.6.2 - webpack: 5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5) + webpack: 5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11)) postcss-modules-extract-imports@2.0.0: dependencies: @@ -20404,11 +20407,11 @@ snapshots: dependencies: js-tokens: 9.0.0 - style-loader@1.3.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)): + style-loader@1.3.0(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))): dependencies: loader-utils: 2.0.4 schema-utils: 2.7.1 - webpack: 5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5) + webpack: 5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11)) styled-components@6.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: @@ -20531,17 +20534,16 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.10(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)): + terser-webpack-plugin@5.3.10(@swc/core@1.6.13(@swc/helpers@0.5.11))(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.36.0 - webpack: 5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5) + webpack: 5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11)) optionalDependencies: '@swc/core': 1.6.13(@swc/helpers@0.5.11) - esbuild: 0.21.5 terser@5.36.0: dependencies: @@ -21096,7 +21098,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5): + webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.6 @@ -21119,7 +21121,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))(esbuild@0.21.5)) + terser-webpack-plugin: 5.3.10(@swc/core@1.6.13(@swc/helpers@0.5.11))(webpack@5.92.1(@swc/core@1.6.13(@swc/helpers@0.5.11))) watchpack: 2.4.2 webpack-sources: 3.2.3 transitivePeerDependencies: