diff --git a/addon/components/o-s-s/context-menu.hbs b/addon/components/o-s-s/context-menu.hbs new file mode 100644 index 000000000..e08aeb872 --- /dev/null +++ b/addon/components/o-s-s/context-menu.hbs @@ -0,0 +1,29 @@ + + +{{#if this.displayContextMenuPanel}} + +{{/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..811026a5e --- /dev/null +++ b/addon/components/o-s-s/context-menu.stories.js @@ -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:
' + + "- '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' + } + }, + 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``, + 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 new file mode 100644 index 000000000..261287c19 --- /dev/null +++ b/addon/components/o-s-s/context-menu.ts @@ -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; + action: () => void | boolean; + [key: string]: unknown; +}; + +interface OSSContextMenuArgs extends OSSButtonArgs { + items: ContextMenuItem[]; + closeOnMouseLeave?: boolean; + onMenuOpened?: () => {}; + onMenuClosed?: () => {}; +} + +export default class OSSContextMenuComponent extends Component { + @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(event: MouseEvent): 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?.(); + } +} 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..014c07a2b --- /dev/null +++ b/addon/components/o-s-s/context-menu/panel.hbs @@ -0,0 +1,87 @@ +
+ {{#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.stories.js b/addon/components/o-s-s/context-menu/panel.stories.js new file mode 100644 index 000000000..a6cdf9b25 --- /dev/null +++ b/addon/components/o-s-s/context-menu/panel.stories.js @@ -0,0 +1,149 @@ +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: '{ mainAxis: 0, crossAxis: 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' } + } + }, + onClose: { + type: { required: false }, + description: 'Callback function called when the context menu panel should be closed', + table: { + category: 'Actions', + type: { summary: 'onClose(): void' } + } + }, + registerPanel: { + type: { required: false }, + description: 'Callback function called on render, to register the current element', + table: { + category: 'Actions', + type: { summary: 'registerPanel(element: HTMLElement): void' } + } + }, + unregisterPanel: { + type: { required: false }, + description: 'Callback function called on destroy, to notify the current element does not exist anymore', + table: { + category: 'Actions', + type: { summary: 'unregisterPanel(element: HTMLElement): 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: { mainAxis: -100, crossAxis: -100 }, + placement: 'bottom-start', + onMouseLeave: action('onMouseLeave'), + onClose: action('onClose'), + registerPanel: action('registerPanel'), + unregisterPanel: action('unregisterPanel'), + 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 new file mode 100644 index 000000000..af29a0728 --- /dev/null +++ b/addon/components/o-s-s/context-menu/panel.ts @@ -0,0 +1,178 @@ +import { action } from '@ember/object'; +import { guidFor } from '@ember/object/internals'; +import { next, scheduleOnce } from '@ember/runloop'; +import { isTesting } from '@embroider/macros'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import attachDropdown from '@upfluence/oss-components/utils/attach-dropdown'; +import type { ContextMenuItem } from '@upfluence/oss-components/components/o-s-s/context-menu'; + +export const DEFAULT_PLACEMENT = 'bottom-start'; +export const DEFAULT_OFFSET = { mainAxis: 0, crossAxis: 0 }; +export const SUBPANEL_OFFSET = -6; + +interface OSSContextMenuPanelComponentSignature { + items: ContextMenuItem[]; + referenceTarget?: HTMLElement; + placement: 'bottom-start' | 'right-start'; + offset: number | { mainAxis: number; crossAxis: number }; + onMouseLeave?: (event: MouseEvent) => void; + onClose?: () => void; + registerPanel?: (element: HTMLElement) => void; + unregisterPanel?: (element: HTMLElement) => 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; + + cleanupDropdownAutoplacement?: () => void; + onScrollbound = this.onScroll.bind(this); + + subPanelOffset: { mainAxis: number; crossAxis: number } = { + mainAxis: 0, + crossAxis: SUBPANEL_OFFSET + }; + + get panelContainerCustomClasses(): string { + return isTesting() ? '' : 'context-menu-panel__hidden'; + } + + @action + registerPanel(element: HTMLElement): void { + this.currentPanel = element; + this.args.registerPanel?.(this.currentPanel); + scheduleOnce('afterRender', this, () => { + this.initializeDropdown(); + }); + + this.currentPanel.querySelector('.oss-scrollable-panel-content')?.addEventListener('scroll', this.onScrollbound); + } + + @action + willDestroy(): void { + this.args.unregisterPanel?.(this.currentPanel); + super.willDestroy(); + this.currentPanel.querySelector('.oss-scrollable-panel-content')?.removeEventListener('scroll', this.onScrollbound); + } + + @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.setupSubMenu(items, index, event); + }); + } + + @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 { + this.clearSubMenu(); + this.args.onMouseLeave?.(event); + } + + @action + onSubPanelMouseLeave(event: MouseEvent): void { + if (this.subReferenceTarget && !this.subReferenceTarget.contains(event.relatedTarget as HTMLElement)) { + 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); + this.clearSubMenu(); + } + + @action + onScroll(): void { + this.clearSubMenu(); + } + + @action + callAction(action: ContextMenuItem['action']): void { + const returnValue = action?.(); + if (returnValue !== false) { + this.args.onClose?.(); + } + } + + private clearSubMenu(): void { + this.displaySubMenu = false; + this.subReferenceIndex = -1; + this.subReferenceTarget = null; + this.subPanelElement = null; + this.subItems = []; + } + + private initializeDropdown(): void { + const referenceTarget = this.args.referenceTarget; + const floatingTarget = document.querySelector(`#${this.portalId}`); + if (referenceTarget && floatingTarget) { + this.cleanupDropdownAutoplacement = attachDropdown( + referenceTarget as HTMLElement, + floatingTarget as HTMLElement, + { + placement: this.args.placement ?? DEFAULT_PLACEMENT, + offset: this.args.offset ?? DEFAULT_OFFSET, + width: 250, + maxHeight: 480 + } + ); + } + } + + private setupSubMenu(items: ContextMenuItem[], index: number, event: PointerEvent): void { + this.subItems = items; + this.displaySubMenu = true; + const parentElement = (event.target as HTMLElement).closest('li[role="button"]') as HTMLElement; + this.subReferenceTarget = parentElement ? parentElement : (event.target as HTMLElement); + this.subReferenceIndex = index; + } +} 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|}} +
      + {{#each items as |item|}} + {{#let (this.findItemIndex item=item) as |index|}} +
    • + {{#if (has-block "option")}} + {{yield item index to="option"}} + {{else}} + {{get item this.itemLabel}} + {{/if}} +
    • + {{/let}} + {{/each}} + {{#if (not-eq groupKey this.lastKey)}} +
      {{/if}} - +
    {{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/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 @@
    -
    +