Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
89ebd9f
[Infinite select] Add ability to group by items (#622)
aprentout Jan 23, 2026
c11825a
feat: added hasError param to text area
OwenCoogan Jan 28, 2026
e89376c
[WIP] Add new OSS::ContextMenu::Panel component
aprentout Jan 26, 2026
9930571
[WIP] Improve component & add tests
aprentout Jan 27, 2026
70ce152
[ContextMenu::Panel] Improve mouse event behavior & add scroll shadow
aprentout Jan 28, 2026
b6caf4f
[ContextMenu::Panel] Add styling
aprentout Jan 28, 2026
1c913b9
[ContextMenu::Panel] Cleanup
aprentout Jan 28, 2026
0c75af8
[ContextMenu::Panel] Fix pr feedbacks
aprentout Jan 28, 2026
371143b
[ContextMenu::Panel] Improve default values
aprentout Jan 29, 2026
487e07b
[ContextMenu::Panel] Fix failing tests & pr feedbacks
aprentout Jan 29, 2026
24e0b08
[ContextMenu] Add new context-menu component
aprentout Jan 29, 2026
7422cb1
[ContextMenu] Add documentation & few behavior improvements
aprentout Jan 30, 2026
dfbe0d0
[ContextMenu] Improve documentation
aprentout Jan 30, 2026
fbd8700
[ContextMenu] fix pr feedbacks
aprentout Jan 30, 2026
9abc6a5
Updated: removed unused event raising a warning in context-menu
Miexil Feb 11, 2026
add5053
fix: added mouse event param to actions for context menu component
OwenCoogan Feb 12, 2026
32074a0
feat: added chevrons for contextual action menu
OwenCoogan Feb 18, 2026
6ffe950
fixes post review
OwenCoogan Feb 19, 2026
2e645ce
fix: typo fix
OwenCoogan Feb 19, 2026
2636d18
fix: added fix post review
OwenCoogan Feb 19, 2026
911373e
Updated: prefixIcon alignment with fixed width and centered
Miexil Feb 20, 2026
5e46939
Updated: ContextMenu - Target parent LI element for mouse-enter events
Miexil Feb 20, 2026
d7e928e
fix: hr.group-separator width to auto
edouardmisset Feb 23, 2026
2d4ebd1
fix: fix mouse leave events in the tooltip case
edouardmisset Feb 24, 2026
2dedf37
[Context-menu/panel] Fix relatedTarget in test env
aprentout Feb 25, 2026
b775589
[Infinite-select] Rollback overscroll-behavior
aprentout Feb 27, 2026
9e014af
[Context-menu/panel] Fix scroll behavior on chrome
aprentout Feb 27, 2026
bbbd2cd
[context-menu] Panel: Open menu on click instead of toggle
aprentout Mar 3, 2026
f0df3a3
[Context-menu/panel] Allow passing data-control-name to options
aprentout Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions addon/components/o-s-s/context-menu.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<OSS::Button
@skin={{@skin}}
@size={{@size}}
@loading={{@loading}}
@loadingOptions={{@loadingOptions}}
@icon={{@icon}}
@iconUrl={{@iconUrl}}
@label={{@label}}
@theme={{@theme}}
@square={{@square}}
@countDown={{@countDown}}
@suffixIcon={{if this.displayContextMenuPanel "fa-chevron-up" "fa-chevron-down"}}
{{did-insert this.registerMenuTrigger}}
{{on "click" this.toggleContextMenuPanel}}
...attributes
/>

{{#if this.displayContextMenuPanel}}
<OSS::ContextMenu::Panel
@referenceTarget={{this.referenceTarget}}
@items={{@items}}
@offset={{4}}
@placement="bottom-start"
@onMouseLeave={{this.onContextMenuPanelMouseLeave}}
@onClose={{this.closeContextMenuPanel}}
@registerPanel={{this.registerContextMenuPanel}}
@unregisterPanel={{this.unregisterContextMenuPanel}}
{{on-click-outside this.onClickOutsidePanel useCapture=true}}
/>
{{/if}}
243 changes: 243 additions & 0 deletions addon/components/o-s-s/context-menu.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
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:<br/>' +
"- 'callback' (mandatory): function to call at the end<br/>" +
"- 'time' (optional): time between execute callback. It is representing entire second in millisecond, for exemple 1000, 2000 or 5000<br/>" +
"- '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'
}
},
closeOnMouseLeave: {
type: { required: false },
description: 'If true, the menu will close when the mouse leaves the panel',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: 'false' }
},
control: { type: 'boolean' }
},
onMenuOpened: {
type: { required: false },
description: 'Callback function called when the menu panel is opened',
table: {
category: 'Actions',
type: { summary: 'onMenuOpened(): void' }
}
},
onMenuClosed: {
type: { required: false },
description: 'Callback function called when the menu panel is closed',
table: {
category: 'Actions',
type: { summary: 'onMenuClosed(): void' }
}
}
},
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,
closeOnMouseLeave: false,
onMenuOpened: action('onMenuOpened'),
onMenuClosed: action('onMenuClosed')
};

const Template = (args) => ({
template: hbs`<OSS::ContextMenu @items={{this.items}}
@label={{this.label}}
@skin={{this.skin}}
@loading={{this.loading}}
@icon={{this.icon}}
@theme={{this.theme}}
@square={{this.square}}
@countDown={{this.countDown}}
@loadingOptions={{this.loadingOptions}}
@iconUrl={{this.iconUrl}}
@size={{this.size}}
disabled={{this.disabled}}
@closeOnMouseLeave={{this.closeOnMouseLeave}}
@onMenuOpened={{this.onMenuOpened}}
@onMenuClosed={{this.onMenuClosed}}
/>`,
context: args
});

export const BasicUsage = Template.bind({});
BasicUsage.args = defaultArgs;
76 changes: 76 additions & 0 deletions addon/components/o-s-s/context-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Component from '@glimmer/component';
import type { OSSButtonArgs } from './button';
import type { ensureSafeComponent } from '@embroider/util';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export type ContextMenuItem = {
items?: ContextMenuItem[];
groupKey?: string;
rowRenderer?: ReturnType<typeof ensureSafeComponent>;
action: (event?: MouseEvent) => void | boolean;
[key: string]: unknown;
};

interface OSSContextMenuArgs extends OSSButtonArgs {
items: ContextMenuItem[];
closeOnMouseLeave?: boolean;
onMenuOpened?: () => {};
onMenuClosed?: () => {};
}

export default class OSSContextMenuComponent extends Component<OSSContextMenuArgs> {
@tracked displayContextMenuPanel: boolean = false;
@tracked declare referenceTarget: HTMLElement;
@tracked private contextMenuPanels: HTMLElement[] = [];

@action
registerMenuTrigger(element: HTMLElement): void {
this.referenceTarget = element;
}

@action
toggleContextMenuPanel(event: PointerEvent): void {
event.stopPropagation();
if (this.args.loading) return;
this.displayContextMenuPanel = !this.displayContextMenuPanel;
this.displayContextMenuPanel ? this.args.onMenuOpened?.() : this.args.onMenuClosed?.();
}

@action
onContextMenuPanelMouseLeave(): void {
if (!this.args.closeOnMouseLeave) return;
this.hideContextMenuPanel();
}

@action
registerContextMenuPanel(element: HTMLElement): void {
this.contextMenuPanels.push(element);
}

@action
unregisterContextMenuPanel(element: HTMLElement): void {
this.contextMenuPanels = this.contextMenuPanels.filter((el) => 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?.();
}
}
Loading
Loading