mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-20 05:39:07 +02:00
refactor, lint, cleanup
This commit is contained in:
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user