diff --git a/client/package.json b/client/package.json index 4908ca82..42cbbbe5 100644 --- a/client/package.json +++ b/client/package.json @@ -29,7 +29,6 @@ "@peculiar/x509": "1.14.3", "@tanstack/react-query": "^5.101.4", "@tanstack/react-query-devtools": "^5.85.0", - "@tsd-ui/core": "^0.3.2", "@vitest/coverage-v8": "^4.1.9", "axios": "^1.18.0", "dayjs": "^1.11.7", diff --git a/client/src/app/components/LoadingWrapper/LoadingWrapper.tsx b/client/src/app/components/LoadingWrapper/LoadingWrapper.tsx new file mode 100644 index 00000000..b069151e --- /dev/null +++ b/client/src/app/components/LoadingWrapper/LoadingWrapper.tsx @@ -0,0 +1,26 @@ +import type React from "react"; + +import { Bullseye, Spinner } from "@patternfly/react-core"; +import { ErrorEmptyState } from "../ErrorEmptyState"; + +export const LoadingWrapper = (props: { + isFetching: boolean; + fetchError?: TError | null; + isFetchingState?: React.ReactNode; + fetchErrorState?: (error: TError) => React.ReactNode; + children: React.ReactNode; +}) => { + if (props.isFetching) { + return ( + props.isFetchingState ?? ( + + + + ) + ); + } + if (props.fetchError) { + return props.fetchErrorState ? props.fetchErrorState(props.fetchError) : ; + } + return props.children; +}; diff --git a/client/src/app/components/LoadingWrapper/index.ts b/client/src/app/components/LoadingWrapper/index.ts new file mode 100644 index 00000000..b2ddd5c4 --- /dev/null +++ b/client/src/app/components/LoadingWrapper/index.ts @@ -0,0 +1 @@ +export * from "./LoadingWrapper"; diff --git a/client/src/app/components/LocalStorageThemeProvider.tsx b/client/src/app/components/LocalStorageThemeProvider.tsx index 7479587c..6b71a8e4 100644 --- a/client/src/app/components/LocalStorageThemeProvider.tsx +++ b/client/src/app/components/LocalStorageThemeProvider.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { ThemeProvider, type ThemeMode } from "@tsd-ui/core"; +import { ThemeProvider, type ThemeMode } from "./Theme"; export const STORAGE_KEY = "theme-preference"; diff --git a/client/src/app/components/Theme/ThemeContext.tsx b/client/src/app/components/Theme/ThemeContext.tsx new file mode 100644 index 00000000..bdf23082 --- /dev/null +++ b/client/src/app/components/Theme/ThemeContext.tsx @@ -0,0 +1,58 @@ +import React, { useState } from "react"; + +import { THEME_MODES, ThemeContext, getSystemTheme, isThemeModeValid, type ThemeMode } from "./theme-utils"; + +const DARK_MODE_KEY = "pf-v6-theme-dark"; + +interface IThemeProviderProps { + children: React.ReactNode; + mode: ThemeMode; + setMode: (value: ThemeMode) => void; +} +export const ThemeProvider: React.FC = ({ children, mode, setMode }) => { + // "mode" sanitized + const sanitizedMode: ThemeMode = isThemeModeValid(mode) ? mode : "system"; + + // "setMode" sanitizer + const setSanitizedMode = React.useCallback( + (value: string) => { + if (value && isThemeModeValid(value)) { + setMode(value); + } else { + setMode("system"); + } + }, + [setMode], + ); + + const [systemTheme, setSystemTheme] = useState<"light" | "dark">(getSystemTheme); + + const isDark = sanitizedMode === THEME_MODES.DARK || (sanitizedMode === THEME_MODES.SYSTEM && systemTheme === "dark"); + + React.useEffect(() => { + const htmlElement = document.documentElement; + const themeMeta = document.querySelector('meta[name="theme-color"]'); + + if (isDark) { + htmlElement.classList.add(DARK_MODE_KEY); + themeMeta?.setAttribute("content", "#000000"); + } else { + htmlElement.classList.remove(DARK_MODE_KEY); + themeMeta?.setAttribute("content", "#ffffff"); + } + }, [isDark]); + + React.useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = () => { + setSystemTheme(getSystemTheme()); + }; + + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + } + }, []); + + return {children}; +}; diff --git a/client/src/app/components/Theme/ThemeSelector.tsx b/client/src/app/components/Theme/ThemeSelector.tsx new file mode 100644 index 00000000..937c4d60 --- /dev/null +++ b/client/src/app/components/Theme/ThemeSelector.tsx @@ -0,0 +1,92 @@ +import React, { useState } from "react"; + +import { Icon, MenuToggle, Select, SelectGroup, SelectList, SelectOption } from "@patternfly/react-core"; +import { DesktopIcon, OutlinedMoonIcon, OutlinedSunIcon } from "@patternfly/react-icons"; + +import { ThemeContext, type ThemeMode } from "./theme-utils"; + +const ColorSchemeGroupLabel = ( +
+ Color scheme +
+); + +type ThemeMetadataType = { + [key in ThemeMode]: { + value: ThemeMode; + icon: React.ReactNode; + displayText: string; + description: string; + }; +}; + +const themesMetadata: ThemeMetadataType = { + light: { + value: "light", + icon: , + displayText: "Light", + description: "Always use light mode", + }, + dark: { + value: "dark", + icon: , + displayText: "Dark", + description: "Always use dark mode", + }, + system: { + value: "system", + icon: , + displayText: "System", + description: "Follow system preference", + }, +}; + +export const ThemeSelector: React.FC = () => { + const { mode, setMode } = React.use(ThemeContext); + const [isOpen, setIsOpen] = useState(false); + + const handleThemeChange = (_event?: React.MouseEvent, selectedMode?: string) => { + setMode((selectedMode as ThemeMode | undefined) ?? "system"); + setIsOpen(false); + }; + + return ( + + ); +}; diff --git a/client/src/app/components/Theme/index.ts b/client/src/app/components/Theme/index.ts new file mode 100644 index 00000000..d6ae6402 --- /dev/null +++ b/client/src/app/components/Theme/index.ts @@ -0,0 +1,3 @@ +export * from "./ThemeContext"; +export * from "./ThemeSelector"; +export * from "./theme-utils"; diff --git a/client/src/app/components/Theme/theme-utils.ts b/client/src/app/components/Theme/theme-utils.ts new file mode 100644 index 00000000..908a3ace --- /dev/null +++ b/client/src/app/components/Theme/theme-utils.ts @@ -0,0 +1,30 @@ +import React from "react"; + +export const THEME_MODES = { + SYSTEM: "system", + LIGHT: "light", + DARK: "dark", +} as const; + +export type ThemeMode = (typeof THEME_MODES)[keyof typeof THEME_MODES]; + +export const isThemeModeValid = (value: string): value is ThemeMode => { + return ["system", "light", "dark"].includes(value); +}; + +export const getSystemTheme = (): Exclude => { + if (typeof window === "undefined" || !window.matchMedia) return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +}; + +interface ThemeState { + mode: ThemeMode; + setMode: (mode: ThemeMode) => void; + isDark: boolean; +} + +export const ThemeContext = React.createContext({ + mode: "system", + setMode: () => {}, + isDark: false, +}); diff --git a/client/src/app/components/ThemeAwareLogo.test.tsx b/client/src/app/components/ThemeAwareLogo.test.tsx index ea20d025..7728531b 100644 --- a/client/src/app/components/ThemeAwareLogo.test.tsx +++ b/client/src/app/components/ThemeAwareLogo.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; -import { ThemeContext } from "@tsd-ui/core"; +import { ThemeContext } from "./Theme"; import { ThemeAwareLogo } from "./ThemeAwareLogo"; const mockProps = { diff --git a/client/src/app/components/ThemeAwareLogo.tsx b/client/src/app/components/ThemeAwareLogo.tsx index 42b0a2da..24a1fc28 100644 --- a/client/src/app/components/ThemeAwareLogo.tsx +++ b/client/src/app/components/ThemeAwareLogo.tsx @@ -1,7 +1,7 @@ import type React from "react"; import { Brand } from "@patternfly/react-core"; import { use } from "react"; -import { ThemeContext } from "@tsd-ui/core"; +import { ThemeContext } from "./Theme"; interface ThemeAwareLogoProps { lightSrc: string; diff --git a/client/src/app/hooks/TableControls/sorting/getLocalSortDerivedState.ts b/client/src/app/hooks/TableControls/sorting/getLocalSortDerivedState.ts index 68389077..bb8ae25e 100644 --- a/client/src/app/hooks/TableControls/sorting/getLocalSortDerivedState.ts +++ b/client/src/app/hooks/TableControls/sorting/getLocalSortDerivedState.ts @@ -1,5 +1,5 @@ import type { ISortState } from "./useSortState"; -import { createComparator } from "@tsd-ui/core"; +import { createComparator } from "@app/utils/utils"; /** * Args for getLocalSortDerivedState diff --git a/client/src/app/layout/about.tsx b/client/src/app/layout/about.tsx index 249eb79a..02938a6c 100644 --- a/client/src/app/layout/about.tsx +++ b/client/src/app/layout/about.tsx @@ -5,7 +5,7 @@ import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; import ENV from "@app/env"; import useBranding from "@app/hooks/useBranding"; -import { ThemeContext } from "@tsd-ui/core"; +import { ThemeContext } from "@app/components/Theme"; import { use } from "react"; interface IButtonAboutAppProps { diff --git a/client/src/app/layout/header.tsx b/client/src/app/layout/header.tsx index 70648099..c3b56d51 100644 --- a/client/src/app/layout/header.tsx +++ b/client/src/app/layout/header.tsx @@ -34,7 +34,7 @@ import useBranding from "@app/hooks/useBranding"; import { ThemeAwareLogo } from "@app/components/ThemeAwareLogo"; import { AboutApp } from "./about"; -import { ThemeSelector } from "@tsd-ui/core"; +import { ThemeSelector } from "@app/components/Theme"; export const HeaderApp: React.FC = () => { const { diff --git a/client/src/app/pages/Artifacts/Artifacts.tsx b/client/src/app/pages/Artifacts/Artifacts.tsx index a4c3f171..e1615b9f 100644 --- a/client/src/app/pages/Artifacts/Artifacts.tsx +++ b/client/src/app/pages/Artifacts/Artifacts.tsx @@ -4,7 +4,7 @@ import { useFetchArtifactsImageData, useVerifyArtifact } from "@app/queries/arti import { ArtifactResults } from "./components/ArtifactResults"; import { DocumentMetadata } from "@app/components/DocumentMetadata"; import { SearchForm } from "@app/components/SearchForm"; -import { LoadingWrapper } from "@tsd-ui/core"; +import { LoadingWrapper } from "@app/components/LoadingWrapper"; export const Artifacts = () => { const [artifactUri, setArtifactUri] = useState(); diff --git a/client/src/app/pages/TrustRoot/TrustRoot.tsx b/client/src/app/pages/TrustRoot/TrustRoot.tsx index bea9ca52..d3c11209 100644 --- a/client/src/app/pages/TrustRoot/TrustRoot.tsx +++ b/client/src/app/pages/TrustRoot/TrustRoot.tsx @@ -10,7 +10,7 @@ import { Overview } from "./components/Overview"; import { RootDetails } from "./components/RootDetails"; import { MetadataNotAvailable } from "./components/ErrorStates/MetadataNotAvailable"; import { DocumentMetadata } from "@app/components/DocumentMetadata"; -import { LoadingWrapper } from "@tsd-ui/core"; +import { LoadingWrapper } from "@app/components/LoadingWrapper"; export const TrustRoots: React.FC = () => { const { metadataInfo, isFetching: isFetchingMetadata, fetchError: fetchErrorMetadata } = useFetchTrustMetadataInfo(); diff --git a/client/src/app/pages/TrustRoot/components/Overview.tsx b/client/src/app/pages/TrustRoot/components/Overview.tsx index d725e7cb..f0b31551 100644 --- a/client/src/app/pages/TrustRoot/components/Overview.tsx +++ b/client/src/app/pages/TrustRoot/components/Overview.tsx @@ -7,7 +7,7 @@ import { Card, CardBody, CardTitle } from "@patternfly/react-core"; import type { Error as ApiError, CertificateInfo } from "@app/client"; import { RepositoryNotInitiated } from "./ErrorStates/RepositoryNotInitialized"; -import { LoadingWrapper } from "@tsd-ui/core"; +import { LoadingWrapper } from "@app/components/LoadingWrapper"; interface IOverviewProps { certificates: CertificateInfo[]; diff --git a/client/src/app/pages/TrustRoot/components/RootDetails.tsx b/client/src/app/pages/TrustRoot/components/RootDetails.tsx index 744ef456..3a766ab0 100644 --- a/client/src/app/pages/TrustRoot/components/RootDetails.tsx +++ b/client/src/app/pages/TrustRoot/components/RootDetails.tsx @@ -19,7 +19,7 @@ import { import type { MetadataInfo, MetadataInfoResponse } from "@app/client"; import { CertificateStatusIcon } from "@app/components/CertificateStatusIcon"; import { capitalizeFirstLetter, formatDate } from "@app/utils/utils"; -import { createComparator } from "@tsd-ui/core"; +import { createComparator } from "@app/utils/utils"; interface IRootDetailsProps { metadataInfo: MetadataInfoResponse; diff --git a/client/src/app/utils/utils.test.ts b/client/src/app/utils/utils.test.ts index 6489f078..8d077462 100644 --- a/client/src/app/utils/utils.test.ts +++ b/client/src/app/utils/utils.test.ts @@ -17,6 +17,7 @@ import { stringMatcher, toIdentity, verificationStatusToLabelColor, + createComparator, } from "./utils"; describe("utils", () => { @@ -642,4 +643,124 @@ describe("utils", () => { expect(result).toEqual({ label: "Unknown", color: "grey" }); }); }); + + describe("createComparator", () => { + describe("defaults (asc, en, nulls first)", () => { + const cmp = createComparator(); + + it("compares numbers arithmetically", () => { + expect(cmp(1, 2)).toBeLessThan(0); + expect(cmp(2, 1)).toBeGreaterThan(0); + expect(cmp(5, 5)).toBe(0); + }); + + it("compares negative numbers", () => { + expect(cmp(-3, 2)).toBeLessThan(0); + expect(cmp(0, -1)).toBeGreaterThan(0); + }); + + it("compares strings alphabetically", () => { + expect(cmp("apple", "banana")).toBeLessThan(0); + expect(cmp("banana", "apple")).toBeGreaterThan(0); + expect(cmp("same", "same")).toBe(0); + }); + + it("compares strings with numeric awareness", () => { + expect(cmp("file2", "file10")).toBeLessThan(0); + expect(cmp("item10", "item2")).toBeGreaterThan(0); + }); + + it("coerces non-number, non-string values to string", () => { + expect(cmp(true, false)).toBeGreaterThan(0); // "true" vs "false" + expect(cmp(false, true)).toBeLessThan(0); + }); + + it("places null before non-null values", () => { + expect(cmp(null, "hello")).toBeLessThan(0); + expect(cmp("hello", null)).toBeGreaterThan(0); + }); + + it("places undefined before non-null values", () => { + expect(cmp(undefined, 42)).toBeLessThan(0); + expect(cmp(42, undefined)).toBeGreaterThan(0); + }); + + it("treats two nullish values as equal", () => { + expect(cmp(null, null)).toBe(0); + expect(cmp(undefined, undefined)).toBe(0); + expect(cmp(null, undefined)).toBe(0); + }); + }); + + describe("direction: desc", () => { + const cmp = createComparator({ direction: "desc" }); + + it("reverses numeric comparison", () => { + expect(cmp(1, 2)).toBeGreaterThan(0); + expect(cmp(2, 1)).toBeLessThan(0); + }); + + it("reverses string comparison", () => { + expect(cmp("apple", "banana")).toBeGreaterThan(0); + expect(cmp("banana", "apple")).toBeLessThan(0); + }); + }); + + describe("nulls: last", () => { + const cmp = createComparator({ nulls: "last" }); + + it("places null after non-null values", () => { + expect(cmp(null, "hello")).toBeGreaterThan(0); + expect(cmp("hello", null)).toBeLessThan(0); + }); + + it("places undefined after non-null values", () => { + expect(cmp(undefined, 1)).toBeGreaterThan(0); + expect(cmp(1, undefined)).toBeLessThan(0); + }); + + it("treats two nullish values as equal", () => { + expect(cmp(null, undefined)).toBe(0); + }); + }); + + describe("locale option", () => { + it("respects locale-specific ordering", () => { + const sv = createComparator({ locale: "sv" }); + // In Swedish, ä sorts after z + expect(sv("ä", "z")).toBeGreaterThan(0); + + const de = createComparator({ locale: "de" }); + // In German, ä sorts near a (before b) + expect(de("ä", "b")).toBeLessThan(0); + }); + }); + + describe("sorting arrays", () => { + it("sorts numbers ascending", () => { + const cmp = createComparator(); + expect([3, 1, 2].sort(cmp)).toEqual([1, 2, 3]); + }); + + it("sorts numbers descending", () => { + const cmp = createComparator({ direction: "desc" }); + expect([3, 1, 2].sort(cmp)).toEqual([3, 2, 1]); + }); + + it("sorts strings with numeric awareness", () => { + const cmp = createComparator(); + expect(["file10", "file2", "file1"].sort(cmp)).toEqual(["file1", "file2", "file10"]); + }); + + it("sorts with nulls first (default)", () => { + const cmp = createComparator(); + expect([3, null, 1, null, 2].sort(cmp)).toEqual([null, null, 1, 2, 3]); + }); + + it("sorts with nulls last", () => { + const cmp = createComparator({ nulls: "last" }); + expect([3, null, 1, null, 2].sort(cmp)).toEqual([1, 2, 3, null, null]); + }); + }); + }); }); diff --git a/client/src/app/utils/utils.ts b/client/src/app/utils/utils.ts index f7b8844c..18367c9b 100644 --- a/client/src/app/utils/utils.ts +++ b/client/src/app/utils/utils.ts @@ -214,3 +214,29 @@ export const verificationStatusToLabelColor = ( return { label: "Unknown", color: "grey" }; } }; + +export interface ComparatorOptions { + locale?: string; + direction?: "asc" | "desc"; + nulls?: "first" | "last"; +} + +/** + * Creates a reusable comparator function with baked-in locale, direction, + * and null-positioning configuration. Uses `Intl.Collator` internally for + * optimal performance when sorting large arrays. + */ +export const createComparator = (opts: ComparatorOptions = {}) => { + const { locale = "en", direction = "asc", nulls = "first" } = opts; + const collator = new Intl.Collator(locale, { numeric: true }); + const dir = direction === "desc" ? -1 : 1; + + return (a: unknown, b: unknown): number => { + if (a == null && b == null) return 0; + if (a == null) return nulls === "first" ? -1 : 1; + if (b == null) return nulls === "first" ? 1 : -1; + + if (typeof a === "number" && typeof b === "number") return (a - b) * dir; + return collator.compare(String(a), String(b)) * dir; + }; +}; diff --git a/package-lock.json b/package-lock.json index bbc5a492..c65678cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,7 +73,6 @@ "@peculiar/x509": "1.14.3", "@tanstack/react-query": "^5.101.4", "@tanstack/react-query-devtools": "^5.85.0", - "@tsd-ui/core": "^0.3.2", "@vitest/coverage-v8": "^4.1.9", "axios": "^1.18.0", "dayjs": "^1.11.7", @@ -2334,9 +2333,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2353,9 +2349,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2372,9 +2365,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2391,9 +2381,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2410,9 +2397,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2429,9 +2413,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2805,9 +2786,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2822,9 +2800,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2839,9 +2814,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2856,9 +2828,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2873,9 +2842,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2890,9 +2856,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2907,9 +2870,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2924,9 +2884,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2941,9 +2898,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2958,9 +2912,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2975,9 +2926,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2992,9 +2940,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3009,9 +2954,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3331,21 +3273,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tsd-ui/core": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@tsd-ui/core/-/core-0.3.2.tgz", - "integrity": "sha512-HGeRJkiOMPGebepfkjLEI5TrefrY3bKhBNZhaN/FA3Z4iXmUWi8SSaUMeC6kA678wTkP3etWMQQT4PUjDns1hg==", - "license": "Apache-2.0", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@patternfly/react-core": "^6.0.0", - "@patternfly/react-icons": "^6.0.0", - "react": "^17 || ^18 || ^19", - "react-dom": "^17 || ^18 || ^19" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -8303,9 +8230,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8326,9 +8250,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8349,9 +8270,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8372,9 +8290,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [