Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
408 changes: 408 additions & 0 deletions src/components/ui/DimSlicer/DimSlicer.tsx

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button-enhanced';
import { ButtonGroup } from '@/components/ui/button-group';

export type Axis = 'x' | 'y' | 'z' | 'c';

const AXIS_CLASS: Record<Axis, string> = {
x: 'text-pink-500',
y: 'text-green-500',
z: 'text-blue-500',
c: 'text-yellow-500',
};

interface DimSlicerAxisToggleProps {
axis: Axis;
onAxisChange?: (axis: Axis) => void;
/** If provided, only these axes are shown. Defaults to all four. */
allowedAxes?: Axis[];
}

export const DimSlicerAxisToggle: React.FC<DimSlicerAxisToggleProps> = ({
axis,
onAxisChange,
allowedAxes,
}) => {
const [expanded, setExpanded] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);

const axisOptions: Axis[] = allowedAxes ?? ['x', 'y', 'z', 'c'];

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setExpanded(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

return (
<div ref={rootRef} className="relative">
{expanded ? (
<ButtonGroup orientation="horizontal" className="h-6 w-fit">
{axisOptions.map(a => (
<Button
key={a}
variant={axis === a ? 'default' : 'outline'}
size="sm"
className={`text-xs px-2 py-1 h-6 cursor-pointer ${axis === a ? AXIS_CLASS[a] : ''}`}
onClick={() => {
onAxisChange?.(a);
setExpanded(false);
}}
>
{a}
</Button>
))}
</ButtonGroup>
) : (
<Button
variant="outline"
size="sm"
className={`text-xs px-2 py-1 h-6 cursor-pointer font-bold ${AXIS_CLASS[axis]}`}
onClick={() => setExpanded(prev => !prev)}
>
{axis}
</Button>
)}
</div>
);
};
67 changes: 67 additions & 0 deletions src/components/ui/DimSlicer/DimSlicerModeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button-enhanced';
import { ButtonGroup } from '@/components/ui/button-group';

export type SelectionMode = 'scalar' | 'slice';

interface DimSlicerModeToggleProps {
mode: SelectionMode;
onModeChange: (nextMode: SelectionMode) => void;
}

export const DimSlicerModeToggle: React.FC<DimSlicerModeToggleProps> = ({ mode, onModeChange }) => {
const [expanded, setExpanded] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setExpanded(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

return (
<div ref={rootRef} className="relative">
{expanded ? (
<ButtonGroup orientation="horizontal" className="h-6 w-fit">
<Button
variant={mode === 'scalar' ? 'default' : 'outline'}
size="sm"
className="text-xs px-2 py-1 h-6 cursor-pointer"
onClick={() => {
onModeChange('scalar');
setExpanded(false);
}}
>
index
</Button>
<Button
variant={mode === 'slice' ? 'default' : 'outline'}
size="sm"
className="text-xs px-2 py-1 h-6 cursor-pointer"
onClick={() => {
onModeChange('slice');
setExpanded(false);
}}
>
slice
</Button>
</ButtonGroup>
) : (
<Button
variant="outline"
size="sm"
className="text-xs px-2 py-1 h-6 cursor-pointer"
onClick={() => setExpanded(prev => !prev)}
>
{mode === 'scalar' ? 'index' : 'slice'}
</Button>
)}
</div>
);
};
36 changes: 36 additions & 0 deletions src/components/ui/DimSlicer/DimSlicerNumericControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client'

import React from 'react'
import { DimSlicerNumericInputWithStepper } from './DimSlicerNumericInputWithStepper'

interface DimSlicerNumericControlProps {
value: string
placeholder: string
ariaLabel: string
onValueChange: (value: string) => void
onIncrement: () => void
onDecrement: () => void
showInput: boolean
}

export function DimSlicerNumericControl({
value,
placeholder,
ariaLabel,
onValueChange,
onIncrement,
onDecrement,
showInput,
}: DimSlicerNumericControlProps) {
return (
<DimSlicerNumericInputWithStepper
value={value}
placeholder={placeholder}
onValueChange={onValueChange}
onIncrement={onIncrement}
onDecrement={onDecrement}
ariaLabel={ariaLabel}
showInput={showInput}
/>
)
}
97 changes: 97 additions & 0 deletions src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button-enhanced';
import { ButtonGroup } from '@/components/ui/button-group';
import { MinusIcon, PlusIcon } from 'lucide-react';

interface DimSlicerNumericInputWithStepperProps {
value: string;
placeholder: string;
onValueChange: (value: string) => void;
onIncrement: () => void;
onDecrement: () => void;
ariaLabel: string;
showInput?: boolean;
}

export const DimSlicerNumericInputWithStepper: React.FC<DimSlicerNumericInputWithStepperProps> = ({
value,
placeholder,
onValueChange,
onIncrement,
onDecrement,
ariaLabel,
showInput = true,
}) => {
const [expanded, setExpanded] = useState(false);
const [localValue, setLocalValue] = useState(value);
const rootRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setExpanded(false);
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);

// Sync prop changes to local state
useEffect(() => {
setLocalValue(value);
}, [value]);

const commitValue = () => {
onValueChange(localValue);
setLocalValue(value);
};
Comment on lines +47 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling setLocalValue(value) synchronously right after onValueChange(localValue) will reset the input to the old prop value before the parent has a chance to re-render with the new value. This causes a visible flickering effect. Removing the synchronous reset allows the prop sync in the useEffect to handle the update smoothly.

  const commitValue = () => {
    onValueChange(localValue);
  };


const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
commitValue();
}
};

return (
<div ref={rootRef}>
<ButtonGroup orientation="horizontal" className="h-7 w-fit">
{showInput && (
<Input
type="number"
value={localValue}
onChange={e => setLocalValue(e.target.value)}
onBlur={commitValue}
onKeyDown={handleKeyDown}
onClick={() => setExpanded(false)}
className="no-spinner h-7 text-xs w-16 text-center appearance-none"
placeholder={placeholder}
aria-label={ariaLabel}
/>
)}
{expanded ? (
<ButtonGroup orientation="horizontal" aria-label={ariaLabel} className="h-fit">
<Button variant="outline" size="icon-sm" className="h-7 w-7 p-0 cursor-pointer" onClick={onDecrement}>
<MinusIcon className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon-sm" className="h-7 w-7 p-0 cursor-pointer" onClick={onIncrement}>
<PlusIcon className="h-4 w-4" />
</Button>
</ButtonGroup>
) : (
<Button
variant="outline"
size="icon-sm"
className="h-7 w-7 p-0 cursor-pointer shrink-0"
onClick={() => setExpanded(true)}
aria-label={ariaLabel}
>
±
</Button>
)}
</ButtonGroup>
</div>
);
};
65 changes: 65 additions & 0 deletions src/components/ui/DimSlicer/DimSlicerTimeControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client'

import React from 'react'
import TimeCombobox from './TimeCombobox'
import { DimSlicerNumericInputWithStepper } from './DimSlicerNumericInputWithStepper'

interface DimSlicerTimeControlProps {
currentIndex: number
onIndexChange: (index: number) => void
value: string
placeholder: string
ariaLabel: string
values: number[]
effectiveDimSize: number
formattedValue: (index: number) => string
onValueChange: (value: string) => void
onIncrement: () => void
onDecrement: () => void
includeEnd?: boolean
layout?: 'row' | 'column'
showInput?: boolean
}

export function DimSlicerTimeControl({
currentIndex,
onIndexChange,
value,
placeholder,
ariaLabel,
values,
effectiveDimSize,
formattedValue,
onValueChange,
onIncrement,
onDecrement,
includeEnd = false,
layout = 'column',
showInput = true,
}: DimSlicerTimeControlProps) {
return (
<div className={`flex gap-1 ${layout === 'row' ? 'items-center' : 'flex-col items-start'}`}>
<div className={layout === 'row' ? 'min-w-0' : ''}>
<TimeCombobox
currentIndex={currentIndex}
onIndexChange={onIndexChange}
ariaLabel={ariaLabel}
placeholder={placeholder}
values={values}
effectiveDimSize={effectiveDimSize}
formattedValue={formattedValue}
includeEnd={includeEnd}
/>
</div>
<DimSlicerNumericInputWithStepper
value={value}
placeholder={placeholder}
onValueChange={onValueChange}
onIncrement={onIncrement}
onDecrement={onDecrement}
ariaLabel={ariaLabel}
showInput={showInput}
/>
</div>
)
}
Loading
Loading