Add new search - cmd + k

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-02-11 20:08:35 +00:00
parent 13422d70aa
commit 9b75c76a9e
6 changed files with 312 additions and 96 deletions
@@ -75,6 +75,10 @@ export const ColorFamily = () => {
the previous one, automatically incrementing the background depth.
This creates clear visual hierarchy without manually picking colors.
</p>
<p className={styles.description}>
Neutral 0 is the application background and should only be used once,
at the root of your app. All other surfaces build on top of it.
</p>
<p className={styles.description}>
Each level can be <strong>interactive</strong> or{' '}
<strong>non-interactive</strong>. A Card, for example, can be flat
@@ -0,0 +1,154 @@
.overlay {
position: fixed;
inset: 0;
z-index: 200;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
&[data-entering] {
animation: overlayFadeIn 200ms ease-out;
}
&[data-exiting] {
animation: overlayFadeOut 150ms ease-in;
}
}
@keyframes overlayFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes overlayFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.modal {
width: min(90vw, 500px);
max-height: min(70vh, 400px);
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
outline: none;
overflow: hidden;
&[data-entering] {
animation: modalScaleIn 200ms ease-out;
}
&[data-exiting] {
animation: modalScaleOut 150ms ease-in;
}
}
@keyframes modalScaleIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modalScaleOut {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.95) translateY(-8px);
}
}
.dialog {
display: flex;
flex-direction: column;
height: 100%;
max-height: min(70vh, 400px);
outline: none;
overflow: hidden;
}
.searchField {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.input {
width: 100%;
background: transparent;
border: none;
outline: none;
font-size: 1rem;
color: var(--primary);
font-family: inherit;
&::placeholder {
color: var(--secondary);
}
}
.menu {
flex: 1;
overflow-y: auto;
padding: 8px;
outline: none;
border: none;
}
.menuItem {
padding: 8px 12px;
border-radius: 8px;
color: var(--primary);
cursor: pointer;
font-size: 0.875rem;
outline: none;
&[data-focused] {
background-color: var(--action);
}
&[data-pressed] {
background-color: var(--action);
}
}
.empty {
padding: 24px 12px;
text-align: center;
color: var(--secondary);
font-size: 0.875rem;
}
@@ -0,0 +1,76 @@
'use client';
import {
Autocomplete,
Dialog,
Input,
Label,
Menu,
MenuItem,
Modal,
ModalOverlay,
SearchField,
useFilter,
} from 'react-aria-components';
import { useRouter } from 'next/navigation';
import { components } from '@/utils/data';
import styles from './CommandPalette.module.css';
interface CommandPaletteProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
}
const items = components.map(c => ({ id: c.slug, ...c }));
export const CommandPalette = ({
isOpen,
onOpenChange,
}: CommandPaletteProps) => {
const router = useRouter();
const { contains } = useFilter({ sensitivity: 'base' });
return (
<ModalOverlay
isOpen={isOpen}
onOpenChange={onOpenChange}
isDismissable
className={styles.overlay}
>
<Modal className={styles.modal}>
<Dialog aria-label="Command palette" className={styles.dialog}>
<Autocomplete filter={contains}>
<SearchField
autoFocus
aria-label="Search components"
className={styles.searchField}
>
<Label className={styles.srOnly}>Search</Label>
<Input
placeholder="Search components..."
className={styles.input}
/>
</SearchField>
<Menu
items={items}
className={styles.menu}
onAction={key => {
router.push(`/components/${key}`);
onOpenChange(false);
}}
renderEmptyState={() => (
<div className={styles.empty}>No components found.</div>
)}
>
{item => (
<MenuItem id={item.slug} className={styles.menuItem}>
{item.title}
</MenuItem>
)}
</Menu>
</Autocomplete>
</Dialog>
</Modal>
</ModalOverlay>
);
};
@@ -0,0 +1 @@
export { CommandPalette } from './CommandPalette';
@@ -10,12 +10,10 @@
}
}
.breadcrumb {
.left {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
}
.logoMobile {
@@ -26,39 +24,6 @@
}
}
.breadcrumbDesktop {
display: none;
@media (min-width: 768px) {
display: flex;
align-items: center;
gap: 0.5rem;
}
}
.breadcrumbLink {
color: var(--secondary);
text-decoration: none;
cursor: pointer;
&:hover {
color: var(--primary);
text-decoration: underline;
transition: color 0.2s ease-in-out;
text-decoration-thickness: 1px;
text-underline-offset: 4px;
}
}
.breadcrumbSeparator {
color: var(--secondary);
flex-shrink: 0;
}
.breadcrumbCurrent {
color: var(--primary);
}
.actions {
display: none;
@@ -119,6 +84,53 @@
}
}
.searchButton {
display: none;
align-items: center;
gap: 6px;
background-color: var(--bg);
border: 1px solid var(--border);
border-radius: 32px;
padding-inline: 16px 12px;
min-width: 180px;
color: var(--secondary);
font-size: 0.8125rem;
font-weight: 500;
height: 32px;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
&:hover {
background-color: var(--action);
color: var(--primary);
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
}
@media (min-width: 768px) {
display: flex;
}
}
.searchLabel {
display: none;
flex: 1;
text-align: left;
display: inline;
}
.searchKbd {
display: none;
font-family: inherit;
font-size: 0.6875rem;
font-weight: 500;
background-color: var(--action);
border-radius: 4px;
padding: 2px 5px;
color: var(--secondary);
display: inline;
}
.buttonGroup {
display: flex;
align-items: center;
+29 -60
View File
@@ -1,10 +1,11 @@
'use client';
import { useState, useEffect } from 'react';
import {
RiArrowDownSLine,
RiArrowRightSLine,
RiGithubLine,
RiMoonLine,
RiSearchLine,
RiSunLine,
} from '@remixicon/react';
import {
@@ -19,10 +20,8 @@ import {
} from 'react-aria-components';
import styles from './Toolbar.module.css';
import { usePlayground } from '@/utils/playground-context';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { components } from '@/utils/data';
import { Logo } from '@/components/Sidebar/Logo';
import { CommandPalette } from '@/components/CommandPalette';
interface ToolbarProps {
version: string;
@@ -42,70 +41,36 @@ export const Toolbar = ({ version }: ToolbarProps) => {
setSelectedThemeName,
} = usePlayground();
const pathname = usePathname();
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
// Determine breadcrumb content based on current path
const getBreadcrumb = () => {
const allComponents = components;
useEffect(() => {
const isMac = /mac(os|intosh)/i.test(navigator.userAgent);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'k' && (isMac ? e.metaKey : e.ctrlKey)) {
e.preventDefault();
setIsCommandPaletteOpen(prev => !prev);
}
};
// Components index page
if (pathname === '/components') {
return { section: null, title: 'Components' };
}
// Component detail pages
if (pathname?.startsWith('/components/')) {
const slug = pathname.split('/components/')[1];
const component = allComponents.find(c => c.slug === slug);
return {
section: 'Components',
sectionLink: '/components',
title: component?.title || slug,
};
}
// Tokens page
if (pathname === '/tokens') {
return { section: null, title: 'Tokens' };
}
// Changelog page
if (pathname === '/changelog') {
return { section: null, title: 'Changelog' };
}
return { section: null, title: '' };
};
const breadcrumb = getBreadcrumb();
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<div className={styles.toolbar}>
<div className={styles.breadcrumb}>
<div className={styles.left}>
<div className={styles.logoMobile}>
<Logo />
</div>
<div className={styles.breadcrumbDesktop}>
{breadcrumb.section && breadcrumb.sectionLink ? (
<>
<Link
href={breadcrumb.sectionLink}
className={styles.breadcrumbLink}
>
{breadcrumb.section}
</Link>
<RiArrowRightSLine
size={16}
className={styles.breadcrumbSeparator}
/>
<span className={styles.breadcrumbCurrent}>
{breadcrumb.title}
</span>
</>
) : (
<span className={styles.breadcrumbCurrent}>{breadcrumb.title}</span>
)}
</div>
<button
type="button"
className={styles.searchButton}
onClick={() => setIsCommandPaletteOpen(true)}
>
<RiSearchLine size={14} />
<span className={styles.searchLabel}>Search</span>
<kbd className={styles.searchKbd}>K</kbd>
</button>
</div>
<div className={styles.actions}>
<Select
@@ -158,6 +123,10 @@ export const Toolbar = ({ version }: ToolbarProps) => {
</ToggleButton>
</ToggleButtonGroup>
</div>
<CommandPalette
isOpen={isCommandPaletteOpen}
onOpenChange={setIsCommandPaletteOpen}
/>
</div>
);
};