chore: initialize @limitless/ui git repo + add AppShell
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>
This commit is contained in:
518
src/components/Slider.tsx
Normal file
518
src/components/Slider.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user