diff --git a/documentation/demo/rtl-long-submenu.md b/documentation/demo/rtl-long-submenu.md new file mode 100644 index 00000000..e0621832 --- /dev/null +++ b/documentation/demo/rtl-long-submenu.md @@ -0,0 +1,55 @@ +--- +currentMenu: rtl-long-submenu +--- + +# Demo: RTL menu with a long (overflowing) sub-menu + + + + + +- [Example code](#example-code) +- [Example HTML](#example-html) + + + +right click me + +## Example code + + + +## Example HTML + diff --git a/documentation/docs.md b/documentation/docs.md index 797fb7b2..1aab5662 100644 --- a/documentation/docs.md +++ b/documentation/docs.md @@ -22,6 +22,7 @@ title: jQuery contextMenu — Documentation - [zIndex](#zindex) - [className](#classname) - [classNames](#classnames) + - [direction](#direction) - [animation](#animation) - [events](#events) - [position](#position) @@ -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 diff --git a/src/jquery.contextMenu.js b/src/jquery.contextMenu.js index 34294b36..03d0b7b0 100644 --- a/src/jquery.contextMenu.js +++ b/src/jquery.contextMenu.js @@ -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 @@ -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 @@ -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 @@ -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; + } + } + } 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; } @@ -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 }; @@ -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 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); diff --git a/src/sass/jquery.contextMenu.scss b/src/sass/jquery.contextMenu.scss index 29280304..dfeb77a5 100644 --- a/src/sass/jquery.contextMenu.scss +++ b/src/sass/jquery.contextMenu.scss @@ -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; +} diff --git a/test/specs/rtl-overflow-submenu.js b/test/specs/rtl-overflow-submenu.js new file mode 100644 index 00000000..85988e10 --- /dev/null +++ b/test/specs/rtl-overflow-submenu.js @@ -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' + ); + }); +}); diff --git a/test/unit/direction-rtl.test.js b/test/unit/direction-rtl.test.js new file mode 100644 index 00000000..1e7e1647 --- /dev/null +++ b/test/unit/direction-rtl.test.js @@ -0,0 +1,138 @@ +// Karma's test page only loads the plugin's JS (see karma.conf.js), not its +// compiled CSS, so `.context-menu-list` never gets `position: absolute` from +// jquery.contextMenu.scss. Without that, the inline top/left the plugin sets +// via JS have no visual effect (a statically positioned element ignores +// top/left), which would make the positioning assertions below meaningless. +// Add just enough CSS ourselves to make position math observable. +$('').appendTo('head'); + +QUnit.module('direction (rtl support)', { + afterEach: function() { + $.contextMenu('destroy'); + var $fixture = $('#qunit-fixture'); + if ($fixture.length) { + $fixture.html(''); + } + } +}); + +function ensureFixture() { + var $fixture = $('#qunit-fixture'); + if ($fixture.length === 0) { + $('
').appendTo('body'); + $fixture = $('#qunit-fixture'); + } + return $fixture; +} + +QUnit.test('default (ltr) menu does not get the context-menu-rtl class', function(assert) { + var $fixture = ensureFixture(); + $fixture.append('right click me'); + + $.contextMenu({ + selector: '.context-menu-ltr-default', + items: { + copy: {name: 'Copy'} + } + }); + + $('.context-menu-ltr-default').contextMenu(); + + var $menu = $('.context-menu-ltr-default').data('contextMenu').$menu; + assert.notOk($menu.hasClass('context-menu-rtl'), 'root menu should not have the rtl class by default'); +}); + +QUnit.test('direction: "rtl" adds the context-menu-rtl class to the root menu and to sub-menus', function(assert) { + var $fixture = ensureFixture(); + $fixture.append('right click me'); + + $.contextMenu({ + selector: '.context-menu-rtl-trigger', + direction: 'rtl', + items: { + copy: {name: 'Copy'}, + more: { + name: 'More', + items: { + sub1: {name: 'Sub item'} + } + } + } + }); + + $('.context-menu-rtl-trigger').contextMenu(); + + var $rootMenu = $('.context-menu-rtl-trigger').data('contextMenu').$menu; + assert.ok($rootMenu.hasClass('context-menu-rtl'), 'root menu should have the context-menu-rtl class'); + + var $subMenu = $rootMenu.find('.context-menu-submenu').first().children('.context-menu-list'); + assert.equal($subMenu.length, 1, 'sanity check: sub-menu was found'); + assert.ok($subMenu.hasClass('context-menu-rtl'), 'sub-menu should also have the context-menu-rtl class'); +}); + +QUnit.test('direction: "rtl" opens sub-menus to the left of their parent item instead of the right', function(assert) { + // Show the root menu at an explicit, comfortably-inset position (well away + // from the viewport edges) so the jQuery UI position "flipfit" collision + // handling doesn't override the intended open side just because one side + // happens to be short on space - that would defeat the point of this + // assertion. + var $fixtureLtr = ensureFixture(); + $fixtureLtr.append('right click me (ltr)'); + + $.contextMenu({ + selector: '.context-menu-ltr-sub', + items: { + more: { + name: 'More', + items: { + sub1: {name: 'Sub item'} + } + } + } + }); + + $('.context-menu-ltr-sub').contextMenu({x: 300, y: 100}); + var $ltrItem = $('.context-menu-ltr-sub').data('contextMenu').$menu.find('.context-menu-submenu').first(); + $ltrItem.trigger('contextmenu:focus'); + + var $ltrSubMenu = $ltrItem.children('.context-menu-list'); + var ltrItemOffset = $ltrItem.offset().left; + var ltrSubMenuOffset = $ltrSubMenu.offset().left; + + assert.ok( + ltrSubMenuOffset >= ltrItemOffset, + 'ltr: sub-menu should open at/after the parent item\'s left edge (opens to the right). item=' + ltrItemOffset + ' sub=' + ltrSubMenuOffset + ); + + $.contextMenu('destroy'); + $fixtureLtr.html(''); + + var $fixtureRtl = ensureFixture(); + $fixtureRtl.append('right click me (rtl)'); + + $.contextMenu({ + selector: '.context-menu-rtl-sub', + direction: 'rtl', + items: { + more: { + name: 'More', + items: { + sub1: {name: 'Sub item'} + } + } + } + }); + + $('.context-menu-rtl-sub').contextMenu({x: 300, y: 100}); + var $rtlItem = $('.context-menu-rtl-sub').data('contextMenu').$menu.find('.context-menu-submenu').first(); + $rtlItem.trigger('contextmenu:focus'); + + var $rtlSubMenu = $rtlItem.children('.context-menu-list'); + var rtlItemOffset = $rtlItem.offset().left; + var rtlSubMenuOffset = $rtlSubMenu.offset().left; + + assert.ok( + rtlSubMenuOffset <= rtlItemOffset, + 'rtl: sub-menu should open before the parent item\'s left edge (opens to the left). item=' + rtlItemOffset + ' sub=' + rtlSubMenuOffset + ); +});