Stricter typing, adjuster colors, mandatory props
This commit is contained in:
@@ -3,11 +3,10 @@ import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
|
||||
export interface SliderProps {
|
||||
value?: number[];
|
||||
defaultValue?: number[];
|
||||
value: number[];
|
||||
step?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
max: number;
|
||||
onValueChange?: (value: number[]) => void;
|
||||
onValueCommit?: (value: number[]) => void;
|
||||
disabled?: boolean;
|
||||
@@ -19,10 +18,9 @@ export interface SliderProps {
|
||||
|
||||
export function Slider({
|
||||
value,
|
||||
defaultValue = [0],
|
||||
step = 1,
|
||||
min = 0,
|
||||
max = 100,
|
||||
max,
|
||||
onValueChange,
|
||||
onValueCommit,
|
||||
disabled = false,
|
||||
@@ -30,8 +28,8 @@ export function Slider({
|
||||
trackClassName,
|
||||
rangeClassName,
|
||||
thumbClassName,
|
||||
}:SliderProps) {
|
||||
const [internalValue, setInternalValue] = useState<number[]>(defaultValue);
|
||||
}: SliderProps) {
|
||||
const [internalValue, setInternalValue] = useState<number[]>(value);
|
||||
const isControlled = value !== undefined;
|
||||
const currentValue = isControlled ? value! : internalValue;
|
||||
|
||||
@@ -46,9 +44,11 @@ export function Slider({
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
className={cn("relative flex items-center select-none touch-none", className)}
|
||||
className={cn(
|
||||
"relative flex items-center select-none touch-none",
|
||||
className,
|
||||
)}
|
||||
value={currentValue}
|
||||
defaultValue={defaultValue}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
@@ -58,22 +58,28 @@ export function Slider({
|
||||
aria-label="Slider"
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
className={cn("relative h-2 flex-1 rounded-full bg-gray-200", trackClassName)}
|
||||
className={cn(
|
||||
"relative h-2 flex-1 rounded-full bg-slate-200",
|
||||
trackClassName,
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
className={cn("absolute h-full rounded-full bg-blue-500", rangeClassName)}
|
||||
className={cn(
|
||||
"absolute h-full rounded-full bg-blue-500",
|
||||
rangeClassName,
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{currentValue.map((_, i) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={i}
|
||||
className={cn(
|
||||
"block w-4 h-4 rounded-full bg-white border border-gray-400 shadow-md",
|
||||
thumbClassName
|
||||
"block w-4 h-4 rounded-full bg-white border border-slate-400 shadow-md",
|
||||
thumbClassName,
|
||||
)}
|
||||
aria-label={`Thumb ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
|
||||
export type FilterValue =
|
||||
| boolean
|
||||
| [number, number]
|
||||
| string[]
|
||||
| string;
|
||||
|
||||
export interface FilterConfig<T extends FilterValue = FilterValue> {
|
||||
interface BooleanFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "boolean" | "range" | "search";
|
||||
bounds?: [number, number];
|
||||
options?: string[];
|
||||
predicate: (node: Protobuf.Mesh.NodeInfo, value: T) => boolean;
|
||||
type: "boolean";
|
||||
predicate: (node: Node, value: boolean) => boolean;
|
||||
}
|
||||
|
||||
interface RangeFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "range";
|
||||
bounds: [number, number];
|
||||
predicate: (node: Node, value: [number, number]) => boolean;
|
||||
}
|
||||
|
||||
interface SearchFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "search";
|
||||
predicate: (node: Node, value: string) => boolean;
|
||||
}
|
||||
|
||||
export type FilterConfig = BooleanFilter | RangeFilter | SearchFilter;
|
||||
|
||||
export type FilterValueMap = {
|
||||
[C in FilterConfig as C["key"]]: C extends BooleanFilter ? boolean
|
||||
: C extends RangeFilter ? [number, number]
|
||||
: C extends SearchFilter ? string
|
||||
: never;
|
||||
};
|
||||
|
||||
// Defines all node filters in this object
|
||||
export const filterConfigs: FilterConfig[] = [
|
||||
export const filterConfigs: FilterConfig[] = [
|
||||
{
|
||||
key: "searchText",
|
||||
label: "Node name/number",
|
||||
@@ -30,7 +46,9 @@ export const filterConfigs: FilterConfig[] = [
|
||||
const nodeNum = node.num?.toString() ?? "";
|
||||
const nodeNumHex = numberToHexUnpadded(node.num) ?? "";
|
||||
const search = text.toLowerCase();
|
||||
return shortName.includes(search) || longName.includes(search) || nodeNum.includes(search) || nodeNumHex.includes(search.replace(/!/g, ""));
|
||||
return shortName.includes(search) || longName.includes(search) ||
|
||||
nodeNum.includes(search) ||
|
||||
nodeNumHex.includes(search.replace(/!/g, ""));
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -84,11 +102,11 @@ export const filterConfigs: FilterConfig[] = [
|
||||
label: "Hide MQTT-connected nodes",
|
||||
type: "boolean",
|
||||
predicate: (node, hide: boolean) => !hide || !node.viaMqtt,
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
|
||||
const defaultState = useMemo<Record<string, FilterValue>>(() => {
|
||||
const defaultState = useMemo(() => {
|
||||
return filterConfigs.reduce((acc, cfg) => {
|
||||
switch (cfg.type) {
|
||||
case "boolean":
|
||||
@@ -102,31 +120,37 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
|
||||
break;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, FilterValue>);
|
||||
}, {} as FilterValueMap);
|
||||
}, []);
|
||||
|
||||
const [filters, setFilters] = useState<Record<string, FilterValue>>(defaultState);
|
||||
const [filters, setFilters] = useState<FilterValueMap>(
|
||||
defaultState,
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters(defaultState);
|
||||
}, [defaultState]);
|
||||
|
||||
const onFilterChange = useCallback(
|
||||
(key: string, value: FilterValue) => {
|
||||
<K extends keyof FilterValueMap>(key: K, value: FilterValueMap[K]) => {
|
||||
setFilters((f) => ({ ...f, [key]: value }));
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
const filteredNodes = useMemo(
|
||||
() =>
|
||||
nodes.filter((node) =>
|
||||
filterConfigs.every((cfg) =>
|
||||
cfg.predicate(node, filters[cfg.key])
|
||||
)
|
||||
filterConfigs.every((cfg) => cfg.predicate(node, filters[cfg.key]))
|
||||
),
|
||||
[nodes, filters]
|
||||
[nodes, filters],
|
||||
);
|
||||
|
||||
return { filters, onFilterChange, resetFilters, filteredNodes, filterConfigs };
|
||||
}
|
||||
return {
|
||||
filters,
|
||||
onFilterChange,
|
||||
resetFilters,
|
||||
filteredNodes,
|
||||
filterConfigs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@components/UI/Popover.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@components/UI/Popover.tsx";
|
||||
import { FunnelIcon } from "lucide-react";
|
||||
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||
import { Slider } from "@components/UI/Slider.tsx";
|
||||
import type { FilterConfig, FilterValue } from "@core/hooks/useNodeFilters.ts";
|
||||
import type {
|
||||
FilterConfig,
|
||||
FilterValueMap,
|
||||
} from "@core/hooks/useNodeFilters.ts";
|
||||
|
||||
interface FilterControlProps {
|
||||
configs: FilterConfig[];
|
||||
values: Record<string, FilterValue>;
|
||||
onChange: (key: string, value: FilterValue) => void;
|
||||
values: FilterValueMap;
|
||||
onChange: <K extends keyof FilterValueMap>(
|
||||
key: K,
|
||||
value: FilterValueMap[K],
|
||||
) => void;
|
||||
resetFilters: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FilterControl({ configs, values, onChange, resetFilters, children }: FilterControlProps) {
|
||||
export function FilterControl(
|
||||
{ configs, values, onChange, resetFilters, children }: FilterControlProps,
|
||||
) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -22,24 +36,38 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre
|
||||
<FunnelIcon />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="end" sideOffset={12} className="dark:bg-slate-100 dark:border-slate-300">
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className="dark:bg-slate-100 dark:border-slate-300"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{configs.map((cfg) => {
|
||||
const val = values[cfg.key];
|
||||
switch (cfg.type) {
|
||||
case "boolean":
|
||||
if (typeof val !== "boolean") return null;
|
||||
return (
|
||||
<Checkbox
|
||||
key={cfg.key}
|
||||
checked={val as boolean}
|
||||
onChange={(v) => onChange(cfg.key, v as boolean)}
|
||||
checked={val}
|
||||
onChange={(v) => onChange(cfg.key, v)}
|
||||
labelClassName="dark:text-gray-900"
|
||||
>
|
||||
>
|
||||
{cfg.label}
|
||||
</Checkbox>
|
||||
);
|
||||
case "range": {
|
||||
const [min, max] = val as [number, number];
|
||||
if (
|
||||
!Array.isArray(val) ||
|
||||
val.length !== 2 ||
|
||||
typeof val[0] !== "number" ||
|
||||
typeof val[1] !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const [min, max] = val;
|
||||
const [lo, hi] = cfg.bounds!;
|
||||
return (
|
||||
<div key={cfg.key} className="space-y-2">
|
||||
@@ -51,9 +79,10 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre
|
||||
min={lo}
|
||||
max={hi}
|
||||
step={1}
|
||||
onValueChange={(newRange) =>
|
||||
onChange(cfg.key, newRange as [number, number])
|
||||
}
|
||||
onValueChange={(newRange) => {
|
||||
const [newMin, newMax] = newRange;
|
||||
onChange(cfg.key, [newMin, newMax]);
|
||||
}}
|
||||
className="w-full"
|
||||
trackClassName="h-1 bg-gray-200 dark:bg-slate-700"
|
||||
rangeClassName="bg-blue-500"
|
||||
@@ -63,24 +92,25 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre
|
||||
);
|
||||
}
|
||||
case "search":
|
||||
return (
|
||||
<div key={cfg.key} className="flex flex-col space-y-1">
|
||||
<label htmlFor={cfg.key} className="font-medium text-sm">
|
||||
{cfg.label}
|
||||
</label>
|
||||
<input
|
||||
id={cfg.key}
|
||||
type="text"
|
||||
value={val as string}
|
||||
onChange={(e) => onChange(cfg.key, e.target.value)}
|
||||
placeholder="Search phrase"
|
||||
className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (typeof val !== "string") return null;
|
||||
return (
|
||||
<div key={cfg.key} className="flex flex-col space-y-1">
|
||||
<label htmlFor={cfg.key} className="font-medium text-sm">
|
||||
{cfg.label}
|
||||
</label>
|
||||
<input
|
||||
id={cfg.key}
|
||||
type="text"
|
||||
value={val}
|
||||
onChange={(e) => onChange(cfg.key, e.target.value)}
|
||||
placeholder="Search phrase"
|
||||
className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
|
||||
@@ -89,7 +119,7 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre
|
||||
onClick={resetFilters}
|
||||
className="w-full py-1 bg-slate-600 text-white rounded text-sm"
|
||||
>
|
||||
Reset Filters
|
||||
Reset Filters
|
||||
</button>
|
||||
|
||||
{children && (
|
||||
@@ -101,4 +131,4 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +214,6 @@ const MapPage = () => {
|
||||
onChange={onFilterChange}
|
||||
resetFilters={resetFilters}
|
||||
/>
|
||||
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user