(null);
-
return (
- onQueryChange(event.target.value)}
+ onValueChange={onQueryChange}
placeholder={`Search ${kind}…`}
aria-label={`Search ${kind}`}
- className={cn(
- "pr-10 pl-9 [&::-webkit-search-cancel-button]:appearance-none",
- inputClassName,
- )}
+ className={cn("pr-10 pl-9", inputClassName)}
/>
- {query && (
-
- )}
);
}
diff --git a/src/components/rbac/pick-list.tsx b/src/components/rbac/pick-list.tsx
index 9bf9a55..1dc3900 100644
--- a/src/components/rbac/pick-list.tsx
+++ b/src/components/rbac/pick-list.tsx
@@ -4,6 +4,7 @@ import { cn } from "cn";
import { Search } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
export type PickOption = {
key: string;
@@ -72,50 +73,50 @@ export function PickList({
/>
)}
-
- {visible.length === 0 ? (
-
- {options.length === 0 ? emptyText : "Nothing matches that filter."}
-
- ) : (
- visible.map((option) => {
- const checked = selected.includes(option.key);
- return (
-
+ );
+ })
+ )}
+
+
{error && (
{error}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 86342c1..8ab754c 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -7,7 +7,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
- "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
+ "h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:mr-3 file:rounded-sm file:bg-secondary file:px-2 file:text-secondary-foreground file:text-sm file:font-medium placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..e2ba50b
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,54 @@
+import type { ComponentProps } from "react";
+import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
+import { cn } from "cn";
+
+/** Styled scrollbars with native wheel, touch and keyboard scrolling. */
+export function ScrollArea({
+ children,
+ className,
+ viewportLabel,
+ ...props
+}: ComponentProps & { viewportLabel?: string }) {
+ return (
+
+
+ {children}
+
+
+
+
+
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ComponentProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/search-input.tsx b/src/components/ui/search-input.tsx
new file mode 100644
index 0000000..a7c3b29
--- /dev/null
+++ b/src/components/ui/search-input.tsx
@@ -0,0 +1,52 @@
+import { useRef, type ComponentProps } from "react";
+import { X } from "lucide-react";
+import { cn } from "cn";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+/** A consistent clear control instead of the browser's search decoration. */
+export function SearchInput({
+ value,
+ onValueChange,
+ className,
+ ...props
+}: Omit, "type" | "value" | "onChange" | "ref"> & {
+ value: string;
+ onValueChange: (value: string) => void;
+}) {
+ const input = useRef(null);
+ return (
+ <>
+ onValueChange(event.target.value)}
+ onKeyDown={(event) => {
+ props.onKeyDown?.(event);
+ if (!event.defaultPrevented && !props.readOnly && event.key === "Escape") {
+ event.preventDefault();
+ onValueChange("");
+ }
+ }}
+ className={cn("pr-10", className)}
+ />
+ {value && !props.disabled && !props.readOnly && (
+
+ )}
+ >
+ );
+}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
index 741a012..cc8853d 100644
--- a/src/components/ui/textarea.tsx
+++ b/src/components/ui/textarea.tsx
@@ -6,7 +6,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
{canWrite && (
-
+
+
+
+
)}
);
diff --git a/src/routes/access/roles/index.tsx b/src/routes/access/roles/index.tsx
index de46a8a..c006ee0 100644
--- a/src/routes/access/roles/index.tsx
+++ b/src/routes/access/roles/index.tsx
@@ -1,4 +1,5 @@
import { matchesSearch } from "@/lib/search";
+import { Hint } from "@/components/ui/tooltip";
import { useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ShieldCheck } from "lucide-react";
@@ -75,28 +76,30 @@ function BuiltInRoleCard({ role, effective }: { role: RoleSummary; effective: nu
return (
-
-
-
-
- {role.name}
-
- {role.key}
-
-
-
- {effective === null ? (
- ALL
- ) : (
- plural(effective, "permission", "permissions")
- )}
-
-
+
+
+
+
+
+ {role.name}
+
+ {role.key}
+
+
+
+ {effective === null ? (
+ ALL
+ ) : (
+ plural(effective, "permission", "permissions")
+ )}
+
+
+
);
diff --git a/src/routes/users/$userId.tsx b/src/routes/users/$userId.tsx
index 32c2bfb..7860225 100644
--- a/src/routes/users/$userId.tsx
+++ b/src/routes/users/$userId.tsx
@@ -1,3 +1,4 @@
+import { Hint } from "@/components/ui/tooltip";
import { createFileRoute, Link, useNavigate, useRouter } from "@tanstack/react-router";
import { ArrowLeft, Clock, KeyRound, Mail, RefreshCw } from "lucide-react";
import { emptyCatalog } from "@/auth/rbac";
@@ -190,9 +191,11 @@ function UserDetailPage() {
{person.permissions.length ? (
{person.permissions.map((key) => (
- -
- {key}
-
+
+ -
+ {key}
+
+
))}
) : (
@@ -236,9 +239,9 @@ function UserDetailPage() {
User ID
-
- {person.id}
-
+
+ {person.id}
+
diff --git a/src/routes/users/index.tsx b/src/routes/users/index.tsx
index cda2202..2d0f8af 100644
--- a/src/routes/users/index.tsx
+++ b/src/routes/users/index.tsx
@@ -1,3 +1,4 @@
+import { Hint } from "@/components/ui/tooltip";
import {
createFileRoute,
Link,
@@ -36,7 +37,7 @@ import { SignInIcons, STATUS_TRAITS, TRAIT_ICONS } from "@/components/users/trai
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
+import { SearchInput } from "@/components/ui/search-input";
import {
Select,
SelectContent,
@@ -70,18 +71,20 @@ const ANY_ROLE = "any";
function HeldMark({ label, held }: { label: string; held: boolean }) {
const Mark = held ? Check : X;
return (
-
-
-
- {label}: {held ? "yes" : "no"}
+
+
+
+
+ {label}: {held ? "yes" : "no"}
+
-
+
);
}
@@ -141,25 +144,27 @@ function TraitFilter({
const state = presenceState(value);
const next = presenceState(cyclePresence(value, false));
return (
-
+
+
+
+
+ {trait.label}
+
+
);
}
@@ -279,10 +284,9 @@ function UsersIndex() {
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
- setQuery(event.target.value)}
+ onValueChange={setQuery}
placeholder="Name, email, @Telegram, ID, role, or account status"
aria-label="Search users"
className="pl-9"
diff --git a/src/styles.css b/src/styles.css
index bde9ee3..9897e0d 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -96,6 +96,8 @@
@layer base {
* {
@apply border-border outline-ring/50;
+ scrollbar-width: thin;
+ scrollbar-color: var(--muted-foreground) var(--muted);
}
body {
@apply m-0 bg-background font-sans text-foreground antialiased;
@@ -116,6 +118,63 @@
color: #152347;
}
}
+/* Theme every scrolling surface, including the document, portals, editors and tables.
+ Firefox uses the standard properties above; WebKit exposes arrow buttons too. */
+@supports selector(::-webkit-scrollbar) {
+ * {
+ scrollbar-width: auto;
+ scrollbar-color: auto;
+ }
+ ::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+ ::-webkit-scrollbar-track,
+ ::-webkit-scrollbar-corner {
+ background: var(--muted);
+ }
+ ::-webkit-scrollbar-thumb {
+ min-height: 32px;
+ border: 2px solid var(--muted);
+ border-radius: 999px;
+ background: var(--muted-foreground);
+ }
+ ::-webkit-scrollbar-thumb:hover {
+ background: var(--foreground);
+ }
+ ::-webkit-scrollbar-button {
+ display: none;
+ width: 0;
+ height: 0;
+ }
+}
+
+input::-webkit-search-decoration,
+input::-webkit-search-cancel-button,
+input::-webkit-search-results-button,
+input::-webkit-search-results-decoration {
+ appearance: none;
+}
+input::-ms-reveal,
+input::-ms-clear {
+ display: none;
+}
+
+@media (forced-colors: active) {
+ * {
+ scrollbar-color: auto;
+ scrollbar-width: auto;
+ }
+ ::-webkit-scrollbar-thumb {
+ background: ButtonText;
+ border-color: Canvas;
+ }
+ ::-webkit-scrollbar-track,
+ ::-webkit-scrollbar-corner {
+ background: Canvas;
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
*,
*::before,