From 9f70b3fbfab695ca68d4575d6d71c86e27b9e825 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:06:55 +0300 Subject: [PATCH] fix: menus open like the other popovers, themed disclosures and a quiet sidebar scrollbar (0.4.2) --- README.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- preview/main.tsx | 4 +++ src/components/Menu.tsx | 27 +++++++++--------- src/lib/useAnchoredPanel.ts | 6 ++-- src/theme.css | 55 +++++++++++++++++++++++++++++-------- test/popovers.spec.tsx | 24 ++++++++++++++++ 8 files changed, 93 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 78fb191..1047f57 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ router: apps pass their own link component in through `UiProvider`. Install a release by URL. No registry account or token is needed: ```sh -npm install --save-exact https://github.com/project-graphite/graphite-ui/releases/download/v0.4.1/project-graphite-ui-0.4.1.tgz +npm install --save-exact https://github.com/project-graphite/graphite-ui/releases/download/v0.4.2/project-graphite-ui-0.4.2.tgz ``` Load the theme after Tailwind. `@source` lets Tailwind see the classes the components use, and its diff --git a/package-lock.json b/package-lock.json index dad1674..45a6d72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@project-graphite/ui", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@project-graphite/ui", - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "devDependencies": { "@tailwindcss/vite": "4.3.3", diff --git a/package.json b/package.json index 1376fed..816a940 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@project-graphite/ui", - "version": "0.4.1", + "version": "0.4.2", "description": "Project Graphite design tokens, theme and React components.", "license": "MIT", "repository": { diff --git a/preview/main.tsx b/preview/main.tsx index 0ad18f7..93c1bc3 100644 --- a/preview/main.tsx +++ b/preview/main.tsx @@ -263,6 +263,10 @@ function Preview() {
  • A framed list row
  • Rows share one padding
  • +
    + A disclosure +

    It opens with the same chevron as the rest of the interface.

    +

    A notice explains something calmly.

    Something went wrong. Try again.

    diff --git a/src/components/Menu.tsx b/src/components/Menu.tsx index 601153a..2d9b1c5 100644 --- a/src/components/Menu.tsx +++ b/src/components/Menu.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'; -import { useDismiss } from '../lib/useDismiss.js'; +import { useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from 'react'; +import { useAnchoredPanel } from '../lib/useAnchoredPanel.js'; import { useLink } from '../UiProvider.js'; export type MenuItem = { label: string; separated?: boolean } & ( @@ -23,16 +23,14 @@ export function Menu({ triggerLabel: string; }) { const Link = useLink(); - const [open, setOpen] = useState(false); - const root = useRef(null); - const button = useRef(null); - const panel = useRef(null); - const close = useCallback(() => setOpen(false), []); - useDismiss(open, root, close); + const { close, open, panel, root, setOpen, trigger: button } = useAnchoredPanel(240, 'end'); + const openedFromKeyboard = useRef(false); - useEffect(() => { - if (open) panel.current?.querySelector('[role="menuitem"]')?.focus(); - }, [open]); + useLayoutEffect(() => { + if (!open) return; + const first = panel.current?.querySelector('[role="menuitem"]'); + (openedFromKeyboard.current ? first : panel.current)?.focus({ preventScroll: true }); + }, [open, panel]); function move(event: KeyboardEvent) { const options = [...(panel.current?.querySelectorAll('[role="menuitem"]') ?? [])]; @@ -62,14 +60,17 @@ export function Menu({ aria-haspopup="menu" aria-label={triggerLabel} className={triggerClassName} - onClick={() => setOpen((current) => !current)} + onClick={(event) => { + openedFromKeyboard.current = event.detail === 0; + setOpen((current) => !current); + }} ref={button} type="button" > {trigger} {open && ( -
    +
    {header} {items.map((item) => { const className = `menu-item ${item.separated ? 'border-t border-line' : ''}`; diff --git a/src/lib/useAnchoredPanel.ts b/src/lib/useAnchoredPanel.ts index ca50fed..f5d9fee 100644 --- a/src/lib/useAnchoredPanel.ts +++ b/src/lib/useAnchoredPanel.ts @@ -4,7 +4,7 @@ import { useDismiss } from './useDismiss.js'; const gap = 6; const margin = 8; -export function useAnchoredPanel(minWidth = 0) { +export function useAnchoredPanel(minWidth = 0, align: 'start' | 'end' = 'start') { const [open, setOpen] = useState(false); const root = useRef(null); const trigger = useRef(null); @@ -17,7 +17,7 @@ export function useAnchoredPanel { const element = panel.current; diff --git a/src/theme.css b/src/theme.css index 60fd574..fbc3e15 100644 --- a/src/theme.css +++ b/src/theme.css @@ -75,6 +75,10 @@ scrollbar-color: var(--color-line) transparent; scrollbar-width: thin; } + + .sidebar-panel:not(:hover, :focus-within) { + scrollbar-color: transparent transparent; + } } ::-webkit-scrollbar { @@ -101,6 +105,10 @@ background-color: var(--color-muted); } +.sidebar-panel:not(:hover, :focus-within)::-webkit-scrollbar-thumb { + background-color: transparent; +} + body { margin: 0; min-width: 320px; @@ -209,6 +217,32 @@ body { padding-right: 2.5rem; } + summary { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + list-style: none; + } + + summary::-webkit-details-marker { + display: none; + } + + summary::before { + width: 1rem; + height: 1rem; + flex: none; + background: var(--color-muted); + content: ''; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'%3E%3Cpath d='m9 18 6-6-6-6' stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.8'/%3E%3C/svg%3E") center / contain no-repeat; + transition: rotate 160ms var(--ease-settle); + } + + details[open] > summary::before { + rotate: 90deg; + } + input::placeholder { color: var(--color-faint); } @@ -1060,12 +1094,6 @@ body { } @media (max-width: 39.99rem) { - .menu-panel { - position: fixed; - top: calc(var(--header-height) + 0.25rem); - right: 0.75rem; - } - .popover-list { position: fixed; top: calc(var(--header-height) + 0.25rem); @@ -1135,12 +1163,17 @@ body { } .menu-panel { - position: absolute; - top: calc(100% + 0.6rem); - right: 0; + position: fixed; + inset: auto; display: grid; - width: 15rem; - overflow: hidden; + align-content: start; + margin: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 0; + color: var(--color-ink); + outline: none; + animation: popover-enter 140ms var(--ease-settle); } .menu-item { diff --git a/test/popovers.spec.tsx b/test/popovers.spec.tsx index e381eca..32b511f 100644 --- a/test/popovers.spec.tsx +++ b/test/popovers.spec.tsx @@ -1,3 +1,4 @@ +import { act } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { Menu, Popover } from '../src/index.ts'; import { click, press, render } from './render.tsx'; @@ -34,6 +35,29 @@ describe('Menu', () => { expect(document.activeElement?.textContent).toBe('Sign out'); }); + it('opens from a pointer click without highlighting an item, and arrows start at the first', async () => { + const view = await render( + , + ); + const trigger = view.container.querySelector('button[aria-haspopup="menu"]')!; + await act(async () => { + trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 })); + }); + const menu = view.container.querySelector('[role="menu"]')!; + + expect(document.activeElement).toBe(menu); + await press(menu, 'ArrowDown'); + expect(document.activeElement?.textContent).toBe('Rename'); + }); + it('closes with Escape and returns focus to the trigger', async () => { const { container, trigger } = await openMenu();