Skip to content

Commit

Permalink
Reintroduce Slider component with tailwind
Browse files Browse the repository at this point in the history
  • Loading branch information
JasonMHasperhoven committed Nov 15, 2024
1 parent 8735133 commit 7bdf9bc
Show file tree
Hide file tree
Showing 6 changed files with 249 additions and 83 deletions.
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions packages/ui/src/Slider/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Meta, StoryObj } from '@storybook/react';
import { Slider } from './index';

const meta: Meta<typeof Slider> = {
component: Slider,
tags: ['autodocs', '!dev'],
};

export default meta;

type Story = StoryObj<typeof Slider>;

export const Default: Story = {
args: {
min: 0,
max: 10,
step: 1,
defaultValue: 5,
leftLabel: 'label',
rightLabel: 'label',
showValue: true,
showFill: true,
},
};
34 changes: 34 additions & 0 deletions packages/ui/src/Slider/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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('<Slider />', () => {
it('renders correctly', () => {
const { container } = render(
<Slider min={0} max={10} step={1} defaultValue={5} leftLabel='left' rightLabel='right' />,
);

expect(container).toHaveTextContent('left');
expect(container).toHaveTextContent('right');
});

it('handles onChange correctly', () => {
const onChange = vi.fn();

const { container } = render(
<Slider min={0} max={10} step={1} defaultValue={5} onChange={onChange} />,
);

const slider = container.querySelector('[role="slider"]')!;
fireEvent.focus(slider);
fireEvent.keyDown(slider, { key: 'ArrowRight' });

expect(onChange).toHaveBeenCalledWith(6);
});
});
114 changes: 114 additions & 0 deletions packages/ui/src/Slider/index.tsx
Original file line number Diff line number Diff line change
@@ -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<SliderProps> = ({
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 (
<div>
{(!!leftLabel || !!rightLabel) && (
<div className='flex justify-between w-full mb-2'>
<div className={cn('text-text-secondary', `leading-[${fontSize}]`)}>{leftLabel}</div>
<div className={cn('text-text-secondary', `leading-[${fontSize}]`)}>{rightLabel}</div>
</div>
)}
<RadixSlider.Root
className='relative flex items-center w-full h-8'
min={min}
max={max}
step={step}
defaultValue={[defaultValue]}
onValueChange={handleValueChange}
disabled={disabled}
>
<RadixSlider.Track className='relative w-full h-2 bg-other-tonalFill10 rounded-full px-2'>
{showFill && (
<RadixSlider.Range className='absolute h-full bg-primary-main rounded-full' />
)}
<div className='relative'>
{showTrackGaps &&
Array.from({ length: totalSteps + 1 })
.map((_, i): number => (i / totalSteps) * 100)
.map(left => {
return (
<div
key={left}
className={cn(
'absolute w-1 h-2 -translate-x-1/2',
getThemeColorClass(trackGapBackground).bg,
)}
style={{
left: `${left}%`,
}}
/>
);
})}
</div>
</RadixSlider.Track>
<RadixSlider.Thumb
className={cn(
'block w-4 h-4 rounded-full bg-neutral-contrast',
!disabled && 'cursor-grab hover:bg-neutral-contrast focus:outline focus:outline-2',
!disabled && `focus:${getThemeColorClass(focusedOutlineColor).outline}`,
disabled &&
"after:content-[''] after:absolute after:inset-0 after:bg-action-disabledOverlay",
)}
/>
</RadixSlider.Root>
{showValue && (
<div
className={cn(
'flex mt-4 border border-tonalStroke text-text-primary p-4',
`leading-[${fontSize}]`,
)}
>
<div className='text-text-primary'>{value}</div>
{valueDetails && <div className='ml-2 text-text-secondary'>· {valueDetails}</div>}
</div>
)}
</div>
);
};
125 changes: 58 additions & 67 deletions packages/ui/src/utils/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,87 +30,78 @@ 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<ThemeColor, [string, string]> = {
'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<ThemeColor, [string, string, string]> = {
'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`);
}

return {
text: mapped[0],
bg: mapped[1],
outline: mapped[2],
};
};
Loading

0 comments on commit 7bdf9bc

Please sign in to comment.