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);
+ };
+
+
+
+
+
+
+ <:example>
+
+ <:control>
+ Dropdown
+
+ <:menu as |Menu|>
+
+ Header
+
+
+ Item 1
+
+ Item 2
+ Item 3
+
+
+ Header 2
+
+ Item 4
+ Item 5
+ Item 6
+
+
+
+ <:api as |Args|>
+
+
+
+
+
+
+
+
+ Promise" />
+
+
+ Promise" />
+
+
+
+
+
+
+}
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(
+
+ <:control>
+ This is the button
+
+ <:menu as |Menu|>
+
+ Item 1
+
+
+ Item 2
+
+
+ Item 3 (disabled)
+
+
+
+ Header
+
+
+
+ );
+
+ 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);
+ };
+
+
+
+
+ {{yield}}
+
+
+
+}
+
+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);
+ };
+
+
+
+
+ <:control as |visibility|>
+ {{#if (has-block-params "control")}}
+ {{yield visibility to="control"}}
+ {{else}}
+
+ {{#if this.showLeftIcon}}
+
+ {{/if}}
+ {{yield visibility to="control"}}
+ {{#if this.showRightIcon}}
+
+ {{/if}}
+
+ {{/if}}
+
+ <:content as |Content visibility|>
+
+
+
+
+
+
+
+}
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 {
{{#let
- (hash show=this.show hide=this.hide toggle=this.toggle)
- as |actions|
+ (hash
+ isShown=this.isShown show=this.show hide=this.hide toggle=this.toggle
+ )
+ as |visibility|
}}
- {{yield actions to="control"}}
+ {{yield visibility to="control"}}
{
{{#if (has-block-params "content")}}
{{yield
(hash Header=(component Header) Body=(component Body))
- actions
+ visibility
to="content"
}}
{{else}}
+ {{! @glint-expect-error - If there are no block params, we don't need to yield anything to the block }}
{{yield to="content"}}
{{/if}}
{{#if this.hasArrow}}
diff --git a/packages/ember-core/src/index.ts b/packages/ember-core/src/index.ts
index 91f905b8f..0ee03205c 100644
--- a/packages/ember-core/src/index.ts
+++ b/packages/ember-core/src/index.ts
@@ -32,6 +32,7 @@ export { default as Alert } from './components/alert.gts';
export { default as Button } from './components/button.gts';
export { default as ButtonGroup } from './components/button-group.gts';
export { default as Card } from './components/card.gts';
+export { default as Dropdown } from './components/dropdown.gts';
export { default as Footer } from './components/footer.gts';
export { default as Header } from './components/header.gts';
export { default as Icon } from './components/icon.gts';
@@ -95,6 +96,7 @@ export type { AlertSignature } from './components/alert.gts';
export type { ButtonGroupSignature } from './components/button-group.gts';
export type { ButtonSignature } from './components/button.gts';
export type { CardSignature } from './components/card.gts';
+export type { DropdownSignature } from './components/dropdown.gts';
export type { FooterSignature } from './components/footer.gts';
export type { HeaderSignature } from './components/header.gts';
export type { IconSignature } from './components/icon.gts';