mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat: new overview filters (#9013)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
M365ProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
import type { ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
aws: <AWSProviderBadge width={18} height={18} />,
|
||||
azure: <AzureProviderBadge width={18} height={18} />,
|
||||
gcp: <GCPProviderBadge width={18} height={18} />,
|
||||
kubernetes: <KS8ProviderBadge width={18} height={18} />,
|
||||
m365: <M365ProviderBadge width={18} height={18} />,
|
||||
github: <GitHubProviderBadge width={18} height={18} />,
|
||||
};
|
||||
|
||||
interface AccountsSelectorProps {
|
||||
providers: ProviderProps[];
|
||||
}
|
||||
|
||||
export function AccountsSelector({ providers }: AccountsSelectorProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const current = searchParams.get("filter[provider_id__in]") || "";
|
||||
const selectedTypes = searchParams.get("filter[provider_type__in]") || "";
|
||||
const selectedTypesList = selectedTypes
|
||||
? selectedTypes.split(",").filter(Boolean)
|
||||
: [];
|
||||
const selectedIds = current ? current.split(",").filter(Boolean) : [];
|
||||
const visibleProviders = providers
|
||||
.filter((p) => p.attributes.connection?.connected)
|
||||
.filter((p) =>
|
||||
selectedTypesList.length > 0
|
||||
? selectedTypesList.includes(p.attributes.provider)
|
||||
: true,
|
||||
);
|
||||
|
||||
const handleMultiValueChange = (ids: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (ids.length > 0) {
|
||||
params.set("filter[provider_id__in]", ids.join(","));
|
||||
} else {
|
||||
params.delete("filter[provider_id__in]");
|
||||
}
|
||||
|
||||
// Auto-deselect provider types that no longer have any selected accounts
|
||||
if (selectedTypesList.length > 0) {
|
||||
// Get provider types of currently selected accounts
|
||||
const selectedProviders = providers.filter((p) => ids.includes(p.id));
|
||||
const selectedProviderTypes = new Set(
|
||||
selectedProviders.map((p) => p.attributes.provider),
|
||||
);
|
||||
|
||||
// Keep only provider types that still have selected accounts
|
||||
const remainingProviderTypes = selectedTypesList.filter((type) =>
|
||||
selectedProviderTypes.has(type as ProviderType),
|
||||
);
|
||||
|
||||
// Update provider_type__in filter
|
||||
if (remainingProviderTypes.length > 0) {
|
||||
params.set(
|
||||
"filter[provider_type__in]",
|
||||
remainingProviderTypes.join(","),
|
||||
);
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedIds.length === 0) return null; // placeholder visible
|
||||
if (selectedIds.length === 1) {
|
||||
const p = providers.find((pr) => pr.id === selectedIds[0]);
|
||||
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
|
||||
return <span className="truncate">{name}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="truncate">{selectedIds.length} accounts selected</span>
|
||||
);
|
||||
};
|
||||
|
||||
const filterDescription =
|
||||
selectedTypesList.length > 0
|
||||
? `Showing accounts for ${selectedTypesList.join(", ")} providers`
|
||||
: "All connected cloud provider accounts";
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label
|
||||
htmlFor="accounts-selector"
|
||||
className="sr-only"
|
||||
id="accounts-label"
|
||||
>
|
||||
Filter by cloud provider account. {filterDescription}. Select one or
|
||||
more accounts to view findings.
|
||||
</label>
|
||||
<Select
|
||||
multiple
|
||||
selectedValues={selectedIds}
|
||||
onMultiValueChange={handleMultiValueChange}
|
||||
ariaLabel="Cloud provider accounts filter"
|
||||
>
|
||||
<SelectTrigger id="accounts-selector" aria-labelledby="accounts-label">
|
||||
<SelectValue placeholder="All accounts">
|
||||
{selectedLabel()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
{visibleProviders.length > 0 ? (
|
||||
visibleProviders.map((p) => {
|
||||
const id = p.id;
|
||||
const displayName = p.attributes.alias || p.attributes.uid;
|
||||
const providerType = p.attributes.provider as ProviderType;
|
||||
const icon = PROVIDER_ICON[providerType];
|
||||
return (
|
||||
<SelectItem
|
||||
key={id}
|
||||
value={id}
|
||||
aria-label={`${displayName} account (${providerType.toUpperCase()})`}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
<span className="truncate">{displayName}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{selectedTypesList.length > 0
|
||||
? "No accounts available for selected providers"
|
||||
: "No connected accounts available"}
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardVariant,
|
||||
ResourceStatsCard,
|
||||
StatsContainer,
|
||||
ResourceStatsCardContainer,
|
||||
} from "@/components/shadcn";
|
||||
import { CardVariant } from "@/components/shadcn/card/resource-stats-card/resource-stats-card-content";
|
||||
|
||||
interface CheckFindingsProps {
|
||||
failFindingsData: {
|
||||
@@ -93,7 +93,7 @@ export const CheckFindings = ({
|
||||
</div>
|
||||
|
||||
{/* Footer with ResourceStatsCards */}
|
||||
<StatsContainer>
|
||||
<ResourceStatsCardContainer className="flex w-full flex-col items-start justify-center gap-4 sm:flex-row md:w-[480px] md:justify-between">
|
||||
<ResourceStatsCard
|
||||
containerless
|
||||
badge={{
|
||||
@@ -106,11 +106,16 @@ export const CheckFindings = ({
|
||||
{ icon: Bell, label: `${failFindingsData.new} New` },
|
||||
{ icon: BellOff, label: `${failFindingsData.muted} Muted` },
|
||||
]}
|
||||
emptyState={
|
||||
failFindingsData.total === 0
|
||||
? { message: "No failed findings to display" }
|
||||
: undefined
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center px-[46px]">
|
||||
<div className="h-full w-px bg-slate-300 dark:bg-[rgba(39,39,42,1)]" />
|
||||
<div className="flex w-full items-center justify-center sm:w-auto sm:self-stretch sm:px-[46px]">
|
||||
<div className="h-px w-full bg-slate-300 sm:h-full sm:w-px dark:bg-[rgba(39,39,42,1)]" />
|
||||
</div>
|
||||
|
||||
<ResourceStatsCard
|
||||
@@ -125,9 +130,14 @@ export const CheckFindings = ({
|
||||
{ icon: Bell, label: `${passFindingsData.new} New` },
|
||||
{ icon: BellOff, label: `${passFindingsData.muted} Muted` },
|
||||
]}
|
||||
emptyState={
|
||||
passFindingsData.total === 0
|
||||
? { message: "No passed findings to display" }
|
||||
: undefined
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</StatsContainer>
|
||||
</ResourceStatsCardContainer>
|
||||
</CardContent>
|
||||
</BaseCard>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
import { type ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AWSProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AzureProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AzureProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GCPProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GCPProviderBadge,
|
||||
})),
|
||||
);
|
||||
const KS8ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.KS8ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const M365ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.M365ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GitHubProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GitHubProviderBadge,
|
||||
})),
|
||||
);
|
||||
|
||||
type IconProps = { width: number; height: number };
|
||||
|
||||
const IconPlaceholder = ({ width, height }: IconProps) => (
|
||||
<div style={{ width, height }} />
|
||||
);
|
||||
|
||||
const PROVIDER_DATA: Record<
|
||||
ProviderType,
|
||||
{ label: string; icon: React.ComponentType<IconProps> }
|
||||
> = {
|
||||
aws: {
|
||||
label: "Amazon Web Services",
|
||||
icon: AWSProviderBadge,
|
||||
},
|
||||
azure: {
|
||||
label: "Microsoft Azure",
|
||||
icon: AzureProviderBadge,
|
||||
},
|
||||
gcp: {
|
||||
label: "Google Cloud Platform",
|
||||
icon: GCPProviderBadge,
|
||||
},
|
||||
kubernetes: {
|
||||
label: "Kubernetes",
|
||||
icon: KS8ProviderBadge,
|
||||
},
|
||||
m365: {
|
||||
label: "Microsoft 365",
|
||||
icon: M365ProviderBadge,
|
||||
},
|
||||
github: {
|
||||
label: "GitHub",
|
||||
icon: GitHubProviderBadge,
|
||||
},
|
||||
};
|
||||
|
||||
type ProviderTypeSelectorProps = {
|
||||
providers: ProviderProps[];
|
||||
};
|
||||
|
||||
export const ProviderTypeSelector = ({
|
||||
providers,
|
||||
}: ProviderTypeSelectorProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentProviders = searchParams.get("filter[provider_type__in]") || "";
|
||||
const selectedTypes = currentProviders
|
||||
? currentProviders.split(",").filter(Boolean)
|
||||
: [];
|
||||
|
||||
const handleMultiValueChange = (values: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
// Update provider_type__in
|
||||
if (values.length > 0) {
|
||||
params.set("filter[provider_type__in]", values.join(","));
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
}
|
||||
|
||||
// Clear account selection when changing provider types
|
||||
// User should manually select accounts if they want to filter by specific accounts
|
||||
params.delete("filter[provider_id__in]");
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const availableTypes = Array.from(
|
||||
new Set(
|
||||
providers
|
||||
.filter((p) => p.attributes.connection?.connected)
|
||||
.map((p) => p.attributes.provider),
|
||||
),
|
||||
) as ProviderType[];
|
||||
|
||||
const renderIcon = (providerType: ProviderType) => {
|
||||
const IconComponent = PROVIDER_DATA[providerType].icon;
|
||||
return (
|
||||
<Suspense fallback={<IconPlaceholder width={24} height={24} />}>
|
||||
<IconComponent width={24} height={24} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedTypes.length === 0) return null; // placeholder visible
|
||||
if (selectedTypes.length === 1) {
|
||||
const providerType = selectedTypes[0] as ProviderType;
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{renderIcon(providerType)}
|
||||
<span>{PROVIDER_DATA[providerType].label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="truncate">
|
||||
{selectedTypes.length} providers selected
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label
|
||||
htmlFor="provider-type-selector"
|
||||
className="sr-only"
|
||||
id="provider-type-label"
|
||||
>
|
||||
Filter by cloud provider type. Select one or more providers to view
|
||||
findings.
|
||||
</label>
|
||||
<Select
|
||||
multiple
|
||||
selectedValues={selectedTypes}
|
||||
onMultiValueChange={handleMultiValueChange}
|
||||
ariaLabel="Cloud provider type filter"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="provider-type-selector"
|
||||
aria-labelledby="provider-type-label"
|
||||
>
|
||||
<SelectValue placeholder="All providers">
|
||||
{selectedLabel()}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTypes.length > 0 ? (
|
||||
availableTypes.map((providerType) => (
|
||||
<SelectItem
|
||||
key={providerType}
|
||||
value={providerType}
|
||||
aria-label={`${PROVIDER_DATA[providerType].label} provider`}
|
||||
>
|
||||
<span aria-hidden="true">{renderIcon(providerType)}</span>
|
||||
<span>{PROVIDER_DATA[providerType].label}</span>
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
No connected providers available
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getFindingsByStatus } from "@/actions/overview/overview";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { AccountsSelector } from "./components/accounts-selector";
|
||||
import { CheckFindings } from "./components/check-findings";
|
||||
import { ProviderTypeSelector } from "./components/provider-type-selector";
|
||||
|
||||
const FILTER_PREFIX = "filter[";
|
||||
|
||||
@@ -24,10 +27,15 @@ export default async function NewOverviewPage({
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const providersData = await getProviders({ page: 1, pageSize: 200 });
|
||||
|
||||
return (
|
||||
<ContentLayout title="New Overview" icon="lucide:square-chart-gantt">
|
||||
<div className="flex min-h-[60vh] items-center justify-center p-6">
|
||||
<div className="xxl:grid-cols-4 mb-6 grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<ProviderTypeSelector providers={providersData?.data ?? []} />
|
||||
<AccountsSelector providers={providersData?.data ?? []} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 md:flex-row">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-[400px] w-full max-w-md items-center justify-center rounded-xl border border-zinc-900 bg-stone-950">
|
||||
|
||||
@@ -94,11 +94,25 @@ export function DonutChart({
|
||||
change: item.change,
|
||||
}));
|
||||
|
||||
const legendPayload = chartData.map((entry) => ({
|
||||
value: entry.name,
|
||||
const total = chartData.reduce((sum, d) => sum + (Number(d.value) || 0), 0);
|
||||
const isEmpty = total <= 0;
|
||||
|
||||
const emptyData = [
|
||||
{
|
||||
name: "No data",
|
||||
value: 1,
|
||||
fill: "var(--chart-border-emphasis)",
|
||||
color: "var(--chart-border-emphasis)",
|
||||
percentage: 0,
|
||||
change: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const legendPayload = (isEmpty ? emptyData : chartData).map((entry) => ({
|
||||
value: isEmpty ? "No data" : entry.name,
|
||||
color: entry.color,
|
||||
payload: {
|
||||
percentage: entry.percentage,
|
||||
percentage: isEmpty ? 0 : entry.percentage,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -109,9 +123,9 @@ export function DonutChart({
|
||||
className="mx-auto aspect-square max-h-[350px]"
|
||||
>
|
||||
<PieChart>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{!isEmpty && <Tooltip content={<CustomTooltip />} />}
|
||||
<Pie
|
||||
data={chartData}
|
||||
data={isEmpty ? emptyData : chartData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={innerRadius}
|
||||
@@ -119,7 +133,7 @@ export function DonutChart({
|
||||
strokeWidth={0}
|
||||
paddingAngle={0}
|
||||
>
|
||||
{chartData.map((entry, index) => {
|
||||
{(isEmpty ? emptyData : chartData).map((entry, index) => {
|
||||
const opacity =
|
||||
hoveredIndex === null ? 1 : hoveredIndex === index ? 1 : 0.5;
|
||||
return (
|
||||
@@ -133,14 +147,18 @@ export function DonutChart({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{centerLabel && (
|
||||
{(centerLabel || isEmpty) && (
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
const centerValue = centerLabel ? centerLabel.value : 0;
|
||||
const centerText = centerLabel
|
||||
? centerLabel.label
|
||||
: "No data";
|
||||
const formattedValue =
|
||||
typeof centerLabel.value === "number"
|
||||
? centerLabel.value.toLocaleString()
|
||||
: centerLabel.value;
|
||||
typeof centerValue === "number"
|
||||
? centerValue.toLocaleString()
|
||||
: centerValue;
|
||||
|
||||
return (
|
||||
<text
|
||||
@@ -151,8 +169,8 @@ export function DonutChart({
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
className="text-3xl font-bold text-black dark:text-white"
|
||||
y={(viewBox.cy || 0) - 6}
|
||||
className="text-2xl font-bold text-zinc-800 dark:text-zinc-300"
|
||||
style={{
|
||||
fill: "currentColor",
|
||||
}}
|
||||
@@ -162,12 +180,12 @@ export function DonutChart({
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="text-black dark:text-white"
|
||||
className="text-xs text-zinc-800 dark:text-zinc-400"
|
||||
style={{
|
||||
fill: "currentColor",
|
||||
}}
|
||||
>
|
||||
{centerLabel.label}
|
||||
{centerText}
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const CardVariant = {
|
||||
default: "default",
|
||||
fail: "fail",
|
||||
pass: "pass",
|
||||
warning: "warning",
|
||||
info: "info",
|
||||
} as const;
|
||||
|
||||
export type CardVariant = (typeof CardVariant)[keyof typeof CardVariant];
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -18,7 +28,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -64,11 +74,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div data-slot="card-content" className={cn("", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ const containerVariants = cva(
|
||||
"rounded-[12px]",
|
||||
"border",
|
||||
"backdrop-blur-[46px]",
|
||||
"border-[rgba(38,38,38,0.70)]",
|
||||
"bg-[rgba(23,23,23,0.50)]",
|
||||
"border-slate-300",
|
||||
"bg-[#F8FAFC80]",
|
||||
"dark:border-[rgba(38,38,38,0.70)]",
|
||||
"dark:bg-[rgba(23,23,23,0.50)]",
|
||||
],
|
||||
|
||||
@@ -3,21 +3,13 @@ import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CardVariant } from "../card";
|
||||
|
||||
export interface StatItem {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const CardVariant = {
|
||||
default: "default",
|
||||
fail: "fail",
|
||||
pass: "pass",
|
||||
warning: "warning",
|
||||
info: "info",
|
||||
} as const;
|
||||
|
||||
export type CardVariant = (typeof CardVariant)[keyof typeof CardVariant];
|
||||
|
||||
const variantColors = {
|
||||
default: "#868994",
|
||||
fail: "#f54280",
|
||||
|
||||
@@ -3,12 +3,10 @@ import { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CardVariant } from "../card";
|
||||
import { ResourceStatsCardContainer } from "./resource-stats-card-container";
|
||||
import type { StatItem } from "./resource-stats-card-content";
|
||||
import {
|
||||
CardVariant,
|
||||
ResourceStatsCardContent,
|
||||
} from "./resource-stats-card-content";
|
||||
import { ResourceStatsCardContent } from "./resource-stats-card-content";
|
||||
import { ResourceStatsCardHeader } from "./resource-stats-card-header";
|
||||
|
||||
export type { StatItem };
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface StatsContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const StatsContainer = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StatsContainerProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex rounded-xl border border-slate-200 bg-white px-[19px] py-[9px] dark:border-[rgba(38,38,38,0.7)] dark:bg-[rgba(23,23,23,0.5)] dark:backdrop-blur-[46px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StatsContainer };
|
||||
@@ -5,4 +5,4 @@ export * from "./card/resource-stats-card/resource-stats-card-container";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-content";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-divider";
|
||||
export * from "./card/resource-stats-card/resource-stats-card-header";
|
||||
export * from "./card/stats-container";
|
||||
export * from "./select/select";
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, X } from "lucide-react";
|
||||
import { createContext, useContext, useId } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Context for managing multi-select state
|
||||
type SelectContextValue = {
|
||||
multiple?: boolean;
|
||||
selectedValues?: string[];
|
||||
onMultiValueChange?: (values: string[]) => void;
|
||||
ariaLabel?: string;
|
||||
liveRegionId?: string;
|
||||
};
|
||||
|
||||
const SelectContext = createContext<SelectContextValue>({});
|
||||
|
||||
function Select({
|
||||
allowDeselect = false,
|
||||
multiple = false,
|
||||
value,
|
||||
onValueChange,
|
||||
selectedValues = [],
|
||||
onMultiValueChange,
|
||||
ariaLabel,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof SelectPrimitive.Root>, "onValueChange"> & {
|
||||
allowDeselect?: boolean;
|
||||
multiple?: boolean;
|
||||
selectedValues?: string[];
|
||||
onValueChange?: (value: string) => void;
|
||||
onMultiValueChange?: (values: string[]) => void;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const liveRegionId = useId();
|
||||
|
||||
const handleValueChange = (nextValue: string) => {
|
||||
if (multiple && onMultiValueChange) {
|
||||
// Multi-select: toggle the value
|
||||
const newValues = selectedValues.includes(nextValue)
|
||||
? selectedValues.filter((v) => v !== nextValue)
|
||||
: [...selectedValues, nextValue];
|
||||
onMultiValueChange(newValues);
|
||||
} else if (
|
||||
allowDeselect &&
|
||||
typeof value === "string" &&
|
||||
value === nextValue
|
||||
) {
|
||||
// Single-select with deselect
|
||||
onValueChange?.("");
|
||||
} else {
|
||||
// Single-select
|
||||
onValueChange?.(nextValue);
|
||||
}
|
||||
};
|
||||
|
||||
const contextValue = {
|
||||
multiple,
|
||||
selectedValues,
|
||||
onMultiValueChange,
|
||||
ariaLabel,
|
||||
liveRegionId,
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectContext.Provider value={contextValue}>
|
||||
<SelectPrimitive.Root
|
||||
data-slot="select"
|
||||
value={multiple ? "" : value}
|
||||
onValueChange={handleValueChange}
|
||||
{...props}
|
||||
/>
|
||||
{/* Live region for screen reader announcements */}
|
||||
{multiple && (
|
||||
<div
|
||||
id={liveRegionId}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
>
|
||||
{selectedValues.length > 0
|
||||
? `${selectedValues.length} ${selectedValues.length === 1 ? "item" : "items"} selected`
|
||||
: "No items selected"}
|
||||
</div>
|
||||
)}
|
||||
</SelectContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
placeholder,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
const { multiple, selectedValues } = useContext(SelectContext);
|
||||
|
||||
// For multi-select, render custom children or placeholder
|
||||
if (multiple) {
|
||||
return (
|
||||
<span data-slot="select-value">
|
||||
{selectedValues && selectedValues.length > 0 ? children : placeholder}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// For single-select, use default Radix behavior
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Value>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
const { multiple, selectedValues, onMultiValueChange, ariaLabel } =
|
||||
useContext(SelectContext);
|
||||
const hasSelection = multiple && selectedValues && selectedValues.length > 0;
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onMultiValueChange) {
|
||||
onMultiValueChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
const clearButtonLabel = `Clear ${ariaLabel || "selection"}${hasSelection ? ` (${selectedValues.length} selected)` : ""}`;
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
aria-label={ariaLabel}
|
||||
aria-multiselectable={multiple ? "true" : undefined}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-lg border border-slate-400 px-4 py-3 text-base leading-7 whitespace-nowrap text-slate-950 shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-slate-600 focus-visible:ring-2 focus-visible:ring-slate-600 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[52px] data-[size=sm]:h-10 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:border-[#262626] dark:bg-[#171717] dark:text-white dark:focus-visible:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div className="flex items-center gap-1">
|
||||
{hasSelection && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClear(e as unknown as React.MouseEvent);
|
||||
}
|
||||
}}
|
||||
className="pointer-events-auto cursor-pointer rounded-sm p-0.5 opacity-70 transition-opacity hover:opacity-100 focus:opacity-100 focus:ring-2 focus:ring-slate-600 focus:ring-offset-2 focus:outline-none dark:focus:ring-slate-400"
|
||||
aria-label={clearButtonLabel}
|
||||
>
|
||||
<X
|
||||
className="size-4 text-slate-950 dark:text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon
|
||||
className="size-6 text-slate-950 dark:text-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</div>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-slate-400 bg-white text-slate-950 shadow-md dark:border-[#262626] dark:bg-[#171717] dark:text-white",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"flex flex-col gap-1 p-3",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
const { multiple, selectedValues } = useContext(SelectContext);
|
||||
const isSelected = multiple && selectedValues?.includes(value);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
value={value}
|
||||
aria-selected={multiple ? isSelected : undefined}
|
||||
aria-checked={multiple ? isSelected : undefined}
|
||||
role={multiple ? "option" : undefined}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-2.5 pr-10 pl-3 text-base outline-hidden select-none hover:bg-slate-200 focus:ring-2 focus:ring-slate-600 focus:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:hover:bg-slate-700/50 dark:focus:ring-slate-400 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
|
||||
isSelected && "bg-slate-100 dark:bg-slate-800/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText asChild>
|
||||
<span className="flex min-w-0 items-center gap-2">{children}</span>
|
||||
</SelectPrimitive.ItemText>
|
||||
<span
|
||||
className="absolute right-3 flex size-4 items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{multiple ? (
|
||||
// Multi-select: show check when selected
|
||||
isSelected && (
|
||||
<CheckIcon className="size-5 text-slate-950 dark:text-white" />
|
||||
)
|
||||
) : (
|
||||
// Single-select: use radix indicator
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-5 text-slate-950 dark:text-white" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
)}
|
||||
</span>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4 text-slate-950 dark:text-white" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4 text-slate-950 dark:text-white" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ChevronUpIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import * as React from "react";
|
||||
import { ComponentPropsWithoutRef, ForwardedRef, forwardRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -17,9 +17,9 @@ const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
const SelectTrigger = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
@@ -37,9 +37,9 @@ const SelectTrigger = React.forwardRef<
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
const SelectScrollUpButton = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
@@ -54,9 +54,9 @@ const SelectScrollUpButton = React.forwardRef<
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
const SelectScrollDownButton = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
@@ -72,9 +72,9 @@ const SelectScrollDownButton = React.forwardRef<
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
const SelectContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
@@ -104,10 +104,10 @@ const SelectContent = React.forwardRef<
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
const SelectLabel = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref: ForwardedRef<HTMLDivElement>) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
@@ -116,9 +116,9 @@ const SelectLabel = React.forwardRef<
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
const SelectItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
@@ -138,9 +138,9 @@ const SelectItem = React.forwardRef<
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
const SelectSeparator = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
|
||||
+14
-2
@@ -20,7 +20,7 @@
|
||||
/* Chart Provider Colors */
|
||||
--chart-provider-aws: #ff9900;
|
||||
--chart-provider-azure: #00bcd4;
|
||||
--chart-provider-google: #EA4335;
|
||||
--chart-provider-google: #ea4335;
|
||||
|
||||
/* Chart UI Colors - Dark Theme (defaults) */
|
||||
--chart-text-primary: #ffffff;
|
||||
@@ -102,6 +102,19 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Screen reader only - visually hidden but accessible */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Hide scrollbar */
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
@@ -125,7 +138,6 @@
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
Reference in New Issue
Block a user