diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/logs-table.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/logs-table.tsx index d25a0d0d63..7aff70ffa4 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/logs-table.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/table/logs-table.tsx @@ -185,14 +185,14 @@ export const KeysOverviewLogsTable = ({ apiId, setSelectedLog, log: selectedLog hide: true, }} emptyState={ -
- +
+ Key Verification Logs - + No key verification data to show. Once requests are made with API keys, you'll see a summary of successful and failed verification attempts. - + {" "} { - const { isMobile } = useResponsive(); + const isMobile = useIsMobile(); return (
diff --git a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/index.tsx b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/index.tsx index 8cb8424f6c..38eed0a0b2 100644 --- a/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/index.tsx +++ b/apps/dashboard/app/(app)/logs/components/controls/components/logs-filters/index.tsx @@ -11,19 +11,22 @@ const FILTER_ITEMS: FilterItemConfig[] = [ { id: "status", label: "Status", - shortcut: "e", + shortcut: "E", + shortcutLabel: "E", component: , }, { id: "methods", label: "Method", - shortcut: "m", + shortcut: "M", + shortcutLabel: "M", component: , }, { id: "paths", label: "Path", - shortcut: "p", + shortcut: "P", + shortcutLabel: "P", component: , }, ]; diff --git a/apps/dashboard/components/keyboard-button.tsx b/apps/dashboard/components/keyboard-button.tsx index eee60bc216..7e65c59a47 100644 --- a/apps/dashboard/components/keyboard-button.tsx +++ b/apps/dashboard/components/keyboard-button.tsx @@ -32,7 +32,7 @@ export const KeyboardButton = ({ {...props} > {modifierKey && {modifierKey}+} - {shortcut.toUpperCase()} + {shortcut?.toUpperCase()} ); }; diff --git a/apps/dashboard/components/logs/checkbox/filter-checkbox.tsx b/apps/dashboard/components/logs/checkbox/filter-checkbox.tsx index cf5db7359c..76db36578f 100644 --- a/apps/dashboard/components/logs/checkbox/filter-checkbox.tsx +++ b/apps/dashboard/components/logs/checkbox/filter-checkbox.tsx @@ -132,24 +132,6 @@ export const FilterCheckbox = < [selectionMode, handleCheckboxChange, handleSingleSelection], ); - // Handle keyboard event - const handleKeyboardEvent = useCallback( - (event: React.KeyboardEvent, index?: number) => { - if (event.key === " " || event.key === "Enter") { - event.preventDefault(); - if (index !== undefined) { - handleCheckboxClick(index); - } else if (selectionMode === "multiple") { - handleSelectAll(); - } - } - - // Use the handleKeyDown from the hook for other keyboard navigation - handleKeyDown(event, index); - }, - [handleCheckboxClick, handleSelectAll, handleKeyDown, selectionMode], - ); - // Handle applying the filter const handleApplyFilter = useCallback(() => { const selectedCheckboxes = checkboxes.filter((c) => c.checked); @@ -211,18 +193,20 @@ export const FilterCheckbox = < {selectionMode === "multiple" && (
@@ -257,7 +242,7 @@ export const FilterCheckbox = < {renderBottomGradient &&
} +
+
+ + + {component} + + + ); +}; diff --git a/apps/dashboard/components/logs/checkbox/filters-popover.tsx b/apps/dashboard/components/logs/checkbox/filters-popover.tsx index 9eb6ed0f05..5af5b2acd8 100644 --- a/apps/dashboard/components/logs/checkbox/filters-popover.tsx +++ b/apps/dashboard/components/logs/checkbox/filters-popover.tsx @@ -1,23 +1,24 @@ import { KeyboardButton } from "@/components/keyboard-button"; import { Drover } from "@/components/ui/drover"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; -import { CaretRight } from "@unkey/icons"; -import { Button } from "@unkey/ui"; -import { - type Dispatch, +import React, { type KeyboardEvent, type PropsWithChildren, + type Dispatch, type SetStateAction, useEffect, useRef, useState, + useCallback, } from "react"; import type { FilterValue } from "../validation/filter.types"; +import { FilterItem } from "./filter-item"; export type FilterItemConfig = { id: string; label: string; shortcut?: string; + shortcutLabel?: string; component: React.ReactNode; }; @@ -29,77 +30,202 @@ type FiltersPopoverProps = { onOpenChange?: Dispatch>; }; +// INFO: Workaround for applying hooks dynamically: Render a separate (null) +// ShortcutActivator component for each item's shortcut below. This allows +// top-level 'useKeyboardShortcut' calls per item, avoiding manual listener boilerplate, +// even if the component structure feels a bit indirect ("hacky"). +const ShortcutActivator = React.memo( + ({ + shortcut, + id, + onActivate, + }: { + shortcut: string; + id: string; + onActivate: (id: string) => void; + }) => { + useKeyboardShortcut(shortcut, () => onActivate(id), { + preventDefault: true, + ignoreInputs: true, + ignoreContentEditable: true, + }); + return null; // Render nothing + }, +); +ShortcutActivator.displayName = "ShortcutActivator"; + export const FiltersPopover = ({ children, - items, - activeFilters, + items = [], + activeFilters = [], open, onOpenChange, - getFilterCount = (field) => activeFilters.filter((f) => f.field === field).length, + getFilterCount = (field) => activeFilters.filter((f) => f?.field === field).length, }: PropsWithChildren) => { const [focusedIndex, setFocusedIndex] = useState(null); const [activeFilter, setActiveFilter] = useState(null); + const [lastFocusedIndex, setLastFocusedIndex] = useState(null); + const triggerRef = useRef(null); + + // Handle local state if external state isn't provided + const [internalOpen, setInternalOpen] = useState(false); + const isControlled = open !== undefined && onOpenChange !== undefined; + const isOpen = isControlled ? open : internalOpen; + const setOpen = useCallback( + (value: boolean | ((prev: boolean) => boolean)) => { + if (isControlled) { + const nextValue = typeof value === "function" ? value(!!open) : value; + onOpenChange?.(nextValue); + } else { + setInternalOpen(value); + } + }, + [isControlled, open, onOpenChange], + ); + + useEffect(() => { + if (!isOpen) { + setActiveFilter(null); + setFocusedIndex(null); + setLastFocusedIndex(null); + } + }, [isOpen]); - // biome-ignore lint/correctness/useExhaustiveDependencies: no need useEffect(() => { - return () => setActiveFilter(null); - }, [open]); + if (!activeFilter && lastFocusedIndex !== null && isOpen) { + setFocusedIndex(lastFocusedIndex); + } + }, [activeFilter, lastFocusedIndex, isOpen]); + + useKeyboardShortcut( + "f", + () => { + setOpen((prev) => { + const newState = !prev; + if (newState && items.length > 0) { + setTimeout(() => setFocusedIndex(0), 0); + } + return newState; + }); + }, + { preventDefault: true, ignoreInputs: true }, + ); - useKeyboardShortcut("f", () => { - onOpenChange?.((prev) => !prev); - }); + const handleActivateFilter = useCallback( + (id: string) => { + setOpen(true); + setTimeout(() => { + setActiveFilter(id); + const index = items.findIndex((i) => i.id === id); + if (index !== -1) { + setFocusedIndex(index); + setLastFocusedIndex(index); + } + }, 0); + }, + [items, setOpen], + ); const handleKeyDown = (e: KeyboardEvent) => { - if (!open) { + if (!isOpen) { return; } + const targetElement = e.target as HTMLElement; + const isInputFocused = + targetElement.tagName === "INPUT" || + targetElement.tagName === "TEXTAREA" || + targetElement.isContentEditable; + + // If a filter item popover is active, only handle ArrowLeft (outside inputs) if (activeFilter) { - if (e.key === "ArrowLeft") { + if (e.key === "ArrowLeft" && !isInputFocused) { e.preventDefault(); - setActiveFilter(null); + const closingIndex = items.findIndex((i) => i.id === activeFilter); + if (closingIndex !== -1) { + setLastFocusedIndex(closingIndex); // Remember index to return focus to + } + setActiveFilter(null); // Deactivate child popover + // useEffect [activeFilter] will handle setting focusedIndex based on lastFocusedIndex } + // Stop parent handling other keys when child is active return; } + // Handle navigation in the main filter list (when activeFilter is null) switch (e.key) { - case "ArrowDown": + case "ArrowDown": { e.preventDefault(); - setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % items.length)); + const newIndex = focusedIndex === null ? 0 : (focusedIndex + 1) % items.length; + setFocusedIndex(newIndex); + setLastFocusedIndex(newIndex); // Keep track for potential activation break; - case "ArrowUp": + } + case "ArrowUp": { e.preventDefault(); - setFocusedIndex((prev) => - prev === null ? items.length - 1 : (prev - 1 + items.length) % items.length, - ); + const newIndex = + focusedIndex === null + ? items.length - 1 + : (focusedIndex - 1 + items.length) % items.length; + setFocusedIndex(newIndex); + setLastFocusedIndex(newIndex); // Keep track break; + } case "Enter": - case "ArrowRight": + case "ArrowRight": { e.preventDefault(); if (focusedIndex !== null) { const selectedFilter = items[focusedIndex]; if (selectedFilter) { - setActiveFilter(selectedFilter.id); + setLastFocusedIndex(focusedIndex); // Store index before activating + setActiveFilter(selectedFilter.id); // Activate the child popover } } break; + } + case "Escape": { + e.preventDefault(); + setOpen(false); // Close the main popover + break; + } } }; return ( - - {children} - -
- -
+ + {/* Render Shortcut Activators (these components render null) */} + {/* These must be rendered for the hooks inside them to be active */} + {items.map((item) => + item.shortcut ? ( + + ) : null, + )} + + + {children} + + + +
+ +
{items.map((item, index) => ( ))}
@@ -109,112 +235,9 @@ export const FiltersPopover = ({ ); }; -const DroverHeader = () => ( -
- Filters - +const PopoverHeader = () => ( +
+ Filters... +
); - -type FilterItemProps = FilterItemConfig & { - isFocused?: boolean; - isActive?: boolean; - filterCount: number; -}; - -const FilterItem = ({ - label, - shortcut, - component, - isFocused, - isActive, - filterCount, -}: FilterItemProps) => { - const [open, setOpen] = useState(false); - const itemRef = useRef(null); - const contentRef = useRef(null); - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "ArrowLeft" && open) { - e.preventDefault(); - setOpen(false); - itemRef.current?.focus(); - } - }; - - useKeyboardShortcut({ key: shortcut || "", meta: true }, () => setOpen(true), { - preventDefault: true, - }); - - useEffect(() => { - if (isFocused && itemRef.current) { - itemRef.current.focus(); - } - }, [isFocused]); - - useEffect(() => { - if (isActive && !open) { - setOpen(true); - } - if (isActive && open && contentRef.current) { - const focusableElements = contentRef.current.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ); - if (focusableElements.length > 0) { - (focusableElements[0] as HTMLElement).focus(); - } else { - contentRef.current.focus(); - } - } - }, [isActive, open]); - - return ( - - -
-
- {shortcut && ( - - )} - {label} -
-
- {filterCount > 0 && ( -
- {filterCount} -
- )} - -
-
-
- - {component} - -
- ); -}; diff --git a/apps/dashboard/components/logs/checkbox/hooks/index.ts b/apps/dashboard/components/logs/checkbox/hooks/index.ts index afbd944f84..6e3847fc22 100644 --- a/apps/dashboard/components/logs/checkbox/hooks/index.ts +++ b/apps/dashboard/components/logs/checkbox/hooks/index.ts @@ -20,7 +20,6 @@ export const useCheckboxState = , TFilter exte const activeFilters = filters .filter((f) => f.field === filterField) .map((f) => String(f.value)); - return options.map((checkbox) => ({ ...checkbox, checked: activeFilters.includes(String(checkbox[checkPath])), @@ -54,40 +53,63 @@ export const useCheckboxState = , TFilter exte }); }; - const handleToggle = (index?: number) => { - if (typeof index === "number") { - handleCheckboxChange(index); - } else { - handleSelectAll(); + const handleKeyDown = (event: React.KeyboardEvent, index?: number) => { + // Special case for Escape key - let it bubble up naturally + if (event.key === "Escape") { + return; } - }; - const handleKeyDown = (event: React.KeyboardEvent, index?: number) => { - // Handle checkbox toggle - if (event.key === " " || event.key === "Enter" || event.key === "h" || event.key === "l") { + // Handle checkbox toggle with Space or Enter + if (event.key === " " || event.key === "Enter") { event.preventDefault(); - handleToggle(index); + if (typeof index === "number") { + handleCheckboxChange(index); + } else { + handleSelectAll(); + } + return; } - // Handle navigation - if ( - event.key === "ArrowDown" || - event.key === "ArrowUp" || - event.key === "j" || - event.key === "k" - ) { + // Handle arrow navigation + if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); - const elements = document.querySelectorAll('label[role="checkbox"]'); - const currentIndex = Array.from(elements).findIndex((el) => el === event.currentTarget); + // Get the parent container + const container = event.currentTarget.closest(".flex-col"); + if (!container) { + return; + } + + // Get all labels using the 'for' attribute (not 'htmlFor' in the DOM) + const checkboxLabels = container.querySelectorAll('label[for^="checkbox-"]'); + if (!checkboxLabels || checkboxLabels.length === 0) { + return; + } + + // Convert NodeList to Array for easier manipulation + const labelsArray = Array.from(checkboxLabels); + + // Find the current element's index in the array + const currentIndex = labelsArray.findIndex((label) => label === event.currentTarget); + if (currentIndex === -1) { + return; + } + + // Calculate the next index based on arrow direction let nextIndex: number; - if (event.key === "ArrowDown" || event.key === "j") { - nextIndex = currentIndex < elements.length - 1 ? currentIndex + 1 : 0; + if (event.key === "ArrowDown") { + nextIndex = (currentIndex + 1) % labelsArray.length; } else { - nextIndex = currentIndex > 0 ? currentIndex - 1 : elements.length - 1; + // ArrowUp + nextIndex = (currentIndex - 1 + labelsArray.length) % labelsArray.length; } - (elements[nextIndex] as HTMLElement).focus(); + // Focus the next element + try { + (labelsArray[nextIndex] as HTMLElement).focus(); + } catch (e) { + console.error("Focus error:", e); + } } }; diff --git a/apps/dashboard/components/logs/control-cloud/control-pill.tsx b/apps/dashboard/components/logs/control-cloud/control-pill.tsx index bef7dbfc8c..6011703295 100644 --- a/apps/dashboard/components/logs/control-cloud/control-pill.tsx +++ b/apps/dashboard/components/logs/control-cloud/control-pill.tsx @@ -78,7 +78,7 @@ export const ControlPill = ({ onKeyDown={handleKeyDown} tabIndex={0} className={cn( - "bg-gray-3 rounded-none rounded-r-md py-[2px] px-2 [&_svg]:stroke-[2px] [&_svg]:size-3 flex items-center border-none h-auto focus:ring-2 focus:ring-accent-7 focus:outline-none hover:bg-gray-4 focus:hover:bg-gray-4", + "bg-gray-3 rounded-none rounded-r-md py-[2px] px-2 [&_svg]:stroke-[2px] [&_svg]:size-3 flex items-center border-none h-auto focus:ring-2 focus:ring-offset-1 focus:ring-accent-9 focus:outline-none hover:bg-gray-4 focus:hover:bg-gray-4", isFocused && "bg-gray-4", )} > diff --git a/apps/dashboard/components/logs/control-cloud/index.tsx b/apps/dashboard/components/logs/control-cloud/index.tsx index 6c1fccd3b4..a4ebf1afaf 100644 --- a/apps/dashboard/components/logs/control-cloud/index.tsx +++ b/apps/dashboard/components/logs/control-cloud/index.tsx @@ -24,7 +24,7 @@ export const ControlCloud = ({ }: ControlCloudProps) => { const [focusedIndex, setFocusedIndex] = useState(null); - useKeyboardShortcut({ key: "d", meta: true }, () => { + useKeyboardShortcut("option+shift+d", () => { const timestamp = Date.now(); updateFilters([ { @@ -42,7 +42,7 @@ export const ControlCloud = ({ ] as TFilter[]); }); - useKeyboardShortcut({ key: "c", meta: true }, () => { + useKeyboardShortcut("option+shift+c", () => { setFocusedIndex(0); }); @@ -144,10 +144,10 @@ export const ControlCloud = ({ ))}
Clear filters - +
Focus filters - +
); diff --git a/apps/dashboard/components/logs/datetime/datetime-popover.tsx b/apps/dashboard/components/logs/datetime/datetime-popover.tsx index 586f4b29d0..8a6354a24e 100644 --- a/apps/dashboard/components/logs/datetime/datetime-popover.tsx +++ b/apps/dashboard/components/logs/datetime/datetime-popover.tsx @@ -4,11 +4,10 @@ import { KeyboardButton } from "@/components/keyboard-button"; import { Drawer } from "@/components/ui/drawer"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; -import { useResponsive } from "@/hooks/use-responsive"; +import { useIsMobile } from "@/hooks/use-mobile"; import { cn, processTimeFilters } from "@/lib/utils"; import { ChevronDown } from "@unkey/icons"; import { Button, DateTime, type Range, type TimeUnit } from "@unkey/ui"; -import { motion } from "framer-motion"; import { type PropsWithChildren, useEffect, useState } from "react"; import { CUSTOM_OPTION_ID, DEFAULT_OPTIONS } from "./constants"; import { DateTimeSuggestions } from "./suggestions"; @@ -37,7 +36,7 @@ export const DatetimePopover = ({ onDateTimeChange, customOptions, // Accept custom options }: DatetimePopoverProps) => { - const { isMobile } = useResponsive(); + const isMobile = useIsMobile(); const [timeRangeOpen, setTimeRangeOpen] = useState(false); const [open, setOpen] = useState(false); useKeyboardShortcut("t", () => setOpen((prev) => !prev)); @@ -130,60 +129,51 @@ export const DatetimePopover = ({
{children}
- -
-
- - {timeRangeOpen && ( - - - - )} + +
+ + +
+
- + +
+ - - - - - - - - + + + + + +
diff --git a/apps/dashboard/components/logs/filter-operator-input/index.tsx b/apps/dashboard/components/logs/filter-operator-input/index.tsx index 3fd96b9ae5..af1590c7c5 100644 --- a/apps/dashboard/components/logs/filter-operator-input/index.tsx +++ b/apps/dashboard/components/logs/filter-operator-input/index.tsx @@ -2,7 +2,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Check } from "@unkey/icons"; import { Button } from "@unkey/ui"; import { cn } from "@unkey/ui/src/lib/utils"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; type FilterOption = { id: T; @@ -26,6 +26,13 @@ export const FilterOperatorInput = ({ }: FilterOperatorInputProps) => { const [selectedOption, setSelectedOption] = useState(defaultOption); const [text, setText] = useState(defaultText); + const optionRefs = useRef<(HTMLButtonElement | null)[]>([]); + const textareaRef = useRef(null); + + // Initialize the refs array when options change + useEffect(() => { + optionRefs.current = optionRefs.current.slice(0, options.length); + }, [options.length]); const handleApply = () => { if (text.trim()) { @@ -33,24 +40,70 @@ export const FilterOperatorInput = ({ } }; - const handleKeyDown = (e: React.KeyboardEvent) => { + // Handle keyboard navigation for options + // INFO: Don't move "stopPropagation" to top. We need "ArrowLeft" to propagate so we can close this popover content when "ArrowLeft" triggered. + const handleOptionKeyDown = (e: React.KeyboardEvent, index: number) => { + switch (e.key) { + case "ArrowDown": { + e.stopPropagation(); + e.preventDefault(); + // Move to next option or wrap to first + const nextIndex = (index + 1) % options.length; + optionRefs.current[nextIndex]?.focus(); + break; + } + + case "ArrowUp": { + e.stopPropagation(); + e.preventDefault(); + // Move to previous option or wrap to last + const prevIndex = (index - 1 + options.length) % options.length; + optionRefs.current[prevIndex]?.focus(); + break; + } + + case "Enter": + case " ": + e.stopPropagation(); + e.preventDefault(); + setSelectedOption(options[index].id); + break; + + case "Tab": + if (!e.shiftKey) { + e.stopPropagation(); + e.preventDefault(); + textareaRef.current?.focus(); + } + break; + } + }; + + const handleTextareaKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleApply(); + return; } }; return (
- {options.map((option) => ( + {options.map((option, index) => (
-

+

{label}{" "} {options.find((opt) => opt.id === selectedOption)?.label} @@ -81,10 +134,11 @@ export const FilterOperatorInput = ({ ...