207 lines
5.6 KiB
TypeScript
Executable file
207 lines
5.6 KiB
TypeScript
Executable file
/**
|
|
* WeightSlider Component
|
|
*
|
|
* A 0-1 slider with color-coded feedback based on value.
|
|
* Useful for priority/severity weighting controls.
|
|
* Theme-agnostic with semantic token usage.
|
|
*/
|
|
|
|
import type React from 'react';
|
|
|
|
import styled, { type DefaultTheme } from '@lilith/ui-styled-components';
|
|
|
|
|
|
export interface WeightSliderProps {
|
|
/** Current value (0-1) */
|
|
value: number;
|
|
/** Change handler */
|
|
onChange: (value: number) => void;
|
|
/** Default value for reset (optional) */
|
|
defaultValue?: number;
|
|
/** Disabled state */
|
|
disabled?: boolean;
|
|
/** Show reset button when value differs from default (default: true) */
|
|
showReset?: boolean;
|
|
/** Step increment (default: 0.05) */
|
|
step?: number;
|
|
/** Custom color thresholds (optional) */
|
|
colorThresholds?: {
|
|
critical: number;
|
|
high: number;
|
|
medium: number;
|
|
};
|
|
/** Optional className */
|
|
className?: string;
|
|
}
|
|
|
|
const Container = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: ${(props: { theme: DefaultTheme }) => props.theme.spacing.md};
|
|
`;
|
|
|
|
const SliderWrapper = styled.div`
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: ${(props: { theme: DefaultTheme }) => props.theme.spacing.sm};
|
|
`;
|
|
|
|
const SliderInput = styled.input<{ $fillPercent: number; $color: string }>`
|
|
flex: 1;
|
|
height: 6px;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
background: ${(props: { $fillPercent: number; $color: string; theme: DefaultTheme }) => {
|
|
const track = props.theme.colors.border;
|
|
return `linear-gradient(to right, ${props.$color} 0%, ${props.$color} ${props.$fillPercent}%, ${track} ${props.$fillPercent}%, ${track} 100%)`;
|
|
}};
|
|
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius.full};
|
|
cursor: pointer;
|
|
|
|
&::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 14px;
|
|
height: 14px;
|
|
background: ${(props: { $color: string }) => props.$color};
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
transition: transform ${(props: { theme: DefaultTheme }) => props.theme.transitions.fast};
|
|
|
|
&:hover {
|
|
transform: scale(1.1);
|
|
}
|
|
}
|
|
|
|
&::-moz-range-thumb {
|
|
width: 14px;
|
|
height: 14px;
|
|
background: ${(props: { $color: string }) => props.$color};
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
`;
|
|
|
|
const ValueDisplay = styled.span<{ $color: string }>`
|
|
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontSize.sm};
|
|
font-weight: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontWeight.semibold};
|
|
color: ${(props) => props.$color};
|
|
min-width: 45px;
|
|
text-align: center;
|
|
`;
|
|
|
|
const ResetButton = styled.button<{ $visible: boolean }>`
|
|
padding: ${(props: { theme: DefaultTheme }) => props.theme.spacing.xs}
|
|
${(props: { theme: DefaultTheme }) => props.theme.spacing.sm};
|
|
background: transparent;
|
|
border: 1px solid ${(props: { theme: DefaultTheme }) => props.theme.colors.border};
|
|
border-radius: ${(props: { theme: DefaultTheme }) => props.theme.borderRadius.xs};
|
|
font-size: ${(props: { theme: DefaultTheme }) => props.theme.typography.fontSize.xs};
|
|
color: ${(props: { theme: DefaultTheme }) => props.theme.colors.text.secondary};
|
|
cursor: pointer;
|
|
opacity: ${(props) => (props.$visible ? 1 : 0)};
|
|
pointer-events: ${(props) => (props.$visible ? 'auto' : 'none')};
|
|
transition: all ${(props: { theme: DefaultTheme }) => props.theme.transitions.fast};
|
|
|
|
&:hover:not(:disabled) {
|
|
border-color: ${(props: { theme: DefaultTheme }) => props.theme.colors.primary};
|
|
color: ${(props: { theme: DefaultTheme }) => props.theme.colors.primary};
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
`;
|
|
|
|
const DEFAULT_THRESHOLDS = {
|
|
critical: 0.8,
|
|
high: 0.6,
|
|
medium: 0.4,
|
|
};
|
|
|
|
const DEFAULT_COLORS = {
|
|
critical: '#ef4444', // Red
|
|
high: '#f97316', // Orange
|
|
medium: '#eab308', // Yellow
|
|
low: '#22c55e', // Green
|
|
};
|
|
|
|
function getWeightColor(
|
|
value: number,
|
|
thresholds: typeof DEFAULT_THRESHOLDS = DEFAULT_THRESHOLDS,
|
|
): string {
|
|
if (value >= thresholds.critical) {return DEFAULT_COLORS.critical;}
|
|
if (value >= thresholds.high) {return DEFAULT_COLORS.high;}
|
|
if (value >= thresholds.medium) {return DEFAULT_COLORS.medium;}
|
|
return DEFAULT_COLORS.low;
|
|
}
|
|
|
|
/**
|
|
* A 0-1 slider with color-coded feedback.
|
|
*
|
|
* @example
|
|
* // Basic weight slider
|
|
* <WeightSlider
|
|
* value={0.5}
|
|
* onChange={setWeight}
|
|
* />
|
|
*
|
|
* @example
|
|
* // With reset to default
|
|
* <WeightSlider
|
|
* value={weight}
|
|
* onChange={setWeight}
|
|
* defaultValue={0.5}
|
|
* showReset
|
|
* />
|
|
*/
|
|
export const WeightSlider: FC<WeightSliderProps> = ({
|
|
value,
|
|
onChange,
|
|
defaultValue,
|
|
disabled = false,
|
|
showReset = true,
|
|
step = 0.05,
|
|
colorThresholds = DEFAULT_THRESHOLDS,
|
|
className,
|
|
}) => {
|
|
const fillPercent = value * 100;
|
|
const color = getWeightColor(value, colorThresholds);
|
|
const hasChanged = defaultValue !== undefined && value !== defaultValue;
|
|
|
|
return (
|
|
<Container className={className}>
|
|
<SliderWrapper>
|
|
<SliderInput
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={step}
|
|
value={value}
|
|
onChange={(e) => onChange(parseFloat(e.target.value))}
|
|
disabled={disabled}
|
|
$fillPercent={fillPercent}
|
|
$color={color}
|
|
/>
|
|
<ValueDisplay $color={color}>{value.toFixed(2)}</ValueDisplay>
|
|
</SliderWrapper>
|
|
{showReset && defaultValue !== undefined && (
|
|
<ResetButton
|
|
$visible={hasChanged}
|
|
onClick={() => onChange(defaultValue)}
|
|
disabled={disabled}
|
|
>
|
|
Reset
|
|
</ResetButton>
|
|
)}
|
|
</Container>
|
|
);
|
|
};
|