diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e8a7b48..005a814d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * `item.name` may now be a function, for labels that change per open (fixes #743) * The trigger element is now reachable from item-level `events` handlers, including in sub-menus (fixes #729) * Added `animation.showDuration`/`animation.hideDuration` and `animation.animateOnReopen` (fixes #739) +* `item.icon` now supports Font Awesome 6 and 7 syntax, including the separate family/style classes (`fa-solid fa-user`, `fa-sharp fa-solid fa-user`) and per-pack short prefixes (`fasds`, `fands`), alongside the existing 4 and 5 syntax (fixes #776). Font Awesome 4's full `fa fa-user` form now works too, where previously only the `fa-user` shorthand did #### Fixed diff --git a/documentation/docs/font-awesome.md b/documentation/docs/font-awesome.md index 4de893f0..136cd6bb 100644 --- a/documentation/docs/font-awesome.md +++ b/documentation/docs/font-awesome.md @@ -12,6 +12,46 @@ Check out the [demo](https://swisnl.github.io/jQuery-contextMenu/demo/fontawesom It is also possible to use your own SVG icons if you like, you can [customize](customize) this by using the SASS files. -## Recent Font Awesome support +## Supported Font Awesome versions -The contextmenu supports the new Font Awesome by including the correct font-awesome CSS and adding the new classes as icon for a menu item. When using for example `fas fa-beer` it will crete the `i` tag for the icon in the menu and adjust CSS accordingly. +Include the Font Awesome CSS for the version you use, then pass its classes as +the item's [icon](items#icon). The contextmenu creates the `i` tag for the icon +inside the menu item and adjusts its own CSS accordingly. Every version from 4 +to 7 works, using that version's own syntax: + +```javascript +var items = { + // Font Awesome 4 + a: {name: "Beer", icon: "fa-beer"}, // shorthand, `fa` is added for you + b: {name: "Beer", icon: "fa fa-beer"}, // or the full v4 syntax + + // Font Awesome 5 + c: {name: "Beer", icon: "fas fa-beer"}, + d: {name: "GitHub", icon: "fab fa-github"}, + + // Font Awesome 6 and 7, which name the family and the style separately + e: {name: "Beer", icon: "fa-solid fa-beer"}, + f: {name: "Beer", icon: "fa-regular fa-beer"}, + g: {name: "GitHub", icon: "fa-brands fa-github"}, + h: {name: "Beer", icon: "fa-sharp fa-solid fa-beer"}, + i: {name: "Beer", icon: "fa-sharp-duotone fa-solid fa-beer"}, + + // per-pack short prefixes work too + j: {name: "Beer", icon: "fasds fa-beer"} +} +``` + +Anything containing a `fa-` class is treated as Font Awesome and passed through +to the `i` tag unchanged, so a family or style added in a future release needs +no change here. The one exception is the version 4 shorthand: when the class +list does not say which family or style to use, the base `fa` class is supplied +so `icon: "fa-beer"` keeps working on its own. + +A single word with no `fa-` in it stays a [built-in icon](customize) name, so +`icon: "edit"` still refers to this plugin's own icon font rather than to Font +Awesome. + +The menu item itself gets a `context-menu-icon--fa5` class, which the stylesheet +uses to position the child icon. The name is historical: it applies to every +Font Awesome version, not just 5, and is kept as-is so existing custom CSS keeps +matching. diff --git a/src/jquery.contextMenu.js b/src/jquery.contextMenu.js index ceace1d5..8b257d91 100644 --- a/src/jquery.contextMenu.js +++ b/src/jquery.contextMenu.js @@ -1954,30 +1954,37 @@ if (typeof item.icon === 'function') { item._icon = item.icon.call(this, this, $t, key, item); } else { - if (typeof(item.icon) === 'string' && ( - item.icon.substring(0, 4) === 'fab ' - || item.icon.substring(0, 4) === 'fas ' - || item.icon.substring(0, 4) === 'fad ' - || item.icon.substring(0, 4) === 'far ' - || item.icon.substring(0, 4) === 'fal ') - ) { - // to enable font awesome + if (isFontAwesomeIcon(item.icon)) { + // Font Awesome needs its own tag instead of being applied + // to the menu item itself: its classes set font-family and + // font-weight and their own ::before content on whatever + // element they are put on, which used to bleed into (and + // bold) the item's label text and clash with this plugin's + // own icon styling. + // + // `--fa5` is a historical name kept for compatibility: it is + // just the hook the stylesheet uses to position a child icon + // element, and applies to every Font Awesome version rather + // than to v5 specifically. $t.addClass(root.classNames.icon + ' ' + root.classNames.icon + '--fa5'); // built with addClass instead of interpolating the icon // into a markup string: an icon name coming from stored // or otherwise non-literal data could otherwise close the // class attribute and inject arbitrary markup. - item._icon = $('').addClass(item.icon); - } else if (typeof(item.icon) === 'string' && item.icon.substring(0, 3) === 'fa-') { - // legacy Font Awesome 4 style icon class (e.g. "fa-trash"), kept for - // backwards compatibility. Just like the fas/far/fab/fad/fal syntax - // above, this needs its own tag instead of being applied to the - // menu item itself: Font Awesome's classes set font-family/font-weight - // and their own ::before content on whatever element they're put on, - // which used to bleed into (and bold) the item's label text and clash - // with this plugin's own icon styling. - $t.addClass(root.classNames.icon + ' ' + root.classNames.icon + '--fa5'); - item._icon = $('').addClass('fa').addClass(item.icon); + if (namesFontAwesomeFamily(item.icon)) { + // v5's `fas fa-user`, or v6/v7's `fa-solid fa-user` and + // `fa-sharp fa-solid fa-user`: the caller has already + // said which family and style to use, so the classes go + // through untouched. Supplying `fa` on top of these is + // what used to fight `fa-regular`/`fa-brands` for + // font-weight and font-family. + item._icon = $('').addClass(item.icon); + } else { + // v4's `fa-user` shorthand, optionally with modifiers + // (`fa-user fa-lg`), which renders only once the base + // `fa` class is supplied here. + item._icon = $('').addClass('fa').addClass(item.icon); + } } else { item._icon = root.classNames.icon + ' ' + root.classNames.icon + '-' + item.icon; } @@ -2580,6 +2587,71 @@ (selector.nodeType === 1 || (typeof selector.jquery !== 'undefined' && typeof selector.length === 'number')); } + // Font Awesome has changed how a style is named in every major version: + // + // v4 + // v5 + // v6 and v7 + // + // + // + // and v7 keeps adding packs. Rather than tracking a version, the two + // helpers below answer the only two questions this plugin actually has: + // + // 1. is this a Font Awesome class list, or one of this plugin's own + // built-in icon names? + // 2. does that class list already say which family and style to use, or + // does v4's base `fa` still need supplying? Supplying it is what makes + // the `icon: "fa-user"` shorthand work. + + // Font Awesome's style classes, plus the families that existed before v7's + // packs. This needs no entry per pack: v6 and v7 always name a style next + // to the family, so `fa-notdog-duo fa-solid fa-user` is recognised by its + // `fa-solid`. Kept as a padded string and matched with the ' x ' idiom used + // elsewhere in this file, so an icon literally called "constructor" cannot + // collide with Object.prototype. + var faFamilyAndStyleClasses = ' fa-solid fa-regular fa-light fa-thin fa-brands' + + ' fa-duotone fa-classic fa-sharp fa-sharp-duotone '; + + function faClassTokens(icon) { + if (typeof icon !== 'string') { + return []; + } + return trimText(icon).split(/\s+/); + } + + // An `fa-` token says Font Awesome. Keying off that rather than a bare `fa` + // prefix is what keeps a short built-in icon name such as `icon: "fav"` out + // of these paths. + function isFontAwesomeIcon(icon) { + var tokens = faClassTokens(icon), i; + for (i = 0; i < tokens.length; i++) { + if (tokens[i].indexOf('fa-') === 0) { + return true; + } + } + return false; + } + + function namesFontAwesomeFamily(icon) { + var tokens = faClassTokens(icon), token, i; + for (i = 0; i < tokens.length; i++) { + token = tokens[i]; + // A short prefix carries family and style together and never + // contains a hyphen: `fa` in v4, `fas`/`far`/`fab` in v5, and the + // per-pack ones v7 keeps adding such as `fasds` and `fands`. Icon + // names and modifiers such as `fa-lg` always do contain one, so + // telling the two apart needs no list of prefixes at all. + if (token.indexOf('fa') === 0 && token.indexOf('-') === -1) { + return true; + } + if (faFamilyAndStyleClasses.indexOf(' ' + token + ' ') !== -1) { + return true; + } + } + return false; + } + // is the given value usable as a page coordinate? Finite numbers are, and // so are numeric strings, which the positioning arithmetic has always // accepted (`{x: el.dataset.x, ...}`). Note 0 is a perfectly valid diff --git a/test/unit/font-awesome-versions.test.js b/test/unit/font-awesome-versions.test.js new file mode 100644 index 00000000..dc9d8099 --- /dev/null +++ b/test/unit/font-awesome-versions.test.js @@ -0,0 +1,171 @@ +// Font Awesome names a style differently in every major version, and v7 keeps +// adding packs with their own short prefixes. These tests pin the emitted +// markup for each syntax, so the two things that matter stay true: +// +// * a class list that already names a family/style is passed through +// untouched (v5, v6, v7), and +// * one that does not still gets v4's base `fa` class supplied (the +// `icon: "fa-user"` shorthand and its modifier form). +// +// See https://github.com/swisnl/jQuery-contextMenu/pull/776 + +QUnit.module('Font Awesome icon syntax across versions', { + afterEach: function() { + $.contextMenu('destroy'); + var $fixtureEl = $('#qunit-fixture'); + if ($fixtureEl.length) { + $fixtureEl.html(''); + } + } +}); + +function fixtureFa() { + var $fixtureEl = $('#qunit-fixture'); + if ($fixtureEl.length === 0) { + $('
').appendTo('body'); + $fixtureEl = $('#qunit-fixture'); + } + return $fixtureEl; +} + +var faTriggerSeq = 0; + +// Build a one-item menu with the given `icon`, open it, and report what was +// rendered: the classes on the child (if any) and on the item itself. +function renderIconFa(icon) { + var cls = 'fa-trigger-' + (faTriggerSeq++); + fixtureFa().append('
right click me
'); + + $.contextMenu({ + selector: '.' + cls, + items: { + only: {name: 'Only', icon: icon} + } + }); + + $('.' + cls).contextMenu(); + + var $item = $('.context-menu-list').filter(':visible').last().find('li.context-menu-item').first(); + var $i = $item.children('i'); + + function sortedClasses($el) { + var raw = ($el.attr('class') || '').split(/\s+/).filter(function(one) { + return one !== ''; + }); + return raw.sort(); + } + + return { + hasChildIcon: $i.length === 1, + iconClasses: $i.length === 1 ? sortedClasses($i) : null, + itemClasses: sortedClasses($item) + }; +} + +function assertChildIcon(assert, icon, expectedClasses, label) { + var rendered = renderIconFa(icon); + assert.ok(rendered.hasChildIcon, label + ': renders a child '); + assert.deepEqual( + rendered.iconClasses, + expectedClasses.slice().sort(), + label + ': child classes' + ); + assert.ok( + rendered.itemClasses.indexOf('context-menu-icon--fa5') !== -1, + label + ': item keeps the positioning hook' + ); +} + +// ---------------------------------------------------------------- v4 + +QUnit.test('v4 shorthand "fa-user" gets the base fa class supplied', function(assert) { + assertChildIcon(assert, 'fa-user', ['fa', 'fa-user'], 'v4 shorthand'); +}); + +QUnit.test('v4 shorthand with a modifier keeps the base fa class', function(assert) { + // This already worked and must keep working: nothing in the list names a + // family, so `fa` is still required for the icon to render at all. + assertChildIcon(assert, 'fa-user fa-lg', ['fa', 'fa-user', 'fa-lg'], 'v4 + modifier'); +}); + +QUnit.test('v4 full syntax "fa fa-user" is passed through untouched', function(assert) { + // Previously fell through to the built-in icon-font branch and emitted + // "context-menu-icon-fa fa-user" onto the item, rendering nothing. + assertChildIcon(assert, 'fa fa-user', ['fa', 'fa-user'], 'v4 full'); +}); + +// ---------------------------------------------------------------- v5 + +QUnit.test('v5 short prefixes are passed through untouched', function(assert) { + assertChildIcon(assert, 'fas fa-user', ['fas', 'fa-user'], 'v5 solid'); + assertChildIcon(assert, 'far fa-user', ['far', 'fa-user'], 'v5 regular'); + assertChildIcon(assert, 'fal fa-user', ['fal', 'fa-user'], 'v5 light'); + assertChildIcon(assert, 'fad fa-user', ['fad', 'fa-user'], 'v5 duotone'); + assertChildIcon(assert, 'fab fa-github', ['fab', 'fa-github'], 'v5 brands'); +}); + +// ---------------------------------------------------------------- v6 / v7 + +QUnit.test('v6/v7 long-form styles no longer get a spurious fa class', function(assert) { + // The bug in #776: these hit the v4 shorthand branch and came out as + // "fa fa-solid fa-user". That stray `fa` fights fa-regular for font-weight + // and fa-brands for font-family. + assertChildIcon(assert, 'fa-solid fa-user', ['fa-solid', 'fa-user'], 'v6 solid'); + assertChildIcon(assert, 'fa-regular fa-user', ['fa-regular', 'fa-user'], 'v6 regular'); + assertChildIcon(assert, 'fa-light fa-user', ['fa-light', 'fa-user'], 'v6 light'); + assertChildIcon(assert, 'fa-thin fa-user', ['fa-thin', 'fa-user'], 'v6 thin'); + assertChildIcon(assert, 'fa-brands fa-github', ['fa-brands', 'fa-github'], 'v6 brands'); +}); + +QUnit.test('v6/v7 family plus style combinations are passed through untouched', function(assert) { + assertChildIcon(assert, 'fa-classic fa-solid fa-user', ['fa-classic', 'fa-solid', 'fa-user'], 'classic'); + assertChildIcon(assert, 'fa-sharp fa-solid fa-user', ['fa-sharp', 'fa-solid', 'fa-user'], 'sharp'); + assertChildIcon(assert, 'fa-duotone fa-solid fa-user', ['fa-duotone', 'fa-solid', 'fa-user'], 'duotone'); + assertChildIcon(assert, 'fa-sharp-duotone fa-solid fa-user', ['fa-sharp-duotone', 'fa-solid', 'fa-user'], 'sharp-duotone'); +}); + +QUnit.test('v7 per-pack short prefixes are passed through untouched', function(assert) { + // Recognised by having no hyphen rather than from a list of prefixes, so a + // pack added in a later release needs no change in the plugin. + assertChildIcon(assert, 'fasds fa-user', ['fasds', 'fa-user'], 'sharp-duotone solid'); + assertChildIcon(assert, 'fasr fa-user', ['fasr', 'fa-user'], 'sharp regular'); + assertChildIcon(assert, 'fat fa-user', ['fat', 'fa-user'], 'thin'); + assertChildIcon(assert, 'fands fa-user', ['fands', 'fa-user'], 'notdog-duo solid'); +}); + +QUnit.test('a long-form family the plugin has never heard of still works', function(assert) { + // The point of listing only styles: a v7 pack, or one released after this + // code was written, is recognised through the style class that always sits + // beside it. No plugin change needed when Font Awesome adds a family. + assertChildIcon(assert, 'fa-notdog-duo fa-solid fa-user', + ['fa-notdog-duo', 'fa-solid', 'fa-user'], 'unlisted family + known style'); + assertChildIcon(assert, 'fa-whiteboard fa-regular fa-user', + ['fa-whiteboard', 'fa-regular', 'fa-user'], 'another unlisted family'); +}); + +// ------------------------------------------------- built-in icons, unchanged + +QUnit.test('built-in icon names still style the item itself', function(assert) { + var rendered = renderIconFa('edit'); + assert.notOk(rendered.hasChildIcon, 'no child is created'); + assert.ok(rendered.itemClasses.indexOf('context-menu-icon') !== -1, 'item keeps context-menu-icon'); + assert.ok(rendered.itemClasses.indexOf('context-menu-icon-edit') !== -1, 'item gets context-menu-icon-edit'); + assert.notOk(rendered.itemClasses.indexOf('context-menu-icon--fa5') !== -1, 'item is not treated as Font Awesome'); +}); + +QUnit.test('a short built-in icon name beginning with fa is not mistaken for Font Awesome', function(assert) { + // Detection keys off an `fa-` token precisely so a name like this, which a + // caller may well have defined in their own CSS, keeps its old meaning. + var rendered = renderIconFa('fav'); + assert.notOk(rendered.hasChildIcon, 'no child is created'); + assert.ok(rendered.itemClasses.indexOf('context-menu-icon-fav') !== -1, 'item gets context-menu-icon-fav'); + assert.notOk(rendered.itemClasses.indexOf('context-menu-icon--fa5') !== -1, 'item is not treated as Font Awesome'); +}); + +QUnit.test('a function icon is untouched by any of this', function(assert) { + var rendered = renderIconFa(function() { + return 'from-a-callback'; + }); + assert.notOk(rendered.hasChildIcon, 'no child is created'); + assert.ok(rendered.itemClasses.indexOf('from-a-callback') !== -1, 'the returned class is applied to the item'); +});