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
29 changes: 29 additions & 0 deletions .changeset/menu-context-menu-api-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@makeplane/propel": minor
---

Standardize the Menu and ContextMenu APIs and the generated props table.

Rows in both families (`MenuItem`, `MenuCheckboxItem`, `MenuRadioItem`, `MenuLinkItem`,
`MenuSubmenuTrigger` and their `ContextMenu*` counterparts) rename `tone` to `variant`, following
the button-family rework — one look axis, same value spellings, now defaulting to `neutral` so the
prop is optional. `MenuItemTone` / `ContextMenuItemTone` / `ContextMenuSubmenuTriggerTone` become
`MenuItemVariant` / `ContextMenuItemVariant` / `ContextMenuSubmenuTriggerVariant`. The rows'
`endContent` prop is renamed `trailing`, and the styled part `MenuItemEndContent` is renamed
`MenuItemTrailing`. The `elements` menu row `layout` axis renames its values from `default` /
`with-description` to `single` / `stacked`.

Fixes `MenuItem` dropping a consumer-supplied `render`: the ready-made hard-coded its own render
target after spreading props, so `<MenuItem render={<a href=… />} />` silently rendered a `<div>`.
The prop is now threaded into the styled row and typed to what it actually accepts. Every other
ready-made that hard-codes its render target (`MenuContent`, `MenuLabel`, `MenuSeparator`, the
remaining rows, and the `ContextMenu` equivalents) now omits `render` instead of advertising a prop
it ignores.

Corrects two wrong entries the props table was advertising: `MenuContent`'s `sizing` claimed an
`"anchor"` default it never applied (omitting it hugs the content), and `MenuSubmenuContent`
inherited `MenuContent`'s `side` default of `"bottom"` when it actually opens to the `"right"`.
`ContextMenuContent`'s positioning props are documented per position rather than with a single
default, since the same component anchors to the pointer at the root and beside its parent row in a
submenu. Every Menu and ContextMenu prop now carries a description and, where a real default
exists, a `@default` tag.
115 changes: 88 additions & 27 deletions apps/docs/src/components/PropsTable.astro
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
---
import type { PropItemType } from "react-docgen-typescript";
import type { PropItem, PropItemType } from "react-docgen-typescript";

import { getComponentDoc } from "~/lib/props-schema";

type Part = {
/** Path relative to `packages/propel/src`, e.g. `"components/menu/menu-item.tsx"`. */
source: string;
/** The exported component name to document, e.g. `"MenuItem"`. */
component: string;
/** Extra prop names to omit from this part's table, on top of the always-hidden set. */
hiddenProps?: string[];
};

type Props = {
Expand All @@ -14,6 +18,9 @@ type Props = {

const { parts } = Astro.props;

// Inherited React internals that appear on every component and describe none of them.
const ALWAYS_HIDDEN = ["key", "ref"];

// `shouldExtractLiteralValuesFromEnum` (props-schema.ts) puts a string-literal union's members in
// `type.value` (each `{ value: '"primary"' }`) while `type.name` stays the generic `"enum"` — join
// them back into the `"primary" | "secondary" | …` form so the table shows the real union, not the
Expand All @@ -24,44 +31,98 @@ function formatType(type: PropItemType): string {
}
return type.name;
}

// react-docgen-typescript reads the `@default` tag verbatim, so a string default arrives already
// quoted (`"start"`) and a boolean/number bare (`false`, `4`) — render it as-is rather than
// re-quoting by type, which would turn `4` into `"4"`.
function formatDefault(prop: PropItem): string | null {
const value = prop.defaultValue?.value;
return value == null || value === "" ? null : String(value);
}

// One shape for every table: the props a consumer MUST pass, then the ones they may, each group
// keeping its declaration order. Without this the order is whatever the intersection type happened
// to produce, which puts inherited props like `render` ahead of the component's own required props.
function orderProps(props: PropItem[]): PropItem[] {
return [...props.filter((prop) => prop.required), ...props.filter((prop) => !prop.required)];
}
---

<div class="flex flex-col gap-8">
{
parts.map(({ source, component }) => {
parts.map(({ source, component, hiddenProps = [] }) => {
const doc = getComponentDoc(source, component);
if (!doc || Object.keys(doc.props).length === 0) {
if (!doc) {
// A renamed or moved export silently produced an empty table before; surface it on the page
// so a broken reference cannot ship unnoticed.
console.warn(
`[PropsTable] No component doc found for "${component}" at source "${source}" — the props table will be empty. Check the source path (relative to packages/propel/src) and the exported component name.`,
`[PropsTable] No component doc found for "${component}" at source "${source}". Check the source path (relative to packages/propel/src) and the exported component name.`,
);
return (
<div class="rounded-lg border border-danger-subtle bg-danger-subtle p-4">
<p class="text-body-sm-regular text-danger-primary">
<span class="font-code">{component}</span> was not found at{" "}
<span class="font-code">{source}</span>. The export was probably renamed or moved —
update this page's <span class="font-code">parts</span> list.
</p>
</div>
);
}
const props = doc ? Object.values(doc.props) : [];

const hidden = new Set([...ALWAYS_HIDDEN, ...hiddenProps]);
const props = orderProps(Object.values(doc.props).filter((prop) => !hidden.has(prop.name)));

return (
<div>
<h3 class="mb-3 font-code text-body-sm-medium text-primary">{component}</h3>
<h3 class="mb-1 font-code text-body-sm-medium text-primary">{component}</h3>
{doc.description ? (
<p class="mb-3 max-w-2xl text-body-sm-regular text-secondary">{doc.description}</p>
) : null}
<div class="scrollbar-sm overflow-x-auto rounded-lg border border-subtle">
<table class="w-full border-collapse text-body-sm-regular">
<thead>
<tr class="border-b border-subtle text-left">
<th class="px-4 py-2 text-body-sm-medium text-secondary">Prop</th>
<th class="px-4 py-2 text-body-sm-medium text-secondary">Type</th>
<th class="px-4 py-2 text-body-sm-medium text-secondary">Required</th>
<th class="px-4 py-2 text-body-sm-medium text-secondary">Description</th>
</tr>
</thead>
<tbody>
{props.map((prop) => (
<tr class="border-b border-subtle last:border-0">
<td class="px-4 py-2 font-code text-13 text-primary">{prop.name}</td>
<td class="max-w-xs px-4 py-2 font-code text-13 text-secondary">
{formatType(prop.type)}
</td>
<td class="px-4 py-2 text-secondary">{prop.required ? "Yes" : "No"}</td>
<td class="px-4 py-2 text-secondary">{prop.description}</td>
{props.length === 0 ? (
<p class="px-4 py-3 text-body-sm-regular text-tertiary">
No component-specific props — accepts the standard HTML attributes for the element it
renders.
</p>
) : (
<table class="w-full border-collapse text-body-sm-regular">
<thead>
<tr class="border-b border-subtle bg-surface-1 text-left">
<th class="px-4 py-2 text-body-sm-medium text-secondary">Prop</th>
<th class="px-4 py-2 text-body-sm-medium text-secondary">Type</th>
<th class="px-4 py-2 text-body-sm-medium text-secondary">Default</th>
<th class="px-4 py-2 text-body-sm-medium text-secondary">Description</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{props.map((prop) => {
const defaultValue = formatDefault(prop);
return (
<tr class="border-b border-subtle last:border-0">
<td class="px-4 py-2 font-code text-13 whitespace-nowrap text-primary">
{prop.name}
{prop.required ? (
<>
<span aria-hidden="true" class="text-danger-primary">
*
</span>
<span class="sr-only">(required)</span>
</>
) : null}
</td>
<td class="max-w-xs px-4 py-2 font-code text-13 text-secondary">
{formatType(prop.type)}
</td>
<td class="px-4 py-2 font-code text-13 text-secondary">
{defaultValue ?? "—"}
</td>
<td class="px-4 py-2 text-secondary">{prop.description}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
Expand Down
11 changes: 4 additions & 7 deletions apps/docs/src/demos/context-menu/basic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,25 @@ export default function BasicDemo() {
<ContextMenuTrigger render={<div />}>Right-click here</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
tone="neutral"
icon={<Icon icon={Scissors} />}
aria-keyshortcuts="Meta+X"
endContent={<Shortcut keys="⌘X" />}
trailing={<Shortcut keys="⌘X" />}
label="Cut"
/>
<ContextMenuItem
tone="neutral"
icon={<Icon icon={Copy} />}
aria-keyshortcuts="Meta+C"
endContent={<Shortcut keys="⌘C" />}
trailing={<Shortcut keys="⌘C" />}
label="Copy"
/>
<ContextMenuItem
tone="neutral"
icon={<Icon icon={ClipboardPaste} />}
aria-keyshortcuts="Meta+V"
endContent={<Shortcut keys="⌘V" />}
trailing={<Shortcut keys="⌘V" />}
label="Paste"
/>
<ContextMenuSeparator />
<ContextMenuItem tone="danger" icon={<Icon icon={Trash2} />} label="Delete" />
<ContextMenuItem variant="danger" icon={<Icon icon={Trash2} />} label="Delete" />
</ContextMenuContent>
</ContextMenu>
);
Expand Down
11 changes: 2 additions & 9 deletions apps/docs/src/demos/context-menu/link-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,18 @@ export default function LinkItemsDemo() {
<ContextMenuTrigger render={<div />}>Right-click here</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLinkItem
tone="neutral"
icon={<Icon icon={ExternalLink} />}
href="#work-item"
target="_blank"
rel="noopener noreferrer"
label="Open in new tab"
/>
<ContextMenuLinkItem
tone="neutral"
icon={<Icon icon={Link2} />}
href="#activity"
label="Go to activity"
/>
<ContextMenuLinkItem icon={<Icon icon={Link2} />} href="#activity" label="Go to activity" />
<ContextMenuSeparator />
<ContextMenuItem
tone="neutral"
icon={<Icon icon={Copy} />}
aria-keyshortcuts="Meta+C"
endContent={<Shortcut keys="⌘C" />}
trailing={<Shortcut keys="⌘C" />}
label="Copy link"
/>
</ContextMenuContent>
Expand Down
15 changes: 2 additions & 13 deletions apps/docs/src/demos/context-menu/selection-rows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,15 @@ export default function SelectionRowsDemo() {
<ContextMenuTrigger render={<div />}>Right-click for view options</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuCheckboxItem
tone="neutral"
checked={wrap}
onCheckedChange={setWrap}
closeOnClick={false}
label="Wrap titles"
/>
<ContextMenuSeparator />
<ContextMenuRadioGroup value={sort} onValueChange={setSort}>
<ContextMenuRadioItem
tone="neutral"
value="manual"
closeOnClick={false}
label="Manual order"
/>
<ContextMenuRadioItem
tone="neutral"
value="updated"
closeOnClick={false}
label="Last updated"
/>
<ContextMenuRadioItem value="manual" closeOnClick={false} label="Manual order" />
<ContextMenuRadioItem value="updated" closeOnClick={false} label="Last updated" />
</ContextMenuRadioGroup>
</ContextMenuContent>
</ContextMenu>
Expand Down
18 changes: 7 additions & 11 deletions apps/docs/src/demos/context-menu/submenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,18 @@ export default function SubmenuDemo() {
<ContextMenu>
<ContextMenuTrigger render={<div />}>Right-click here</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem tone="neutral" icon={<Icon icon={PencilLine} />} label="Rename" />
<ContextMenuItem tone="neutral" icon={<Icon icon={Copy} />} label="Duplicate" />
<ContextMenuItem icon={<Icon icon={PencilLine} />} label="Rename" />
<ContextMenuItem icon={<Icon icon={Copy} />} label="Duplicate" />
<ContextMenuSubmenu>
<ContextMenuSubmenuTrigger
tone="neutral"
icon={<Icon icon={FolderInput} />}
label="Move to"
/>
<ContextMenuSubmenuTrigger icon={<Icon icon={FolderInput} />} label="Move to" />
<ContextMenuContent>
<ContextMenuItem tone="neutral" label="Mobile app" />
<ContextMenuItem tone="neutral" label="Web app" />
<ContextMenuItem tone="neutral" label="Design system" />
<ContextMenuItem label="Mobile app" />
<ContextMenuItem label="Web app" />
<ContextMenuItem label="Design system" />
</ContextMenuContent>
</ContextMenuSubmenu>
<ContextMenuSeparator />
<ContextMenuItem tone="danger" icon={<Icon icon={Trash2} />} label="Delete" />
<ContextMenuItem variant="danger" icon={<Icon icon={Trash2} />} label="Delete" />
</ContextMenuContent>
</ContextMenu>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/demos/dialog/open-from-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export default function OpenFromMenuDemo() {
<MenuItem icon={<Icon icon={Link2} tint="secondary" />} label="Copy link" />
<MenuSeparator />
<MenuItem
tone="danger"
variant="danger"
icon={<Icon icon={Trash2} />}
onClick={() => setOpen(true)}
label="Delete project…"
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/demos/home/members-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export default function MembersTableDemo() {
<TableActionCell aria-label={`Options for ${person.name}`}>
<MenuContent>
<MenuItem icon={<Icon icon={Pencil} tint="secondary" />} label="Edit" />
<MenuItem tone="danger" icon={<Icon icon={Trash2} />} label="Delete" />
<MenuItem variant="danger" icon={<Icon icon={Trash2} />} label="Delete" />
</MenuContent>
</TableActionCell>
</TableRow>
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/demos/menu/basic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default function BasicDemo() {
<MenuItem icon={<Icon icon={Copy} tint="secondary" />} label="Make a copy" />
<MenuItem icon={<Icon icon={ExternalLink} tint="secondary" />} label="Open in new tab" />
<MenuSeparator />
<MenuItem tone="danger" icon={<Icon icon={Trash2} />} label="Delete" />
<MenuItem variant="danger" icon={<Icon icon={Trash2} />} label="Delete" />
</MenuContent>
</Menu>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/docs/src/demos/menu/submenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default function SubmenuDemo() {
<MenuContent sizing="sm">
<MenuSubmenu>
<MenuSubmenuTrigger
endContent={<Badge magnitude="sm" tone="neutral" label="4" />}
trailing={<Badge magnitude="sm" tone="neutral" label="4" />}
label="Priority"
/>
<MenuSubmenuContent sizing="sm">
Expand Down Expand Up @@ -62,7 +62,7 @@ export default function SubmenuDemo() {
</MenuSubmenu>
<MenuSubmenu>
<MenuSubmenuTrigger
endContent={<Badge magnitude="sm" tone="neutral" label="5" />}
trailing={<Badge magnitude="sm" tone="neutral" label="5" />}
label="State"
/>
<MenuSubmenuContent sizing="sm">
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/demos/table/rich-rows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default function RichRowsDemo() {
<TableActionCell aria-label={`Options for ${person.name}`}>
<MenuContent>
<MenuItem icon={<Icon icon={Pencil} tint="secondary" />} label="Edit" />
<MenuItem tone="danger" icon={<Icon icon={Trash2} />} label="Delete" />
<MenuItem variant="danger" icon={<Icon icon={Trash2} />} label="Delete" />
</MenuContent>
</TableActionCell>
</TableRow>
Expand Down
11 changes: 10 additions & 1 deletion apps/docs/src/lib/props-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,17 @@ const propelTsconfig = resolve(propelRoot, "tsconfig.json");
const parser = withCustomConfig(propelTsconfig, {
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
// A prop propel declares itself is public API even when it SHADOWS a native attribute of the
// same name (`placeholder`, `value`, `type`). docgen collapses those into one entry whose
// `parent` is whichever declaration it saw first — @types/react — so a parent-only test drops
// them: `MenuSearch`'s documented `placeholder` was invisible this way. `declarations` lists
// every declaration site, so a prop survives when ANY of them is outside node_modules; the ~300
// purely-inherited DOM/React attributes have none and are still filtered.
propFilter: (prop) =>
prop.name === "children" || !prop.parent || !prop.parent.fileName.includes("node_modules"),
prop.declarations?.some((declaration) => !declaration.fileName.includes("node_modules")) ||
prop.name === "children" ||
!prop.parent ||
!prop.parent.fileName.includes("node_modules"),
});

// `parser.parse` rebuilds TypeScript program state and is expensive; several pages
Expand Down
Loading
Loading