diff --git a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/fa-icon-stack.js b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/fa-icon-stack.js new file mode 100644 index 0000000000..2d7efc0039 --- /dev/null +++ b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/fa-icon-stack.js @@ -0,0 +1,10 @@ +import { collection, create } from 'ember-cli-page-object'; +import faIcon from 'ilios-common/page-objects/components/fa-icon'; + +const definition = { + scope: '[data-test-awesome-icon-stack]', + icons: collection('[data-test-awesome-icon]', faIcon), +}; + +export default definition; +export const component = create(definition); diff --git a/packages/ilios-common/addon/components/fa-icon-stack.gjs b/packages/ilios-common/addon/components/fa-icon-stack.gjs new file mode 100644 index 0000000000..5fc6721601 --- /dev/null +++ b/packages/ilios-common/addon/components/fa-icon-stack.gjs @@ -0,0 +1,41 @@ +import Component from '@glimmer/component'; +import or from 'ember-truth-helpers/helpers/or'; +import FaIcon from 'ilios-common/components/fa-icon'; + +export default class FaIconStackComponent extends Component { + get titleId() { + if (!this.args.title) { + return null; + } + + return `inline-title-${this.uniqueId}`; + } + get iconClasses() { + return this.args.icons.join('_'); + } + +} diff --git a/packages/ilios-common/addon/components/fa-icon.gjs b/packages/ilios-common/addon/components/fa-icon.gjs index 0d0b552241..da63698c11 100644 --- a/packages/ilios-common/addon/components/fa-icon.gjs +++ b/packages/ilios-common/addon/components/fa-icon.gjs @@ -62,6 +62,10 @@ export default class FaIconComponent extends Component { classes.push(this.flip); } + if (this.args.extraClasses) { + classes.push(this.args.extraClasses); + } + return classes.length ? ` ${classes.join(' ')}` : ''; } //prettier-ignore diff --git a/packages/ilios-common/app/components/fa-icon-stack.js b/packages/ilios-common/app/components/fa-icon-stack.js new file mode 100644 index 0000000000..8cb451f0d0 --- /dev/null +++ b/packages/ilios-common/app/components/fa-icon-stack.js @@ -0,0 +1 @@ +export { default } from 'ilios-common/components/fa-icon-stack'; diff --git a/packages/ilios-common/app/styles/ilios-common/components.scss b/packages/ilios-common/app/styles/ilios-common/components.scss index 0677fab303..9b26ad55a8 100644 --- a/packages/ilios-common/app/styles/ilios-common/components.scss +++ b/packages/ilios-common/app/styles/ilios-common/components.scss @@ -1,5 +1,6 @@ @forward "components/api-version-notice"; @forward "components/awesome-icon"; +@forward "components/awesome-icon-stack"; @forward "components/back-link"; @forward "components/body"; @forward "components/breadcrumbs"; diff --git a/packages/ilios-common/app/styles/ilios-common/components/awesome-icon-stack.scss b/packages/ilios-common/app/styles/ilios-common/components/awesome-icon-stack.scss new file mode 100644 index 0000000000..f7c293e144 --- /dev/null +++ b/packages/ilios-common/app/styles/ilios-common/components/awesome-icon-stack.scss @@ -0,0 +1,22 @@ +.fa-layers { + display: inline-block; + height: 1em; + position: relative; + text-align: center; + vertical-align: -0.125em; + width: 1em; + + svg { + bottom: 0; + left: 0; + margin: auto; + position: absolute; + right: 0; + top: 0; + } +} + +.fa-fw { + text-align: center; + width: 1.25em; +} diff --git a/packages/test-app/tests/integration/components/fa-icon-stack-test.gjs b/packages/test-app/tests/integration/components/fa-icon-stack-test.gjs new file mode 100644 index 0000000000..7bad5fa0c4 --- /dev/null +++ b/packages/test-app/tests/integration/components/fa-icon-stack-test.gjs @@ -0,0 +1,246 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'test-app/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { htmlSafe } from '@ember/template'; +import { component } from 'ilios-common/page-objects/components/fa-icon-stack'; +import FaIconStack from 'ilios-common/components/fa-icon-stack'; + +module('Integration | Component | fa-icon-stack', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set('icons', ['circle-check', 'slash']); + }); + + test('it renders multiple layered icons', async function (assert) { + await render(); + + assert.strictEqual(component.icons.length, 2); + assert.strictEqual(component.icons[0].type, 'circle-check'); + assert.ok(component.icons[0].innerUse); + assert.ok(component.icons[0].innerUse.href, '/fontawesome/solid.svg#circle-check'); + assert.strictEqual(component.icons[1].type, 'slash'); + assert.ok(component.icons[1].innerUse); + assert.ok(component.icons[1].innerUse.href, '/fontawesome/solid.svg#slash'); + }); + + test('it renders extra classes', async function (assert) { + this.set('class', 'foo-xyz'); + await render( + , + ); + assert.strictEqual(component.icons[0].classes, 'awesome-icon fa-circle-check foo-xyz'); + assert.strictEqual(component.icons[1].classes, 'awesome-icon fa-slash foo-xyz'); + this.set('class', 'foo-new-class'); + assert.strictEqual(component.icons[0].classes, 'awesome-icon fa-circle-check foo-new-class'); + assert.strictEqual(component.icons[1].classes, 'awesome-icon fa-slash foo-new-class'); + }); + + test('it optionally renders spin class', async function (assert) { + this.set('isSpinning', false); + await render( + , + ); + assert.strictEqual(component.icons[0].classes, 'awesome-icon fa-circle-check'); + assert.strictEqual(component.icons[1].classes, 'awesome-icon fa-slash'); + this.set('isSpinning', true); + assert.strictEqual(component.icons[0].classes, 'awesome-icon fa-circle-check spin'); + assert.strictEqual(component.icons[1].classes, 'awesome-icon fa-slash spin'); + }); + + test('it optionally renders fixed-width class', async function (assert) { + this.set('fixedWidth', false); + await render( + , + ); + assert.strictEqual(component.icons[0].classes, 'awesome-icon fa-circle-check'); + assert.strictEqual(component.icons[1].classes, 'awesome-icon fa-slash'); + this.set('fixedWidth', true); + assert.strictEqual(component.icons[0].classes, 'awesome-icon fa-circle-check fixed-width'); + assert.strictEqual(component.icons[1].classes, 'awesome-icon fa-slash fixed-width'); + }); + + test('it renders vertically and horizontally flipped', async function (assert) { + this.set('flip', ''); + await render(); + assert.dom('svg').doesNotHaveClass('flip-horizontal'); + assert.dom('svg').doesNotHaveClass('flip-vertical'); + this.set('flip', 'horizontal'); + assert.dom('svg').hasClass('flip-horizontal'); + assert.dom('svg').doesNotHaveClass('flip-vertical'); + this.set('flip', 'vertical'); + assert.dom('svg').doesNotHaveClass('flip-horizontal'); + assert.dom('svg').hasClass('flip-vertical'); + this.set('flip', 'both'); + assert.dom('svg').hasClass('flip-horizontal'); + assert.dom('svg').hasClass('flip-vertical'); + }); + + test('it binds title', async function (assert) { + const title = 'awesome is as awesome does'; + this.set('title', title); + await render(); + assert.ok(component.icons[0].title, 'first icon layer has title element'); + assert.strictEqual(component.icons[0].title.text, title, 'first icon layer has correct title'); + assert.ok(component.icons[1].title, 'second icon layer has title element'); + assert.strictEqual(component.icons[1].title.text, title, 'second icon layer has correct title'); + + assert.ok(component.icons[0].ariaLabelledBy); + assert.ok(component.icons[1].ariaLabelledBy); + }); + + test('no title attribute gives no icon title element', async function (assert) { + await render(); + assert.notOk(component.icons[0].title.exists, 'first icon layer has no title'); + assert.notOk(component.icons[1].title.exists, 'second icon layer has no title'); + }); + + test('title from string like object', async function (assert) { + const title = 'awesome is as awesome does'; + this.set('title', htmlSafe(title)); + await render(); + assert.ok(component.icons[0].title, 'first icon layer has title element'); + assert.strictEqual(component.icons[0].title.text, title, 'first icon layer has correct title'); + assert.ok(component.icons[1].title, 'second icon layer has title element'); + assert.strictEqual(component.icons[1].title.text, title, 'second icon layer has correct title'); + }); + + test('it renders with the default focusable attributes as false', async function (assert) { + await render(); + assert.strictEqual(component.icons[0].focusable, 'false'); + assert.strictEqual(component.icons[1].focusable, 'false'); + }); + + test('it should change the focusable attributes to true', async function (assert) { + this.set('title', 'awesome title of awesomeness'); + await render(); + assert.strictEqual(component.icons[0].focusable, 'true'); + assert.strictEqual(component.icons[1].focusable, 'true'); + }); + + test('it defaults to ariaHidden', async function (assert) { + await render(); + assert.dom('svg').hasAttribute('aria-hidden', 'true'); + }); + + test('it binds ariaHidden', async function (assert) { + this.set('title', 'awesome title of awesomeness'); + await render(); + assert.strictEqual(component.icons[0].ariaHidden, 'false'); + assert.strictEqual(component.icons[1].ariaHidden, 'false'); + this.set('title', null); + assert.strictEqual(component.icons[0].ariaHidden, 'true'); + assert.strictEqual(component.icons[1].ariaHidden, 'true'); + }); + + test('role defaults to img', async function (assert) { + await render(); + assert.strictEqual(component.icons[0].role, 'img'); + assert.strictEqual(component.icons[1].role, 'img'); + }); + + test('it binds role', async function (assert) { + this.set('role', 'img'); + await render(); + assert.strictEqual(component.icons[0].role, 'img', 'first icon has correct img role'); + assert.strictEqual(component.icons[1].role, 'img', 'second icon has correct img role'); + this.set('role', 'presentation'); + assert.strictEqual( + component.icons[0].role, + 'presentation', + 'first icon has correct presentation role', + ); + assert.strictEqual( + component.icons[1].role, + 'presentation', + 'first icon has correct presentation role', + ); + this.set('role', false); + assert.strictEqual(component.icons[0].role, 'img', 'first icon has default img role'); + assert.strictEqual(component.icons[1].role, 'img', 'second icon has default img role'); + }); + + test('it binds attributes', async function (assert) { + this.set('height', '5px'); + this.set('width', '6px'); + this.set('x', '19'); + this.set('y', '81'); + + await render( + , + ); + + assert.strictEqual( + component.icons[0].height, + '5px', + 'first icon layer has correct height attribute', + ); + assert.strictEqual( + component.icons[1].height, + '5px', + 'second icon layer has correct height attribute', + ); + assert.strictEqual( + component.icons[0].width, + '6px', + 'first icon layer has correct width attribute', + ); + assert.strictEqual( + component.icons[1].width, + '6px', + 'second icon layer has correct width attribute', + ); + assert.strictEqual(component.icons[0].x, '19', 'first icon layer has correct x attribute'); + assert.strictEqual(component.icons[1].x, '19', 'second icon layer has correct x attribute'); + assert.strictEqual(component.icons[0].y, '81', 'first icon layer has correct y attribute'); + assert.strictEqual(component.icons[1].y, '81', 'second icon layer has correct y attribute'); + this.set('height', '10rem'); + this.set('width', '10rem'); + this.set('x', '2'); + this.set('y', '2'); + assert.strictEqual( + component.icons[0].height, + '10rem', + 'first icon layer has correct new height attribute', + ); + assert.strictEqual( + component.icons[1].height, + '10rem', + 'second icon layer has correct new height attribute', + ); + assert.strictEqual( + component.icons[0].width, + '10rem', + 'first icon layer has correct new width attribute', + ); + assert.strictEqual( + component.icons[1].width, + '10rem', + 'second icon layer has correct new width attribute', + ); + assert.strictEqual(component.icons[0].x, '2', 'first icon layer has correct new x attribute'); + assert.strictEqual(component.icons[1].x, '2', 'second icon layer has correct new x attribute'); + assert.strictEqual(component.icons[0].y, '2', 'first icon layer has correct new y attribute'); + assert.strictEqual(component.icons[1].y, '2', 'second icon layer has correct new y attribute'); + }); + + test('it accepts listItem', async function (assert) { + this.set('listItem', false); + await render( + , + ); + assert.strictEqual(component.icons[0].classes, 'awesome-icon fa-circle-check'); + assert.strictEqual(component.icons[1].classes, 'awesome-icon fa-slash'); + this.set('listItem', true); + assert.strictEqual(component.icons[0].classes, 'awesome-icon fa-circle-check list-item'); + assert.strictEqual(component.icons[1].classes, 'awesome-icon fa-slash list-item'); + }); +});