This brings the long-untracked @limitless/ui source tree under version
control. Until now /srv/k8s/templates/limitless-ui has been a plain
file: dependency consumed by gscChronos / gscCRM / gscAdmin, with
copies scattered across web/gsc{Portal,WWW,Aether,Register}/ and
apps/gsc{Meet,Share}/. None were git-tracked.
Treating /srv/k8s/templates/limitless-ui as the canonical going
forward; secondary copies should be replaced with this version
in their consumers' Dockerfiles when they next get touched.
Changes in this initial commit beyond the snapshot:
- Add src/layout/AppShell.tsx — runtime-loaded chrome (header,
sidebar, footer) backed by gsc-shell-api. Public surface:
AppShell, ShellProvider, useShell, ShellConfig types
Framework-agnostic (no Next.js dep). Apps pass appKey + apiUrl +
getToken; AppShell composes the existing PageShell / Navbar /
Sidebar / Footer primitives with API data.
- Re-export AppShell from src/index.ts.
- Fix build script: `tsc -p tsconfig.json --noEmit false`. The bare
`tsc` command was a no-op because tsconfig.json sets noEmit:true
for typecheck speed. Existing dist/ only existed because of an
earlier emit; clean rebuilds were silently broken.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
519 lines
16 KiB
TypeScript
519 lines
16 KiB
TypeScript
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
|
|
export interface SliderProps {
|
|
/** Current value (controlled) */
|
|
value?: number;
|
|
/** Default value */
|
|
defaultValue?: number;
|
|
/** Minimum value */
|
|
min?: number;
|
|
/** Maximum value */
|
|
max?: number;
|
|
/** Step increment */
|
|
step?: number;
|
|
/** Callback when value changes */
|
|
onChange?: (value: number) => void;
|
|
/** Callback when sliding ends */
|
|
onChangeEnd?: (value: number) => void;
|
|
/** Show current value tooltip */
|
|
showTooltip?: boolean | 'always' | 'hover' | 'drag';
|
|
/** Format tooltip value */
|
|
formatTooltip?: (value: number) => string;
|
|
/** Show tick marks */
|
|
showTicks?: boolean;
|
|
/** Custom tick values */
|
|
ticks?: number[];
|
|
/** Show tick labels */
|
|
showTickLabels?: boolean;
|
|
/** Format tick labels */
|
|
formatTickLabel?: (value: number) => string;
|
|
/** Color variant */
|
|
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
|
|
/** Size variant */
|
|
size?: 'sm' | 'md' | 'lg';
|
|
/** Disabled state */
|
|
disabled?: boolean;
|
|
/** Orientation */
|
|
orientation?: 'horizontal' | 'vertical';
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
export const Slider: React.FC<SliderProps> = ({
|
|
value: controlledValue,
|
|
defaultValue = 0,
|
|
min = 0,
|
|
max = 100,
|
|
step = 1,
|
|
onChange,
|
|
onChangeEnd,
|
|
showTooltip = 'drag',
|
|
formatTooltip = (v) => String(v),
|
|
showTicks = false,
|
|
ticks,
|
|
showTickLabels = false,
|
|
formatTickLabel = (v) => String(v),
|
|
variant = 'primary',
|
|
size = 'md',
|
|
disabled = false,
|
|
orientation = 'horizontal',
|
|
className = '',
|
|
}) => {
|
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [isHovering, setIsHovering] = useState(false);
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
|
|
const value = controlledValue ?? internalValue;
|
|
|
|
// Calculate percentage
|
|
const percentage = ((value - min) / (max - min)) * 100;
|
|
|
|
// Get value from position
|
|
const getValueFromPosition = useCallback((clientX: number, clientY: number): number => {
|
|
if (!trackRef.current) return value;
|
|
|
|
const rect = trackRef.current.getBoundingClientRect();
|
|
let ratio: number;
|
|
|
|
if (orientation === 'horizontal') {
|
|
ratio = (clientX - rect.left) / rect.width;
|
|
} else {
|
|
ratio = 1 - (clientY - rect.top) / rect.height;
|
|
}
|
|
|
|
ratio = Math.max(0, Math.min(1, ratio));
|
|
let newValue = min + ratio * (max - min);
|
|
|
|
// Snap to step
|
|
newValue = Math.round(newValue / step) * step;
|
|
newValue = Math.max(min, Math.min(max, newValue));
|
|
|
|
return newValue;
|
|
}, [min, max, step, value, orientation]);
|
|
|
|
// Handle mouse/touch move
|
|
const handleMove = useCallback((clientX: number, clientY: number) => {
|
|
if (disabled) return;
|
|
|
|
const newValue = getValueFromPosition(clientX, clientY);
|
|
|
|
if (controlledValue === undefined) {
|
|
setInternalValue(newValue);
|
|
}
|
|
onChange?.(newValue);
|
|
}, [disabled, getValueFromPosition, controlledValue, onChange]);
|
|
|
|
// Handle mouse down
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (disabled) return;
|
|
e.preventDefault();
|
|
|
|
setIsDragging(true);
|
|
handleMove(e.clientX, e.clientY);
|
|
};
|
|
|
|
// Handle touch start
|
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|
if (disabled) return;
|
|
|
|
setIsDragging(true);
|
|
const touch = e.touches[0];
|
|
handleMove(touch.clientX, touch.clientY);
|
|
};
|
|
|
|
// Handle mouse/touch events
|
|
useEffect(() => {
|
|
if (!isDragging) return;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
handleMove(e.clientX, e.clientY);
|
|
};
|
|
|
|
const handleTouchMove = (e: TouchEvent) => {
|
|
const touch = e.touches[0];
|
|
handleMove(touch.clientX, touch.clientY);
|
|
};
|
|
|
|
const handleEnd = () => {
|
|
setIsDragging(false);
|
|
onChangeEnd?.(value);
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleEnd);
|
|
document.addEventListener('touchmove', handleTouchMove);
|
|
document.addEventListener('touchend', handleEnd);
|
|
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleEnd);
|
|
document.removeEventListener('touchmove', handleTouchMove);
|
|
document.removeEventListener('touchend', handleEnd);
|
|
};
|
|
}, [isDragging, handleMove, onChangeEnd, value]);
|
|
|
|
// Handle keyboard
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (disabled) return;
|
|
|
|
let newValue = value;
|
|
const largeStep = step * 10;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowRight':
|
|
case 'ArrowUp':
|
|
newValue = Math.min(max, value + step);
|
|
break;
|
|
case 'ArrowLeft':
|
|
case 'ArrowDown':
|
|
newValue = Math.max(min, value - step);
|
|
break;
|
|
case 'PageUp':
|
|
newValue = Math.min(max, value + largeStep);
|
|
break;
|
|
case 'PageDown':
|
|
newValue = Math.max(min, value - largeStep);
|
|
break;
|
|
case 'Home':
|
|
newValue = min;
|
|
break;
|
|
case 'End':
|
|
newValue = max;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
|
|
if (controlledValue === undefined) {
|
|
setInternalValue(newValue);
|
|
}
|
|
onChange?.(newValue);
|
|
onChangeEnd?.(newValue);
|
|
};
|
|
|
|
// Generate tick marks
|
|
const tickMarks = ticks || (showTicks ?
|
|
Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, i) => min + i * step) :
|
|
[]
|
|
);
|
|
|
|
// Determine tooltip visibility
|
|
const shouldShowTooltip =
|
|
showTooltip === 'always' ||
|
|
(showTooltip === 'hover' && (isHovering || isDragging)) ||
|
|
(showTooltip === 'drag' && isDragging) ||
|
|
(showTooltip === true && (isHovering || isDragging));
|
|
|
|
const classes = [
|
|
'll-slider',
|
|
`ll-slider-${orientation}`,
|
|
`ll-slider-${variant}`,
|
|
`ll-slider-${size}`,
|
|
disabled && 'll-slider-disabled',
|
|
className,
|
|
].filter(Boolean).join(' ');
|
|
|
|
const trackStyle = orientation === 'horizontal'
|
|
? { width: `${percentage}%` }
|
|
: { height: `${percentage}%` };
|
|
|
|
const thumbStyle = orientation === 'horizontal'
|
|
? { left: `${percentage}%` }
|
|
: { bottom: `${percentage}%` };
|
|
|
|
return (
|
|
<div className={classes}>
|
|
<div
|
|
ref={trackRef}
|
|
className="ll-slider-track-container"
|
|
onMouseDown={handleMouseDown}
|
|
onTouchStart={handleTouchStart}
|
|
onMouseEnter={() => setIsHovering(true)}
|
|
onMouseLeave={() => setIsHovering(false)}
|
|
>
|
|
<div className="ll-slider-track">
|
|
<div className="ll-slider-track-fill" style={trackStyle} />
|
|
</div>
|
|
|
|
{/* Tick marks */}
|
|
{tickMarks.length > 0 && (
|
|
<div className="ll-slider-ticks">
|
|
{tickMarks.map((tick) => {
|
|
const tickPercent = ((tick - min) / (max - min)) * 100;
|
|
const tickStyle = orientation === 'horizontal'
|
|
? { left: `${tickPercent}%` }
|
|
: { bottom: `${tickPercent}%` };
|
|
|
|
return (
|
|
<div key={tick} className="ll-slider-tick" style={tickStyle}>
|
|
<div className="ll-slider-tick-mark" />
|
|
{showTickLabels && (
|
|
<div className="ll-slider-tick-label">
|
|
{formatTickLabel(tick)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Thumb */}
|
|
<div
|
|
className="ll-slider-thumb"
|
|
style={thumbStyle}
|
|
tabIndex={disabled ? -1 : 0}
|
|
role="slider"
|
|
aria-valuemin={min}
|
|
aria-valuemax={max}
|
|
aria-valuenow={value}
|
|
aria-orientation={orientation}
|
|
aria-disabled={disabled}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
{shouldShowTooltip && (
|
|
<div className="ll-slider-tooltip">
|
|
{formatTooltip(value)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Range Slider
|
|
export interface RangeSliderProps extends Omit<SliderProps, 'value' | 'defaultValue' | 'onChange' | 'onChangeEnd'> {
|
|
/** Current values [min, max] (controlled) */
|
|
value?: [number, number];
|
|
/** Default values [min, max] */
|
|
defaultValue?: [number, number];
|
|
/** Callback when values change */
|
|
onChange?: (value: [number, number]) => void;
|
|
/** Callback when sliding ends */
|
|
onChangeEnd?: (value: [number, number]) => void;
|
|
/** Minimum gap between handles */
|
|
minGap?: number;
|
|
}
|
|
|
|
export const RangeSlider: React.FC<RangeSliderProps> = ({
|
|
value: controlledValue,
|
|
defaultValue = [25, 75],
|
|
min = 0,
|
|
max = 100,
|
|
step = 1,
|
|
onChange,
|
|
onChangeEnd,
|
|
minGap = 0,
|
|
showTooltip = 'drag',
|
|
formatTooltip = (v) => String(v),
|
|
showTicks = false,
|
|
ticks,
|
|
showTickLabels = false,
|
|
formatTickLabel = (v) => String(v),
|
|
variant = 'primary',
|
|
size = 'md',
|
|
disabled = false,
|
|
orientation = 'horizontal',
|
|
className = '',
|
|
}) => {
|
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
const [activeHandle, setActiveHandle] = useState<0 | 1 | null>(null);
|
|
const [isHovering, setIsHovering] = useState<0 | 1 | null>(null);
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
|
|
const value = controlledValue ?? internalValue;
|
|
|
|
// Calculate percentages
|
|
const lowPercent = ((value[0] - min) / (max - min)) * 100;
|
|
const highPercent = ((value[1] - min) / (max - min)) * 100;
|
|
|
|
// Get value from position
|
|
const getValueFromPosition = useCallback((clientX: number, clientY: number): number => {
|
|
if (!trackRef.current) return 0;
|
|
|
|
const rect = trackRef.current.getBoundingClientRect();
|
|
let ratio: number;
|
|
|
|
if (orientation === 'horizontal') {
|
|
ratio = (clientX - rect.left) / rect.width;
|
|
} else {
|
|
ratio = 1 - (clientY - rect.top) / rect.height;
|
|
}
|
|
|
|
ratio = Math.max(0, Math.min(1, ratio));
|
|
let newValue = min + ratio * (max - min);
|
|
newValue = Math.round(newValue / step) * step;
|
|
|
|
return newValue;
|
|
}, [min, max, step, orientation]);
|
|
|
|
// Handle move
|
|
const handleMove = useCallback((clientX: number, clientY: number) => {
|
|
if (disabled || activeHandle === null) return;
|
|
|
|
const newValue = getValueFromPosition(clientX, clientY);
|
|
let newValues: [number, number] = [...value];
|
|
|
|
if (activeHandle === 0) {
|
|
newValues[0] = Math.min(newValue, value[1] - minGap);
|
|
newValues[0] = Math.max(min, newValues[0]);
|
|
} else {
|
|
newValues[1] = Math.max(newValue, value[0] + minGap);
|
|
newValues[1] = Math.min(max, newValues[1]);
|
|
}
|
|
|
|
if (controlledValue === undefined) {
|
|
setInternalValue(newValues);
|
|
}
|
|
onChange?.(newValues);
|
|
}, [disabled, activeHandle, getValueFromPosition, value, minGap, min, max, controlledValue, onChange]);
|
|
|
|
// Handle track click
|
|
const handleTrackClick = (e: React.MouseEvent) => {
|
|
if (disabled) return;
|
|
|
|
const clickValue = getValueFromPosition(e.clientX, e.clientY);
|
|
const distToLow = Math.abs(clickValue - value[0]);
|
|
const distToHigh = Math.abs(clickValue - value[1]);
|
|
|
|
const handle = distToLow <= distToHigh ? 0 : 1;
|
|
setActiveHandle(handle);
|
|
|
|
let newValues: [number, number] = [...value];
|
|
if (handle === 0) {
|
|
newValues[0] = Math.min(clickValue, value[1] - minGap);
|
|
newValues[0] = Math.max(min, newValues[0]);
|
|
} else {
|
|
newValues[1] = Math.max(clickValue, value[0] + minGap);
|
|
newValues[1] = Math.min(max, newValues[1]);
|
|
}
|
|
|
|
if (controlledValue === undefined) {
|
|
setInternalValue(newValues);
|
|
}
|
|
onChange?.(newValues);
|
|
};
|
|
|
|
// Handle mouse/touch events
|
|
useEffect(() => {
|
|
if (activeHandle === null) return;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => handleMove(e.clientX, e.clientY);
|
|
const handleTouchMove = (e: TouchEvent) => handleMove(e.touches[0].clientX, e.touches[0].clientY);
|
|
const handleEnd = () => {
|
|
setActiveHandle(null);
|
|
onChangeEnd?.(value);
|
|
};
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
document.addEventListener('mouseup', handleEnd);
|
|
document.addEventListener('touchmove', handleTouchMove);
|
|
document.addEventListener('touchend', handleEnd);
|
|
|
|
return () => {
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
document.removeEventListener('mouseup', handleEnd);
|
|
document.removeEventListener('touchmove', handleTouchMove);
|
|
document.removeEventListener('touchend', handleEnd);
|
|
};
|
|
}, [activeHandle, handleMove, onChangeEnd, value]);
|
|
|
|
// Tick marks
|
|
const tickMarks = ticks || (showTicks ?
|
|
Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, i) => min + i * step) :
|
|
[]
|
|
);
|
|
|
|
const classes = [
|
|
'll-slider',
|
|
'll-range-slider',
|
|
`ll-slider-${orientation}`,
|
|
`ll-slider-${variant}`,
|
|
`ll-slider-${size}`,
|
|
disabled && 'll-slider-disabled',
|
|
className,
|
|
].filter(Boolean).join(' ');
|
|
|
|
const fillStyle = orientation === 'horizontal'
|
|
? { left: `${lowPercent}%`, width: `${highPercent - lowPercent}%` }
|
|
: { bottom: `${lowPercent}%`, height: `${highPercent - lowPercent}%` };
|
|
|
|
const thumbStyles = [
|
|
orientation === 'horizontal' ? { left: `${lowPercent}%` } : { bottom: `${lowPercent}%` },
|
|
orientation === 'horizontal' ? { left: `${highPercent}%` } : { bottom: `${highPercent}%` },
|
|
];
|
|
|
|
const shouldShowTooltip = (handle: 0 | 1) =>
|
|
showTooltip === 'always' ||
|
|
(showTooltip === 'hover' && (isHovering === handle || activeHandle === handle)) ||
|
|
(showTooltip === 'drag' && activeHandle === handle) ||
|
|
(showTooltip === true && (isHovering === handle || activeHandle === handle));
|
|
|
|
return (
|
|
<div className={classes}>
|
|
<div
|
|
ref={trackRef}
|
|
className="ll-slider-track-container"
|
|
onClick={handleTrackClick}
|
|
>
|
|
<div className="ll-slider-track">
|
|
<div className="ll-slider-track-fill" style={fillStyle} />
|
|
</div>
|
|
|
|
{/* Tick marks */}
|
|
{tickMarks.length > 0 && (
|
|
<div className="ll-slider-ticks">
|
|
{tickMarks.map((tick) => {
|
|
const tickPercent = ((tick - min) / (max - min)) * 100;
|
|
const tickStyle = orientation === 'horizontal'
|
|
? { left: `${tickPercent}%` }
|
|
: { bottom: `${tickPercent}%` };
|
|
|
|
return (
|
|
<div key={tick} className="ll-slider-tick" style={tickStyle}>
|
|
<div className="ll-slider-tick-mark" />
|
|
{showTickLabels && (
|
|
<div className="ll-slider-tick-label">
|
|
{formatTickLabel(tick)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Thumbs */}
|
|
{[0, 1].map((handle) => (
|
|
<div
|
|
key={handle}
|
|
className={`ll-slider-thumb ${activeHandle === handle ? 'active' : ''}`}
|
|
style={thumbStyles[handle]}
|
|
tabIndex={disabled ? -1 : 0}
|
|
role="slider"
|
|
aria-valuemin={handle === 0 ? min : value[0]}
|
|
aria-valuemax={handle === 0 ? value[1] : max}
|
|
aria-valuenow={value[handle as 0 | 1]}
|
|
aria-orientation={orientation}
|
|
aria-disabled={disabled}
|
|
onMouseDown={(e) => { e.stopPropagation(); setActiveHandle(handle as 0 | 1); }}
|
|
onTouchStart={() => setActiveHandle(handle as 0 | 1)}
|
|
onMouseEnter={() => setIsHovering(handle as 0 | 1)}
|
|
onMouseLeave={() => setIsHovering(null)}
|
|
>
|
|
{shouldShowTooltip(handle as 0 | 1) && (
|
|
<div className="ll-slider-tooltip">
|
|
{formatTooltip(value[handle as 0 | 1])}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|