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
16 changes: 6 additions & 10 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
@@ -1,37 +1,33 @@
name: React-contexify CI

on: [push, pull_request]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Install node
uses: actions/setup-node@v1
uses: actions/setup-node@v4
with:
node-version: '16.x'
node-version: '20.x'
- name: Install example dependencies
run: cd example && yarn
- name: Install dependencies & build
run: yarn
- name: Run cypress
uses: cypress-io/github-action@v4
uses: cypress-io/github-action@v6
with:
browser: chrome
start: yarn start
wait-on: 'http://localhost:1234'
- uses: actions/upload-artifact@v1
- uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots

- uses: actions/upload-artifact@v1
- uses: actions/upload-artifact@v4
if: always()
with:
name: cypress-videos
path: cypress/videos


3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ yarn-error.log*
.vscode
.cache
dist
*.log
*.log
cypress
11 changes: 6 additions & 5 deletions example/package.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
{
"name": "example",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "parcel --no-cache index.html",
"build": "parcel build index.html"
},
"dependencies": {
"react-app-polyfill": "^3.0.0"
"react": "19.2.1",
"react-app-polyfill": "^3.0.0",
"react-dom": "19.2.1"
},
"alias": {
"react": "../node_modules/react",
"react-dom": "../node_modules/react-dom"
},
"devDependencies": {
"@parcel/transformer-sass": "2.7.0",
"@parcel/transformer-sass": "2.16.3",
"@types/react": "^18.0.24",
"@types/react-dom": "^18.0.8",
"parcel": "^2.7.0",
"parcel": "^2.16.3",
"typescript": "^4.8.4"
}
}
}
2,437 changes: 1,125 additions & 1,312 deletions example/yarn.lock

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
"style2js": "style2js --out-dir dist dist/ReactContexify.min.css"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
"react": "^16 || ^17 || ^18 || ^19",
"react-dom": "^16 || ^17 || ^18 || ^19"
},
"prettier": {
"printWidth": 80,
Expand Down Expand Up @@ -66,15 +66,15 @@
"popup"
],
"devDependencies": {
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.8",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"cssnano": "^5.1.14",
"cssnano-cli": "^1.0.5",
"cypress": "^11.0.1",
"postcss": "^8.4.18",
"postcss-cli": "^10.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"sass": "^1.56.1",
"style2js": "^1.0.1",
"tsup": "^6.4.0",
Expand Down
4 changes: 2 additions & 2 deletions src/components/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { contextMenu } from '../core';

export interface ItemProps
extends InternalProps,
Omit<React.HTMLAttributes<HTMLElement>, 'hidden' | 'disabled' | 'onClick'> {
Omit<React.HTMLAttributes<HTMLElement>, 'hidden' | 'disabled' | 'onClick'> {
/**
* Any valid node that can be rendered
*/
Expand Down Expand Up @@ -135,7 +135,7 @@ export const Item: React.FC<ItemProps> = ({
handlerEvent = 'onClick',
...rest
}) => {
const itemNode = useRef<HTMLElement>();
const itemNode = useRef<HTMLElement>(null);
const itemTracker = useItemTrackerContext();
const handlerParams = {
id,
Expand Down
113 changes: 96 additions & 17 deletions src/components/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, {
ReactNode,
Ref,
useEffect,
useImperativeHandle,
useReducer,
useRef,
useState,
Expand All @@ -10,7 +12,7 @@ import cx from 'clsx';
import { ItemTrackerProvider } from './ItemTrackerProvider';

import { eventManager } from '../core/eventManager';
import { TriggerEvent, MenuId, MenuAnimation, Theme } from '../types';
import { TriggerEvent, MenuId, MenuAnimation, Theme, MenuOnShowCallback, MenuOnHideCallback, StyleMargin, StyleMarginObject } from '../types';
import { useItemTracker } from '../hooks';
import { createKeyboardController } from './keyboardController';
import { CssClass, EVENT, hideOnEvents } from '../constants';
Expand All @@ -20,6 +22,8 @@ import { ShowContextMenuParams } from '../core';

export interface MenuProps
extends Omit<React.HTMLAttributes<HTMLElement>, 'id'> {

ref?: Ref<HTMLDivElement>;
/**
* Unique id to identify the menu. Use to Trigger the corresponding menu
*/
Expand All @@ -37,6 +41,14 @@ export interface MenuProps
*/
theme?: Theme;

/**
* Apply a margin to the viewport for boundary detection. If a context menu
* would appear within this viewport margin it counts as being outside the
* viewport and moves within the margin limits.
* This only works if `disableBoundariesCheck` is not true.
*/
viewportMargin?: StyleMargin;

/**
* Animation is appended to
* - `.contexify_willEnter-${given animation}`
Expand All @@ -62,8 +74,35 @@ export interface MenuProps

/**
* Used to track menu visibility
* @deprecated -- May be removed in the next major version
* - For the false case use `onHide`.
* - For the true case use `onShow`.
* NOTE: `onShow` behaves slightly differently. `onVisibilityChange` would trigger
* with `isVisible=true` only when transitioning from hidden to shown, this meant
* if the same context menu was re-triggered from a new position the callback would
* not trigger, so there was no way to know if the menu moved. These new events
* trigger regardless of current state and mirror the context menu events. This
* means you can now get `onShow`, `onShow`, then `onHide` if you open a context menu,
* move it, then click away.
* For the same behaviour `onShow` has a `fromHidden` parameter which is only true
* if the menu was previously in a hidden state.
*/
onVisibilityChange?: (isVisible: boolean) => void;

/**
* Triggers when a show event is triggered. This triggers even if the menu
* is already shown.
* @param fromHidden - True if the menu was previously hidden.
* @param position - An object of `{x: number, y:number}` for the position the
* context menu is appearing.
*/
onShow?: MenuOnShowCallback;
/**
* Triggers when a hide event is triggered. This triggers even if the menu
* is already hidden.
* @param fromVisible - True if the menu was previously visible.
*/
onHide?: MenuOnHideCallback;
}

interface MenuState {
Expand All @@ -82,18 +121,22 @@ function reducer(
return { ...state, ...(isFn(payload) ? payload(state) : payload) };
}

export const Menu: React.FC<MenuProps> = ({
export const Menu = ({
id,
ref,
theme,
style,
className,
children,
animation = 'fade',
preventDefaultOnKeydown = true,
disableBoundariesCheck = false,
viewportMargin = 0,
onShow,
onHide,
onVisibilityChange,
...rest
}) => {
}: MenuProps) => {
const [state, setState] = useReducer(reducer, {
x: 0,
y: 0,
Expand All @@ -105,8 +148,18 @@ export const Menu: React.FC<MenuProps> = ({
const nodeRef = useRef<HTMLDivElement>(null);
const itemTracker = useItemTracker();
const [menuController] = useState(() => createKeyboardController());
const wasVisible = useRef<boolean>();
const visibilityId = useRef<number>();

const wasVisible = useRef<boolean>(false);
const viewPortMargin = useRef<StyleMarginObject>(typeof viewportMargin === 'number' ? { bottom: viewportMargin, top: viewportMargin, left: viewportMargin, right: viewportMargin } : viewportMargin)

// @deprecated -- NOTE: this is to keep backwards compatibility for onVisibilityChange
const wasVisibleDeprecated = useRef<boolean>(false);
// @deprecated
const visibilityId = useRef<number>(0);


// allows the caller to assign their own ref, which is then synced with nodeRef
useImperativeHandle(ref, () => nodeRef.current as HTMLDivElement);
Comment thread
Aerilym marked this conversation as resolved.

// subscribe event manager
useEffect(() => {
Expand All @@ -128,9 +181,14 @@ export const Menu: React.FC<MenuProps> = ({
const { innerWidth, innerHeight } = window;
const { offsetWidth, offsetHeight } = nodeRef.current;

if (x + offsetWidth > innerWidth) x -= x + offsetWidth - innerWidth;
const xMax = innerWidth - (viewPortMargin.current?.right ?? 0);
// const xMin = viewPortMargin.current.left;
const yMax = innerHeight - (viewPortMargin.current?.bottom ?? 0);
// const yMin = viewPortMargin.current.top;

if (x + offsetWidth > xMax) x -= x + offsetWidth - xMax;

if (y + offsetHeight > innerHeight) y -= y + offsetHeight - innerHeight;
if (y + offsetHeight > yMax) y -= y + offsetHeight - yMax;
}

return { x, y };
Expand Down Expand Up @@ -210,11 +268,23 @@ export const Menu: React.FC<MenuProps> = ({
});
});

clearTimeout(visibilityId.current);
if (!wasVisible.current && isFn(onVisibilityChange)) {
onVisibilityChange(true);

if (isFn(onShow)) {
onShow(!wasVisible.current, { x, y });
}

if (!wasVisible.current) {
wasVisible.current = true;
}
Comment thread
Aerilym marked this conversation as resolved.

// TODO: remove deprecated functionality
if (isFn(onVisibilityChange)) {
clearTimeout(visibilityId.current);
if (!wasVisibleDeprecated.current) {
onVisibilityChange(true);
Comment thread
Aerilym marked this conversation as resolved.
wasVisibleDeprecated.current = true;
}
}
}

function hide(e?: Event) {
Expand All @@ -232,13 +302,22 @@ export const Menu: React.FC<MenuProps> = ({
animation && (isStr(animation) || ('exit' in animation && animation.exit))
? setState((state) => ({ willLeave: state.visible }))
: setState((state) => ({
visible: state.visible ? false : state.visible,
}));
visible: state.visible ? false : state.visible,
}));

visibilityId.current = setTimeout(() => {
isFn(onVisibilityChange) && onVisibilityChange(false);
wasVisible.current = false;
});
if (isFn(onHide)) {
onHide(wasVisible.current);
}

wasVisible.current = false;

Comment thread
Aerilym marked this conversation as resolved.
// TODO: remove deprecated functionality
if (isFn(onVisibilityChange)) {
visibilityId.current = setTimeout(() => {
onVisibilityChange(false);
wasVisibleDeprecated.current = false;
});
}
}

function handleAnimationEnd() {
Expand Down Expand Up @@ -300,4 +379,4 @@ export const Menu: React.FC<MenuProps> = ({
)}
</ItemTrackerProvider>
);
};
}
14 changes: 10 additions & 4 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,10 @@ export type PredicateParams<Props = any, Data = any> = HandlerParams<
export interface ItemParams<Props = any, Data = any>
extends HandlerParams<Props, Data> {
event:
| React.MouseEvent<HTMLElement>
| React.TouchEvent<HTMLElement>
| React.KeyboardEvent<HTMLElement>
| KeyboardEvent;
| React.MouseEvent<HTMLElement>
| React.TouchEvent<HTMLElement>
| React.KeyboardEvent<HTMLElement>
| KeyboardEvent;
}

export interface InternalProps {
Expand Down Expand Up @@ -139,3 +139,9 @@ export type MenuAnimation =
| { enter: Animation | false; exit: Animation | false };

type Animation = BuiltInOrString<'fade' | 'scale' | 'flip' | 'slide'>;

export type Position = { x: number, y: number };
export type MenuOnShowCallback = (fromHidden: boolean, position: Position) => void;
export type MenuOnHideCallback = (fromVisible: boolean) => void;
export type StyleMarginObject = { top?: number, bottom?: number, left?: number, right?: number }
export type StyleMargin = number | StyleMarginObject
3 changes: 2 additions & 1 deletion tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export default defineConfig({
format: ['esm', 'cjs'],
sourcemap: true,
minify: true,
treeshake: true
treeshake: true,
external: ['react', 'react-dom', 'react/jsx-runtime'],
});
Loading