Skip to content

Commit cd8ac5e

Browse files
Merge pull request #136 from corbitsdev/cl-11569-sidebar-search-and-collapse
feat: add a sidebar search control that opens the palette
2 parents f180bb0 + 80ea84c commit cd8ac5e

8 files changed

Lines changed: 480 additions & 50 deletions

File tree

‎README.md‎

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ Requires Node 24 or later. Tested on React 19; the peer range also accepts React
1919

2020
Optional peers are needed only by the modules that import them. Those modules are not in the root entry, so import them by subpath:
2121

22-
| Peer | Subpaths |
23-
| ------------------------------- | ------------------------------------------------------------- |
24-
| `@radix-ui/react-dialog` | `ui/dialog`, `ui/command-palette`, `ui/mic-permission-dialog` |
25-
| `@radix-ui/react-dropdown-menu` | `ui/menu` |
26-
| `@radix-ui/react-tooltip` | `ui/tooltip` |
27-
| `sonner` | `ui/toast` |
22+
| Peer | Subpaths |
23+
| ------------------------------- | ---------------------------------------------------------------------------------- |
24+
| `@radix-ui/react-dialog` | `ui/dialog`, `ui/command-palette`, `ui/sidebar-search`, `ui/mic-permission-dialog` |
25+
| `@radix-ui/react-dropdown-menu` | `ui/menu` |
26+
| `@radix-ui/react-tooltip` | `ui/tooltip` |
27+
| `sonner` | `ui/toast` |
2828

2929
## Quickstart
3030

@@ -190,27 +190,28 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer",
190190
191191
#### Layout
192192
193-
| Component | What it is |
194-
| ------------------- | -------------------------------------------- |
195-
| `PageShell` | Page margins and scroll ownership |
196-
| `PagePanel` | Bordered page-content frame |
197-
| `TopBar` | Title, breadcrumbs and actions row |
198-
| `Sidebar` | Collapsible navigation sidebar |
199-
| `SidebarPanel` | Sidebar with pins, body and footer |
200-
| `SidebarItemRow` | One sidebar row |
201-
| `ListDetail` | Index on the left, open item on the right |
202-
| `InspectorShell` | Side inspector rail |
203-
| `Section` | Titled block of content |
204-
| `SettingsPanel` | Settings frame with optional save |
205-
| `LibraryPageHeader` | Title, count and controls for a library page |
206-
| `Card` | Bordered content card |
207-
| `EmptyState` | Title, description, icon and action |
208-
| `RichEmptyState` | Empty state with suggested next-step actions |
209-
| `BootScreen` | Screen shown before the app loads |
210-
| `DitherCanvas` | Animated dithered background |
211-
| `CorbitsMark` | Corbits logo |
212-
| `AuthLayout` | Sign-in page layout |
213-
| `LoginForm` | Sign-in and sign-up form, with optional SSO |
193+
| Component | What it is |
194+
| ------------------- | -------------------------------------------------------------- |
195+
| `PageShell` | Page margins and scroll ownership |
196+
| `PagePanel` | Bordered page-content frame |
197+
| `TopBar` | Title, breadcrumbs and actions row |
198+
| `Sidebar` | Collapsible navigation sidebar |
199+
| `SidebarSearch` | Search row labelled Cmd+K (optional peer, `ui/sidebar-search`) |
200+
| `SidebarPanel` | Sidebar with pins, body and footer |
201+
| `SidebarItemRow` | One sidebar row |
202+
| `ListDetail` | Index on the left, open item on the right |
203+
| `InspectorShell` | Side inspector rail |
204+
| `Section` | Titled block of content |
205+
| `SettingsPanel` | Settings frame with optional save |
206+
| `LibraryPageHeader` | Title, count and controls for a library page |
207+
| `Card` | Bordered content card |
208+
| `EmptyState` | Title, description, icon and action |
209+
| `RichEmptyState` | Empty state with suggested next-step actions |
210+
| `BootScreen` | Screen shown before the app loads |
211+
| `DitherCanvas` | Animated dithered background |
212+
| `CorbitsMark` | Corbits logo |
213+
| `AuthLayout` | Sign-in page layout |
214+
| `LoginForm` | Sign-in and sign-up form, with optional SSO |
214215
215216
#### Controls
216217
@@ -257,7 +258,7 @@ The host app owns the connection to the hub; components only take props.
257258
258259
- Install from npm. Git installs (`github:corbitsdev/react-ui#<sha>`) no longer build `dist/`.
259260
- Per-module subpaths are gone: import from `@corbits/react-ui` (`import { Button } from "@corbits/react-ui"`).
260-
- `ui/dialog`, `ui/command-palette`, `ui/mic-permission-dialog`, `ui/menu`, `ui/tooltip` and `ui/toast` are subpath-only, and their peers are optional: `import { Toaster, toast } from "@corbits/react-ui/ui/toast"`. Install the peer for each subpath you keep.
261+
- `ui/dialog`, `ui/command-palette`, `ui/sidebar-search`, `ui/mic-permission-dialog`, `ui/menu`, `ui/tooltip` and `ui/toast` are subpath-only, and their peers are optional: `import { Toaster, toast } from "@corbits/react-ui/ui/toast"`. Install the peer for each subpath you keep.
261262
- `lib/csv`, `lib/url`, `lib/utils`, `lib/workflow-registry` and `ui/chat-dock-timing` are internal. Replace `cn` with `twMerge(clsx(...))`.
262263
- Stateful components ship `"use client"`. Client wrappers you wrote for them still work, and can go.
263264
- 84 unused modules are removed, `ui/command` is removed, `ActivityBlock` is renamed `ReasoningBlock`, and `AuthLayout` no longer renders `DitherCanvas` by default.

‎package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
"types": "./dist/ui/command-palette.d.ts",
4141
"default": "./dist/ui/command-palette.js"
4242
},
43+
"./ui/sidebar-search": {
44+
"types": "./dist/ui/sidebar-search.d.ts",
45+
"default": "./dist/ui/sidebar-search.js"
46+
},
4347
"./ui/dialog": {
4448
"types": "./dist/ui/dialog.d.ts",
4549
"default": "./dist/ui/dialog.js"

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// barrel is one module, so any root import would load that peer. They have
55
// their own entries in the `exports` map instead:
66
// @corbits/react-ui/ui/command-palette
7+
// @corbits/react-ui/ui/sidebar-search
78
// @corbits/react-ui/ui/dialog
89
// @corbits/react-ui/ui/menu
910
// @corbits/react-ui/ui/mic-permission-dialog

‎src/ui/command-palette.tsx‎

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -520,36 +520,60 @@ export function CommandPaletteInline({
520520
);
521521
}
522522

523+
const commandShortcutSubscribers = new Set<() => void>();
524+
525+
function onCommandShortcutKeyDown(event: KeyboardEvent): void {
526+
if (
527+
event.key.toLowerCase() !== "k" ||
528+
!(event.metaKey || event.ctrlKey) ||
529+
event.repeat
530+
) {
531+
return;
532+
}
533+
const target = event.target as HTMLElement | null;
534+
if (
535+
target?.isContentEditable === true ||
536+
/^(input|textarea|select)$/i.test(target?.tagName ?? "")
537+
) {
538+
return;
539+
}
540+
const subscriber = commandShortcutSubscribers.values().next().value;
541+
if (subscriber === undefined) return;
542+
event.preventDefault();
543+
subscriber();
544+
}
545+
523546
/**
524547
* Binds ⌘K / Ctrl-K to a toggle.
525548
*
526549
* A hook and not built into the palette, because plenty of apps already own
527550
* their global key handling and a second listener fighting theirs is worse
528-
* than no listener at all. It ignores repeats from a held key and lets the
529-
* browser keep its own ⌘K where the user is typing into a text field —
551+
* than no listener at all. Every mounted caller shares one document listener,
552+
* and a shortcut invokes exactly one subscriber, so a host toggle and a search
553+
* row cannot undo each other. A later subscriber keeps the listener when the
554+
* one that attached it unmounts. It ignores repeats from a held key and lets
555+
* the browser keep its own ⌘K where the user is typing into a text field —
530556
* stealing it there is how a palette becomes the thing that eats your search
531-
* box.
557+
* box. Pass `enabled: false` to stay out of the subscriber set.
532558
*/
533559
export function useCommandShortcut(onToggle: () => void, enabled = true): void {
560+
const onToggleRef = useRef(onToggle);
561+
onToggleRef.current = onToggle;
562+
534563
useEffect(() => {
535564
if (!enabled) return;
536-
const onKeyDown = (event: KeyboardEvent) => {
537-
if (
538-
event.key.toLowerCase() !== "k" ||
539-
!(event.metaKey || event.ctrlKey) ||
540-
event.repeat
541-
)
542-
return;
543-
const target = event.target as HTMLElement | null;
544-
if (
545-
target?.isContentEditable === true ||
546-
/^(input|textarea|select)$/i.test(target?.tagName ?? "")
547-
)
548-
return;
549-
event.preventDefault();
550-
onToggle();
565+
const subscriber = () => {
566+
onToggleRef.current();
567+
};
568+
commandShortcutSubscribers.add(subscriber);
569+
if (commandShortcutSubscribers.size === 1) {
570+
document.addEventListener("keydown", onCommandShortcutKeyDown);
571+
}
572+
return () => {
573+
commandShortcutSubscribers.delete(subscriber);
574+
if (commandShortcutSubscribers.size === 0) {
575+
document.removeEventListener("keydown", onCommandShortcutKeyDown);
576+
}
551577
};
552-
document.addEventListener("keydown", onKeyDown);
553-
return () => document.removeEventListener("keydown", onKeyDown);
554-
}, [onToggle, enabled]);
578+
}, [enabled]);
555579
}

0 commit comments

Comments
 (0)