From 9b75c76a9e5299614fda21564ba42be351d0dfbd Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 11 Feb 2026 20:08:35 +0000 Subject: [PATCH] Add new search - cmd + k Signed-off-by: Charles de Dreuille --- .../components/ColorFamily/ColorFamily.tsx | 4 + .../CommandPalette/CommandPalette.module.css | 154 ++++++++++++++++++ .../CommandPalette/CommandPalette.tsx | 76 +++++++++ .../src/components/CommandPalette/index.ts | 1 + .../src/components/Toolbar/Toolbar.module.css | 84 ++++++---- docs-ui/src/components/Toolbar/Toolbar.tsx | 89 ++++------ 6 files changed, 312 insertions(+), 96 deletions(-) create mode 100644 docs-ui/src/components/CommandPalette/CommandPalette.module.css create mode 100644 docs-ui/src/components/CommandPalette/CommandPalette.tsx create mode 100644 docs-ui/src/components/CommandPalette/index.ts diff --git a/docs-ui/src/components/ColorFamily/ColorFamily.tsx b/docs-ui/src/components/ColorFamily/ColorFamily.tsx index 03cf8be848..93e44c43ed 100644 --- a/docs-ui/src/components/ColorFamily/ColorFamily.tsx +++ b/docs-ui/src/components/ColorFamily/ColorFamily.tsx @@ -75,6 +75,10 @@ export const ColorFamily = () => { the previous one, automatically incrementing the background depth. This creates clear visual hierarchy without manually picking colors.

+

+ 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. +

Each level can be interactive or{' '} non-interactive. A Card, for example, can be flat diff --git a/docs-ui/src/components/CommandPalette/CommandPalette.module.css b/docs-ui/src/components/CommandPalette/CommandPalette.module.css new file mode 100644 index 0000000000..dbbdb72ceb --- /dev/null +++ b/docs-ui/src/components/CommandPalette/CommandPalette.module.css @@ -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; +} diff --git a/docs-ui/src/components/CommandPalette/CommandPalette.tsx b/docs-ui/src/components/CommandPalette/CommandPalette.tsx new file mode 100644 index 0000000000..bcc534372a --- /dev/null +++ b/docs-ui/src/components/CommandPalette/CommandPalette.tsx @@ -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 ( + + +

+ + + + + + { + router.push(`/components/${key}`); + onOpenChange(false); + }} + renderEmptyState={() => ( +
No components found.
+ )} + > + {item => ( + + {item.title} + + )} +
+
+
+ + + ); +}; diff --git a/docs-ui/src/components/CommandPalette/index.ts b/docs-ui/src/components/CommandPalette/index.ts new file mode 100644 index 0000000000..95c1305982 --- /dev/null +++ b/docs-ui/src/components/CommandPalette/index.ts @@ -0,0 +1 @@ +export { CommandPalette } from './CommandPalette'; diff --git a/docs-ui/src/components/Toolbar/Toolbar.module.css b/docs-ui/src/components/Toolbar/Toolbar.module.css index 581fc599b3..56b047447c 100644 --- a/docs-ui/src/components/Toolbar/Toolbar.module.css +++ b/docs-ui/src/components/Toolbar/Toolbar.module.css @@ -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; diff --git a/docs-ui/src/components/Toolbar/Toolbar.tsx b/docs-ui/src/components/Toolbar/Toolbar.tsx index 05757c4459..4a071bf45e 100644 --- a/docs-ui/src/components/Toolbar/Toolbar.tsx +++ b/docs-ui/src/components/Toolbar/Toolbar.tsx @@ -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 (
-
+
-
- {breadcrumb.section && breadcrumb.sectionLink ? ( - <> - - {breadcrumb.section} - - - - {breadcrumb.title} - - - ) : ( - {breadcrumb.title} - )} -
+