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('_');
+ }
+
+
+ {{#each @icons as |icon|}}
+
+ {{/each}}
+
+
+}
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');
+ });
+});