Browse Source

Stricter typing, adjuster colors, mandatory props

pull/580/head
Jeremy Gallant 1 year ago
parent
commit
3dce031f8e
  1. 34
      src/components/UI/Slider.tsx
  2. 74
      src/core/hooks/useNodeFilters.ts
  3. 94
      src/pages/Map/FilterControl.tsx
  4. 1
      src/pages/Map/index.tsx

34
src/components/UI/Slider.tsx

@ -3,11 +3,10 @@ import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
export interface SliderProps { export interface SliderProps {
value?: number[]; value: number[];
defaultValue?: number[];
step?: number; step?: number;
min?: number; min?: number;
max?: number; max: number;
onValueChange?: (value: number[]) => void; onValueChange?: (value: number[]) => void;
onValueCommit?: (value: number[]) => void; onValueCommit?: (value: number[]) => void;
disabled?: boolean; disabled?: boolean;
@ -19,10 +18,9 @@ export interface SliderProps {
export function Slider({ export function Slider({
value, value,
defaultValue = [0],
step = 1, step = 1,
min = 0, min = 0,
max = 100, max,
onValueChange, onValueChange,
onValueCommit, onValueCommit,
disabled = false, disabled = false,
@ -30,8 +28,8 @@ export function Slider({
trackClassName, trackClassName,
rangeClassName, rangeClassName,
thumbClassName, thumbClassName,
}:SliderProps) { }: SliderProps) {
const [internalValue, setInternalValue] = useState<number[]>(defaultValue); const [internalValue, setInternalValue] = useState<number[]>(value);
const isControlled = value !== undefined; const isControlled = value !== undefined;
const currentValue = isControlled ? value! : internalValue; const currentValue = isControlled ? value! : internalValue;
@ -46,9 +44,11 @@ export function Slider({
return ( return (
<SliderPrimitive.Root <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} value={currentValue}
defaultValue={defaultValue}
step={step} step={step}
min={min} min={min}
max={max} max={max}
@ -58,22 +58,28 @@ export function Slider({
aria-label="Slider" aria-label="Slider"
> >
<SliderPrimitive.Track <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 <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> </SliderPrimitive.Track>
{currentValue.map((_, i) => ( {currentValue.map((_, i) => (
<SliderPrimitive.Thumb <SliderPrimitive.Thumb
key={i} key={i}
className={cn( className={cn(
"block w-4 h-4 rounded-full bg-white border border-gray-400 shadow-md", "block w-4 h-4 rounded-full bg-white border border-slate-400 shadow-md",
thumbClassName thumbClassName,
)} )}
aria-label={`Thumb ${i + 1}`} aria-label={`Thumb ${i + 1}`}
/> />
))} ))}
</SliderPrimitive.Root> </SliderPrimitive.Root>
); );
}; }

74
src/core/hooks/useNodeFilters.ts

@ -1,24 +1,40 @@
import { useState, useMemo, useCallback } from "react"; import { useCallback, useMemo, useState } from "react";
import type { Protobuf } from "@meshtastic/core"; import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
export type FilterValue = interface BooleanFilter {
| boolean key: string;
| [number, number] label: string;
| string[] type: "boolean";
| string; predicate: (node: Node, value: boolean) => boolean;
}
interface RangeFilter {
key: string;
label: string;
type: "range";
bounds: [number, number];
predicate: (node: Node, value: [number, number]) => boolean;
}
export interface FilterConfig<T extends FilterValue = FilterValue> { interface SearchFilter {
key: string; key: string;
label: string; label: string;
type: "boolean" | "range" | "search"; type: "search";
bounds?: [number, number]; predicate: (node: Node, value: string) => boolean;
options?: string[];
predicate: (node: Protobuf.Mesh.NodeInfo, value: T) => 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 // Defines all node filters in this object
export const filterConfigs: FilterConfig[] = [ export const filterConfigs: FilterConfig[] = [
{ {
key: "searchText", key: "searchText",
label: "Node name/number", label: "Node name/number",
@ -30,7 +46,9 @@ export const filterConfigs: FilterConfig[] = [
const nodeNum = node.num?.toString() ?? ""; const nodeNum = node.num?.toString() ?? "";
const nodeNumHex = numberToHexUnpadded(node.num) ?? ""; const nodeNumHex = numberToHexUnpadded(node.num) ?? "";
const search = text.toLowerCase(); 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", label: "Hide MQTT-connected nodes",
type: "boolean", type: "boolean",
predicate: (node, hide: boolean) => !hide || !node.viaMqtt, predicate: (node, hide: boolean) => !hide || !node.viaMqtt,
} },
]; ];
export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) { export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
const defaultState = useMemo<Record<string, FilterValue>>(() => { const defaultState = useMemo(() => {
return filterConfigs.reduce((acc, cfg) => { return filterConfigs.reduce((acc, cfg) => {
switch (cfg.type) { switch (cfg.type) {
case "boolean": case "boolean":
@ -102,31 +120,37 @@ export function useNodeFilters(nodes: Protobuf.Mesh.NodeInfo[]) {
break; break;
} }
return acc; 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(() => { const resetFilters = useCallback(() => {
setFilters(defaultState); setFilters(defaultState);
}, [defaultState]); }, [defaultState]);
const onFilterChange = useCallback( const onFilterChange = useCallback(
(key: string, value: FilterValue) => { <K extends keyof FilterValueMap>(key: K, value: FilterValueMap[K]) => {
setFilters((f) => ({ ...f, [key]: value })); setFilters((f) => ({ ...f, [key]: value }));
}, },
[] [],
); );
const filteredNodes = useMemo( const filteredNodes = useMemo(
() => () =>
nodes.filter((node) => nodes.filter((node) =>
filterConfigs.every((cfg) => filterConfigs.every((cfg) => cfg.predicate(node, filters[cfg.key]))
cfg.predicate(node, filters[cfg.key])
)
), ),
[nodes, filters] [nodes, filters],
); );
return { filters, onFilterChange, resetFilters, filteredNodes, filterConfigs }; return {
} filters,
onFilterChange,
resetFilters,
filteredNodes,
filterConfigs,
};
}

94
src/pages/Map/FilterControl.tsx

@ -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 { FunnelIcon } from "lucide-react";
import { Checkbox } from "@components/UI/Checkbox/index.tsx"; import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import { Slider } from "@components/UI/Slider.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 { interface FilterControlProps {
configs: FilterConfig[]; configs: FilterConfig[];
values: Record<string, FilterValue>; values: FilterValueMap;
onChange: (key: string, value: FilterValue) => void; 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 ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -22,24 +36,38 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre
<FunnelIcon /> <FunnelIcon />
</button> </button>
</PopoverTrigger> </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"> <div className="space-y-4">
{configs.map((cfg) => { {configs.map((cfg) => {
const val = values[cfg.key]; const val = values[cfg.key];
switch (cfg.type) { switch (cfg.type) {
case "boolean": case "boolean":
if (typeof val !== "boolean") return null;
return ( return (
<Checkbox <Checkbox
key={cfg.key} key={cfg.key}
checked={val as boolean} checked={val}
onChange={(v) => onChange(cfg.key, v as boolean)} onChange={(v) => onChange(cfg.key, v)}
labelClassName="dark:text-gray-900" labelClassName="dark:text-gray-900"
> >
{cfg.label} {cfg.label}
</Checkbox> </Checkbox>
); );
case "range": { 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!; const [lo, hi] = cfg.bounds!;
return ( return (
<div key={cfg.key} className="space-y-2"> <div key={cfg.key} className="space-y-2">
@ -51,9 +79,10 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre
min={lo} min={lo}
max={hi} max={hi}
step={1} step={1}
onValueChange={(newRange) => onValueChange={(newRange) => {
onChange(cfg.key, newRange as [number, number]) const [newMin, newMax] = newRange;
} onChange(cfg.key, [newMin, newMax]);
}}
className="w-full" className="w-full"
trackClassName="h-1 bg-gray-200 dark:bg-slate-700" trackClassName="h-1 bg-gray-200 dark:bg-slate-700"
rangeClassName="bg-blue-500" rangeClassName="bg-blue-500"
@ -63,24 +92,25 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre
); );
} }
case "search": case "search":
return ( if (typeof val !== "string") return null;
<div key={cfg.key} className="flex flex-col space-y-1"> return (
<label htmlFor={cfg.key} className="font-medium text-sm"> <div key={cfg.key} className="flex flex-col space-y-1">
{cfg.label} <label htmlFor={cfg.key} className="font-medium text-sm">
</label> {cfg.label}
<input </label>
id={cfg.key} <input
type="text" id={cfg.key}
value={val as string} type="text"
onChange={(e) => onChange(cfg.key, e.target.value)} value={val}
placeholder="Search phrase" onChange={(e) => onChange(cfg.key, e.target.value)}
className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600" placeholder="Search phrase"
/> className="w-full px-2 py-1 border rounded shadow-sm dark:bg-slate-200 dark:border-slate-600"
</div> />
); </div>
);
default: default:
return null; return null;
} }
})} })}
@ -89,7 +119,7 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre
onClick={resetFilters} onClick={resetFilters}
className="w-full py-1 bg-slate-600 text-white rounded text-sm" className="w-full py-1 bg-slate-600 text-white rounded text-sm"
> >
Reset Filters Reset Filters
</button> </button>
{children && ( {children && (
@ -101,4 +131,4 @@ export function FilterControl({ configs, values, onChange, resetFilters, childre
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
} }

1
src/pages/Map/index.tsx

@ -214,7 +214,6 @@ const MapPage = () => {
onChange={onFilterChange} onChange={onFilterChange}
resetFilters={resetFilters} resetFilters={resetFilters}
/> />
</PageLayout> </PageLayout>
</> </>
); );

Loading…
Cancel
Save