+
diff --git a/tests/integration/components/o-s-s/context-menu-test.ts b/tests/integration/components/o-s-s/context-menu-test.ts
new file mode 100644
index 000000000..1f2032f9c
--- /dev/null
+++ b/tests/integration/components/o-s-s/context-menu-test.ts
@@ -0,0 +1,221 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { click, render, triggerEvent } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import sinon from 'sinon';
+import { setupIntl } from 'ember-intl/test-support';
+
+module('Integration | Component | o-s-s/context-menu', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks);
+
+ hooks.beforeEach(function () {
+ this.label = 'Open menu';
+ this.onOpenStub = sinon.stub();
+ this.onCloseStub = sinon.stub();
+ });
+
+ test('it renders', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('[data-control-name="context-menu"]').exists();
+ assert.dom('[data-control-name="context-menu"]').hasText(this.label);
+ });
+
+ module('It accepts same arguments as OSSButton component', function () {
+ test('it accepts label argument', async function (assert) {
+ this.label = 'first';
+ await render(hbs`
`);
+ assert.dom('button').hasText('first');
+ this.set('label', 'secondary');
+ assert.dom('button').hasText('secondary');
+ });
+
+ test('it accepts skin argument', async function (assert) {
+ this.skin = 'primary';
+ await render(hbs`
`);
+ assert.dom('button').hasClass('upf-btn--primary');
+ this.set('skin', 'secondary');
+ assert.dom('button').hasClass('upf-btn--secondary');
+ });
+
+ test('it accepts size argument', async function (assert) {
+ this.size = 'sm';
+ await render(hbs`
`);
+ assert.dom('button').hasClass('upf-btn--sm');
+ this.set('size', 'md');
+ assert.dom('button').hasClass('upf-btn--md');
+ });
+
+ test('it accepts loading argument', async function (assert) {
+ this.loading = true;
+ await render(
+ hbs`
`
+ );
+ assert.dom('button i').hasClass('fa-circle-notch').hasClass('fa-spin');
+ this.set('loading', false);
+ assert.dom('button i').doesNotExist();
+ });
+
+ test('it accepts loadingOptions argument', async function (assert) {
+ this.loadingOptions = { showLabel: true };
+ await render(
+ hbs`
`
+ );
+ assert.dom('button i').hasClass('fa-circle-notch').hasClass('fa-spin');
+ assert.dom('button').hasText(this.label);
+ this.set('loadingOptions', { showLabel: false });
+ assert.dom('button').hasNoText();
+ });
+
+ test('it accepts icon argument', async function (assert) {
+ this.icon = 'fa-plane';
+ await render(hbs`
`);
+ assert.dom('button i').hasClass('fa-plane');
+ this.set('icon', 'fa-plus');
+ assert.dom('button i').hasClass('fa-plus');
+ });
+
+ test('it accepts iconUrl argument', async function (assert) {
+ this.iconUrl = '/@upfluence/oss-components/assets/heart.svg';
+ await render(
+ hbs`
`
+ );
+ assert.dom('button img').hasAttribute('src', this.iconUrl);
+ });
+
+ test('it accepts theme argument', async function (assert) {
+ this.theme = 'light';
+ await render(
+ hbs`
`
+ );
+ assert.dom('button').hasNoClass('upf-btn--dark-bg');
+ this.set('theme', 'dark');
+ assert.dom('button').hasClass('upf-btn--dark-bg');
+ });
+
+ test('it accepts square argument', async function (assert) {
+ this.square = true;
+ await render(
+ hbs`
`
+ );
+ assert.dom('button').hasClass('upf-square-btn');
+ this.set('square', false);
+ assert.dom('button').doesNotHaveClass('upf-square-btn');
+ });
+
+ test('it accepts countDown argument', async function (assert) {
+ const clock = sinon.useFakeTimers({
+ shouldAdvanceTime: true
+ });
+
+ this.countDown = { callback: sinon.stub(), time: 50, step: 10 };
+ await render(
+ hbs`
`
+ );
+ await click('button');
+ assert.true(this.countDown.callback.notCalled);
+ clock.tick(100);
+ assert.true(this.countDown.callback.calledOnce);
+ clock.restore();
+ });
+ });
+
+ module('When clicking on the button', function () {
+ test('it opens the panel', async function (assert) {
+ await render(hbs`
`);
+
+ assert.dom('.context-menu-panel__scrollable-container').doesNotExist();
+ await click('button');
+ assert.dom('.context-menu-panel__scrollable-container').exists();
+ });
+
+ test('it trigger the onMenuOpened callback', async function (assert) {
+ await render(hbs`
`);
+
+ assert.ok(this.onOpenStub.notCalled);
+ await click('button');
+ assert.ok(this.onOpenStub.calledOnce);
+ });
+
+ module('When clicking a second time on the button', function () {
+ test('it closes the panel', async function (assert) {
+ await render(hbs`
`);
+ await click('button');
+ assert.dom('.context-menu-panel__scrollable-container').exists();
+ await click('button');
+ assert.dom('.context-menu-panel__scrollable-container').doesNotExist();
+ });
+
+ test('it trigger the onMenuOpened callback', async function (assert) {
+ await render(hbs`
`);
+ await click('button');
+ assert.ok(this.onCloseStub.notCalled);
+ await click('button');
+ assert.ok(this.onCloseStub.calledOnce);
+ });
+ });
+ });
+
+ module('When the panel is opened', function () {
+ test('On click outside of the panel, it closes', async function (assert) {
+ await render(hbs`
+
+
`);
+ await click('button');
+ assert.ok(this.onCloseStub.notCalled);
+ await click('[data-control-name="outside-container"]');
+ assert.ok(this.onCloseStub.calledOnce);
+ });
+
+ module('CloseOnMouseLeave', () => {
+ test('When closeOnMouseLeave is true, on mouse leave it closes the panel', async function (assert) {
+ await render(hbs`
`);
+ await click('button');
+ assert.dom('.context-menu-panel__scrollable-container').exists();
+ await triggerEvent('.context-menu-panel__scrollable-container', 'mouseleave');
+ assert.dom('.context-menu-panel__scrollable-container').doesNotExist();
+ assert.ok(this.onCloseStub.calledOnce);
+ });
+
+ test('When closeOnMouseLeave is false, on mouse leave it does not close the panel', async function (assert) {
+ await render(hbs`
`);
+ await click('button');
+ assert.dom('.context-menu-panel__scrollable-container').exists();
+ await triggerEvent('.context-menu-panel__scrollable-container', 'mouseleave');
+ assert.dom('.context-menu-panel__scrollable-container').exists();
+ assert.ok(this.onCloseStub.notCalled);
+ });
+ });
+ });
+
+ test('When button is loading, clicking does nothing', async function (assert) {
+ await render(hbs`
`);
+
+ assert.ok(this.onOpenStub.notCalled);
+ await click('button');
+ assert.ok(this.onOpenStub.notCalled);
+ });
+});
From 9331278dd40a254495cb490da713cf9538de12aa Mon Sep 17 00:00:00 2001
From: Antoine Prentout
Date: Fri, 30 Jan 2026 11:54:08 +0100
Subject: [PATCH 12/14] [ContextMenu] Add documentation & few behavior
improvements
---
addon/components/o-s-s/context-menu.hbs | 4 +-
.../components/o-s-s/context-menu.stories.js | 212 ++++++++++++++++++
addon/components/o-s-s/context-menu.ts | 23 +-
addon/components/o-s-s/context-menu/panel.hbs | 15 +-
addon/components/o-s-s/context-menu/panel.ts | 17 ++
.../app/components/panel/example-row.hbs | 2 +-
tests/dummy/app/controllers/input.ts | 9 +-
tests/dummy/app/templates/input.hbs | 3 +-
.../o-s-s/context-menu/panel-test.ts | 45 +++-
9 files changed, 305 insertions(+), 25 deletions(-)
create mode 100644 addon/components/o-s-s/context-menu.stories.js
diff --git a/addon/components/o-s-s/context-menu.hbs b/addon/components/o-s-s/context-menu.hbs
index 4c2eac945..e08aeb872 100644
--- a/addon/components/o-s-s/context-menu.hbs
+++ b/addon/components/o-s-s/context-menu.hbs
@@ -21,7 +21,9 @@
@offset={{4}}
@placement="bottom-start"
@onMouseLeave={{this.onContextMenuPanelMouseLeave}}
- {{did-insert this.registerContextMenuPanel}}
+ @onClose={{this.closeContextMenuPanel}}
+ @registerPanel={{this.registerContextMenuPanel}}
+ @unregisterPanel={{this.unregisterContextMenuPanel}}
{{on-click-outside this.onClickOutsidePanel useCapture=true}}
/>
{{/if}}
\ No newline at end of file
diff --git a/addon/components/o-s-s/context-menu.stories.js b/addon/components/o-s-s/context-menu.stories.js
new file mode 100644
index 000000000..19e4cd27f
--- /dev/null
+++ b/addon/components/o-s-s/context-menu.stories.js
@@ -0,0 +1,212 @@
+import { action } from '@storybook/addon-actions';
+import hbs from 'htmlbars-inline-precompile';
+
+const SkinTypes = [
+ 'default',
+ 'primary',
+ 'secondary',
+ 'destructive',
+ 'alert',
+ 'success',
+ 'instagram',
+ 'facebook',
+ 'youtube',
+ 'primary-gradient',
+ 'xtd-cyan',
+ 'xtd-orange',
+ 'xtd-yellow',
+ 'xtd-lime',
+ 'xtd-blue',
+ 'xtd-violet'
+];
+const SizeTypes = ['xs', 'sm', 'md', 'lg'];
+const ThemeTypes = ['light', 'dark'];
+
+export default {
+ title: 'Components/OSS::ContextMenu',
+ component: 'o-s-s/context-menu',
+ argTypes: {
+ items: {
+ type: { required: true },
+ description: 'An array of context menu items to be displayed in the panel',
+ table: {
+ type: { summary: 'ContextMenuItem[]' }
+ },
+ control: { type: 'object' }
+ },
+ skin: {
+ description: 'Adjust appearance',
+ table: {
+ type: {
+ summary: SkinTypes.join('|')
+ },
+ defaultValue: { summary: 'default' }
+ },
+ options: SkinTypes,
+ control: { type: 'select' }
+ },
+ size: {
+ description: 'Adjust size',
+ table: {
+ type: {
+ summary: SizeTypes.join('|')
+ },
+ defaultValue: { summary: 'null' }
+ },
+ options: SizeTypes,
+ control: { type: 'select' }
+ },
+ loading: {
+ description: 'Display loading state',
+ table: {
+ type: {
+ summary: 'boolean'
+ },
+ defaultValue: { summary: 'false' }
+ },
+ control: { type: 'boolean' }
+ },
+ loadingOptions: {
+ description: 'Options to configure the loading state',
+ table: {
+ type: {
+ summary: '{ showLabel?: boolean }'
+ },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: { type: 'object' }
+ },
+ label: {
+ description: 'Text content of the button',
+ table: {
+ type: { summary: 'string' },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: {
+ type: 'text'
+ }
+ },
+ icon: {
+ description: 'Font Awesome class, for example: far fa-envelope-open',
+ table: {
+ type: { summary: 'string' },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: {
+ type: 'text'
+ }
+ },
+ iconUrl: {
+ description: 'Url of an icon that will be shown within the button',
+ table: {
+ type: { summary: 'string' },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: {
+ type: 'text'
+ }
+ },
+ square: {
+ description: 'Displays the button as a square. Useful for icon buttons.',
+ table: {
+ type: { summary: 'boolean' },
+ defaultValue: { summary: 'false' }
+ },
+ control: {
+ type: 'boolean'
+ }
+ },
+ theme: {
+ description: 'Whether the button is being on a dark background or not',
+ table: {
+ type: {
+ summary: ThemeTypes.join('|')
+ },
+ defaultValue: { summary: 'light' }
+ },
+ options: ThemeTypes,
+ control: { type: 'select' }
+ },
+ countDown: {
+ description:
+ 'Definition of countDown object, it takes 3 keys:
' +
+ "- 'callback' (mandatory): function to call at the end
" +
+ "- 'time' (optional): time between execute callback. It is representing entire second in millisecond, for exemple 1000, 2000 or 5000
" +
+ "- 'step' (optional): the step value, it should be in the same unit as the time",
+ table: {
+ type: {
+ summary: '{ callback: () => {}, time?: number, step?: number }'
+ },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: { type: 'object' }
+ },
+ disabled: {
+ description:
+ 'This is a non-ember parameter, it is passed to the HTML input tag using the splattributes. (It should not be passed with `@` prefix)',
+ table: {
+ type: { summary: 'boolean' },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: {
+ type: 'boolean'
+ }
+ }
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'The `OSS::ContextMenu` component provides a button that, when clicked, displays a context menu with various options. It supports nested sub-menus, loading states, and customizable appearance through skins and sizes.'
+ }
+ }
+ }
+};
+
+const items = [
+ { title: 'Item 1', action: () => console.log('Item 1 selected') },
+ {
+ title: 'Item 2',
+ action: () => console.log('Item 2 selected'),
+ items: [{ title: 'Sub Item 1', action: () => console.log('Sub Item 1 selected') }]
+ },
+ {
+ title: 'Item 3',
+ action: () => console.log('Item 3 selected')
+ }
+];
+
+const defaultArgs = {
+ items: items,
+ label: 'Open menu',
+ skin: 'default',
+ loading: false,
+ icon: 'far fa-envelope-open',
+ theme: 'light',
+ size: 'md',
+ square: false,
+ countDown: undefined,
+ loadingOptions: undefined,
+ iconUrl: undefined,
+ disabled: false
+};
+
+const Template = (args) => ({
+ template: hbs``,
+ context: args
+});
+
+export const BasicUsage = Template.bind({});
+BasicUsage.args = defaultArgs;
diff --git a/addon/components/o-s-s/context-menu.ts b/addon/components/o-s-s/context-menu.ts
index e179800c7..fea56c251 100644
--- a/addon/components/o-s-s/context-menu.ts
+++ b/addon/components/o-s-s/context-menu.ts
@@ -22,7 +22,7 @@ interface OSSContextMenuArgs extends OSSButtonArgs {
export default class OSSContextMenuComponent extends Component {
@tracked displayContextMenuPanel: boolean = false;
@tracked declare referenceTarget: HTMLElement;
- @tracked declare contextMenuPanel: HTMLElement;
+ @tracked contextMenuPanels: HTMLElement[] = [];
@action
registerMenuTrigger(element: HTMLElement): void {
@@ -32,7 +32,6 @@ export default class OSSContextMenuComponent extends Component el !== element);
+ }
+
+ @action
+ onClickOutsidePanel(_: HTMLElement, event: Event): void {
+ if (
+ (event.target && this.referenceTarget?.contains(event.target as HTMLElement)) ||
+ this.contextMenuPanels.some((el) => el.contains(event.target as HTMLElement))
+ )
+ return;
this.hideContextMenuPanel();
}
+ @action
+ closeContextMenuPanel(): void {
+ this.hideContextMenuPanel();
+ }
+
private hideContextMenuPanel(): void {
this.displayContextMenuPanel = false;
this.args.onMenuClosed?.();
diff --git a/addon/components/o-s-s/context-menu/panel.hbs b/addon/components/o-s-s/context-menu/panel.hbs
index 2c566e271..014c07a2b 100644
--- a/addon/components/o-s-s/context-menu/panel.hbs
+++ b/addon/components/o-s-s/context-menu/panel.hbs
@@ -1,4 +1,4 @@
-