Skip to content
Open
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
55 changes: 55 additions & 0 deletions documentation/demo/rtl-long-submenu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
currentMenu: rtl-long-submenu
---

# Demo: RTL menu with a long (overflowing) sub-menu

<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->


- [Example code](#example-code)
- [Example HTML](#example-html)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

<span class="context-menu-one btn btn-neutral">right click me</span>

## Example code

<script type="text/javascript" class="showcase">
$(function(){
/**************************************************
* Combined regression coverage for `direction: 'rtl'`
* (#742) together with a sub-menu taller than the
* viewport (#752): the sub-menu must still open on
* the LEFT of its parent item (RTL) AND be detached,
* capped to the viewport and scrollable, exactly like
* the plain LTR case in long-submenu-short-root.md.
**************************************************/
var subItems = {};
for (var i = 1; i <= 40; i++) {
subItems['sub-key' + i] = {name: 'Sub item ' + i};
}

$.contextMenu({
selector: '.context-menu-one',
direction: 'rtl',
callback: function(key, options) {
var m = "clicked: " + key;
window.console && console.log(m) || alert(m);
},
items: {
item1: {name: 'Item 1'},
item2: {name: 'Item 2'},
fold1: {
name: 'Long sub group',
items: subItems
}
}
});
});
</script>

## Example HTML
<div style="display:none;" class="showcase" data-showcase-import=".context-menu-one"></div>
29 changes: 29 additions & 0 deletions documentation/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ title: jQuery contextMenu — Documentation
- [zIndex](#zindex)
- [className](#classname)
- [classNames](#classnames)
- [direction](#direction)
- [animation](#animation)
- [events](#events)
- [position](#position)
Expand Down Expand Up @@ -284,6 +285,34 @@ var options = {
}
```

### direction

Specifies the text direction of the menu. Set to `'rtl'` for right-to-left languages (e.g. Arabic, Hebrew). This adds a `context-menu-rtl` class to the menu and its sub-menus, which right-aligns text and mirrors icon and submenu-arrow placement, and flips the side sub-menus open on so they expand to the *left* of their parent item instead of the right.

`direction`: `string` default: `'ltr'`

Value | Description
---- | ----
`ltr` | Left-to-right (default)
`rtl` | Right-to-left

#### Example
```javascript
$.contextMenu({
selector: 'span.context-menu',
direction: 'rtl',
items: {
edit: {name: 'Edit'},
more: {
name: 'More',
items: {
foo: {name: 'Foo'}
}
}
}
});
```


### animation

Expand Down
48 changes: 42 additions & 6 deletions src/jquery.contextMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@
//ability to select submenu
selectableSubMenu: false,

// text direction of the menu, use 'rtl' for right-to-left languages.
// adds a `context-menu-rtl` class to the menu and flips the side
// sub-menus open on.
direction: 'ltr',

// Default classname configuration to be able avoid conflicts in frameworks
classNames: {
hover: 'context-menu-hover', // Item hover
Expand Down Expand Up @@ -247,6 +252,10 @@
// call positionSubmenu after promise is completed.
return;
}
// in 'rtl' mode sub-menus open to the left of their parent
// item instead of the right (see op.create / opt.direction).
var root = $menu.data('contextMenuRoot'),
isRtl = !!root && root.direction === 'rtl';

// 'top' (used further down to position a detached sub-menu)
// places the element's margin edge, not its border edge, so
Expand Down Expand Up @@ -278,8 +287,12 @@
// considered settled for the rest of this show cycle; it's
// only redone in op.reattachSubmenus(), right before the next
// time the root menu is (re)activated.
//
// This is independent of which side (left/right) the
// sub-menu opens on - the RTL/LTR open-side decision below
// only affects horizontal placement, not whether/how a
// too-tall sub-menu gets detached and capped.
if (!$menu.hasClass('context-menu-detached') && (preciseOuterHeight($menu) || $menu.height()) > $win.height()) {
var root = this.data('contextMenuRoot');
if (root) {
root._detachedSubmenus = root._detachedSubmenus || [];
$menu
Expand Down Expand Up @@ -325,11 +338,24 @@
// see preciseOuterHeight() for why this can't just be
// $menu.outerHeight()
menuHeight = preciseOuterHeight($menu) || $menu.height(),
left = itemOffset.left + this.outerWidth() - 5,
left = isRtl ?
itemOffset.left - menuWidth + 5 :
itemOffset.left + this.outerWidth() - 5,
top = itemOffset.top - 9,
maxTop = $win.scrollTop() + $win.height() - menuHeight - marginTop;

if (left + menuWidth > $win.scrollLeft() + $win.width()) {
if (isRtl) {
if (left < $win.scrollLeft()) {
// doesn't fit to the left of the item, flip to the right
left = itemOffset.left + this.outerWidth() - 5;
// ...and if it doesn't fit there either (viewport
// narrower than the menu), clamp against the
// right edge rather than leaving it hanging off.
if (left + menuWidth > $win.scrollLeft() + $win.width()) {
left = $win.scrollLeft() + $win.width() - menuWidth;
}
}
Comment on lines +347 to +357

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp flipped RTL submenus against the right viewport edge

When a detached RTL submenu cannot fit to the left of an opener near the viewport's left edge, this branch flips it to the right but never checks whether left + menuWidth now exceeds the viewport's right edge. If neither side has enough room, the submenu therefore remains partially off-screen instead of receiving the edge-clamping behavior described here; clamp the flipped result against the right boundary before applying the existing left clamp.

Useful? React with 👍 / 👎.

} else if (left + menuWidth > $win.scrollLeft() + $win.width()) {
// doesn't fit to the right of the item, flip to the left
left = itemOffset.left - menuWidth + 5;
}
Expand All @@ -350,14 +376,17 @@
// .position() is provided as a jQuery UI utility
// (...and it won't work on hidden elements)
$menu.css('display', 'block').position({
my: 'left top-5',
at: 'right top',
my: isRtl ? 'right top-5' : 'left top-5',
at: isRtl ? 'left top' : 'right top',
of: this,
collision: 'flipfit fit'
}).css('display', '');
} else {
// determine contextMenu position
var offset = {
var offset = isRtl ? {
top: -9,
left: -(($menu.outerWidth() || $menu.width()) - 5)
} : {
top: -9,
left: this.outerWidth() - 5
};
Expand Down Expand Up @@ -1434,6 +1463,13 @@
'contextMenu': opt,
'contextMenuRoot': root
});
if (root.direction === 'rtl') {
// applied to every menu/sub-menu, not just the root, so
// detached sub-menus (see op.detachSubmenus) that get
// moved out to <body> keep the styling even though
// they're no longer a CSS descendant of the root menu.
opt.$menu.addClass('context-menu-rtl');
}
if(opt.dataAttr){
$.each(opt.dataAttr, function (key, item) {
opt.$menu.attr('data-' + opt.key, item);
Expand Down
41 changes: 41 additions & 0 deletions src/sass/jquery.contextMenu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,44 @@
.context-menu-accesskey {
text-decoration: underline;
}

/**
* RTL support (see the `direction` option in jquery.contextMenu.js).
* Mirrors text alignment, icon placement and the submenu-arrow/opening side.
* Applied to every menu/sub-menu (not just the root) so detached sub-menus
* (see op.detachSubmenus, #775) keep the styling even when they're moved
* out of the root menu's DOM subtree.
*/
.context-menu-list.context-menu-rtl {
direction: rtl;
text-align: right;
}

.context-menu-rtl .context-menu-icon::before {
left: auto;
right: 0;
}

.context-menu-rtl .context-menu-icon--fa5 i,
.context-menu-rtl .context-menu-icon--fa5 svg {
left: auto;
right: 0.5em;
}

.context-menu-rtl .context-menu-input > label > input[type="checkbox"],
.context-menu-rtl .context-menu-input > label > input[type="radio"] {
margin-right: 0;
margin-left: .4em;
}

.context-menu-rtl .context-menu-submenu:after {
right: auto;
left: .5em;
border-width: .25em .25em .25em 0;
border-color: transparent $context-menu-submenu-arrow-color transparent transparent;
}

.context-menu-item > .context-menu-list.context-menu-rtl {
right: auto;
left: -.3em;
}
56 changes: 56 additions & 0 deletions test/specs/rtl-overflow-submenu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const { test, expect } = require('@playwright/test');
const { fixture, expectAlert } = require('../support/helpers');

// Regression test for the combination of `direction: 'rtl'` (#742) and a
// sub-menu taller than the viewport (#752): these two features both modify
// positionSubmenu() in src/jquery.contextMenu.js (see the merge commit that
// combined them), so this exercises them together rather than trusting that
// each one working in isolation (covered separately by
// test/unit/direction-rtl.test.js and test/specs/overflow-submenu-only.js)
// means the combined logic is also correct.
test.describe('Test direction: "rtl" together with an overflowing sub-menu (#742 + #752)', () => {
test.use({ viewport: { width: 1000, height: 500 } });

test('an overflowing sub-menu still opens to the left in rtl mode, and is capped/scrollable', async ({ page }) => {
await page.goto(fixture('rtl-long-submenu.html'));
await page.click('.context-menu-one', { button: 'right' });

const menu = page.locator('.context-menu-root');
await expect(menu).toBeVisible();
await expect(menu).toHaveClass(/context-menu-rtl/);

const opener = page.locator('span:text-is("Long sub group")');
const openerBox = await opener.boundingBox();

await opener.hover();

const submenu = page.locator('.context-menu-list:not(.context-menu-root)');
await expect(submenu).toBeVisible();
await expect(submenu).toHaveClass(/context-menu-rtl/);

const submenuBox = await submenu.boundingBox();

// rtl: the sub-menu should open to the LEFT of its opener, not the right
expect(submenuBox.x).toBeLessThanOrEqual(openerBox.x);

// ...and, despite opening on a different side, it must still be capped
// to the viewport height and made scrollable rather than clipped
// (the same #752 behaviour as the plain ltr case)
const viewportHeight = page.viewportSize().height;
expect(submenuBox.y + submenuBox.height).toBeLessThanOrEqual(viewportHeight);

// the last item must actually be reachable via the sub-menu's own
// internal scroll container
await submenu.evaluate((el) => {
el.scrollTop = el.scrollHeight;
});

const lastItem = page.locator('span:text-is("Sub item 40")');
await expect(lastItem).toBeInViewport();
await expectAlert(
page,
() => lastItem.click(),
'clicked: sub-key40'
);
});
});
Loading
Loading