Skip to content

Commit

Permalink
feat: Create dropdown component (#323)
Browse files Browse the repository at this point in the history
* feat(popover): Add `isShown` to yielded visibility

* feat: Copy dropdown styling from Bootstrap 5.3.3

* feat: Create dropdown component

* docs: Add page for dropdown

* chore: Add tests for dropdown

* feat(dropdown): Allow custom controls

* fix: Reinstate styling for select components
  • Loading branch information
TSenter authored Jan 3, 2025
1 parent 11a461e commit 71ea545
Show file tree
Hide file tree
Showing 13 changed files with 669 additions and 17 deletions.
157 changes: 157 additions & 0 deletions apps/docs-app/app/components/f/components/dropdown.gts
Original file line number Diff line number Diff line change
@@ -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);
};

<template>
<Toaster />
<FreestyleSection @name="Dropdown" as |Section|>
<Section.subsection @name="Button">
<FreestyleUsage>
<:example>
<Dropdown
class={{this.class}}
@alignment={{this.alignment}}
@closeOnSelect={{this.closeOnSelect}}
@hasIcon={{this.hasIcon}}
@isOpen={{this.isOpen}}
@offset={{this.offset}}
@side={{this.side}}
@onShow={{fn this.toast.info "onShow was fired"}}
@onHide={{fn this.toast.info "onHide was fired"}}
>
<:control>
Dropdown
</:control>
<:menu as |Menu|>
<Menu.Header>
Header
</Menu.Header>
<Menu.Item
@disabled={{true}}
@onSelect={{fn this.log "Item 1 clicked"}}
>
Item 1
</Menu.Item>
<Menu.Item @onSelect={{fn this.log "Item 2 clicked"}}>Item 2</Menu.Item>
<Menu.Item @onSelect={{fn this.log "Item 3 clicked"}}>Item 3</Menu.Item>
<Menu.Divider />
<Menu.Header>
Header 2
</Menu.Header>
<Menu.Item @onSelect={{fn this.log "Item 4 clicked"}}>Item 4</Menu.Item>
<Menu.Item @onSelect={{fn this.log "Item 5 clicked"}}>Item 5</Menu.Item>
<Menu.Item @onSelect={{fn this.log "Item 6 clicked"}}>Item 6</Menu.Item>
</:menu>
</Dropdown>
</:example>
<:api as |Args|>
<Args.String
@name="class"
@description="The class to apply to the dropdown button. Note that this is not an argument but rather a class applied directly to the button"
@value={{this.class}}
@onInput={{fn this.update "class"}}
@options={{this.classOptions}}
/>
<Args.String
@name="alignment"
@defaultValue="start"
@description="How to align the dropdown"
@value={{this.alignment}}
@options={{array "" "start" "end"}}
@onInput={{fn this.update "alignment"}}
/>
<Args.Bool
@name="closeOnSelect"
@defaultValue={{true}}
@description="Whether to close the dropdown when an item is selected"
@value={{this.closeOnSelect}}
@onInput={{fn this.update "closeOnSelect"}}
/>
<Args.Bool
@name="hasIcon"
@defaultValue="true"
@description="Whether to show the dropdown icon"
@value={{this.hasIcon}}
@onInput={{fn this.update "hasIcon"}}
/>
<Args.Bool
@name="isShown"
@defaultValue="undefined"
@description="Whether to open the dropdown"
@value={{this.isShown}}
@onInput={{fn this.update "isShown"}}
/>
<Args.Number
@name="offset"
@description="How far to offset the dropdown from the button (in pixels)"
@value={{this.offset}}
@onInput={{fn this.update "offset"}}
/>
<Args.String
@name="side"
@defaultValue="bottom"
@description="Which side of the control to show the dropdown"
@value={{this.side}}
@options={{array "" "top" "end" "bottom" "start"}}
@onInput={{fn this.update "side"}}
/>
<Args.Action
@name="onShow"
@description="Fired when the dropdown is shown"
>
<CodeBlock @lang="typescript" @code="() => Promise<void>" />
</Args.Action>
<Args.Action
@name="onHide"
@description="Fired when the dropdown is hidden"
>
<CodeBlock @lang="typescript" @code="() => Promise<void>" />
</Args.Action>
</:api>
</FreestyleUsage>
</Section.subsection>
</FreestyleSection>
</template>
}
1 change: 1 addition & 0 deletions apps/docs-app/app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 1 addition & 0 deletions apps/docs-app/app/templates/components.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<:group as |Item|>
<Item @name="Button" @route="components.button" />
<Item @name="Card" @route="components.card" />
<Item @name="Dropdown" @route="components.dropdown" />
<Item @name="Footer" @route="components.footer" />
<Item @name="Header" @route="components.header" />
<Item @name="Icon" @route="components.icon" />
Expand Down
5 changes: 5 additions & 0 deletions apps/docs-app/app/templates/components/dropdown.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{page-title "Dropdown"}}

<div class="container mx-auto">
<F::Components::Dropdown />
</div>
62 changes: 62 additions & 0 deletions apps/test-app/tests/integration/components/dropdown-test.gts
Original file line number Diff line number Diff line change
@@ -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(<template>
<Dropdown>
<:control>
This is the button
</:control>
<:menu as |Menu|>
<Menu.Item @onSelect={{fn clickHandler "item1"}}>
Item 1
</Menu.Item>
<Menu.Item @onSelect={{fn clickHandler "item2"}}>
Item 2
</Menu.Item>
<Menu.Item @disabled={{true}} @onSelect={{fn clickHandler "item3"}}>
Item 3 (disabled)
</Menu.Item>
<Menu.Divider />
<Menu.Header>
Header
</Menu.Header>
</:menu>
</Dropdown>
</template>);

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();
});
});
5 changes: 4 additions & 1 deletion packages/design-system/src/_bootstrap.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading

0 comments on commit 71ea545

Please sign in to comment.