diff --git a/apps/docs-app/app/components/f/components/dropdown.gts b/apps/docs-app/app/components/f/components/dropdown.gts new file mode 100644 index 000000000..cd04159cb --- /dev/null +++ b/apps/docs-app/app/components/f/components/dropdown.gts @@ -0,0 +1,157 @@ +import { array, fn } from '@ember/helper'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { Dropdown, Toaster } from '@nrg-ui/core'; +import FreestyleUsage from 'ember-freestyle/components/freestyle/usage'; +import FreestyleSection from 'ember-freestyle/components/freestyle-section'; + +import CodeBlock from '../../code-block'; + +import type { Alignment, Side } from '@floating-ui/dom'; +import type { ToastService } from '@nrg-ui/core/services/toast'; + +export default class extends Component { + @service + declare toast: ToastService; + + @tracked + alignment?: Alignment; + + @tracked + class: string = 'btn-primary'; + + @tracked + closeOnSelect?: boolean; + + @tracked + hasIcon?: boolean; + + @tracked + isOpen?: boolean; + + @tracked + offset?: number; + + @tracked + side?: Side; + + update = (key: string, value: unknown) => { + this[key] = value; + }; + + log = (...args: unknown[]) => { + console.log(...args); + }; + + +} diff --git a/apps/docs-app/app/router.ts b/apps/docs-app/app/router.ts index 327895472..9d6453981 100644 --- a/apps/docs-app/app/router.ts +++ b/apps/docs-app/app/router.ts @@ -16,6 +16,7 @@ Router.map(function () { this.route('components', function () { this.route('button'); this.route('card'); + this.route('dropdown'); this.route('header'); this.route('form', function () { this.route('checkbox'); diff --git a/apps/docs-app/app/templates/components.hbs b/apps/docs-app/app/templates/components.hbs index 5d2b4db86..28ac498f4 100644 --- a/apps/docs-app/app/templates/components.hbs +++ b/apps/docs-app/app/templates/components.hbs @@ -5,6 +5,7 @@ <:group as |Item|> + diff --git a/apps/docs-app/app/templates/components/dropdown.hbs b/apps/docs-app/app/templates/components/dropdown.hbs new file mode 100644 index 000000000..87f67ae7c --- /dev/null +++ b/apps/docs-app/app/templates/components/dropdown.hbs @@ -0,0 +1,5 @@ +{{page-title "Dropdown"}} + +
+ +
\ No newline at end of file diff --git a/apps/test-app/tests/integration/components/dropdown-test.gts b/apps/test-app/tests/integration/components/dropdown-test.gts new file mode 100644 index 000000000..ca5780a38 --- /dev/null +++ b/apps/test-app/tests/integration/components/dropdown-test.gts @@ -0,0 +1,62 @@ +import { fn } from '@ember/helper'; +import { click, render } from '@ember/test-helpers'; +import Dropdown from '@nrg-ui/core/components/dropdown'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'test-app/tests/helpers'; + +module('Integration | Component | dropdown', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + const clickHandler = (val: string) => { + assert.step(val); + }; + + await render(); + + await click('.btn.dropdown'); + await click('.dropdown-menu > li:nth-child(1)'); + + await click('.btn.dropdown'); + await click('.dropdown-menu > li:nth-child(2)'); + + await click('.btn.dropdown'); + await click('.dropdown-menu > li:nth-child(3)'); + + assert.verifySteps(['item1', 'item2']); + + assert.dom('[data-test-dropdown-item]').exists({ count: 3 }); + assert + .dom( + '[data-test-dropdown-item]:nth-child(3) + li > [data-test-dropdown-divider]', + ) + .exists(); + assert + .dom( + 'li:has(> [data-test-dropdown-divider]) + li > [data-test-dropdown-header]', + ) + .hasText('Header') + .exists(); + }); +}); diff --git a/packages/design-system/src/_bootstrap.scss b/packages/design-system/src/_bootstrap.scss index f29cb9243..0c7d15a44 100644 --- a/packages/design-system/src/_bootstrap.scss +++ b/packages/design-system/src/_bootstrap.scss @@ -8,7 +8,10 @@ @import "bootstrap/scss/forms"; @import "bootstrap/scss/buttons"; @import "bootstrap/scss/transitions"; -@import "bootstrap/scss/dropdown"; +// Bootstrap's dropdown uses Popper.js which is not included in this project. +// Instead, we're using Floating UI and modifying the dropdown styles. +// We expect Bootstrap 6 to migrate to Floating UI. +// @import "bootstrap/scss/dropdown"; @import "bootstrap/scss/button-group"; @import "bootstrap/scss/nav"; @import "bootstrap/scss/navbar"; diff --git a/packages/design-system/src/custom/_dropdown.scss b/packages/design-system/src/custom/_dropdown.scss new file mode 100644 index 000000000..f4d051ba7 --- /dev/null +++ b/packages/design-system/src/custom/_dropdown.scss @@ -0,0 +1,166 @@ +.dropdown-menu { + --bs-dropdown-zindex: #{$zindex-dropdown}; + --bs-dropdown-min-width: #{$dropdown-min-width}; + --bs-dropdown-padding-x: #{$dropdown-padding-x}; + --bs-dropdown-padding-y: #{$dropdown-padding-y}; + --bs-dropdown-spacer: #{$dropdown-spacer}; + @include rfs($dropdown-font-size, --bs-dropdown-font-size); + --bs-dropdown-color: #{$dropdown-color}; + --bs-dropdown-bg: #{$dropdown-bg}; + --bs-dropdown-border-color: #{$dropdown-border-color}; + --bs-dropdown-border-radius: #{$dropdown-border-radius}; + --bs-dropdown-border-width: #{$dropdown-border-width}; + --bs-dropdown-inner-border-radius: #{$dropdown-inner-border-radius}; + --bs-dropdown-divider-bg: #{$dropdown-divider-bg}; + --bs-dropdown-divider-margin-y: #{$dropdown-divider-margin-y}; + --bs-dropdown-box-shadow: #{$dropdown-box-shadow}; + --bs-dropdown-link-color: #{$dropdown-link-color}; + --bs-dropdown-link-hover-color: #{$dropdown-link-hover-color}; + --bs-dropdown-link-hover-bg: #{$dropdown-link-hover-bg}; + --bs-dropdown-link-active-color: #{$dropdown-link-active-color}; + --bs-dropdown-link-active-bg: #{$dropdown-link-active-bg}; + --bs-dropdown-link-disabled-color: #{$dropdown-link-disabled-color}; + --bs-dropdown-item-padding-x: #{$dropdown-item-padding-x}; + --bs-dropdown-item-padding-y: #{$dropdown-item-padding-y}; + --bs-dropdown-header-color: #{$dropdown-header-color}; + --bs-dropdown-header-padding-x: #{$dropdown-header-padding-x}; + --bs-dropdown-header-padding-y: #{$dropdown-header-padding-y}; + + z-index: var(--bs-dropdown-zindex); + display: block; + min-width: var(--bs-dropdown-min-width); + padding: var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x); + margin: 0; + @include font-size(var(--bs-dropdown-font-size)); + color: var(--bs-dropdown-color); + text-align: left; + list-style: none; + background-color: var(--bs-dropdown-bg); + background-clip: padding-box; + border: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); + @include border-radius(var(--bs-dropdown-border-radius)); + @include box-shadow(var(--bs-dropdown-box-shadow)); + + @if $dropdown-padding-y == 0 { + > .dropdown-item:first-child, + > li:first-child .dropdown-item { + @include border-top-radius(var(--bs-dropdown-inner-border-radius)); + } + > .dropdown-item:last-child, + > li:last-child .dropdown-item { + @include border-bottom-radius(var(--bs-dropdown-inner-border-radius)); + } + + } + + &.hidden { + visibility: hidden; + } +} + +@each $breakpoint in map-keys($grid-breakpoints) { + @include media-breakpoint-up($breakpoint) { + $infix: breakpoint-infix($breakpoint, $grid-breakpoints); + + .dropdown-menu#{$infix}-start { + --bs-position: start; + + &[data-bs-popper] { + right: auto; + left: 0; + } + } + + .dropdown-menu#{$infix}-end { + --bs-position: end; + + &[data-bs-popper] { + right: 0; + left: auto; + } + } + } +} + + +.dropdown-divider { + height: 0; + margin: var(--bs-dropdown-divider-margin-y) 0; + overflow: hidden; + border-top: 1px solid var(--bs-dropdown-divider-bg); + opacity: 1; +} + +.dropdown-item { + display: block; + width: 100%; + padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x); + clear: both; + font-weight: $font-weight-normal; + color: var(--bs-dropdown-link-color); + text-align: inherit; + text-decoration: if($link-decoration == none, null, none); + white-space: nowrap; + background-color: transparent; + border: 0; + @include border-radius(var(--bs-dropdown-item-border-radius, 0)); + + &:not(.disabled) { + cursor: pointer; + } + + &:hover, + &:focus { + color: var(--bs-dropdown-link-hover-color); + text-decoration: if($link-hover-decoration == underline, none, null); + @include gradient-bg(var(--bs-dropdown-link-hover-bg)); + } + + &.active, + &:active { + color: var(--bs-dropdown-link-active-color); + text-decoration: none; + @include gradient-bg(var(--bs-dropdown-link-active-bg)); + } + + &.disabled, + &:disabled { + color: var(--bs-dropdown-link-disabled-color); + pointer-events: none; + background-color: transparent; + background-image: if($enable-gradients, none, null); + } +} +.dropdown-header { + display: block; + padding: var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x); + margin-bottom: 0; + @include font-size($font-size-sm); + color: var(--bs-dropdown-header-color); + white-space: nowrap; +} + +.dropdown-item-text { + display: block; + padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x); + color: var(--bs-dropdown-link-color); +} + +.dropdown-menu-dark { + --bs-dropdown-color: #{$dropdown-dark-color}; + --bs-dropdown-bg: #{$dropdown-dark-bg}; + --bs-dropdown-border-color: #{$dropdown-dark-border-color}; + --bs-dropdown-box-shadow: #{$dropdown-dark-box-shadow}; + --bs-dropdown-link-color: #{$dropdown-dark-link-color}; + --bs-dropdown-link-hover-color: #{$dropdown-dark-link-hover-color}; + --bs-dropdown-divider-bg: #{$dropdown-dark-divider-bg}; + --bs-dropdown-link-hover-bg: #{$dropdown-dark-link-hover-bg}; + --bs-dropdown-link-active-color: #{$dropdown-dark-link-active-color}; + --bs-dropdown-link-active-bg: #{$dropdown-dark-link-active-bg}; + --bs-dropdown-link-disabled-color: #{$dropdown-dark-link-disabled-color}; + --bs-dropdown-header-color: #{$dropdown-dark-header-color}; +} + +button.dropdown + .popover { + max-width: 100%; +} diff --git a/packages/design-system/src/custom/_forms.scss b/packages/design-system/src/custom/_forms.scss index 7208db0dd..2eadda273 100644 --- a/packages/design-system/src/custom/_forms.scss +++ b/packages/design-system/src/custom/_forms.scss @@ -1,8 +1,14 @@ .form-control.dropdown { & .dropdown-menu { + display: none; + position: absolute; width: 100%; margin-left: -0.75rem; // This value needs to be the inverse of the padding on the input margin-top: 0.75rem; + + &.show { + display: block; + } } &.scrollable > .dropdown-menu { diff --git a/packages/design-system/src/main.scss b/packages/design-system/src/main.scss index 68b61646a..9600f15b8 100644 --- a/packages/design-system/src/main.scss +++ b/packages/design-system/src/main.scss @@ -2,6 +2,7 @@ @import "bootstrap"; @import "custom/buttons"; +@import "custom/dropdown"; @import "custom/forms"; @import "custom/icons"; @import "custom/modal"; diff --git a/packages/ember-core/package.json b/packages/ember-core/package.json index f09252f77..87ae7b3e3 100644 --- a/packages/ember-core/package.json +++ b/packages/ember-core/package.json @@ -189,6 +189,7 @@ "./components/button-group.js": "./dist/_app_/components/button-group.js", "./components/button.js": "./dist/_app_/components/button.js", "./components/card.js": "./dist/_app_/components/card.js", + "./components/dropdown.js": "./dist/_app_/components/dropdown.js", "./components/footer.js": "./dist/_app_/components/footer.js", "./components/form/-private/calendar.js": "./dist/_app_/components/form/-private/calendar.js", "./components/form/-private/input-field.js": "./dist/_app_/components/form/-private/input-field.js", diff --git a/packages/ember-core/src/components/dropdown.gts b/packages/ember-core/src/components/dropdown.gts new file mode 100644 index 000000000..ee4158bcf --- /dev/null +++ b/packages/ember-core/src/components/dropdown.gts @@ -0,0 +1,245 @@ +import { fn, hash } from '@ember/helper'; +import { on } from '@ember/modifier'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; + +import Popover from './popover.gts'; +import { classes } from '../helpers/classes.ts'; +import onClickOutside from '../modifiers/on-click-outside.ts'; +import onInsert from '../modifiers/on-insert.ts'; + +import type { Direction, PopoverVisibility } from './popover.gts'; +import type { TOC } from '@ember/component/template-only'; +import type { Alignment } from '@floating-ui/dom'; +import type { ComponentLike } from '@glint/template'; +import type { WithBoundArgs } from '@glint/template'; +import type IntlService from 'ember-intl/services/intl'; + +interface ItemSignature { + Element: HTMLSpanElement; + Args: { + disabled?: boolean; + + onSelect?: (evt: MouseEvent) => unknown; + onSelectInternal: ( + evt: MouseEvent, + callback?: (evt: MouseEvent) => unknown, + ) => unknown; + }; + Blocks: { + default: []; + }; +} + +class Item extends Component { + onSelect = (evt: MouseEvent) => { + if (this.args.disabled) { + return; + } + + this.args.onSelectInternal(evt, this.args.onSelect); + }; + + +} + +interface DividerSignature { + Element: HTMLHRElement; +} + +const Divider: TOC = ; + +interface HeaderSignature { + Element: HTMLHeadingElement; + Blocks: { + default: []; + }; +} + +const Header: TOC = ; + +export interface DropdownSignature { + Element: HTMLSpanElement; + Args: { + alignment?: Alignment; + closeOnSelect?: boolean; + controlElement?: HTMLElement; + disabled?: boolean; + hasIcon?: boolean; + isShown?: boolean; + loading?: boolean; + offset?: string | number; + scrollable?: boolean; + side?: Direction; + + onShow?: () => unknown | Promise; + onHide?: () => unknown | Promise; + }; + Blocks: { + control: [PopoverVisibility]; + menu: [ + { + Divider: ComponentLike; + Header: ComponentLike; + Item: WithBoundArgs; + }, + ]; + }; +} + +export default class Dropdown extends Component { + declare menuElement: HTMLElement; + declare visibility: PopoverVisibility; + + menuId = crypto.randomUUID(); + + @service + declare intl: IntlService; + + get alignment(): Alignment { + return this.args.alignment ?? 'start'; + } + + get disabled() { + return this.args.disabled || this.args.loading; + } + + get hasIcon() { + return this.args.hasIcon ?? true; + } + + get showLeftIcon() { + return this.hasIcon && this.args.side === 'start'; + } + + get showRightIcon() { + return this.hasIcon && !this.showLeftIcon; + } + + get icon() { + switch (this.args.side) { + case 'top': + return 'bi-caret-up-fill'; + case 'start': + return 'bi-caret-left-fill'; + case 'end': + return 'bi-caret-right-fill'; + case 'bottom': + default: + return 'bi-caret-down-fill'; + } + } + + get scrollable() { + return this.args.scrollable ?? true; + } + + onMenuInsert = (visibility: PopoverVisibility, element: HTMLElement) => { + this.visibility = visibility; + this.menuElement = element; + }; + + onSelect = async ( + evt: MouseEvent, + callback?: (evt: MouseEvent) => unknown, + ) => { + if (!this.visibility.isShown) { + return; + } + + if (this.args.closeOnSelect ?? true) { + await this.visibility.hide(); + } + + await callback?.(evt); + }; + + +} diff --git a/packages/ember-core/src/components/popover.gts b/packages/ember-core/src/components/popover.gts index 014618dd3..17a389f0d 100644 --- a/packages/ember-core/src/components/popover.gts +++ b/packages/ember-core/src/components/popover.gts @@ -12,10 +12,11 @@ import type { TOC } from '@ember/component/template-only'; import type { Alignment, Placement, Side } from '@floating-ui/dom'; import type { ComponentLike } from '@glint/template'; -export interface PopoverActions { +export interface PopoverVisibility { + isShown: boolean; toggle: (evt: Event) => Promise; show: (evt: Event) => Promise; - hide: (evt: Event) => Promise; + hide: () => Promise; } export interface HeaderSignature { @@ -60,16 +61,14 @@ export interface PopoverSignature { onHide?: () => unknown; }; Blocks: { - control: [PopoverActions]; - content: - | [ - { - Header: ComponentLike; - Body: ComponentLike; - }, - PopoverActions, - ] - | []; + control: [PopoverVisibility]; + content: [ + { + Header: ComponentLike; + Body: ComponentLike; + }, + PopoverVisibility, + ]; }; } @@ -217,10 +216,12 @@ export default class Popover extends Component {