refactor, lint, cleanup

This commit is contained in:
Eduard Gert
2026-06-09 16:31:52 +02:00
parent bada2b5b78
commit f8e3ac6d92
79 changed files with 1441 additions and 2463 deletions
@@ -5,12 +5,8 @@ import { cn } from "@/lib/cn";
export type BadgeVariant = "info" | "neutral" | "brand" | "success" | "warning" | "danger";
type Props = HTMLAttributes<HTMLSpanElement> & {
/** Visual color scheme. Defaults to `info` (sky), used as the
* "Active profile" indicator. */
variant?: BadgeVariant;
/** Optional leading lucide icon. */
icon?: ComponentType<LucideProps>;
/** Override icon size. Defaults to 10px to match the compact pill. */
iconSize?: number;
};
@@ -23,10 +19,6 @@ const VARIANT_CLASSES: Record<BadgeVariant, string> = {
danger: "bg-red-900 border border-red-700 text-red-200",
};
// Pill shape sized for inline use next to text. `top-px` nudges the badge
// down so its midline aligns with the surrounding text baseline; `leading-none`
// lets the small text sit flush in the pill without the line-height padding
// inflating it.
export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge(
{ variant = "info", icon: Icon, iconSize = 10, className, children, ...rest },
ref,
@@ -1,9 +1,7 @@
import { useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/cn";
// Static map — Tailwind JIT only picks up literal class names, so dynamic
// template strings would be invisible to it.
const VARIANT_HOVER = {
default: "group-hover/copy:[&_*]:text-nb-gray-300",
bright: "group-hover/copy:[&_*]:text-nb-gray-200",
@@ -19,10 +17,6 @@ type CopyToClipboardProps = {
className?: string;
iconClassName?: string;
alwaysShowIcon?: boolean;
// variant picks the text colour the wrapped content fades into on hover.
// - "default" → nb-gray-300 (peer-details, settings, etc.)
// - "bright" → nb-gray-200 (deeper-surface contexts like the main
// connection card where text needs more lift)
variant?: CopyToClipboardVariant;
};
@@ -36,8 +30,15 @@ export const CopyToClipboard = ({
alwaysShowIcon = false,
variant = "default",
}: CopyToClipboardProps) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLButtonElement>(null);
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation();
@@ -47,29 +48,25 @@ export const CopyToClipboard = ({
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 500);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 500);
} catch {
//
}
};
return (
<div
<button
type="button"
ref={wrapperRef}
onClick={handleClick}
className={cn(
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable",
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left",
className,
)}
>
<span
className={cn(
"relative truncate min-w-0",
// [&_*] is Tailwind's arbitrary descendant variant: & is
// this element, _ is the CSS descendant combinator, * is
// every descendant. The generated selector has higher
// specificity than a child's own text-nb-gray-* class, so
// the hover colour wins the cascade.
"[&_*]:transition-colors",
VARIANT_HOVER[variant],
)}
@@ -105,6 +102,6 @@ export const CopyToClipboard = ({
)}
/>
</span>
</div>
</button>
);
};
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { errorDialog } from "@/lib/dialogs.ts";
import { CheckIcon, ChevronDown, LanguagesIcon, Search } from "lucide-react";
import { Preferences } from "@bindings/services";
import { LanguageCode, type Language } from "@bindings/i18n/models.js";
@@ -11,14 +10,9 @@ import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
// Intentionally no flag icons here: flags represent countries, not
// languages (German is spoken across DE/AT/CH; English across US/UK/AU/
// etc.). Each label shows the endonym followed by the englishName in
// parentheses when the two differ (e.g. "Deutsch (German)"), in both
// the trigger and the dropdown rows.
// See: https://www.flagsarenotlanguages.com/blog/
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
const labelFor = (lang: Language): string =>
lang.englishName && lang.englishName !== lang.displayName
@@ -7,10 +7,6 @@ import { ManagementMode } from "@/hooks/useManagementUrl.ts";
type Props = {
value: ManagementMode;
onChange: (mode: ManagementMode) => void;
// fullWidth stretches the segmented control to fill its container —
// the SettingsGeneral row uses the default (shrink-to-content) layout,
// the welcome dialog asks for the wide variant so the picker spans the
// narrow dialog width.
fullWidth?: boolean;
};
@@ -2,11 +2,6 @@ import { ComponentType } from "react";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
// SquareIcon is the rounded-square icon tile used by dialog-style surfaces
// (ConfirmDialog, etc.). Renders a bordered tile with the provided lucide
// icon centered inside. The `variant` selects the semantic colour scheme — all
// variants keep the neutral dark tile + border; only the icon colour changes
// to match the action's severity.
export type SquareIconVariant = "default" | "info" | "warning" | "danger";
const variantClass: Record<SquareIconVariant, string> = {
+2 -11
View File
@@ -12,11 +12,7 @@ type Props = {
alignOffset?: number;
interactive?: boolean;
keepOpenOnClick?: boolean;
// Overrides the default tooltip-content chrome (background, padding,
// border, radius). Use when a richer body needs popover-style layout.
contentClassName?: string;
// Ms to wait after pointer-leave before closing. Lets the user cross
// a gap between trigger and content without the tooltip snapping shut.
closeDelay?: number;
};
@@ -60,10 +56,7 @@ export const Tooltip = ({
};
return (
<RTooltip.Provider
delayDuration={delayDuration}
disableHoverableContent={!interactive}
>
<RTooltip.Provider delayDuration={delayDuration} disableHoverableContent={!interactive}>
<RTooltip.Root open={open} onOpenChange={handleOpenChange}>
<RTooltip.Trigger
asChild
@@ -86,9 +79,7 @@ export const Tooltip = ({
alignOffset={alignOffset}
onPointerEnter={interactive ? cancelClose : undefined}
onPointerLeave={interactive ? scheduleClose : undefined}
onPointerDownOutside={
interactive ? undefined : (e) => e.preventDefault()
}
onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()}
className={cn(
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
@@ -8,17 +8,7 @@ type Props = {
delayDuration?: number;
};
// Renders text with `truncate`; measures scrollWidth vs clientWidth after
// layout and wraps in a Tooltip only when the text actually overflows. Avoids
// the "tooltip on hover even though everything fits" annoyance. The caller
// supplies the wrapper styling (font, max-width, etc.) via className — this
// component only owns the truncate + measure + tooltip behavior.
export const TruncatedText = ({
text,
className,
tooltipContent,
delayDuration = 600,
}: Props) => {
export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
@@ -3,32 +3,32 @@ import * as Tabs from "@radix-ui/react-tabs";
import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
const Root = forwardRef<
HTMLDivElement,
Omit<Tabs.TabsProps, "orientation">
>(function VerticalTabsRoot({ className, ...props }, ref) {
return (
<Tabs.Root
ref={ref}
orientation={"vertical"}
className={cn("flex flex-1 min-h-0", className)}
{...props}
/>
);
});
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(
function VerticalTabsList({ className, ...props }, ref) {
const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>(
function VerticalTabsRoot({ className, ...props }, ref) {
return (
<Tabs.List
<Tabs.Root
ref={ref}
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
orientation={"vertical"}
className={cn("flex flex-1 min-h-0", className)}
{...props}
/>
);
},
);
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTabsList(
{ className, ...props },
ref,
) {
return (
<Tabs.List
ref={ref}
className={cn("w-full flex flex-col gap-1 p-5 pr-0", className)}
{...props}
/>
);
});
type TriggerProps = Tabs.TabsTriggerProps & {
icon: ComponentType<LucideProps>;
title: string;
@@ -36,54 +36,47 @@ type TriggerProps = Tabs.TabsTriggerProps & {
adornment?: ReactNode;
};
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(
function VerticalTabsTrigger(
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
ref,
) {
return (
<Tabs.Trigger
ref={ref}
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTabsTrigger(
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
ref,
) {
return (
<Tabs.Trigger
ref={ref}
className={cn(
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
"data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-935",
className,
)}
{...props}
>
<Icon
size={iconSize}
className={cn(
"group w-full flex items-center gap-3 py-2.5 px-2 rounded-lg cursor-default outline-none text-left",
"transition-colors duration-150",
"data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-935",
className,
"shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
{...props}
>
<Icon
size={iconSize}
className={cn(
"shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
/>
<h2
className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
>
{title}
</h2>
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
</Tabs.Trigger>
);
},
);
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(
function VerticalTabsContent({ className, ...props }, ref) {
return (
<Tabs.Content
ref={ref}
className={cn("outline-none", className)}
{...props}
/>
);
},
);
<h2
className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
>
{title}
</h2>
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
</Tabs.Trigger>
);
});
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(function VerticalTabsContent(
{ className, ...props },
ref,
) {
return <Tabs.Content ref={ref} className={cn("outline-none", className)} {...props} />;
});
export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });
@@ -1,6 +1,6 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, Copy, Loader2 } from "lucide-react";
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
import { ButtonHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/cn";
@@ -10,9 +10,6 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVar
disabled?: boolean;
stopPropagation?: boolean;
copy?: string;
// When true, the content is replaced by a centered spinner while keeping
// the button's rendered width/height (the content stays in the layout,
// just hidden). Also disables the button.
loading?: boolean;
}
@@ -134,6 +131,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref,
) {
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const iconSize = size === "xs" ? 12 : 14;
return (
<button
@@ -156,7 +160,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
.writeText(copy)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
}
@@ -2,15 +2,6 @@ import { ReactNode, forwardRef } from "react";
import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
// ConfirmDialog is the shared layout wrapper used by dialog-style window
// surfaces (SessionExpiration, …). Purely a layout
// primitive — callers compose the contents (SquareIcon, DialogHeading,
// DialogDescription, DialogActions) so each dialog can tweak its own
// internal structure without growing the ConfirmDialog API.
//
// Callers that mount the dialog inside its own Wails window pair this
// with useAutoSizeWindow by forwarding the returned ref onto the content
// wrapper so the window height tracks the rendered content.
type ConfirmDialogProps = {
children: ReactNode;
};
@@ -6,25 +6,13 @@ import { DialogHeading } from "@/components/dialog/DialogHeading";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogActions } from "@/components/dialog/DialogActions";
// ConfirmModal is the shared in-app confirmation modal — a left-aligned
// title + (optionally multi-line) description with Cancel / confirm buttons
// in the footer. It's the in-window counterpart to a native confirm dialog.
//
// Most call sites should not render this directly: use the imperative
// `useConfirm()` from DialogContext (`await confirm({...})`), which mounts a
// single instance at the provider level. Render ConfirmModal yourself only
// when you need bespoke control over its open/busy lifecycle.
type ConfirmModalProps = {
open: boolean;
title: ReactNode;
description: ReactNode;
/** Confirm button label. */
confirmLabel: string;
/** Cancel button label; defaults to the shared "Cancel" string. */
cancelLabel?: string;
/** Use the destructive (red) confirm button variant. */
danger?: boolean;
/** Disable the buttons (and ignore dismiss) while an action runs. */
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
@@ -43,9 +31,7 @@ export const ConfirmModal = ({
}: ConfirmModalProps) => {
const { t } = useTranslation();
// Retain the last shown content so it stays rendered through Radix's
// close animation instead of blanking out the instant the caller clears
// its state on close.
// Retain last content so it survives Radix's close animation.
type Snapshot = Pick<ConfirmModalProps, "title" | "description" | "confirmLabel" | "danger"> & {
cancelLabel: string;
};
@@ -1,21 +1,13 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
// DialogActions wraps a vertical stack of Buttons inside a dialog surface.
// The wails-no-draggable class lets the user click the buttons even when
// the dialog window itself is draggable from any background region.
type DialogActionsProps = {
children: ReactNode;
className?: string;
};
export const DialogActions = ({ children, className }: DialogActionsProps) => (
<div
className={cn(
"wails-no-draggable flex flex-col gap-3 w-full mx-auto",
className,
)}
>
<div className={cn("wails-no-draggable flex flex-col gap-3 w-full mx-auto", className)}>
{children}
</div>
);
@@ -1,8 +1,6 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
// DialogDescription is the supporting description text rendered under a
// DialogHeading inside ConfirmDialog (and similar dialog surfaces).
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
@@ -17,18 +15,12 @@ type DialogDescriptionProps = {
align?: DialogAlign;
};
export const DialogDescription = ({ children, className, align = "center" }: DialogDescriptionProps) => (
// w-full for the same reason DialogHeading carries it — see the
// comment there. The default text-center remains visually identical
// to before; left/right alignment now anchors to the dialog content
// edge instead of collapsing to no-op on a content-width box.
<p
className={cn(
"w-full text-sm text-nb-gray-300 select-none",
alignClass[align],
className,
)}
>
export const DialogDescription = ({
children,
className,
align = "center",
}: DialogDescriptionProps) => (
<p className={cn("w-full text-sm text-nb-gray-300 select-none", alignClass[align], className)}>
{children}
</p>
);
@@ -1,9 +1,6 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn";
// DialogHeading is the title text used inside ConfirmDialog (and any other
// dialog-style surface with the same shape). Pair with DialogDescription
// for the standard title/description stack.
type DialogAlign = "left" | "center" | "right";
const alignClass: Record<DialogAlign, string> = {
@@ -19,12 +16,6 @@ type DialogHeadingProps = {
};
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
// w-full so the alignClass actually has a box to anchor against.
// The wrapping <p> defaulted to content width inside a flex column,
// which made `text-left` a no-op (nothing to push the text away
// from). Stretching the element is invisible for the default
// text-center case (center of content == center of box) and lets
// text-left/right line up with the dialog's content edge.
<p
className={cn(
"w-full text-base font-semibold text-nb-gray-50 select-none",
@@ -7,7 +7,7 @@ import { useStatus } from "@/contexts/StatusContext.tsx";
const DOCS_URL = "https://docs.netbird.io/how-to/installation";
function openUrl(url: string) {
void Browser.OpenURL(url).catch(() => window.open(url, "_blank"));
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
}
export const DaemonUnavailableOverlay = () => {
@@ -21,10 +21,6 @@ export const DaemonUnavailableOverlay = () => {
className={
"fixed inset-0 z-[100] flex items-center justify-center bg-nb-gray-950 backdrop-blur-sm cursor-default select-none wails-draggable"
}
onKeyDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div className={"flex flex-col items-center gap-5 px-8 max-w-lg text-center"}>
<div
@@ -3,6 +3,10 @@ import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
import { SquareIcon } from "@/components/SquareIcon";
// Knob to shift the centered main-window content up/down together.
export const CONTENT_VERTICAL_OFFSET = "-1.4rem";
export const contentTop = (base: string) => `calc(${base} + ${CONTENT_VERTICAL_OFFSET})`;
type Props = {
icon: ComponentType<LucideProps>;
title: string;
@@ -15,8 +19,9 @@ export const EmptyState = ({ icon, title, description, className }: Props) => {
<div className={cn("py-12 text-center", className)}>
<div
className={
"flex flex-col items-center justify-start max-w-sm mx-auto relative top-[7.8rem]"
"flex flex-col items-center justify-start max-w-sm mx-auto relative"
}
style={{ top: contentTop("7.8rem") }}
>
<SquareIcon icon={icon} className={"mb-3"} />
<p className={"text-[0.95rem] font-medium text-nb-gray-200 mb-1"}>{title}</p>
+191 -109
View File
@@ -1,6 +1,14 @@
import { cva, VariantProps } from "class-variance-authority";
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
import { forwardRef, InputHTMLAttributes, ReactNode, useId, useRef, useState } from "react";
import {
forwardRef,
InputHTMLAttributes,
ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { Label } from "@/components/typography/Label";
@@ -14,9 +22,6 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, Input
maxWidthClass?: string;
icon?: ReactNode;
error?: string;
// A soft, non-blocking caveat rendered in orange (vs. error's red). Used
// e.g. for "couldn't reach this server" where the value is syntactically
// fine and the user may still proceed. `error` takes precedence.
warning?: string;
prefixClassName?: string;
showPasswordToggle?: boolean;
@@ -52,6 +57,151 @@ const inputVariants = cva("", {
},
});
function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number {
const stepAttr = el.step === "" ? 1 : Number(el.step);
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
const min = el.min === "" ? -Infinity : Number(el.min);
const max = el.max === "" ? Infinity : Number(el.max);
const current = el.value === "" ? 0 : Number(el.value);
let next = (Number.isFinite(current) ? current : 0) + delta * step;
if (next < min) next = min;
if (next > max) next = max;
return next;
}
function buildInputClassName(
opts: Readonly<{
variant: InputVariants["variant"];
hasCustomPrefix: boolean;
hasSuffix: boolean;
hasIcon: boolean;
readOnly?: boolean;
showStepper: boolean;
className?: string;
}>,
): string {
return cn(
inputVariants({ variant: opts.variant }),
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm select-text",
"file:bg-transparent file:text-sm file:font-medium file:border-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
opts.hasCustomPrefix && "!border-l-0 !rounded-l-none",
opts.hasSuffix && "!pr-9",
opts.hasIcon && "!pl-10",
"border",
opts.readOnly && "!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
opts.showStepper &&
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
opts.className,
);
}
function InputAffix({
content,
error,
disabled,
className,
}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) {
return (
<div
className={cn(
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"border items-center whitespace-nowrap",
disabled && "opacity-40",
className,
)}
>
{content}
</div>
);
}
function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) {
return (
<div
className={cn(
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
disabled && "opacity-40",
)}
>
{icon}
</div>
);
}
function InputSuffixSlot({
suffix,
disabled,
}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) {
return (
<div
className={cn(
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
disabled && "opacity-30",
)}
>
{suffix}
</div>
);
}
function NumberStepper({
error,
disabled,
onStep,
}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) {
const { t } = useTranslation();
return (
<div
className={cn(
"flex flex-col h-[40px] shrink-0 overflow-hidden",
"border border-l-0 rounded-r-md",
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
disabled && "opacity-40 pointer-events-none",
)}
>
<button
type="button"
tabIndex={-1}
aria-label={t("common.increase")}
onClick={() => onStep(1)}
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
>
<ChevronUp size={12} />
</button>
<button
type="button"
tabIndex={-1}
aria-label={t("common.decrease")}
onClick={() => onStep(-1)}
className={cn(
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
"border-t border-neutral-200 dark:border-nb-gray-700",
)}
>
<ChevronDown size={12} />
</button>
</div>
);
}
function FieldMessage({ error, warning }: Readonly<{ error?: string; warning?: string }>) {
if (!error && !warning) return null;
return (
<span
className={cn(
"text-xs mt-2 inline-flex items-center gap-1",
error ? "text-red-500" : "text-orange-400",
)}
>
{error ?? warning}
</span>
);
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
className,
@@ -82,28 +232,29 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
const reactId = useId();
const inputId = id ?? (label ? `input-${reactId}` : undefined);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
},
[],
);
const internalRef = useRef<HTMLInputElement | null>(null);
const setRefs = (el: HTMLInputElement | null) => {
internalRef.current = el;
if (typeof ref === "function") ref(el);
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
else if (ref) ref.current = el;
};
const stepBy = (delta: 1 | -1) => {
const el = internalRef.current;
if (!el || el.disabled || el.readOnly) return;
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
globalThis.HTMLInputElement.prototype,
"value",
)?.set;
const stepAttr = el.step !== "" ? Number(el.step) : 1;
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
const min = el.min !== "" ? Number(el.min) : -Infinity;
const max = el.max !== "" ? Number(el.max) : Infinity;
const current = el.value === "" ? 0 : Number(el.value);
let next = (Number.isFinite(current) ? current : 0) + delta * step;
if (next < min) next = min;
if (next > max) next = max;
const next = computeNextStepValue(el, delta);
setter?.call(el, String(next));
el.dispatchEvent(new Event("input", { bubbles: true }));
};
@@ -121,14 +272,14 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
) : null;
const onCopy = async () => {
const text = props.value != null ? String(props.value) : (internalRef.current?.value ?? "");
const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value);
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 1500);
} catch {
// ignore
}
};
@@ -145,37 +296,33 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
const suffix = passwordToggle || copyToggle || customSuffix;
const showStepper = isNumber;
const warningVariant = warning ? "warning" : variant;
const resolvedVariant = error ? "error" : warningVariant;
const inputClassName = buildInputClassName({
variant: resolvedVariant,
hasCustomPrefix: !!customPrefix,
hasSuffix: !!suffix,
hasIcon: !!icon,
readOnly: props.readOnly,
showStepper,
className,
});
return (
<div className="flex flex-col w-full min-w-0">
{label && <Label htmlFor={inputId}>{label}</Label>}
<div className={cn("flex relative h-[40px] w-full", maxWidthClass)}>
{customPrefix && (
<div
className={cn(
inputVariants({
prefixSuffixVariant: error ? "error" : "default",
}),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"border items-center whitespace-nowrap",
props.disabled && "opacity-40",
prefixClassName,
)}
>
{customPrefix}
</div>
<InputAffix
content={customPrefix}
error={error}
disabled={props.disabled}
className={prefixClassName}
/>
)}
{icon && (
<div
className={cn(
"absolute left-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pl-3 leading-[0]",
props.disabled && "opacity-40",
)}
>
{icon}
</div>
)}
{icon && <InputIconSlot icon={icon} disabled={props.disabled} />}
<div className="relative flex flex-grow min-w-0">
<input
@@ -183,82 +330,17 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
type={inputType}
ref={setRefs}
{...props}
className={cn(
inputVariants({
variant: error ? "error" : warning ? "warning" : variant,
}),
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm select-text",
"file:bg-transparent file:text-sm file:font-medium file:border-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
customPrefix && "!border-l-0 !rounded-l-none",
suffix && "!pr-9",
icon && "!pl-10",
"border",
props.readOnly &&
"!bg-nb-gray-910 text-nb-gray-350 !border-nb-gray-800",
showStepper &&
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
className,
)}
className={inputClassName}
/>
{suffix && (
<div
className={cn(
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
props.disabled && "opacity-30",
)}
>
{suffix}
</div>
)}
{suffix && <InputSuffixSlot suffix={suffix} disabled={props.disabled} />}
</div>
{showStepper && (
<div
className={cn(
"flex flex-col h-[40px] shrink-0 overflow-hidden",
"border border-l-0 rounded-r-md",
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
props.disabled && "opacity-40 pointer-events-none",
)}
>
<button
type="button"
tabIndex={-1}
aria-label={t("common.increase")}
onClick={() => stepBy(1)}
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
>
<ChevronUp size={12} />
</button>
<button
type="button"
tabIndex={-1}
aria-label={t("common.decrease")}
onClick={() => stepBy(-1)}
className={cn(
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
"border-t border-neutral-200 dark:border-nb-gray-700",
)}
>
<ChevronDown size={12} />
</button>
</div>
<NumberStepper error={error} disabled={props.disabled} onStep={stepBy} />
)}
</div>
{(error || warning) && (
<span
className={cn(
"text-xs mt-2 inline-flex items-center gap-1",
error ? "text-red-500" : "text-orange-400",
)}
>
{error ?? warning}
</span>
)}
<FieldMessage error={error} warning={warning} />
</div>
);
});
@@ -7,49 +7,39 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
shortcut?: ReactNode;
};
export const SearchInput = forwardRef<HTMLInputElement, Props>(
function SearchInput(
{ iconSize = 16, className, disabled, shortcut, ...props },
ref,
) {
return (
<div
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
{ iconSize = 16, className, disabled, shortcut, ...props },
ref,
) {
return (
<div className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}>
<SearchIcon size={iconSize} className={"text-nb-gray-300 shrink-0"} />
<input
ref={ref}
type={"text"}
disabled={disabled}
{...props}
className={cn(
"flex items-center gap-2 px-1 h-10",
disabled && "opacity-50",
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
"outline-none border-none",
disabled && "cursor-not-allowed",
className,
)}
>
<SearchIcon
size={iconSize}
className={"text-nb-gray-300 shrink-0"}
/>
<input
ref={ref}
type={"text"}
disabled={disabled}
{...props}
/>
{shortcut && (
<span
className={cn(
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
"outline-none border-none",
disabled && "cursor-not-allowed",
className,
"shrink-0 select-none",
"inline-flex items-center justify-center",
"h-5 min-w-[20px] px-1.5 rounded",
"border border-nb-gray-850 bg-nb-gray-920",
"text-[10px] font-medium text-nb-gray-400",
"wails-no-draggable",
)}
/>
{shortcut && (
<span
className={cn(
"shrink-0 select-none",
"inline-flex items-center justify-center",
"h-5 min-w-[20px] px-1.5 rounded",
"border border-nb-gray-850 bg-nb-gray-920",
"text-[10px] font-medium text-nb-gray-400",
"wails-no-draggable",
)}
>
{shortcut}
</span>
)}
</div>
);
},
);
>
{shortcut}
</span>
)}
</div>
);
});
@@ -31,39 +31,27 @@ export default function FancyToggleSwitch({
labelClassName,
textWrapperClassName = "max-w-lg",
}: Readonly<Props>) {
const childrenRef = React.useRef<HTMLDivElement>(null);
if (loading) {
// Match the global SkeletonTheme in app.tsx (#25282d base /
// #33373e highlight) so the loading row blends in with
// SettingsSkeleton. box-decoration-clone gives every wrapped line
// of text its own rounded corners instead of just the first/last.
const shimmer =
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
return (
<div
className={cn("inline-block text-left w-full", className)}
aria-busy
>
<div className={cn("inline-block text-left w-full", className)} aria-busy>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>
<span className={shimmer}>{label}</span>
</Label>
<HelpText margin={false}>
<span
className={cn(
shimmer,
"text-[0.6rem] leading-relaxed",
)}
>
<span className={cn(shimmer, "text-[0.6rem] leading-relaxed")}>
{helpText}
</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<div
className={
"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"
}
className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"}
/>
</div>
</div>
@@ -71,16 +59,19 @@ export default function FancyToggleSwitch({
);
}
const handleToggle = () => {
if (disabled) return;
const fromChildren = (target: EventTarget | null) =>
target instanceof Node && childrenRef.current?.contains(target);
const handleToggle = (event: React.MouseEvent) => {
if (disabled || fromChildren(event.target)) return;
onChange(!value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return;
if (disabled || fromChildren(event.target)) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleToggle();
onChange(!value);
}
};
@@ -108,7 +99,7 @@ export default function FancyToggleSwitch({
</div>
</div>
{children && value ? (
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
<div className="mt-4" ref={childrenRef}>
{children}
</div>
) : null}
@@ -1,11 +1,10 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { createContext, ReactNode, useContext, useId } from "react";
import { createContext, ReactNode, useContext, useId, useMemo } from "react";
import { cn } from "@/lib/cn";
type SwitchItemGroupContextValue = {
value: string;
layoutId: string;
disabled: boolean;
};
const SwitchItemGroupContext = createContext<SwitchItemGroupContextValue | null>(null);
@@ -34,9 +33,10 @@ export const SwitchItemGroup = ({
disabled = false,
}: Props) => {
const layoutId = useId();
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
return (
<SwitchItemGroupContext.Provider value={{ value, layoutId, disabled }}>
<SwitchItemGroupContext.Provider value={contextValue}>
<RadioGroup.Root
value={value}
onValueChange={onChange}
@@ -27,11 +27,7 @@ export const Label = forwardRef<HTMLElement, LabelProps>(function Label(
}
return (
<LabelPrimitive.Root
ref={ref as Ref<HTMLLabelElement>}
className={classes}
{...props}
>
<LabelPrimitive.Root ref={ref as Ref<HTMLLabelElement>} className={classes} {...props}>
{children}
</LabelPrimitive.Root>
);