mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-18 07:23:51 +01:00
Add .oxfmtignore to skip generated bindings and wasm-pack output. Add npm format script, update DEVELOPMENT.md for Vite+ toolchain, and format all non-generated files with oxfmt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
125 lines
3.8 KiB
TypeScript
125 lines
3.8 KiB
TypeScript
import classNames from "classnames";
|
|
import { type ReactNode, useRef } from "react";
|
|
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
|
import { generateId } from "../../lib/generateId";
|
|
import { Button } from "./Button";
|
|
import type { IconProps } from "./Icon";
|
|
import { IconButton, type IconButtonProps } from "./IconButton";
|
|
import { Label } from "./Label";
|
|
import { HStack } from "./Stacks";
|
|
|
|
interface Props<T extends string> {
|
|
options: { value: T; label: string; icon?: IconProps["icon"] }[];
|
|
onChange: (value: T) => void;
|
|
value: T;
|
|
name: string;
|
|
size?: IconButtonProps["size"];
|
|
label: string;
|
|
className?: string;
|
|
hideLabel?: boolean;
|
|
labelClassName?: string;
|
|
help?: ReactNode;
|
|
}
|
|
|
|
export function SegmentedControl<T extends string>({
|
|
value,
|
|
onChange,
|
|
options,
|
|
size = "xs",
|
|
label,
|
|
hideLabel,
|
|
labelClassName,
|
|
help,
|
|
className,
|
|
}: Props<T>) {
|
|
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const id = useRef(`input-${generateId()}`);
|
|
|
|
return (
|
|
<div className="w-full grid">
|
|
<Label
|
|
htmlFor={id.current}
|
|
help={help}
|
|
visuallyHidden={hideLabel}
|
|
className={classNames(labelClassName)}
|
|
>
|
|
{label}
|
|
</Label>
|
|
<HStack
|
|
id={id.current}
|
|
ref={containerRef}
|
|
role="group"
|
|
dir="ltr"
|
|
space={1}
|
|
className={classNames(
|
|
className,
|
|
"bg-surface-highlight rounded-lg mb-auto mr-auto",
|
|
"transition-opacity transform-gpu p-1",
|
|
)}
|
|
onKeyDown={(e) => {
|
|
const selectedIndex = options.findIndex((o) => o.value === selectedValue);
|
|
if (e.key === "ArrowRight") {
|
|
e.preventDefault();
|
|
const newIndex = Math.abs((selectedIndex + 1) % options.length);
|
|
if (options[newIndex]) setSelectedValue(options[newIndex].value);
|
|
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
|
|
child.focus();
|
|
} else if (e.key === "ArrowLeft") {
|
|
e.preventDefault();
|
|
const newIndex = Math.abs((selectedIndex - 1) % options.length);
|
|
if (options[newIndex]) setSelectedValue(options[newIndex].value);
|
|
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
|
|
child.focus();
|
|
}
|
|
}}
|
|
>
|
|
{options.map((o) => {
|
|
const isSelected = selectedValue === o.value;
|
|
const isActive = value === o.value;
|
|
if (o.icon == null) {
|
|
return (
|
|
<Button
|
|
key={o.label}
|
|
aria-checked={isActive}
|
|
size={size}
|
|
variant="solid"
|
|
color={isActive ? "secondary" : undefined}
|
|
role="radio"
|
|
tabIndex={isSelected ? 0 : -1}
|
|
className={classNames(
|
|
isActive && "!text-text",
|
|
"focus:ring-1 focus:ring-border-focus",
|
|
)}
|
|
onClick={() => onChange(o.value)}
|
|
>
|
|
{o.label}
|
|
</Button>
|
|
);
|
|
} else {
|
|
return (
|
|
<IconButton
|
|
key={o.label}
|
|
aria-checked={isActive}
|
|
size={size}
|
|
variant="solid"
|
|
color={isActive ? "secondary" : undefined}
|
|
role="radio"
|
|
tabIndex={isSelected ? 0 : -1}
|
|
className={classNames(
|
|
isActive && "!text-text",
|
|
"!px-1.5 !w-auto",
|
|
"focus:ring-border-focus",
|
|
)}
|
|
title={o.label}
|
|
icon={o.icon}
|
|
onClick={() => onChange(o.value)}
|
|
/>
|
|
);
|
|
}
|
|
})}
|
|
</HStack>
|
|
</div>
|
|
);
|
|
}
|