From cdf0be3c0459919873cd0827058ef300c8f8f310 Mon Sep 17 00:00:00 2001 From: Antoine Prentout <111493996+aprentout@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:21:06 +0100 Subject: [PATCH 01/14] [Infinite select] Add ability to group by items (#622) * [Infinite select] Add ability to group by items * Fix pr feedbacks --- addon/components/o-s-s/infinite-select.hbs | 48 +++++++----- .../o-s-s/infinite-select.stories.js | 11 +++ addon/components/o-s-s/infinite-select.ts | 29 ++++++- app/styles/base/_infinite-select.less | 17 +++- tests/dummy/app/controllers/input.ts | 10 ++- .../components/o-s-s/infinite-select-test.js | 78 +++++++++++++++++++ 6 files changed, 166 insertions(+), 27 deletions(-) diff --git a/addon/components/o-s-s/infinite-select.hbs b/addon/components/o-s-s/infinite-select.hbs index 593f6e77f..2f8b6a692 100644 --- a/addon/components/o-s-s/infinite-select.hbs +++ b/addon/components/o-s-s/infinite-select.hbs @@ -28,25 +28,34 @@ {{#if (and @loading (not @loadingMore))}} {{else}} - {{#each this.items as |item index|}} -
  • - {{#if (has-block "option")}} - {{yield item index to="option"}} - {{else}} - {{get item this.itemLabel}} + {{#each-in this.groups as |groupKey items|}} + {{else}}
    {{#if (has-block "empty-state")}} @@ -68,8 +77,7 @@
    {{/if}} - {{/each}} - + {{/each-in}} {{#if @loadingMore}} {{/if}} diff --git a/addon/components/o-s-s/infinite-select.stories.js b/addon/components/o-s-s/infinite-select.stories.js index e53b655e8..0f2cfb865 100644 --- a/addon/components/o-s-s/infinite-select.stories.js +++ b/addon/components/o-s-s/infinite-select.stories.js @@ -237,3 +237,14 @@ EmptyState.args = { items: [] } }; + +export const WithGroupsBlock = Template.bind({}); +WithGroupsBlock.args = { + ...defaultArgs, + ...{ + items: FAKE_DATA.map((item, index) => ({ + ...item, + groupKey: index % 2 === 0 ? 'Group A' : 'Group B' + })) + } +}; diff --git a/addon/components/o-s-s/infinite-select.ts b/addon/components/o-s-s/infinite-select.ts index e4eda6d49..7a138dd59 100644 --- a/addon/components/o-s-s/infinite-select.ts +++ b/addon/components/o-s-s/infinite-select.ts @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { assert } from '@ember/debug'; import { action } from '@ember/object'; +import { helper } from '@ember/component/helper'; import { guidFor } from '@ember/object/internals'; import type { SkinType } from './button'; @@ -35,8 +36,11 @@ interface InfiniteSelectArgs { type InfinityItem = { selected: boolean; + groupKey?: string; }; +type InfinityItemByGroup = Record; + const DEFAULT_ITEM_LABEL = 'name'; export default class OSSInfiniteSelect extends Component { @@ -58,6 +62,29 @@ export default class OSSInfiniteSelect extends Component { assert('[component][OSS::InfiniteSelect] `onSelect` action is mandatory', typeof this.args.onSelect === 'function'); } + findItemIndex = helper((_, { item }: { item: InfinityItem }): number => { + return Object.values(this.groups) + .flat() + .findIndex((element) => element === item); + }); + + get groups(): InfinityItemByGroup { + return (this.args.items ?? []).reduce((groups, item) => { + const groupKey = item.groupKey ?? '_ungrouped_'; + if (!groups[groupKey]) { + groups[groupKey] = []; + } + + groups[groupKey]!.push(item); + + return groups; + }, {}); + } + + get lastKey(): string | undefined { + return Object.keys(this.groups).slice(-1)[0]; + } + get enableKeyboard(): boolean { return this.args.enableKeyboard ?? false; } @@ -170,7 +197,7 @@ export default class OSSInfiniteSelect extends Component { } @action - handleItemHover(index: number): void { + handleItemHover(index: number, event: MouseEvent): void { if (document.activeElement === this.searchInput) { return; } diff --git a/app/styles/base/_infinite-select.less b/app/styles/base/_infinite-select.less index 6667a9480..f0eaa1b48 100644 --- a/app/styles/base/_infinite-select.less +++ b/app/styles/base/_infinite-select.less @@ -5,6 +5,16 @@ ul { padding: 0; } + + ul.group-container { + display: flex; + flex-direction: column; + gap: var(--spacing-px-3); + } + + hr.group-separator { + margin: 0 var(--spacing-px-12); + } } .upf-infinite-select--absolute { @@ -20,15 +30,14 @@ margin: 0; overflow: hidden auto; overscroll-behavior: contain; + display: flex; + flex-direction: column; + gap: var(--spacing-px-6); } .upf-infinite-select__item { .upf-floating-menu__item; - &:not(:first-child) { - margin-top: var(--spacing-px-3); - } - &:hover { background-color: var(--color-gray-100); } diff --git a/tests/dummy/app/controllers/input.ts b/tests/dummy/app/controllers/input.ts index 557b3f967..b21bc54b9 100644 --- a/tests/dummy/app/controllers/input.ts +++ b/tests/dummy/app/controllers/input.ts @@ -26,9 +26,15 @@ export default class Input extends Controller { 'Black Panther', 'Captain Marvel' ]; - @tracked items: { name: string; label: string }[] = [ + @tracked items: { name: string; label: string; groupKey?: string }[] = [ { name: 'foo', label: 'foo' }, - { name: 'bar', label: 'bar' } + { name: 'bar', label: 'bar' }, + { name: 'banana', label: 'banana', groupKey: 'fruit' }, + { name: 'lettuce', label: 'lettuce', groupKey: 'vegetable' }, + { name: 'orange', label: 'orange', groupKey: 'fruit' }, + { name: 'carrot', label: 'carrot', groupKey: 'vegetable' }, + { name: 'apple', label: 'apple', groupKey: 'fruit' }, + { name: 'spinach', label: 'spinach', groupKey: 'vegetable' } ]; @tracked selectedItem: { name: string; label: string } | undefined = this.items[0]; @tracked emailInputValue: string = ''; diff --git a/tests/integration/components/o-s-s/infinite-select-test.js b/tests/integration/components/o-s-s/infinite-select-test.js index 30edb3b82..1dd910487 100644 --- a/tests/integration/components/o-s-s/infinite-select-test.js +++ b/tests/integration/components/o-s-s/infinite-select-test.js @@ -23,6 +23,16 @@ const FAKE_DATA = [ { name: 'Wolverine', characters: 'James Howlett' } ]; +const FAKE_DATA_GROUPED = [ + { name: 'banana', label: 'banana', groupKey: 'fruit' }, + { name: 'lettuce', label: 'lettuce', groupKey: 'vegetable' }, + { name: 'orange', label: 'orange', groupKey: 'fruit' }, + { name: 'carrot', label: 'carrot', groupKey: 'vegetable' }, + { name: 'apple', label: 'apple', groupKey: 'fruit' }, + { name: 'spinach', label: 'spinach', groupKey: 'vegetable' }, + { name: 'other', label: 'other' } +]; + module('Integration | Component | o-s-s/infinite-select', function (hooks) { setupRenderingTest(hooks); setupIntl(hooks); @@ -508,4 +518,72 @@ module('Integration | Component | o-s-s/infinite-select', function (hooks) { }); }); }); + + module('When data has groupKey', function (hooks) { + hooks.beforeEach(function () { + this.items = FAKE_DATA_GROUPED; + this.onSelect = () => {}; + }); + + test('For each different groupKey, a group is created', async function (assert) { + await renderGrouped(); + + assert.dom('.upf-infinite-select__items-container ul').exists({ count: 3 }); + assert + .dom('.upf-infinite-select__items-container ul:nth-of-type(1)') + .hasAttribute('data-control-name', 'infinite-select-group-fruit'); + assert + .dom('.upf-infinite-select__items-container ul:nth-of-type(2)') + .hasAttribute('data-control-name', 'infinite-select-group-vegetable'); + assert + .dom('.upf-infinite-select__items-container ul:nth-of-type(3)') + .hasAttribute('data-control-name', 'infinite-select-group-_ungrouped_'); + }); + + test('Items are placed in their respective group', async function (assert) { + await renderGrouped(); + + const groupFruitItems = Array.from( + document.querySelectorAll('.upf-infinite-select__items-container ul:nth-of-type(1) .upf-infinite-select__item') + ).map((el) => el.textContent.trim()); + const groupVegetableItems = Array.from( + document.querySelectorAll('.upf-infinite-select__items-container ul:nth-of-type(2) .upf-infinite-select__item') + ).map((el) => el.textContent.trim()); + const groupUngroupedItems = Array.from( + document.querySelectorAll('.upf-infinite-select__items-container ul:nth-of-type(3) .upf-infinite-select__item') + ).map((el) => el.textContent.trim()); + + assert.deepEqual(groupFruitItems, ['banana', 'orange', 'apple']); + assert.deepEqual(groupVegetableItems, ['lettuce', 'carrot', 'spinach']); + assert.deepEqual(groupUngroupedItems, ['other']); + }); + + module('Separators', function () { + test('A separator is rendered between each group', async function (assert) { + await renderGrouped(); + + assert.dom('.upf-infinite-select__items-container hr.group-separator').exists({ count: 2 }); + }); + + test('There is no separator after the last group', async function (assert) { + await renderGrouped(); + + assert.dom('.upf-infinite-select__items-container ul:last-of-type + hr.group-separator').doesNotExist(); + }); + }); + }); + + async function renderGrouped(): Promise { + await render( + hbs` + <:option as |item index|> +
    {{item.label}}
    + +
    ` + ); + } }); From 5ff494e09846fe5c8d00f56ca20cd516bbaaee3b Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Mon, 26 Jan 2026 14:59:33 +0100 Subject: [PATCH 02/14] [WIP] Add new OSS::ContextMenu::Panel component --- addon/components/o-s-s/context-menu/panel.hbs | 76 ++++++ addon/components/o-s-s/context-menu/panel.ts | 147 ++++++++++++ addon/components/o-s-s/infinite-select.ts | 12 +- addon/utils/attach-dropdown.ts | 2 +- app/components/o-s-s/context-menu/panel.js | 1 + .../app/components/panel/example-row.hbs | 4 + .../dummy/app/components/panel/example-row.ts | 7 + tests/dummy/app/controllers/input.ts | 223 ++++++++++++++++++ tests/dummy/app/templates/input.hbs | 25 ++ .../o-s-s/context-menu/panel-test.ts | 26 ++ 10 files changed, 516 insertions(+), 7 deletions(-) create mode 100644 addon/components/o-s-s/context-menu/panel.hbs create mode 100644 addon/components/o-s-s/context-menu/panel.ts create mode 100644 app/components/o-s-s/context-menu/panel.js create mode 100644 tests/dummy/app/components/panel/example-row.hbs create mode 100644 tests/dummy/app/components/panel/example-row.ts create mode 100644 tests/integration/components/o-s-s/context-menu/panel-test.ts diff --git a/addon/components/o-s-s/context-menu/panel.hbs b/addon/components/o-s-s/context-menu/panel.hbs new file mode 100644 index 000000000..58214630a --- /dev/null +++ b/addon/components/o-s-s/context-menu/panel.hbs @@ -0,0 +1,76 @@ +
    + {{#if this.isInitialized}} + {{#in-element this.portalTarget insertBefore=null}} + + <:option as |item index|> + {{#if item.items}} + + {{else if item.rowRenderer}} + + {{else}} + + {{/if}} + + + {{/in-element}} + + {{#if this.displaySubMenu}} + + {{/if}} + {{/if}} +
    \ No newline at end of file diff --git a/addon/components/o-s-s/context-menu/panel.ts b/addon/components/o-s-s/context-menu/panel.ts new file mode 100644 index 000000000..9e4d73aae --- /dev/null +++ b/addon/components/o-s-s/context-menu/panel.ts @@ -0,0 +1,147 @@ +import { action } from '@ember/object'; +import { guidFor } from '@ember/object/internals'; +import { next, scheduleOnce } from '@ember/runloop'; +import { isTesting } from '@embroider/macros'; +import type { ensureSafeComponent } from '@embroider/util'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import attachDropdown from '@upfluence/oss-components/utils/attach-dropdown'; + +///////////////////// TODO EXPORT IN PROPER FILE WHEN IMPLEMENTATION IS DONE +export type ContextMenuItem = { + items?: ContextMenuItem[]; + groupKey?: string; + rowRenderer?: ReturnType; + action: () => void | boolean; + [key: string]: unknown; +}; +////////////////////// + +interface OSSContextMenuPanelComponentSignature { + items: ContextMenuItem[]; + referenceTarget?: HTMLElement; + placement: 'bottom-start' | 'right-start'; + offset: number; + onMouseLeave?: (event: MouseEvent) => void; +} + +export default class OSSContextMenuPanelComponent extends Component { + declare portalTarget: HTMLElement; + declare currentPanel: HTMLElement; + portalId: string = guidFor(this); + + @tracked isInitialized: boolean = false; + @tracked displaySubMenu: boolean = false; + @tracked subItems: ContextMenuItem[] = []; + @tracked subReferenceTarget: HTMLElement | null = null; + @tracked subReferenceIndex: number = -1; + + @tracked subPanelElement: HTMLElement | null = null; + + cleanupDrodpownAutoplacement?: () => void; + + constructor(owner: unknown, args: OSSContextMenuPanelComponentSignature) { + super(owner, args); + } + + @action + registerPanel(element: HTMLElement): void { + this.currentPanel = element; + scheduleOnce('afterRender', this, () => { + const referenceTarget = this.args.referenceTarget; + const floatingTarget = document.querySelector(`#${this.portalId}`); + if (referenceTarget && floatingTarget) { + this.cleanupDrodpownAutoplacement = attachDropdown( + referenceTarget as HTMLElement, + floatingTarget as HTMLElement, + { + placement: this.args.placement, + offset: { mainAxis: this.args.offset, crossAxis: -12 }, + width: 250, + maxHeight: 480 + } + ); + } + }); + } + + @action + registerPanelContainer(element: HTMLElement): void { + this.portalTarget = isTesting() ? element : document.body; + this.isInitialized = true; + } + + @action + openSubMenu(items: ContextMenuItem[], index: number, event: PointerEvent): void { + if (this.subReferenceIndex === index) return; + this.displaySubMenu = false; + + next(() => { + this.subItems = items; + this.displaySubMenu = true; + // event.target or // currentTarget etc to focus the proper element on click :) + this.subReferenceTarget = event.target as HTMLElement; + this.subReferenceIndex = index; + }); + } + + @action + closeSubMenu(): void { + this.clearSubMenu(); + } + + @action + toggleSubMenu(items: ContextMenuItem[], index: number, event: PointerEvent): void { + if (this.subReferenceIndex === index) { + this.clearSubMenu(); + return; + } + this.openSubMenu(items, index, event); + } + + @action + noop(): void {} + + @action + onClickOutside(_: HTMLElement, event: MouseEvent): void { + console.log('click outside context menu panel'); + } + + @action + onSubPanelMouseLeave(event: MouseEvent): void { + this.clearSubMenu(); + + if (this.currentPanel && this.currentPanel.contains(event.relatedTarget as HTMLElement)) { + return; + } + this.args.onMouseLeave?.(event); + } + + @action + registerSubPanel(element: HTMLElement): void { + this.subPanelElement = element; + } + + @action + mouseLeave(event: MouseEvent): void { + if (this.subPanelElement && this.subPanelElement.contains(event.relatedTarget as HTMLElement)) { + return; + } + + this.args.onMouseLeave?.(event); + } + + @action + onScroll(): void { + this.clearSubMenu(); + } + + private clearSubMenu(): void { + this.displaySubMenu = false; + this.subReferenceIndex = -1; + this.subReferenceTarget = null; + this.subItems = []; + } + + // Fix the blink issue when moving the mouse on already opened menu +} diff --git a/addon/components/o-s-s/infinite-select.ts b/addon/components/o-s-s/infinite-select.ts index 7a138dd59..91f872608 100644 --- a/addon/components/o-s-s/infinite-select.ts +++ b/addon/components/o-s-s/infinite-select.ts @@ -27,7 +27,7 @@ interface InfiniteSelectArgs { skin?: 'default' | 'smart'; action?: InfiniteSelectAction; - onSelect: (item: InfinityItem) => void; + onSelect: (item: InfinityItem, event: PointerEvent | KeyboardEvent) => void; onSearch?: (keyword: string) => void; onBottomReached?: () => void; onClose?: () => void; @@ -136,9 +136,9 @@ export default class OSSInfiniteSelect extends Component { } @action - didSelectItem(item: InfinityItem, event?: PointerEvent) { + didSelectItem(item: InfinityItem, event: PointerEvent | KeyboardEvent) { event?.stopPropagation(); - this.args.onSelect(item); + this.args.onSelect(item, event); } @action @@ -249,9 +249,9 @@ export default class OSSInfiniteSelect extends Component { } } - private handleEnter(self: any, e: KeyboardEvent): void { - self.didSelectItem(self.items[self._focusElement]); - e.preventDefault(); + private handleEnter(self: any, event: KeyboardEvent): void { + self.didSelectItem(self.items[self._focusElement], event); + event.preventDefault(); } private handleTab(self: any): void { diff --git a/addon/utils/attach-dropdown.ts b/addon/utils/attach-dropdown.ts index a24bf1e16..6e67551d7 100644 --- a/addon/utils/attach-dropdown.ts +++ b/addon/utils/attach-dropdown.ts @@ -13,7 +13,7 @@ import { } from '@floating-ui/dom'; export type AttachmentOptions = { - offset?: number; + offset?: number | { mainAxis: number; crossAxis: number }; width?: number; maxHeight?: number; maxWidth?: number; diff --git a/app/components/o-s-s/context-menu/panel.js b/app/components/o-s-s/context-menu/panel.js new file mode 100644 index 000000000..015c6c62a --- /dev/null +++ b/app/components/o-s-s/context-menu/panel.js @@ -0,0 +1 @@ +export { default } from '@upfluence/oss-components/components/o-s-s/context-menu/panel'; diff --git a/tests/dummy/app/components/panel/example-row.hbs b/tests/dummy/app/components/panel/example-row.hbs new file mode 100644 index 000000000..61ddd3298 --- /dev/null +++ b/tests/dummy/app/components/panel/example-row.hbs @@ -0,0 +1,4 @@ +
    + icicici - + {{@item.title}} +
    \ No newline at end of file diff --git a/tests/dummy/app/components/panel/example-row.ts b/tests/dummy/app/components/panel/example-row.ts new file mode 100644 index 000000000..7ddf85d88 --- /dev/null +++ b/tests/dummy/app/components/panel/example-row.ts @@ -0,0 +1,7 @@ +import Component from '@glimmer/component'; + +interface PanelExampleRowArgs { + item: any; +} + +export default class PanelExampleRow extends Component {} diff --git a/tests/dummy/app/controllers/input.ts b/tests/dummy/app/controllers/input.ts index b21bc54b9..8ffab7001 100644 --- a/tests/dummy/app/controllers/input.ts +++ b/tests/dummy/app/controllers/input.ts @@ -5,6 +5,8 @@ import { action } from '@ember/object'; import { countries, type CountryData } from '@upfluence/oss-components/utils/country-codes'; import type { Feedback, FormInstance } from '@upfluence/oss-components/services/form-manager'; import { isBlank } from '@ember/utils'; +import type { ContextMenuItem } from '@upfluence/oss-components/components/o-s-s/context-menu/panel'; +import { ensureSafeComponent } from '@embroider/util'; export default class Input extends Controller { @tracked shopUrl: string = ''; @@ -50,6 +52,204 @@ export default class Input extends Controller { @tracked formInstance?: FormInstance; @tracked formFieldValue: string = ''; + @tracked declare referenceTarget: HTMLElement; + @tracked declare contextMenuPanel: HTMLElement; + @tracked displayContextMenuPanel: boolean = false; + + subMenu2 = [ + { + icon: { icon: 'fa-arrow-progress' }, + title: 'First', + action: () => { + console.log('click on first'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Second', + action: () => { + console.log('click on second'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Third', + action: () => { + console.log('click on third'); + } + } + ]; + + subMenu1 = [ + { + icon: { icon: 'fa-arrow-progress' }, + title: 'First sub action', + items: this.subMenu2, + groupKey: 'actions', + action: () => { + console.log('click on first'); + } + }, + { + prefixIcon: { icon: 'fa-arrow-progress' }, + title: 'Second sub action', + items: this.subMenu2, + groupKey: 'other', + action: () => { + console.log('click on second'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }, + { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + } + ]; + + @tracked customContextMenuItems: ContextMenuItem[] = [ + { + prefixIcon: { icon: 'fa-arrow-progress' }, + title: 'Move to next step', + items: this.subMenu1, + groupKey: 'actions', + action: () => { + console.log('click on move to next step'); + } + }, + { + prefixIcon: { icon: 'fa-paper-plane' }, + title: 'Direct action', + groupKey: 'actions', + action: () => { + console.log('click on direct action'); + } + }, + { + prefixIcon: { icon: 'fa-paper-plane' }, + title: 'Custom action', + groupKey: 'custom', + rowRenderer: ensureSafeComponent('panel/example-row', this), + action: () => { + console.log('click on direct action'); + } + }, + { + prefixIcon: { icon: 'fa-trash' }, + title: 'Delete', + groupKey: 'overall', + action: () => { + console.log('click on delete'); + } + } + ]; + countries: CountryData[] = countries; allowedCurrencies: { code: string; symbol: string }[] = [ { code: 'USD', symbol: '$' }, @@ -178,4 +378,27 @@ export default class Input extends Controller { onInfiniteSelectOptionChange(value: boolean): void { console.log('Infinite select option changed', value); } + + @action + registerMenuTrigger(element: HTMLElement): void { + this.referenceTarget = element; + } + + @action + toggleContextMenuPanel(): void { + this.displayContextMenuPanel = !this.displayContextMenuPanel; + } + + @action + onContextMenuPanelMouseLeave(event: MouseEvent): void { + if (this.referenceTarget && this.referenceTarget.contains(event.relatedTarget as HTMLElement)) { + return; + } + this.displayContextMenuPanel = false; + } + + @action + registerContextMenuPanel(element: HTMLElement): void { + this.contextMenuPanel = element; + } } diff --git a/tests/dummy/app/templates/input.hbs b/tests/dummy/app/templates/input.hbs index c4b0f9db4..d3ed79df0 100644 --- a/tests/dummy/app/templates/input.hbs +++ b/tests/dummy/app/templates/input.hbs @@ -95,6 +95,31 @@ +
    +
    + Context menu +
    +
    + + {{#if this.displayContextMenuPanel}} + + {{/if}} +
    +
    +
    diff --git a/tests/integration/components/o-s-s/context-menu/panel-test.ts b/tests/integration/components/o-s-s/context-menu/panel-test.ts new file mode 100644 index 000000000..d2f6c94a2 --- /dev/null +++ b/tests/integration/components/o-s-s/context-menu/panel-test.ts @@ -0,0 +1,26 @@ +// import { module, test } from 'qunit'; +// import { setupRenderingTest } from 'ember-qunit'; +// import { render } from '@ember/test-helpers'; +// import { hbs } from 'ember-cli-htmlbars'; + +// module('Integration | Component | o-s-s/context-menu/panel', function (hooks) { +// setupRenderingTest(hooks); + +// test('it renders', async function (assert) { +// // Set any properties with this.set('myProperty', 'value'); +// // Handle any actions with this.set('myAction', function (val) { ... }); + +// await render(hbs``); + +// assert.dom().hasText(''); + +// // Template block usage: +// await render(hbs` +// +// template block text +// +// `); + +// assert.dom().hasText('template block text'); +// }); +// }); From 6e91c1165bfdf972ca07761c804bbede2f1056b9 Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Tue, 27 Jan 2026 16:54:34 +0100 Subject: [PATCH 03/14] [WIP] Improve component & add tests --- addon/components/o-s-s/context-menu/panel.hbs | 7 +- .../o-s-s/context-menu/panel.stories.js | 119 +++++++++ addon/components/o-s-s/context-menu/panel.ts | 22 +- addon/modifiers/scroll-shadow.ts | 1 + addon/utils/attach-dropdown.ts | 2 +- app/styles/organisms/context-menu.less | 5 + app/styles/oss-components.less | 1 + tests/dummy/app/controllers/input.ts | 6 +- tests/dummy/app/templates/input.hbs | 2 +- .../o-s-s/context-menu/panel-test.ts | 242 ++++++++++++++++-- 10 files changed, 371 insertions(+), 36 deletions(-) create mode 100644 addon/components/o-s-s/context-menu/panel.stories.js create mode 100644 app/styles/organisms/context-menu.less diff --git a/addon/components/o-s-s/context-menu/panel.hbs b/addon/components/o-s-s/context-menu/panel.hbs index 58214630a..0a6106e67 100644 --- a/addon/components/o-s-s/context-menu/panel.hbs +++ b/addon/components/o-s-s/context-menu/panel.hbs @@ -8,9 +8,8 @@ @onClose={{this.closeDropdown}} @enableKeyboard={{true}} id={{this.portalId}} - class="margin-top-px-0 test" + class="margin-top-px-0 context-menu-panel__dropdown" {{on-click-outside this.onClickOutside}} - {{on "click" this.noop}} {{did-insert this.registerPanel}} {{on "scroll" this.onScroll}} {{on "mouseleave" this.mouseLeave}} @@ -35,7 +34,6 @@ @onSelect={{item.action}} {{on "mouseenter" (fn this.openSubMenu item.items index)}} {{on "click" (fn this.toggleSubMenu item.items index)}} - {{! DATA-HERE TO mark is as triggerable ? }} /> {{else if item.rowRenderer}} @@ -56,6 +54,7 @@ @disabled={{item.disabled}} @onSelect={{item.action}} {{on "mouseenter" this.closeSubMenu}} + {{on "click" this.closeSubMenu}} /> {{/if}} @@ -67,7 +66,7 @@ @referenceTarget={{this.subReferenceTarget}} @items={{this.subItems}} @placement="right-start" - @offset={{12}} + @offset={{this.subPanelOffset}} @onMouseLeave={{this.onSubPanelMouseLeave}} {{did-insert this.registerSubPanel}} /> diff --git a/addon/components/o-s-s/context-menu/panel.stories.js b/addon/components/o-s-s/context-menu/panel.stories.js new file mode 100644 index 000000000..82163d7af --- /dev/null +++ b/addon/components/o-s-s/context-menu/panel.stories.js @@ -0,0 +1,119 @@ +import { action } from '@storybook/addon-actions'; +import hbs from 'htmlbars-inline-precompile'; + +export default { + title: 'Components/OSS::ContextMenu::Panel', + component: 'o-s-s/context-menu/panel', + 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' } + }, + referenceTarget: { + description: 'The reference HTMLElement to which the context menu panel is anchored', + table: { + type: { summary: 'HTMLElement' }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'object' } + }, + offset: { + type: { required: false }, + description: + 'The offset distance between the context menu panel and its reference target. Can be a number or an object specifying mainAxis and crossAxis offsets.', + table: { + type: { summary: 'number | { mainAxis: number; crossAxis: number }' }, + defaultValue: { summary: 0 } + }, + control: { type: 'object' } + }, + placement: { + type: { required: false }, + description: + 'The placement of the context menu panel relative to its reference target. Options are "bottom-start" or "right-start".', + table: { + type: { summary: '"bottom-start" | "right-start"' }, + defaultValue: { summary: 'bottom-start' } + }, + control: { + type: 'select', + options: ['bottom-start', 'right-start'] + } + }, + onMouseLeave: { + type: { required: false }, + description: 'Callback function called when the mouse leaves the context menu panel', + table: { + category: 'Actions', + type: { summary: 'onMouseLeave(event: MouseEvent): void' } + } + }, + postRender: { + table: { + disable: true + } + }, + isInitialized: { + table: { + disable: true + } + } + }, + parameters: { + docs: { + description: { + component: + 'The `OSS::ContextMenu::Panel` component displays a context menu panel anchored to a specified reference target. It supports nested submenus, customizable placement, and offset options. The panel can trigger actions when menu items are selected and handle mouse leave events.' + } + } + } +}; + +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, + offset: 6, + placement: 'bottom-start', + onMouseLeave: action('onMouseLeave'), + isInitialized: false, + postRender(self, element) { + self.set('referenceTarget', element); + self.set('isInitialized', true); + } +}; + +const Template = (args) => ({ + template: hbs` +
    + {{#if this.isInitialized}} + + {{/if}} +
    + `, + context: args +}); + +export const BasicUsage = Template.bind({}); +BasicUsage.args = defaultArgs; diff --git a/addon/components/o-s-s/context-menu/panel.ts b/addon/components/o-s-s/context-menu/panel.ts index 9e4d73aae..3f4306642 100644 --- a/addon/components/o-s-s/context-menu/panel.ts +++ b/addon/components/o-s-s/context-menu/panel.ts @@ -7,11 +7,13 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import attachDropdown from '@upfluence/oss-components/utils/attach-dropdown'; +export const SUBPANEL_OFFSET = -6; + ///////////////////// TODO EXPORT IN PROPER FILE WHEN IMPLEMENTATION IS DONE export type ContextMenuItem = { items?: ContextMenuItem[]; groupKey?: string; - rowRenderer?: ReturnType; + rowRenderer?: ReturnType; // move to Component action: () => void | boolean; [key: string]: unknown; }; @@ -21,7 +23,7 @@ interface OSSContextMenuPanelComponentSignature { items: ContextMenuItem[]; referenceTarget?: HTMLElement; placement: 'bottom-start' | 'right-start'; - offset: number; + offset: number | { mainAxis: number; crossAxis: number }; onMouseLeave?: (event: MouseEvent) => void; } @@ -40,6 +42,11 @@ export default class OSSContextMenuPanelComponent extends Component void; + subPanelOffset: { mainAxis: number; crossAxis: number } = { + mainAxis: 0, + crossAxis: SUBPANEL_OFFSET + }; + constructor(owner: unknown, args: OSSContextMenuPanelComponentSignature) { super(owner, args); } @@ -56,7 +63,7 @@ export default class OSSContextMenuPanelComponent extends Component { this.subItems = items; this.displaySubMenu = true; - // event.target or // currentTarget etc to focus the proper element on click :) - this.subReferenceTarget = event.target as HTMLElement; + const parentElement = (event.target as HTMLElement).closest('li[role="button"]') as HTMLElement; + this.subReferenceTarget = parentElement ? parentElement : (event.target as HTMLElement); this.subReferenceIndex = index; }); } @@ -104,7 +111,8 @@ export default class OSSContextMenuPanelComponent extends Component observedEntry.target.clientHeight; + console.log('hasScrollbar ??', hasScrollbar); if (hasScrollbar && !state.element.classList.contains(SCROLL_SHADOW_CLASS)) { window.requestAnimationFrame(() => { state.element.classList.add(SCROLL_SHADOW_CLASS, this.colorCSSClass(args.named.color)); diff --git a/addon/utils/attach-dropdown.ts b/addon/utils/attach-dropdown.ts index 6e67551d7..8310474a5 100644 --- a/addon/utils/attach-dropdown.ts +++ b/addon/utils/attach-dropdown.ts @@ -62,7 +62,7 @@ export default function attachDropdown( } if (mergedOptions.maxHeight) { - floatingStyle.maxHeight = `${floatingStyle.maxHeight}px`; + floatingStyle.maxHeight = `${mergedOptions.maxHeight}px`; elements.floating.style.setProperty('--floating-max-height', `${mergedOptions.maxHeight}px`); } diff --git a/app/styles/organisms/context-menu.less b/app/styles/organisms/context-menu.less new file mode 100644 index 000000000..d812bdddb --- /dev/null +++ b/app/styles/organisms/context-menu.less @@ -0,0 +1,5 @@ +.context-menu-panel__dropdown { + .upf-infinite-select__item { + padding: 0px; + } +} diff --git a/app/styles/oss-components.less b/app/styles/oss-components.less index 3ca48c95f..e710db7e9 100644 --- a/app/styles/oss-components.less +++ b/app/styles/oss-components.less @@ -88,4 +88,5 @@ @import 'organisms/carousel'; @import 'organisms/wizard-container'; @import 'organisms/marketing-banner'; +@import 'organisms/context-menu'; @import 'animations/smart-rotating-gradient'; diff --git a/tests/dummy/app/controllers/input.ts b/tests/dummy/app/controllers/input.ts index 8ffab7001..60f3dfd33 100644 --- a/tests/dummy/app/controllers/input.ts +++ b/tests/dummy/app/controllers/input.ts @@ -94,7 +94,7 @@ export default class Input extends Controller { prefixIcon: { icon: 'fa-arrow-progress' }, title: 'Second sub action', items: this.subMenu2, - groupKey: 'other', + groupKey: 'actions', action: () => { console.log('click on second'); } @@ -218,6 +218,7 @@ export default class Input extends Controller { prefixIcon: { icon: 'fa-arrow-progress' }, title: 'Move to next step', items: this.subMenu1, + // selected: true, groupKey: 'actions', action: () => { console.log('click on move to next step'); @@ -385,7 +386,8 @@ export default class Input extends Controller { } @action - toggleContextMenuPanel(): void { + toggleContextMenuPanel(event: PointerEvent): void { + event.stopPropagation(); this.displayContextMenuPanel = !this.displayContextMenuPanel; } diff --git a/tests/dummy/app/templates/input.hbs b/tests/dummy/app/templates/input.hbs index d3ed79df0..c2755d3b3 100644 --- a/tests/dummy/app/templates/input.hbs +++ b/tests/dummy/app/templates/input.hbs @@ -111,7 +111,7 @@ console.log('Sub Item 1 clicked') }, + { title: 'Sub Item 1.2', action: () => console.log('Sub Item 2 clicked') }, + { title: 'Sub Item 1.3', action: () => console.log('Sub Item 3 clicked') } + ]; + this.items = [ + { title: 'Item 1', action: () => console.log('Item 1 clicked'), items: this.subItems }, + { + title: 'Item 2', + action: () => {} + } + ]; + }); -// await render(hbs``); + test('it renders properly', async function (assert) { + await render(hbs``); -// assert.dom().hasText(''); + assert.dom('.context-menu-panel__dropdown').exists(); + assert.dom('.context-menu-panel__dropdown li .oss-infinite-select-option').exists({ count: 2 }); + }); -// // Template block usage: -// await render(hbs` -// -// template block text -// -// `); + test('When referenceTarget is passed, it attaches and moves with the target', async function (assert) { + await render(hbs` + {{#if this.isInitialized}} + + {{/if}} + + + `); -// assert.dom().hasText('template block text'); -// }); -// }); + const buttonRef = document.querySelector('#second_button') as HTMLElement; + + this.set('referenceTarget', buttonRef); + this.set('isInitialized', true); + await settled(); + const panelRef = document.querySelector('.context-menu-panel__dropdown') as HTMLElement; + + assert.equal(panelRef.style.top, '36px'); + await waitUntil(() => panelRef.style.left === '63.5px', { timeout: 300 }); + assert.equal(panelRef.style.left, '63.5px'); + buttonRef.style.marginLeft = '50px'; + await settled(); + assert.equal(panelRef.style.top, '36px'); + await waitUntil(() => panelRef.style.left === '113.5px', { timeout: 300 }); + assert.equal(panelRef.style.left, '113.5px'); + }); + + module('placement', function () { + test('When placement is set to right-start, it positions the panel accordingly', async function (assert) { + await render(hbs` + {{#if this.isInitialized}} + + {{/if}} + + `); + + const buttonRef = document.querySelector('#trigger') as HTMLElement; + + this.set('referenceTarget', buttonRef); + this.set('isInitialized', true); + const panelRef = document.querySelector('.context-menu-panel__dropdown') as HTMLElement; + const qunitTestContainer = buttonRef.offsetParent as HTMLElement; + const leftPosition = buttonRef.getBoundingClientRect().left - qunitTestContainer.getBoundingClientRect().left; + await settled(); + const expectedLeftPosition = + Number(((leftPosition + buttonRef.getBoundingClientRect().width) * 2).toFixed(4)) + 'px'; + const expectedTopPosition = 0 + 'px'; + assert.equal(expectedLeftPosition, panelRef.style.left); + assert.equal(expectedTopPosition, panelRef.style.top); + }); + + test('When placement is set to bottom-start, it positions the panel accordingly', async function (assert) { + await render(hbs` + {{#if this.isInitialized}} + + {{/if}} + + `); + + const buttonRef = document.querySelector('#trigger') as HTMLElement; + + this.set('referenceTarget', buttonRef); + this.set('isInitialized', true); + const panelRef = document.querySelector('.context-menu-panel__dropdown') as HTMLElement; + const qunitTestContainer = buttonRef.offsetParent as HTMLElement; + const topPosition = buttonRef.getBoundingClientRect().top - qunitTestContainer.getBoundingClientRect().top; + await settled(); + const expectedLeftPosition = 0 + 'px'; + const expectedTopPosition = + Number(((topPosition + buttonRef.getBoundingClientRect().height) * 2).toFixed(4)) + 'px'; + assert.equal(expectedLeftPosition, panelRef.style.left); + assert.equal(expectedTopPosition, panelRef.style.top); + }); + }); + + module('Offset', function () { + test('When offset is 0, panel is stuck to his reference target', async function (assert) { + this.offset = 0; + await render(hbs` + {{#if this.isInitialized}} + + {{/if}} + + `); + + const buttonRef = document.querySelector('#trigger') as HTMLElement; + + this.set('referenceTarget', buttonRef); + this.set('isInitialized', true); + const panelRef = document.querySelector('.context-menu-panel__dropdown') as HTMLElement; + await settled(); + assert.equal(panelRef.style.top, '36px'); + }); + + test('When offset is defined, panel is is moved by that many pixels than defined to his reference target', async function (assert) { + this.offset = 20; + await render(hbs` + {{#if this.isInitialized}} + + {{/if}} + + `); + + const buttonRef = document.querySelector('#trigger') as HTMLElement; + + this.set('referenceTarget', buttonRef); + this.set('isInitialized', true); + const panelRef = document.querySelector('.context-menu-panel__dropdown') as HTMLElement; + await settled(); + assert.equal(panelRef.style.top, '56px'); + }); + }); + + module('When items are passed', function () { + module('if items has subitems', function () { + test('Clicking on item opens a submenu on the right of the trigger element', async function (assert) { + await render(hbs``); + + assert.dom('div.context-menu-panel__dropdown').exists({ count: 1 }); + await click('div.context-menu-panel__dropdown li:nth-of-type(1) .oss-infinite-select-option'); + assert.dom('div.context-menu-panel__dropdown').exists({ count: 2 }); + const panels = document.querySelectorAll('div.context-menu-panel__dropdown'); + const triggerPosition = ( + panels[0]?.querySelector( + 'div.context-menu-panel__dropdown li:nth-of-type(1) .oss-infinite-select-option' + ) as HTMLElement + ).getBoundingClientRect(); + + assert.equal(triggerPosition.x + triggerPosition.width, panels[1]?.getBoundingClientRect().x); + assert.equal(triggerPosition.y + SUBPANEL_OFFSET / 2, panels[1]?.getBoundingClientRect().y); + }); + + test('Submenu items are properly displayed', async function (assert) { + await render(hbs``); + + assert.dom('div.context-menu-panel__dropdown').exists({ count: 1 }); + await click('div.context-menu-panel__dropdown li:nth-of-type(1) .oss-infinite-select-option'); + assert.dom('div.context-menu-panel__dropdown').exists({ count: 2 }); + const panels = document.querySelectorAll('div.context-menu-panel__dropdown'); + assert.equal(panels[1]?.querySelectorAll('li').length, 3); + assert.equal( + (panels[1]?.querySelector('li:nth-of-type(1)') as HTMLElement).textContent?.trim(), + 'Sub Item 1.1' + ); + assert.equal( + (panels[1]?.querySelector('li:nth-of-type(2)') as HTMLElement).textContent?.trim(), + 'Sub Item 1.2' + ); + assert.equal( + (panels[1]?.querySelector('li:nth-of-type(3)') as HTMLElement).textContent?.trim(), + 'Sub Item 1.3' + ); + }); + }); + + test('If item has custom component it render the component instead of default one', async function (assert) { + class TestComponent extends Component {} + setComponentTemplate(hbs`
    {{@item.title}}
    ` as any, TestComponent); + const component = this.owner.register('component:test-component', TestComponent); + + this.items = [{ title: 'custom', action: () => console.log('Item 1 clicked'), rowRenderer: TestComponent }]; + await render(hbs``); + + assert.dom('.context-menu-panel__dropdown li [data-control-name="custom-row"]').exists(); + }); + }); + + test('When no items are passed, it displays the empty state', async function (assert) { + await render(hbs``); + + assert.dom('.upf-infinite-select__items-container--empty').exists(); + }); + + test('When mouse leave the panel, it triggers onMouseLeave action', async function (assert) { + this.onMouseLeaveStub = sinon.stub(); + await render(hbs``); + assert.ok(this.onMouseLeaveStub.notCalled); + await triggerEvent('.context-menu-panel__dropdown', 'mouseleave'); + assert.ok(this.onMouseLeaveStub.calledOnce); + }); +}); From 63798cc24656db5773b8a6bb6d4c9ae0da126765 Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Wed, 28 Jan 2026 10:34:32 +0100 Subject: [PATCH 04/14] [ContextMenu::Panel] Improve mouse event behavior & add scroll shadow --- addon/components/o-s-s/context-menu/panel.hbs | 111 +++++++++--------- addon/components/o-s-s/context-menu/panel.ts | 14 ++- addon/modifiers/scroll-shadow.ts | 1 - app/styles/organisms/context-menu.less | 22 +++- .../app/components/panel/example-row.hbs | 2 +- 5 files changed, 90 insertions(+), 60 deletions(-) diff --git a/addon/components/o-s-s/context-menu/panel.hbs b/addon/components/o-s-s/context-menu/panel.hbs index 0a6106e67..2658af80f 100644 --- a/addon/components/o-s-s/context-menu/panel.hbs +++ b/addon/components/o-s-s/context-menu/panel.hbs @@ -1,64 +1,69 @@
    {{#if this.isInitialized}} {{#in-element this.portalTarget insertBefore=null}} - - <:option as |item index|> - {{#if item.items}} - - {{else if item.rowRenderer}} - - {{else}} - - {{/if}} - - + + <:option as |item index|> + {{#if item.items}} + + {{else if item.rowRenderer}} + + {{else}} + + {{/if}} + + + {{/in-element}} {{#if this.displaySubMenu}} diff --git a/addon/components/o-s-s/context-menu/panel.ts b/addon/components/o-s-s/context-menu/panel.ts index 3f4306642..7a5e55402 100644 --- a/addon/components/o-s-s/context-menu/panel.ts +++ b/addon/components/o-s-s/context-menu/panel.ts @@ -41,6 +41,7 @@ export default class OSSContextMenuPanelComponent extends Component void; + onScrollbound = this.onScroll.bind(this); subPanelOffset: { mainAxis: number; crossAxis: number } = { mainAxis: 0, @@ -70,6 +71,14 @@ export default class OSSContextMenuPanelComponent extends Component observedEntry.target.clientHeight; - console.log('hasScrollbar ??', hasScrollbar); if (hasScrollbar && !state.element.classList.contains(SCROLL_SHADOW_CLASS)) { window.requestAnimationFrame(() => { state.element.classList.add(SCROLL_SHADOW_CLASS, this.colorCSSClass(args.named.color)); diff --git a/app/styles/organisms/context-menu.less b/app/styles/organisms/context-menu.less index d812bdddb..9bbfc36c2 100644 --- a/app/styles/organisms/context-menu.less +++ b/app/styles/organisms/context-menu.less @@ -1,5 +1,21 @@ -.context-menu-panel__dropdown { - .upf-infinite-select__item { - padding: 0px; +.context-menu-panel__scrollable-container { + height: fit-content; + position: absolute; + border-radius: var(--border-radius-sm); + z-index: 1100; + border: 1px solid var(--color-border-default); + background: var(--color-white); + + .context-menu-panel__dropdown { + border: none; + position: relative; + z-index: 0; + overflow: hidden; + max-height: fit-content; + border-radius: 0; + + .upf-infinite-select__item { + padding: 0px; + } } } diff --git a/tests/dummy/app/components/panel/example-row.hbs b/tests/dummy/app/components/panel/example-row.hbs index 61ddd3298..2c78cbb4c 100644 --- a/tests/dummy/app/components/panel/example-row.hbs +++ b/tests/dummy/app/components/panel/example-row.hbs @@ -1,4 +1,4 @@
    - icicici - + 🦆 - {{@item.title}}
    \ No newline at end of file From e2cad0a1210080bd7e624dd4e410f843e511b8ee Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Wed, 28 Jan 2026 11:01:19 +0100 Subject: [PATCH 05/14] [ContextMenu::Panel] Add styling --- addon/components/o-s-s/context-menu/panel.hbs | 2 ++ app/styles/organisms/context-menu.less | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/addon/components/o-s-s/context-menu/panel.hbs b/addon/components/o-s-s/context-menu/panel.hbs index 2658af80f..c09e07fdd 100644 --- a/addon/components/o-s-s/context-menu/panel.hbs +++ b/addon/components/o-s-s/context-menu/panel.hbs @@ -38,6 +38,7 @@ @onSelect={{item.action}} {{on "mouseenter" (fn this.openSubMenu item.items index)}} {{on "click" (fn this.toggleSubMenu item.items index)}} + class="infinit-select-option-panel-container {{if (eq index subReferenceIndex) 'active' ''}}" /> {{else if item.rowRenderer}} @@ -59,6 +60,7 @@ @onSelect={{item.action}} {{on "mouseenter" this.closeSubMenu}} {{on "click" this.closeSubMenu}} + class="infinit-select-option-panel-container" /> {{/if}} diff --git a/app/styles/organisms/context-menu.less b/app/styles/organisms/context-menu.less index 9bbfc36c2..76174a823 100644 --- a/app/styles/organisms/context-menu.less +++ b/app/styles/organisms/context-menu.less @@ -15,7 +15,21 @@ border-radius: 0; .upf-infinite-select__item { - padding: 0px; + padding: var(--spacing-px-3) var(--spacing-px-12); + + &:hover { + background-color: var(--color-primary-50); + } + + &:active, + &:has(.infinit-select-option-panel-container.active) { + background-color: var(--color-primary-100); + } + } + + .infinit-select-option-panel-container { + padding: 0; + background-color: transparent; } } } From 2fbe5e9670013d31065aac9ea604db80b0d37572 Mon Sep 17 00:00:00 2001 From: Antoine Prentout Date: Wed, 28 Jan 2026 11:30:22 +0100 Subject: [PATCH 06/14] [ContextMenu::Panel] Cleanup --- addon/components/o-s-s/context-menu/panel.hbs | 2 +- addon/components/o-s-s/context-menu/panel.ts | 2 +- addon/components/o-s-s/infinite-select.ts | 12 +- tests/dummy/app/controllers/input.ts | 128 +++--------------- 4 files changed, 24 insertions(+), 120 deletions(-) diff --git a/addon/components/o-s-s/context-menu/panel.hbs b/addon/components/o-s-s/context-menu/panel.hbs index c09e07fdd..6e2a81b01 100644 --- a/addon/components/o-s-s/context-menu/panel.hbs +++ b/addon/components/o-s-s/context-menu/panel.hbs @@ -38,7 +38,7 @@ @onSelect={{item.action}} {{on "mouseenter" (fn this.openSubMenu item.items index)}} {{on "click" (fn this.toggleSubMenu item.items index)}} - class="infinit-select-option-panel-container {{if (eq index subReferenceIndex) 'active' ''}}" + class="infinit-select-option-panel-container {{if (eq index this.subReferenceIndex) 'active' ''}}" /> {{else if item.rowRenderer}} diff --git a/addon/components/o-s-s/context-menu/panel.ts b/addon/components/o-s-s/context-menu/panel.ts index 7a5e55402..b086182f5 100644 --- a/addon/components/o-s-s/context-menu/panel.ts +++ b/addon/components/o-s-s/context-menu/panel.ts @@ -13,7 +13,7 @@ export const SUBPANEL_OFFSET = -6; export type ContextMenuItem = { items?: ContextMenuItem[]; groupKey?: string; - rowRenderer?: ReturnType; // move to Component + rowRenderer?: ReturnType; action: () => void | boolean; [key: string]: unknown; }; diff --git a/addon/components/o-s-s/infinite-select.ts b/addon/components/o-s-s/infinite-select.ts index 91f872608..7a138dd59 100644 --- a/addon/components/o-s-s/infinite-select.ts +++ b/addon/components/o-s-s/infinite-select.ts @@ -27,7 +27,7 @@ interface InfiniteSelectArgs { skin?: 'default' | 'smart'; action?: InfiniteSelectAction; - onSelect: (item: InfinityItem, event: PointerEvent | KeyboardEvent) => void; + onSelect: (item: InfinityItem) => void; onSearch?: (keyword: string) => void; onBottomReached?: () => void; onClose?: () => void; @@ -136,9 +136,9 @@ export default class OSSInfiniteSelect extends Component { } @action - didSelectItem(item: InfinityItem, event: PointerEvent | KeyboardEvent) { + didSelectItem(item: InfinityItem, event?: PointerEvent) { event?.stopPropagation(); - this.args.onSelect(item, event); + this.args.onSelect(item); } @action @@ -249,9 +249,9 @@ export default class OSSInfiniteSelect extends Component { } } - private handleEnter(self: any, event: KeyboardEvent): void { - self.didSelectItem(self.items[self._focusElement], event); - event.preventDefault(); + private handleEnter(self: any, e: KeyboardEvent): void { + self.didSelectItem(self.items[self._focusElement]); + e.preventDefault(); } private handleTab(self: any): void { diff --git a/tests/dummy/app/controllers/input.ts b/tests/dummy/app/controllers/input.ts index 60f3dfd33..fefb4aeb3 100644 --- a/tests/dummy/app/controllers/input.ts +++ b/tests/dummy/app/controllers/input.ts @@ -80,6 +80,15 @@ export default class Input extends Controller { } ]; + otherItem = { + icon: { icon: 'fa-arrow-progress' }, + title: 'Other', + groupKey: 'other', + action: () => { + console.log('click on other'); + } + }; + subMenu1 = [ { icon: { icon: 'fa-arrow-progress' }, @@ -99,118 +108,13 @@ export default class Input extends Controller { console.log('click on second'); } }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - }, - { - icon: { icon: 'fa-arrow-progress' }, - title: 'Other', - groupKey: 'other', - action: () => { - console.log('click on other'); - } - } + this.otherItem, + this.otherItem, + this.otherItem, + this.otherItem, + this.otherItem, + this.otherItem, + this.otherItem ]; @tracked customContextMenuItems: ContextMenuItem[] = [ From a22f902f5696cf90248228c092b7fd253b2ea776 Mon Sep 17 00:00:00 2001 From: Owen Coogan Date: Wed, 28 Jan 2026 11:49:25 +0100 Subject: [PATCH 07/14] feat: added hasError param to text area --- addon/components/o-s-s/text-area.hbs | 2 +- addon/components/o-s-s/text-area.stories.js | 25 +++++++++++- addon/components/o-s-s/text-area.ts | 6 +++ .../components/o-s-s/text-area-test.ts | 40 +++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/addon/components/o-s-s/text-area.hbs b/addon/components/o-s-s/text-area.hbs index bbe26c361..b1df4cb62 100644 --- a/addon/components/o-s-s/text-area.hbs +++ b/addon/components/o-s-s/text-area.hbs @@ -1,5 +1,5 @@
    -
    +