Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions preview/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ function Preview() {
<li className="panel-row">A framed list row</li>
<li className="panel-row">Rows share one padding</li>
</ul>
<details className="rounded-lg border border-line p-3">
<summary className="text-ink">A disclosure</summary>
<p className="mt-2 mb-0 text-sm text-muted">It opens with the same chevron as the rest of the interface.</p>
</details>
<p className="notice m-0">A notice explains something calmly.</p>
<p className="error-message m-0">Something went wrong. Try again.</p>
</div>
Expand Down
27 changes: 14 additions & 13 deletions src/components/Menu.tsx
Original file line number Diff line number Diff line change
@@ -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 } & (
Expand All @@ -23,16 +23,14 @@ export function Menu({
triggerLabel: string;
}) {
const Link = useLink();
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
const button = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
useDismiss(open, root, close);
const { close, open, panel, root, setOpen, trigger: button } = useAnchoredPanel<HTMLButtonElement, HTMLDivElement>(240, 'end');
const openedFromKeyboard = useRef(false);

useEffect(() => {
if (open) panel.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
}, [open]);
useLayoutEffect(() => {
if (!open) return;
const first = panel.current?.querySelector<HTMLElement>('[role="menuitem"]');
(openedFromKeyboard.current ? first : panel.current)?.focus({ preventScroll: true });
}, [open, panel]);

function move(event: KeyboardEvent<HTMLDivElement>) {
const options = [...(panel.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
Expand Down Expand Up @@ -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}
</button>
{open && (
<div aria-label={label} className="popover-panel menu-panel" onKeyDown={move} ref={panel} role="menu">
<div aria-label={label} className="popover-panel menu-panel" onKeyDown={move} popover="manual" ref={panel} role="menu" tabIndex={-1}>
{header}
{items.map((item) => {
const className = `menu-item ${item.separated ? 'border-t border-line' : ''}`;
Expand Down
6 changes: 3 additions & 3 deletions src/lib/useAnchoredPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useDismiss } from './useDismiss.js';
const gap = 6;
const margin = 8;

export function useAnchoredPanel<Trigger extends HTMLElement, Panel extends HTMLElement>(minWidth = 0) {
export function useAnchoredPanel<Trigger extends HTMLElement, Panel extends HTMLElement>(minWidth = 0, align: 'start' | 'end' = 'start') {
const [open, setOpen] = useState(false);
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<Trigger>(null);
Expand All @@ -17,7 +17,7 @@ export function useAnchoredPanel<Trigger extends HTMLElement, Panel extends HTML
const element = panel.current;
if (!anchor || !element) return;
const width = Math.min(Math.max(anchor.width, minWidth), window.innerWidth - margin * 2);
const left = Math.min(Math.max(anchor.left, margin), window.innerWidth - width - margin);
const left = Math.min(Math.max(align === 'end' ? anchor.right - width : anchor.left, margin), window.innerWidth - width - margin);
const below = window.innerHeight - anchor.bottom - gap - margin;
const above = anchor.top - gap - margin;
const height = element.scrollHeight;
Expand All @@ -27,7 +27,7 @@ export function useAnchoredPanel<Trigger extends HTMLElement, Panel extends HTML
element.style.maxHeight = `${Math.max(upwards ? above : below, 160)}px`;
element.style.top = upwards ? '' : `${anchor.bottom + gap}px`;
element.style.bottom = upwards ? `${window.innerHeight - anchor.top + gap}px` : '';
}, [minWidth]);
}, [align, minWidth]);

useLayoutEffect(() => {
const element = panel.current;
Expand Down
55 changes: 44 additions & 11 deletions src/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions test/popovers.spec.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
<Menu
items={[
{ label: 'Rename', onSelect: vi.fn() },
{ label: 'Delete', onSelect: vi.fn() },
]}
label="Actions"
trigger="..."
triggerLabel="Actions for Garden"
/>,
);
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();

Expand Down
Loading