@gregogun/radial-menu 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +763 -0
- package/dist/modules/radialMenu/RadialMenu.d.ts +60 -0
- package/dist/modules/radialMenu/geometry.d.ts +47 -0
- package/dist/modules/radialMenu/shared/context.d.ts +6 -0
- package/dist/modules/radialMenu/shared/theme.d.ts +8 -0
- package/dist/modules/radialMenu/skin/RootRing.d.ts +29 -0
- package/dist/modules/radialMenu/skin/SubBand.d.ts +17 -0
- package/dist/modules/radialMenu/useAngleRouter.d.ts +45 -0
- package/dist/modules/radialMenu/wiring/WedgeItem.d.ts +46 -0
- package/dist/radial-menu.css +152 -0
- package/dist/types.d.ts +54 -0
- package/package.json +77 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ContextMenu } from '@base-ui/react/context-menu';
|
|
2
|
+
import { MenuItem, SubmenuLayout, SubsectionDirection } from '../../types';
|
|
3
|
+
/** Imperative handle exposed via `RadialMenu.Root`'s `actionsRef` — open the
|
|
4
|
+
* menu from outside any pointer gesture (e.g. a game event, a keybind). */
|
|
5
|
+
export interface RadialMenuActions {
|
|
6
|
+
/** Open centred on the viewport point (clientX, clientY). */
|
|
7
|
+
open: (x: number, y: number) => void;
|
|
8
|
+
/** Open centred on an element's bounding-box centre. */
|
|
9
|
+
openAtElement: (el: Element) => void;
|
|
10
|
+
/** Close the menu. */
|
|
11
|
+
close: () => void;
|
|
12
|
+
}
|
|
13
|
+
export interface RadialMenuRootProps {
|
|
14
|
+
/** The ordered radial items. Position = f(index, count): order IS placement —
|
|
15
|
+
* item 0 is the top wedge, the rest fan clockwise. A parent item carries a
|
|
16
|
+
* `submenu`; a leaf carries `onSelect`. */
|
|
17
|
+
items: MenuItem[];
|
|
18
|
+
/** Diameter of the root ring in px. Default 250. */
|
|
19
|
+
size?: number;
|
|
20
|
+
/** Submenu band layout: `"halved"` (band spans half a wedge per child) or
|
|
21
|
+
* `"full"` (a full wedge per child). Default "halved". */
|
|
22
|
+
layout?: SubmenuLayout;
|
|
23
|
+
/** Submenu fan direction. Default "cw". */
|
|
24
|
+
direction?: SubsectionDirection;
|
|
25
|
+
/** Submenu band thickness as a fraction of `size`. Default 0.3. */
|
|
26
|
+
submenuThickness?: number;
|
|
27
|
+
/** Gap between the root rim and the submenu band, in px. Default 8. */
|
|
28
|
+
ringGap?: number;
|
|
29
|
+
/** Dwell (ms) before a hovered submenu-parent opens. Default 280. */
|
|
30
|
+
openDelay?: number;
|
|
31
|
+
/** Controlled open state. Pair with `onOpenChange`. */
|
|
32
|
+
open?: boolean;
|
|
33
|
+
/** Uncontrolled initial open state. */
|
|
34
|
+
defaultOpen?: boolean;
|
|
35
|
+
/** Called when the menu opens or closes (after the internal highlight-gate
|
|
36
|
+
* reset runs). */
|
|
37
|
+
onOpenChange?: (open: boolean) => void;
|
|
38
|
+
/** Imperative handle for programmatic open/close (see RadialMenuActions). */
|
|
39
|
+
actionsRef?: React.Ref<RadialMenuActions>;
|
|
40
|
+
/** A `RadialMenu.Trigger` (the right-click target). Optional — omit for a
|
|
41
|
+
* purely programmatic menu driven via `actionsRef` / controlled `open`. */
|
|
42
|
+
children?: React.ReactNode;
|
|
43
|
+
}
|
|
44
|
+
export declare function RadialMenuRoot(props: RadialMenuRootProps): import("react/jsx-runtime").JSX.Element;
|
|
45
|
+
/**
|
|
46
|
+
* The right-click / long-press target. A thin wrapper over Base UI's
|
|
47
|
+
* `ContextMenu.Trigger` that registers its element with the surrounding
|
|
48
|
+
* `RadialMenu.Root` (for the imperative open path). Compose your own element via
|
|
49
|
+
* `render` (Base UI's pattern):
|
|
50
|
+
*
|
|
51
|
+
* <RadialMenu.Trigger render={(props) => <button {...props}>Menu</button>} />
|
|
52
|
+
*/
|
|
53
|
+
export interface RadialMenuTriggerProps extends React.ComponentProps<typeof ContextMenu.Trigger> {
|
|
54
|
+
}
|
|
55
|
+
export declare function RadialMenuTrigger(props: RadialMenuTriggerProps): import("react/jsx-runtime").JSX.Element;
|
|
56
|
+
/** Base-UI-style parts namespace: `<RadialMenu.Root>` + `<RadialMenu.Trigger>`. */
|
|
57
|
+
export declare const RadialMenu: {
|
|
58
|
+
Root: typeof RadialMenuRoot;
|
|
59
|
+
Trigger: typeof RadialMenuTrigger;
|
|
60
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { MenuItem, SubmenuLayout, SubsectionDirection } from '../../types';
|
|
2
|
+
export declare const ITEM_OFFSET = 90;
|
|
3
|
+
export declare const normalizeAngle: (angle: number) => number;
|
|
4
|
+
/** Point on a circle (screen degrees, y-down). */
|
|
5
|
+
export declare const polar: (cx: number, cy: number, r: number, deg: number) => [number, number];
|
|
6
|
+
/** SVG path for a bare arc at radius `r` from `a0` to `a1` screen degrees. */
|
|
7
|
+
export declare const arc: (cx: number, cy: number, r: number, a0: number, a1: number) => string;
|
|
8
|
+
/** SVG path for an annular sector (ring slice) from `a0` to `a1` screen degrees. */
|
|
9
|
+
export declare const annularSector: (cx: number, cy: number, innerR: number, outerR: number, a0: number, a1: number) => string;
|
|
10
|
+
/**
|
|
11
|
+
* Sample an annular-sector wedge into a `clip-path: polygon()` point list, in
|
|
12
|
+
* percentages of a `boxSize`-sided box. The arc need not be exact — this clips
|
|
13
|
+
* a wedge's hit/focus target; the crisp visual is the SVG path beneath. We walk
|
|
14
|
+
* the outer arc a0→a1, then the inner arc back a1→a0.
|
|
15
|
+
*/
|
|
16
|
+
export declare const wedgeClip: (boxSize: number, cx: number, cy: number, innerR: number, outerR: number, a0: number, a1: number) => string;
|
|
17
|
+
/** Resolved band placement for one open submenu (root SVG viewBox units). */
|
|
18
|
+
export interface SubBandGeometry {
|
|
19
|
+
count: number;
|
|
20
|
+
startAngle: number;
|
|
21
|
+
span: number;
|
|
22
|
+
subStep: number;
|
|
23
|
+
innerR: number;
|
|
24
|
+
outerR: number;
|
|
25
|
+
}
|
|
26
|
+
/** Whole-menu inputs the band derivation reads (count comes from `menuItems`). */
|
|
27
|
+
export interface BandLayout {
|
|
28
|
+
layout: SubmenuLayout;
|
|
29
|
+
direction: SubsectionDirection;
|
|
30
|
+
size: number;
|
|
31
|
+
ringGap: number;
|
|
32
|
+
submenuThickness: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Where the band for `parentIndex` sits: start angle, span, per-child step, and
|
|
36
|
+
* radii. `count` is the number of ROOT wedges (sets the base step); `childCount`
|
|
37
|
+
* the parent's children.
|
|
38
|
+
*/
|
|
39
|
+
export declare function bandGeometry(band: BandLayout & {
|
|
40
|
+
count: number;
|
|
41
|
+
}, parentIndex: number, childCount: number): SubBandGeometry;
|
|
42
|
+
/**
|
|
43
|
+
* Build the per-parent band lookup the router calls: given the menu's items +
|
|
44
|
+
* band layout, returns `(parentIndex) → SubBandGeometry | null` (null when the
|
|
45
|
+
* parent has no children).
|
|
46
|
+
*/
|
|
47
|
+
export declare function makeSubRadii(menuItems: MenuItem[], band: BandLayout): (parentIndex: number) => SubBandGeometry | null;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface RadialMenuContextValue {
|
|
2
|
+
/** Trigger calls this with its DOM element (or null on unmount). */
|
|
3
|
+
registerTrigger: (el: HTMLElement | null) => void;
|
|
4
|
+
}
|
|
5
|
+
export declare const RadialMenuContext: import('react').Context<RadialMenuContextValue | null>;
|
|
6
|
+
export declare function useRadialMenuContext(): RadialMenuContextValue;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const RADIAL: {
|
|
2
|
+
readonly c1: "var(--radial-1, #fcfcfd)";
|
|
3
|
+
readonly c2: "var(--radial-2, #f9f9fb)";
|
|
4
|
+
readonly c3: "var(--radial-3, #f0f0f3)";
|
|
5
|
+
readonly c4: "var(--radial-4, #e8e8ec)";
|
|
6
|
+
readonly c11: "var(--radial-11, #60646c)";
|
|
7
|
+
readonly c12: "var(--radial-12, #1c2024)";
|
|
8
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MenuItem } from '../../../types';
|
|
2
|
+
/** Root pie geometry in the SVG's local viewBox units. */
|
|
3
|
+
export interface RootGeometry {
|
|
4
|
+
/** viewBox side (== size). */
|
|
5
|
+
box: number;
|
|
6
|
+
/** Centre x/y (== size / 2). */
|
|
7
|
+
c: number;
|
|
8
|
+
/** Inner radius — the centre hub edge. */
|
|
9
|
+
innerR: number;
|
|
10
|
+
/** Outer radius — the rim (== size / 2). */
|
|
11
|
+
outerR: number;
|
|
12
|
+
/** Degrees per wedge (360 / count). */
|
|
13
|
+
step: number;
|
|
14
|
+
/** Icon-placement radius along the wedge bisector. */
|
|
15
|
+
iconR: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The root ring skin. `selected` is the highlighted wedge index (Base UI's
|
|
19
|
+
* activeIndex, mirrored up by the parent); `centerLabel` is the deepest active
|
|
20
|
+
* selection. `children` are the parent-supplied focusable hit-targets, layered
|
|
21
|
+
* above the visual skin inside the same SVG.
|
|
22
|
+
*/
|
|
23
|
+
export declare function RootRing({ menuItems, selected, centerLabel, geometry, children, }: {
|
|
24
|
+
menuItems: MenuItem[];
|
|
25
|
+
selected: number | null;
|
|
26
|
+
centerLabel: string | null;
|
|
27
|
+
geometry: RootGeometry;
|
|
28
|
+
children?: React.ReactNode;
|
|
29
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { MenuItem, SubmenuLayout, SubsectionDirection } from '../../../types';
|
|
2
|
+
/** One submenu band, drawn locally inside the SubmenuRoot's Popup. */
|
|
3
|
+
export declare function SubBand({ parent, parentIndex, count, layout, direction, size, ringGap, submenuThickness, captureChild, onActiveLabel, }: {
|
|
4
|
+
parent: MenuItem;
|
|
5
|
+
parentIndex: number;
|
|
6
|
+
/** Number of ROOT wedges (sets the base step the band fans out from). */
|
|
7
|
+
count: number;
|
|
8
|
+
layout: SubmenuLayout;
|
|
9
|
+
direction: SubsectionDirection;
|
|
10
|
+
size: number;
|
|
11
|
+
ringGap: number;
|
|
12
|
+
submenuThickness: number;
|
|
13
|
+
/** Register child j's focusable element with the router (in render order). */
|
|
14
|
+
captureChild: (j: number, el: HTMLElement | null) => void;
|
|
15
|
+
/** Report the highlighted child's label (or null) up for the centre disc. */
|
|
16
|
+
onActiveLabel: (label: string | null) => void;
|
|
17
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { MenuItem } from '../../types';
|
|
2
|
+
import { SubBandGeometry } from './geometry';
|
|
3
|
+
interface AngleRouterOptions {
|
|
4
|
+
menuItems: MenuItem[];
|
|
5
|
+
count: number;
|
|
6
|
+
/** Root SVG viewBox size (px); the cursor is mapped back into these units. */
|
|
7
|
+
box: number;
|
|
8
|
+
/** Centre hub radius — inside this is "cancel" (deselect + close). */
|
|
9
|
+
innerR: number;
|
|
10
|
+
/** Root rim radius — outside this, an open submenu owns the angle. */
|
|
11
|
+
outerR: number;
|
|
12
|
+
/** Degrees per root wedge (360 / count). */
|
|
13
|
+
step: number;
|
|
14
|
+
/** Dwell before a hovered submenu-parent opens (ms). */
|
|
15
|
+
openDelay: number;
|
|
16
|
+
/** Band geometry for a given parent index, or null if it has no children. */
|
|
17
|
+
subRadii: (parentIndex: number) => SubBandGeometry | null;
|
|
18
|
+
/** Currently-open submenu parent index (controlled), or null. */
|
|
19
|
+
openSub: number | null;
|
|
20
|
+
setOpenSub: (next: number | null) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Called on the first genuine pointer move that lands OUTSIDE the hub (i.e. on
|
|
23
|
+
* a wedge/band, not the centre-cancel zone). Lets the component arm its
|
|
24
|
+
* highlight gate so a stale reopen-highlight is suppressed until the user
|
|
25
|
+
* actually moves onto an item. A no-op once armed (cheap to call every move).
|
|
26
|
+
*/
|
|
27
|
+
onArm?: () => void;
|
|
28
|
+
/**
|
|
29
|
+
* Called when the pointer enters the centre hub (cancel zone) — clears the
|
|
30
|
+
* component's highlight directly. Needed because closing a submenu from the
|
|
31
|
+
* hub leaves Base UI's root activeIndex pinned on the trigger (focus was in
|
|
32
|
+
* the band, so no root focusout fires), which `blurActive` alone can't reset.
|
|
33
|
+
*/
|
|
34
|
+
onCancel?: () => void;
|
|
35
|
+
}
|
|
36
|
+
export interface AngleRouter {
|
|
37
|
+
/** Callback ref for the root SVG — attaches/detaches the global pointermove. */
|
|
38
|
+
attachRouter: (svg: SVGSVGElement | null) => void;
|
|
39
|
+
/** Root wedge hit-target elements, by item index (the JSX fills these). */
|
|
40
|
+
rootEls: React.MutableRefObject<(HTMLElement | null)[]>;
|
|
41
|
+
/** Open submenu child hit-target elements, by child index (the band fills these). */
|
|
42
|
+
subEls: React.MutableRefObject<(HTMLElement | null)[]>;
|
|
43
|
+
}
|
|
44
|
+
export declare function useAngleRouter(opts: AngleRouterOptions): AngleRouter;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { MenuItem } from '../../../types';
|
|
2
|
+
/** Props common to both wedge wrappers — the wedge's identity + hit geometry. */
|
|
3
|
+
interface WedgeBase {
|
|
4
|
+
item: MenuItem;
|
|
5
|
+
/** The wedge's clip-path polygon (full-box coords). */
|
|
6
|
+
clip: string;
|
|
7
|
+
/** Mirror Base UI's `highlighted` for this wedge up to the component. */
|
|
8
|
+
onHi: (hi: boolean) => void;
|
|
9
|
+
/** Register this wedge's focusable element with the router. */
|
|
10
|
+
captureRef: (el: HTMLElement | null) => void;
|
|
11
|
+
/** Full-box side length (the foreignObject spans the whole root SVG box). */
|
|
12
|
+
box: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A LEAF wedge: a `ContextMenu.Item` rendered as a wedge-clipped hit-target.
|
|
16
|
+
* Selecting it fires `item.onSelect` and (unless `closeOnSelect === false`)
|
|
17
|
+
* closes the menu.
|
|
18
|
+
*/
|
|
19
|
+
export declare function WedgeItem({ item, clip, onHi, captureRef, box }: WedgeBase): import("react/jsx-runtime").JSX.Element;
|
|
20
|
+
/**
|
|
21
|
+
* A SUBMENU wedge: the parent wedge is a `SubmenuTrigger`; its Popup hosts the
|
|
22
|
+
* band passed as `children` (the SubBand skin). The ANGLE-ROUTER owns open/close
|
|
23
|
+
* (dwell to open, leave-all/centre to close) via the controlled `open` prop, so:
|
|
24
|
+
* - opens are router-owned EXCEPT a genuine `trigger-press` (keyboard Enter/Space
|
|
25
|
+
* or a real click), which we honour so keyboard can open the submenu;
|
|
26
|
+
* - closes honour only real dismiss/select reasons (`escape-key` /
|
|
27
|
+
* `outside-press` / `item-press` / `close-press`), ignoring `sibling-open` and
|
|
28
|
+
* hover/focus/list-nav — the router's synthetic `mousemove`s wake Base UI's
|
|
29
|
+
* auto open/close, and `openSub` (the controlled prop) is the single source of
|
|
30
|
+
* truth for which submenu is open.
|
|
31
|
+
* The trigger wedge stays highlighted while its submenu is open (`highlighted ||
|
|
32
|
+
* open`): focus moves into the band, so `highlighted` alone goes false and the
|
|
33
|
+
* indicator would wrongly drop off the trigger.
|
|
34
|
+
*/
|
|
35
|
+
export declare function WedgeSubmenu({ item, index, clip, onHi, captureRef, box, open, onOpen, onClose, children, }: WedgeBase & {
|
|
36
|
+
index: number;
|
|
37
|
+
/** Whether this submenu is open (controlled by the router via `openSub`). */
|
|
38
|
+
open: boolean;
|
|
39
|
+
/** A genuine press opened it (keyboard/click) — open this submenu. */
|
|
40
|
+
onOpen: (index: number) => void;
|
|
41
|
+
/** A genuine dismiss/select closed it. */
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
/** The band skin (SubBand), composed into the submenu Popup. */
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/* @gregogun/radial-menu — default skin styles.
|
|
2
|
+
*
|
|
3
|
+
* Import ONCE in your app: import "@gregogun/radial-menu/styles.css";
|
|
4
|
+
*
|
|
5
|
+
* Theming is driven entirely by the `--radial-*` CSS variables below. They are
|
|
6
|
+
* the Radix `slate` scale by default (so the menu looks good out of the box with
|
|
7
|
+
* zero config), but you can override any of them on a parent selector to retheme.
|
|
8
|
+
* The numbers mirror Radix's slate steps; their roles in the menu:
|
|
9
|
+
* --radial-1 base / hub & gap discs (Radix slate 1)
|
|
10
|
+
* --radial-2 wedge fill (idle) (Radix slate 2)
|
|
11
|
+
* --radial-3 wedge fill (selected) + borders (Radix slate 3)
|
|
12
|
+
* --radial-4 ring strokes (Radix slate 4)
|
|
13
|
+
* --radial-11 icon color (idle) + indicator (Radix slate 11)
|
|
14
|
+
* --radial-12 icon / label color (active) (Radix slate 12)
|
|
15
|
+
*
|
|
16
|
+
* Dark mode applies automatically via `prefers-color-scheme`, AND under a
|
|
17
|
+
* `.dark` class (or `[data-theme="dark"]`) if you drive theme manually (e.g.
|
|
18
|
+
* next-themes). Override the vars yourself to opt out of either.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
:root {
|
|
22
|
+
--radial-1: #fcfcfd;
|
|
23
|
+
--radial-2: #f9f9fb;
|
|
24
|
+
--radial-3: #f0f0f3;
|
|
25
|
+
--radial-4: #e8e8ec;
|
|
26
|
+
--radial-11: #60646c;
|
|
27
|
+
--radial-12: #1c2024;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@media (prefers-color-scheme: dark) {
|
|
31
|
+
:root {
|
|
32
|
+
--radial-1: #111113;
|
|
33
|
+
--radial-2: #18191b;
|
|
34
|
+
--radial-3: #212225;
|
|
35
|
+
--radial-4: #272a2d;
|
|
36
|
+
--radial-11: #b0b4ba;
|
|
37
|
+
--radial-12: #edeef0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Manual dark theming (class- or data-attribute-driven) wins over the media
|
|
42
|
+
* query so a forced light theme in a dark OS still works, and vice versa. */
|
|
43
|
+
.dark,
|
|
44
|
+
[data-theme="dark"] {
|
|
45
|
+
--radial-1: #111113;
|
|
46
|
+
--radial-2: #18191b;
|
|
47
|
+
--radial-3: #212225;
|
|
48
|
+
--radial-4: #272a2d;
|
|
49
|
+
--radial-11: #b0b4ba;
|
|
50
|
+
--radial-12: #edeef0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* --- Popups (root menu + submenu band) ----------------------------------- */
|
|
54
|
+
/* Enter/exit are CSS transitions driven by Base UI's transition status. Base UI
|
|
55
|
+
* holds the unmount through the transition so the exit fade plays. */
|
|
56
|
+
.radial-popup,
|
|
57
|
+
.radial-band-popup {
|
|
58
|
+
transform-origin: center;
|
|
59
|
+
background: transparent;
|
|
60
|
+
outline: none;
|
|
61
|
+
transition: opacity 200ms ease-out, transform 200ms ease-out;
|
|
62
|
+
}
|
|
63
|
+
.radial-band-popup {
|
|
64
|
+
pointer-events: none;
|
|
65
|
+
}
|
|
66
|
+
.radial-popup[data-starting-style],
|
|
67
|
+
.radial-popup[data-ending-style],
|
|
68
|
+
.radial-band-popup[data-starting-style],
|
|
69
|
+
.radial-band-popup[data-ending-style] {
|
|
70
|
+
opacity: 0;
|
|
71
|
+
}
|
|
72
|
+
.radial-popup[data-starting-style],
|
|
73
|
+
.radial-band-popup[data-starting-style] {
|
|
74
|
+
transform: scale(0.6);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* --- SVG surfaces --------------------------------------------------------- */
|
|
78
|
+
.radial-svg,
|
|
79
|
+
.radial-band-svg {
|
|
80
|
+
position: absolute;
|
|
81
|
+
inset: 0;
|
|
82
|
+
overflow: visible;
|
|
83
|
+
}
|
|
84
|
+
.radial-svg {
|
|
85
|
+
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.08));
|
|
86
|
+
}
|
|
87
|
+
.radial-band-svg {
|
|
88
|
+
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.06));
|
|
89
|
+
}
|
|
90
|
+
@media (prefers-color-scheme: dark) {
|
|
91
|
+
.radial-svg {
|
|
92
|
+
filter: drop-shadow(0 12px 24px rgb(0 0 0 / 0.55));
|
|
93
|
+
}
|
|
94
|
+
.radial-band-svg {
|
|
95
|
+
filter: drop-shadow(0 8px 16px rgb(0 0 0 / 0.5));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
.dark .radial-svg,
|
|
99
|
+
[data-theme="dark"] .radial-svg {
|
|
100
|
+
filter: drop-shadow(0 12px 24px rgb(0 0 0 / 0.55));
|
|
101
|
+
}
|
|
102
|
+
.dark .radial-band-svg,
|
|
103
|
+
[data-theme="dark"] .radial-band-svg {
|
|
104
|
+
filter: drop-shadow(0 8px 16px rgb(0 0 0 / 0.5));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.radial-band-box {
|
|
108
|
+
position: relative;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* --- Wedges / icons ------------------------------------------------------- */
|
|
112
|
+
.radial-wedge {
|
|
113
|
+
transition: fill 150ms ease;
|
|
114
|
+
}
|
|
115
|
+
.radial-icon {
|
|
116
|
+
display: flex;
|
|
117
|
+
width: 100%;
|
|
118
|
+
height: 100%;
|
|
119
|
+
align-items: center;
|
|
120
|
+
justify-content: center;
|
|
121
|
+
user-select: none;
|
|
122
|
+
transition: color 200ms ease;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Focusable hit-target overlay (one per wedge): transparent, owns the pointer
|
|
126
|
+
* area, never visible. */
|
|
127
|
+
.radial-hit {
|
|
128
|
+
position: absolute;
|
|
129
|
+
inset: 0;
|
|
130
|
+
opacity: 0;
|
|
131
|
+
outline: none;
|
|
132
|
+
pointer-events: auto;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.radial-label {
|
|
136
|
+
user-select: none;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* foreignObject wrappers that only host visuals never take the pointer. */
|
|
140
|
+
.radial-no-pointer {
|
|
141
|
+
pointer-events: none;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* --- Reduced motion ------------------------------------------------------- */
|
|
145
|
+
@media (prefers-reduced-motion: reduce) {
|
|
146
|
+
.radial-popup,
|
|
147
|
+
.radial-band-popup,
|
|
148
|
+
.radial-wedge,
|
|
149
|
+
.radial-icon {
|
|
150
|
+
transition: none;
|
|
151
|
+
}
|
|
152
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
export interface MenuItem {
|
|
3
|
+
icon: ReactNode;
|
|
4
|
+
label: string;
|
|
5
|
+
submenu?: MenuItem[];
|
|
6
|
+
/**
|
|
7
|
+
* Fired when this leaf item is chosen (click / Enter / pointer-release on it).
|
|
8
|
+
* Items with a `submenu` open their children instead of selecting; only
|
|
9
|
+
* leaves call `onSelect`. The menu closes afterwards unless `closeOnSelect`
|
|
10
|
+
* is false.
|
|
11
|
+
*/
|
|
12
|
+
onSelect?: () => void;
|
|
13
|
+
/** Keep the menu open after selecting this item (default: close). */
|
|
14
|
+
closeOnSelect?: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* How wide each submenu segment is, relative to a base ring segment:
|
|
18
|
+
* - "halved": each child occupies half a base segment (so at most two children
|
|
19
|
+
* fit in one base segment), spanning childCount / 2 base segments.
|
|
20
|
+
* - "full": each child occupies a whole base segment, spanning childCount.
|
|
21
|
+
* Either way child 0 is anchored to the parent item's base segment.
|
|
22
|
+
*/
|
|
23
|
+
export type SubmenuLayout = "halved" | "full";
|
|
24
|
+
/**
|
|
25
|
+
* Which way a submenu's children fan out from the parent item's base segment:
|
|
26
|
+
* "cw" extends into clockwise (increasing-angle) base segments, "ccw" into
|
|
27
|
+
* counter-clockwise ones. Child 0 stays anchored to the parent slice either way.
|
|
28
|
+
*/
|
|
29
|
+
export type SubsectionDirection = "cw" | "ccw";
|
|
30
|
+
/**
|
|
31
|
+
* How the root menu is activated:
|
|
32
|
+
* - "fixed": always visible, centred in the viewport.
|
|
33
|
+
* - "area": hidden until you press inside a defined area; the menu then opens
|
|
34
|
+
* at the press point and tracks the cursor while held, closing on
|
|
35
|
+
* release.
|
|
36
|
+
*/
|
|
37
|
+
export type RadialMode = "fixed" | "area";
|
|
38
|
+
/** Tunable motion values for the submenu intro and selection indicator. */
|
|
39
|
+
export interface MotionConfig {
|
|
40
|
+
/** How far (px) segments slide outward from on intro. */
|
|
41
|
+
introOutward: number;
|
|
42
|
+
/** Per-segment delay (s) for the intro stagger. */
|
|
43
|
+
introStagger: number;
|
|
44
|
+
/** Intro slide duration (s). */
|
|
45
|
+
introDuration: number;
|
|
46
|
+
/** Thickness (px) of the selection arc just beyond the band's outer edge. */
|
|
47
|
+
indicatorRim: number;
|
|
48
|
+
/** Gap (px) between the segment's outer edge and the selection arc. */
|
|
49
|
+
indicatorGap: number;
|
|
50
|
+
/** Selection-arc spring damping. */
|
|
51
|
+
indicatorDamping: number;
|
|
52
|
+
/** Selection-arc spring stiffness. */
|
|
53
|
+
indicatorStiffness: number;
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gregogun/radial-menu",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "A radial (pie) context menu for React, built on Base UI — right-click / long-press or open programmatically, with angle-from-centre routing, nested submenus, and CSS-variable theming.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Greg Ogun",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": [
|
|
9
|
+
"*.css"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./styles.css": "./dist/radial-menu.css"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"react",
|
|
29
|
+
"radial-menu",
|
|
30
|
+
"pie-menu",
|
|
31
|
+
"context-menu",
|
|
32
|
+
"base-ui",
|
|
33
|
+
"menu",
|
|
34
|
+
"svg"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"dev": "vite",
|
|
38
|
+
"build": "tsc -b && vite build",
|
|
39
|
+
"build:lib": "vite build --config vite.lib.config.ts",
|
|
40
|
+
"lint": "eslint .",
|
|
41
|
+
"preview": "vite preview",
|
|
42
|
+
"test:e2e": "playwright test",
|
|
43
|
+
"test:e2e:bench": "playwright test --project=bench"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@base-ui/react": ">=1.5.0",
|
|
47
|
+
"motion": ">=12.0.0",
|
|
48
|
+
"react": ">=18",
|
|
49
|
+
"react-dom": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@axe-core/playwright": "^4.11.3",
|
|
53
|
+
"@base-ui/react": "^1.5.0",
|
|
54
|
+
"@eslint/js": "^9.17.0",
|
|
55
|
+
"@playwright/test": "^1.60.0",
|
|
56
|
+
"@radix-ui/colors": "3.0.0",
|
|
57
|
+
"@tailwindcss/vite": "^4.3.0",
|
|
58
|
+
"@types/react": "^18.3.18",
|
|
59
|
+
"@types/react-dom": "^18.3.5",
|
|
60
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
61
|
+
"eslint": "^9.17.0",
|
|
62
|
+
"eslint-plugin-react-hooks": "^5.0.0",
|
|
63
|
+
"eslint-plugin-react-refresh": "^0.4.16",
|
|
64
|
+
"globals": "^15.14.0",
|
|
65
|
+
"leva": "^0.10.1",
|
|
66
|
+
"motion": "12.0.5",
|
|
67
|
+
"next-themes": "^0.4.6",
|
|
68
|
+
"react": "^18.3.1",
|
|
69
|
+
"react-dom": "^18.3.1",
|
|
70
|
+
"react-icons": "5.4.0",
|
|
71
|
+
"tailwindcss": "^4.3.0",
|
|
72
|
+
"typescript": "~5.6.2",
|
|
73
|
+
"typescript-eslint": "^8.18.2",
|
|
74
|
+
"vite": "^6.0.5",
|
|
75
|
+
"vite-plugin-dts": "^5.0.2"
|
|
76
|
+
}
|
|
77
|
+
}
|