* refactor to @base-ui/react

* fix all

* fixes to accordion

* more updates

* switch to chat/completions api from openai

* update version to v5.0.12
This commit is contained in:
Amruth Pillai
2026-03-17 23:38:06 +01:00
committed by GitHub
parent 89beb43ea2
commit 5cd16a62d9
192 changed files with 7333 additions and 9548 deletions
+7 -5
View File
@@ -305,11 +305,13 @@ export function AIChat() {
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button size="icon" variant="ghost">
<SparkleIcon />
</Button>
</PopoverTrigger>
<PopoverTrigger
render={
<Button size="icon" variant="ghost">
<SparkleIcon />
</Button>
}
/>
<PopoverContent className="flex h-128 w-md flex-col gap-y-0 overflow-hidden p-0" side="top" align="center">
{/* Header with clear button */}
+36 -824
View File
@@ -1,829 +1,41 @@
import { EyedropperIcon } from "@phosphor-icons/react";
import { Slider as SliderPrimitive, Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import { VisuallyHiddenInput } from "@/components/input/visually-hidden-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
type ColorValue,
type HSVColorValue,
hexToRgb,
hsvToRgb,
parseRgbString,
rgbToHsv,
rgbToString,
} from "@/utils/color";
import { useComposedRefs } from "@/utils/compose-refs";
import { cn } from "@/utils/style";
import { type ColorResult, hsvaToRgbaString, rgbaStringToHsva } from "@uiw/color-convert";
import ReactColorColorful from "@uiw/react-color-colorful";
import { useMemo } from "react";
import { useControlledState } from "@/hooks/use-controlled-state";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
interface EyeDropper {
open: (options?: { signal?: AbortSignal }) => Promise<{ sRGBHex: string }>;
}
declare global {
interface Window {
EyeDropper?: {
new (): EyeDropper;
};
}
}
type Direction = "ltr" | "rtl";
const DirectionContext = React.createContext<Direction | undefined>(undefined);
function useDirection(dirProp?: Direction): Direction {
const contextDir = React.useContext(DirectionContext);
return dirProp ?? contextDir ?? "ltr";
}
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
interface ColorPickerStoreState {
color: ColorValue;
hsv: HSVColorValue;
open: boolean;
}
interface ColorPickerStoreCallbacks {
onColorChange?: (colorString: string) => void;
onOpenChange?: (open: boolean) => void;
}
function colorValuesEqual(a: ColorValue, b: ColorValue): boolean {
return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a;
}
function hsvValuesEqual(a: HSVColorValue, b: HSVColorValue): boolean {
return a.h === b.h && a.s === b.s && a.v === b.v && a.a === b.a;
}
interface ColorPickerStore {
subscribe: (cb: () => void) => () => void;
getState: () => ColorPickerStoreState;
setColor: (value: ColorValue) => void;
setHsv: (value: HSVColorValue) => void;
setOpen: (value: boolean) => void;
syncColor: (value: ColorValue) => void;
notify: () => void;
}
function createColorPickerStore(
listenersRef: React.RefObject<Set<() => void>>,
stateRef: React.RefObject<ColorPickerStoreState>,
callbacks?: ColorPickerStoreCallbacks,
): ColorPickerStore {
const store: ColorPickerStore = {
subscribe: (cb) => {
if (listenersRef.current) {
listenersRef.current.add(cb);
return () => listenersRef.current?.delete(cb);
}
return () => {};
},
getState: () =>
stateRef.current || {
color: { r: 0, g: 0, b: 0, a: 1 },
hsv: { h: 0, s: 0, v: 0, a: 1 },
open: false,
},
setColor: (value: ColorValue) => {
if (!stateRef.current) return;
if (colorValuesEqual(stateRef.current.color, value)) return;
stateRef.current.color = value;
stateRef.current.hsv = rgbToHsv(value);
if (callbacks?.onColorChange) {
const colorString = rgbToString(value);
callbacks.onColorChange(colorString);
}
store.notify();
},
setHsv: (value: HSVColorValue) => {
if (!stateRef.current) return;
if (hsvValuesEqual(stateRef.current.hsv, value)) return;
stateRef.current.hsv = value;
stateRef.current.color = hsvToRgb(value);
if (callbacks?.onColorChange) {
const colorString = rgbToString(stateRef.current.color);
callbacks.onColorChange(colorString);
}
store.notify();
},
syncColor: (value: ColorValue) => {
if (!stateRef.current) return;
if (colorValuesEqual(stateRef.current.color, value)) return;
stateRef.current.color = value;
stateRef.current.hsv = rgbToHsv(value);
store.notify();
},
setOpen: (value: boolean) => {
if (!stateRef.current) return;
if (Object.is(stateRef.current.open, value)) return;
stateRef.current.open = value;
if (callbacks?.onOpenChange) {
callbacks.onOpenChange(value);
}
store.notify();
},
notify: () => {
if (listenersRef.current) {
for (const cb of listenersRef.current) {
cb();
}
}
},
};
return store;
}
function useColorPickerStoreContext(consumerName: string) {
const context = React.useContext(ColorPickerStoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`ColorPickerRoot\``);
}
return context;
}
function useColorPickerStore<U>(selector: (state: ColorPickerStoreState) => U): U {
const store = useColorPickerStoreContext("useColorPickerStoreSelector");
const getSnapshot = React.useCallback(() => selector(store.getState()), [store, selector]);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface ColorPickerContextValue {
dir: Direction;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
}
const ColorPickerStoreContext = React.createContext<ColorPickerStore | null>(null);
const ColorPickerContext = React.createContext<ColorPickerContextValue | null>(null);
function useColorPickerContext(consumerName: string) {
const context = React.useContext(ColorPickerContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`ColorPickerRoot\``);
}
return context;
}
interface ColorPickerRootProps
extends Omit<React.ComponentProps<"div">, "onValueChange">,
Pick<React.ComponentProps<typeof Popover>, "defaultOpen" | "open" | "onOpenChange" | "modal"> {
type ColorPickerProps = {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
dir?: Direction;
name?: string;
asChild?: boolean;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
}
function ColorPickerRoot(props: ColorPickerRootProps) {
const {
value: valueProp,
defaultValue = "rgb(0, 0, 0)",
onValueChange,
defaultOpen,
open: openProp,
onOpenChange,
name,
disabled,
readOnly,
required,
...rootProps
} = props;
const initialColor = React.useMemo(() => {
const colorString = valueProp ?? defaultValue;
const parsed = parseRgbString(colorString);
const color = parsed ?? { r: 0, g: 0, b: 0, a: 1 };
return {
color,
hsv: rgbToHsv(color),
open: openProp ?? defaultOpen ?? false,
};
}, [valueProp, defaultValue, openProp, defaultOpen]);
const stateRef = useLazyRef(() => initialColor);
const listenersRef = useLazyRef(() => new Set<() => void>());
const storeCallbacks = React.useMemo<ColorPickerStoreCallbacks>(
() => ({
onColorChange: onValueChange,
onOpenChange: onOpenChange,
}),
[onValueChange, onOpenChange],
);
const store = React.useMemo(
() => createColorPickerStore(listenersRef, stateRef, storeCallbacks),
[listenersRef, stateRef, storeCallbacks],
);
return (
<ColorPickerStoreContext.Provider value={store}>
<ColorPickerRootImpl
{...rootProps}
value={valueProp}
defaultOpen={defaultOpen}
open={openProp}
onOpenChange={onOpenChange}
name={name}
disabled={disabled}
readOnly={readOnly}
required={required}
/>
</ColorPickerStoreContext.Provider>
);
}
interface ColorPickerRootImplProps extends Omit<ColorPickerRootProps, "defaultValue" | "onValueChange"> {}
function ColorPickerRootImpl(props: ColorPickerRootImplProps) {
const {
value: valueProp,
dir: dirProp,
defaultOpen,
open: openProp,
onOpenChange,
name,
ref,
asChild,
disabled,
modal,
readOnly,
required,
...rootProps
} = props;
const store = useColorPickerStoreContext("ColorPickerRootImpl");
const dir = useDirection(dirProp);
const [formTrigger, setFormTrigger] = React.useState<HTMLDivElement | null>(null);
const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node));
const isFormControl = formTrigger ? !!formTrigger.closest("form") : true;
React.useEffect(() => {
if (valueProp !== undefined) {
const parsed = parseRgbString(valueProp);
if (parsed) {
const currentState = store.getState();
const color = { ...parsed, a: parsed.a ?? currentState.color.a };
store.syncColor(color);
}
}
}, [valueProp, store]);
React.useEffect(() => {
if (openProp !== undefined) {
store.setOpen(openProp);
}
}, [openProp, store]);
const contextValue = React.useMemo<ColorPickerContextValue>(
() => ({
dir,
disabled,
readOnly,
required,
}),
[dir, disabled, readOnly, required],
);
const value = useColorPickerStore((state) => rgbToString(state.color));
const open = useColorPickerStore((state) => state.open);
const onPopoverOpenChange = React.useCallback(
(newOpen: boolean) => {
store.setOpen(newOpen);
onOpenChange?.(newOpen);
},
[store, onOpenChange],
);
const RootPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<ColorPickerContext.Provider value={contextValue}>
<Popover defaultOpen={defaultOpen} open={open} onOpenChange={onPopoverOpenChange} modal={modal}>
<RootPrimitive {...rootProps} ref={composedRef} />
{isFormControl && (
<VisuallyHiddenInput
type="hidden"
name={name}
value={value}
disabled={disabled}
readOnly={readOnly}
required={required}
control={formTrigger}
/>
)}
</Popover>
</ColorPickerContext.Provider>
);
}
interface ColorPickerTriggerProps extends React.ComponentProps<typeof PopoverTrigger> {}
function ColorPickerTrigger(props: ColorPickerTriggerProps) {
const { asChild, ...triggerProps } = props;
const context = useColorPickerContext("ColorPickerTrigger");
const TriggerPrimitive = asChild ? SlotPrimitive.Slot : PopoverTrigger;
return (
<PopoverTrigger asChild disabled={context.disabled}>
<TriggerPrimitive data-slot="color-picker-trigger" {...triggerProps} />
</PopoverTrigger>
);
}
interface ColorPickerContentProps extends React.ComponentProps<typeof PopoverContent> {}
function ColorPickerContent(props: ColorPickerContentProps) {
const { asChild, className, children, ...popoverContentProps } = props;
return (
<PopoverContent
data-slot="color-picker-content"
asChild={asChild}
{...popoverContentProps}
className={cn("flex w-[340px] flex-col gap-4 p-4", className)}
>
{children}
</PopoverContent>
);
}
interface ColorPickerSwatchProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
function ColorPickerSwatch(props: ColorPickerSwatchProps) {
const { asChild, className, ...swatchProps } = props;
const context = useColorPickerContext("ColorPickerSwatch");
const color = useColorPickerStore((state) => state.color);
const backgroundStyle = React.useMemo(() => {
if (!color) {
return {
background:
"linear-gradient(to bottom right, transparent calc(50% - 1px), hsl(var(--destructive)) calc(50% - 1px) calc(50% + 1px), transparent calc(50% + 1px)) no-repeat",
};
}
const colorString = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
if (color.a < 1) {
return {
background: `linear-gradient(${colorString}, ${colorString}), repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0% 50% / 8px 8px`,
};
}
return {
backgroundColor: colorString,
};
}, [color]);
const ariaLabel = !color ? "No color selected" : `Current color: ${rgbToString(color)}`;
const SwatchPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<div className="flex size-9 items-center justify-start">
<SwatchPrimitive
{...swatchProps}
role="img"
aria-label={ariaLabel}
data-slot="color-picker-swatch"
className={cn("box-border size-7 rounded-md border", context.disabled && "opacity-50", className)}
style={{
...backgroundStyle,
forcedColorAdjust: "none",
}}
/>
</div>
);
}
interface ColorPickerAreaProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
function ColorPickerArea(props: ColorPickerAreaProps) {
const { asChild, className, ref, ...areaProps } = props;
const context = useColorPickerContext("ColorPickerArea");
const store = useColorPickerStoreContext("ColorPickerArea");
const hsv = useColorPickerStore((state) => state.hsv);
const isDraggingRef = React.useRef(false);
const areaRef = React.useRef<HTMLDivElement>(null);
const composedRef = useComposedRefs(ref, areaRef);
const updateColorFromPosition = React.useCallback(
(clientX: number, clientY: number) => {
if (!areaRef.current) return;
const rect = areaRef.current.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, 1 - (clientY - rect.top) / rect.height));
const newHsv: HSVColorValue = {
h: hsv?.h ?? 0,
s: Math.round(x * 100),
v: Math.round(y * 100),
a: hsv?.a ?? 1,
};
store.setHsv(newHsv);
},
[hsv, store],
);
const onPointerDown = React.useCallback(
(event: React.PointerEvent) => {
if (context.disabled) return;
isDraggingRef.current = true;
areaRef.current?.setPointerCapture(event.pointerId);
updateColorFromPosition(event.clientX, event.clientY);
},
[context.disabled, updateColorFromPosition],
);
const onPointerMove = React.useCallback(
(event: React.PointerEvent) => {
if (isDraggingRef.current) {
updateColorFromPosition(event.clientX, event.clientY);
}
},
[updateColorFromPosition],
);
const onPointerUp = React.useCallback((event: React.PointerEvent) => {
isDraggingRef.current = false;
areaRef.current?.releasePointerCapture(event.pointerId);
}, []);
const hue = hsv?.h ?? 0;
const backgroundHue = hsvToRgb({ h: hue, s: 100, v: 100, a: 1 });
const AreaPrimitive = asChild ? SlotPrimitive.Slot : "div";
return (
<AreaPrimitive
data-slot="color-picker-area"
{...areaProps}
className={cn(
"relative h-40 w-full cursor-crosshair touch-none rounded-md border",
context.disabled && "pointer-events-none opacity-50",
className,
)}
ref={composedRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div className="absolute inset-0 overflow-hidden rounded-md">
<div
className="absolute inset-0"
style={{
backgroundColor: `rgb(${backgroundHue.r}, ${backgroundHue.g}, ${backgroundHue.b})`,
}}
/>
<div
className="absolute inset-0"
style={{
background: "linear-gradient(to right, #fff, transparent)",
}}
/>
<div
className="absolute inset-0"
style={{
background: "linear-gradient(to bottom, transparent, #000)",
}}
/>
</div>
<div
className="absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white"
style={{
left: `${hsv?.s ?? 0}%`,
top: `${100 - (hsv?.v ?? 0)}%`,
}}
/>
</AreaPrimitive>
);
}
interface ColorPickerHueSliderProps extends React.ComponentProps<typeof SliderPrimitive.Root> {}
function ColorPickerHueSlider(props: ColorPickerHueSliderProps) {
const { className, ...sliderProps } = props;
const context = useColorPickerContext("ColorPickerHueSlider");
const store = useColorPickerStoreContext("ColorPickerHueSlider");
const hsv = useColorPickerStore((state) => state.hsv);
const onValueChange = React.useCallback(
(values: number[]) => {
const newHsv: HSVColorValue = {
h: values[0] ?? 0,
s: hsv?.s ?? 0,
v: hsv?.v ?? 0,
a: hsv?.a ?? 1,
};
store.setHsv(newHsv);
},
[hsv, store],
);
return (
<SliderPrimitive.Root
data-slot="color-picker-hue-slider"
{...sliderProps}
max={360}
step={1}
className={cn("relative flex w-full touch-none select-none items-center", className)}
value={[hsv?.h ?? 0]}
onValueChange={onValueChange}
disabled={context.disabled}
>
<SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-[linear-gradient(to_right,#ff0000_0%,#ffff00_16.66%,#00ff00_33.33%,#00ffff_50%,#0000ff_66.66%,#ff00ff_83.33%,#ff0000_100%)]">
<SliderPrimitive.Range className="absolute h-full" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block size-4 rounded-full border border-primary/50 bg-background transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
);
}
type ColorPickerEyeDropperProps = React.ComponentProps<typeof Button>;
function ColorPickerEyeDropper(props: ColorPickerEyeDropperProps) {
const { children, size, ...buttonProps } = props;
const context = useColorPickerContext("ColorPickerEyeDropper");
const store = useColorPickerStoreContext("ColorPickerEyeDropper");
const color = useColorPickerStore((state) => state.color);
const onEyeDropper = React.useCallback(async () => {
if (!window.EyeDropper) return;
try {
const eyeDropper = new window.EyeDropper();
const result = await eyeDropper.open();
if (result.sRGBHex) {
const currentAlpha = color?.a ?? 1;
const newColor = hexToRgb(result.sRGBHex, currentAlpha);
store.setColor(newColor);
}
} catch (error) {
console.warn("EyeDropper error:", error);
}
}, [color, store]);
const hasEyeDropper = typeof window !== "undefined" && !!window.EyeDropper;
if (!hasEyeDropper) return null;
const buttonSize = size ?? (children ? "default" : "icon");
return (
<Button
data-slot="color-picker-eye-dropper"
{...buttonProps}
variant="outline"
size={buttonSize}
onClick={onEyeDropper}
disabled={context.disabled}
>
{children ?? <EyedropperIcon />}
</Button>
);
}
interface ColorPickerAlphaSliderProps extends React.ComponentProps<typeof SliderPrimitive.Root> {}
function ColorPickerAlphaSlider(props: ColorPickerAlphaSliderProps) {
const { className, ...sliderProps } = props;
const context = useColorPickerContext("ColorPickerAlphaSlider");
const store = useColorPickerStoreContext("ColorPickerAlphaSlider");
const color = useColorPickerStore((state) => state.color);
const onValueChange = React.useCallback(
(values: number[]) => {
const alpha = (values[0] ?? 0) / 100;
const newColor = { ...color, a: alpha };
store.setColor(newColor);
},
[color, store],
);
const gradientColor = `rgb(${color?.r ?? 0}, ${color?.g ?? 0}, ${color?.b ?? 0})`;
return (
<SliderPrimitive.Root
data-slot="color-picker-alpha-slider"
{...sliderProps}
max={100}
step={1}
disabled={context.disabled}
className={cn("relative flex w-full touch-none select-none items-center", className)}
value={[Math.round((color?.a ?? 1) * 100)]}
onValueChange={onValueChange}
>
<SliderPrimitive.Track
className="relative h-3 w-full grow overflow-hidden rounded-full"
style={{
background:
"linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)",
backgroundSize: "8px 8px",
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
}}
>
<div
className="absolute inset-0 rounded-full"
style={{
background: `linear-gradient(to right, transparent, ${gradientColor})`,
}}
/>
<SliderPrimitive.Range className="absolute h-full" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block size-4 rounded-full border border-primary/50 bg-background transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
);
}
interface ColorPickerInputProps extends Omit<React.ComponentProps<typeof Input>, "value" | "onChange"> {
withoutAlpha?: boolean;
}
function ColorPickerInput(props: ColorPickerInputProps) {
const context = useColorPickerContext("ColorPickerInput");
const store = useColorPickerStoreContext("ColorPickerInput");
const color = useColorPickerStore((state) => state.color);
const onChannelChange = React.useCallback(
(channel: "r" | "g" | "b" | "a", max: number, isAlpha = false) =>
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = Number.parseInt(event.target.value, 10);
if (!Number.isNaN(value) && value >= 0 && value <= max) {
const newValue = isAlpha ? value / 100 : value;
const newColor = { ...color, [channel]: newValue };
store.setColor(newColor);
}
},
[color, store],
);
const rValue = Math.round(color?.r ?? 0);
const gValue = Math.round(color?.g ?? 0);
const bValue = Math.round(color?.b ?? 0);
const alphaValue = Math.round((color?.a ?? 1) * 100);
return (
<div data-slot="color-picker-input-wrapper" className="flex items-center gap-2">
<span>R</span>
<Input
type="number"
aria-label="Red color component (0-255)"
placeholder="0"
inputMode="numeric"
pattern="[0-9]*"
min="0"
max="255"
className="flex-1 shrink-0"
value={rValue}
onChange={onChannelChange("r", 255)}
disabled={context.disabled}
/>
<span>G</span>
<Input
type="number"
aria-label="Green color component (0-255)"
placeholder="0"
inputMode="numeric"
pattern="[0-9]*"
min="0"
max="255"
className="flex-1 shrink-0"
value={gValue}
onChange={onChannelChange("g", 255)}
disabled={context.disabled}
/>
<span>B</span>
<Input
type="number"
aria-label="Blue color component (0-255)"
placeholder="0"
inputMode="numeric"
pattern="[0-9]*"
min="0"
max="255"
className="flex-1 shrink-0"
value={bValue}
onChange={onChannelChange("b", 255)}
disabled={context.disabled}
/>
{!props.withoutAlpha && (
<>
<span>A</span>
<Input
type="number"
aria-label="Alpha transparency percentage"
placeholder="100"
inputMode="numeric"
pattern="[0-9]*"
min="0"
max="100"
className="flex-1 shrink-0"
value={alphaValue}
onChange={onChannelChange("a", 100, true)}
disabled={context.disabled}
/>
</>
)}
</div>
);
}
type ColorPickerProps = Omit<React.ComponentProps<typeof ColorPickerRoot>, never>;
const ColorPicker = (props: ColorPickerProps) => {
return (
<ColorPickerRoot {...props}>
<ColorPickerTrigger asChild>
<ColorPickerSwatch />
</ColorPickerTrigger>
<ColorPickerContent>
<ColorPickerArea />
<div className="flex items-center gap-4">
<ColorPickerEyeDropper />
<div className="flex flex-1 flex-col gap-4">
<ColorPickerHueSlider />
<ColorPickerAlphaSlider />
</div>
</div>
<ColorPickerInput />
</ColorPickerContent>
</ColorPickerRoot>
);
onChange?: (value: string) => void;
};
export {
ColorPicker,
//
ColorPickerRoot,
ColorPickerTrigger,
ColorPickerContent,
ColorPickerArea,
ColorPickerHueSlider,
ColorPickerAlphaSlider,
ColorPickerSwatch,
ColorPickerEyeDropper,
ColorPickerInput,
//
ColorPickerRoot as Root,
ColorPickerTrigger as Trigger,
ColorPickerContent as Content,
ColorPickerArea as Area,
ColorPickerHueSlider as HueSlider,
ColorPickerAlphaSlider as AlphaSlider,
ColorPickerSwatch as Swatch,
ColorPickerEyeDropper as EyeDropper,
ColorPickerInput as Input,
};
export function ColorPicker({ value, defaultValue, onChange }: ColorPickerProps) {
const [currentValue, setCurrentValue] = useControlledState<string>({
value,
defaultValue,
onChange,
});
const color = useMemo(() => rgbaStringToHsva(currentValue), [currentValue]);
function onColorChange(color: ColorResult) {
const rgbaString = hsvaToRgbaString(color.hsva);
setCurrentValue(rgbaString);
}
return (
<Popover>
<PopoverTrigger>
<div
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground transition-opacity hover:opacity-60"
style={{ backgroundColor: currentValue }}
/>
</PopoverTrigger>
<PopoverContent className="max-w-fit rounded-xl p-2">
<ReactColorColorful color={color} onChange={onColorChange} />
</PopoverContent>
</Popover>
);
}
+13 -9
View File
@@ -14,14 +14,18 @@ export function GithubStarsButton() {
: t`Star us on GitHub (opens in new tab)`;
return (
<Button asChild variant="outline">
<a target="_blank" href="https://github.com/amruthpillai/reactive-resume" aria-label={ariaLabel} rel="noopener">
<GithubLogoIcon aria-hidden="true" />
{starCount != null ? (
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
) : null}
<StarIcon aria-hidden="true" />
</a>
</Button>
<Button
variant="outline"
nativeButton={false}
render={
<a target="_blank" href="https://github.com/amruthpillai/reactive-resume" aria-label={ariaLabel} rel="noopener">
<GithubLogoIcon aria-hidden="true" />
{starCount != null ? (
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
) : null}
<StarIcon aria-hidden="true" />
</a>
}
/>
);
}
+7 -5
View File
@@ -91,11 +91,13 @@ export function IconPicker({ value, onChange, popoverProps, ...props }: IconPick
return (
<Popover {...popoverProps}>
<PopoverTrigger asChild>
<Button size="icon" variant="outline" {...props}>
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
</Button>
</PopoverTrigger>
<PopoverTrigger
render={
<Button size="icon" variant="outline" {...props}>
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
</Button>
}
/>
<PopoverContent align="start" className="h-[326px] w-[290px] gap-0 p-0">
<IconSearchInput value={search} onChange={setSearch} />
+56 -49
View File
@@ -50,7 +50,6 @@ import {
useEditorState,
} from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { VisuallyHidden } from "radix-ui";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
@@ -170,10 +169,10 @@ export function RichInput({ value, onChange, style, className, editorClassName,
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent className="flex h-[95svh] max-h-none! w-[95svw] max-w-none! flex-col p-4 sm:max-w-none! 2xl:max-w-none!">
<VisuallyHidden.Root>
<div className="sr-only">
<DialogTitle>Fullscreen Editor</DialogTitle>
<DialogDescription>Edit content in fullscreen mode</DialogDescription>
</VisuallyHidden.Root>
</div>
{editorElement}
</DialogContent>
</Dialog>
@@ -420,21 +419,24 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
<div className="mx-1 h-5 w-px bg-border" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
{match(state)
.with({ isParagraph: true }, () => <ParagraphIcon className="size-3.5" />)
.with({ isHeading1: true }, () => <TextHOneIcon className="size-3.5" />)
.with({ isHeading2: true }, () => <TextHTwoIcon className="size-3.5" />)
.with({ isHeading3: true }, () => <TextHThreeIcon className="size-3.5" />)
.with({ isHeading4: true }, () => <TextHFourIcon className="size-3.5" />)
.with({ isHeading5: true }, () => <TextHFiveIcon className="size-3.5" />)
.with({ isHeading6: true }, () => <TextHSixIcon className="size-3.5" />)
.otherwise(() => (
<ParagraphIcon className="size-3.5" />
))}
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
{match(state)
.with({ isParagraph: true }, () => <ParagraphIcon className="size-3.5" />)
.with({ isHeading1: true }, () => <TextHOneIcon className="size-3.5" />)
.with({ isHeading2: true }, () => <TextHTwoIcon className="size-3.5" />)
.with({ isHeading3: true }, () => <TextHThreeIcon className="size-3.5" />)
.with({ isHeading4: true }, () => <TextHFourIcon className="size-3.5" />)
.with({ isHeading5: true }, () => <TextHFiveIcon className="size-3.5" />)
.with({ isHeading6: true }, () => <TextHSixIcon className="size-3.5" />)
.otherwise(() => (
<ParagraphIcon className="size-3.5" />
))}
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuCheckboxItem
disabled={!state.canParagraph}
@@ -490,18 +492,21 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
{match(state)
.with({ isLeftAlign: true }, () => <TextAlignLeftIcon className="size-3.5" />)
.with({ isCenterAlign: true }, () => <TextAlignCenterIcon className="size-3.5" />)
.with({ isRightAlign: true }, () => <TextAlignRightIcon className="size-3.5" />)
.with({ isJustifyAlign: true }, () => <TextAlignJustifyIcon className="size-3.5" />)
.otherwise(() => (
<TextAlignLeftIcon className="size-3.5" />
))}
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
{match(state)
.with({ isLeftAlign: true }, () => <TextAlignLeftIcon className="size-3.5" />)
.with({ isCenterAlign: true }, () => <TextAlignCenterIcon className="size-3.5" />)
.with({ isRightAlign: true }, () => <TextAlignRightIcon className="size-3.5" />)
.with({ isJustifyAlign: true }, () => <TextAlignJustifyIcon className="size-3.5" />)
.otherwise(() => (
<TextAlignLeftIcon className="size-3.5" />
))}
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuCheckboxItem
disabled={!state.canLeftAlign}
@@ -631,52 +636,54 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
</Toggle>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Table`}
>
<TableIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Table`}
>
<TableIcon className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem disabled={!state.canInsertTable} onSelect={state.insertTable}>
<DropdownMenuItem disabled={!state.canInsertTable} onClick={state.insertTable}>
<PlusIcon />
<Trans>Insert Table</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canAddColumnBefore} onSelect={state.addColumnBefore}>
<DropdownMenuItem disabled={!state.canAddColumnBefore} onClick={state.addColumnBefore}>
<ColumnsPlusLeftIcon />
<Trans>Add Column Before</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canAddColumnAfter} onSelect={state.addColumnAfter}>
<DropdownMenuItem disabled={!state.canAddColumnAfter} onClick={state.addColumnAfter}>
<ColumnsPlusRightIcon />
<Trans>Add Column After</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canAddRowBefore} onSelect={state.addRowBefore}>
<DropdownMenuItem disabled={!state.canAddRowBefore} onClick={state.addRowBefore}>
<RowsPlusTopIcon />
<Trans>Add Row Before</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canAddRowAfter} onSelect={state.addRowAfter}>
<DropdownMenuItem disabled={!state.canAddRowAfter} onClick={state.addRowAfter}>
<RowsPlusBottomIcon />
<Trans>Add Row After</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canDeleteColumn} onSelect={state.deleteColumn}>
<DropdownMenuItem disabled={!state.canDeleteColumn} onClick={state.deleteColumn}>
<TrashSimpleIcon />
<Trans>Delete Column</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canDeleteRow} onSelect={state.deleteRow}>
<DropdownMenuItem disabled={!state.canDeleteRow} onClick={state.deleteRow}>
<TrashSimpleIcon />
<Trans>Delete Row</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={!state.canDeleteTable} onSelect={state.deleteTable}>
<DropdownMenuItem variant="destructive" disabled={!state.canDeleteTable} onClick={state.deleteTable}>
<TrashSimpleIcon />
<Trans>Delete Table</Trans>
</DropdownMenuItem>
+7 -5
View File
@@ -63,11 +63,13 @@ export function URLInput({ value, onChange, hideLabelButton, ...props }: Props)
{!hideLabelButton && (
<InputGroupAddon align="inline-end">
<Popover>
<PopoverTrigger asChild>
<InputGroupButton size="icon-sm" title={t`Add a label to the URL`}>
<TagIcon />
</InputGroupButton>
</PopoverTrigger>
<PopoverTrigger
render={
<InputGroupButton size="icon-sm" title={t`Add a label to the URL`}>
<TagIcon />
</InputGroupButton>
}
/>
<PopoverContent className="pt-3">
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
@@ -74,6 +74,7 @@ export function VisuallyHiddenInput<T = InputValue>(props: VisuallyHiddenInputPr
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
+1 -1
View File
@@ -18,7 +18,7 @@ export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
<AlertDescription>{routeId}</AlertDescription>
</Alert>
<Button asChild>
<Button>
<Link to="..">
<ArrowLeftIcon />
<Trans>Go Back</Trans>
+2 -2
View File
@@ -3,11 +3,11 @@ import { useMemo } from "react";
import { match } from "ts-pattern";
import type z from "zod";
import { levelDesignSchema } from "@/schema/resume/data";
import { Combobox, type ComboboxProps } from "../ui/combobox";
import { Combobox, type SingleComboboxProps } from "../ui/combobox";
type LevelType = z.infer<typeof levelDesignSchema>["type"];
type LevelTypeComboboxProps = Omit<ComboboxProps, "options">;
type LevelTypeComboboxProps = Omit<SingleComboboxProps, "options">;
export const getLevelTypeName = (type: LevelType) => {
return match(type)
+11 -3
View File
@@ -1,9 +1,9 @@
import { i18n } from "@lingui/core";
import { useLingui } from "@lingui/react";
import { isLocale, type Locale, loadLocale, localeMap, setLocaleServerFn } from "@/utils/locale";
import { Combobox, type ComboboxProps } from "../ui/combobox";
import { Combobox, type SingleComboboxProps } from "../ui/combobox";
type Props = Omit<ComboboxProps, "options" | "value" | "onValueChange">;
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
export const getLocaleOptions = () => {
return Object.entries(localeMap).map(([value, label]) => ({
@@ -22,5 +22,13 @@ export function LocaleCombobox(props: Props) {
window.location.reload();
};
return <Combobox options={getLocaleOptions()} defaultValue={i18n.locale} onValueChange={onLocaleChange} {...props} />;
return (
<Combobox
showClear={false}
defaultValue={i18n.locale}
options={getLocaleOptions()}
onValueChange={onLocaleChange}
{...props}
/>
);
}
-145
View File
@@ -1,145 +0,0 @@
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import * as React from "react";
import { useControlledState } from "@/hooks/use-controlled-state";
import { getStrictContext } from "@/utils/get-strict-context";
type AccordionContextType = {
value: string | string[] | undefined;
setValue: (value: string | string[] | undefined) => void;
};
type AccordionItemContextType = {
value: string;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
};
const [AccordionProvider, useAccordion] = getStrictContext<AccordionContextType>("AccordionContext");
const [AccordionItemProvider, useAccordionItem] = getStrictContext<AccordionItemContextType>("AccordionItemContext");
type AccordionProps = React.ComponentProps<typeof AccordionPrimitive.Root>;
function Accordion(props: AccordionProps) {
const [value, setValue] = useControlledState<string | string[] | undefined>({
value: props?.value,
defaultValue: props?.defaultValue,
onChange: props?.onValueChange as (value: string | string[] | undefined) => void,
});
return (
<AccordionProvider value={{ value, setValue }}>
<AccordionPrimitive.Root data-slot="accordion" {...props} onValueChange={setValue} />
</AccordionProvider>
);
}
type AccordionItemProps = React.ComponentProps<typeof AccordionPrimitive.Item>;
function AccordionItem(props: AccordionItemProps) {
const { value } = useAccordion();
const [isOpen, setIsOpen] = React.useState(value?.includes(props?.value) ?? false);
React.useEffect(() => {
setIsOpen(value?.includes(props?.value) ?? false);
}, [value, props?.value]);
return (
<AccordionItemProvider value={{ isOpen, setIsOpen, value: props.value }}>
<AccordionPrimitive.Item data-slot="accordion-item" {...props} />
</AccordionItemProvider>
);
}
type AccordionHeaderProps = React.ComponentProps<typeof AccordionPrimitive.Header>;
function AccordionHeader(props: AccordionHeaderProps) {
return <AccordionPrimitive.Header data-slot="accordion-header" {...props} />;
}
type AccordionTriggerProps = React.ComponentProps<typeof AccordionPrimitive.Trigger>;
function AccordionTrigger(props: AccordionTriggerProps) {
return <AccordionPrimitive.Trigger data-slot="accordion-trigger" {...props} />;
}
type AccordionContentProps = Omit<React.ComponentProps<typeof AccordionPrimitive.Content>, "asChild" | "forceMount"> &
HTMLMotionProps<"div"> & {
keepRendered?: boolean;
};
function AccordionContent({
keepRendered = false,
transition = { duration: 0.35, ease: "easeInOut" },
...props
}: AccordionContentProps) {
const { isOpen } = useAccordionItem();
return (
<AnimatePresence>
{keepRendered ? (
<AccordionPrimitive.Content asChild forceMount>
<motion.div
key="accordion-content"
data-slot="accordion-content"
initial={{ height: 0, opacity: 0, "--mask-stop": "0%", y: 20 }}
animate={
isOpen
? { height: "auto", opacity: 1, "--mask-stop": "100%", y: 0 }
: { height: 0, opacity: 0, "--mask-stop": "0%", y: 20 }
}
transition={transition}
style={{
maskImage: "linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
WebkitMaskImage: "linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
overflow: "hidden",
}}
{...props}
/>
</AccordionPrimitive.Content>
) : (
isOpen && (
<AccordionPrimitive.Content asChild forceMount>
<motion.div
key="accordion-content"
data-slot="accordion-content"
initial={{ height: 0, opacity: 0, "--mask-stop": "0%", y: 20 }}
animate={{
height: "auto",
opacity: 1,
"--mask-stop": "100%",
y: 0,
}}
exit={{ height: 0, opacity: 0, "--mask-stop": "0%", y: 20 }}
transition={transition}
style={{
maskImage: "linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
WebkitMaskImage: "linear-gradient(black var(--mask-stop), transparent var(--mask-stop))",
overflow: "hidden",
}}
{...props}
/>
</AccordionPrimitive.Content>
)
)}
</AnimatePresence>
);
}
export {
Accordion,
AccordionItem,
AccordionHeader,
AccordionTrigger,
AccordionContent,
useAccordion,
useAccordionItem,
type AccordionProps,
type AccordionItemProps,
type AccordionHeaderProps,
type AccordionTriggerProps,
type AccordionContentProps,
type AccordionContextType,
type AccordionItemContextType,
};
-186
View File
@@ -1,186 +0,0 @@
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import type * as React from "react";
import { useControlledState } from "@/hooks/use-controlled-state";
import { getStrictContext } from "@/utils/get-strict-context";
type AlertDialogContextType = {
isOpen: boolean;
setIsOpen: AlertDialogProps["onOpenChange"];
};
const [AlertDialogProvider, useAlertDialog] = getStrictContext<AlertDialogContextType>("AlertDialogContext");
type AlertDialogProps = React.ComponentProps<typeof AlertDialogPrimitive.Root>;
function AlertDialog(props: AlertDialogProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<AlertDialogProvider value={{ isOpen, setIsOpen }}>
<AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} onOpenChange={setIsOpen} />
</AlertDialogProvider>
);
}
type AlertDialogTriggerProps = React.ComponentProps<typeof AlertDialogPrimitive.Trigger>;
function AlertDialogTrigger(props: AlertDialogTriggerProps) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
type AlertDialogPortalProps = Omit<React.ComponentProps<typeof AlertDialogPrimitive.Portal>, "forceMount">;
function AlertDialogPortal(props: AlertDialogPortalProps) {
const { isOpen } = useAlertDialog();
return (
<AnimatePresence>
{isOpen && <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" forceMount {...props} />}
</AnimatePresence>
);
}
type AlertDialogOverlayProps = Omit<
React.ComponentProps<typeof AlertDialogPrimitive.Overlay>,
"forceMount" | "asChild"
> &
HTMLMotionProps<"div">;
function AlertDialogOverlay({ transition = { duration: 0.2, ease: "easeInOut" }, ...props }: AlertDialogOverlayProps) {
return (
<AlertDialogPrimitive.Overlay data-slot="alert-dialog-overlay" asChild forceMount>
<motion.div
key="alert-dialog-overlay"
initial={{ opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(4px)" }}
transition={transition}
{...props}
/>
</AlertDialogPrimitive.Overlay>
);
}
type AlertDialogFlipDirection = "top" | "bottom" | "left" | "right";
type AlertDialogContentProps = Omit<
React.ComponentProps<typeof AlertDialogPrimitive.Content>,
"forceMount" | "asChild"
> &
HTMLMotionProps<"div"> & {
from?: AlertDialogFlipDirection;
};
function AlertDialogContent({
from = "top",
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
transition = { type: "spring", stiffness: 250, damping: 25 },
...props
}: AlertDialogContentProps) {
const initialRotation = from === "bottom" || from === "left" ? "20deg" : "-20deg";
const isVertical = from === "top" || from === "bottom";
const rotateAxis = isVertical ? "rotateX" : "rotateY";
return (
<AlertDialogPrimitive.Content
asChild
forceMount
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
>
<motion.div
key="alert-dialog-content"
data-slot="alert-dialog-content"
transition={transition}
initial={{
opacity: 0,
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
animate={{
opacity: 1,
filter: "blur(0px)",
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
}}
exit={{
opacity: 0,
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
{...props}
/>
</AlertDialogPrimitive.Content>
);
}
type AlertDialogCancelProps = React.ComponentProps<typeof AlertDialogPrimitive.Cancel>;
function AlertDialogCancel(props: AlertDialogCancelProps) {
return <AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" {...props} />;
}
type AlertDialogActionProps = React.ComponentProps<typeof AlertDialogPrimitive.Action>;
function AlertDialogAction(props: AlertDialogActionProps) {
return <AlertDialogPrimitive.Action data-slot="alert-dialog-action" {...props} />;
}
type AlertDialogHeaderProps = React.ComponentProps<"div">;
function AlertDialogHeader(props: AlertDialogHeaderProps) {
return <div data-slot="alert-dialog-header" {...props} />;
}
type AlertDialogFooterProps = React.ComponentProps<"div">;
function AlertDialogFooter(props: AlertDialogFooterProps) {
return <div data-slot="alert-dialog-footer" {...props} />;
}
type AlertDialogTitleProps = React.ComponentProps<typeof AlertDialogPrimitive.Title>;
function AlertDialogTitle(props: AlertDialogTitleProps) {
return <AlertDialogPrimitive.Title data-slot="alert-dialog-title" {...props} />;
}
type AlertDialogDescriptionProps = React.ComponentProps<typeof AlertDialogPrimitive.Description>;
function AlertDialogDescription(props: AlertDialogDescriptionProps) {
return <AlertDialogPrimitive.Description data-slot="alert-dialog-description" {...props} />;
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogCancel,
AlertDialogAction,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
useAlertDialog,
type AlertDialogProps,
type AlertDialogTriggerProps,
type AlertDialogPortalProps,
type AlertDialogCancelProps,
type AlertDialogActionProps,
type AlertDialogOverlayProps,
type AlertDialogContentProps,
type AlertDialogHeaderProps,
type AlertDialogFooterProps,
type AlertDialogTitleProps,
type AlertDialogDescriptionProps,
type AlertDialogContextType,
type AlertDialogFlipDirection,
};
-19
View File
@@ -1,19 +0,0 @@
import { type HTMLMotionProps, motion } from "motion/react";
import { Slot, type WithAsChild } from "@/components/primitives/slot";
type ButtonProps = WithAsChild<
Omit<HTMLMotionProps<"button">, "children"> & {
hoverScale?: number;
tapScale?: number;
// biome-ignore lint/suspicious/noExplicitAny: unknown type
children?: any;
}
>;
function Button({ hoverScale = 1.0, tapScale = 0.95, asChild = false, ...props }: ButtonProps) {
const Component = asChild ? Slot : motion.button;
return <Component whileTap={{ scale: tapScale }} whileHover={{ scale: hoverScale }} {...props} />;
}
export { Button, type ButtonProps };
-176
View File
@@ -1,176 +0,0 @@
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { useControlledState } from "@/hooks/use-controlled-state";
import { getStrictContext } from "@/utils/get-strict-context";
type DialogContextType = {
isOpen: boolean;
setIsOpen: DialogProps["onOpenChange"];
};
const [DialogProvider, useDialog] = getStrictContext<DialogContextType>("DialogContext");
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
function Dialog(props: DialogProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<DialogProvider value={{ isOpen, setIsOpen }}>
<DialogPrimitive.Root data-slot="dialog" {...props} onOpenChange={setIsOpen} />
</DialogProvider>
);
}
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
function DialogTrigger(props: DialogTriggerProps) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
type DialogPortalProps = Omit<React.ComponentProps<typeof DialogPrimitive.Portal>, "forceMount">;
function DialogPortal(props: DialogPortalProps) {
const { isOpen } = useDialog();
return (
<AnimatePresence>
{isOpen && <DialogPrimitive.Portal data-slot="dialog-portal" forceMount {...props} />}
</AnimatePresence>
);
}
type DialogOverlayProps = Omit<React.ComponentProps<typeof DialogPrimitive.Overlay>, "forceMount" | "asChild"> &
HTMLMotionProps<"div">;
function DialogOverlay({ transition = { duration: 0.2, ease: "easeInOut" }, ...props }: DialogOverlayProps) {
return (
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
<motion.div
key="dialog-overlay"
initial={{ opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(4px)" }}
transition={transition}
{...props}
/>
</DialogPrimitive.Overlay>
);
}
type DialogFlipDirection = "top" | "bottom" | "left" | "right";
type DialogContentProps = Omit<React.ComponentProps<typeof DialogPrimitive.Content>, "forceMount" | "asChild"> &
HTMLMotionProps<"div"> & {
from?: DialogFlipDirection;
};
function DialogContent({
from = "top",
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
onPointerDownOutside,
onInteractOutside,
transition = { type: "spring", stiffness: 250, damping: 25 },
...props
}: DialogContentProps) {
const initialRotation = from === "bottom" || from === "left" ? "20deg" : "-20deg";
const isVertical = from === "top" || from === "bottom";
const rotateAxis = isVertical ? "rotateX" : "rotateY";
return (
<DialogPrimitive.Content
asChild
forceMount
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onInteractOutside={onInteractOutside}
>
<motion.div
key="dialog-content"
data-slot="dialog-content"
transition={transition}
initial={{
opacity: 0,
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
animate={{
opacity: 1,
filter: "blur(0px)",
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
}}
exit={{
opacity: 0,
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
{...props}
/>
</DialogPrimitive.Content>
);
}
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
function DialogClose(props: DialogCloseProps) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
type DialogHeaderProps = React.ComponentProps<"div">;
function DialogHeader(props: DialogHeaderProps) {
return <div data-slot="dialog-header" {...props} />;
}
type DialogFooterProps = React.ComponentProps<"div">;
function DialogFooter(props: DialogFooterProps) {
return <div data-slot="dialog-footer" {...props} />;
}
type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>;
function DialogTitle(props: DialogTitleProps) {
return <DialogPrimitive.Title data-slot="dialog-title" {...props} />;
}
type DialogDescriptionProps = React.ComponentProps<typeof DialogPrimitive.Description>;
function DialogDescription(props: DialogDescriptionProps) {
return <DialogPrimitive.Description data-slot="dialog-description" {...props} />;
}
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
useDialog,
type DialogProps,
type DialogTriggerProps,
type DialogPortalProps,
type DialogCloseProps,
type DialogOverlayProps,
type DialogContentProps,
type DialogHeaderProps,
type DialogFooterProps,
type DialogTitleProps,
type DialogDescriptionProps,
type DialogContextType,
type DialogFlipDirection,
};
-348
View File
@@ -1,348 +0,0 @@
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { useControlledState } from "@/hooks/use-controlled-state";
import { getStrictContext } from "@/utils/get-strict-context";
type DropdownMenuContextType = {
isOpen: boolean;
setIsOpen: (o: boolean) => void;
};
type DropdownMenuSubContextType = {
isOpen: boolean;
setIsOpen: (o: boolean) => void;
};
const [DropdownMenuProvider, useDropdownMenu] = getStrictContext<DropdownMenuContextType>("DropdownMenuContext");
const [DropdownMenuSubProvider, useDropdownMenuSub] =
getStrictContext<DropdownMenuSubContextType>("DropdownMenuSubContext");
type DropdownMenuProps = React.ComponentProps<typeof DropdownMenuPrimitive.Root>;
function DropdownMenu(props: DropdownMenuProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<DropdownMenuProvider value={{ isOpen, setIsOpen }}>
<DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} onOpenChange={setIsOpen} />
</DropdownMenuProvider>
);
}
type DropdownMenuTriggerProps = React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>;
function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
type DropdownMenuPortalProps = React.ComponentProps<typeof DropdownMenuPrimitive.Portal>;
function DropdownMenuPortal(props: DropdownMenuPortalProps) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
type DropdownMenuGroupProps = React.ComponentProps<typeof DropdownMenuPrimitive.Group>;
function DropdownMenuGroup(props: DropdownMenuGroupProps) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
type DropdownMenuSubProps = React.ComponentProps<typeof DropdownMenuPrimitive.Sub>;
function DropdownMenuSub(props: DropdownMenuSubProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<DropdownMenuSubProvider value={{ isOpen, setIsOpen }}>
<DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} onOpenChange={setIsOpen} />
</DropdownMenuSubProvider>
);
}
type DropdownMenuRadioGroupProps = React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>;
function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
type DropdownMenuSubTriggerProps = Omit<React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger>, "asChild"> &
HTMLMotionProps<"div">;
function DropdownMenuSubTrigger({ disabled, textValue, ...props }: DropdownMenuSubTriggerProps) {
return (
<DropdownMenuPrimitive.SubTrigger disabled={disabled} textValue={textValue} asChild>
<motion.div data-slot="dropdown-menu-sub-trigger" data-disabled={disabled} {...props} />
</DropdownMenuPrimitive.SubTrigger>
);
}
type DropdownMenuSubContentProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>,
"forceMount" | "asChild"
> &
Omit<React.ComponentProps<typeof DropdownMenuPrimitive.Portal>, "forceMount"> &
HTMLMotionProps<"div">;
function DropdownMenuSubContent({
loop,
onEscapeKeyDown,
onPointerDownOutside,
onFocusOutside,
onInteractOutside,
sideOffset,
alignOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
transition = { duration: 0.2 },
style,
container,
...props
}: DropdownMenuSubContentProps) {
const { isOpen } = useDropdownMenuSub();
return (
<AnimatePresence>
{isOpen && (
<DropdownMenuPortal forceMount container={container}>
<DropdownMenuPrimitive.SubContent
asChild
forceMount
loop={loop}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onFocusOutside={onFocusOutside}
onInteractOutside={onInteractOutside}
sideOffset={sideOffset}
alignOffset={alignOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
>
<motion.div
key="dropdown-menu-sub-content"
data-slot="dropdown-menu-sub-content"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={transition}
style={{ willChange: "opacity, transform", ...style }}
{...props}
/>
</DropdownMenuPrimitive.SubContent>
</DropdownMenuPortal>
)}
</AnimatePresence>
);
}
type DropdownMenuContentProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.Content>,
"forceMount" | "asChild"
> &
Omit<React.ComponentProps<typeof DropdownMenuPrimitive.Portal>, "forceMount"> &
HTMLMotionProps<"div">;
function DropdownMenuContent({
loop,
onCloseAutoFocus,
onEscapeKeyDown,
onPointerDownOutside,
onFocusOutside,
onInteractOutside,
side,
sideOffset,
align,
alignOffset,
avoidCollisions,
collisionBoundary,
collisionPadding,
arrowPadding,
sticky,
hideWhenDetached,
transition = { duration: 0.2 },
style,
container,
...props
}: DropdownMenuContentProps) {
const { isOpen } = useDropdownMenu();
return (
<AnimatePresence>
{isOpen && (
<DropdownMenuPortal forceMount container={container}>
<DropdownMenuPrimitive.Content
asChild
loop={loop}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onFocusOutside={onFocusOutside}
onInteractOutside={onInteractOutside}
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
avoidCollisions={avoidCollisions}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
arrowPadding={arrowPadding}
sticky={sticky}
hideWhenDetached={hideWhenDetached}
>
<motion.div
key="dropdown-menu-content"
data-slot="dropdown-menu-content"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={transition}
style={{ willChange: "opacity, transform", ...style }}
{...props}
/>
</DropdownMenuPrimitive.Content>
</DropdownMenuPortal>
)}
</AnimatePresence>
);
}
type DropdownMenuItemProps = Omit<React.ComponentProps<typeof DropdownMenuPrimitive.Item>, "asChild"> &
HTMLMotionProps<"div">;
function DropdownMenuItem({ disabled, onSelect, textValue, ...props }: DropdownMenuItemProps) {
return (
<DropdownMenuPrimitive.Item disabled={disabled} onSelect={onSelect} textValue={textValue} asChild>
<motion.div data-slot="dropdown-menu-item" data-disabled={disabled} {...props} />
</DropdownMenuPrimitive.Item>
);
}
type DropdownMenuCheckboxItemProps = Omit<React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>, "asChild"> &
HTMLMotionProps<"div">;
function DropdownMenuCheckboxItem({
checked,
onCheckedChange,
disabled,
onSelect,
textValue,
...props
}: DropdownMenuCheckboxItemProps) {
return (
<DropdownMenuPrimitive.CheckboxItem
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
onSelect={onSelect}
textValue={textValue}
asChild
>
<motion.div data-slot="dropdown-menu-checkbox-item" data-disabled={disabled} {...props} />
</DropdownMenuPrimitive.CheckboxItem>
);
}
type DropdownMenuRadioItemProps = Omit<React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>, "asChild"> &
HTMLMotionProps<"div">;
function DropdownMenuRadioItem({ value, disabled, onSelect, textValue, ...props }: DropdownMenuRadioItemProps) {
return (
<DropdownMenuPrimitive.RadioItem
value={value}
disabled={disabled}
onSelect={onSelect}
textValue={textValue}
asChild
>
<motion.div data-slot="dropdown-menu-radio-item" data-disabled={disabled} {...props} />
</DropdownMenuPrimitive.RadioItem>
);
}
type DropdownMenuLabelProps = React.ComponentProps<typeof DropdownMenuPrimitive.Label>;
function DropdownMenuLabel(props: DropdownMenuLabelProps) {
return <DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" {...props} />;
}
type DropdownMenuSeparatorProps = React.ComponentProps<typeof DropdownMenuPrimitive.Separator>;
function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) {
return <DropdownMenuPrimitive.Separator data-slot="dropdown-menu-separator" {...props} />;
}
type DropdownMenuShortcutProps = React.ComponentProps<"span">;
function DropdownMenuShortcut(props: DropdownMenuShortcutProps) {
return <span data-slot="dropdown-menu-shortcut" {...props} />;
}
type DropdownMenuItemIndicatorProps = Omit<
React.ComponentProps<typeof DropdownMenuPrimitive.ItemIndicator>,
"asChild"
> &
HTMLMotionProps<"div">;
function DropdownMenuItemIndicator(props: DropdownMenuItemIndicatorProps) {
return (
<DropdownMenuPrimitive.ItemIndicator data-slot="dropdown-menu-item-indicator" asChild>
<motion.div {...props} />
</DropdownMenuPrimitive.ItemIndicator>
);
}
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIndicator,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
useDropdownMenu,
useDropdownMenuSub,
type DropdownMenuProps,
type DropdownMenuTriggerProps,
type DropdownMenuContentProps,
type DropdownMenuItemProps,
type DropdownMenuItemIndicatorProps,
type DropdownMenuCheckboxItemProps,
type DropdownMenuRadioItemProps,
type DropdownMenuLabelProps,
type DropdownMenuSeparatorProps,
type DropdownMenuShortcutProps,
type DropdownMenuGroupProps,
type DropdownMenuPortalProps,
type DropdownMenuSubProps,
type DropdownMenuSubContentProps,
type DropdownMenuSubTriggerProps,
type DropdownMenuRadioGroupProps,
type DropdownMenuContextType,
type DropdownMenuSubContextType,
};
-67
View File
@@ -1,67 +0,0 @@
import { type HTMLMotionProps, isMotionComponent, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/utils/style";
type AnyProps = Record<string, unknown>;
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<HTMLMotionProps<keyof HTMLElementTagNameMap>, "ref"> & {
ref?: React.Ref<T>;
};
type WithAsChild<Base extends object> =
| (Base & { asChild: true; children: React.ReactElement })
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
children?: React.ReactElement;
} & DOMMotionProps<T>;
function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
return (node) => {
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === "function") {
ref(node);
} else {
(ref as React.RefObject<T | null>).current = node;
}
});
};
}
function mergeProps<T extends HTMLElement>(childProps: AnyProps, slotProps: DOMMotionProps<T>): AnyProps {
const merged: AnyProps = { ...childProps, ...slotProps };
if (childProps.className || slotProps.className) {
merged.className = cn(childProps.className as string, slotProps.className as string);
}
if (childProps.style || slotProps.style) {
merged.style = {
...(childProps.style as React.CSSProperties),
...(slotProps.style as React.CSSProperties),
};
}
return merged;
}
function Slot<T extends HTMLElement = HTMLElement>({ children, ref, ...props }: SlotProps<T>) {
const isAlreadyMotion =
children && typeof children.type === "object" && children.type !== null && isMotionComponent(children.type);
const Base = React.useMemo(
() => (isAlreadyMotion ? (children.type as React.ElementType) : motion.create(children?.type as React.ElementType)),
[isAlreadyMotion, children],
);
if (!React.isValidElement(children)) return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
return <Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />;
}
export { Slot, type SlotProps, type WithAsChild, type DOMMotionProps, type AnyProps };
-113
View File
@@ -1,113 +0,0 @@
import { type HTMLMotionProps, motion, type TargetAndTransition, type VariantLabels } from "motion/react";
import { Switch as SwitchPrimitives } from "radix-ui";
import * as React from "react";
import { useControlledState } from "@/hooks/use-controlled-state";
import { getStrictContext } from "@/utils/get-strict-context";
type SwitchContextType = {
isChecked: boolean;
setIsChecked: (isChecked: boolean) => void;
isPressed: boolean;
setIsPressed: (isPressed: boolean) => void;
};
const [SwitchProvider, useSwitch] = getStrictContext<SwitchContextType>("SwitchContext");
type SwitchProps = Omit<React.ComponentProps<typeof SwitchPrimitives.Root>, "asChild"> & HTMLMotionProps<"button">;
function Switch(props: SwitchProps) {
const [isPressed, setIsPressed] = React.useState(false);
const { checked, defaultChecked, onCheckedChange, ...restProps } = props;
const [isChecked, setIsChecked] = useControlledState({
value: checked,
defaultValue: defaultChecked,
onChange: onCheckedChange,
});
return (
<SwitchProvider value={{ isChecked, setIsChecked, isPressed, setIsPressed }}>
<SwitchPrimitives.Root
{...restProps}
asChild
checked={isChecked}
defaultChecked={defaultChecked}
onCheckedChange={setIsChecked}
>
<motion.button
{...restProps}
whileTap="tap"
initial={false}
data-slot="switch"
onTap={() => setIsPressed(false)}
onTapStart={() => setIsPressed(true)}
onTapCancel={() => setIsPressed(false)}
/>
</SwitchPrimitives.Root>
</SwitchProvider>
);
}
type SwitchThumbProps = Omit<React.ComponentProps<typeof SwitchPrimitives.Thumb>, "asChild"> &
HTMLMotionProps<"div"> & {
pressedAnimation?: TargetAndTransition | VariantLabels | boolean;
};
function SwitchThumb({
pressedAnimation,
transition = { type: "spring", stiffness: 300, damping: 25 },
...props
}: SwitchThumbProps) {
const { isPressed } = useSwitch();
return (
<SwitchPrimitives.Thumb asChild>
<motion.div
layout
whileTap="tab"
data-slot="switch-thumb"
transition={transition}
animate={isPressed ? pressedAnimation : undefined}
{...props}
/>
</SwitchPrimitives.Thumb>
);
}
type SwitchIconPosition = "left" | "right" | "thumb";
type SwitchIconProps = HTMLMotionProps<"div"> & {
position: SwitchIconPosition;
};
function SwitchIcon({ position, transition = { type: "spring", bounce: 0 }, ...props }: SwitchIconProps) {
const { isChecked } = useSwitch();
const isAnimated = React.useMemo(() => {
if (position === "right") return !isChecked;
if (position === "left") return isChecked;
if (position === "thumb") return true;
return false;
}, [position, isChecked]);
return (
<motion.div
data-slot={`switch-${position}-icon`}
animate={isAnimated ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
transition={transition}
{...props}
/>
);
}
export {
Switch,
SwitchThumb,
SwitchIcon,
useSwitch,
type SwitchProps,
type SwitchThumbProps,
type SwitchIconProps,
type SwitchIconPosition,
type SwitchContextType,
};
+3 -3
View File
@@ -1,10 +1,10 @@
import { useLingui } from "@lingui/react";
import { useRouter } from "@tanstack/react-router";
import { isTheme, setThemeServerFn, themeMap } from "@/utils/theme";
import { Combobox, type ComboboxProps } from "../ui/combobox";
import { Combobox, type SingleComboboxProps } from "../ui/combobox";
import { useTheme } from "./provider";
type Props = Omit<ComboboxProps, "options" | "value" | "onValueChange">;
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
export function ThemeCombobox(props: Props) {
const router = useRouter();
@@ -24,5 +24,5 @@ export function ThemeCombobox(props: Props) {
router.invalidate();
};
return <Combobox options={options} defaultValue={theme} onValueChange={onThemeChange} {...props} />;
return <Combobox {...props} showClear={false} options={options} defaultValue={theme} onValueChange={onThemeChange} />;
}
+5 -6
View File
@@ -1,7 +1,6 @@
import { useMemo } from "react";
import { cn } from "@/utils/style";
import { Combobox, type ComboboxProps } from "../ui/combobox";
import { MultipleCombobox, type MultipleComboboxProps } from "../ui/multiple-combobox";
import { Combobox, type MultiComboboxProps, type SingleComboboxProps } from "../ui/combobox";
import { FontDisplay } from "./font-display";
import type { LocalFont, WebFont } from "./types";
import webFontListJSON from "./webfontlist.json";
@@ -61,7 +60,7 @@ export function getNextWeights(fontFamily: string): Weight[] | null {
return weights.length > 0 ? weights : null;
}
type FontFamilyComboboxProps = Omit<ComboboxProps, "options">;
type FontFamilyComboboxProps = Omit<SingleComboboxProps, "options">;
export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) {
const options = useMemo(() => {
@@ -72,10 +71,10 @@ export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxPr
}));
}, []);
return <Combobox options={options} className={cn("w-full", className)} {...props} />;
return <Combobox {...props} options={options} className={cn("w-full", className)} />;
}
type FontWeightComboboxProps = Omit<MultipleComboboxProps, "options"> & { fontFamily: string };
type FontWeightComboboxProps = Omit<MultiComboboxProps, "options" | "multiple"> & { fontFamily: string };
export function FontWeightCombobox({ fontFamily, ...props }: FontWeightComboboxProps) {
const options = useMemo(() => {
@@ -100,5 +99,5 @@ export function FontWeightCombobox({ fontFamily, ...props }: FontWeightComboboxP
}));
}, [fontFamily]);
return <MultipleCombobox options={options} {...props} />;
return <Combobox {...props} multiple options={options} />;
}
+34 -45
View File
@@ -1,63 +1,52 @@
import {
AccordionContent as AccordionContentPrimitive,
type AccordionContentProps as AccordionContentPrimitiveProps,
AccordionHeader as AccordionHeaderPrimitive,
AccordionItem as AccordionItemPrimitive,
type AccordionItemProps as AccordionItemPrimitiveProps,
Accordion as AccordionPrimitive,
type AccordionProps as AccordionPrimitiveProps,
AccordionTrigger as AccordionTriggerPrimitive,
type AccordionTriggerProps as AccordionTriggerPrimitiveProps,
} from "@/components/primitives/accordion";
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
import { CaretUpIcon } from "@phosphor-icons/react";
import { cn } from "@/utils/style";
type AccordionProps = AccordionPrimitiveProps;
function Accordion(props: AccordionProps) {
return <AccordionPrimitive {...props} />;
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return <AccordionPrimitive.Root data-slot="accordion" className={cn("flex w-full flex-col", className)} {...props} />;
}
type AccordionItemProps = AccordionItemPrimitiveProps;
function AccordionItem({ className, ...props }: AccordionItemProps) {
return <AccordionItemPrimitive className={cn(className)} {...props} />;
}
type AccordionTriggerProps = AccordionTriggerPrimitiveProps;
function AccordionTrigger({ className, children, ...props }: AccordionTriggerProps) {
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionHeaderPrimitive className="flex">
<AccordionTriggerPrimitive
<AccordionPrimitive.Item data-slot="accordion-item" className={cn("not-last:border-b", className)} {...props} />
);
}
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-start font-medium text-sm outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=closed]>svg]:rotate-0 [&[data-state=open]>svg]:rotate-90 [&[data-state=open]>svg]:transition-transform",
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-md border border-transparent py-4 text-left font-medium text-sm outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className,
)}
{...props}
>
{children}
</AccordionTriggerPrimitive>
</AccordionHeaderPrimitive>
<CaretUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
type AccordionContentProps = AccordionContentPrimitiveProps;
function AccordionContent({ className, children, ...props }: AccordionContentProps) {
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
return (
<AccordionContentPrimitive {...props}>
<div className={cn("pt-0 pb-4 text-sm", className)}>{children}</div>
</AccordionContentPrimitive>
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-sm data-closed:animate-accordion-up data-open:animate-accordion-down"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className,
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
);
}
export {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
type AccordionProps,
type AccordionItemProps,
type AccordionTriggerProps,
type AccordionContentProps,
};
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
+127 -96
View File
@@ -1,124 +1,155 @@
import {
AlertDialogAction as AlertDialogActionPrimitive,
type AlertDialogActionProps as AlertDialogActionPrimitiveProps,
AlertDialogCancel as AlertDialogCancelPrimitive,
type AlertDialogCancelProps as AlertDialogCancelPrimitiveProps,
AlertDialogContent as AlertDialogContentPrimitive,
type AlertDialogContentProps as AlertDialogContentPrimitiveProps,
AlertDialogDescription as AlertDialogDescriptionPrimitive,
type AlertDialogDescriptionProps as AlertDialogDescriptionPrimitiveProps,
AlertDialogFooter as AlertDialogFooterPrimitive,
type AlertDialogFooterProps as AlertDialogFooterPrimitiveProps,
AlertDialogHeader as AlertDialogHeaderPrimitive,
type AlertDialogHeaderProps as AlertDialogHeaderPrimitiveProps,
AlertDialogOverlay as AlertDialogOverlayPrimitive,
type AlertDialogOverlayProps as AlertDialogOverlayPrimitiveProps,
AlertDialogPortal as AlertDialogPortalPrimitive,
AlertDialog as AlertDialogPrimitive,
type AlertDialogProps as AlertDialogPrimitiveProps,
AlertDialogTitle as AlertDialogTitlePrimitive,
type AlertDialogTitleProps as AlertDialogTitlePrimitiveProps,
AlertDialogTrigger as AlertDialogTriggerPrimitive,
type AlertDialogTriggerProps as AlertDialogTriggerPrimitiveProps,
} from "@/components/primitives/alert-dialog";
import { buttonVariants } from "@/components/ui/button";
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/style";
type AlertDialogProps = AlertDialogPrimitiveProps;
function AlertDialog(props: AlertDialogProps) {
return <AlertDialogPrimitive {...props} />;
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
type AlertDialogTriggerProps = AlertDialogTriggerPrimitiveProps;
function AlertDialogTrigger(props: AlertDialogTriggerProps) {
return <AlertDialogTriggerPrimitive {...props} />;
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
type AlertDialogOverlayProps = AlertDialogOverlayPrimitiveProps;
function AlertDialogOverlay({ className, ...props }: AlertDialogOverlayProps) {
return <AlertDialogOverlayPrimitive className={cn("fixed inset-0 z-50 bg-black/50", className)} {...props} />;
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
type AlertDialogContentProps = AlertDialogContentPrimitiveProps;
function AlertDialogContent({ className, ...props }: AlertDialogContentProps) {
function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPortalPrimitive>
<AlertDialogOverlay />
<AlertDialogContentPrimitive
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortalPrimitive>
);
}
type AlertDialogHeaderProps = AlertDialogHeaderPrimitiveProps;
function AlertDialogHeader({ className, ...props }: AlertDialogHeaderProps) {
return (
<AlertDialogHeaderPrimitive className={cn("flex flex-col gap-2 text-center sm:text-start", className)} {...props} />
);
}
type AlertDialogFooterProps = AlertDialogFooterPrimitiveProps;
function AlertDialogFooter({ className, ...props }: AlertDialogFooterProps) {
return (
<AlertDialogFooterPrimitive
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"data-open:fade-in-0 data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 data-closed:animate-out data-open:animate-in supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
);
}
type AlertDialogTitleProps = AlertDialogTitlePrimitiveProps;
function AlertDialogTitle({ className, ...props }: AlertDialogTitleProps) {
return <AlertDialogTitlePrimitive className={cn("font-semibold text-lg", className)} {...props} />;
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-background p-6 outline-none ring-1 ring-foreground/10 duration-100 data-[size=default]:max-w-2xl data-[size=sm]:max-w-sm data-closed:animate-out data-open:animate-in data-[size=default]:sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
type AlertDialogDescriptionProps = AlertDialogDescriptionPrimitiveProps;
function AlertDialogDescription({ className, ...props }: AlertDialogDescriptionProps) {
return <AlertDialogDescriptionPrimitive className={cn("text-muted-foreground text-sm", className)} {...props} />;
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
type AlertDialogActionProps = AlertDialogActionPrimitiveProps;
function AlertDialogAction({ className, ...props }: AlertDialogActionPrimitiveProps) {
return <AlertDialogActionPrimitive className={cn(buttonVariants(), className)} {...props} />;
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
type AlertDialogCancelProps = AlertDialogCancelPrimitiveProps;
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className,
)}
{...props}
/>
);
}
function AlertDialogCancel({ className, ...props }: AlertDialogCancelPrimitiveProps) {
return <AlertDialogCancelPrimitive className={cn(buttonVariants({ variant: "outline" }), className)} {...props} />;
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-medium text-lg sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-balance text-muted-foreground text-sm md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof Button>) {
return <Button data-slot="alert-dialog-action" className={cn(className)} {...props} />;
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
type AlertDialogProps,
type AlertDialogTriggerProps,
type AlertDialogContentProps,
type AlertDialogHeaderProps,
type AlertDialogFooterProps,
type AlertDialogTitleProps,
type AlertDialogDescriptionProps,
type AlertDialogActionProps,
type AlertDialogCancelProps,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+1 -1
View File
@@ -52,4 +52,4 @@ function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-action" className={cn("absolute inset-e-3 top-2.5", className)} {...props} />;
}
export { Alert, AlertTitle, AlertDescription, AlertAction };
export { Alert, AlertAction, AlertDescription, AlertTitle };
+5 -5
View File
@@ -1,4 +1,4 @@
import { Avatar as AvatarPrimitive } from "radix-ui";
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import type * as React from "react";
import { cn } from "@/utils/style";
@@ -6,7 +6,7 @@ function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg";
}) {
return (
@@ -22,7 +22,7 @@ function Avatar({
);
}
function AvatarImage({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
@@ -32,7 +32,7 @@ function AvatarImage({ className, ...props }: React.ComponentProps<typeof Avatar
);
}
function AvatarFallback({ className, ...props }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
@@ -87,4 +87,4 @@ function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">)
);
}
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge };
export { Avatar, AvatarBadge, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage };
+11 -10
View File
@@ -1,6 +1,6 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import type * as React from "react";
import { cn } from "@/utils/style";
const badgeVariants = cva(
@@ -26,14 +26,15 @@ const badgeVariants = cva(
function Badge({
className,
variant = "default",
asChild = false,
render,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
);
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
render,
defaultTagName: "span",
state: { slot: "badge", variant },
props: mergeProps<"span">({ className: cn(badgeVariants({ variant }), className) }, props),
});
}
export { Badge };
export { Badge, badgeVariants };
+21 -23
View File
@@ -1,5 +1,6 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/utils/style";
@@ -9,9 +10,9 @@ const buttonGroupVariants = cva(
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-s-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md!",
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md!",
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
@@ -36,24 +37,21 @@ function ButtonGroup({
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-md border bg-muted px-2.5 font-medium text-sm shadow-xs [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
function ButtonGroupText({ className, render, ...props }: useRender.ComponentProps<"div">) {
return useRender({
render,
defaultTagName: "div",
state: { slot: "button-group-text" },
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-md border bg-muted px-2.5 font-medium text-sm shadow-xs [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
),
},
props,
),
});
}
function ButtonGroupSeparator({
@@ -66,7 +64,7 @@ function ButtonGroupSeparator({
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-[orientation=horizontal]:mx-px data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto data-[orientation=horizontal]:w-auto",
"relative self-stretch bg-input data-horizontal:mx-px data-vertical:my-px data-vertical:h-auto data-horizontal:w-auto",
className,
)}
{...props}
@@ -74,4 +72,4 @@ function ButtonGroupSeparator({
);
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText };
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
+20 -17
View File
@@ -1,28 +1,31 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { Button as ButtonPrimitive, type ButtonProps as ButtonPrimitiveProps } from "@/components/primitives/button";
import { cn } from "@/utils/style";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[box-shadow,color,background-color,border-color,outline-color,text-decoration-color,fill,stroke] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group/button inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap rounded-md border border-transparent bg-clip-padding font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
accent: "bg-accent text-accent-foreground shadow-xs hover:bg-accent/90",
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-muted hover:text-foreground",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline: "border bg-background shadow-xs hover:bg-secondary/40 hover:text-secondary-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-secondary/40 hover:text-secondary-foreground",
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
default:
"h-9 gap-1.5 in-data-[slot=button-group]:rounded-md px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 in-data-[slot=button-group]:rounded-md rounded-[min(var(--radius-md),8px)] px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 in-data-[slot=button-group]:rounded-md rounded-[min(var(--radius-md),10px)] px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-sm": "size-8 rounded-md",
"icon-lg": "size-10 rounded-md",
"icon-xs":
"size-6 in-data-[slot=button-group]:rounded-md rounded-[min(var(--radius-md),8px)] [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8 in-data-[slot=button-group]:rounded-md rounded-[min(var(--radius-md),10px)]",
"icon-lg": "size-10",
},
},
defaultVariants: {
@@ -32,10 +35,10 @@ const buttonVariants = cva(
},
);
type ButtonProps = ButtonPrimitiveProps & VariantProps<typeof buttonVariants>;
type ButtonProps = ButtonPrimitive.Props & VariantProps<typeof buttonVariants>;
function Button({ className, variant, size, type = "button", ...props }: ButtonProps) {
return <ButtonPrimitive type={type} className={cn(buttonVariants({ variant, size, className }))} {...props} />;
function Button({ className, variant = "default", size = "default", ...props }: ButtonProps) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants, type ButtonProps };
export { Button, type ButtonProps, buttonVariants };
+416 -125
View File
@@ -1,12 +1,252 @@
import { Combobox as ComboboxPrimitive, type ComboboxTriggerState, type UseRenderRenderProp } from "@base-ui/react";
import { t } from "@lingui/core/macro";
import { CaretUpDownIcon, CheckIcon } from "@phosphor-icons/react";
import { CaretDownIcon, CheckIcon, XIcon } from "@phosphor-icons/react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { useControlledState } from "@/hooks/use-controlled-state";
import { cn } from "@/utils/style";
const ComboboxRoot = ComboboxPrimitive.Root;
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
}
function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("shrink-0 [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<CaretDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
);
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
className={cn(className)}
{...props}
render={
<InputGroupButton variant="ghost" size="icon-xs">
<XIcon className="pointer-events-none" />
</InputGroupButton>
}
/>
);
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean;
showClear?: boolean;
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
);
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor">) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 group/combobox-content relative max-h-(--available-height) w-(--anchor-width) min-w-60 max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-md bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-closed:animate-out data-open:animate-in *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-80 scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className,
)}
{...props}
/>
);
}
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default select-none items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-50 not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<CheckIcon className="pointer-events-none" />
</span>
}
/>
</ComboboxPrimitive.Item>
);
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return <ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />;
}
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-muted-foreground text-xs", className)}
{...props}
/>
);
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />;
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-muted-foreground text-sm group-data-empty/combobox-content:flex",
className,
)}
{...props}
/>
);
}
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-md border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-2 has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean;
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-6 w-fit items-center justify-center gap-1 whitespace-nowrap rounded-md bg-muted px-1.5 font-medium text-foreground text-xs has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-data-[slot=combobox-chip-remove]:pr-0 has-disabled:opacity-50",
className,
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
render={
<Button variant="ghost" size="icon-xs">
<XIcon className="pointer-events-none" />
</Button>
}
/>
)}
</ComboboxPrimitive.Chip>
);
}
function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
);
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null);
}
type ComboboxOption<TValue extends string | number = string> = {
value: TValue;
label: React.ReactNode;
@@ -14,149 +254,200 @@ type ComboboxOption<TValue extends string | number = string> = {
disabled?: boolean;
};
type ComboboxProps<TValue extends string | number = string> = Omit<
React.ComponentProps<typeof PopoverContent>,
"value" | "defaultValue" | "children"
> & {
disabled?: boolean;
clearable?: boolean;
options: ReadonlyArray<ComboboxOption<TValue>>;
type SingleComboboxProps<TValue extends string | number = string> = {
options: ComboboxOption<TValue>[];
value?: TValue | null;
defaultValue?: TValue | null;
placeholder?: React.ReactNode;
onValueChange?: (value: TValue | null) => void;
multiple?: false;
disabled?: boolean;
showClear?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: React.ReactNode;
buttonProps?: Omit<React.ComponentProps<typeof Button>, "children"> & {
children?: (value: TValue | null, option: ComboboxOption<TValue> | null) => React.ReactNode;
};
onValueChange?: (value: TValue | null, option: ComboboxOption<TValue> | null) => void;
className?: string;
id?: string;
name?: string;
render?: UseRenderRenderProp<ComboboxTriggerState>;
};
function Combobox<TValue extends string | number = string>({
disabled = false,
clearable = true,
options,
value,
defaultValue = null,
placeholder = t`Select...`,
searchPlaceholder = t`Search...`,
emptyMessage = t`No results found.`,
className,
buttonProps,
onValueChange,
...props
}: ComboboxProps<TValue>) {
const { className: buttonClassName, children: buttonChildren, ...buttonRestProps } = buttonProps ?? {};
const [open, setOpen] = React.useState(false);
type MultiComboboxProps<TValue extends string | number = string> = {
options: ComboboxOption<TValue>[];
value?: TValue[] | null;
defaultValue?: TValue[] | null;
onValueChange?: (value: TValue[] | null) => void;
multiple: true;
disabled?: boolean;
showClear?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: React.ReactNode;
className?: string;
id?: string;
name?: string;
render?: UseRenderRenderProp<ComboboxTriggerState>;
};
const [selectedValue, setSelectedValue] = useControlledState<TValue | null>({
value,
defaultValue,
onChange: (next) => onValueChange?.(next, options.find((o) => o.value === next) ?? null),
});
type ComboboxProps<TValue extends string | number = string> = SingleComboboxProps<TValue> | MultiComboboxProps<TValue>;
const selectedOption = React.useMemo(() => {
return options.find((option) => option.value === selectedValue) ?? null;
}, [options, selectedValue]);
function Combobox<TValue extends string | number = string>(props: ComboboxProps<TValue>) {
const {
options,
multiple = false,
disabled = false,
showClear = false,
placeholder,
searchPlaceholder,
emptyMessage,
className,
id,
name,
render,
} = props;
const selectedLabel = selectedOption?.label;
const { contains } = ComboboxPrimitive.useFilter();
const onSelect = React.useCallback(
(current: string) => {
const next = (current as unknown as TValue) ?? null;
const optionMap = React.useMemo(() => new Map(options.map((opt) => [String(opt.value), opt])), [options]);
if (!clearable && selectedValue === next) {
setOpen(false);
return;
const findOption = React.useCallback(
(v: TValue | TValue[] | null | undefined) => {
if (multiple) {
if (!v || !Array.isArray(v)) return [];
return (v as TValue[])
.map((item) => optionMap.get(String(item)) ?? null)
.filter(Boolean) as ComboboxOption<TValue>[];
} else {
if (v == null) return null;
return optionMap.get(String(v)) ?? null;
}
const toggled = selectedValue === next ? null : next;
setSelectedValue(toggled);
setOpen(false);
},
[clearable, selectedValue, setSelectedValue],
[optionMap, multiple],
);
// Prevent opening popover and handling interaction if disabled
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
if (disabled) return;
setOpen(nextOpen);
type OptionValue = ComboboxOption<TValue>[] | ComboboxOption<TValue> | null;
const rawValueKey = props.value !== undefined ? JSON.stringify(props.value) : undefined;
const resolvedValue = React.useMemo(
() => (props.value !== undefined ? (findOption(props.value) as OptionValue) : undefined),
// eslint-disable-next-line react-hooks/exhaustive-deps -- stable key avoids new-reference loops
[rawValueKey, optionMap],
);
const rawDefaultKey = props.defaultValue !== undefined ? JSON.stringify(props.defaultValue) : undefined;
const resolvedDefaultValue = React.useMemo(
() => (props.defaultValue !== undefined ? (findOption(props.defaultValue) as OptionValue) : undefined),
// eslint-disable-next-line react-hooks/exhaustive-deps -- only needed on mount / options change
[rawDefaultKey, optionMap],
);
const handleExternalChange = React.useCallback(
(option: ComboboxOption<TValue>[] | ComboboxOption<TValue> | null) => {
if (multiple) {
const arrOpt = Array.isArray(option) ? option : option ? [option] : [];
(props as MultiComboboxProps<TValue>).onValueChange?.(arrOpt.length > 0 ? arrOpt.map((opt) => opt.value) : []);
} else {
const value = option && !Array.isArray(option) ? (option as ComboboxOption<TValue>).value : null;
const cb = props.onValueChange as ((value: TValue | null) => void) | undefined;
cb?.(value ?? null);
}
},
[disabled],
[props, multiple],
);
const [selectedValue, setSelectedValue] = useControlledState({
value: resolvedValue,
defaultValue: resolvedDefaultValue,
onChange: handleExternalChange,
});
const itemToStringLabel = React.useCallback(
(item: ComboboxOption<TValue>) => (typeof item.label === "string" ? item.label : String(item.value)),
[],
);
const isItemEqualToValue = React.useCallback(
(a: ComboboxOption<TValue>, b: ComboboxOption<TValue>) => String(a.value) === String(b.value),
[],
);
const filter = React.useCallback(
(item: ComboboxOption<TValue>, query: string) => {
const labelStr = typeof item.label === "string" ? item.label : String(item.value);
if (contains(labelStr, query)) return true;
return item.keywords?.some((kw) => contains(kw, query)) ?? false;
},
[contains],
);
const listContent = (item: ComboboxOption<TValue>) => (
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
{item.label}
</ComboboxItem>
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
role="combobox"
variant="outline"
aria-expanded={open}
disabled={disabled}
aria-disabled={disabled}
tapScale={1}
className={cn(
"font-normal active:scale-100",
typeof buttonChildren === "function" ? "" : "justify-between",
disabled && "pointer-events-none opacity-60",
buttonClassName,
)}
{...buttonRestProps}
>
{typeof buttonChildren === "function" ? (
buttonChildren(selectedValue, selectedOption)
) : (
<>
{selectedLabel ?? placeholder}
<CaretUpDownIcon className="ms-2 shrink-0 opacity-50" />
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
aria-disabled={disabled}
tabIndex={disabled ? -1 : undefined}
className={cn("min-w-[200px] p-0", className, disabled && "pointer-events-none select-none opacity-60")}
{...props}
<ComboboxRoot
name={name}
items={options}
filter={filter}
disabled={disabled}
value={selectedValue as ComboboxOption<TValue>[] & ComboboxOption<TValue>}
onValueChange={setSelectedValue as (value: ComboboxOption<TValue>[] | ComboboxOption<TValue> | null) => void}
itemToStringLabel={itemToStringLabel}
isItemEqualToValue={isItemEqualToValue}
{...(multiple ? { multiple: true } : {})}
>
<ComboboxTrigger
id={id}
disabled={disabled}
render={
render ?? (
<Button
variant="outline"
className={cn("justify-start text-left font-normal hover:bg-muted/20", className)}
/>
)
}
>
<Command>
<CommandInput placeholder={searchPlaceholder} disabled={disabled} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValue === option.value;
const isDisabled = option.disabled ?? disabled;
<span className="min-w-0 flex-1 truncate text-left">
<ComboboxValue placeholder={placeholder ?? t`Select...`} />
</span>
return (
<CommandItem
key={String(option.value)}
value={String(option.value)}
keywords={option.keywords}
disabled={isDisabled}
onSelect={isDisabled ? undefined : onSelect}
aria-disabled={isDisabled}
className={cn(isDisabled && "pointer-events-none opacity-60")}
>
<CheckIcon
aria-hidden
className={cn("size-4 shrink-0 transition-opacity", isSelected ? "opacity-100" : "opacity-0")}
/>
<span className={cn("truncate", isDisabled && "opacity-60")}>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{showClear && <ComboboxClear disabled={disabled} />}
</ComboboxTrigger>
<ComboboxContent>
<ComboboxPrimitive.Input
placeholder={searchPlaceholder ?? placeholder ?? t`Search...`}
render={<Input disabled={disabled} className="rounded-b-none focus-visible:ring-0" />}
/>
<ComboboxEmpty>{emptyMessage ?? t`No results found.`}</ComboboxEmpty>
<ComboboxList>{listContent}</ComboboxList>
</ComboboxContent>
</ComboboxRoot>
);
}
export type { ComboboxOption, ComboboxProps };
export { Combobox };
export {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxLabel,
ComboboxList,
type ComboboxOption,
type ComboboxProps,
ComboboxRoot,
ComboboxSeparator,
ComboboxTrigger,
ComboboxValue,
type MultiComboboxProps,
type SingleComboboxProps,
useComboboxAnchor,
};
+15 -7
View File
@@ -10,7 +10,7 @@ function Command({ className, ...props }: React.ComponentProps<typeof CommandPri
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col gap-y-1 overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className,
)}
{...props}
@@ -23,11 +23,14 @@ function CommandDialog({
description = "Search for a command to run...",
children,
className,
showClose = false,
...props
}: React.ComponentProps<typeof Dialog> & {
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string;
description?: string;
className?: string;
showClose?: boolean;
children: React.ReactNode;
}) {
return (
<Dialog {...props}>
@@ -35,7 +38,12 @@ function CommandDialog({
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className={cn("overflow-hidden rounded-xl! p-0", className)}>{children}</DialogContent>
<DialogContent
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", className)}
showClose={showClose}
>
{children}
</DialogContent>
</Dialog>
);
}
@@ -43,7 +51,7 @@ function CommandDialog({
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:ps-2!">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn("w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className)}
@@ -132,11 +140,11 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"span">)
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandShortcut,
CommandList,
CommandSeparator,
CommandShortcut,
};
+91 -81
View File
@@ -1,13 +1,17 @@
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/utils/style";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
}
function ContextMenuTrigger({ className, ...props }: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
@@ -17,39 +21,55 @@ function ContextMenuTrigger({ className, ...props }: React.ComponentProps<typeof
);
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
side = "right",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<ContextMenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn(
"data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-md outline-none ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
}
function ContextMenuContent({
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 font-medium text-muted-foreground text-xs data-inset:pl-8", className)}
{...props}
/>
);
}
@@ -58,7 +78,7 @@ function ContextMenuItem({
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
@@ -68,7 +88,7 @@ function ContextMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-inset:ps-8 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
"group/context-menu-item relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
@@ -76,111 +96,101 @@ function ContextMenuItem({
);
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />;
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-inset:ps-8 data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-inset:pl-8 data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<CaretRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
</ContextMenuPrimitive.SubmenuTrigger>
);
}
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-closed:animate-out data-open:animate-in",
className,
)}
{...props}
/>
);
function ContextMenuSubContent({ ...props }: React.ComponentProps<typeof ContextMenuContent>) {
return <ContextMenuContent data-slot="context-menu-sub-content" className="shadow-lg" side="right" {...props} />;
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute inset-e-2">
<ContextMenuPrimitive.ItemIndicator>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.ItemIndicator>
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioGroup({ ...props }: ContextMenuPrimitive.RadioGroup.Props) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="pointer-events-none absolute inset-e-2">
<ContextMenuPrimitive.ItemIndicator>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.RadioItemIndicator>
<CheckIcon />
</ContextMenuPrimitive.ItemIndicator>
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 font-medium text-muted-foreground text-xs data-inset:ps-8", className)}
{...props}
/>
);
}
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
function ContextMenuSeparator({ className, ...props }: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
@@ -205,18 +215,18 @@ function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
ContextMenuTrigger,
};
+92 -88
View File
@@ -1,115 +1,119 @@
import {
DialogClose as DialogClosePrimitive,
type DialogCloseProps as DialogClosePrimitiveProps,
DialogContent as DialogContentPrimitive,
type DialogContentProps as DialogContentPrimitiveProps,
DialogDescription as DialogDescriptionPrimitive,
type DialogDescriptionProps as DialogDescriptionPrimitiveProps,
DialogFooter as DialogFooterPrimitive,
type DialogFooterProps as DialogFooterPrimitiveProps,
DialogHeader as DialogHeaderPrimitive,
type DialogHeaderProps as DialogHeaderPrimitiveProps,
DialogOverlay as DialogOverlayPrimitive,
type DialogOverlayProps as DialogOverlayPrimitiveProps,
DialogPortal as DialogPortalPrimitive,
Dialog as DialogPrimitive,
type DialogProps as DialogPrimitiveProps,
DialogTitle as DialogTitlePrimitive,
type DialogTitleProps as DialogTitlePrimitiveProps,
DialogTrigger as DialogTriggerPrimitive,
type DialogTriggerProps as DialogTriggerPrimitiveProps,
} from "@/components/primitives/dialog";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { Trans } from "@lingui/react/macro";
import { XIcon } from "@phosphor-icons/react";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/style";
type DialogProps = DialogPrimitiveProps;
function Dialog(props: DialogProps) {
return <DialogPrimitive {...props} />;
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
type DialogTriggerProps = DialogTriggerPrimitiveProps;
function DialogTrigger(props: DialogTriggerProps) {
return <DialogTriggerPrimitive {...props} />;
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
type DialogCloseProps = DialogClosePrimitiveProps;
function DialogClose(props: DialogCloseProps) {
return <DialogClosePrimitive {...props} />;
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
type DialogOverlayProps = DialogOverlayPrimitiveProps;
function DialogOverlay({ className, ...props }: DialogOverlayProps) {
return <DialogOverlayPrimitive className={cn("fixed inset-0 z-50 bg-black/50", className)} {...props} />;
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
type DialogContentProps = DialogContentPrimitiveProps;
function DialogContent({ className, children, ...props }: DialogContentProps) {
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPortalPrimitive>
<DialogOverlay />
<DialogContentPrimitive
className={cn(
"fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100%-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6 shadow-lg sm:max-w-2xl 2xl:max-w-4xl",
className,
)}
{...props}
>
{children}
</DialogContentPrimitive>
</DialogPortalPrimitive>
);
}
type DialogHeaderProps = DialogHeaderPrimitiveProps;
function DialogHeader({ className, ...props }: DialogHeaderProps) {
return (
<DialogHeaderPrimitive className={cn("flex flex-col gap-2 text-center sm:text-start", className)} {...props} />
);
}
type DialogFooterProps = DialogFooterPrimitiveProps;
function DialogFooter({ className, ...props }: DialogFooterProps) {
return (
<DialogFooterPrimitive
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"data-open:fade-in-0 data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 data-closed:animate-out data-open:animate-in supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
/>
);
}
type DialogTitleProps = DialogTitlePrimitiveProps;
function DialogTitle({ className, ...props }: DialogTitleProps) {
return <DialogTitlePrimitive className={cn("font-semibold text-lg leading-none", className)} {...props} />;
function DialogContent({
className,
children,
showClose = false,
...props
}: DialogPrimitive.Popup.Props & {
showClose?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-background p-6 text-sm outline-none ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in sm:max-w-2xl",
className,
)}
{...props}
>
{children}
{showClose && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">
<Trans>Close</Trans>
</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
type DialogDescriptionProps = DialogDescriptionPrimitiveProps;
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />;
}
function DialogDescription({ className, ...props }: DialogDescriptionProps) {
return <DialogDescriptionPrimitive className={cn("text-muted-foreground text-sm", className)} {...props} />;
function DialogFooter({ className, children, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title data-slot="dialog-title" className={cn("font-medium leading-none", className)} {...props} />
);
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-muted-foreground text-sm *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
type DialogProps,
type DialogTriggerProps,
type DialogCloseProps,
type DialogContentProps,
type DialogHeaderProps,
type DialogFooterProps,
type DialogTitleProps,
type DialogDescriptionProps,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+172 -168
View File
@@ -1,86 +1,87 @@
import { CaretRightIcon, CheckIcon, CircleIcon } from "@phosphor-icons/react";
import {
DropdownMenuCheckboxItem as DropdownMenuCheckboxItemPrimitive,
type DropdownMenuCheckboxItemProps as DropdownMenuCheckboxItemPrimitiveProps,
DropdownMenuContent as DropdownMenuContentPrimitive,
type DropdownMenuContentProps as DropdownMenuContentPrimitiveProps,
DropdownMenuGroup as DropdownMenuGroupPrimitive,
type DropdownMenuGroupProps as DropdownMenuGroupPrimitiveProps,
DropdownMenuItemIndicator as DropdownMenuItemIndicatorPrimitive,
DropdownMenuItem as DropdownMenuItemPrimitive,
type DropdownMenuItemProps as DropdownMenuItemPrimitiveProps,
DropdownMenuLabel as DropdownMenuLabelPrimitive,
type DropdownMenuLabelProps as DropdownMenuLabelPrimitiveProps,
DropdownMenu as DropdownMenuPrimitive,
type DropdownMenuProps as DropdownMenuPrimitiveProps,
DropdownMenuRadioGroup as DropdownMenuRadioGroupPrimitive,
type DropdownMenuRadioGroupProps as DropdownMenuRadioGroupPrimitiveProps,
DropdownMenuRadioItem as DropdownMenuRadioItemPrimitive,
type DropdownMenuRadioItemProps as DropdownMenuRadioItemPrimitiveProps,
DropdownMenuSeparator as DropdownMenuSeparatorPrimitive,
type DropdownMenuSeparatorProps as DropdownMenuSeparatorPrimitiveProps,
DropdownMenuShortcut as DropdownMenuShortcutPrimitive,
type DropdownMenuShortcutProps as DropdownMenuShortcutPrimitiveProps,
DropdownMenuSubContent as DropdownMenuSubContentPrimitive,
type DropdownMenuSubContentProps as DropdownMenuSubContentPrimitiveProps,
DropdownMenuSub as DropdownMenuSubPrimitive,
type DropdownMenuSubProps as DropdownMenuSubPrimitiveProps,
DropdownMenuSubTrigger as DropdownMenuSubTriggerPrimitive,
type DropdownMenuSubTriggerProps as DropdownMenuSubTriggerPrimitiveProps,
DropdownMenuTrigger as DropdownMenuTriggerPrimitive,
type DropdownMenuTriggerProps as DropdownMenuTriggerPrimitiveProps,
} from "@/components/primitives/dropdown-menu";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { CaretRightIcon, CheckIcon } from "@phosphor-icons/react";
import type * as React from "react";
import { cn } from "@/utils/style";
type DropdownMenuProps = DropdownMenuPrimitiveProps;
function DropdownMenu(props: DropdownMenuProps) {
return <DropdownMenuPrimitive {...props} />;
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
type DropdownMenuTriggerProps = DropdownMenuTriggerPrimitiveProps;
function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
return <DropdownMenuTriggerPrimitive {...props} />;
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
type DropdownMenuContentProps = DropdownMenuContentPrimitiveProps;
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({ sideOffset = 4, className, children, ...props }: DropdownMenuContentProps) {
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props & Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<DropdownMenuContentPrimitive
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
className,
)}
{...props}
>
{children}
</DropdownMenuContentPrimitive>
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-y-auto overflow-x-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-md outline-none ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in data-closed:overflow-hidden",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
type DropdownMenuGroupProps = DropdownMenuGroupPrimitiveProps;
function DropdownMenuGroup({ ...props }: DropdownMenuGroupProps) {
return <DropdownMenuGroupPrimitive {...props} />;
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
type DropdownMenuItemProps = DropdownMenuItemPrimitiveProps & {
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 font-medium text-muted-foreground text-xs data-inset:pl-8", className)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
};
function DropdownMenuItem({ className, inset, variant = "default", disabled, ...props }: DropdownMenuItemProps) {
}) {
return (
<DropdownMenuItemPrimitive
disabled={disabled}
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-inset:ps-8 data-[variant=destructive]:text-destructive data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive!",
"data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive data-highlighted:bg-accent data-highlighted:text-accent-foreground dark:data-[variant=destructive]:data-highlighted:bg-destructive/20",
"group/dropdown-menu-item relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-[variant=destructive]:text-destructive data-disabled:opacity-50 data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
@@ -88,126 +89,142 @@ function DropdownMenuItem({ className, inset, variant = "default", disabled, ...
);
}
type DropdownMenuCheckboxItemProps = DropdownMenuCheckboxItemPrimitiveProps;
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuCheckboxItem({ className, children, checked, disabled, ...props }: DropdownMenuCheckboxItemProps) {
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<DropdownMenuCheckboxItemPrimitive
disabled={disabled}
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-open:bg-accent data-popup-open:bg-accent data-inset:pl-8 data-open:text-accent-foreground data-popup-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<CaretRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 w-auto min-w-[96px] rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute inset-s-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicatorPrimitive initial={{ opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }}>
<CheckIcon className="size-4" />
</DropdownMenuItemIndicatorPrimitive>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</DropdownMenuCheckboxItemPrimitive>
</MenuPrimitive.CheckboxItem>
);
}
type DropdownMenuRadioGroupProps = DropdownMenuRadioGroupPrimitiveProps;
function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps) {
return <DropdownMenuRadioGroupPrimitive {...props} />;
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
type DropdownMenuRadioItemProps = DropdownMenuRadioItemPrimitiveProps;
function DropdownMenuRadioItem({ className, children, disabled, ...props }: DropdownMenuRadioItemProps) {
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<DropdownMenuRadioItemPrimitive
disabled={disabled}
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-disabled:pointer-events-none data-inset:pl-8 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="pointer-events-none absolute inset-s-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicatorPrimitive layoutId="dropdown-menu-item-indicator-radio">
<CircleIcon className="size-2 fill-current" />
</DropdownMenuItemIndicatorPrimitive>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</DropdownMenuRadioItemPrimitive>
</MenuPrimitive.RadioItem>
);
}
type DropdownMenuLabelProps = DropdownMenuLabelPrimitiveProps & {
inset?: boolean;
};
function DropdownMenuLabel({ className, inset, ...props }: DropdownMenuLabelProps) {
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<DropdownMenuLabelPrimitive
data-inset={inset}
className={cn("px-2 py-1.5 font-medium text-sm data-inset:ps-8", className)}
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
type DropdownMenuSeparatorProps = DropdownMenuSeparatorPrimitiveProps;
function DropdownMenuSeparator({ className, ...props }: DropdownMenuSeparatorProps) {
return <DropdownMenuSeparatorPrimitive className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />;
}
type DropdownMenuShortcutProps = DropdownMenuShortcutPrimitiveProps;
function DropdownMenuShortcut({ className, ...props }: DropdownMenuShortcutProps) {
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<DropdownMenuShortcutPrimitive
className={cn("ml-auto text-muted-foreground text-xs tracking-widest", className)}
{...props}
/>
);
}
type DropdownMenuSubProps = DropdownMenuSubPrimitiveProps;
function DropdownMenuSub(props: DropdownMenuSubProps) {
return <DropdownMenuSubPrimitive {...props} />;
}
type DropdownMenuSubTriggerProps = DropdownMenuSubTriggerPrimitiveProps & {
inset?: boolean;
};
function DropdownMenuSubTrigger({ disabled, className, inset, children, ...props }: DropdownMenuSubTriggerProps) {
return (
<DropdownMenuSubTriggerPrimitive
disabled={disabled}
data-inset={inset}
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden data-inset:ps-8 data-[state=open]:text-accent-foreground",
"data-[state=open]:**:data-[slot=chevron]:rotate-90 **:data-[slot=chevron]:transition-transform **:data-[slot=chevron]:duration-300 **:data-[slot=chevron]:ease-in-out",
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
className,
)}
{...props}
>
{children}
<CaretRightIcon data-slot="chevron" className="ml-auto size-4" />
</DropdownMenuSubTriggerPrimitive>
);
}
type DropdownMenuSubContentProps = DropdownMenuSubContentPrimitiveProps;
function DropdownMenuSubContent({ className, ...props }: DropdownMenuSubContentProps) {
return (
<DropdownMenuSubContentPrimitive
className={cn(
"z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg outline-none",
"ml-auto text-muted-foreground text-xs tracking-widest group-focus/dropdown-menu-item:text-accent-foreground",
className,
)}
{...props}
@@ -217,31 +234,18 @@ function DropdownMenuSubContent({ className, ...props }: DropdownMenuSubContentP
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
type DropdownMenuProps,
type DropdownMenuTriggerProps,
type DropdownMenuContentProps,
type DropdownMenuGroupProps,
type DropdownMenuItemProps,
type DropdownMenuCheckboxItemProps,
type DropdownMenuRadioGroupProps,
type DropdownMenuRadioItemProps,
type DropdownMenuLabelProps,
type DropdownMenuSeparatorProps,
type DropdownMenuShortcutProps,
type DropdownMenuSubProps,
type DropdownMenuSubTriggerProps,
type DropdownMenuSubContentProps,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
+16 -14
View File
@@ -1,6 +1,5 @@
import { useRender } from "@base-ui/react";
import { isPlainObject } from "es-toolkit";
import type { Label as LabelPrimitive } from "radix-ui";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import {
Controller,
@@ -76,7 +75,7 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
);
}
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
function FormLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
const { error, formItemId } = useFormField();
return (
@@ -90,18 +89,21 @@ function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPri
);
}
function FormControl({ ...props }: React.ComponentProps<typeof SlotPrimitive.Slot>) {
function FormControl(props: useRender.ComponentProps<"div">) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<SlotPrimitive.Slot
data-slot="form-control"
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
return useRender({
...props,
defaultTagName: "div",
state: { slot: "form-control" },
props: {
id: formItemId,
"data-slot": "form-control",
"aria-describedby": !error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`,
"aria-invalid": !!error,
...props,
},
});
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
@@ -152,4 +154,4 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
);
}
export { Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };
export { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage };
+27 -19
View File
@@ -1,35 +1,43 @@
import { HoverCard as HoverCardPrimitive } from "radix-ui";
import type * as React from "react";
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card";
import { cn } from "@/utils/style";
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
return <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
}
function HoverCardContent({
className,
align = "center",
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
}: PreviewCardPrimitive.Popup.Props &
Pick<PreviewCardPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
<PreviewCardPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-lg bg-popover p-4 text-popover-foreground text-sm shadow-md outline-hidden ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
className="isolate z-50"
>
<PreviewCardPrimitive.Popup
data-slot="hover-card-content"
className={cn(
"data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-4 text-popover-foreground text-sm shadow-md outline-hidden ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
className,
)}
{...props}
/>
</PreviewCardPrimitive.Positioner>
</PreviewCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };
export { HoverCard, HoverCardContent, HoverCardTrigger };
+13 -9
View File
@@ -11,7 +11,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input shadow-xs outline-none transition-[color,box-shadow] in-data-[slot=combobox-content]:focus-within:border-ring has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-start]]:h-auto has-[>textarea]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:flex-col has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=inline-start]]:[&>input]:ps-1.5 has-[>[data-align=inline-end]]:[&>input]:pe-1.5 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3",
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input shadow-xs outline-none transition-[color,box-shadow] in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-start]]:h-auto has-[>textarea]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:flex-col has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
@@ -24,8 +24,8 @@ const inputGroupAddonVariants = cva(
{
variants: {
align: {
"inline-start": "order-first ps-2 has-[>button]:-ms-1 has-[>kbd]:ms-[-0.15rem]",
"inline-end": "order-last pe-2 has-[>button]:-me-1 has-[>kbd]:me-[-0.15rem]",
"inline-start": "order-first pl-2 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]",
"inline-end": "order-last pr-2 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end": "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
@@ -73,21 +73,25 @@ const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-non
},
});
type InputGroupButtonProps = Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset";
};
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> & VariantProps<typeof inputGroupButtonVariants>) {
}: InputGroupButtonProps) {
return (
<Button
{...props}
type={type}
asChild={false}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
@@ -109,7 +113,7 @@ function InputGroupInput({ className, ...props }: React.ComponentProps<"input">)
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none hover:bg-secondary/40 focus-visible:bg-secondary/40 aria-invalid:ring-0",
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className,
)}
{...props}
@@ -122,7 +126,7 @@ function InputGroupTextarea({ className, ...props }: React.ComponentProps<"texta
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none aria-invalid:ring-0 dark:bg-transparent",
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className,
)}
{...props}
@@ -130,4 +134,4 @@ function InputGroupTextarea({ className, ...props }: React.ComponentProps<"texta
);
}
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea };
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea };
+1 -1
View File
@@ -77,4 +77,4 @@ function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };
+3 -2
View File
@@ -1,13 +1,14 @@
import { Input as InputPrimitive } from "@base-ui/react/input";
import type * as React from "react";
import { cn } from "@/utils/style";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 shadow-xs outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground placeholder:text-muted-foreground hover:bg-secondary/40 focus-visible:border-ring focus-visible:bg-secondary/40 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-sm shadow-xs outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
+2 -3
View File
@@ -1,10 +1,9 @@
import { Label as LabelPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/utils/style";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<LabelPrimitive.Root
<label
data-slot="label"
className={cn(
"flex select-none items-center gap-2 font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
-216
View File
@@ -1,216 +0,0 @@
import { t } from "@lingui/core/macro";
import { CaretUpDownIcon, CheckIcon } from "@phosphor-icons/react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useControlledState } from "@/hooks/use-controlled-state";
import { cn } from "@/utils/style";
type MultipleComboboxOption<TValue extends string | number = string> = {
value: TValue;
label: React.ReactNode;
keywords?: string[];
disabled?: boolean;
};
type BasePopoverProps = Omit<React.ComponentProps<typeof PopoverContent>, "value" | "defaultValue" | "children">;
type MultipleComboboxProps<TValue extends string | number = string> = BasePopoverProps & {
options: MultipleComboboxOption<TValue>[];
value?: TValue[];
defaultValue?: TValue[];
placeholder?: React.ReactNode;
searchPlaceholder?: string;
emptyMessage?: React.ReactNode;
clearLabel?: React.ReactNode;
buttonProps?: Omit<React.ComponentProps<typeof Button>, "children"> & {
children?: (values: TValue[], options: MultipleComboboxOption<TValue>[]) => React.ReactNode;
};
onValueChange?: (values: TValue[], options: MultipleComboboxOption<TValue>[]) => void;
onOpenChange?: (open: boolean) => void;
disableClear?: boolean;
};
const CLEAR_COMMAND_VALUE = "__command-clear-multi-select-combobox";
function MultipleCombobox<TValue extends string | number = string>({
options,
value,
defaultValue,
placeholder = t`Select...`,
searchPlaceholder = t`Search...`,
emptyMessage = t`No results found.`,
clearLabel = t`Clear selection`,
className,
buttonProps,
onValueChange,
onOpenChange,
disableClear = false,
...popoverProps
}: MultipleComboboxProps<TValue>) {
const [open, setOpen] = React.useState(false);
const { children: buttonChildren, className: buttonClassName, ...buttonRest } = buttonProps ?? {};
const resolvedDefaultValue = React.useMemo<TValue[]>(() => (defaultValue ? [...defaultValue] : []), [defaultValue]);
const optionsByStringValue = React.useMemo(() => {
return new Map(options.map((option) => [String(option.value), option]));
}, [options]);
const [selectedValues, setSelectedValues] = useControlledState<TValue[]>({
value,
defaultValue: resolvedDefaultValue,
onChange: (next) =>
onValueChange?.(
next,
next
.map((item) => options.find((option) => option.value === item))
.filter((option): option is MultipleComboboxOption<TValue> => Boolean(option)),
),
});
const selectionSet = React.useMemo(() => new Set(selectedValues), [selectedValues]);
const selectedOptions = React.useMemo(
() => options.filter((option) => selectionSet.has(option.value)),
[options, selectionSet],
);
const selectionCount = selectedOptions.length;
const canClear = !disableClear && selectionCount > 0;
const toggleOption = React.useCallback(
(option: MultipleComboboxOption<TValue>) => {
if (option.disabled) return;
const isSelected = selectionSet.has(option.value);
const nextValues = isSelected
? selectedValues.filter((selected) => selected !== option.value)
: [...selectedValues, option.value];
setSelectedValues(nextValues);
},
[selectionSet, selectedValues, setSelectedValues],
);
const handleSelect = React.useCallback(
(current: string) => {
if (current === CLEAR_COMMAND_VALUE) {
setSelectedValues([]);
return;
}
const option = optionsByStringValue.get(current);
if (!option) return;
toggleOption(option);
},
[optionsByStringValue, toggleOption, setSelectedValues],
);
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
},
[onOpenChange],
);
const buttonContent =
typeof buttonChildren === "function" ? (
buttonChildren(selectedValues, selectedOptions)
) : (
<>
<span className="truncate">{selectionCount > 0 ? `${selectionCount} selected` : placeholder}</span>
<CaretUpDownIcon aria-hidden className="ms-2 shrink-0 opacity-50" />
</>
);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
role="combobox"
variant="outline"
aria-expanded={open}
aria-label="Multi-select Combobox"
className={cn("justify-between gap-2 font-normal active:scale-100", buttonClassName)}
{...buttonRest}
>
{buttonContent}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
role="listbox"
className={cn("w-[260px] p-0", className)}
aria-multiselectable="true"
{...popoverProps}
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const stringValue = String(option.value);
const isSelected = selectionSet.has(option.value);
const isDisabled = option.disabled ?? false;
return (
<CommandItem
key={stringValue}
value={stringValue}
keywords={option.keywords}
onSelect={handleSelect}
disabled={isDisabled}
data-selected={isSelected ? "" : undefined}
aria-selected={isSelected}
>
<CheckIcon
aria-hidden
className={cn("size-4 shrink-0 transition-opacity", isSelected ? "opacity-100" : "opacity-0")}
/>
<span className={cn("truncate", isDisabled && "opacity-60")}>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
{canClear ? (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
value={CLEAR_COMMAND_VALUE}
onSelect={handleSelect}
keywords={[]}
data-selected={undefined}
aria-selected="false"
>
{clearLabel}
</CommandItem>
</CommandGroup>
</>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export type { MultipleComboboxOption, MultipleComboboxProps };
export { MultipleCombobox };
+32 -21
View File
@@ -1,51 +1,62 @@
import { Popover as PopoverPrimitive } from "radix-ui";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import type * as React from "react";
import { cn } from "@/utils/style";
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
}: PopoverPrimitive.Popup.Props &
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 flex min-w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-4 rounded-md bg-popover p-4 text-popover-foreground text-sm shadow-md outline-hidden ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
className,
)}
{...props}
/>
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 z-50 flex w-72 origin-(--transform-origin) flex-col gap-4 rounded-md bg-popover p-4 text-popover-foreground text-sm shadow-md outline-hidden ring-1 ring-foreground/10 duration-100 data-closed:animate-out data-open:animate-in",
className,
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="popover-header" className={cn("flex flex-col gap-1 text-sm", className)} {...props} />;
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return <div data-slot="popover-title" className={cn("font-medium", className)} {...props} />;
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return <PopoverPrimitive.Title data-slot="popover-title" className={cn("font-medium", className)} {...props} />;
}
function PopoverDescription({ className, ...props }: React.ComponentProps<"p">) {
return <p data-slot="popover-description" className={cn("text-muted-foreground", className)} {...props} />;
function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
export { Popover, PopoverAnchor, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
+7 -15
View File
@@ -1,13 +1,12 @@
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import type * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
import { cn } from "@/utils/style";
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
function ScrollArea({ className, children, ...props }: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative", className)} {...props}>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="[&>div]:block! size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{children}
</ScrollAreaPrimitive.Viewport>
@@ -17,13 +16,9 @@ function ScrollArea({ className, children, ...props }: React.ComponentProps<type
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
function ScrollBar({ className, orientation = "vertical", ...props }: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
@@ -33,11 +28,8 @@ function ScrollBar({
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
<ScrollAreaPrimitive.Thumb data-slot="scroll-area-thumb" className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.Scrollbar>
);
}
+4 -11
View File
@@ -1,20 +1,13 @@
import { Separator as SeparatorPrimitive } from "radix-ui";
import type * as React from "react";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/utils/style";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive.Root
<SeparatorPrimitive
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
+27 -23
View File
@@ -1,31 +1,32 @@
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { Trans } from "@lingui/react/macro";
import { XIcon } from "@phosphor-icons/react";
import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/utils/style";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Overlay
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/10 duration-100 data-closed:animate-out data-open:animate-in data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
"data-open:fade-in-0 data-closed:fade-out-0 fixed inset-0 z-50 bg-black/10 duration-100 data-closed:animate-out data-open:animate-in data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className,
)}
{...props}
@@ -37,34 +38,37 @@ function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
showClose = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
showClose?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"data-[side=right]:data-closed:slide-out-to-end-10 data-[side=right]:data-open:slide-in-from-end-10 data-[side=left]:data-closed:slide-out-to-start-10 data-[side=left]:data-open:slide-in-from-start-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=left]:inset-s-0 data-[side=right]:inset-e-0 data-[side=bottom]:inset-x-0 data-[side=top]:inset-x-0 data-[side=left]:inset-y-0 data-[side=right]:inset-y-0 data-[side=top]:top-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=left]:h-full data-[side=right]:h-full data-[side=top]:h-auto data-[side=left]:w-3/4 data-[side=right]:w-3/4 data-closed:animate-out data-open:animate-in data-[side=bottom]:border-t data-[side=left]:border-r data-[side=top]:border-b data-[side=right]:border-l data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
"data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10 fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=top]:inset-x-0 data-[side=left]:inset-y-0 data-[side=right]:inset-y-0 data-[side=top]:top-0 data-[side=right]:right-0 data-[side=bottom]:bottom-0 data-[side=left]:left-0 data-[side=bottom]:h-auto data-[side=left]:h-full data-[side=right]:h-full data-[side=top]:h-auto data-[side=left]:w-3/4 data-[side=right]:w-3/4 data-closed:animate-out data-open:animate-in data-[side=bottom]:border-t data-[side=left]:border-r data-[side=top]:border-b data-[side=right]:border-l data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button variant="ghost" className="absolute inset-e-4 top-4" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
</Button>
{showClose && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={<Button variant="ghost" className="absolute top-4 right-4" size="icon-sm" />}
>
<XIcon />
<span className="sr-only">
<Trans>Close</Trans>
</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPrimitive.Popup>
</SheetPortal>
);
}
@@ -77,13 +81,13 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title data-slot="sheet-title" className={cn("font-medium text-foreground", className)} {...props} />
);
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
@@ -93,4 +97,4 @@ function SheetDescription({ className, ...props }: React.ComponentProps<typeof S
);
}
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription };
export { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger };
+164 -169
View File
@@ -1,18 +1,21 @@
import { SidebarSimpleIcon } from "@phosphor-icons/react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { SidebarIcon } from "@phosphor-icons/react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot as SlotPrimitive } from "radix-ui";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/utils/style";
const SIDEBAR_WIDTH = "18rem";
const SIDEBAR_WIDTH_MOBILE = "16rem";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
@@ -32,7 +35,7 @@ function useSidebarState() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebarState must be used within a <SidebarProvider />.");
throw new Error("useSidebarState must be used within a SidebarProvider.");
}
return context;
@@ -58,13 +61,18 @@ function SidebarProvider({
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
setOpenProp?.(openState);
_setOpen(openState);
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// biome-ignore lint/suspicious/noDocumentCookie: it's ok
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
@@ -72,7 +80,7 @@ function SidebarProvider({
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen]);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -101,28 +109,25 @@ function SidebarProvider({
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className)}
suppressHydrationWarning
{...props}
>
{children}
</div>
</TooltipProvider>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
);
}
@@ -133,6 +138,7 @@ function Sidebar({
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
@@ -157,6 +163,7 @@ function Sidebar({
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
@@ -187,26 +194,26 @@ function Sidebar({
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] ease-in-out",
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] ease-in-out md:flex",
side === "left"
? "inset-s-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "inset-e-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=right]:right-0 data-[side=left]:left-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
@@ -215,7 +222,7 @@ function Sidebar({
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-md group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
@@ -232,15 +239,15 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<SidebarSimpleIcon />
<SidebarIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
@@ -258,12 +265,12 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-in-out after:absolute after:inset-s-1/2 after:inset-y-0 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-inset-e-4 group-data-[side=right]:inset-s-0 sm:flex",
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear after:absolute after:inset-s-1/2 after:inset-y-0 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-inset-e-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-inset-s-2",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
@@ -276,8 +283,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:rounded-md",
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className,
)}
{...props}
@@ -290,7 +296,7 @@ function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background", className)}
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
);
@@ -323,7 +329,7 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof S
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("w-auto bg-sidebar-border", className)}
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
@@ -335,7 +341,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
@@ -356,45 +362,50 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
function SidebarGroupLabel({
className,
asChild = false,
render,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? SlotPrimitive.Root : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] ease-in-out focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 font-medium text-sidebar-foreground/70 text-xs outline-hidden ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
});
}
function SidebarGroupAction({
className,
asChild = false,
render,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? SlotPrimitive.Root : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute inset-e-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 group-data-[collapsible=icon]:hidden md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
});
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -431,12 +442,13 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline: "bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
@@ -452,45 +464,34 @@ const sidebarMenuButtonVariants = cva(
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
render,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? SlotPrimitive.Root : "button";
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebarState();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
const children = useRender({
defaultTagName: "button",
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: { size, slot: "sidebar-menu-button", sidebar: "menu-button", active: isActive },
props: mergeProps<"button">({ className: cn(sidebarMenuButtonVariants({ variant, size }), className) }, props),
});
if (!tooltip) {
return button;
}
if (!tooltip) return children;
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
if (typeof tooltip === "string") tooltip = { children: tooltip };
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
{children}
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
);
@@ -498,33 +499,32 @@ function SidebarMenuButton({
function SidebarMenuAction({
className,
asChild = false,
render,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? SlotPrimitive.Root : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute inset-e-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean;
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 aria-expanded:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
});
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
@@ -533,12 +533,7 @@ function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">)
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute inset-e-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 font-medium text-sidebar-foreground text-xs tabular-nums peer-hover/menu-button:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className,
)}
{...props}
@@ -553,9 +548,10 @@ function SidebarMenuSkeleton({
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
const width = React.useMemo(() => {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
});
return (
<div
@@ -564,7 +560,7 @@ function SidebarMenuSkeleton({
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded" data-sidebar="menu-skeleton-icon" />}
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
@@ -584,8 +580,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-sidebar-border border-l px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
@@ -605,35 +600,35 @@ function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">)
}
function SidebarMenuSubButton({
asChild = false,
render,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? SlotPrimitive.Root : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md";
isActive?: boolean;
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:bg-sidebar-accent data-[size=md]:text-sm data-[size=sm]:text-xs data-active:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className,
),
},
props,
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
});
}
export {
+22 -29
View File
@@ -1,15 +1,8 @@
import { Slider as SliderPrimitive } from "radix-ui";
import { Slider as SliderPrimitive } from "@base-ui/react/slider";
import * as React from "react";
import { cn } from "@/utils/style";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
function Slider({ className, defaultValue, value, min = 0, max = 100, ...props }: SliderPrimitive.Root.Props) {
const _values = React.useMemo(
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
@@ -17,33 +10,33 @@ function Slider({
return (
<SliderPrimitive.Root
className={cn("data-vertical:h-full data-horizontal:w-full", className)}
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none select-none items-center data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col data-disabled:opacity-50",
className,
)}
thumbAlignment="edge"
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow overflow-hidden rounded-full bg-muted data-horizontal:h-1.5 data-vertical:h-full data-horizontal:w-full data-vertical:w-1.5"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="absolute select-none bg-primary data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="block size-4 shrink-0 select-none rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
/>
))}
<SliderPrimitive.Control className="relative flex w-full touch-none select-none items-center data-vertical:h-full data-vertical:min-h-40 data-vertical:w-auto data-vertical:flex-col data-disabled:opacity-50">
<SliderPrimitive.Track
data-slot="slider-track"
className="relative grow select-none overflow-hidden rounded-full bg-muted data-horizontal:h-1.5 data-vertical:h-full data-horizontal:w-full data-vertical:w-1.5"
>
<SliderPrimitive.Indicator
data-slot="slider-range"
className="select-none bg-primary data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="block size-4 shrink-0 select-none rounded-full border border-primary bg-white shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Control>
</SliderPrimitive.Root>
);
}
+18 -53
View File
@@ -1,64 +1,29 @@
import type * as React from "react";
import {
SwitchIcon as SwitchIconPrimitive,
Switch as SwitchPrimitive,
type SwitchProps as SwitchPrimitiveProps,
SwitchThumb as SwitchThumbPrimitive,
} from "@/components/primitives/switch";
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "@/utils/style";
type SwitchProps = SwitchPrimitiveProps & {
pressedWidth?: number;
startIcon?: React.ReactElement;
endIcon?: React.ReactElement;
thumbIcon?: React.ReactElement;
};
function Switch({ className, pressedWidth = 20, startIcon, endIcon, thumbIcon, ...props }: SwitchProps) {
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer relative flex h-5 w-8 shrink-0 items-center justify-start rounded-full border border-transparent px-px shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:justify-end data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs outline-none transition-all after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=sm]:h-[14px] data-[size=default]:w-[32px] data-[size=sm]:w-[24px] data-disabled:cursor-not-allowed data-checked:bg-primary data-unchecked:bg-input data-disabled:opacity-50 dark:data-unchecked:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
>
<SwitchThumbPrimitive
className={cn(
"pointer-events-none relative z-10 block size-4 rounded-full bg-background ring-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground",
)}
pressedAnimation={{ width: pressedWidth }}
>
{thumbIcon && (
<SwitchIconPrimitive
position="thumb"
className="-translate-1/2 absolute inset-s-1/2 top-1/2 text-neutral-400 dark:text-neutral-500 [&_svg]:size-[9px]"
>
{thumbIcon}
</SwitchIconPrimitive>
)}
</SwitchThumbPrimitive>
{startIcon && (
<SwitchIconPrimitive
position="left"
className="absolute inset-s-0.5 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-500 [&_svg]:size-[9px]"
>
{startIcon}
</SwitchIconPrimitive>
)}
{endIcon && (
<SwitchIconPrimitive
position="right"
className="absolute inset-e-0.5 top-1/2 -translate-y-1/2 text-neutral-500 dark:text-neutral-400 [&_svg]:size-[9px]"
>
{endIcon}
</SwitchIconPrimitive>
)}
</SwitchPrimitive>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-checked:bg-primary-foreground dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
);
}
export { Switch, type SwitchProps };
export { Switch };
+11 -16
View File
@@ -1,9 +1,8 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/utils/style";
function Tabs({ className, orientation = "horizontal", ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
@@ -15,7 +14,7 @@ function Tabs({ className, orientation = "horizontal", ...props }: React.Compone
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-full px-1.5 py-0.25 text-muted-foreground data-[variant=line]:rounded-none group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground data-[variant=line]:rounded-none group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
{
variants: {
variant: {
@@ -33,7 +32,7 @@ function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
@@ -44,14 +43,14 @@ function TabsList({
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Trigger
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 font-medium text-foreground/60 text-sm transition-all hover:text-foreground focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 font-medium text-foreground/60 text-sm transition-all hover:text-foreground focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className,
)}
@@ -60,14 +59,10 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
<TabsPrimitive.Panel data-slot="tabs-content" className={cn("flex-1 text-sm outline-none", className)} {...props} />
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants };
+1 -1
View File
@@ -6,7 +6,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"field-sizing-content flex min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"field-sizing-content flex min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-sm shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
+4 -7
View File
@@ -1,6 +1,5 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui";
import type * as React from "react";
import { cn } from "@/utils/style";
const toggleVariants = cva(
@@ -29,10 +28,8 @@ function Toggle({
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props} />
);
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return <TogglePrimitive data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props} />;
}
export { Toggle };
export { Toggle, toggleVariants };
+29 -23
View File
@@ -1,43 +1,49 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import type * as React from "react";
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from "@/utils/style";
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
}: TooltipPrimitive.Popup.Props &
Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className={cn(
"data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) rounded-md bg-foreground px-3 py-1.5 text-background text-xs data-[state=delayed-open]:animate-in data-closed:animate-out data-open:animate-in",
className,
)}
{...props}
className="isolate z-50"
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-background text-xs has-data-[slot=kbd]:pr-1.5 data-[state=delayed-open]:animate-in data-closed:animate-out data-open:animate-in **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-start]:top-1/2! data-[side=left]:top-1/2! data-[side=right]:top-1/2! data-[side=inline-start]:-right-1 data-[side=left]:-right-1 data-[side=top]:-bottom-2.5 data-[side=inline-end]:-left-1 data-[side=right]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:-translate-y-1/2 data-[side=right]:-translate-y-1/2" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
);
}
+3 -3
View File
@@ -24,7 +24,7 @@ import { isLocale, loadLocale, localeMap, setLocaleServerFn } from "@/utils/loca
import { isTheme } from "@/utils/theme";
type Props = {
children: ({ session }: { session: AuthSession }) => React.ReactNode;
children: ({ session }: { session: AuthSession }) => React.ComponentProps<typeof DropdownMenuTrigger>["render"];
};
export function UserDropdownMenu({ children }: Props) {
@@ -64,7 +64,7 @@ export function UserDropdownMenu({ children }: Props) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children({ session })}</DropdownMenuTrigger>
<DropdownMenuTrigger render={children({ session })} />
<DropdownMenuContent align="start" side="top">
<DropdownMenuGroup>
@@ -104,7 +104,7 @@ export function UserDropdownMenu({ children }: Props) {
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleLogout}>
<DropdownMenuItem onClick={handleLogout}>
<SignOutIcon />
<Trans>Logout</Trans>
</DropdownMenuItem>
+31 -31
View File
@@ -90,9 +90,7 @@ const CreateApiKeyForm = ({ setApiKey }: CreateApiKeyFormProps) => {
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input min={1} max={64} {...field} />
</FormControl>
<FormControl render={<Input min={1} max={64} {...field} />} />
<FormMessage />
<FormDescription>
<Trans>
@@ -112,34 +110,36 @@ const CreateApiKeyForm = ({ setApiKey }: CreateApiKeyFormProps) => {
<FormLabel>
<Trans>Expires in</Trans>
</FormLabel>
<FormControl>
<Combobox
value={field.value}
onValueChange={(value) => value && field.onChange(Number(value))}
options={[
{
// 1 month = 30 days
value: 3600 * 24 * 30,
label: t`1 month`,
},
{
// 3 months = 90 days
value: 3600 * 24 * 90,
label: t`3 months`,
},
{
// 6 months = 180 days
value: 3600 * 24 * 180,
label: t`6 months`,
},
{
// 1 year = 365 days
value: 3600 * 24 * 365,
label: t`1 year`,
},
]}
/>
</FormControl>
<FormControl
render={
<Combobox
value={field.value}
onValueChange={(value) => value && field.onChange(Number(value))}
options={[
{
// 1 month = 30 days
value: 3600 * 24 * 30,
label: t`1 month`,
},
{
// 3 months = 90 days
value: 3600 * 24 * 90,
label: t`3 months`,
},
{
// 6 months = 180 days
value: 3600 * 24 * 180,
label: t`6 months`,
},
{
// 1 year = 365 days
value: 3600 * 24 * 365,
label: t`1 year`,
},
]}
/>
}
/>
<FormMessage />
</FormItem>
)}
+22 -18
View File
@@ -85,15 +85,17 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
<Trans>Current Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl>
<Input
min={6}
max={64}
type={showCurrentPassword ? "text" : "password"}
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
min={6}
max={64}
type={showCurrentPassword ? "text" : "password"}
autoComplete="current-password"
{...field}
/>
}
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowCurrentPassword}>
{showCurrentPassword ? <EyeIcon /> : <EyeSlashIcon />}
@@ -113,15 +115,17 @@ export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
<Trans>New Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl>
<Input
min={6}
max={64}
type={showNewPassword ? "text" : "password"}
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
min={6}
max={64}
type={showNewPassword ? "text" : "password"}
autoComplete="new-password"
{...field}
/>
}
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowNewPassword}>
{showNewPassword ? <EyeIcon /> : <EyeSlashIcon />}
+11 -9
View File
@@ -77,15 +77,17 @@ export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">
<Trans>Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl>
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="current-password"
{...field}
/>
}
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
+32 -28
View File
@@ -189,15 +189,17 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
<Trans>Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl>
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="current-password"
{...field}
/>
}
/>
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
@@ -244,25 +246,27 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<InputOTP
maxLength={6}
value={field.value}
onChange={field.onChange}
pattern={REGEXP_ONLY_DIGITS}
onComplete={verifyForm.handleSubmit(onVerifySubmit)}
pasteTransformer={(pasted) => pasted.replaceAll("-", "")}
>
<InputOTPGroup>
<InputOTPSlot index={0} className="size-12" />
<InputOTPSlot index={1} className="size-12" />
<InputOTPSlot index={2} className="size-12" />
<InputOTPSlot index={3} className="size-12" />
<InputOTPSlot index={4} className="size-12" />
<InputOTPSlot index={5} className="size-12" />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormControl
render={
<InputOTP
maxLength={6}
value={field.value}
onChange={field.onChange}
pattern={REGEXP_ONLY_DIGITS}
onComplete={verifyForm.handleSubmit(onVerifySubmit)}
pasteTransformer={(pasted) => pasted.replaceAll("-", "")}
>
<InputOTPGroup>
<InputOTPSlot index={0} className="size-12" />
<InputOTPSlot index={1} className="size-12" />
<InputOTPSlot index={2} className="size-12" />
<InputOTPSlot index={3} className="size-12" />
<InputOTPSlot index={4} className="size-12" />
<InputOTPSlot index={5} className="size-12" />
</InputOTPGroup>
</InputOTP>
}
/>
<FormMessage />
</FormItem>
)}
+2 -2
View File
@@ -27,7 +27,7 @@ import { useDialogStore } from "./store";
export function DialogManager() {
const { open, activeDialog, onOpenChange } = useDialogStore();
const dialogContent = match(activeDialog)
const DialogContent = match(activeDialog)
.with({ type: "auth.change-password" }, () => <ChangePasswordDialog />)
.with({ type: "auth.two-factor.enable" }, () => <EnableTwoFactorDialog />)
.with({ type: "auth.two-factor.disable" }, () => <DisableTwoFactorDialog />)
@@ -71,7 +71,7 @@ export function DialogManager() {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{dialogContent}
{DialogContent}
</Dialog>
);
}
+48 -48
View File
@@ -226,34 +226,36 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
<FormLabel>
<Trans>Type</Trans>
</FormLabel>
<FormControl>
<Combobox
clearable={false}
value={field.value}
onValueChange={field.onChange}
options={[
{ value: "reactive-resume-json", label: "Reactive Resume (JSON)" },
{ value: "reactive-resume-v4-json", label: "Reactive Resume v4 (JSON)" },
{ value: "json-resume-json", label: "JSON Resume" },
{
value: "pdf",
label: (
<div className="flex items-center gap-x-2">
PDF <Badge>{t`AI`}</Badge>
</div>
),
},
{
value: "docx",
label: (
<div className="flex items-center gap-x-2">
Microsoft Word <Badge>{t`AI`}</Badge>
</div>
),
},
]}
/>
</FormControl>
<FormControl
render={
<Combobox
showClear={false}
value={field.value}
onValueChange={field.onChange}
options={[
{ value: "reactive-resume-json", label: "Reactive Resume (JSON)" },
{ value: "reactive-resume-v4-json", label: "Reactive Resume v4 (JSON)" },
{ value: "json-resume-json", label: "JSON Resume" },
{
value: "pdf",
label: (
<div className="flex items-center gap-x-2">
PDF <Badge>{t`AI`}</Badge>
</div>
),
},
{
value: "docx",
label: (
<div className="flex items-center gap-x-2">
Microsoft Word <Badge>{t`AI`}</Badge>
</div>
),
},
]}
/>
}
/>
<FormMessage />
</FormItem>
)}
@@ -266,27 +268,25 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
render={({ field }) => (
<FormItem className={cn(!type && "hidden")}>
<FormControl>
<div>
<Input type="file" className="hidden" ref={inputRef} onChange={onUploadFile} />
<Input type="file" className="hidden" ref={inputRef} onChange={onUploadFile} />
<Button
variant="outline"
className="h-auto w-full flex-col border-dashed py-8 font-normal"
onClick={onSelectFile}
>
{field.value ? (
<>
<FileIcon weight="thin" size={32} />
<p>{field.value.name}</p>
</>
) : (
<>
<UploadSimpleIcon weight="thin" size={32} />
<Trans>Click here to select a file to import</Trans>
</>
)}
</Button>
</div>
<Button
variant="outline"
className="h-auto w-full flex-col border-dashed py-8 font-normal"
onClick={onSelectFile}
>
{field.value ? (
<>
<FileIcon weight="thin" size={32} />
<p>{field.value.name}</p>
</>
) : (
<>
<UploadSimpleIcon weight="thin" size={32} />
<Trans>Click here to select a file to import</Trans>
</>
)}
</Button>
</FormControl>
<FormMessage />
</FormItem>
+20 -20
View File
@@ -125,14 +125,16 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" disabled={isPending}>
<CaretDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button size="icon" disabled={isPending}>
<CaretDownIcon />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={onCreateSampleResume}>
<DropdownMenuItem onClick={onCreateSampleResume}>
<TestTubeIcon />
<Trans>Create a Sample Resume</Trans>
</DropdownMenuItem>
@@ -309,9 +311,7 @@ function ResumeForm() {
<Trans>Name</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<FormControl>
<Input min={1} max={64} {...field} />
</FormControl>
<FormControl render={<Input min={1} max={64} {...field} />} />
<Button size="icon" variant="outline" title={t`Generate a random name`} onClick={onGenerateName}>
<MagicWandIcon />
@@ -333,14 +333,16 @@ function ResumeForm() {
<FormLabel>
<Trans>Slug</Trans>
</FormLabel>
<FormControl>
<InputGroup>
<InputGroupAddon align="inline-start" className="hidden sm:flex">
<InputGroupText>{slugPrefix}</InputGroupText>
</InputGroupAddon>
<InputGroupInput min={1} max={64} className="ps-0!" {...field} />
</InputGroup>
</FormControl>
<FormControl
render={
<InputGroup>
<InputGroupAddon align="inline-start" className="hidden sm:flex">
<InputGroupText>{slugPrefix}</InputGroupText>
</InputGroupAddon>
<InputGroupInput min={1} max={64} className="ps-0!" {...field} />
</InputGroup>
}
/>
<FormMessage />
<FormDescription>
<Trans>This is a URL-friendly name for your resume.</Trans>
@@ -357,9 +359,7 @@ function ResumeForm() {
<FormLabel>
<Trans>Tags</Trans>
</FormLabel>
<FormControl>
<ChipInput {...field} />
</FormControl>
<FormControl render={<ChipInput {...field} />} />
<FormMessage />
<FormDescription>
<Trans>Tags can be used to categorize your resume by keywords.</Trans>
+12 -24
View File
@@ -159,9 +159,7 @@ function AwardForm() {
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -175,9 +173,7 @@ function AwardForm() {
<FormLabel>
<Trans context="(noun) person, organization, or entity that gives an award">Awarder</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -191,9 +187,7 @@ function AwardForm() {
<FormLabel>
<Trans>Date</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -207,14 +201,12 @@ function AwardForm() {
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
</FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
<FormMessage />
</FormItem>
)}
@@ -225,10 +217,8 @@ function AwardForm() {
name="options.showLinkInTitle"
render={({ field }) => (
<FormItem className="flex items-center gap-x-2">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="!mt-0">
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
@@ -243,9 +233,7 @@ function AwardForm() {
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
+12 -24
View File
@@ -159,9 +159,7 @@ function CertificationForm() {
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -175,9 +173,7 @@ function CertificationForm() {
<FormLabel>
<Trans>Issuer</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -191,9 +187,7 @@ function CertificationForm() {
<FormLabel>
<Trans>Date</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -207,14 +201,12 @@ function CertificationForm() {
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
</FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
<FormMessage />
</FormItem>
)}
@@ -225,10 +217,8 @@ function CertificationForm() {
name="options.showLinkInTitle"
render={({ field }) => (
<FormItem className="flex items-center gap-x-2">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="!mt-0">
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
@@ -243,9 +233,7 @@ function CertificationForm() {
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
+2 -6
View File
@@ -143,9 +143,7 @@ function CoverLetterForm() {
<FormLabel>
<Trans>Recipient</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
@@ -159,9 +157,7 @@ function CoverLetterForm() {
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
+15 -15
View File
@@ -167,9 +167,7 @@ function CustomSectionForm({ isUpdate = false }: { isUpdate?: boolean }) {
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -183,18 +181,20 @@ function CustomSectionForm({ isUpdate = false }: { isUpdate?: boolean }) {
<FormLabel>
<Trans>Section Type</Trans>
</FormLabel>
<FormControl>
<Combobox
{...field}
value={field.value}
disabled={isUpdate}
onValueChange={field.onChange}
options={SECTION_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: i18n.t(option.label),
}))}
/>
</FormControl>
<FormControl
render={
<Combobox
{...field}
value={field.value}
disabled={isUpdate}
onValueChange={field.onChange}
options={SECTION_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: i18n.t(option.label),
}))}
/>
}
/>
<FormMessage />
</FormItem>
)}
+15 -33
View File
@@ -165,9 +165,7 @@ function EducationForm() {
<FormLabel>
<Trans>School</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -181,9 +179,7 @@ function EducationForm() {
<FormLabel>
<Trans>Degree</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -197,9 +193,7 @@ function EducationForm() {
<FormLabel>
<Trans>Area of Study</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -213,9 +207,7 @@ function EducationForm() {
<FormLabel>
<Trans>Grade</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -229,9 +221,7 @@ function EducationForm() {
<FormLabel>
<Trans>Location</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -245,9 +235,7 @@ function EducationForm() {
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -261,14 +249,12 @@ function EducationForm() {
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
</FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
<FormMessage />
</FormItem>
)}
@@ -279,10 +265,8 @@ function EducationForm() {
name="options.showLinkInTitle"
render={({ field }) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="!mt-0">
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
@@ -297,9 +281,7 @@ function EducationForm() {
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
+17 -35
View File
@@ -204,9 +204,7 @@ function RoleFields({ role, index, onRemove }: RoleFieldsProps) {
<FormLabel>
<Trans>Position</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -220,9 +218,7 @@ function RoleFields({ role, index, onRemove }: RoleFieldsProps) {
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -236,9 +232,7 @@ function RoleFields({ role, index, onRemove }: RoleFieldsProps) {
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
@@ -273,9 +267,7 @@ function ExperienceForm() {
<FormLabel>
<Trans>Company</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -287,9 +279,9 @@ function ExperienceForm() {
render={({ field }) => (
<FormItem>
<FormLabel>{hasRoles ? <Trans>Overall Title (optional)</Trans> : <Trans>Position</Trans>}</FormLabel>
<FormControl>
<Input {...field} placeholder={hasRoles ? "e.g. Software Engineer → Senior Engineer" : ""} />
</FormControl>
<FormControl
render={<Input {...field} placeholder={hasRoles ? "e.g. Software Engineer → Senior Engineer" : ""} />}
/>
<FormMessage />
</FormItem>
)}
@@ -303,9 +295,7 @@ function ExperienceForm() {
<FormLabel>
<Trans>Location</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -317,9 +307,7 @@ function ExperienceForm() {
render={({ field }) => (
<FormItem>
<FormLabel>{hasRoles ? <Trans>Overall Period</Trans> : <Trans>Period</Trans>}</FormLabel>
<FormControl>
<Input {...field} placeholder={hasRoles ? "e.g. 2018 Present" : ""} />
</FormControl>
<FormControl render={<Input {...field} placeholder={hasRoles ? "e.g. 2018 Present" : ""} />} />
<FormMessage />
</FormItem>
)}
@@ -333,14 +321,12 @@ function ExperienceForm() {
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
</FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
<FormMessage />
</FormItem>
)}
@@ -351,9 +337,7 @@ function ExperienceForm() {
name="options.showLinkInTitle"
render={({ field }) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
<FormLabel>
<Trans>Show link in title</Trans>
</FormLabel>
@@ -408,9 +392,7 @@ function ExperienceForm() {
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
+7 -9
View File
@@ -157,9 +157,11 @@ function InterestForm() {
name={"icon"}
render={({ field }) => (
<FormItem className="shrink-0">
<FormControl>
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
</FormControl>
<FormControl
render={
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
}
/>
</FormItem>
)}
/>
@@ -172,9 +174,7 @@ function InterestForm() {
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input className="rounded-l-none!" {...field} />
</FormControl>
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -189,9 +189,7 @@ function InterestForm() {
<FormLabel>
<Trans>Keywords</Trans>
</FormLabel>
<FormControl>
<ChipInput {...field} />
</FormControl>
<FormControl render={<ChipInput {...field} />} />
<FormMessage />
</FormItem>
)}
+13 -15
View File
@@ -152,9 +152,7 @@ function LanguageForm() {
<FormLabel>
<Trans>Language</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -168,9 +166,7 @@ function LanguageForm() {
<FormLabel>
<Trans>Fluency</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -184,15 +180,17 @@ function LanguageForm() {
<FormLabel>
<Trans>Level</Trans>
</FormLabel>
<FormControl>
<Slider
min={0}
max={5}
step={1}
value={[field.value]}
onValueChange={(value) => field.onChange(value[0])}
/>
</FormControl>
<FormControl
render={
<Slider
min={0}
max={5}
step={1}
value={[field.value]}
onValueChange={(value) => field.onChange(Array.isArray(value) ? value[0] : value)}
/>
}
/>
<FormMessage />
<FormDescription>{Number(field.value) === 0 ? t`Hidden` : `${field.value} / 5`}</FormDescription>
</FormItem>
+15 -21
View File
@@ -162,9 +162,11 @@ function ProfileForm() {
name={"icon"}
render={({ field }) => (
<FormItem className="shrink-0">
<FormControl>
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
</FormControl>
<FormControl
render={
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
}
/>
</FormItem>
)}
/>
@@ -177,9 +179,7 @@ function ProfileForm() {
<FormLabel>
<Trans>Network</Trans>
</FormLabel>
<FormControl>
<Input className="rounded-l-none!" {...field} />
</FormControl>
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -201,9 +201,7 @@ function ProfileForm() {
</InputGroupText>
</InputGroupAddon>
<FormControl>
<InputGroupInput {...field} />
</FormControl>
<FormControl render={<InputGroupInput {...field} />} />
</InputGroup>
<FormMessage />
</FormItem>
@@ -218,14 +216,12 @@ function ProfileForm() {
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
</FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
<FormMessage />
</FormItem>
)}
@@ -236,10 +232,8 @@ function ProfileForm() {
name="options.showLinkInTitle"
render={({ field }) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="!mt-0">
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
+11 -21
View File
@@ -157,9 +157,7 @@ function ProjectForm() {
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -173,9 +171,7 @@ function ProjectForm() {
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -189,14 +185,12 @@ function ProjectForm() {
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
</FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
<FormMessage />
</FormItem>
)}
@@ -207,10 +201,8 @@ function ProjectForm() {
name="options.showLinkInTitle"
render={({ field }) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="!mt-0">
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
@@ -225,9 +217,7 @@ function ProjectForm() {
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
+12 -24
View File
@@ -159,9 +159,7 @@ function PublicationForm() {
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -175,9 +173,7 @@ function PublicationForm() {
<FormLabel>
<Trans>Publisher</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -191,9 +187,7 @@ function PublicationForm() {
<FormLabel>
<Trans>Date</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -207,14 +201,12 @@ function PublicationForm() {
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
</FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
<FormMessage />
</FormItem>
)}
@@ -225,10 +217,8 @@ function PublicationForm() {
name="options.showLinkInTitle"
render={({ field }) => (
<FormItem className="flex items-center gap-x-2">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="!mt-0">
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
@@ -243,9 +233,7 @@ function PublicationForm() {
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
+12 -24
View File
@@ -159,9 +159,7 @@ function ReferenceForm() {
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -175,9 +173,7 @@ function ReferenceForm() {
<FormLabel>
<Trans>Position</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -191,9 +187,7 @@ function ReferenceForm() {
<FormLabel>
<Trans>Phone</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -207,14 +201,12 @@ function ReferenceForm() {
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
</FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
<FormMessage />
</FormItem>
)}
@@ -225,10 +217,8 @@ function ReferenceForm() {
name="options.showLinkInTitle"
render={({ field }) => (
<FormItem className="flex items-center gap-x-2">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="!mt-0">
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
@@ -243,9 +233,7 @@ function ReferenceForm() {
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
+19 -21
View File
@@ -163,9 +163,11 @@ function SkillForm() {
name={"icon"}
render={({ field }) => (
<FormItem className="shrink-0">
<FormControl>
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
</FormControl>
<FormControl
render={
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
}
/>
</FormItem>
)}
/>
@@ -178,9 +180,7 @@ function SkillForm() {
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input className="rounded-l-none!" {...field} />
</FormControl>
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -195,9 +195,7 @@ function SkillForm() {
<FormLabel>
<Trans>Proficiency</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -211,15 +209,17 @@ function SkillForm() {
<FormLabel>
<Trans>Level</Trans>
</FormLabel>
<FormControl>
<Slider
min={0}
max={5}
step={1}
value={[field.value]}
onValueChange={(value) => field.onChange(value[0])}
/>
</FormControl>
<FormControl
render={
<Slider
min={0}
max={5}
step={1}
value={[field.value]}
onValueChange={(value) => field.onChange(Array.isArray(value) ? value[0] : value)}
/>
}
/>
<FormMessage />
<FormDescription>{Number(field.value) === 0 ? t`Hidden` : `${field.value} / 5`}</FormDescription>
</FormItem>
@@ -234,9 +234,7 @@ function SkillForm() {
<FormLabel>
<Trans>Keywords</Trans>
</FormLabel>
<FormControl>
<ChipInput {...field} />
</FormControl>
<FormControl render={<ChipInput {...field} />} />
<FormMessage />
</FormItem>
)}
+1 -3
View File
@@ -140,9 +140,7 @@ function SummaryItemForm() {
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
+12 -24
View File
@@ -159,9 +159,7 @@ function VolunteerForm() {
<FormLabel>
<Trans>Organization</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -175,9 +173,7 @@ function VolunteerForm() {
<FormLabel>
<Trans>Location</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -191,9 +187,7 @@ function VolunteerForm() {
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormControl render={<Input {...field} />} />
<FormMessage />
</FormItem>
)}
@@ -207,14 +201,12 @@ function VolunteerForm() {
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
</FormControl>
<URLInput
{...field}
value={field.value}
onChange={field.onChange}
hideLabelButton={form.watch("options.showLinkInTitle")}
/>
<FormMessage />
</FormItem>
)}
@@ -225,10 +217,8 @@ function VolunteerForm() {
name="options.showLinkInTitle"
render={({ field }) => (
<FormItem className="flex items-center gap-x-2">
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="!mt-0">
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
@@ -243,9 +233,7 @@ function VolunteerForm() {
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
<FormMessage />
</FormItem>
)}
+17 -21
View File
@@ -1,7 +1,6 @@
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { SlideshowIcon } from "@phosphor-icons/react";
import { type RefObject, useRef } from "react";
import { CometCard } from "@/components/animation/comet-card";
import { useResumeStore } from "@/components/resume/store/resume";
import { Badge } from "@/components/ui/badge";
@@ -14,8 +13,6 @@ import { cn } from "@/utils/style";
import { type TemplateMetadata, templates } from "./data";
export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const closeDialog = useDialogStore((state) => state.closeDialog);
const selectedTemplate = useResumeStore((state) => state.resume.data.metadata.template);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
@@ -44,14 +41,13 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">)
</DialogDescription>
</DialogHeader>
<ScrollArea ref={scrollAreaRef} className="max-h-[80svh] pb-8">
<ScrollArea className="max-h-[80svh] pb-8">
<div className="grid grid-cols-2 gap-6 p-4 md:grid-cols-3 lg:grid-cols-4">
{Object.entries(templates).map(([template, metadata]) => (
<TemplateCard
key={template}
metadata={metadata}
id={template as Template}
collisionBoundary={scrollAreaRef}
isActive={template === selectedTemplate}
onSelect={onSelectTemplate}
/>
@@ -66,28 +62,29 @@ type TemplateCardProps = {
id: Template;
isActive?: boolean;
metadata: TemplateMetadata;
collisionBoundary: RefObject<HTMLDivElement | null>;
onSelect: (template: Template) => void;
};
function TemplateCard({ id, metadata, isActive, collisionBoundary, onSelect }: TemplateCardProps) {
function TemplateCard({ id, metadata, isActive, onSelect }: TemplateCardProps) {
const { i18n } = useLingui();
return (
<HoverCard openDelay={0} closeDelay={0}>
<HoverCard>
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
<HoverCardTrigger asChild>
<button
tabIndex={-1}
onClick={() => onSelect(id)}
className={cn(
"relative block aspect-page size-full cursor-pointer overflow-hidden rounded-md bg-popover outline-none",
isActive && "ring-2 ring-ring ring-offset-4 ring-offset-background",
)}
>
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
</button>
</HoverCardTrigger>
<HoverCardTrigger
render={
<button
tabIndex={-1}
onClick={() => onSelect(id)}
className={cn(
"relative block aspect-page size-full cursor-pointer overflow-hidden rounded-md bg-popover outline-none",
isActive && "ring-2 ring-ring ring-offset-4 ring-offset-background",
)}
>
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
</button>
}
/>
<div className="flex items-center justify-center">
<span className="font-bold leading-loose tracking-tight">{metadata.name}</span>
@@ -98,7 +95,6 @@ function TemplateCard({ id, metadata, isActive, collisionBoundary, onSelect }: T
sideOffset={-32}
align="start"
alignOffset={32}
collisionBoundary={collisionBoundary.current}
className="pointer-events-none! flex w-80 flex-col justify-between space-y-6 rounded-md bg-background/80 p-4 pb-6"
>
<div className="space-y-1">
+1
View File
@@ -76,6 +76,7 @@ export function ConfirmDialogProvider({ children }: { children: React.ReactNode
{state.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>{state.cancelText ?? "Cancel"}</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>{state.confirmText ?? "Confirm"}</AlertDialogAction>
+52
View File
@@ -0,0 +1,52 @@
import * as React from "react";
type DataStateValue = string | boolean | null;
function parseDatasetValue(value: string | null): DataStateValue {
if (value === null) return null;
if (value === "" || value === "true") return true;
if (value === "false") return false;
return value;
}
function useDataState<T extends HTMLElement = HTMLElement>(
key: string,
forwardedRef?: React.Ref<T | null>,
onChange?: (value: DataStateValue) => void,
): [DataStateValue, React.RefObject<T | null>] {
const localRef = React.useRef<T | null>(null);
React.useImperativeHandle(forwardedRef, () => localRef.current as T);
const getSnapshot = (): DataStateValue => {
const el = localRef.current;
return el ? parseDatasetValue(el.getAttribute(`data-${key}`)) : null;
};
const subscribe = (callback: () => void) => {
const el = localRef.current;
if (!el) return () => {};
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.attributeName === `data-${key}`) {
callback();
break;
}
}
});
observer.observe(el, {
attributes: true,
attributeFilter: [`data-${key}`],
});
return () => observer.disconnect();
};
const value = React.useSyncExternalStore(subscribe, getSnapshot);
React.useEffect(() => {
if (onChange) onChange(value);
}, [value, onChange]);
return [value, localRef];
}
export { useDataState };
+34 -52
View File
@@ -1,62 +1,44 @@
You are an expert resume writer and a specialist in JSON Patch (RFC 6902) operations. Your role is to help the user improve and modify their resume through natural conversation.
You are a resume editing assistant that must modify resume data only through JSON Patch (RFC 6902) tool calls.
## Your Capabilities
## Objective
- Help the user improve resume content and structure.
- Apply edits safely and minimally via the `patch_resume` tool.
- You can read and understand the user's current resume data (provided below).
- You can modify the resume by calling the `patch_resume` tool with JSON Patch operations.
- You can advise on resume best practices, wording, and structure.
## Allowed Inputs
- User instructions in conversation.
- Current resume JSON state provided below.
## Rules
## Hard Constraints
1. For any data change, always call `patch_resume`. Do not output raw patch arrays directly in chat text.
2. Generate the minimal set of patch operations required for the request.
3. Preserve existing data unless the user explicitly asks to replace or remove it.
4. Ask for confirmation before destructive edits (deletions, clears, or replacing large sections).
5. Stay resume-focused; decline off-topic requests.
6. Do not fabricate factual user history. For drafted content, label it as a draft and ask for confirmation.
7. Keep all paths and operations valid for RFC 6902 and current schema.
8. New item IDs must be UUIDs in `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` format.
9. HTML fields (such as summary/description) must use valid HTML (`<p>`, `<ul>`, `<li>`, `<strong>`, `<em>` as needed).
1. **Always use the `patch_resume` tool** to make changes. Never output raw JSON or patch operations in your text response.
2. **Generate the minimal set of operations** needed for each change. Do not replace entire objects when only a single field needs updating.
3. **Preserve existing data** unless the user explicitly asks to remove or replace it.
4. **Confirm before destructive actions** like removing sections or clearing large amounts of content.
5. **Stay on topic.** Only discuss resume-related content. Politely decline off-topic requests.
6. **Do not fabricate content.** Only add information the user provides or explicitly asks you to generate. If generating content (e.g. a summary), make it clear you are drafting and ask for approval.
7. **HTML content fields** (description, summary content, cover letter content) must use valid HTML. Use `<p>` for paragraphs, `<ul>`/`<li>` for lists, `<strong>` for bold, `<em>` for italic.
8. **IDs for new items** must be valid UUIDs (use the format `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`).
## Conflict Resolution Order
1. Data safety and schema validity
2. User intent from latest instruction
3. Minimal-change editing strategy
## Resume Data Structure
## Editing Rules
- Prefer targeted `replace` operations over broad object replacements.
- Use `add` at `/items/-` for appending list entries.
- Use `remove` only when explicitly requested or confirmed.
- Keep `website` objects shaped as `{ "url": string, "label": string }`.
- Keep `hidden` fields explicit booleans.
The resume data is a JSON object with these top-level keys:
## Resume Shape Reference
- Top-level keys: `basics`, `summary`, `picture`, `sections`, `customSections`, `metadata`
- Section item families in `sections`: `profiles`, `experience`, `education`, `projects`, `skills`, `languages`, `interests`, `awards`, `certifications`, `publications`, `volunteer`, `references`
- `basics` — name, headline, email, phone, location, website, customFields
- `summary` — title, content (HTML), columns, hidden
- `picture` — url, size, rotation, aspectRatio, border/shadow settings
- `sections` — built-in sections, each with title, columns, hidden, items[]
- `profiles` — items: { network, username, icon, website }
- `experience` — items: { company, position, location, period, website, description }
- `education` — items: { school, degree, area, grade, location, period, website, description }
- `projects` — items: { name, period, website, description }
- `skills` — items: { name, proficiency, level, keywords[], icon }
- `languages` — items: { language, fluency, level }
- `interests` — items: { name, keywords[], icon }
- `awards` — items: { title, awarder, date, website, description }
- `certifications` — items: { title, issuer, date, website, description }
- `publications` — items: { title, publisher, date, website, description }
- `volunteer` — items: { organization, location, period, website, description }
- `references` — items: { name, position, website, phone, description }
- `customSections` — array of user-created sections with id, type, title, items[]
- `metadata` — template, layout, css, page, design, typography, notes
Every item in a section has: `id` (UUID), `hidden` (boolean), and optionally `options`.
Every `website` field is an object: `{ url: string, label: string }`.
## JSON Patch Path Examples
| Action | Operation |
|--------|-----------|
| Change name | `{ "op": "replace", "path": "/basics/name", "value": "Jane Doe" }` |
| Update headline | `{ "op": "replace", "path": "/basics/headline", "value": "Senior Engineer" }` |
| Replace summary content | `{ "op": "replace", "path": "/summary/content", "value": "<p>Experienced engineer...</p>" }` |
| Add experience item | `{ "op": "add", "path": "/sections/experience/items/-", "value": { ...full item object } }` |
| Remove skill at index 2 | `{ "op": "remove", "path": "/sections/skills/items/2" }` |
| Update a specific item field | `{ "op": "replace", "path": "/sections/experience/items/0/company", "value": "New Corp" }` |
| Change template | `{ "op": "replace", "path": "/metadata/template", "value": "bronzor" }` |
| Change primary color | `{ "op": "replace", "path": "/metadata/design/colors/primary", "value": "rgba(37, 99, 235, 1)" }` |
| Hide a section | `{ "op": "replace", "path": "/sections/interests/hidden", "value": true }` |
| Rename a section title | `{ "op": "replace", "path": "/sections/experience/title", "value": "Work History" }` |
## Output Contract
- If a change is needed: call `patch_resume`, then provide a concise natural-language confirmation.
- If no change is needed: provide concise guidance without tool calls.
- Never include markdown code blocks for patch payloads in your chat reply.
## Current Resume Data
@@ -1,75 +1,46 @@
You are a specialized resume parsing assistant that converts Microsoft Word (DOC/DOCX) resumes into a structured JSON format compatible with Reactive Resume. Your primary directive is accuracy and faithfulness to the source document.
You are a strict resume extraction engine for Microsoft Word files (DOC/DOCX). Convert the attached document into a Reactive Resume JSON object.
## CRITICAL RULES
## Objective
- Extract resume content accurately and map it into the provided JSON template.
- Prioritize source fidelity and schema correctness over completeness.
### Anti-Hallucination Guidelines
1. **Extract ONLY information explicitly present in the resume** - Never invent, assume, or infer data that isn't clearly stated
2. **When uncertain, omit rather than guess** - Leave fields empty ("") or use empty arrays ([]) rather than fabricating content
3. **Preserve original wording** - Use the exact text from the resume; do not paraphrase, embellish, or "improve" the content
4. **Do not fill gaps** - If a date range is missing an end date, leave it empty; if a job title seems incomplete, use what's provided
5. **No external knowledge** - Do not add information about companies, schools, or technologies that isn't in the resume itself
6. **Ignore formatting artifacts** - Word documents may contain hidden formatting, track changes, comments, or metadata. Extract only visible, intended content
## Allowed Input
- Use only visible, intended content from the attached document.
- Ignore hidden text, comments, track changes, revision history, document metadata, and layout artifacts.
### Data Extraction Rules
- **Dates**: Use only dates explicitly stated. Do not calculate or estimate dates. Use the format provided in the resume.
- **URLs**: Only include URLs that are explicitly written in the resume. Do not construct URLs from usernames or company names.
- **Contact Information**: Extract only what is explicitly provided. Do not format or standardize phone numbers beyond what's shown.
- **Skills**: List only skills explicitly mentioned. Do not infer skills from job descriptions or technologies mentioned in passing.
- **Descriptions**: Convert to HTML format but preserve the original content exactly. Use <p> for paragraphs and <ul><li> for bullet points.
- **Tables and Lists**: Extract content from Word tables and lists accurately. Preserve the structure but convert to appropriate HTML format.
- **Headers and Footers**: Only extract content from headers/footers if it contains resume-relevant information (like contact details). Ignore page numbers and document metadata.
## Hard Constraints
1. Extract only explicitly stated information.
2. Never fabricate, infer, or normalize missing data.
3. Keep original wording and original language.
4. When uncertain, omit content and leave template defaults.
5. Do not use external knowledge.
### Required Field Handling
- Generate UUIDs for all \`id\` fields (use format: lowercase alphanumeric, 8-12 characters)
- Set \`hidden: false\` for all items unless the resume explicitly indicates something should be hidden
- Use \`columns: 1\` as default for sections unless multi-column layout is clearly appropriate
- For \`website\` objects, use \`{"url": "", "label": ""}\` when no URL is provided
## Conflict Resolution Order
1. Schema validity (must return valid JSON matching template shape)
2. Source fidelity (exactly what the document states)
3. Omit uncertain values (never guess)
### Section Mapping Guide
Map resume content to these sections based on explicit section headers or clear context:
- **basics**: Name, title/headline, email, phone, location (city/state/country)
- **summary**: Professional summary, objective, about me, profile
- **experience**: Work experience, employment history, professional experience
- **education**: Education, academic background, qualifications
- **skills**: Skills, technical skills, competencies, expertise
- **projects**: Projects, portfolio, personal projects
- **certifications**: Certifications, licenses, credentials
- **awards**: Awards, honors, achievements, recognition
- **languages**: Languages, language proficiency
- **volunteer**: Volunteer experience, community involvement
- **publications**: Publications, articles, papers
- **references**: References (often "Available upon request")
- **profiles**: Social media links, online profiles (LinkedIn, GitHub, etc.)
- **interests**: Interests, hobbies (only if explicitly listed)
## Extraction Rules
- Dates: preserve exactly as written.
- URLs: include only URLs explicitly visible in document content.
- Contact data: copy as-is; do not reformat.
- Skills: include only explicit skill mentions.
- Descriptions: output HTML using `<p>`, `<ul>`, `<li>` while preserving meaning.
- Lists and tables: extract visible text faithfully; preserve relationships in section fields.
- Headers/footers: include only if they contain real resume data.
- IDs: generate unique UUIDs for all `id` fields.
- `hidden`: default to `false` unless explicitly indicated otherwise.
- `columns`: default to `1` unless clearly multi-column by content intent.
- `website`: when missing, use `{ "url": "", "label": "" }`.
### Word Document Specific Considerations
- **Styles and Formatting**: Ignore Word-specific formatting (styles, themes, fonts). Focus on content structure and hierarchy.
- **Track Changes**: Ignore any tracked changes or comments. Extract only the final, accepted version of the text.
- **Hyperlinks**: Extract hyperlink URLs only if they are explicitly visible in the document. Do not extract hidden hyperlinks.
- **Tables**: Extract table content accurately, converting to appropriate structured format. Preserve relationships between table cells.
- **Multi-column Layouts**: Recognize multi-column sections and extract content in the correct order (left to right, top to bottom).
- **Text Boxes and Shapes**: Extract content from text boxes and shapes if they contain resume-relevant information.
## Section Mapping
- `basics`, `summary`, `experience`, `education`, `skills`, `projects`, `certifications`, `awards`, `languages`, `volunteer`, `publications`, `references`, `profiles`, `interests`
- Map based on explicit headings first; use local context only when heading is absent.
### Output Requirements
1. Output ONLY valid JSON - no markdown code blocks, no explanations, no comments
2. The JSON must strictly conform to the provided schema
3. All required fields must be present, even if empty
4. Use empty strings ("") for missing text fields
5. Use empty arrays ([]) for missing array fields
## Fallback Rules
- If the document is malformed or partially unreadable, return best-effort extraction for readable parts only.
- Keep unknown fields empty according to the template.
### What NOT To Do
- ❌ Do not add job responsibilities that aren't listed
- ❌ Do not expand acronyms unless the expansion is provided
- ❌ Do not add technologies to skills that are only mentioned in job descriptions
- ❌ Do not create profile URLs from usernames (e.g., don't create "github.com/username" unless the full URL is provided)
- ❌ Do not assume current employment - only mark as "Present" if the resume explicitly says so
- ❌ Do not add metrics or achievements not explicitly stated
- ❌ Do not standardize or reformat dates beyond basic consistency
- ❌ Do not translate content to another language - preserve the original language
- ❌ Do not extract hidden text, comments, or tracked changes
- ❌ Do not infer information from Word document properties or metadata
- ❌ Do not extract content from headers/footers unless it's clearly resume content (ignore page numbers, document paths, etc.)
## OUTPUT
Respond with ONLY the JSON object. No preamble, no explanation, no markdown formatting.
## Output Contract
- Return only one raw JSON object.
- No markdown, no commentary, no extra keys.
@@ -1 +1,3 @@
Here is the Microsoft Word resume document to parse. Parse it carefully following all rules above, extracting only visible content and ignoring any formatting artifacts, track changes, or hidden metadata.
The Microsoft Word resume file is attached as a file in this message.
Process the attached document and return the final JSON object only, strictly following the system rules and the provided schema template.
@@ -1,61 +1,44 @@
You are a specialized resume parsing assistant that converts PDF resumes into a structured JSON format compatible with Reactive Resume. Your primary directive is accuracy and faithfulness to the source document.
You are a strict resume extraction engine for PDF files. Convert the attached PDF into a Reactive Resume JSON object.
## CRITICAL RULES
## Objective
- Extract resume content accurately and map it into the provided JSON template.
- Prioritize source fidelity and schema correctness over completeness.
### Anti-Hallucination Guidelines
1. **Extract ONLY information explicitly present in the resume** - Never invent, assume, or infer data that isn't clearly stated
2. **When uncertain, omit rather than guess** - Leave fields empty ("") or use empty arrays ([]) rather than fabricating content
3. **Preserve original wording** - Use the exact text from the resume; do not paraphrase, embellish, or "improve" the content
4. **Do not fill gaps** - If a date range is missing an end date, leave it empty; if a job title seems incomplete, use what's provided
5. **No external knowledge** - Do not add information about companies, schools, or technologies that isn't in the resume itself
## Allowed Input
- Use only the visible content from the attached PDF document.
- Ignore OCR noise, watermarks, repeated headers/footers, and broken line wraps.
### Data Extraction Rules
- **Dates**: Use only dates explicitly stated. Do not calculate or estimate dates. Use the format provided in the resume.
- **URLs**: Only include URLs that are explicitly written in the resume. Do not construct URLs from usernames or company names.
- **Contact Information**: Extract only what is explicitly provided. Do not format or standardize phone numbers beyond what's shown.
- **Skills**: List only skills explicitly mentioned. Do not infer skills from job descriptions or technologies mentioned in passing.
- **Descriptions**: Convert to HTML format but preserve the original content exactly. Use <p> for paragraphs and <ul><li> for bullet points.
## Hard Constraints
1. Extract only explicitly stated information.
2. Never fabricate, infer, or normalize missing data.
3. Keep original wording and original language.
4. When uncertain, omit content and leave template defaults.
5. Do not use external knowledge.
### Required Field Handling
- Generate UUIDs for all \`id\` fields (use format: lowercase alphanumeric, 8-12 characters)
- Set \`hidden: false\` for all items unless the resume explicitly indicates something should be hidden
- Use \`columns: 1\` as default for sections unless multi-column layout is clearly appropriate
- For \`website\` objects, use \`{"url": "", "label": ""}\` when no URL is provided
## Conflict Resolution Order
1. Schema validity (must return valid JSON matching template shape)
2. Source fidelity (exactly what the PDF states)
3. Omit uncertain values (never guess)
### Section Mapping Guide
Map resume content to these sections based on explicit section headers or clear context:
- **basics**: Name, title/headline, email, phone, location (city/state/country)
- **summary**: Professional summary, objective, about me, profile
- **experience**: Work experience, employment history, professional experience
- **education**: Education, academic background, qualifications
- **skills**: Skills, technical skills, competencies, expertise
- **projects**: Projects, portfolio, personal projects
- **certifications**: Certifications, licenses, credentials
- **awards**: Awards, honors, achievements, recognition
- **languages**: Languages, language proficiency
- **volunteer**: Volunteer experience, community involvement
- **publications**: Publications, articles, papers
- **references**: References (often "Available upon request")
- **profiles**: Social media links, online profiles (LinkedIn, GitHub, etc.)
- **interests**: Interests, hobbies (only if explicitly listed)
## Extraction Rules
- Dates: preserve exactly as written.
- URLs: include only full URLs that are explicitly present.
- Contact data: copy as-is; do not reformat.
- Skills: include only explicit skill mentions.
- Descriptions: output HTML using `<p>`, `<ul>`, `<li>` while preserving meaning.
- IDs: generate unique UUIDs for all `id` fields.
- `hidden`: default to `false` unless explicitly indicated otherwise.
- `columns`: default to `1` unless clearly multi-column by content intent.
- `website`: when missing, use `{ "url": "", "label": "" }`.
### Output Requirements
1. Output ONLY valid JSON - no markdown code blocks, no explanations, no comments
2. The JSON must strictly conform to the provided schema
3. All required fields must be present, even if empty
4. Use empty strings ("") for missing text fields
5. Use empty arrays ([]) for missing array fields
## Section Mapping
- `basics`, `summary`, `experience`, `education`, `skills`, `projects`, `certifications`, `awards`, `languages`, `volunteer`, `publications`, `references`, `profiles`, `interests`
- Map based on explicit headings first; use local context only when heading is absent.
### What NOT To Do
- ❌ Do not add job responsibilities that aren't listed
- ❌ Do not expand acronyms unless the expansion is provided
- ❌ Do not add technologies to skills that are only mentioned in job descriptions
- ❌ Do not create profile URLs from usernames (e.g., don't create "github.com/username" unless the full URL is provided)
- ❌ Do not assume current employment - only mark as "Present" if the resume explicitly says so
- ❌ Do not add metrics or achievements not explicitly stated
- ❌ Do not standardize or reformat dates beyond basic consistency
- ❌ Do not translate content to another language - preserve the original language
## Fallback Rules
- If the PDF is low quality or partially unreadable, return best-effort extraction for readable parts only.
- Keep unknown fields empty according to the template.
## OUTPUT
Respond with ONLY the JSON object. No preamble, no explanation, no markdown formatting.
## Output Contract
- Return only one raw JSON object.
- No markdown, no commentary, no extra keys.
@@ -1 +1,3 @@
Here is the PDF resume document to parse. Parse it carefully following all rules above, extracting only information that is explicitly visible in the PDF. Ignore any artifacts caused by PDF formatting, scanned image noise, OCR errors, watermarks, or hidden content. Do not infer or assume any information beyond what is clearly present in the visible text of the PDF.
The resume PDF is attached as a file in this message.
Process the attached document and return the final JSON object only, strictly following the system rules and the provided schema template.
+2
View File
@@ -1,4 +1,5 @@
import { apiKeyClient } from "@better-auth/api-key/client";
import { sentinelClient } from "@better-auth/infra/client";
import { genericOAuthClient, inferAdditionalFields, twoFactorClient, usernameClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import type { auth } from "./config";
@@ -7,6 +8,7 @@ const getAuthClient = () => {
return createAuthClient({
plugins: [
apiKeyClient(),
sentinelClient(),
usernameClient(),
twoFactorClient({
onTwoFactorRedirect() {
+9 -4
View File
@@ -1,7 +1,11 @@
import { apiKey } from "@better-auth/api-key";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { dash } from "@better-auth/infra";
import { BetterAuthError, betterAuth } from "better-auth";
import { type GenericOAuthConfig, genericOAuth, openAPI, twoFactor } from "better-auth/plugins";
import type { GenericOAuthConfig } from "better-auth/plugins";
import { openAPI } from "better-auth/plugins";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { twoFactor } from "better-auth/plugins/two-factor";
import { username } from "better-auth/plugins/username";
import { and, eq, or } from "drizzle-orm";
import { db } from "@/integrations/drizzle/client";
@@ -79,9 +83,8 @@ const getAuthConfig = () => {
return betterAuth({
appName: "Reactive Resume",
baseURL: env.APP_URL,
secret: env.AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL ?? env.APP_URL,
secret: process.env.BETTER_AUTH_SECRET ?? env.AUTH_SECRET,
database: drizzleAdapter(db, { schema, provider: "pg" }),
@@ -90,6 +93,7 @@ const getAuthConfig = () => {
advanced: {
database: { generateId },
useSecureCookies: env.APP_URL.startsWith("https://"),
ipAddress: { ipAddressHeaders: ["x-forwarded-for", "cf-connecting-ip"] },
},
emailAndPassword: {
@@ -227,6 +231,7 @@ const getAuthConfig = () => {
},
plugins: [
dash(),
openAPI(),
apiKey({
enableSessionForAPIKeys: true,
@@ -3,11 +3,11 @@
import z, { flattenError, ZodError } from "zod";
import { type ResumeData, resumeDataSchema } from "@/schema/resume/data";
import { type Template, templateSchema } from "@/schema/templates";
import { parseRgbString } from "@/utils/color";
import { parseColorString } from "@/utils/color";
import { generateId } from "@/utils/string";
function colorToRgba(color: string): string {
const parsed = parseRgbString(color);
const parsed = parseColorString(color);
if (!parsed) return "rgba(0, 0, 0, 1)";
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${parsed.a})`;
}
@@ -1,14 +0,0 @@
import { extractRawText } from "mammoth";
import { PDFParse } from "pdf-parse";
export async function extractPdfText(base64Data: string): Promise<string> {
const pdfParse = new PDFParse({ data: base64Data });
const data = await pdfParse.getText();
return data.text;
}
export async function extractDocxText(base64Data: string): Promise<string> {
const buffer = Buffer.from(base64Data, "base64");
const data = await extractRawText({ buffer });
return data.value;
}
+100 -37
View File
@@ -29,7 +29,6 @@ import {
import type { ResumeData } from "@/schema/resume/data";
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
import { isObject } from "@/utils/sanitize";
import { extractDocxText, extractPdfText } from "./ai.server";
const aiExtractionTemplate = {
...defaultResumeData,
@@ -209,9 +208,10 @@ function parseAndValidateResumeJson(resultText: string): ResumeData {
const repairedJson = jsonrepair(jsonString);
const parsedJson = JSON.parse(repairedJson);
const mergedData = mergeDefaults(defaultResumeData, parsedJson);
const normalizedData = normalizeResumeDataForSchema(mergedData);
return resumeDataSchema.parse({
...mergedData,
...normalizedData,
customSections: [],
picture: defaultResumeData.picture,
metadata: defaultResumeData.metadata,
@@ -227,6 +227,62 @@ function parseAndValidateResumeJson(resultText: string): ResumeData {
}
}
const sectionRequiredFieldMap = {
profiles: "network",
experience: "company",
education: "school",
projects: "name",
skills: "name",
languages: "language",
interests: "name",
awards: "title",
certifications: "title",
publications: "title",
volunteer: "organization",
references: "name",
} as const;
type SectionKey = keyof typeof sectionRequiredFieldMap;
function normalizeResumeDataForSchema(data: Record<string, unknown>) {
if (!isObject(data)) return data;
if (!isObject(data.sections)) return data;
const normalizedSections: Record<string, unknown> = { ...data.sections };
for (const sectionKey of Object.keys(sectionRequiredFieldMap) as SectionKey[]) {
const section = normalizedSections[sectionKey];
if (!isObject(section)) continue;
if (!Array.isArray(section.items)) continue;
const itemTemplate = aiExtractionTemplate.sections[sectionKey].items[0] as Record<string, unknown>;
const requiredField = sectionRequiredFieldMap[sectionKey];
const normalizedItems = section.items
.filter((item): item is Record<string, unknown> => isObject(item))
.map((item) => mergeDefaults(itemTemplate, item))
.filter((item) => {
const requiredValue = item[requiredField];
if (typeof requiredValue !== "string") return false;
return requiredValue.trim().length > 0;
})
.map((item) => {
const normalizedItem = { ...item };
if (typeof normalizedItem.id !== "string" || normalizedItem.id.trim().length === 0) {
normalizedItem.id = crypto.randomUUID();
}
if (typeof normalizedItem.hidden !== "boolean") {
normalizedItem.hidden = false;
}
return normalizedItem;
});
normalizedSections[sectionKey] = { ...section, items: normalizedItems };
}
return { ...data, sections: normalizedSections };
}
export const aiProviderSchema = z.enum(["ollama", "openai", "gemini", "anthropic", "vercel-ai-gateway"]);
type AIProvider = z.infer<typeof aiProviderSchema>;
@@ -246,7 +302,7 @@ function getModel(input: GetModelInput) {
const baseURL = input.baseURL || undefined;
return match(provider)
.with("openai", () => createOpenAI({ apiKey, baseURL }).languageModel(model))
.with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model))
.with("ollama", () => createOllama({ apiKey, baseURL }).languageModel(model))
.with("anthropic", () => createAnthropic({ apiKey, baseURL }).languageModel(model))
.with("vercel-ai-gateway", () => createGateway({ apiKey, baseURL }).languageModel(model))
@@ -284,28 +340,46 @@ type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
file: z.infer<typeof fileInputSchema>;
};
function buildResumeParsingMessages({
systemPrompt,
userPrompt,
file,
mediaType,
}: {
systemPrompt: string;
userPrompt: string;
file: z.infer<typeof fileInputSchema>;
mediaType: string;
}) {
return [
{
role: "system" as const,
content:
systemPrompt +
"\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n" +
JSON.stringify(aiExtractionTemplate, null, 2),
},
{
role: "user" as const,
content: [
{ type: "text" as const, text: userPrompt },
{ type: "file" as const, data: file.data, mediaType, filename: file.name },
],
},
];
}
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
const model = getModel(input);
const pdfText = await extractPdfText(input.file.data).catch((error: unknown) =>
logAndRethrow("Failed to parse PDF locally", error),
);
const result = await generateText({
model,
messages: [
{
role: "system",
content:
pdfParserSystemPrompt +
"\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n" +
JSON.stringify(aiExtractionTemplate, null, 2),
},
{
role: "user",
content: `${pdfParserUserPrompt}\n\n--- EXTRACTED RESUME TEXT ---\n${pdfText}\n--- END OF EXTRACTED TEXT ---`,
},
],
messages: buildResumeParsingMessages({
systemPrompt: pdfParserSystemPrompt,
userPrompt: pdfParserUserPrompt,
file: input.file,
mediaType: "application/pdf",
}),
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return parseAndValidateResumeJson(result.text);
@@ -319,25 +393,14 @@ type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
const model = getModel(input);
const docxText = await extractDocxText(input.file.data).catch((error: unknown) =>
logAndRethrow("Failed to parse DOCX locally", error),
);
const result = await generateText({
model,
messages: [
{
role: "system",
content:
docxParserSystemPrompt +
"\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n" +
JSON.stringify(aiExtractionTemplate, null, 2),
},
{
role: "user",
content: `${docxParserUserPrompt}\n\n--- EXTRACTED RESUME TEXT ---\n${docxText}\n--- END OF EXTRACTED TEXT ---`,
},
],
messages: buildResumeParsingMessages({
systemPrompt: docxParserSystemPrompt,
userPrompt: docxParserUserPrompt,
file: input.file,
mediaType: input.mediaType,
}),
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return parseAndValidateResumeJson(result.text);
+12 -9
View File
@@ -11,6 +11,7 @@ import { CommandPalette } from "@/components/command-palette";
import { BreakpointIndicator } from "@/components/layout/breakpoint-indicator";
import { ThemeProvider } from "@/components/theme/provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { DialogManager } from "@/dialogs/manager";
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
import { PromptDialogProvider } from "@/hooks/use-prompt";
@@ -116,17 +117,19 @@ function RootDocument({ children }: Props) {
<I18nProvider i18n={i18n}>
<IconContext.Provider value={{ size: 16, weight: "regular" }}>
<ThemeProvider theme={theme}>
<ConfirmDialogProvider>
<PromptDialogProvider>
{children}
<TooltipProvider>
<ConfirmDialogProvider>
<PromptDialogProvider>
{children}
<DialogManager />
<CommandPalette />
<Toaster richColors position="bottom-right" />
<DialogManager />
<CommandPalette />
<Toaster richColors position="bottom-right" />
{import.meta.env.DEV && <BreakpointIndicator />}
</PromptDialogProvider>
</ConfirmDialogProvider>
{import.meta.env.DEV && <BreakpointIndicator />}
</PromptDialogProvider>
</ConfirmDialogProvider>
</TooltipProvider>
</ThemeProvider>
</IconContext.Provider>
</I18nProvider>
+24 -14
View File
@@ -204,21 +204,31 @@ export const DonationBanner = () => (
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.6 }}
>
<Button asChild size="lg" className="h-11 gap-2 px-6">
<a href="https://opencollective.com/reactive-resume" target="_blank" rel="noopener">
<HeartIcon aria-hidden="true" weight="fill" className="text-rose-400 dark:text-rose-600" />
Open Collective
<span className="sr-only"> ({t`opens in new tab`})</span>
</a>
</Button>
<Button
size="lg"
nativeButton={false}
className="h-11 gap-2 px-6"
render={
<a href="https://opencollective.com/reactive-resume" target="_blank" rel="noopener">
<HeartIcon aria-hidden="true" weight="fill" className="text-rose-400 dark:text-rose-600" />
Open Collective
<span className="sr-only"> ({t`opens in new tab`})</span>
</a>
}
/>
<Button asChild size="lg" className="h-11 gap-2 px-6">
<a href="https://github.com/sponsors/AmruthPillai" target="_blank" rel="noopener">
<GithubLogoIcon aria-hidden="true" weight="fill" className="text-zinc-400 dark:text-zinc-600" />
GitHub Sponsors
<span className="sr-only"> ({t`opens in new tab`})</span>
</a>
</Button>
<Button
size="lg"
nativeButton={false}
className="h-11 gap-2 px-6"
render={
<a href="https://github.com/sponsors/AmruthPillai" target="_blank" rel="noopener">
<GithubLogoIcon aria-hidden="true" weight="fill" className="text-zinc-400 dark:text-zinc-600" />
GitHub Sponsors
<span className="sr-only"> ({t`opens in new tab`})</span>
</a>
}
/>
</motion.div>
{/* Footer note */}
+2 -6
View File
@@ -1,6 +1,5 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CaretRightIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { buttonVariants } from "@/components/ui/button";
@@ -92,7 +91,7 @@ export function FAQ() {
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
>
<Accordion type="multiple">
<Accordion multiple>
{faqItems.map((item, index) => (
<FAQItemComponent key={item.question} item={item} index={index} />
))}
@@ -117,10 +116,7 @@ function FAQItemComponent({ item, index }: FAQItemComponentProps) {
transition={{ duration: 0.4, delay: index * 0.05 }}
>
<AccordionItem value={item.question} className="group border-t">
<AccordionTrigger className="py-5">
{item.question}
<CaretRightIcon aria-hidden="true" className="shrink-0 transition-transform duration-200" />
</AccordionTrigger>
<AccordionTrigger className="py-5">{item.question}</AccordionTrigger>
<AccordionContent className="pb-5 text-muted-foreground leading-relaxed">{item.answer}</AccordionContent>
</AccordionItem>
</motion.div>
+16 -10
View File
@@ -72,16 +72,22 @@ export function Footer() {
{/* Social Links */}
<div className="flex items-center gap-2 pt-2">
{socialLinks.map((social) => (
<Button key={social.label} size="icon-sm" variant="ghost" asChild>
<a
href={social.url}
target="_blank"
rel="noopener"
aria-label={`${social.label} (${t`opens in new tab`})`}
>
<social.icon aria-hidden="true" size={18} />
</a>
</Button>
<Button
key={social.label}
size="icon-sm"
variant="ghost"
nativeButton={false}
render={
<a
href={social.url}
target="_blank"
rel="noopener"
aria-label={`${social.label} (${t`opens in new tab`})`}
>
<social.icon aria-hidden="true" size={18} />
</a>
}
/>
))}
</div>
</div>
+15 -12
View File
@@ -58,13 +58,11 @@ export function Header() {
<div className="ml-auto flex items-center gap-x-2">
<LocaleCombobox
buttonProps={{
size: "icon",
variant: "ghost",
className: "justify-center",
"aria-label": t`Change language`,
children: () => <TranslateIcon aria-hidden="true" />,
}}
render={
<Button size="icon" variant="ghost">
<TranslateIcon />
</Button>
}
/>
<ThemeToggleButton />
@@ -72,11 +70,16 @@ export function Header() {
<div className="hidden items-center gap-x-4 sm:flex">
<GithubStarsButton />
<Button asChild size="icon" aria-label={t`Go to dashboard`}>
<Link to="/dashboard">
<ArrowRightIcon aria-hidden="true" />
</Link>
</Button>
<Button
size="icon"
nativeButton={false}
aria-label={t`Go to dashboard`}
render={
<Link to="/dashboard">
<ArrowRightIcon aria-hidden="true" />
</Link>
}
/>
</div>
</div>
</nav>
+31 -20
View File
@@ -91,27 +91,38 @@ export function Hero() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 1.4 }}
>
<Button asChild size="lg" className="group relative overflow-hidden px-4">
<Link to="/dashboard">
<span className="relative z-10 flex items-center gap-2">
<Trans>Get Started</Trans>
<ArrowRightIcon
aria-hidden="true"
className="size-4 transition-transform group-hover:translate-x-0.5"
/>
</span>
</Link>
</Button>
<Button
size="lg"
nativeButton={false}
className="group relative overflow-hidden px-4"
render={
<Link to="/dashboard">
<span className="relative z-10 flex items-center gap-2">
<Trans>Get Started</Trans>
<ArrowRightIcon
aria-hidden="true"
className="size-4 transition-transform group-hover:translate-x-0.5"
/>
</span>
</Link>
}
/>
<Button asChild size="lg" variant="ghost" className="gap-2 px-4">
<a href="https://docs.rxresu.me" target="_blank" rel="noopener">
<BookIcon aria-hidden="true" className="size-4" />
<Trans>Learn More</Trans>
<span className="sr-only">
<Trans>(opens in new tab)</Trans>
</span>
</a>
</Button>
<Button
size="lg"
variant="ghost"
className="gap-2 px-4"
nativeButton={false}
render={
<a href="https://docs.rxresu.me" target="_blank" rel="noopener">
<BookIcon aria-hidden="true" className="size-4" />
<Trans>Learn More</Trans>
<span className="sr-only">
<Trans>(opens in new tab)</Trans>
</span>
</a>
}
/>
</motion.div>
</div>
+21 -13
View File
@@ -64,11 +64,16 @@ function RouteComponent() {
<div className="text-muted-foreground">
<Trans>
Remember your password?{" "}
<Button asChild variant="link" className="h-auto gap-1.5 px-1! py-0">
<Link to="/auth/login">
Sign in now <ArrowRightIcon />
</Link>
</Button>
<Button
variant="link"
className="h-auto gap-1.5 px-1! py-0"
nativeButton={false}
render={
<Link to="/auth/login">
Sign in now <ArrowRightIcon />
</Link>
}
/>
</Trans>
</div>
</div>
@@ -83,9 +88,9 @@ function RouteComponent() {
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" placeholder="john.doe@example.com" {...field} />
</FormControl>
<FormControl
render={<Input type="email" autoComplete="email" placeholder="john.doe@example.com" {...field} />}
/>
<FormMessage />
</FormItem>
)}
@@ -112,11 +117,14 @@ function PostForgotPasswordScreen() {
</p>
</div>
<Button asChild>
<a href="mailto:">
<Trans>Open Email Client</Trans>
</a>
</Button>
<Button
nativeButton={false}
render={
<a href="mailto:">
<Trans>Open Email Client</Trans>
</a>
}
/>
</>
);
}
+42 -27
View File
@@ -90,11 +90,16 @@ function RouteComponent() {
<div className="text-muted-foreground">
<Trans>
Don't have an account?{" "}
<Button asChild variant="link" className="h-auto gap-1.5 px-1! py-0">
<Link to="/auth/register">
Create one now <ArrowRightIcon />
</Link>
</Button>
<Button
variant="link"
nativeButton={false}
className="h-auto gap-1.5 px-1! py-0"
render={
<Link to="/auth/register">
Create one now <ArrowRightIcon />
</Link>
}
/>
</Trans>
</div>
)}
@@ -111,14 +116,16 @@ function RouteComponent() {
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<Input
autoComplete="section-login username"
placeholder="john.doe@example.com"
className="lowercase"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
autoComplete="section-login username"
placeholder="john.doe@example.com"
className="lowercase"
{...field}
/>
}
/>
<FormMessage />
<FormDescription>
<Trans>You can also use your username to login.</Trans>
@@ -137,22 +144,30 @@ function RouteComponent() {
<Trans>Password</Trans>
</FormLabel>
<Button asChild tabIndex={-1} variant="link" className="h-auto p-0 text-xs leading-none">
<Link to="/auth/forgot-password">
<Trans>Forgot Password?</Trans>
</Link>
</Button>
<Button
tabIndex={-1}
variant="link"
nativeButton={false}
className="h-auto p-0 text-xs leading-none"
render={
<Link to="/auth/forgot-password">
<Trans>Forgot Password?</Trans>
</Link>
}
/>
</div>
<div className="flex items-center gap-x-1.5">
<FormControl>
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="section-login current-password"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="section-login current-password"
{...field}
/>
}
/>
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
+57 -41
View File
@@ -89,11 +89,16 @@ function RouteComponent() {
<div className="text-muted-foreground">
<Trans>
Already have an account?{" "}
<Button asChild variant="link" className="h-auto gap-1.5 px-1! py-0">
<Link to="/auth/login">
Sign in now <ArrowRightIcon />
</Link>
</Button>
<Button
variant="link"
nativeButton={false}
className="h-auto gap-1.5 px-1! py-0"
render={
<Link to="/auth/login">
Sign in now <ArrowRightIcon />
</Link>
}
/>
</Trans>
</div>
</div>
@@ -109,9 +114,11 @@ function RouteComponent() {
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input min={3} max={64} autoComplete="section-register name" placeholder="John Doe" {...field} />
</FormControl>
<FormControl
render={
<Input min={3} max={64} autoComplete="section-register name" placeholder="John Doe" {...field} />
}
/>
<FormMessage />
</FormItem>
)}
@@ -125,16 +132,18 @@ function RouteComponent() {
<FormLabel>
<Trans>Username</Trans>
</FormLabel>
<FormControl>
<Input
min={3}
max={64}
autoComplete="section-register username"
placeholder="john.doe"
className="lowercase"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
min={3}
max={64}
autoComplete="section-register username"
placeholder="john.doe"
className="lowercase"
{...field}
/>
}
/>
<FormMessage />
</FormItem>
)}
@@ -148,15 +157,17 @@ function RouteComponent() {
<FormLabel>
<Trans>Email Address</Trans>
</FormLabel>
<FormControl>
<Input
type="email"
autoComplete="section-register email"
placeholder="john.doe@example.com"
className="lowercase"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
type="email"
autoComplete="section-register email"
placeholder="john.doe@example.com"
className="lowercase"
{...field}
/>
}
/>
<FormMessage />
</FormItem>
)}
@@ -171,15 +182,17 @@ function RouteComponent() {
<Trans>Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl>
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="section-register new-password"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="section-register new-password"
{...field}
/>
}
/>
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
@@ -223,11 +236,14 @@ function PostSignupScreen() {
</AlertDescription>
</Alert>
<Button asChild>
<Link to="/dashboard">
<Trans>Continue</Trans> <ArrowRightIcon />
</Link>
</Button>
<Button
nativeButton={false}
render={
<Link to="/dashboard">
<Trans>Continue</Trans> <ArrowRightIcon />
</Link>
}
/>
</>
);
}
+11 -9
View File
@@ -85,15 +85,17 @@ function RouteComponent() {
<Trans>New Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl>
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
{...field}
/>
}
/>
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
+11 -9
View File
@@ -104,15 +104,17 @@ function RouteComponent() {
<Trans>Password</Trans>
</FormLabel>
<div className="flex items-center gap-x-1.5">
<FormControl>
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormControl
render={
<Input
min={6}
max={64}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
{...field}
/>
}
/>
<Button size="icon" variant="ghost" onClick={toggleShowPassword}>
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
+40 -32
View File
@@ -70,44 +70,52 @@ function RouteComponent() {
name="code"
render={({ field }) => (
<FormItem className="justify-self-center">
<FormControl>
<InputOTP
maxLength={10}
value={field.value}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
onChange={field.onChange}
onComplete={form.handleSubmit(onSubmit)}
pasteTransformer={(pasted) => pasted.replaceAll("-", "")}
>
<InputOTPGroup>
<InputOTPSlot index={0} className="size-12" />
<InputOTPSlot index={1} className="size-12" />
<InputOTPSlot index={2} className="size-12" />
<InputOTPSlot index={3} className="size-12" />
<InputOTPSlot index={4} className="size-12" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={5} className="size-12" />
<InputOTPSlot index={6} className="size-12" />
<InputOTPSlot index={7} className="size-12" />
<InputOTPSlot index={8} className="size-12" />
<InputOTPSlot index={9} className="size-12" />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormControl
render={
<InputOTP
maxLength={10}
value={field.value}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
onChange={field.onChange}
onComplete={form.handleSubmit(onSubmit)}
pasteTransformer={(pasted) => pasted.replaceAll("-", "")}
>
<InputOTPGroup>
<InputOTPSlot index={0} className="size-12" />
<InputOTPSlot index={1} className="size-12" />
<InputOTPSlot index={2} className="size-12" />
<InputOTPSlot index={3} className="size-12" />
<InputOTPSlot index={4} className="size-12" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={5} className="size-12" />
<InputOTPSlot index={6} className="size-12" />
<InputOTPSlot index={7} className="size-12" />
<InputOTPSlot index={8} className="size-12" />
<InputOTPSlot index={9} className="size-12" />
</InputOTPGroup>
</InputOTP>
}
/>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-x-2">
<Button type="button" variant="outline" className="flex-1" asChild>
<Link to="/auth/verify-2fa">
<ArrowLeftIcon />
<Trans>Go Back</Trans>
</Link>
</Button>
<Button
variant="outline"
className="flex-1"
nativeButton={false}
render={
<Link to="/auth/verify-2fa">
<ArrowLeftIcon />
<Trans>Go Back</Trans>
</Link>
}
/>
<Button type="submit" className="flex-1">
<CheckIcon />
<Trans>Verify</Trans>
+46 -33
View File
@@ -71,40 +71,47 @@ function RouteComponent() {
name="code"
render={({ field }) => (
<FormItem className="justify-self-center">
<FormControl>
<InputOTP
maxLength={6}
value={field.value}
pattern={REGEXP_ONLY_DIGITS}
onChange={field.onChange}
onComplete={form.handleSubmit(onSubmit)}
pasteTransformer={(pasted) => pasted.replaceAll("-", "")}
>
<InputOTPGroup>
<InputOTPSlot index={0} className="size-12" />
<InputOTPSlot index={1} className="size-12" />
<InputOTPSlot index={2} className="size-12" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} className="size-12" />
<InputOTPSlot index={4} className="size-12" />
<InputOTPSlot index={5} className="size-12" />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormControl
render={
<InputOTP
maxLength={6}
value={field.value}
pattern={REGEXP_ONLY_DIGITS}
onChange={field.onChange}
onComplete={form.handleSubmit(onSubmit)}
pasteTransformer={(pasted) => pasted.replaceAll("-", "")}
>
<InputOTPGroup>
<InputOTPSlot index={0} className="size-12" />
<InputOTPSlot index={1} className="size-12" />
<InputOTPSlot index={2} className="size-12" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} className="size-12" />
<InputOTPSlot index={4} className="size-12" />
<InputOTPSlot index={5} className="size-12" />
</InputOTPGroup>
</InputOTP>
}
/>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-x-2">
<Button type="button" variant="outline" className="flex-1" asChild>
<Link to="/auth/login">
<ArrowLeftIcon />
<Trans>Back to Login</Trans>
</Link>
</Button>
<Button
variant="outline"
className="flex-1"
nativeButton={false}
render={
<Link to="/auth/login">
<ArrowLeftIcon />
<Trans>Back to Login</Trans>
</Link>
}
/>
<Button type="submit" className="flex-1">
<CheckIcon />
@@ -112,11 +119,17 @@ function RouteComponent() {
</Button>
</div>
</form>
<Button type="button" variant="link" className="h-auto justify-self-center p-0 text-sm" asChild>
<Link to="/auth/verify-2fa-backup">
<Trans>Lost access to your authenticator?</Trans>
</Link>
</Button>
<Button
variant="link"
nativeButton={false}
className="h-auto justify-self-center p-0 text-sm"
render={
<Link to="/auth/verify-2fa-backup">
<Trans>Lost access to your authenticator?</Trans>
</Link>
}
/>
</Form>
</>
);
@@ -148,11 +148,14 @@ type DockIconProps = {
function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockIconProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
<Icon className={cn("size-4", iconClassName)} />
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
<Icon className={cn("size-4", iconClassName)} />
</Button>
}
/>
<TooltipContent side="top" align="center" className="font-medium">
{title}
</TooltipContent>
@@ -39,11 +39,16 @@ export function BuilderHeader() {
</Button>
<div className="flex items-center gap-x-1">
<Button asChild size="icon" variant="ghost">
<Link to="/dashboard/resumes" search={{ sort: "lastUpdatedAt", tags: [] }}>
<HouseSimpleIcon />
</Link>
</Button>
<Button
size="icon"
variant="ghost"
nativeButton={false}
render={
<Link to="/dashboard/resumes" search={{ sort: "lastUpdatedAt", tags: [] }}>
<HouseSimpleIcon />
</Link>
}
/>
<span className="me-2.5 text-muted-foreground">/</span>
<h2 className="flex-1 truncate font-medium">{name}</h2>
{isLocked && <LockSimpleIcon className="ms-2 text-muted-foreground" />}
@@ -123,31 +128,33 @@ function BuilderHeaderDropdown() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<CaretDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button size="icon" variant="ghost">
<CaretDownIcon />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem disabled={isLocked} onSelect={handleUpdate}>
<DropdownMenuItem disabled={isLocked} onClick={handleUpdate}>
<PencilSimpleLineIcon className="me-2" />
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleDuplicate}>
<DropdownMenuItem onClick={handleDuplicate}>
<CopySimpleIcon className="me-2" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleToggleLock}>
<DropdownMenuItem onClick={handleToggleLock}>
{isLocked ? <LockSimpleOpenIcon className="me-2" /> : <LockSimpleIcon className="me-2" />}
{isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={isLocked} onSelect={handleDelete}>
<DropdownMenuItem variant="destructive" disabled={isLocked} onClick={handleDelete}>
<TrashSimpleIcon className="me-2" />
<Trans>Delete</Trans>
</DropdownMenuItem>

Some files were not shown because too many files have changed in this diff Show More