From dabcc0b696dc1ae4dd46aa15c12eced6738a2540 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Tue, 6 Oct 2026 08:36:57 +0200 Subject: [PATCH 1/2] feat(ui-ng): add a dropdown mode and localisable strings to flr-multi-select --- .../showcase/src/stories/Pickers.stories.ts | 57 ++++ .../src/lib/checkbox/checkbox.component.ts | 11 +- .../multi-select/multi-select.component.css | 253 +++++++++++++++ .../multi-select/multi-select.component.html | 139 ++++++++ .../multi-select/multi-select.component.ts | 297 +++++++++--------- packages/ui-ng/src/public-api.ts | 2 +- 6 files changed, 610 insertions(+), 149 deletions(-) create mode 100644 packages/ui-ng/src/lib/multi-select/multi-select.component.css create mode 100644 packages/ui-ng/src/lib/multi-select/multi-select.component.html diff --git a/packages/showcase/src/stories/Pickers.stories.ts b/packages/showcase/src/stories/Pickers.stories.ts index 90cc6005..d391559f 100644 --- a/packages/showcase/src/stories/Pickers.stories.ts +++ b/packages/showcase/src/stories/Pickers.stories.ts @@ -2,6 +2,7 @@ import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { MultiSelectComponent, TreeSelectComponent } from '@ferrlabs/ui-ng'; import type { Meta, StoryObj } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; const REGIONS = [ { id: 'eu-west-1', label: 'eu-west-1', hint: 'Ireland' }, @@ -46,6 +47,62 @@ export const MultiSelectLoading: Story = { }), }; +export const MultiSelectDropdown: Story = { + render: () => ({ + props: { options: REGIONS, picked: new FormControl(['eu-west-1']) }, + template: ` +
+ +

{{ picked.value.join(',') }}

+
+

Outside

`, + }), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button', { name: /^Regions:/ }); + const value = canvas.getByTestId('value'); + await expect(trigger).toHaveTextContent('eu-west-1'); + await expect(trigger).toHaveAttribute('aria-haspopup', 'listbox'); + + await userEvent.click(trigger); + const listbox = await canvas.findByRole('listbox'); + await expect(trigger).toHaveAttribute('aria-expanded', 'true'); + await expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); + await userEvent.click(within(listbox).getByRole('option', { name: /us-east-1/ })); + await waitFor(() => expect(value).toHaveTextContent('eu-west-1,us-east-1')); + await expect(trigger).toHaveTextContent('2 selected'); + + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(canvas.queryByRole('listbox')).toBeNull()); + await expect(trigger).toHaveFocus(); + + await userEvent.click(trigger); + await canvas.findByRole('listbox'); + await userEvent.click(canvas.getByTestId('outside')); + await waitFor(() => expect(canvas.queryByRole('listbox')).toBeNull()); + + trigger.focus(); + await userEvent.keyboard('{ArrowDown}'); + await canvas.findByRole('listbox'); + await waitFor(() => expect(canvas.getByRole('searchbox')).toHaveFocus()); + await userEvent.keyboard('{ArrowDown}{ArrowDown} '); + await waitFor(() => expect(value).toHaveTextContent('eu-west-1,us-east-1,eu-central-1')); + await expect( + within(canvas.getByRole('listbox')).getByRole('option', { name: /eu-central-1/ }), + ).toHaveAttribute('aria-selected', 'true'); + + await userEvent.click(canvas.getByRole('button', { name: 'Clear' })); + await waitFor(() => expect(trigger).toHaveTextContent('Any region')); + await expect(value.textContent?.trim()).toBe(''); + }, +}; + export const TreeSelect: Story = { render: () => ({ props: { groups: GROUPS, picked: new FormControl(['eu-west-1', 'us-east-1']) }, diff --git a/packages/ui-ng/src/lib/checkbox/checkbox.component.ts b/packages/ui-ng/src/lib/checkbox/checkbox.component.ts index aca5ca9a..e79e7879 100644 --- a/packages/ui-ng/src/lib/checkbox/checkbox.component.ts +++ b/packages/ui-ng/src/lib/checkbox/checkbox.component.ts @@ -114,18 +114,19 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; position: absolute; inset: 0; pointer-events: none; - background-repeat: no-repeat; - background-position: center; - background-size: 12px 12px; + background: var(--color-on-accent, #ffffff); + mask-repeat: no-repeat; + mask-position: center; + mask-size: 12px 12px; opacity: 0; transition: opacity 120ms; } .flr-checkbox__icon--on { opacity: 1; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m4 8 3 3 5-6'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m4 8 3 3 5-6'/%3E%3C/svg%3E"); } .flr-checkbox__icon--indeterminate { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath stroke='black' stroke-linecap='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E"); } .flr-checkbox__text { display: flex; diff --git a/packages/ui-ng/src/lib/multi-select/multi-select.component.css b/packages/ui-ng/src/lib/multi-select/multi-select.component.css new file mode 100644 index 00000000..636566df --- /dev/null +++ b/packages/ui-ng/src/lib/multi-select/multi-select.component.css @@ -0,0 +1,253 @@ +:host { + --flr-ms-accent: var(--flr-accent, var(--color-accent, #f59e0b)); + --flr-ms-accent-ink: var(--flr-accent-ink, var(--color-on-accent, #ffffff)); + --flr-ms-rule: var(--flr-rule, #e2e8f0); + display: block; +} + +:host(.flr-ms-host--dropdown) { + position: relative; +} + +.flr-ms { + border: 1px solid var(--flr-ms-rule); + border-radius: 8px; + overflow: hidden; + background: var(--flr-bg, var(--color-card, #fff)); +} + +.flr-ms--invalid { + border-color: var(--color-danger, #dc2626); +} + +.flr-ms__trigger { + display: flex; + align-items: center; + width: 100%; + height: 40px; + box-sizing: border-box; + padding: 0 32px 0 12px; + font-family: var(--font-sans, 'Fraunces', Georgia, ui-serif, serif); + font-size: 14px; + line-height: 1.4; + text-align: left; + color: var(--color-ink, #1e293b); + background-color: var(--color-card, #ffffff); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%2364748b' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='m4 6 4 4 4-4'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 16px 16px; + border: 1px solid var(--color-rule-strong, rgba(30, 41, 59, 0.28)); + border-radius: 8px; + outline: none; + cursor: pointer; + transition: + border-color 140ms, + box-shadow 140ms; +} + +.flr-ms__trigger:focus-visible, +.flr-ms__trigger[aria-expanded='true'] { + border-color: var(--color-accent, var(--color-ink, #1e293b)); + box-shadow: 0 0 0 3px + color-mix(in oklab, var(--color-accent, var(--color-ink, #1e293b)) 30%, transparent); +} + +.flr-ms__trigger--invalid, +.flr-ms__trigger--invalid:focus-visible { + border-color: var(--color-danger, #dc2626); +} + +.flr-ms__trigger:disabled { + cursor: not-allowed; + opacity: 0.65; + background-color: var(--color-paper-2, #f3efe7); +} + +.flr-ms__trigger--placeholder .flr-ms__summary { + color: var(--color-ink-3, #5d6b80); +} + +.flr-ms__summary { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.flr-ms__panel { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 30; + width: max-content; + min-width: 100%; + max-width: min(360px, calc(100vw - 32px)); + box-sizing: border-box; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18); +} + +.flr-ms__search { + width: 100%; + box-sizing: border-box; + border: 0; + border-bottom: 1px solid var(--flr-ms-rule); + padding: 9px 12px; + font: 400 13px/1.4 var(--flr-font-sans, system-ui); + color: inherit; + background: transparent; +} + +.flr-ms__search:focus { + outline: none; + border-bottom-color: var(--flr-ms-accent); +} + +.flr-ms__list { + max-height: 260px; + overflow-y: auto; +} + +.flr-ms__list:focus { + outline: none; +} + +.flr-ms__row { + position: relative; + display: flex; + align-items: flex-start; + gap: 9px; + padding: 8px 12px; + cursor: pointer; + font: 400 13px/1.4 var(--flr-font-sans, system-ui); +} + +.flr-ms__panel .flr-ms__row { + align-items: center; + min-height: 44px; + box-sizing: border-box; +} + +.flr-ms__row:hover { + background: color-mix(in oklab, currentColor 5%, transparent); +} + +.flr-ms__row--on { + background: color-mix(in oklab, var(--flr-ms-accent) 8%, transparent); +} + +.flr-ms__list:focus-visible .flr-ms__row--active { + outline: 2px solid var(--flr-ms-accent); + outline-offset: -2px; +} + +.flr-ms__native { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; + pointer-events: none; +} + +.flr-ms__check { + position: relative; + flex-shrink: 0; + width: 16px; + height: 16px; + margin-top: 2px; + box-sizing: border-box; + border: 1px solid var(--flr-rule, var(--color-rule-strong, rgba(30, 41, 59, 0.28))); + border-radius: 4px; + background: transparent; + transition: + background 140ms, + border-color 140ms; +} + +.flr-ms__panel .flr-ms__check { + margin-top: 0; +} + +.flr-ms__check::after { + content: ''; + position: absolute; + inset: 0; + background: var(--flr-ms-accent-ink); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none'%3E%3Cpath stroke='black' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m4 8 3 3 5-6'/%3E%3C/svg%3E") + center / 12px 12px no-repeat; + opacity: 0; + transition: opacity 120ms; +} + +.flr-ms__row--on .flr-ms__check { + background: var(--flr-ms-accent); + border-color: var(--flr-ms-accent); +} + +.flr-ms__row--on .flr-ms__check::after { + opacity: 1; +} + +.flr-ms__native:focus-visible + .flr-ms__check { + box-shadow: 0 0 0 3px color-mix(in oklab, var(--flr-ms-accent) 35%, transparent); +} + +.flr-ms__native:disabled + .flr-ms__check { + opacity: 0.5; +} + +.flr-ms__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.flr-ms__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.flr-ms__hint { + font-size: 11.5px; + color: var(--color-ink-3, #5d6b80); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.flr-ms__empty { + margin: 0; + padding: 14px 12px; + font: 400 12.5px/1.4 var(--flr-font-sans, system-ui); + color: var(--color-ink-3, #5d6b80); +} + +.flr-ms__foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 12px; + border-top: 1px solid var(--flr-ms-rule); + font: 400 11.5px/1 var(--flr-font-sans, system-ui); + opacity: 0.75; +} + +.flr-ms__clear { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + text-decoration: underline; + padding: 0; +} + +@media (prefers-reduced-motion: reduce) { + .flr-ms__trigger, + .flr-ms__check, + .flr-ms__check::after { + transition: none; + } +} diff --git a/packages/ui-ng/src/lib/multi-select/multi-select.component.html b/packages/ui-ng/src/lib/multi-select/multi-select.component.html new file mode 100644 index 00000000..380137e5 --- /dev/null +++ b/packages/ui-ng/src/lib/multi-select/multi-select.component.html @@ -0,0 +1,139 @@ +@if (mode() === 'dropdown') { + + + @if (open()) { + + } +} @else { +
+ @if (searchable()) { + + } + +
+ @for (o of visible(); track o.id) { + + } + + @if (visible().length === 0) { +

+ @if (loading()) { + {{ loadingText() }} + } @else { + {{ options().length === 0 ? emptyText() : noMatchText() }} + } +

+ } +
+ +
+ {{ countText() }} + @if (selectedCount() > 0 && !disabled()) { + + } +
+
+} diff --git a/packages/ui-ng/src/lib/multi-select/multi-select.component.ts b/packages/ui-ng/src/lib/multi-select/multi-select.component.ts index 34790df7..c735b73b 100644 --- a/packages/ui-ng/src/lib/multi-select/multi-select.component.ts +++ b/packages/ui-ng/src/lib/multi-select/multi-select.component.ts @@ -1,10 +1,15 @@ import { + afterNextRender, ChangeDetectionStrategy, Component, computed, + ElementRef, forwardRef, + inject, + Injector, input, signal, + viewChild, } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @@ -14,6 +19,10 @@ export interface MultiSelectOption { readonly hint?: string; } +export type MultiSelectMode = 'list' | 'dropdown'; + +let nextId = 0; + @Component({ selector: 'flr-multi-select', changeDetection: ChangeDetectionStrategy.OnPush, @@ -24,153 +33,22 @@ export interface MultiSelectOption { multi: true, }, ], - template: ` -
- @if (searchable()) { - - } - -
- @for (o of visible(); track o.id) { - - } - - @if (visible().length === 0) { -

- @if (loading()) { - {{ loadingText() }} - } @else { - {{ options().length === 0 ? emptyText() : noMatchText() }} - } -

- } -
- -
- {{ selectedCount() }} selected - @if (selectedCount() > 0 && !disabled()) { - - } -
-
- `, - styles: ` - :host { - display: block; - } - .flr-ms { - border: 1px solid var(--flr-rule, #e2e8f0); - border-radius: 8px; - overflow: hidden; - background: var(--flr-bg, #fff); - } - .flr-ms--invalid { - border-color: var(--color-danger, #dc2626); - } - .flr-ms__search { - width: 100%; - box-sizing: border-box; - border: 0; - border-bottom: 1px solid var(--flr-rule, #e2e8f0); - padding: 9px 12px; - font: 400 13px/1.4 var(--flr-font-sans, system-ui); - color: inherit; - background: transparent; - } - .flr-ms__search:focus { - outline: none; - border-bottom-color: var(--flr-accent, #f59e0b); - } - .flr-ms__list { - max-height: 260px; - overflow-y: auto; - } - .flr-ms__row { - display: flex; - align-items: flex-start; - gap: 9px; - padding: 8px 12px; - cursor: pointer; - font: 400 13px/1.4 var(--flr-font-sans, system-ui); - } - .flr-ms__row:hover { - background: color-mix(in oklab, currentColor 5%, transparent); - } - .flr-ms__row--on { - background: color-mix(in oklab, var(--flr-accent, #f59e0b) 8%, transparent); - } - .flr-ms__box { - margin-top: 2px; - flex-shrink: 0; - } - .flr-ms__text { - display: flex; - flex-direction: column; - min-width: 0; - } - .flr-ms__label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .flr-ms__hint { - font-size: 11.5px; - opacity: 0.65; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .flr-ms__empty { - margin: 0; - padding: 14px 12px; - font: 400 12.5px/1.4 var(--flr-font-sans, system-ui); - color: var(--color-ink-3, #5d6b80); - } - .flr-ms__foot { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 7px 12px; - border-top: 1px solid var(--flr-rule, #e2e8f0); - font: 400 11.5px/1 var(--flr-font-sans, system-ui); - opacity: 0.75; - } - .flr-ms__clear { - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - font: inherit; - text-decoration: underline; - padding: 0; - } - `, + host: { + '[class.flr-ms-host--dropdown]': "mode() === 'dropdown'", + '(document:pointerdown)': 'onDocumentPointerDown($event)', + '(focusout)': 'onFocusOut($event)', + }, + templateUrl: './multi-select.component.html', + styleUrl: './multi-select.component.css', }) export class MultiSelectComponent implements ControlValueAccessor { readonly options = input.required(); + readonly mode = input('list'); readonly label = input('Options'); + readonly triggerId = input(null, { alias: 'id' }); + readonly placeholder = input('Select…'); + readonly selectedText = input<(count: number) => string>((count) => `${count} selected`); + readonly clearText = input('Clear'); readonly searchable = input(true); readonly searchPlaceholder = input('Filter…'); readonly searchLabel = input('Filter options'); @@ -180,10 +58,19 @@ export class MultiSelectComponent implements ControlValueAccessor { readonly noMatchText = input('No match.'); readonly invalid = input(false); + protected readonly uid = `flr-ms-${nextId++}`; protected readonly query = signal(''); protected readonly disabled = signal(false); + protected readonly open = signal(false); + protected readonly active = signal(0); private readonly selected = signal>(new Set()); + private readonly host = inject>(ElementRef); + private readonly injector = inject(Injector); + private readonly trigger = viewChild>('trigger'); + private readonly search = viewChild>('search'); + private readonly listbox = viewChild>('listbox'); + protected readonly visible = computed(() => { const q = this.query().trim().toLowerCase(); if (!q) return this.options(); @@ -194,6 +81,22 @@ export class MultiSelectComponent implements ControlValueAccessor { protected readonly selectedCount = computed(() => this.selected().size); + protected readonly countText = computed(() => this.selectedText()(this.selectedCount())); + + protected readonly summary = computed(() => { + const picked = this.selected(); + if (picked.size === 0) return this.placeholder(); + if (picked.size === 1) { + const [id] = picked; + return this.options().find((o) => o.id === id)?.label ?? id; + } + return this.countText(); + }); + + protected readonly activeOptionId = computed(() => + this.open() && this.visible().length > 0 ? this.optionId(this.active()) : null, + ); + private onChange: (value: string[]) => void = () => {}; private onTouched: () => void = () => {}; @@ -201,8 +104,13 @@ export class MultiSelectComponent implements ControlValueAccessor { return this.selected().has(id); } + protected optionId(index: number): string { + return `${this.uid}-opt-${index}`; + } + protected onQuery(event: Event): void { this.query.set((event.target as HTMLInputElement).value); + this.active.set(0); } protected toggle(id: string): void { @@ -221,6 +129,108 @@ export class MultiSelectComponent implements ControlValueAccessor { this.emit(); } + protected toggleOpen(): void { + if (this.open()) this.close(true); + else this.openPanel(); + } + + protected onTriggerKeydown(event: KeyboardEvent): void { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + this.openPanel(); + } + } + + protected onPanelKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.close(true); + } + } + + protected onSearchKeydown(event: KeyboardEvent): void { + if (event.key === 'ArrowDown' && this.visible().length > 0) { + event.preventDefault(); + this.listbox()?.nativeElement.focus(); + } + } + + protected onListKeydown(event: KeyboardEvent): void { + const count = this.visible().length; + if (count === 0) return; + switch (event.key) { + case 'ArrowDown': + this.moveActive(Math.min(this.active() + 1, count - 1)); + break; + case 'ArrowUp': + if (this.active() === 0 && this.search()) { + this.search()?.nativeElement.focus(); + } else { + this.moveActive(Math.max(this.active() - 1, 0)); + } + break; + case 'Home': + this.moveActive(0); + break; + case 'End': + this.moveActive(count - 1); + break; + case ' ': + case 'Enter': { + const option = this.visible()[this.active()]; + if (option) this.toggle(option.id); + break; + } + default: + return; + } + event.preventDefault(); + } + + protected onListClick(event: MouseEvent): void { + const row = (event.target as HTMLElement).closest('[role="option"]'); + const index = Number(row?.dataset['index']); + const option = this.visible()[index]; + if (!option) return; + this.active.set(index); + this.toggle(option.id); + } + + protected onDocumentPointerDown(event: PointerEvent): void { + if (!this.open()) return; + if (!this.host.nativeElement.contains(event.target as Node)) this.close(false); + } + + protected onFocusOut(event: FocusEvent): void { + const next = event.relatedTarget as Node | null; + if (this.open() && next && !this.host.nativeElement.contains(next)) this.close(false); + } + + private openPanel(): void { + if (this.disabled() || this.open()) return; + const firstSelected = this.visible().findIndex((o) => this.isSelected(o.id)); + this.active.set(Math.max(firstSelected, 0)); + this.open.set(true); + afterNextRender(() => (this.search() ?? this.listbox())?.nativeElement.focus(), { + injector: this.injector, + }); + } + + private close(restoreFocus: boolean): void { + this.open.set(false); + this.query.set(''); + this.onTouched(); + if (restoreFocus) this.trigger()?.nativeElement.focus(); + } + + private moveActive(index: number): void { + this.active.set(index); + this.host.nativeElement + .querySelector(`#${this.optionId(index)}`) + ?.scrollIntoView({ block: 'nearest' }); + } + private emit(): void { this.onChange([...this.selected()]); this.onTouched(); @@ -240,5 +250,6 @@ export class MultiSelectComponent implements ControlValueAccessor { setDisabledState(isDisabled: boolean): void { this.disabled.set(isDisabled); + if (isDisabled && this.open()) this.close(false); } } diff --git a/packages/ui-ng/src/public-api.ts b/packages/ui-ng/src/public-api.ts index ba91b85b..4b341330 100644 --- a/packages/ui-ng/src/public-api.ts +++ b/packages/ui-ng/src/public-api.ts @@ -56,7 +56,7 @@ export type { SwitchSize } from './lib/switch/switch.component'; export { SelectComponent } from './lib/select/select.component'; export { MultiSelectComponent } from './lib/multi-select/multi-select.component'; -export type { MultiSelectOption } from './lib/multi-select/multi-select.component'; +export type { MultiSelectMode, MultiSelectOption } from './lib/multi-select/multi-select.component'; export { BarChartComponent } from './lib/bar-chart/bar-chart.component'; export type { BarChartPoint } from './lib/bar-chart/bar-chart.component'; export { TreeSelectComponent } from './lib/tree-select/tree-select.component'; From 129ba68064cc45d45f12b81f69dd2603655c45d6 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Tue, 6 Oct 2026 09:07:48 +0200 Subject: [PATCH 2/2] fix(ui-ng): render the multi-select dropdown panel in a CDK connected overlay --- .../showcase/src/stories/Pickers.stories.ts | 17 ++++---- .../multi-select/multi-select.component.css | 12 ++---- .../multi-select/multi-select.component.html | 23 +++++++++-- .../multi-select/multi-select.component.ts | 41 ++++++++++++++----- .../src/lib/multi-select/panel-tokens.ts | 24 +++++++++++ 5 files changed, 88 insertions(+), 29 deletions(-) create mode 100644 packages/ui-ng/src/lib/multi-select/panel-tokens.ts diff --git a/packages/showcase/src/stories/Pickers.stories.ts b/packages/showcase/src/stories/Pickers.stories.ts index d391559f..4a496c62 100644 --- a/packages/showcase/src/stories/Pickers.stories.ts +++ b/packages/showcase/src/stories/Pickers.stories.ts @@ -65,13 +65,14 @@ export const MultiSelectDropdown: Story = { }), play: async ({ canvasElement }) => { const canvas = within(canvasElement); + const overlay = within(canvasElement.ownerDocument.body); const trigger = canvas.getByRole('button', { name: /^Regions:/ }); const value = canvas.getByTestId('value'); await expect(trigger).toHaveTextContent('eu-west-1'); await expect(trigger).toHaveAttribute('aria-haspopup', 'listbox'); await userEvent.click(trigger); - const listbox = await canvas.findByRole('listbox'); + const listbox = await overlay.findByRole('listbox'); await expect(trigger).toHaveAttribute('aria-expanded', 'true'); await expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); await userEvent.click(within(listbox).getByRole('option', { name: /us-east-1/ })); @@ -79,25 +80,25 @@ export const MultiSelectDropdown: Story = { await expect(trigger).toHaveTextContent('2 selected'); await userEvent.keyboard('{Escape}'); - await waitFor(() => expect(canvas.queryByRole('listbox')).toBeNull()); + await waitFor(() => expect(overlay.queryByRole('listbox')).toBeNull()); await expect(trigger).toHaveFocus(); await userEvent.click(trigger); - await canvas.findByRole('listbox'); + await overlay.findByRole('listbox'); await userEvent.click(canvas.getByTestId('outside')); - await waitFor(() => expect(canvas.queryByRole('listbox')).toBeNull()); + await waitFor(() => expect(overlay.queryByRole('listbox')).toBeNull()); trigger.focus(); await userEvent.keyboard('{ArrowDown}'); - await canvas.findByRole('listbox'); - await waitFor(() => expect(canvas.getByRole('searchbox')).toHaveFocus()); + await overlay.findByRole('listbox'); + await waitFor(() => expect(overlay.getByRole('searchbox')).toHaveFocus()); await userEvent.keyboard('{ArrowDown}{ArrowDown} '); await waitFor(() => expect(value).toHaveTextContent('eu-west-1,us-east-1,eu-central-1')); await expect( - within(canvas.getByRole('listbox')).getByRole('option', { name: /eu-central-1/ }), + within(overlay.getByRole('listbox')).getByRole('option', { name: /eu-central-1/ }), ).toHaveAttribute('aria-selected', 'true'); - await userEvent.click(canvas.getByRole('button', { name: 'Clear' })); + await userEvent.click(overlay.getByRole('button', { name: 'Clear' })); await waitFor(() => expect(trigger).toHaveTextContent('Any region')); await expect(value.textContent?.trim()).toBe(''); }, diff --git a/packages/ui-ng/src/lib/multi-select/multi-select.component.css b/packages/ui-ng/src/lib/multi-select/multi-select.component.css index 636566df..4b55e549 100644 --- a/packages/ui-ng/src/lib/multi-select/multi-select.component.css +++ b/packages/ui-ng/src/lib/multi-select/multi-select.component.css @@ -1,12 +1,12 @@ -:host { +flr-multi-select, +.flr-ms__panel { --flr-ms-accent: var(--flr-accent, var(--color-accent, #f59e0b)); --flr-ms-accent-ink: var(--flr-accent-ink, var(--color-on-accent, #ffffff)); --flr-ms-rule: var(--flr-rule, #e2e8f0); - display: block; } -:host(.flr-ms-host--dropdown) { - position: relative; +flr-multi-select { + display: block; } .flr-ms { @@ -75,10 +75,6 @@ } .flr-ms__panel { - position: absolute; - top: calc(100% + 6px); - left: 0; - z-index: 30; width: max-content; min-width: 100%; max-width: min(360px, calc(100vw - 32px)); diff --git a/packages/ui-ng/src/lib/multi-select/multi-select.component.html b/packages/ui-ng/src/lib/multi-select/multi-select.component.html index 380137e5..a213868f 100644 --- a/packages/ui-ng/src/lib/multi-select/multi-select.component.html +++ b/packages/ui-ng/src/lib/multi-select/multi-select.component.html @@ -1,6 +1,8 @@ @if (mode() === 'dropdown') { - @if (open()) { -