diff --git a/packages/showcase/src/stories/Pickers.stories.ts b/packages/showcase/src/stories/Pickers.stories.ts
index 90cc6005..4a496c62 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,63 @@ 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 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 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/ }));
+ await waitFor(() => expect(value).toHaveTextContent('eu-west-1,us-east-1'));
+ await expect(trigger).toHaveTextContent('2 selected');
+
+ await userEvent.keyboard('{Escape}');
+ await waitFor(() => expect(overlay.queryByRole('listbox')).toBeNull());
+ await expect(trigger).toHaveFocus();
+
+ await userEvent.click(trigger);
+ await overlay.findByRole('listbox');
+ await userEvent.click(canvas.getByTestId('outside'));
+ await waitFor(() => expect(overlay.queryByRole('listbox')).toBeNull());
+
+ trigger.focus();
+ await userEvent.keyboard('{ArrowDown}');
+ 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(overlay.getByRole('listbox')).getByRole('option', { name: /eu-central-1/ }),
+ ).toHaveAttribute('aria-selected', 'true');
+
+ await userEvent.click(overlay.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..4b55e549
--- /dev/null
+++ b/packages/ui-ng/src/lib/multi-select/multi-select.component.css
@@ -0,0 +1,249 @@
+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);
+}
+
+flr-multi-select {
+ display: block;
+}
+
+.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 {
+ 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..a213868f
--- /dev/null
+++ b/packages/ui-ng/src/lib/multi-select/multi-select.component.html
@@ -0,0 +1,156 @@
+@if (mode() === 'dropdown') {
+
+
+
+
+ @if (searchable()) {
+
+ }
+
+
+ @for (o of visible(); track o.id; let i = $index) {
+
+
+
+ {{ o.label }}
+ @if (o.hint) {
+ {{ o.hint }}
+ }
+
+
+ }
+
+
+ @if (visible().length === 0) {
+
+ @if (loading()) {
+ {{ loadingText() }}
+ } @else {
+ {{ options().length === 0 ? emptyText() : noMatchText() }}
+ }
+
+ }
+
+
+
+
+} @else {
+
+ @if (searchable()) {
+
+ }
+
+
+ @for (o of visible(); track o.id) {
+
+ }
+
+ @if (visible().length === 0) {
+
+ @if (loading()) {
+ {{ loadingText() }}
+ } @else {
+ {{ options().length === 0 ? emptyText() : noMatchText() }}
+ }
+
+ }
+
+
+
+
+}
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..36549b0f 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,19 +1,39 @@
import {
+ afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
+ ElementRef,
forwardRef,
+ inject,
+ Injector,
input,
signal,
+ viewChild,
+ ViewEncapsulation,
} from '@angular/core';
+import { type ConnectedPosition, OverlayModule } from '@angular/cdk/overlay';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { inheritedTokens } from './panel-tokens';
+
export interface MultiSelectOption {
readonly id: string;
readonly label: string;
readonly hint?: string;
}
+export type MultiSelectMode = 'list' | 'dropdown';
+
+let nextId = 0;
+
+const PANEL_POSITIONS: ConnectedPosition[] = [
+ { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 6 },
+ { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -6 },
+ { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 6 },
+ { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -6 },
+];
+
@Component({
selector: 'flr-multi-select',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -24,153 +44,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() }}
- }
-
- }
-
-
-
-
- `,
- 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;
- }
- `,
+ encapsulation: ViewEncapsulation.None,
+ imports: [OverlayModule],
+ host: {
+ '(focusout)': 'onFocusOut($event)',
+ },
+ templateUrl: './multi-select.component.html',
+ styleUrls: ['./multi-select.component.css', '../overlay/cdk-overlay.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 +69,23 @@ 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);
+ protected readonly panelTokens = signal>({});
+ protected readonly panelMinWidth = signal(0);
+ protected readonly positions = PANEL_POSITIONS;
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');
+ private readonly panel = viewChild>('panel');
+
protected readonly visible = computed(() => {
const q = this.query().trim().toLowerCase();
if (!q) return this.options();
@@ -194,6 +96,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 +119,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 +144,114 @@ 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 onOutsideClick(event: MouseEvent): void {
+ if (this.trigger()?.nativeElement.contains(event.target as Node)) return;
+ this.close(false);
+ }
+
+ protected onFocusOut(event: FocusEvent): void {
+ const next = event.relatedTarget as Node | null;
+ if (!this.open() || !next) return;
+ if (this.host.nativeElement.contains(next)) return;
+ if (this.panel()?.nativeElement.contains(next)) return;
+ 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.panelTokens.set(inheritedTokens(this.host.nativeElement));
+ this.panelMinWidth.set(this.trigger()?.nativeElement.offsetWidth ?? 0);
+ this.open.set(true);
+ afterNextRender(() => (this.search() ?? this.listbox())?.nativeElement.focus(), {
+ injector: this.injector,
+ });
+ }
+
+ protected close(restoreFocus: boolean): void {
+ if (!this.open()) return;
+ 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.listbox()
+ ?.nativeElement.querySelector(`#${this.optionId(index)}`)
+ ?.scrollIntoView({ block: 'nearest' });
+ }
+
private emit(): void {
this.onChange([...this.selected()]);
this.onTouched();
@@ -240,5 +271,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/lib/multi-select/panel-tokens.ts b/packages/ui-ng/src/lib/multi-select/panel-tokens.ts
new file mode 100644
index 00000000..f1fadaf8
--- /dev/null
+++ b/packages/ui-ng/src/lib/multi-select/panel-tokens.ts
@@ -0,0 +1,24 @@
+const INHERITED_TOKENS = [
+ '--flr-bg',
+ '--flr-rule',
+ '--flr-accent',
+ '--flr-accent-ink',
+ '--flr-font-sans',
+ '--font-sans',
+ '--color-card',
+ '--color-ink',
+ '--color-ink-3',
+ '--color-accent',
+ '--color-on-accent',
+ '--color-rule-strong',
+] as const;
+
+export function inheritedTokens(element: HTMLElement): Record {
+ const style = getComputedStyle(element);
+ const tokens: Record = { color: style.color };
+ for (const name of INHERITED_TOKENS) {
+ const value = style.getPropertyValue(name).trim();
+ if (value) tokens[name] = value;
+ }
+ return tokens;
+}
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';