feat: privacy settings

This commit is contained in:
zhom committed 2026-09-26 10:35:32 +04:00
1 parent 759263b63f
commit d6657eac98
30 files changed
+1229 -418

No files matched your search

+95 -173
View File
@@ -1,17 +1,10 @@
"use client";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import {
LuChevronLeft,
LuChevronRight,
LuCircleHelp,
LuSearch,
LuX,
} from "react-icons/lu";
import { LuCircleHelp, LuSearch, LuX } from "react-icons/lu";
import { useWindowDecorations } from "@/hooks/use-window-decorations";
import { getCurrentOS } from "@/lib/browser-utils";
import {
@@ -25,6 +18,7 @@ import { UNGROUPED_DROP_ID, useProfileGroupDrag } from "./profile-group-drag";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollStrip } from "./ui/scroll-strip";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const HOLD_MS = 150;
@@ -165,7 +159,6 @@ const HomeHeader = ({
}: Props) => {
const { t } = useTranslation();
const profileDrag = useProfileGroupDrag();
const reducedMotion = useReducedMotion();
const [platform, setPlatform] = useState<string>("macos");
useEffect(() => {
@@ -265,36 +258,6 @@ const HomeHeader = ({
[clearHold],
);
// Horizontal scroll fades for the group filter strip — when the user
// has more groups than fit, the right edge fades to hint at overflow.
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
const [groupsFadeLeft, setGroupsFadeLeft] = useState(false);
const [groupsFadeRight, setGroupsFadeRight] = useState(false);
useEffect(() => {
if (!showProfileToolbar) return;
const el = groupsScrollRef.current;
if (!el) return;
const update = () => {
setGroupsFadeLeft(el.scrollLeft > 1);
setGroupsFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
const mutations = new MutationObserver(update);
mutations.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
mutations.disconnect();
};
}, [showProfileToolbar]);
const isWindows = platform === "windows";
return (
@@ -351,145 +314,104 @@ const HomeHeader = ({
) : null}
{showProfileToolbar && (
<div className="relative flex min-w-0 flex-1 items-center">
{groupsFadeLeft && (
<button
type="button"
aria-label={t("header.scrollGroupsLeft")}
onClick={() => {
const el = groupsScrollRef.current;
if (el)
el.scrollBy({
left: -el.clientWidth * 0.6,
behavior:
reducedMotion || profileDrag?.active
? "instant"
: "smooth",
});
}}
className="absolute top-1/2 left-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<LuChevronLeft className="size-3" />
</button>
)}
<div
ref={groupsScrollRef}
data-slot="profile-group-strip"
className="ml-2 flex scrollbar-none items-center gap-3 overflow-x-auto [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
style={{
paddingLeft: groupsFadeLeft ? 22 : 0,
paddingRight: groupsFadeRight ? 22 : 0,
}}
>
{/* "All" filter — shows every profile regardless of group. */}
{(() => {
const active = selectedGroupId === ALL_FILTER_ID;
return (
<button
key="__all__"
type="button"
onClick={() => {
onGroupSelect(ALL_FILTER_ID);
}}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
active
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span>{t("groups.all")}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{totalProfiles}
</span>
</button>
);
})()}
{profileDrag && profileDrag.movingIds.size > 0 && (
<span
data-profile-group-drop={UNGROUPED_DROP_ID}
data-drop-state={
profileDrag.targetId === UNGROUPED_DROP_ID
? "target"
: profileDrag.canDrop(UNGROUPED_DROP_ID)
? "available"
: "unavailable"
}
title={t("profileMotion.dragTarget", {
group: t("groups.noGroup"),
})}
<ScrollStrip
data-slot="profile-group-strip"
className="ml-2 flex-1"
stripClassName="gap-3"
activeKey={selectedGroupId}
instant={Boolean(profileDrag?.active)}
scrollLeftLabel={t("header.scrollGroupsLeft")}
scrollRightLabel={t("header.scrollGroupsRight")}
>
{/* "All" filter — shows every profile regardless of group. */}
{(() => {
const active = selectedGroupId === ALL_FILTER_ID;
return (
<button
key="__all__"
type="button"
aria-current={active ? "true" : undefined}
onClick={() => {
onGroupSelect(ALL_FILTER_ID);
}}
className={cn(
"flex h-7 shrink-0 items-center rounded-sm px-2 text-xs text-muted-foreground transition-colors duration-100",
profileDrag.targetId === UNGROUPED_DROP_ID &&
"bg-accent font-medium text-accent-foreground",
!profileDrag.canDrop(UNGROUPED_DROP_ID) && "opacity-40",
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
active
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{t("groups.noGroup")}
</span>
)}
{groups.map((group) => {
const active = selectedGroupId === group.id;
const dropTarget = profileDrag?.targetId === group.id;
return (
<button
key={group.id}
type="button"
title={group.name}
data-profile-group-drop={group.id}
data-drop-state={
profileDrag?.active
? dropTarget
? "target"
: profileDrag.canDrop(group.id)
? "available"
: "unavailable"
: undefined
}
aria-current={active ? "true" : undefined}
onClick={() => {
onGroupSelect(active ? ALL_FILTER_ID : group.id);
}}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-sm px-1 text-xs transition-colors duration-100",
dropTarget && "bg-accent text-accent-foreground",
profileDrag?.active &&
!profileDrag.canDrop(group.id) &&
"opacity-40",
active
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className="max-w-40 truncate">{group.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
</button>
);
})}
</div>
{groupsFadeRight && (
<button
type="button"
aria-label={t("header.scrollGroupsRight")}
onClick={() => {
const el = groupsScrollRef.current;
if (el)
el.scrollBy({
left: el.clientWidth * 0.6,
behavior:
reducedMotion || profileDrag?.active
? "instant"
: "smooth",
});
}}
className="absolute top-1/2 right-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
<span>{t("groups.all")}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{totalProfiles}
</span>
</button>
);
})()}
{profileDrag && profileDrag.movingIds.size > 0 && (
<span
data-profile-group-drop={UNGROUPED_DROP_ID}
data-drop-state={
profileDrag.targetId === UNGROUPED_DROP_ID
? "target"
: profileDrag.canDrop(UNGROUPED_DROP_ID)
? "available"
: "unavailable"
}
title={t("profileMotion.dragTarget", {
group: t("groups.noGroup"),
})}
className={cn(
"flex h-7 shrink-0 items-center rounded-sm px-2 text-xs text-muted-foreground transition-colors duration-100",
profileDrag.targetId === UNGROUPED_DROP_ID &&
"bg-accent font-medium text-accent-foreground",
!profileDrag.canDrop(UNGROUPED_DROP_ID) && "opacity-40",
)}
>
<LuChevronRight className="size-3" />
</button>
{t("groups.noGroup")}
</span>
)}
</div>
{groups.map((group) => {
const active = selectedGroupId === group.id;
const dropTarget = profileDrag?.targetId === group.id;
return (
<button
key={group.id}
type="button"
title={group.name}
data-profile-group-drop={group.id}
data-drop-state={
profileDrag?.active
? dropTarget
? "target"
: profileDrag.canDrop(group.id)
? "available"
: "unavailable"
: undefined
}
aria-current={active ? "true" : undefined}
onClick={() => {
onGroupSelect(active ? ALL_FILTER_ID : group.id);
}}
className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-sm px-1 text-xs transition-colors duration-100",
dropTarget && "bg-accent text-accent-foreground",
profileDrag?.active &&
!profileDrag.canDrop(group.id) &&
"opacity-40",
active
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className="max-w-40 truncate">{group.name}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{group.count}
</span>
</button>
);
})}
</ScrollStrip>
)}
{!showProfileToolbar && <div className="flex-1" />}
+226 -102
View File
@@ -40,6 +40,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollStrip } from "@/components/ui/scroll-strip";
import {
Select,
SelectContent,
@@ -82,8 +83,15 @@ interface AppSettings {
keep_decrypted_profiles_in_ram?: boolean;
fingerprint_gate_disabled?: boolean;
vpn_extension_warning_disabled?: boolean;
require_route_for_launch?: boolean;
trash_retention_days?: number;
record_traffic_domains?: boolean;
}
/** The retention choices offered, in days. 0 turns the trash off. */
const TRASH_RETENTION_DAYS = [0, 1, 7, 14, 30, 90] as const;
const DEFAULT_TRASH_RETENTION_DAYS = 30;
interface CustomThemeState {
selectedThemeId: string | null;
colors: Record<string, string>;
@@ -603,10 +611,12 @@ export function SettingsDialog({
| "es"
| "pt"
| "fr"
| "de"
| "zh"
| "ja"
| "ko"
| "ru"
| "tr"
| "vi"),
);
setOriginalLanguage(selectedLanguage);
@@ -826,12 +836,21 @@ export function SettingsDialog({
"vpn_extension_warning_disabled",
"settings.privacy.vpnExtensionWarning",
],
["require_route_for_launch", "settings.privacy.requireRoute"],
] as const
)
.filter(
([key]) => Boolean(settings[key]) !== Boolean(originalSettings[key]),
)
.map(([, key]) => t(key)),
...((settings.record_traffic_domains ?? true) !==
(originalSettings.record_traffic_domains ?? true)
? [t("settings.privacy.recordSites")]
: []),
...((settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !==
(originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS)
? [t("settings.trashRetention")]
: []),
];
const hasChanges = pendingChanges.length > 0;
const sections = [
@@ -859,6 +878,15 @@ export function SettingsDialog({
["dns", "dnsBlocklist.title", ["dnsBlocklist"]],
["encryption", "settings.encryption.title", ["settings.encryption"]],
["commercial", "settings.commercial.title", ["settings.commercial"]],
[
"privacy",
"settings.privacy.title",
[
"settings.privacy",
"settings.trashRetention",
"settings.trashRetentionDescription",
],
],
[
"advanced",
"settings.advanced.title",
@@ -868,7 +896,6 @@ export function SettingsDialog({
"settings.disableAutoUpdatesDescription",
"settings.keepDecryptedProfilesInRam",
"settings.keepDecryptedProfilesInRamDescription",
"settings.privacy",
],
],
] as Array<[string, string, string[]]>;
@@ -965,48 +992,55 @@ export function SettingsDialog({
aria-label={t("appFeedback.searchSettings")}
className="h-8"
/>
<nav
aria-label={t("settings.title")}
className="flex flex-wrap gap-x-1 gap-y-1"
>
{sections.map(([id, label]) => {
const active = activeSection === id;
return (
<button
type="button"
key={id}
data-slot="settings-nav-item"
data-section={id}
aria-current={active ? "location" : undefined}
onClick={() => {
setSearch("");
setJumpTo(id);
}}
className={cn(
"relative isolate rounded-md px-2 py-1 text-xs transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
active
? "text-accent-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
aria-hidden="true"
data-slot="settings-nav-indicator"
layoutId={
animateNav ? "settings-nav-indicator" : undefined
}
initial={false}
transition={
animateNav ? MOTION_SPRING_POSITION : { duration: 0 }
}
className="absolute inset-0 -z-10 rounded-md bg-accent"
/>
)}
{t(label)}
</button>
);
})}
<nav aria-label={t("settings.title")}>
<ScrollStrip
data-slot="settings-nav"
activeKey={activeSection}
scrollLeftLabel={t("settings.scrollSectionsLeft")}
scrollRightLabel={t("settings.scrollSectionsRight")}
stripClassName="gap-1"
>
{sections.map(([id, label]) => {
const active = activeSection === id;
return (
<button
type="button"
key={id}
data-slot="settings-nav-item"
data-section={id}
aria-current={active ? "location" : undefined}
onClick={() => {
setSearch("");
setJumpTo(id);
}}
className={cn(
"relative isolate shrink-0 rounded-md px-2 py-1 text-xs whitespace-nowrap transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
active
? "text-accent-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
aria-hidden="true"
data-slot="settings-nav-indicator"
layoutId={
animateNav ? "settings-nav-indicator" : undefined
}
initial={false}
transition={
animateNav
? MOTION_SPRING_POSITION
: { duration: 0 }
}
className="absolute inset-0 -z-10 rounded-md bg-accent"
/>
)}
{t(label)}
</button>
);
})}
</ScrollStrip>
</nav>
</div>
@@ -1015,6 +1049,7 @@ export function SettingsDialog({
them was the dead-wheel-zone bug. */}
<div
ref={scrollerRef}
data-slot="settings-scroller"
onScroll={syncActiveSection}
className={cn(
"min-h-0 flex-1 overflow-y-auto",
@@ -1637,6 +1672,154 @@ export function SettingsDialog({
</div>
</div>
{/* Privacy Section */}
<div
data-settings-section="privacy"
tabIndex={-1}
hidden={!sectionVisible("privacy")}
className="scroll-mt-2 space-y-4 focus:outline-none"
>
<Label className="text-base font-medium">
{t("settings.privacy.title")}
</Label>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.requireRoute")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.requireRouteDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.requireRoute")}
checked={settings.require_route_for_launch ?? false}
onCheckedChange={(v) => {
updateSetting("require_route_for_launch", v === true);
}}
/>
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.consistencyWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.consistencyWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.consistencyWarning")}
checked={!(settings.fingerprint_gate_disabled ?? false)}
onCheckedChange={(v) => {
updateSetting("fingerprint_gate_disabled", v !== true);
}}
/>
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.vpnExtensionWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.vpnExtensionWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.vpnExtensionWarning")}
checked={
!(settings.vpn_extension_warning_disabled ?? false)
}
onCheckedChange={(v) => {
updateSetting(
"vpn_extension_warning_disabled",
v !== true,
);
}}
/>
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.recordSites")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.recordSitesDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.recordSites")}
checked={settings.record_traffic_domains ?? true}
onCheckedChange={(v) => {
updateSetting("record_traffic_domains", v === true);
}}
/>
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Label
htmlFor="trash-retention"
className="text-sm font-medium"
>
{t("settings.trashRetention")}
</Label>
<span className="block text-xs text-muted-foreground">
{t("settings.trashRetentionDescription")}
</span>
</div>
<Select
value={String(
settings.trash_retention_days ??
DEFAULT_TRASH_RETENTION_DAYS,
)}
onValueChange={(value) => {
updateSetting("trash_retention_days", Number(value));
}}
>
<SelectTrigger
id="trash-retention"
className="w-40 shrink-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRASH_RETENTION_DAYS.map((days) => (
<SelectItem key={days} value={String(days)}>
{days === 0
? t("settings.trashRetentionOff")
: t("settings.trashRetentionDays", {
count: days,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<LoadingButton
isLoading={isClearingTraffic}
variant="outline"
className="w-full"
onClick={() => {
handleClearTraffic().catch((err: unknown) => {
console.error(err);
});
}}
>
{t("settings.privacy.clearTraffic")}
</LoadingButton>
<p className="mt-2 text-xs text-muted-foreground">
{t("settings.privacy.clearTrafficDescription")}
</p>
</div>
</div>
{/* Advanced Section */}
<div
data-settings-section="advanced"
@@ -1750,65 +1933,6 @@ export function SettingsDialog({
<p className="text-xs text-muted-foreground">
{t("settings.advanced.copyLogsDescription")}
</p>
<div className="flex items-center justify-between gap-3 border-t pt-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.consistencyWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.consistencyWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.consistencyWarning")}
checked={!(settings.fingerprint_gate_disabled ?? false)}
onCheckedChange={(v) => {
updateSetting("fingerprint_gate_disabled", v !== true);
}}
/>
</div>
<div className="flex items-start justify-between gap-x-3 rounded-lg border p-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.vpnExtensionWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.vpnExtensionWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.vpnExtensionWarning")}
checked={
!(settings.vpn_extension_warning_disabled ?? false)
}
onCheckedChange={(v) => {
updateSetting(
"vpn_extension_warning_disabled",
v !== true,
);
}}
/>
</div>
<div className="border-t pt-3">
<LoadingButton
isLoading={isClearingTraffic}
variant="outline"
className="w-full"
onClick={() => {
handleClearTraffic().catch((err: unknown) => {
console.error(err);
});
}}
>
{t("settings.privacy.clearTraffic")}
</LoadingButton>
<p className="mt-2 text-xs text-muted-foreground">
{t("settings.privacy.clearTrafficDescription")}
</p>
</div>
</div>
{matchingSections.length === 0 && (
+257
View File
@@ -0,0 +1,257 @@
"use client";
import { useReducedMotion } from "motion/react";
import {
type HTMLAttributes,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
import { cn } from "@/lib/utils";
/** How far a pressed pointer travels before a press becomes a drag. */
const DRAG_THRESHOLD_PX = 4;
/** Room kept between the active item and a faded edge. */
const EDGE_ROOM_PX = 28;
/** Clears a 2px focus outline: a row that scrolls sideways also clips
* vertically, so a badge's focus ring would be cut at every edge. */
const RING_ROOM_PX = 2;
interface ScrollStripProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
/** Changes whenever the item marked `aria-current` changes. */
activeKey?: string | null;
scrollLeftLabel: string;
scrollRightLabel: string;
/** Classes for the scrolling row itself; `className` styles the frame. */
stripClassName?: string;
/** Scroll without animation, e.g. while something is dragged over it. */
instant?: boolean;
}
/**
* One row of badges that never wraps. When the badges do not fit, the row
* fades out at the side that has more, shows a chevron there, and scrolls
* with the wheel, a drag or the chevrons. The badge marked `aria-current` is
* kept in view whenever `activeKey` changes.
*/
export function ScrollStrip({
children,
activeKey,
scrollLeftLabel,
scrollRightLabel,
className,
stripClassName,
instant = false,
onPointerDown,
onClickCapture,
...rest
}: ScrollStripProps) {
const ref = useRef<HTMLDivElement | null>(null);
const reducedMotion = useReducedMotion();
const [fadeLeft, setFadeLeft] = useState(false);
const [fadeRight, setFadeRight] = useState(false);
const [dragging, setDragging] = useState(false);
const dragRef = useRef<{
pointerId: number;
startX: number;
startLeft: number;
moved: boolean;
} | null>(null);
const suppressClickRef = useRef(false);
const behavior: ScrollBehavior =
instant || reducedMotion ? "instant" : "smooth";
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () => {
setFadeLeft(el.scrollLeft > 1);
setFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
};
update();
el.addEventListener("scroll", update, { passive: true });
const resize = new ResizeObserver(update);
resize.observe(el);
const mutations = new MutationObserver(update);
mutations.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
// A vertical wheel has nowhere to go in a single row, so turn it
// sideways. At either end the event is left alone.
const onWheel = (event: WheelEvent) => {
if (event.ctrlKey) return;
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
const room = el.scrollWidth - el.clientWidth;
if (room <= 1) return;
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
const next = Math.min(room, Math.max(0, el.scrollLeft + delta));
if (next === el.scrollLeft) return;
event.preventDefault();
el.scrollLeft = next;
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => {
el.removeEventListener("scroll", update);
el.removeEventListener("wheel", onWheel);
resize.disconnect();
mutations.disconnect();
};
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: activeKey is the trigger; the active badge is read from the DOM
useEffect(() => {
const el = ref.current;
if (!el || dragRef.current?.moved) return;
const item = el.querySelector<HTMLElement>(
'[aria-current]:not([aria-current="false"])',
);
if (!item) return;
const start = item.offsetLeft;
const end = start + item.offsetWidth;
let target: number | null = null;
if (start < el.scrollLeft + EDGE_ROOM_PX) {
target = start - EDGE_ROOM_PX;
} else if (end > el.scrollLeft + el.clientWidth - EDGE_ROOM_PX) {
target = end - el.clientWidth + EDGE_ROOM_PX;
}
if (target !== null) {
el.scrollTo({ left: Math.max(0, target), behavior });
}
}, [activeKey]);
const page = useCallback(
(direction: -1 | 1) => {
const el = ref.current;
if (el) {
el.scrollBy({ left: direction * el.clientWidth * 0.6, behavior });
}
},
[behavior],
);
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
if (drag.moved) {
// The click that ends a drag arrives in this same task. If none comes,
// the next real click must not be swallowed.
suppressClickRef.current = true;
window.setTimeout(() => {
suppressClickRef.current = false;
}, 0);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
}
dragRef.current = null;
setDragging(false);
};
const overflowing = fadeLeft || fadeRight;
const chevron =
"absolute top-1/2 z-10 grid size-6 -translate-y-1/2 place-items-center rounded-sm text-muted-foreground transition-colors duration-100 hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring";
return (
<div className={cn("relative flex min-w-0 items-center", className)}>
{fadeLeft && (
<button
type="button"
aria-label={scrollLeftLabel}
onClick={() => page(-1)}
className={cn(chevron, "left-0")}
>
<LuChevronLeft className="size-3.5" />
</button>
)}
<div
ref={ref}
data-fade-left={fadeLeft ? "true" : "false"}
data-fade-right={fadeRight ? "true" : "false"}
{...rest}
onPointerDown={(event) => {
onPointerDown?.(event);
const el = ref.current;
if (
!el ||
event.pointerType !== "mouse" ||
event.button !== 0 ||
el.scrollWidth - el.clientWidth <= 1
) {
return;
}
// The strip owns this press. Without this the title bar under it
// would start moving the window on a long press.
event.stopPropagation();
suppressClickRef.current = false;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startLeft: el.scrollLeft,
moved: false,
};
}}
onPointerMove={(event) => {
const drag = dragRef.current;
const el = ref.current;
if (!drag || !el || drag.pointerId !== event.pointerId) return;
const dx = event.clientX - drag.startX;
if (!drag.moved) {
if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
drag.moved = true;
// Keep receiving moves when the pointer leaves the strip. A
// pointer the browser no longer tracks cannot be captured, and
// the drag still works without it while the pointer stays over.
try {
el.setPointerCapture(event.pointerId);
} catch {}
setDragging(true);
}
el.scrollLeft = drag.startLeft - dx;
}}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClickCapture={(event) => {
// The press that ended a drag is not a click on the badge under it.
if (suppressClickRef.current) {
suppressClickRef.current = false;
event.preventDefault();
event.stopPropagation();
return;
}
onClickCapture?.(event);
}}
className={cn(
"scroll-fade-x relative flex scrollbar-none items-center overflow-x-auto py-0.5 [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
overflowing && "cursor-grab",
dragging && "cursor-grabbing select-none",
stripClassName,
)}
style={{
paddingLeft: fadeLeft ? 22 : RING_ROOM_PX,
paddingRight: fadeRight ? 22 : RING_ROOM_PX,
}}
>
{children}
</div>
{fadeRight && (
<button
type="button"
aria-label={scrollRightLabel}
onClick={() => page(1)}
className={cn(chevron, "right-0")}
>
<LuChevronRight className="size-3.5" />
</button>
)}
</div>
);
}