diff --git a/addon/components/o-s-s/banner.hbs b/addon/components/o-s-s/banner.hbs index 63a77dd20..5dacbe040 100644 --- a/addon/components/o-s-s/banner.hbs +++ b/addon/components/o-s-s/banner.hbs @@ -1,10 +1,12 @@ -
+
{{#if (has-block "custom-icon")}}
{{yield to="custom-icon"}}
{{else if @icon}} - + {{else if @image}} banner {{/if}} @@ -27,4 +29,9 @@ {{#if (has-block "actions")}}
{{yield to="actions"}}
{{/if}} -
+ {{#if this.feedbackMessage.value}} +
+ {{this.feedbackMessage.value}} +
+ {{/if}} +
\ No newline at end of file diff --git a/addon/components/o-s-s/banner.stories.js b/addon/components/o-s-s/banner.stories.js index e56161f60..f88ad64a6 100644 --- a/addon/components/o-s-s/banner.stories.js +++ b/addon/components/o-s-s/banner.stories.js @@ -1,6 +1,7 @@ import { hbs } from 'ember-cli-htmlbars'; const COMPONENT_SIZES = ['sm', 'md', 'lg']; +const FEEDBACK_TYPES = ['error', 'warning', 'success']; export default { title: 'Components/OSS::Banner', @@ -77,9 +78,19 @@ export default { type: 'boolean' } }, + feedbackMessage: { + description: 'A feedback message that will be displayed below the banner. Its color changes based on its type', + table: { + type: { + summary: `{ type: ${FEEDBACK_TYPES.join(' | ')}, value: string }` + }, + defaultValue: { summary: 'undefined' } + }, + control: { type: 'object' } + }, size: { description: - 'Allows to adjust the size of the component. Currently available options are `sm`, `md` and `lg`. Defaults to `md`.', + 'Allows to adjust the size of the component. Currently available options are `sm`, `md` and `lg`. Defaults to `md`', table: { type: COMPONENT_SIZES.join('|'), defaultValue: { summary: 'md' } @@ -93,7 +104,7 @@ export default { parameters: { docs: { description: { - component: 'A configurable Banner component. Can display a badge or an image, a title and a subtitle.' + component: 'A configurable Banner component. Can display a badge or an image, a title and a subtitle' }, iframeHeight: 120 } @@ -108,13 +119,15 @@ const defaultArgs = { plain: false, selected: false, disabled: false, + feedbackMessage: undefined, size: undefined }; const Template = (args) => ({ template: hbs` + @image={{this.image}} @selected={{this.selected}} @disabled={{this.disabled}} @size={{this.size}} + @feedbackMessage={{this.feedbackMessage}} /> `, context: args }); @@ -122,7 +135,8 @@ const Template = (args) => ({ const CustomTitleTemplate = (args) => ({ template: hbs` + @image={{this.image}} @selected={{this.selected}} @disabled={{this.disabled}} @size={{this.size}} + @feedbackMessage={{this.feedbackMessage}}> <:title-suffix>
Custom title @@ -136,7 +150,8 @@ const CustomTitleTemplate = (args) => ({ const CustomIconTemplate = (args) => ({ template: hbs` + @image={{this.image}} @selected={{this.selected}} @disabled={{this.disabled}} @size={{this.size}} + @feedbackMessage={{this.feedbackMessage}}> <:custom-icon> @@ -148,7 +163,8 @@ const CustomIconTemplate = (args) => ({ const ActionTemplate = (args) => ({ template: hbs` + @image={{this.image}} @selected={{this.selected}} @disabled={{this.disabled}} @size={{this.size}} + @feedbackMessage={{this.feedbackMessage}}> <:actions> diff --git a/addon/components/o-s-s/banner.ts b/addon/components/o-s-s/banner.ts index 97623b832..fa083c166 100644 --- a/addon/components/o-s-s/banner.ts +++ b/addon/components/o-s-s/banner.ts @@ -1,5 +1,6 @@ import { isBlank } from '@ember/utils'; import Component from '@glimmer/component'; +import { FEEDBACK_TYPES, type FeedbackMessage } from './input-container'; type SizeType = 'sm' | 'md' | 'lg'; @@ -8,6 +9,7 @@ interface OSSBannerArgs { plain?: boolean; selected?: boolean; disabled?: boolean; + feedbackMessage?: FeedbackMessage; } const SIZE_CLASSES: Record = { @@ -32,8 +34,31 @@ export default class OSSBanner extends Component { return SIZE_CLASSES[this.args.size ?? 'md'] ?? ''; } + get feedbackMessage(): FeedbackMessage | undefined { + if (this.args.feedbackMessage && FEEDBACK_TYPES.includes(this.args.feedbackMessage.type)) { + return this.args.feedbackMessage; + } + return undefined; + } + + get borderColorClass(): string { + if (this.feedbackMessage) return `upf-banner--${this.feedbackMessage.type}`; + return ''; + } + + get feedbackMarginClass(): string { + return this.feedbackMessage?.value ? 'margin-bottom-px-24' : ''; + } + get modifierClasses(): string { - return [this.disabledClass, this.selectedClass, this.plainClass, this.sizeClass] + return [ + this.disabledClass, + this.selectedClass, + this.plainClass, + this.sizeClass, + this.borderColorClass, + this.feedbackMarginClass + ] .filter((mc) => !isBlank(mc)) .join(' '); } diff --git a/addon/components/o-s-s/button.hbs b/addon/components/o-s-s/button.hbs index 43a9b23e6..1cb5eb7a3 100644 --- a/addon/components/o-s-s/button.hbs +++ b/addon/components/o-s-s/button.hbs @@ -20,9 +20,15 @@ {{else if @iconUrl}} icon {{/if}} - {{#if @label}} {{@label}} {{/if}} + {{#if @suffixIcon}} + + {{/if}} {{/if}} \ No newline at end of file diff --git a/addon/components/o-s-s/button.stories.js b/addon/components/o-s-s/button.stories.js index 18e3374ee..3e05f6f23 100644 --- a/addon/components/o-s-s/button.stories.js +++ b/addon/components/o-s-s/button.stories.js @@ -97,6 +97,16 @@ export default { type: 'text' } }, + suffixIcon: { + description: 'Font Awesome class for an icon displayed after the label (e.g. fas fa-chevron-down)', + table: { + type: { summary: 'string' }, + defaultValue: { summary: 'undefined' } + }, + control: { + type: 'text' + } + }, square: { description: 'Displays the button as a square. Useful for icon buttons.', table: { @@ -164,6 +174,7 @@ const defaultArgs = { countDown: undefined, loadingOptions: undefined, iconUrl: undefined, + suffixIcon: undefined, disabled: undefined }; @@ -172,7 +183,8 @@ const Template = (args) => ({ + @iconUrl={{this.iconUrl}} @suffixIcon={{this.suffixIcon}} + @loadingOptions={{this.loadingOptions}} disabled={{this.disabled}} /> `, context: args }); @@ -201,3 +213,11 @@ WithIconUrl.args = { iconUrl: '/@upfluence/oss-components/assets/heart.svg' } }; + +export const WithSuffixIcon = Template.bind({}); +WithSuffixIcon.args = { + ...defaultArgs, + ...{ + suffixIcon: 'fas fa-chevron-down' + } +}; diff --git a/addon/components/o-s-s/button.ts b/addon/components/o-s-s/button.ts index 3a0329d88..6cd3fc2d9 100644 --- a/addon/components/o-s-s/button.ts +++ b/addon/components/o-s-s/button.ts @@ -77,6 +77,7 @@ export interface OSSButtonArgs { loadingOptions?: LoadingOptions; icon?: string; iconUrl?: string; + suffixIcon?: string; label?: string; theme?: string; square?: boolean; 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..b02fec298 --- /dev/null +++ b/addon/components/o-s-s/context-menu.hbs @@ -0,0 +1,31 @@ + + +{{#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..517856000 --- /dev/null +++ b/addon/components/o-s-s/context-menu.stories.js @@ -0,0 +1,254 @@ +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 GapTypes = ['fx-gap-px-3', 'fx-gap-px-6', 'fx-gap-px-9', 'fx-gap-px-12', 'fx-gap-px-18', 'fx-gap-px-24']; +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' + } + }, + chevronGap: { + description: 'Gap between label and chevron. Must be a valid fx-gap-px-* class. Same type as OSS::Button @gap.', + table: { + type: { summary: 'GapClassType' }, + defaultValue: { summary: 'fx-gap-px-6' } + }, + options: GapTypes, + control: { type: 'select' } + }, + 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..a01cbb894 --- /dev/null +++ b/addon/components/o-s-s/context-menu.ts @@ -0,0 +1,79 @@ +import Component from '@glimmer/component'; +import type { OSSButtonArgs, GapClassType } from './button'; +import type { ensureSafeComponent } from '@embroider/util'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +export type { GapClassType } from './button'; + +export type ContextMenuItem = { + items?: ContextMenuItem[]; + groupKey?: string; + rowRenderer?: ReturnType; + action: (event?: MouseEvent) => void | boolean; + [key: string]: unknown; +}; + +interface OSSContextMenuArgs extends OSSButtonArgs { + items: ContextMenuItem[]; + chevronGap?: GapClassType; + 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(): 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..8eac5f045 --- /dev/null +++ b/addon/components/o-s-s/context-menu/panel.ts @@ -0,0 +1,179 @@ +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'], eventOrValue?: MouseEvent | boolean): void { + const event = eventOrValue instanceof MouseEvent ? eventOrValue : undefined; + const returnValue = action?.(event); + 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/input-container.ts b/addon/components/o-s-s/input-container.ts index dc5baf75c..b8d6f9dc1 100644 --- a/addon/components/o-s-s/input-container.ts +++ b/addon/components/o-s-s/input-container.ts @@ -2,8 +2,11 @@ import { action } from '@ember/object'; import { next } from '@ember/runloop'; import Component from '@glimmer/component'; +export const FEEDBACK_TYPES = ['error', 'warning', 'success'] as const; +export type FeedbackType = (typeof FEEDBACK_TYPES)[number]; + export type FeedbackMessage = { - type: 'error' | 'warning' | 'success'; + type: FeedbackType; value: string; }; @@ -23,7 +26,7 @@ export const AutocompleteValues = ['on', 'off']; export default class OSSInputContainer extends Component { get feedbackMessage(): FeedbackMessage | undefined { - if (this.args.feedbackMessage && ['error', 'warning', 'success'].includes(this.args.feedbackMessage.type)) { + if (this.args.feedbackMessage && FEEDBACK_TYPES.includes(this.args.feedbackMessage.type)) { return this.args.feedbackMessage; } diff --git a/addon/components/o-s-s/nav-tab.hbs b/addon/components/o-s-s/nav-tab.hbs index 02243ee78..1f2ca5784 100644 --- a/addon/components/o-s-s/nav-tab.hbs +++ b/addon/components/o-s-s/nav-tab.hbs @@ -1,7 +1,11 @@
    {{#each @tabArray as |tab|}} - {{/each}} -
    + \ No newline at end of file diff --git a/addon/components/o-s-s/nav-tab.stories.js b/addon/components/o-s-s/nav-tab.stories.js index 25bca00b3..584e24166 100644 --- a/addon/components/o-s-s/nav-tab.stories.js +++ b/addon/components/o-s-s/nav-tab.stories.js @@ -19,7 +19,7 @@ export default { tabArray: { type: { required: true }, description: - 'Array of TabDefinition which has the following parameters:
    -icon?: string;
    -label?: string;
    -infoCircle?: boolean;
    -notificationDot?: boolean;
    -selected: boolean;
    -disabled: boolean;
    @label or @icon is mandatory for each element of tabArray', + 'Array of TabDefinition which has the following parameters:
    -icon?: string;
    -label?: string;
    -infoCircle?: boolean;
    -notificationDot?: boolean;
    -selected: boolean;
    -disabled: boolean;
    -tag: OSS::Tag arg;
    @label or @icon is mandatory for each element of tabArray', table: { type: { summary: 'TabDefinition[]' @@ -40,7 +40,15 @@ const defaultArgs = { { label: 'Tab', icon: 'far fa-thumbs-up', infoCircle: true, notificationDot: true }, { label: 'Tab', icon: 'far fa-thumbs-up', infoCircle: true, notificationDot: true, selected: true }, { label: 'Tab', icon: 'far fa-thumbs-up', infoCircle: true, notificationDot: true, disabled: true }, - { label: 'Tab', icon: 'far fa-thumbs-up', infoCircle: true, notificationDot: true, selected: true, disabled: true } + { label: 'Tab', icon: 'far fa-thumbs-up', infoCircle: true, notificationDot: true, selected: true, disabled: true }, + { + label: 'Tab', + icon: 'far fa-thumbs-up', + infoCircle: true, + notificationDot: true, + selected: true, + tag: { label: 'X', skin: 'danger' } + } ], onSelection: action('onSelection') }; diff --git a/addon/components/o-s-s/nav-tab.ts b/addon/components/o-s-s/nav-tab.ts index f7a50661a..4783af9d3 100644 --- a/addon/components/o-s-s/nav-tab.ts +++ b/addon/components/o-s-s/nav-tab.ts @@ -1,6 +1,8 @@ import { assert } from '@ember/debug'; -import Component from '@glimmer/component'; import { action } from '@ember/object'; +import Component from '@glimmer/component'; + +import type { OSSTagArgs } from './tag'; interface OSSNavTabArgs { onSelection(selectedTab: TabDefinition): void; @@ -8,12 +10,13 @@ interface OSSNavTabArgs { } export interface TabDefinition { + selected: boolean; + disabled: boolean; icon?: string; label?: string; infoCircle?: boolean; notificationDot?: boolean; - selected: boolean; - disabled: boolean; + tag?: OSSTagArgs; } export default class OSSNavTab extends Component { diff --git a/addon/components/o-s-s/power-select.hbs b/addon/components/o-s-s/power-select.hbs index b0d705ef7..381c61df6 100644 --- a/addon/components/o-s-s/power-select.hbs +++ b/addon/components/o-s-s/power-select.hbs @@ -47,8 +47,8 @@ {{on "click" this.noop}} {{on-click-outside this.onClickOutside useCapture=@captureClickOutside}} > - <:option as |item|> - {{yield item to="option-item"}} + <:option as |item index|> + {{yield item index to="option-item"}} <:empty-state> @@ -71,8 +71,8 @@ {{on "click" this.noop}} {{on-click-outside this.onClickOutside useCapture=@captureClickOutside}} > - <:option as |item|> - {{yield item to="option-item"}} + <:option as |item index|> + {{yield item index to="option-item"}} {{/if}} diff --git a/addon/components/o-s-s/progress-bar.hbs b/addon/components/o-s-s/progress-bar.hbs index 1bafa09ce..3dadf794c 100644 --- a/addon/components/o-s-s/progress-bar.hbs +++ b/addon/components/o-s-s/progress-bar.hbs @@ -7,16 +7,14 @@ {{#if this.hasSkins}}
    {{#each this.progressSegments as |skin|}} - {{#if (this.isSegmentVisible skin=skin)}} -
    - {{/if}} +
    {{/each}}
    {{else}} diff --git a/addon/components/o-s-s/progress-bar.ts b/addon/components/o-s-s/progress-bar.ts index 866b9f6d4..3e25f48e8 100644 --- a/addon/components/o-s-s/progress-bar.ts +++ b/addon/components/o-s-s/progress-bar.ts @@ -36,12 +36,8 @@ export default class OSSProgressBar extends Component { } } - isSegmentVisible = helper((_, { skin }: { skin: ProgressBarSkins }): boolean => { - return this.hasSkins && (this.skins[skin] || 0) > 0; - }); - getSegmentValue = helper((_, { skin }: { skin: ProgressBarSkins }): number => { - return this.hasSkins ? this.skins[skin] || 0 : 0; + return this.skins[skin] ?? 0; }); progressBarStyle = helper((_, { value }: { value: number }): ReturnType => { @@ -59,7 +55,7 @@ export default class OSSProgressBar extends Component { } get progressSegments(): ProgressBarSkins[] { - return this.hasSkins ? (Object.keys(this.skins) as ProgressBarSkins[]) : []; + return Object.keys(this.skins) as ProgressBarSkins[]; } get computedStyles(): string { 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 @@
    -
    +