mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-28 07:08:48 +02:00
refactor: harden tests
This commit is contained in:
@@ -1,54 +1,3 @@
|
||||
/**
|
||||
* Unified Toast System
|
||||
*
|
||||
* This module provides a comprehensive toast system that solves styling issues
|
||||
* and provides a single, flexible toast component for all use cases.
|
||||
*
|
||||
* Features:
|
||||
* - Proper background styling (no transparency issues)
|
||||
* - Loading states with spinners
|
||||
* - Progress bars for downloads/updates
|
||||
* - Success/error states
|
||||
* - Customizable icons and content
|
||||
* - Auto-update notifications
|
||||
*
|
||||
* Usage Examples:
|
||||
*
|
||||
* Simple loading toast:
|
||||
* ```
|
||||
* import { showToast } from "./custom-toast";
|
||||
* showToast({
|
||||
* type: "loading",
|
||||
* title: "Loading...",
|
||||
* description: "Please wait..."
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Auto-update toast:
|
||||
* ```
|
||||
* showAutoUpdateToast("Wayfern", "149.0.7827.116");
|
||||
* ```
|
||||
*
|
||||
* Download progress toast:
|
||||
* ```
|
||||
* showToast({
|
||||
* type: "download",
|
||||
* title: "Downloading Wayfern 149.0.7827.116",
|
||||
* progress: { percentage: 45, speed: "2.5", eta: "30s" }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Version update progress:
|
||||
* ```
|
||||
* showToast({
|
||||
* type: "version-update",
|
||||
* title: "Updating browser versions",
|
||||
* progress: { current: 3, total: 5, found: 12 }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: TODO */
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
/** biome-ignore-all lint/a11y/noStaticElementInteractions: temporary suppress until in active use */
|
||||
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: temporary suppress until in active use */
|
||||
"use client";
|
||||
|
||||
import { Command as CommandPrimitive, useCommandState } from "cmdk";
|
||||
@@ -14,9 +12,9 @@ export interface Option {
|
||||
value: string;
|
||||
label?: string;
|
||||
disable?: boolean;
|
||||
/** fixed option that can't be removed. */
|
||||
/** An option that cannot be removed. */
|
||||
fixed?: boolean;
|
||||
/** Group the options by providing key. */
|
||||
/** Group options by this property. */
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
type GroupOption = Record<string, Option[]>;
|
||||
@@ -24,21 +22,20 @@ type GroupOption = Record<string, Option[]>;
|
||||
interface MultipleSelectorProps {
|
||||
value?: Option[];
|
||||
defaultOptions?: Option[];
|
||||
/** manually controlled options */
|
||||
/** Manually controlled options. */
|
||||
options?: Option[];
|
||||
placeholder?: string;
|
||||
/** Loading component. */
|
||||
loadingIndicator?: React.ReactNode;
|
||||
/** Empty component. */
|
||||
emptyIndicator?: React.ReactNode;
|
||||
/** Debounce time for async search. Only work with `onSearch`. */
|
||||
/** Debounce time for async search. Used only with `onSearch`. */
|
||||
delay?: number;
|
||||
/**
|
||||
* Only work with `onSearch` prop. Trigger search when `onFocus`.
|
||||
* For example, when user click on the input, it will trigger the search to get initial options.
|
||||
* Trigger `onSearch` when the input receives focus.
|
||||
**/
|
||||
triggerSearchOnFocus?: boolean;
|
||||
/** async search */
|
||||
/** Asynchronous search. */
|
||||
onSearch?: (value: string) => Promise<Option[]>;
|
||||
onChange?: (options: Option[]) => void;
|
||||
/** Limit the maximum number of selected options. */
|
||||
@@ -48,13 +45,12 @@ interface MultipleSelectorProps {
|
||||
/** Hide the placeholder when there are options selected. */
|
||||
hidePlaceholderWhenSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Group the options base on provided key. */
|
||||
/** Group options by the provided key. */
|
||||
groupBy?: string;
|
||||
className?: string;
|
||||
badgeClassName?: string;
|
||||
/**
|
||||
* First item selected is a default behavior by cmdk. That is why the default is true.
|
||||
* This is a workaround solution by add a dummy item.
|
||||
* Prevent cmdk from selecting the first item by inserting a dummy item.
|
||||
*
|
||||
* @reference: https://github.com/pacocoursey/cmdk/issues/171
|
||||
*/
|
||||
@@ -375,7 +371,7 @@ const MultipleSelector = React.forwardRef<
|
||||
return Object.values(selectables).some((group) => group.length > 0);
|
||||
}, [selectables]);
|
||||
|
||||
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
|
||||
// Skip fuzzy matching for creatable inputs to keep long pasted values responsive.
|
||||
const commandFilter = React.useCallback(() => {
|
||||
if (commandProps?.filter) {
|
||||
return commandProps.filter;
|
||||
@@ -401,13 +397,15 @@ const MultipleSelector = React.forwardRef<
|
||||
"relative h-auto overflow-visible bg-transparent",
|
||||
commandProps?.className,
|
||||
)}
|
||||
// Consumers may override filtering even when search is asynchronous.
|
||||
shouldFilter={
|
||||
commandProps?.shouldFilter !== undefined
|
||||
? commandProps.shouldFilter
|
||||
: !onSearch
|
||||
} // When onSearch is provided, we don't want to filter the options. You can still override it.
|
||||
}
|
||||
filter={commandFilter()}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer focus is forwarded to the nested input; keyboard users focus nested controls directly */}
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
|
||||
@@ -417,7 +415,7 @@ const MultipleSelector = React.forwardRef<
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
onMouseDown={() => {
|
||||
if (disabled) return;
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
|
||||
import { type HTMLMotionProps, motion } from "motion/react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -72,20 +72,24 @@ type DialogPortalProps = Omit<
|
||||
"forceMount"
|
||||
>;
|
||||
|
||||
function DialogPortal(props: DialogPortalProps) {
|
||||
function DialogPortal({
|
||||
children,
|
||||
container: portalContainer,
|
||||
...props
|
||||
}: DialogPortalProps) {
|
||||
const { isOpen, container } = useDialog();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DialogPrimitive.Portal
|
||||
data-slot="dialog-portal"
|
||||
forceMount
|
||||
container={container ?? props.container}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<DialogPrimitive.Portal
|
||||
data-slot="dialog-portal"
|
||||
forceMount
|
||||
container={container ?? portalContainer}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +110,6 @@ function DialogOverlay({
|
||||
key="dialog-overlay"
|
||||
initial={{ opacity: 0, filter: "blur(4px)" }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, filter: "blur(4px)" }}
|
||||
transition={transition}
|
||||
className={cn("fixed inset-0 z-9999 bg-background/50", className)}
|
||||
{...props}
|
||||
@@ -217,8 +220,9 @@ function DialogContent({
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogOverlay key="dialog-overlay" />
|
||||
<DialogPrimitive.Content
|
||||
key="dialog-content"
|
||||
asChild
|
||||
forceMount
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
@@ -243,22 +247,15 @@ function DialogContent({
|
||||
<motion.div
|
||||
key="dialog-content"
|
||||
data-slot="dialog-content"
|
||||
// Open/close motion modeled on transitions.dev's modal: a subtle
|
||||
// scale from 0.96 → 1 with opacity, eased with cubic-bezier(0.22, 1,
|
||||
// 0.36, 1). Open is 250ms; close is a quicker 150ms. The centering
|
||||
// translate stays in `style` so `scale` animates around the center
|
||||
// without fighting the transform-based positioning.
|
||||
// Open motion modeled on transitions.dev's modal: a subtle scale
|
||||
// from 0.96 → 1 with opacity, eased with cubic-bezier(0.22, 1, 0.36,
|
||||
// 1). The portal unmounts immediately on close so a closed Radix
|
||||
// surface cannot linger over the app. The centering translate stays
|
||||
// in `style` so `scale` animates around the center without fighting
|
||||
// the transform-based positioning.
|
||||
style={{ transformOrigin: "center" }}
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.96,
|
||||
transition: transition ?? {
|
||||
duration: 0.15,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
},
|
||||
}}
|
||||
transition={
|
||||
transition ?? { duration: 0.25, ease: [0.22, 1, 0.36, 1] }
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function PopoverContent({
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:pointer-events-none data-[state=closed]:animate-none data-[state=closed]:opacity-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "Copy logs",
|
||||
"openLogDir": "Open log folder",
|
||||
"copyLogsSuccess": "Logs copied to clipboard",
|
||||
"copyLogsDescription": "Bundles the most recent log files (up to 5 MB) into your clipboard for sharing in bug reports."
|
||||
"copyLogsDescription": "Copies a redacted bundle of recent logs (up to 5 MB). Review it before sharing because redaction cannot identify every kind of personal data."
|
||||
},
|
||||
"disableAutoUpdates": "Disable App Auto Updates",
|
||||
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "Copiar registros",
|
||||
"openLogDir": "Abrir carpeta de registros",
|
||||
"copyLogsSuccess": "Registros copiados al portapapeles",
|
||||
"copyLogsDescription": "Une los archivos de registro más recientes (hasta 5 MB) en tu portapapeles para compartirlos en informes de error."
|
||||
"copyLogsDescription": "Copia un paquete censurado de los registros recientes (hasta 5 MB). Revísalo antes de compartirlo, ya que la censura no puede identificar todos los tipos de datos personales."
|
||||
},
|
||||
"disableAutoUpdates": "Desactivar Actualizaciones Automáticas de la App",
|
||||
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "Copier les journaux",
|
||||
"openLogDir": "Ouvrir le dossier des journaux",
|
||||
"copyLogsSuccess": "Journaux copiés dans le presse-papiers",
|
||||
"copyLogsDescription": "Regroupe les derniers fichiers de journal (jusqu’à 5 Mo) dans votre presse-papiers pour les rapports de bug."
|
||||
"copyLogsDescription": "Copie un lot expurgé des journaux récents (jusqu’à 5 Mo). Vérifiez-le avant de le partager, car l’expurgation ne peut pas identifier tous les types de données personnelles."
|
||||
},
|
||||
"disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app",
|
||||
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "ログをコピー",
|
||||
"openLogDir": "ログフォルダを開く",
|
||||
"copyLogsSuccess": "ログをクリップボードにコピーしました",
|
||||
"copyLogsDescription": "最新のログファイル(最大 5 MB)をクリップボードにまとめ、不具合報告で共有できるようにします。"
|
||||
"copyLogsDescription": "最近のログを編集したバンドル(最大 5 MB)をコピーします。編集ではすべての種類の個人データを識別できないため、共有前に内容を確認してください。"
|
||||
},
|
||||
"disableAutoUpdates": "アプリの自動更新を無効にする",
|
||||
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "로그 복사",
|
||||
"openLogDir": "로그 폴더 열기",
|
||||
"copyLogsSuccess": "로그가 클립보드에 복사되었습니다",
|
||||
"copyLogsDescription": "최신 로그 파일(최대 5MB)을 클립보드에 묶어 버그 보고서에서 공유할 수 있도록 합니다."
|
||||
"copyLogsDescription": "최근 로그를 민감 정보가 제거된 묶음으로 복사합니다(최대 5MB). 모든 유형의 개인 데이터를 식별할 수는 없으므로 공유하기 전에 검토하세요."
|
||||
},
|
||||
"disableAutoUpdates": "앱 자동 업데이트 사용 안 함",
|
||||
"disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "Copiar logs",
|
||||
"openLogDir": "Abrir pasta de logs",
|
||||
"copyLogsSuccess": "Logs copiados para a área de transferência",
|
||||
"copyLogsDescription": "Junta os arquivos de log mais recentes (até 5 MB) na sua área de transferência para compartilhar em relatórios de bug."
|
||||
"copyLogsDescription": "Copia um pacote editado dos logs recentes (até 5 MB). Revise-o antes de compartilhar, pois a edição não consegue identificar todos os tipos de dados pessoais."
|
||||
},
|
||||
"disableAutoUpdates": "Desativar Atualizações Automáticas do App",
|
||||
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "Скопировать логи",
|
||||
"openLogDir": "Открыть папку логов",
|
||||
"copyLogsSuccess": "Логи скопированы в буфер обмена",
|
||||
"copyLogsDescription": "Собирает последние файлы логов (до 5 МБ) в буфер обмена для прикрепления к багам."
|
||||
"copyLogsDescription": "Копирует отредактированный набор последних логов (до 5 МБ). Проверьте его перед отправкой: редактирование не может выявить все виды персональных данных."
|
||||
},
|
||||
"disableAutoUpdates": "Отключить автообновление приложения",
|
||||
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "Günlükleri kopyala",
|
||||
"openLogDir": "Günlük klasörünü aç",
|
||||
"copyLogsSuccess": "Günlükler panoya kopyalandı",
|
||||
"copyLogsDescription": "En son günlük dosyalarını (en fazla 5 MB) hata raporlarında paylaşmak üzere panonuza paketler."
|
||||
"copyLogsDescription": "Son günlüklerin hassas verileri ayıklanmış bir paketini kopyalar (en fazla 5 MB). Ayıklama her tür kişisel veriyi belirleyemeyeceğinden paylaşmadan önce inceleyin."
|
||||
},
|
||||
"disableAutoUpdates": "Uygulama Otomatik Güncellemelerini Devre Dışı Bırak",
|
||||
"disableAutoUpdatesDescription": "Uygulamanın Donut Browser güncellemelerini otomatik olarak denetlemesini ve yüklemesini engelleyin. Tarayıcı güncellemeleri bundan etkilenmez.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "Sao chép nhật ký",
|
||||
"openLogDir": "Mở thư mục nhật ký",
|
||||
"copyLogsSuccess": "Đã sao chép nhật ký vào clipboard",
|
||||
"copyLogsDescription": "Đóng gói các file nhật ký gần nhất (tối đa 5 MB) vào clipboard để chia sẻ trong báo cáo lỗi."
|
||||
"copyLogsDescription": "Sao chép gói nhật ký gần đây đã được che thông tin nhạy cảm (tối đa 5 MB). Hãy xem lại trước khi chia sẻ vì việc che dữ liệu không thể nhận diện mọi loại dữ liệu cá nhân."
|
||||
},
|
||||
"disableAutoUpdates": "Tắt tự động cập nhật ứng dụng",
|
||||
"disableAutoUpdatesDescription": "Ngăn ứng dụng tự động kiểm tra và cài đặt bản cập nhật Donut Browser. Cập nhật trình duyệt không bị ảnh hưởng.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "复制日志",
|
||||
"openLogDir": "打开日志文件夹",
|
||||
"copyLogsSuccess": "日志已复制到剪贴板",
|
||||
"copyLogsDescription": "将最近的日志文件(最多 5 MB)合并到剪贴板,便于在反馈问题时分享。"
|
||||
"copyLogsDescription": "复制经过脱敏的近期日志包(最多 5 MB)。脱敏无法识别所有类型的个人数据,请在分享前检查内容。"
|
||||
},
|
||||
"disableAutoUpdates": "禁用应用自动更新",
|
||||
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。",
|
||||
|
||||
@@ -167,12 +167,8 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
/* Interactive elements show a pointer cursor app-wide, so cursor behavior is
|
||||
consistent everywhere without per-element classes — and there's no lingering
|
||||
"fix the cursor" surface for drive-by PRs. Disabled controls rely on
|
||||
pointer-events:none / their own disabled cursor, and an explicit `cursor-*`
|
||||
utility still wins over this base rule (utilities out-rank the base layer),
|
||||
e.g. the invisible click-to-close backdrop and the auto-scroll buttons. */
|
||||
/* Keep pointer behavior consistent; explicit cursor utilities still override
|
||||
this base rule. */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
[role="menuitem"],
|
||||
|
||||
Reference in New Issue
Block a user