-
+ @if (select.bottomSheetMode()) {
+
{{ trigger.placeholder }}
+ } @else {
+
+ }
@if (displayerCount() !== null) {
diff --git a/packages/ng/multi-select/input/select-input.bottom-sheet.spec.ts b/packages/ng/multi-select/input/select-input.bottom-sheet.spec.ts
new file mode 100644
index 0000000000..2e31eb85ea
--- /dev/null
+++ b/packages/ng/multi-select/input/select-input.bottom-sheet.spec.ts
@@ -0,0 +1,450 @@
+import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout';
+import { ApplicationRef, ChangeDetectionStrategy, Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { LuCoreSelectTotalCountDirective } from '@lucca-front/ng/core-select';
+import { FormFieldComponent } from '@lucca-front/ng/form-field';
+import { BehaviorSubject, map, Observable } from 'rxjs';
+import { vi } from 'vitest';
+import { LuMultiSelection } from '../select.model';
+import { LuMultiSelectWithSelectAllDirective } from './select-all/with-select-all.directive';
+import { LuMultiSelectInputComponent } from './select-input.component';
+
+type Entity = { id: number; name: string };
+
+const options: Entity[] = [
+ { id: 1, name: 'test 1' },
+ { id: 2, name: 'test 2' },
+];
+
+/**
+ * The select observes `injectMediaMinBreakpoint('S', true)`, which matches while the viewport is *below*
+ * the S breakpoint, so this single subject drives bottom sheet mode.
+ */
+class FakeBreakpointObserver {
+ readonly belowSmallBreakpoint = new BehaviorSubject(false);
+
+ observe(): Observable
{
+ return this.belowSmallBreakpoint.pipe(map((matches) => ({ matches, breakpoints: {} })));
+ }
+
+ isMatched(): boolean {
+ return this.belowSmallBreakpoint.value;
+ }
+}
+
+@Component({
+ selector: 'lu-multi-select-bottom-sheet-host',
+ imports: [FormsModule, LuMultiSelectInputComponent, FormFieldComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+ `,
+})
+class HostComponent {
+ selected: Entity[] = [];
+
+ options: Entity[] = options;
+}
+
+@Component({
+ selector: 'lu-multi-select-required-bottom-sheet-host',
+ imports: [FormsModule, LuMultiSelectInputComponent, FormFieldComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+ `,
+})
+class RequiredHostComponent {
+ selected: Entity[] = [];
+
+ options: Entity[] = options;
+}
+
+@Component({
+ selector: 'lu-multi-select-all-bottom-sheet-host',
+ imports: [FormsModule, LuMultiSelectInputComponent, FormFieldComponent, LuMultiSelectWithSelectAllDirective, LuCoreSelectTotalCountDirective],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+ `,
+})
+class SelectAllHostComponent {
+ selected: LuMultiSelection = { mode: 'none' };
+
+ options: Entity[] = options;
+}
+
+/**
+ * Pages of 10 options out of 25, the way a paginated consumer does it: `nextPage` extends the
+ * `[options]` array, and asking for a page past the end leaves it unchanged.
+ */
+@Component({
+ selector: 'lu-multi-select-paginated-bottom-sheet-host',
+ imports: [FormsModule, LuMultiSelectInputComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: ` `,
+})
+class PaginatedHostComponent {
+ readonly allOptions: Entity[] = Array.from({ length: 25 }, (_, index) => ({ id: index, name: `test ${index}` }));
+
+ selected: Entity[] = [];
+
+ options: Entity[] = this.allOptions.slice(0, 10);
+
+ nextPageCount = 0;
+
+ loadNextPage(): void {
+ this.nextPageCount++;
+ this.options = this.allOptions.slice(0, this.options.length + 10);
+ }
+}
+
+describe(`${LuMultiSelectInputComponent.name} bottom sheet`, () => {
+ let breakpointObserver: FakeBreakpointObserver;
+
+ beforeEach(() => {
+ breakpointObserver = new FakeBreakpointObserver();
+ TestBed.configureTestingModule({
+ providers: [{ provide: BreakpointObserver, useValue: breakpointObserver }],
+ });
+ });
+
+ function createSelect(belowSmallBreakpoint: boolean): { fixture: ComponentFixture; select: LuMultiSelectInputComponent } {
+ breakpointObserver.belowSmallBreakpoint.next(belowSmallBreakpoint);
+ const fixture = TestBed.createComponent(HostComponent);
+ fixture.detectChanges();
+ const select = fixture.debugElement.query(By.directive(LuMultiSelectInputComponent)).componentInstance as LuMultiSelectInputComponent;
+ return { fixture, select };
+ }
+
+ describe('overlay', () => {
+ it('should open the panel as a dialog in sheet mode below the S breakpoint', () => {
+ // Arrange
+ const { select } = createSelect(true);
+
+ // Act
+ select.openPanel();
+
+ // Assert
+ const pane = document.querySelector('.cdk-overlay-pane');
+ expect(pane).toHaveClass('dialog');
+ expect(pane).toHaveClass('mod-sheet');
+ expect(pane).toHaveClass('mod-maxContent');
+ // Scopes the select-specific dialog header tweaks (centered title, close button pulled out of
+ // flow) so they don't leak onto unrelated `lu-dialog-header` usages elsewhere in the app.
+ expect(pane).toHaveClass('mod-select');
+ expect(document.querySelector('.dialog_backdrop')).not.toBeNull();
+ });
+
+ it('should keep anchoring the panel to the field above the S breakpoint', () => {
+ // Arrange
+ const { select } = createSelect(false);
+
+ // Act
+ select.openPanel();
+
+ // Assert
+ expect(document.querySelector('.cdk-overlay-pane')).not.toHaveClass('dialog');
+ expect(document.querySelector('.dialog_backdrop')).toBeNull();
+ });
+ });
+
+ describe('resizing across the breakpoint while the panel stays open', () => {
+ /**
+ * The panel's own ref (`SelectPanelSheetRef` vs `SelectPanelRef`) is picked once at open time —
+ * `bottomSheetMode()` changing afterwards must not make the panel's template try to switch which
+ * surface it renders (it was never actually attached as a CDK dialog), which used to crash with
+ * `NG0201: No provider found for LuDialogRef`. Instead, the panel closes outright.
+ */
+ it('should close, rather than crash, when going from the desktop overlay to below the S breakpoint', () => {
+ // Arrange
+ const { fixture, select } = createSelect(false);
+ select.openPanel();
+ expect(select.isPanelOpen).toBe(true);
+
+ // Act
+ breakpointObserver.belowSmallBreakpoint.next(true);
+ expect(() => fixture.detectChanges()).not.toThrow();
+
+ // Assert
+ expect(select.isPanelOpen).toBe(false);
+ });
+
+ it('should close, rather than crash, when going from the sheet to above the S breakpoint', () => {
+ // Arrange
+ const { fixture, select } = createSelect(true);
+ select.openPanel();
+ expect(select.isPanelOpen).toBe(true);
+
+ // Act
+ breakpointObserver.belowSmallBreakpoint.next(false);
+ expect(() => fixture.detectChanges()).not.toThrow();
+
+ // Assert
+ expect(select.isPanelOpen).toBe(false);
+ });
+
+ it('should still resolve the panel title on the next open, even though the trigger was torn down and rebuilt as button/input while the breakpoint round-tripped', () => {
+ // Arrange
+ const { fixture, select } = createSelect(true);
+ select.openPanel();
+ expect(select.panelTitle()).toBe('Options');
+
+ // Act — round trip through desktop, which auto-closes the panel and rebuilds the trigger
+ // (button below the S breakpoint, input above it) each way
+ breakpointObserver.belowSmallBreakpoint.next(false);
+ fixture.detectChanges();
+ breakpointObserver.belowSmallBreakpoint.next(true);
+ fixture.detectChanges();
+ select.openPanel();
+ TestBed.inject(ApplicationRef).tick();
+
+ // Assert
+ expect(select.panelTitle()).toBe('Options');
+ });
+ });
+
+ describe('trigger element', () => {
+ function trigger(fixture: ComponentFixture): HTMLElement | null {
+ return fixture.nativeElement.querySelector('.multipleSelect-displayer-search');
+ }
+
+ it('should be a button below the S breakpoint, so tapping it on iOS does not raise the keyboard', () => {
+ // Act
+ const { fixture } = createSelect(true);
+
+ // Assert
+ expect(trigger(fixture)?.tagName).toBe('BUTTON');
+ // Without this an implicit submit fires when the field sits in a form
+ expect(trigger(fixture)).toHaveAttribute('type', 'button');
+ });
+
+ it('should stay a searchable text input above the S breakpoint', () => {
+ // Act
+ const { fixture } = createSelect(false);
+
+ // Assert
+ expect(trigger(fixture)?.tagName).toBe('INPUT');
+ expect(trigger(fixture)).toHaveAttribute('type', 'text');
+ expect(trigger(fixture)).toHaveAttribute('role', 'combobox');
+ });
+
+ it('should announce that it opens the sheet rather than an inline listbox', () => {
+ // Arrange
+ const { fixture, select } = createSelect(true);
+
+ // Act
+ select.openPanel();
+ fixture.detectChanges();
+
+ // Assert
+ expect(trigger(fixture)).toHaveAttribute('aria-haspopup', 'dialog');
+ expect(trigger(fixture)).toHaveAttribute('aria-expanded', 'true');
+ expect(trigger(fixture)).not.toHaveAttribute('role');
+ });
+
+ it('should drop the input-only attributes a button has no use for', () => {
+ // Act
+ const { fixture } = createSelect(true);
+
+ // Assert
+ expect(trigger(fixture)).not.toHaveAttribute('placeholder');
+ expect(trigger(fixture)).not.toHaveAttribute('readonly');
+ expect(trigger(fixture)).not.toHaveAttribute('aria-activedescendant');
+ });
+
+ it('should render the placeholder as text, a button having no placeholder of its own', () => {
+ // Act
+ const { fixture } = createSelect(true);
+
+ // Assert
+ expect(trigger(fixture)?.textContent?.trim()).toBeTruthy();
+ });
+
+ // The select-all displayer is a fourth displayer variant, easy to leave behind when the three
+ // others swap their input for a button: it kept rendering an ` `, whose placeholder the
+ // directive nulls out below the S breakpoint, so the field showed nothing at all.
+ it('should render the placeholder as text on the select-all displayer too', () => {
+ // Arrange
+ breakpointObserver.belowSmallBreakpoint.next(true);
+
+ // Act
+ const fixture = TestBed.createComponent(SelectAllHostComponent);
+ fixture.detectChanges();
+
+ // Assert
+ const selectAllTrigger = fixture.nativeElement.querySelector('.multipleSelect-displayer-search') as HTMLElement | null;
+ expect(selectAllTrigger?.tagName).toBe('BUTTON');
+ expect(selectAllTrigger?.textContent?.trim()).toBeTruthy();
+ });
+ });
+
+ describe('focus', () => {
+ it('should not send focus back to the covered displayer below the S breakpoint', () => {
+ // Arrange
+ const { select } = createSelect(true);
+ const focusRequest = vi.spyOn(select.focusInput$, 'next');
+
+ // Act — this runs after every selection, and would steal focus from the sheet's own search input
+ select.focusInput();
+
+ // Assert
+ expect(focusRequest).not.toHaveBeenCalled();
+ });
+
+ it('should send focus to the displayer above the S breakpoint', () => {
+ // Arrange
+ const { select } = createSelect(false);
+ const focusRequest = vi.spyOn(select.focusInput$, 'next');
+
+ // Act
+ select.focusInput();
+
+ // Assert
+ expect(focusRequest).toHaveBeenCalledWith({ keepClue: true });
+ });
+ });
+
+ describe('sheet header', () => {
+ function sheet(): HTMLElement | null {
+ return document.querySelector('cdk-dialog-container');
+ }
+
+ it('should mark the sheet as a modal dialog named after the field, with its own search input', () => {
+ // Arrange
+ const { fixture, select } = createSelect(true);
+
+ // Act
+ select.openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+
+ // Assert
+ expect(sheet()).toHaveAttribute('role', 'dialog');
+ expect(sheet()).toHaveAttribute('aria-modal', 'true');
+ expect(sheet()).toHaveAttribute('aria-label', 'Options');
+ expect(sheet()?.querySelector('h1')?.textContent?.trim()).toBe('Options');
+ });
+
+ it('should echo the label required marker in the title when the field is required', () => {
+ // Arrange
+ breakpointObserver.belowSmallBreakpoint.next(true);
+ const fixture = TestBed.createComponent(RequiredHostComponent);
+ fixture.detectChanges();
+ const select = fixture.debugElement.query(By.directive(LuMultiSelectInputComponent)).componentInstance as LuMultiSelectInputComponent;
+
+ // Act
+ select.openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+
+ // Assert
+ expect(sheet()?.querySelector('h1 .formLabel-required')).toHaveAttribute('aria-hidden', 'true');
+ expect(sheet()?.querySelector('h1 .formLabel-required')?.textContent).toBe('*');
+ // A single asterisk: the label's own `.formLabel-required` marker must not leak into the plain
+ // title text (`panelTitle`) on top of the one rendered here, or it would show up twice.
+ expect(sheet()?.querySelector('h1')?.textContent?.trim()).toBe('Options*');
+ });
+
+ it('should not show the required marker when the field is not required', () => {
+ // Arrange
+ const { fixture, select } = createSelect(true);
+
+ // Act
+ select.openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+
+ // Assert
+ expect(sheet()?.querySelector('h1 .formLabel-required')).toBeNull();
+ });
+
+ it('should focus the search input synchronously so the tap that opened the sheet also raises the iOS keyboard', () => {
+ // Arrange
+ const { fixture, select } = createSelect(true);
+
+ // Act
+ select.openPanel();
+ fixture.detectChanges();
+
+ // Assert
+ const searchInput = sheet()?.querySelector('.textField-input-value');
+ expect(searchInput).not.toBeNull();
+ expect(document.activeElement).toBe(searchInput);
+ });
+
+ it('should not render a sheet above the S breakpoint', () => {
+ // Arrange
+ const { fixture, select } = createSelect(false);
+
+ // Act
+ select.openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+
+ // Assert
+ expect(document.querySelector('.lu-select-panel-layout')).not.toBeNull();
+ expect(sheet()).toBeNull();
+ });
+ });
+ describe('pagination', () => {
+ function createPaginatedSelect(belowSmallBreakpoint: boolean): { fixture: ComponentFixture; select: LuMultiSelectInputComponent } {
+ breakpointObserver.belowSmallBreakpoint.next(belowSmallBreakpoint);
+ const fixture = TestBed.createComponent(PaginatedHostComponent);
+ fixture.detectChanges();
+ const select = fixture.debugElement.query(By.directive(LuMultiSelectInputComponent)).componentInstance as LuMultiSelectInputComponent;
+ return { fixture, select };
+ }
+
+ /**
+ * Each page request is deferred by a microtask and only the next render measures the viewport
+ * again, so the two are alternated until the panel stops asking — with a bound, so a panel that
+ * never stops fails the test instead of hanging it.
+ */
+ async function drainPageRequests(fixture: ComponentFixture): Promise {
+ for (let previous = -1, rounds = 0; previous !== fixture.componentInstance.nextPageCount && rounds < 10; rounds++) {
+ previous = fixture.componentInstance.nextPageCount;
+ await Promise.resolve();
+ TestBed.inject(ApplicationRef).tick();
+ }
+ }
+
+ it('should keep asking for the next page while the sheet has nothing to scroll, and stop once the options are exhausted', async () => {
+ // Arrange
+ const { fixture, select } = createPaginatedSelect(true);
+
+ // Act
+ select.openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+ await drainPageRequests(fixture);
+
+ // Assert: a sheet taller than its first page never scrolls, so the scroll handler alone would
+ // leave pagination stuck on page 1 — and asking for a page that brings nothing back stops it.
+ expect(fixture.componentInstance.options.length).toBe(25);
+ expect(fixture.componentInstance.nextPageCount).toBe(3);
+ });
+
+ it('should leave pagination to the scroll handler above the S breakpoint', async () => {
+ // Arrange
+ const { fixture, select } = createPaginatedSelect(false);
+
+ // Act
+ select.openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+ await drainPageRequests(fixture);
+
+ // Assert: the popover caps its content at 20rem, which a page of options always overflows.
+ expect(fixture.componentInstance.nextPageCount).toBe(0);
+ });
+ });
+});
diff --git a/packages/ng/multi-select/input/select-input.component.ts b/packages/ng/multi-select/input/select-input.component.ts
index a44882881e..3e8a407a69 100644
--- a/packages/ng/multi-select/input/select-input.component.ts
+++ b/packages/ng/multi-select/input/select-input.component.ts
@@ -22,6 +22,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { ClearComponent } from '@lucca-front/ng/clear';
import { getIntlPluralLabel, intlInputOptions, LOCALE_PLURAL_RULES, luBooleanAttribute, luNumberAttribute, LuPluralForms } from '@lucca-front/ng/core';
import { ALuSelectInputComponent, LU_CORE_SELECT_TRANSLATIONS, LuOptionContext, provideLuSelectLabelsAndIds, ɵLuOptionOutletDirective } from '@lucca-front/ng/core-select';
+import { provideLuDialog } from '@lucca-front/ng/dialog';
import { FILTER_PILL_INPUT_COMPONENT, FilterPillDisplayerDirective, FilterPillLabelDirective } from '@lucca-front/ng/filter-pills/core';
import { ɵPresentationDisplayDefaultDirective } from '@lucca-front/ng/form-field';
import { LuTooltipModule } from '@lucca-front/ng/tooltip';
@@ -61,6 +62,8 @@ import { LuMultiSelectPanelRef } from './panel.model';
},
provideLuSelectLabelsAndIds(),
LuMultiSelectPanelRefFactory,
+ // Below the `S` breakpoint the panel opens as a dialog in `sheet` mode.
+ provideLuDialog(),
{
provide: FILTER_PILL_INPUT_COMPONENT,
useExisting: forwardRef(() => LuMultiSelectInputComponent),
@@ -158,6 +161,11 @@ export class LuMultiSelectInputComponent extends ALuSelectInputComponent
+@if (selectInput.panelBottomSheetMode()) {
+
+
+
+ @if (selectInput.panelTitle(); as title) {
+
+ @if (selectInput.panelTitleRequired()) {
+ {{ title }}*
+ } @else {
+ {{ title }}
+ }
+ }
+
+
+
+
+
+
+
+
+} @else {
+
+
+
+}
+
+
+
+
+
+
@@ -100,4 +154,4 @@
}
-
+
diff --git a/packages/ng/multi-select/panel/panel.component.ts b/packages/ng/multi-select/panel/panel.component.ts
index 14db2d32c5..530b1e665e 100644
--- a/packages/ng/multi-select/panel/panel.component.ts
+++ b/packages/ng/multi-select/panel/panel.component.ts
@@ -1,5 +1,5 @@
import { NgTemplateOutlet } from '@angular/common';
-import { afterNextRender, AfterViewInit, ChangeDetectionStrategy, Component, computed, ElementRef, forwardRef, inject, Injector, signal } from '@angular/core';
+import { afterNextRender, AfterViewInit, ChangeDetectionStrategy, Component, computed, ElementRef, forwardRef, inject, Injector, signal, viewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { isNotNil, PortalDirective } from '@lucca-front/ng/core';
@@ -12,11 +12,14 @@ import {
SELECT_PANEL_INSTANCE,
TreeDisplayPipe,
ɵCoreSelectPanelElement,
+ ɵfillScrollViewport,
ɵgetGroupTemplateLocation,
ɵinjectPointerNavigation,
ɵLuOptionComponent,
ɵLuOptionGroupPipe,
} from '@lucca-front/ng/core-select';
+import { DialogComponent, DialogContentComponent, DialogHeaderComponent } from '@lucca-front/ng/dialog';
+import { IconComponent } from '@lucca-front/ng/icon';
import { ListboxComponent, ListboxState, OptionComponent as ListboxOptionComponent } from '@lucca-front/ng/listbox';
import { TreeBranchComponent } from '@lucca-front/ng/tree-select';
import { EMPTY, firstValueFrom } from 'rxjs';
@@ -37,6 +40,9 @@ import { LuOptionsGroupContextPipe } from './option-group-context.pipe';
},
imports: [
LuSelectPanelLayoutComponent,
+ DialogComponent,
+ DialogHeaderComponent,
+ DialogContentComponent,
FormsModule,
LuIsOptionSelectedPipe,
ɵLuOptionComponent,
@@ -47,6 +53,7 @@ import { LuOptionsGroupContextPipe } from './option-group-context.pipe';
ɵCoreSelectPanelElement,
ListboxComponent,
ListboxOptionComponent,
+ IconComponent,
TreeDisplayPipe,
TreeBranchComponent,
],
@@ -110,6 +117,21 @@ export class LuMultiSelectPanelComponent implements AfterViewInit, CoreSelect
return this.clue().length ? this.intl().emptyResults : this.intl().emptyOptions;
});
+ private readonly sheetViewport = viewChild>('sheetViewport', { read: ElementRef });
+
+ constructor() {
+ // Only the sheet needs the viewport filled by hand: it is sized to its own content, so a first
+ // page that doesn't reach its maximum height leaves nothing to scroll and `onScroll` — the only
+ // thing that asks for the next page — never fires. The popover panel caps its content at 20rem,
+ // which a page of options always overflows, so there it already works on its own.
+ ɵfillScrollViewport({
+ viewport: computed(() => this.sheetViewport()?.nativeElement),
+ optionCount: computed(() => this.dataSourceOptions().length),
+ loading: this.loading,
+ nextPage: () => this.panelRef.nextPage.emit(),
+ });
+ }
+
onScroll(evt: Event): void {
if (!(evt.target instanceof HTMLElement)) {
return;
diff --git a/packages/ng/safe-content/safe-external-svg.pipe.ts b/packages/ng/safe-content/safe-external-svg.pipe.ts
index 1008ffafac..ab9813da2b 100644
--- a/packages/ng/safe-content/safe-external-svg.pipe.ts
+++ b/packages/ng/safe-content/safe-external-svg.pipe.ts
@@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { ChangeDetectorRef, inject, Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import DOMPurify from 'isomorphic-dompurify';
-import { map, Subscription } from 'rxjs';
+import { catchError, map, of, Subscription } from 'rxjs';
@Pipe({
name: 'luSafeExternalSvg',
@@ -32,6 +32,9 @@ export class LuSafeExternalSvgPipe implements PipeTransform {
.pipe(
map((svg) => DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } })),
map((svg) => this.#domSanitizer.bypassSecurityTrustHtml(svg)),
+ // The illustrations are decorative and hosted on an external CDN: a failed fetch
+ // must degrade to no illustration, not surface as an uncaught error in the host app.
+ catchError(() => of('')),
)
.subscribe((svg) => {
this.#lastSvgValue = svg;
diff --git a/packages/ng/simple-select/input/panel-ref.factory.ts b/packages/ng/simple-select/input/panel-ref.factory.ts
index 3d02cb525b..f26642bfc3 100644
--- a/packages/ng/simple-select/input/panel-ref.factory.ts
+++ b/packages/ng/simple-select/input/panel-ref.factory.ts
@@ -2,14 +2,17 @@ import { Overlay, OverlayConfig, OverlayPositionBuilder, OverlayRef, ScrollStrat
import { ComponentPortal } from '@angular/cdk/portal';
import { ComponentRef, ElementRef, inject, Injectable, Injector, Type, ViewContainerRef } from '@angular/core';
import { getPushPanelViewportMargin } from '@lucca-front/ng/core';
-import { addAttributesOnCdkContainer, LuSelectPanelRef, SELECT_ID, SELECT_LABEL_ID } from '@lucca-front/ng/core-select';
+import { addAttributesOnCdkContainer, LuSelectPanelRef, SELECT_ID, SELECT_LABEL_ID, ɵopenSelectPanelSheet } from '@lucca-front/ng/core-select';
+import { LuDialogRef, LuDialogService } from '@lucca-front/ng/dialog';
import { takeUntil } from 'rxjs';
import { LuSelectPanelComponent } from '../panel';
import { SIMPLE_SELECT_INPUT } from '../select.model';
import { LuSimpleSelectInputComponent } from './select-input.component';
abstract class BaseSelectPanelRef extends LuSelectPanelRef {
- protected readonly portalRef: ComponentPortal>;
+ /** Carries this ref and the select input down to the panel, however it ends up being rendered. */
+ protected readonly panelInjector: Injector;
+
instance: LuSelectPanelComponent;
protected panelRef: ComponentRef>;
@@ -20,7 +23,7 @@ abstract class BaseSelectPanelRef extends LuSelectPanelRef {
) {
super();
- this.portalRef = new ComponentPortal>(LuSelectPanelComponent, undefined, this.createInjector(selectInput, parentInjector));
+ this.panelInjector = this.createInjector(selectInput, parentInjector);
}
protected createInjector(selectInput: LuSimpleSelectInputComponent, parentInjector: Injector): Injector {
@@ -54,7 +57,7 @@ class SelectPanelRef extends BaseSelectPanelRef {
selectInput: LuSimpleSelectInputComponent,
) {
super(parentInjector, selectInput);
- this.panelRef = overlayRef.attach(this.portalRef);
+ this.panelRef = overlayRef.attach(new ComponentPortal>(LuSelectPanelComponent, undefined, this.panelInjector));
this.instance = this.panelRef.instance;
overlayRef
@@ -75,13 +78,51 @@ class SelectPanelRef extends BaseSelectPanelRef {
}
}
+class SelectPanelSheetRef extends BaseSelectPanelRef {
+ private readonly dialogRef: LuDialogRef, never>;
+
+ // The sheet can be closed from either side (a selection here, the backdrop/Escape/close button there);
+ // this keeps the two from bouncing the close back at each other.
+ private dialogClosed = false;
+
+ constructor(dialogService: LuDialogService, parentInjector: Injector, selectInput: LuSimpleSelectInputComponent) {
+ super(parentInjector, selectInput);
+
+ const panelComponent: Type> = LuSelectPanelComponent;
+
+ this.dialogRef = ɵopenSelectPanelSheet(dialogService, panelComponent, this.panelInjector, selectInput.panelTitle());
+ this.panelRef = this.dialogRef.cdkRef.componentRef!;
+ this.instance = this.panelRef.instance;
+
+ this.dialogRef.closed$.pipe(takeUntil(this.closed)).subscribe(() => {
+ this.dialogClosed = true;
+ this.close();
+ });
+ }
+
+ updatePosition(): void {
+ // The sheet is pinned to the viewport, it never follows the field.
+ }
+
+ override close(): void {
+ super.close();
+
+ if (!this.dialogClosed) {
+ this.dialogClosed = true;
+ this.dialogRef.dismiss();
+ }
+ // Focus goes back to the field through the dialog's own `restoreFocus`.
+ }
+}
+
class SelectPanelDOMHostRef extends BaseSelectPanelRef {
constructor(host: ViewContainerRef, parentInjector: Injector, selectInput: LuSimpleSelectInputComponent) {
super(parentInjector, selectInput);
- const panelComponent = this.portalRef.component as Type>;
+
+ const panelComponent: Type> = LuSelectPanelComponent;
this.panelRef = host.createComponent(panelComponent, {
- injector: this.portalRef.injector ?? undefined,
+ injector: this.panelInjector,
});
this.instance = this.panelRef.instance;
}
@@ -102,10 +143,15 @@ export class LuSimpleSelectPanelRefFactory {
protected positionBuilder = inject(OverlayPositionBuilder);
protected scrollStrategies = inject(ScrollStrategyOptions);
protected parentInjector = inject(Injector);
+ protected dialogService = inject(LuDialogService);
private selectLabelId = inject(SELECT_LABEL_ID);
private selectId = inject(SELECT_ID);
buildPanelRef(selectInput: LuSimpleSelectInputComponent, overlayConfigOverride: OverlayConfig = {}): LuSelectPanelRef {
+ if (selectInput.bottomSheetMode()) {
+ return new SelectPanelSheetRef(this.dialogService, this.parentInjector, selectInput);
+ }
+
const overlayConfig = this.buildOverlayConfig(overlayConfigOverride);
const overlayRef = this.overlay.create(overlayConfig);
diff --git a/packages/ng/simple-select/input/select-input.bottom-sheet.spec.ts b/packages/ng/simple-select/input/select-input.bottom-sheet.spec.ts
new file mode 100644
index 0000000000..d576f98f40
--- /dev/null
+++ b/packages/ng/simple-select/input/select-input.bottom-sheet.spec.ts
@@ -0,0 +1,707 @@
+import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout';
+import { ApplicationRef, ChangeDetectionStrategy, Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { FormFieldComponent } from '@lucca-front/ng/form-field';
+import { BehaviorSubject, map, Observable } from 'rxjs';
+import { vi } from 'vitest';
+import { LuSimpleSelectInputComponent } from './select-input.component';
+
+type Entity = { id: number; name: string };
+
+const options: Entity[] = [
+ { id: 1, name: 'test 1' },
+ { id: 2, name: 'test 2' },
+];
+
+/**
+ * The select observes `injectMediaMinBreakpoint('S', true)`, which matches while the viewport is *below*
+ * the S breakpoint, so this single subject drives bottom sheet mode.
+ */
+class FakeBreakpointObserver {
+ readonly belowSmallBreakpoint = new BehaviorSubject(false);
+ readonly observedQueries: string[] = [];
+
+ observe(query: string | readonly string[]): Observable {
+ this.observedQueries.push(...(typeof query === 'string' ? [query] : query));
+ return this.belowSmallBreakpoint.pipe(map((matches) => ({ matches, breakpoints: {} })));
+ }
+
+ isMatched(): boolean {
+ return this.belowSmallBreakpoint.value;
+ }
+}
+
+@Component({
+ selector: 'lu-simple-select-bare-host',
+ imports: [FormsModule, LuSimpleSelectInputComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: ` `,
+})
+class BareHostComponent {
+ selected: Entity | null = null;
+
+ options: Entity[] = options;
+}
+
+@Component({
+ selector: 'lu-simple-select-form-field-host',
+ imports: [FormsModule, LuSimpleSelectInputComponent, FormFieldComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+ `,
+})
+class FormFieldHostComponent {
+ selected: Entity | null = null;
+
+ options: Entity[] = options;
+}
+
+@Component({
+ selector: 'lu-simple-select-required-form-field-host',
+ imports: [FormsModule, LuSimpleSelectInputComponent, FormFieldComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+
+
+ `,
+})
+class RequiredFormFieldHostComponent {
+ selected: Entity | null = null;
+
+ options: Entity[] = options;
+}
+
+@Component({
+ selector: 'lu-simple-select-label-host',
+ imports: [FormsModule, LuSimpleSelectInputComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: `
+
+ Wrapping label
+ Additional info
+ screen reader only
+
+
+ `,
+})
+class LabelHostComponent {
+ selected: Entity | null = null;
+
+ options: Entity[] = options;
+}
+
+/**
+ * Pages of 10 options out of 25, the way a paginated consumer does it: `nextPage` extends the
+ * `[options]` array, and asking for a page past the end leaves it unchanged.
+ */
+@Component({
+ selector: 'lu-simple-select-paginated-host',
+ imports: [FormsModule, LuSimpleSelectInputComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ template: ` `,
+})
+class PaginatedHostComponent {
+ readonly allOptions: Entity[] = Array.from({ length: 25 }, (_, index) => ({ id: index, name: `test ${index}` }));
+
+ selected: Entity | null = null;
+
+ options: Entity[] = this.allOptions.slice(0, 10);
+
+ nextPageCount = 0;
+
+ loadNextPage(): void {
+ this.nextPageCount++;
+ this.options = this.allOptions.slice(0, this.options.length + 10);
+ }
+}
+
+describe(`${LuSimpleSelectInputComponent.name} bottom sheet`, () => {
+ let breakpointObserver: FakeBreakpointObserver;
+
+ beforeEach(() => {
+ breakpointObserver = new FakeBreakpointObserver();
+ TestBed.configureTestingModule({
+ providers: [{ provide: BreakpointObserver, useValue: breakpointObserver }],
+ });
+ });
+
+ function createHost(host: new () => THost, belowSmallBreakpoint: boolean): ComponentFixture {
+ breakpointObserver.belowSmallBreakpoint.next(belowSmallBreakpoint);
+ const fixture = TestBed.createComponent(host);
+ fixture.detectChanges();
+ return fixture;
+ }
+
+ function selectOf(fixture: ComponentFixture): LuSimpleSelectInputComponent {
+ return fixture.debugElement.query(By.directive(LuSimpleSelectInputComponent)).componentInstance as LuSimpleSelectInputComponent;
+ }
+
+ describe('mode detection', () => {
+ it('should watch the viewport for the S breakpoint', () => {
+ // Act
+ createHost(BareHostComponent, false);
+
+ // Assert
+ expect(breakpointObserver.observedQueries).toContain('not all and (min-width: 50em)');
+ });
+
+ it('should not use bottom sheet mode above the S breakpoint', () => {
+ // Act
+ const fixture = createHost(BareHostComponent, false);
+
+ // Assert
+ expect(selectOf(fixture).bottomSheetMode()).toBe(false);
+ });
+
+ it('should use bottom sheet mode below the S breakpoint', () => {
+ // Act
+ const fixture = createHost(BareHostComponent, true);
+
+ // Assert
+ expect(selectOf(fixture).bottomSheetMode()).toBe(true);
+ });
+
+ it('should not use bottom sheet mode in a filter pill, which brings its own overlay', () => {
+ // Arrange
+ const fixture = createHost(BareHostComponent, true);
+ const select = selectOf(fixture);
+
+ // Act — a filter pill flips this flag as it takes the select over, before any panel is opened
+ select.filterPillMode = true;
+
+ // Assert
+ expect(select.bottomSheetMode()).toBe(false);
+ });
+ });
+
+ describe('trigger element', () => {
+ function trigger(fixture: ComponentFixture): HTMLElement | null {
+ return fixture.nativeElement.querySelector('.simpleSelect-field-input');
+ }
+
+ it('should be a button below the S breakpoint, so tapping it on iOS does not raise the keyboard', () => {
+ // Act
+ const fixture = createHost(FormFieldHostComponent, true);
+
+ // Assert
+ expect(trigger(fixture)?.tagName).toBe('BUTTON');
+ // Without this an implicit submit fires when the field sits in a form
+ expect(trigger(fixture)).toHaveAttribute('type', 'button');
+ });
+
+ it('should stay a searchable text input above the S breakpoint', () => {
+ // Act
+ const fixture = createHost(FormFieldHostComponent, false);
+
+ // Assert
+ expect(trigger(fixture)?.tagName).toBe('INPUT');
+ expect(trigger(fixture)).toHaveAttribute('role', 'combobox');
+ });
+
+ it('should announce that it opens the sheet rather than an inline listbox', () => {
+ // Arrange
+ const fixture = createHost(FormFieldHostComponent, true);
+
+ // Act
+ selectOf(fixture).openPanel();
+ fixture.detectChanges();
+
+ // Assert
+ expect(trigger(fixture)).toHaveAttribute('aria-haspopup', 'dialog');
+ expect(trigger(fixture)).toHaveAttribute('aria-expanded', 'true');
+ expect(trigger(fixture)).not.toHaveAttribute('role');
+ });
+
+ it('should render the placeholder as text, a button having no placeholder of its own', () => {
+ // Act
+ const fixture = createHost(FormFieldHostComponent, true);
+
+ // Assert
+ expect(trigger(fixture)?.querySelector('.simpleSelect-field-placeholder')?.textContent?.trim()).toBeTruthy();
+ });
+
+ it('should drop the placeholder once a value is selected, the value displayer taking over', () => {
+ // Arrange
+ const fixture = createHost(FormFieldHostComponent, true);
+
+ // Act
+ selectOf(fixture).writeValue(options[0]);
+ fixture.detectChanges();
+
+ // Assert
+ expect(trigger(fixture)?.querySelector('.simpleSelect-field-placeholder')).toBeNull();
+ });
+
+ it('should carry the disabled state so the button cannot be tapped', () => {
+ // Arrange
+ const fixture = createHost(FormFieldHostComponent, true);
+
+ // Act
+ selectOf(fixture).setDisabledState(true);
+ fixture.detectChanges();
+
+ // Assert
+ expect((trigger(fixture) as HTMLButtonElement).disabled).toBe(true);
+ });
+ });
+
+ describe('panel title', () => {
+ it('should use the label associated by the form field', () => {
+ // Arrange
+ const fixture = createHost(FormFieldHostComponent, true);
+ const select = selectOf(fixture);
+
+ // Act
+ select.openPanel();
+
+ // Assert
+ expect(select.panelTitle()).toBe('Country');
+ });
+
+ it('should use the wrapping label without its adornments', () => {
+ // Arrange
+ const fixture = createHost(LabelHostComponent, true);
+ const select = selectOf(fixture);
+
+ // Act
+ select.openPanel();
+
+ // Assert
+ expect(select.panelTitle()).toBe('Wrapping label');
+ });
+
+ it('should stay empty rather than echo the selected value when there is no label', () => {
+ // Arrange
+ const fixture = createHost(BareHostComponent, true);
+ const select = selectOf(fixture);
+ select.writeValue(options[0]);
+ fixture.detectChanges();
+
+ // Act
+ select.openPanel();
+
+ // Assert
+ expect(select.panelTitle()).toBe('');
+ });
+ });
+
+ describe('overlay', () => {
+ function pane(): HTMLElement | null {
+ return document.querySelector('.cdk-overlay-pane');
+ }
+
+ it('should open the panel as a dialog in sheet mode below the S breakpoint', () => {
+ // Arrange
+ const fixture = createHost(BareHostComponent, true);
+
+ // Act
+ selectOf(fixture).openPanel();
+
+ // Assert
+ expect(pane()).toHaveClass('dialog');
+ expect(pane()).toHaveClass('mod-sheet');
+ expect(pane()).toHaveClass('mod-maxContent');
+ // Scopes the select-specific dialog header tweaks (centered title, close button pulled out of
+ // flow) so they don't leak onto unrelated `lu-dialog-header` usages elsewhere in the app.
+ expect(pane()).toHaveClass('mod-select');
+ expect(document.querySelector('.dialog_backdrop')).not.toBeNull();
+ });
+
+ it('should keep anchoring the panel to the field above the S breakpoint', () => {
+ // Arrange
+ const fixture = createHost(BareHostComponent, false);
+
+ // Act
+ selectOf(fixture).openPanel();
+
+ // Assert
+ expect(pane()).not.toHaveClass('dialog');
+ expect(document.querySelector('.dialog_backdrop')).toBeNull();
+ });
+ });
+
+ describe('resizing across the breakpoint while the panel stays open', () => {
+ /**
+ * The panel's own ref (`SelectPanelSheetRef` vs `SelectPanelRef`) is picked once at open time —
+ * `bottomSheetMode()` changing afterwards must not make the panel's template try to switch which
+ * surface it renders (it was never actually attached as a CDK dialog), which used to crash with
+ * `NG0201: No provider found for LuDialogRef`. Instead, the panel closes outright.
+ */
+ it('should close, rather than crash, when going from the desktop overlay to below the S breakpoint', () => {
+ // Arrange
+ const fixture = createHost(BareHostComponent, false);
+ const select = selectOf(fixture);
+ select.openPanel();
+ expect(select.isPanelOpen).toBe(true);
+
+ // Act
+ breakpointObserver.belowSmallBreakpoint.next(true);
+ expect(() => fixture.detectChanges()).not.toThrow();
+
+ // Assert
+ expect(select.isPanelOpen).toBe(false);
+ });
+
+ it('should close, rather than crash, when going from the sheet to above the S breakpoint', () => {
+ // Arrange
+ const fixture = createHost(BareHostComponent, true);
+ const select = selectOf(fixture);
+ select.openPanel();
+ expect(select.isPanelOpen).toBe(true);
+
+ // Act
+ breakpointObserver.belowSmallBreakpoint.next(false);
+ expect(() => fixture.detectChanges()).not.toThrow();
+
+ // Assert
+ expect(select.isPanelOpen).toBe(false);
+ });
+
+ it('should still resolve the panel title on the next open, even though the trigger was torn down and rebuilt as button/input while the breakpoint round-tripped', () => {
+ // Arrange
+ const fixture = createHost(FormFieldHostComponent, true);
+ const select = selectOf(fixture);
+ select.openPanel();
+ expect(select.panelTitle()).toBe('Country');
+
+ // Act — round trip through desktop, which auto-closes the panel and rebuilds the trigger
+ // (button below the S breakpoint, input above it) each way
+ breakpointObserver.belowSmallBreakpoint.next(false);
+ fixture.detectChanges();
+ breakpointObserver.belowSmallBreakpoint.next(true);
+ fixture.detectChanges();
+ select.openPanel();
+ TestBed.inject(ApplicationRef).tick();
+
+ // Assert
+ expect(select.panelTitle()).toBe('Country');
+ });
+ });
+
+ describe('focus', () => {
+ it('should not focus the covered field below the S breakpoint', () => {
+ // Arrange
+ const fixture = createHost(BareHostComponent, true);
+ const select = selectOf(fixture);
+ const focusInput = vi.spyOn(select, 'focusInput');
+
+ // Act
+ select.openPanel();
+
+ // Assert
+ expect(focusInput).not.toHaveBeenCalled();
+ });
+
+ it('should focus the field above the S breakpoint', () => {
+ // Arrange
+ const fixture = createHost(BareHostComponent, false);
+ const select = selectOf(fixture);
+ const focusInput = vi.spyOn(select, 'focusInput');
+
+ // Act
+ select.openPanel();
+
+ // Assert
+ expect(focusInput).toHaveBeenCalled();
+ });
+
+ it('should keep the sheet open on Tab, which cycles inside the modal surface', () => {
+ // Arrange
+ const fixture = createHost(BareHostComponent, true);
+ const select = selectOf(fixture);
+ select.openPanel();
+
+ // Act
+ select.onKeyDownNavigation(new KeyboardEvent('keydown', { key: 'Tab' }));
+
+ // Assert
+ expect(select.isPanelOpen).toBe(true);
+ });
+
+ it('should close the panel on Tab above the S breakpoint', () => {
+ // Arrange
+ const fixture = createHost(BareHostComponent, false);
+ const select = selectOf(fixture);
+ select.openPanel();
+
+ // Act
+ select.onKeyDownNavigation(new KeyboardEvent('keydown', { key: 'Tab' }));
+
+ // Assert
+ expect(select.isPanelOpen).toBe(false);
+ });
+ });
+
+ describe('sheet header', () => {
+ function sheet(): HTMLElement | null {
+ return document.querySelector('cdk-dialog-container');
+ }
+
+ function openSheet(): void {
+ const fixture = createHost(FormFieldHostComponent, true);
+ selectOf(fixture).openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+ }
+
+ it('should mark the sheet as a modal dialog named after the field', () => {
+ // Act
+ openSheet();
+
+ // Assert
+ expect(sheet()).toHaveAttribute('role', 'dialog');
+ expect(sheet()).toHaveAttribute('aria-modal', 'true');
+ expect(sheet()).toHaveAttribute('aria-label', 'Country');
+ expect(sheet()?.querySelector('h1')?.textContent?.trim()).toBe('Country');
+ });
+
+ it('should echo the label required marker in the title when the field is required', () => {
+ // Arrange
+ const fixture = createHost(RequiredFormFieldHostComponent, true);
+
+ // Act
+ selectOf(fixture).openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+
+ // Assert
+ expect(sheet()?.querySelector('h1 .formLabel-required')).toHaveAttribute('aria-hidden', 'true');
+ expect(sheet()?.querySelector('h1 .formLabel-required')?.textContent).toBe('*');
+ // A single asterisk: the label's own `.formLabel-required` marker must not leak into the plain
+ // title text (`panelTitle`) on top of the one rendered here, or it would show up twice.
+ expect(sheet()?.querySelector('h1')?.textContent?.trim()).toBe('Country*');
+ });
+
+ it('should not show the required marker when the field is not required', () => {
+ // Act
+ openSheet();
+
+ // Assert
+ expect(sheet()?.querySelector('h1 .formLabel-required')).toBeNull();
+ });
+
+ it('should embed a search input, focused synchronously so the tap that opened the sheet also raises the iOS keyboard', () => {
+ // Act
+ openSheet();
+
+ // Assert
+ const searchInput = sheet()?.querySelector('.textField-input-value');
+ expect(searchInput).not.toBeNull();
+ expect(document.activeElement).toBe(searchInput);
+ });
+
+ it('should let the dialog header bring its own close button', () => {
+ // Act
+ openSheet();
+
+ // Assert
+ expect(sheet()?.querySelector('.dialog-inside-header-button')).not.toBeNull();
+ });
+
+ it('should not render a sheet above the S breakpoint', () => {
+ // Arrange
+ const fixture = createHost(FormFieldHostComponent, false);
+
+ // Act
+ selectOf(fixture).openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+
+ // Assert
+ expect(document.querySelector('.lu-select-panel-layout')).not.toBeNull();
+ expect(sheet()).toBeNull();
+ });
+ });
+
+ describe('keyboard-aware height', () => {
+ /**
+ * iOS doesn't shrink the layout viewport when the on-screen keyboard opens, only the visual one, so
+ * the sheet tracks the visual viewport itself: its height, to clamp the sheet's own max height, and
+ * how much of the layout viewport it no longer covers, to nudge the sheet's `bottom` up by that much
+ * — otherwise the sheet stays pinned behind the keyboard no matter how short it is.
+ */
+ function mockVisualViewport(initialHeight: number, initialOffsetTop = 0): { setHeight: (height: number) => void; setOffsetTop: (offsetTop: number) => void } {
+ const listeners = new Set<() => void>();
+ const viewport = {
+ height: initialHeight,
+ offsetTop: initialOffsetTop,
+ addEventListener: (type: string, listener: () => void) => {
+ if (type === 'resize' || type === 'scroll') {
+ listeners.add(listener);
+ }
+ },
+ removeEventListener: (type: string, listener: () => void) => {
+ if (type === 'resize' || type === 'scroll') {
+ listeners.delete(listener);
+ }
+ },
+ };
+ vi.stubGlobal('visualViewport', viewport);
+ vi.stubGlobal('innerHeight', 800);
+ return {
+ setHeight: (height: number) => {
+ viewport.height = height;
+ listeners.forEach((listener) => listener());
+ },
+ setOffsetTop: (offsetTop: number) => {
+ viewport.offsetTop = offsetTop;
+ listeners.forEach((listener) => listener());
+ },
+ };
+ }
+
+ function pane(): HTMLElement | null {
+ return document.querySelector('.cdk-overlay-pane');
+ }
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("should track the visual viewport's height on the sheet's own overlay element once it opens, so the keyboard shrinking it clamps the sheet's own max height", () => {
+ // Arrange
+ const viewport = mockVisualViewport(800);
+ const fixture = createHost(FormFieldHostComponent, true);
+
+ // Act
+ selectOf(fixture).openPanel();
+ viewport.setHeight(400); // the keyboard just opened
+
+ // Assert
+ expect(pane()?.style.getPropertyValue('--components-dialog-visibleViewportBlockSize')).toBe('400px');
+ });
+
+ it("should track how much of the layout viewport the keyboard covers, so the sheet's `bottom` can be nudged above it instead of staying pinned underneath", () => {
+ // Arrange
+ const viewport = mockVisualViewport(800);
+ const fixture = createHost(FormFieldHostComponent, true);
+
+ // Act
+ selectOf(fixture).openPanel();
+ viewport.setHeight(400); // the keyboard now covers the bottom 400px of the 800px layout viewport
+
+ // Assert
+ expect(pane()?.style.getPropertyValue('--components-dialog-insetBlockEnd')).toBe('400px');
+ });
+
+ it('should not leak the tracked properties onto the document, which other sheets (e.g. stacked underneath) also read from', () => {
+ // Arrange
+ const viewport = mockVisualViewport(800);
+ const fixture = createHost(FormFieldHostComponent, true);
+
+ // Act
+ selectOf(fixture).openPanel();
+ viewport.setHeight(400);
+
+ // Assert
+ expect(document.documentElement.style.getPropertyValue('--components-dialog-visibleViewportBlockSize')).toBe('');
+ expect(document.documentElement.style.getPropertyValue('--components-dialog-insetBlockEnd')).toBe('');
+ });
+
+ it('should stop tracking and clean up both properties once the sheet closes', () => {
+ // Arrange
+ mockVisualViewport(800);
+ const fixture = createHost(FormFieldHostComponent, true);
+ const select = selectOf(fixture);
+ select.openPanel();
+ const sheetPane = pane();
+
+ // Act
+ select.closePanel();
+
+ // Assert
+ expect(sheetPane?.style.getPropertyValue('--components-dialog-visibleViewportBlockSize')).toBe('');
+ expect(sheetPane?.style.getPropertyValue('--components-dialog-insetBlockEnd')).toBe('');
+ });
+ });
+
+ describe('selected option scroll position', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('should re-scroll the highlighted option into view once the visual viewport settles, since resizing the sheet down for the keyboard can scroll it back out of view', () => {
+ // Arrange
+ const listeners = new Set<() => void>();
+ const viewport = {
+ height: 800,
+ offsetTop: 0,
+ addEventListener: (_type: string, listener: () => void) => listeners.add(listener),
+ removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener),
+ };
+ vi.stubGlobal('visualViewport', viewport);
+ vi.stubGlobal('innerHeight', 800);
+ const scrollIntoView = vi.spyOn(Element.prototype, 'scrollIntoView').mockImplementation(() => {});
+ const fixture = createHost(FormFieldHostComponent, true);
+ selectOf(fixture).writeValue(options[1]);
+ fixture.detectChanges();
+
+ // Act
+ selectOf(fixture).openPanel();
+ scrollIntoView.mockClear(); // ignore whatever scrolled synchronously while the sheet was opening
+ viewport.height = 400; // the keyboard just finished opening
+ listeners.forEach((listener) => listener());
+
+ // Assert
+ expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
+ });
+ });
+ describe('pagination', () => {
+ function createPaginatedSelect(belowSmallBreakpoint: boolean): { fixture: ComponentFixture; select: LuSimpleSelectInputComponent } {
+ breakpointObserver.belowSmallBreakpoint.next(belowSmallBreakpoint);
+ const fixture = TestBed.createComponent(PaginatedHostComponent);
+ fixture.detectChanges();
+ const select = fixture.debugElement.query(By.directive(LuSimpleSelectInputComponent)).componentInstance as LuSimpleSelectInputComponent;
+ return { fixture, select };
+ }
+
+ /**
+ * Each page request is deferred by a microtask and only the next render measures the viewport
+ * again, so the two are alternated until the panel stops asking — with a bound, so a panel that
+ * never stops fails the test instead of hanging it.
+ */
+ async function drainPageRequests(fixture: ComponentFixture): Promise {
+ for (let previous = -1, rounds = 0; previous !== fixture.componentInstance.nextPageCount && rounds < 10; rounds++) {
+ previous = fixture.componentInstance.nextPageCount;
+ await Promise.resolve();
+ TestBed.inject(ApplicationRef).tick();
+ }
+ }
+
+ it('should keep asking for the next page while the sheet has nothing to scroll, and stop once the options are exhausted', async () => {
+ // Arrange
+ const { fixture, select } = createPaginatedSelect(true);
+
+ // Act
+ select.openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+ await drainPageRequests(fixture);
+
+ // Assert: a sheet taller than its first page never scrolls, so the scroll handler alone would
+ // leave pagination stuck on page 1 — and asking for a page that brings nothing back stops it.
+ expect(fixture.componentInstance.options.length).toBe(25);
+ expect(fixture.componentInstance.nextPageCount).toBe(3);
+ });
+
+ it('should leave pagination to the scroll handler above the S breakpoint', async () => {
+ // Arrange
+ const { fixture, select } = createPaginatedSelect(false);
+
+ // Act
+ select.openPanel();
+ fixture.detectChanges();
+ TestBed.inject(ApplicationRef).tick();
+ await drainPageRequests(fixture);
+
+ // Assert: the popover caps its content at 20rem, which a page of options always overflows.
+ expect(fixture.componentInstance.nextPageCount).toBe(0);
+ });
+ });
+});
diff --git a/packages/ng/simple-select/input/select-input.component.html b/packages/ng/simple-select/input/select-input.component.html
index 63dda15be7..15a87ec0bb 100644
--- a/packages/ng/simple-select/input/select-input.component.html
+++ b/packages/ng/simple-select/input/select-input.component.html
@@ -4,33 +4,58 @@
}
-
+ @if (bottomSheetMode()) {
+
+
+ @if (!hasValue()) {
+ {{ inputPlaceholder ? inputPlaceholder : intl().placeholder }}
+ }
+
+ } @else {
+
+ }
@if (!filterPillMode && !impersonation()) {
@if (clearable() && hasValue() && (disabled$ | async) === false) {
- {{ intl().clear }}
+ {{ intl().clear }}
}
}
diff --git a/packages/ng/simple-select/input/select-input.component.ts b/packages/ng/simple-select/input/select-input.component.ts
index 9d2ff32f5d..cba44ebb3a 100644
--- a/packages/ng/simple-select/input/select-input.component.ts
+++ b/packages/ng/simple-select/input/select-input.component.ts
@@ -5,6 +5,7 @@ import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/f
import { ClearComponent } from '@lucca-front/ng/clear';
import { intlInputOptions, isNotNil, luBooleanAttribute, PortalDirective } from '@lucca-front/ng/core';
import { ALuSelectInputComponent, LU_CORE_SELECT_TRANSLATIONS, LuSelectPanelRef, provideLuSelectLabelsAndIds, ɵLuOptionOutletDirective } from '@lucca-front/ng/core-select';
+import { provideLuDialog } from '@lucca-front/ng/dialog';
import { FILTER_PILL_INPUT_COMPONENT, FilterPillDisplayerDirective } from '@lucca-front/ng/filter-pills/core';
import { InputDirective, PresentationDisplayDirective, ɵPresentationDisplayDefaultDirective } from '@lucca-front/ng/form-field';
import { IconComponent } from '@lucca-front/ng/icon';
@@ -48,6 +49,8 @@ let nextID = 0;
useExisting: forwardRef(() => LuSimpleSelectInputComponent),
},
LuSimpleSelectPanelRefFactory,
+ // Below the `S` breakpoint the panel opens as a dialog in `sheet` mode.
+ provideLuDialog(),
provideLuSelectLabelsAndIds(),
{
provide: FILTER_PILL_INPUT_COMPONENT,
diff --git a/packages/ng/simple-select/panel/panel.component.html b/packages/ng/simple-select/panel/panel.component.html
index fe3ce92a05..ce7b0518ee 100644
--- a/packages/ng/simple-select/panel/panel.component.html
+++ b/packages/ng/simple-select/panel/panel.component.html
@@ -1,4 +1,58 @@
-
+@if (selectInput.panelBottomSheetMode()) {
+
+
+
+ @if (selectInput.panelTitle(); as title) {
+
+ @if (selectInput.panelTitleRequired()) {
+ {{ title }}*
+ } @else {
+ {{ title }}
+ }
+ }
+
+
+
+
+
+
+
+
+} @else {
+
+
+
+}
+
+
+
+
+
+
@@ -77,4 +131,4 @@
}
-
+
diff --git a/packages/ng/simple-select/panel/panel.component.ts b/packages/ng/simple-select/panel/panel.component.ts
index f93a7e349e..0bf5f88370 100644
--- a/packages/ng/simple-select/panel/panel.component.ts
+++ b/packages/ng/simple-select/panel/panel.component.ts
@@ -1,5 +1,5 @@
import { NgTemplateOutlet } from '@angular/common';
-import { AfterViewInit, ChangeDetectionStrategy, Component, computed, ElementRef, forwardRef, inject, signal } from '@angular/core';
+import { AfterViewInit, ChangeDetectionStrategy, Component, computed, ElementRef, forwardRef, inject, signal, viewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { isNotNil, PortalDirective } from '@lucca-front/ng/core';
@@ -12,11 +12,14 @@ import {
SELECT_PANEL_INSTANCE,
TreeDisplayPipe,
ɵCoreSelectPanelElement,
+ ɵfillScrollViewport,
ɵgetGroupTemplateLocation,
ɵinjectPointerNavigation,
ɵLuOptionComponent,
ɵLuOptionGroupPipe,
} from '@lucca-front/ng/core-select';
+import { DialogComponent, DialogContentComponent, DialogHeaderComponent } from '@lucca-front/ng/dialog';
+import { IconComponent } from '@lucca-front/ng/icon';
import { ListboxComponent, ListboxState, OptionComponent as ListboxOptionComponent } from '@lucca-front/ng/listbox';
import { TreeBranchComponent } from '@lucca-front/ng/tree-select';
import { EMPTY } from 'rxjs';
@@ -37,6 +40,9 @@ import { LuIsOptionSelectedPipe } from './option-selected.pipe';
},
imports: [
LuSelectPanelLayoutComponent,
+ DialogComponent,
+ DialogHeaderComponent,
+ DialogContentComponent,
FormsModule,
NgTemplateOutlet,
ɵLuOptionGroupPipe,
@@ -46,6 +52,7 @@ import { LuIsOptionSelectedPipe } from './option-selected.pipe';
ɵCoreSelectPanelElement,
ListboxComponent,
ListboxOptionComponent,
+ IconComponent,
TreeBranchComponent,
TreeDisplayPipe,
],
@@ -107,6 +114,21 @@ export class LuSelectPanelComponent
implements AfterViewInit, CoreSelectPanel
return this.clue().length ? this.intl().emptyResults : this.intl().emptyOptions;
});
+ private readonly sheetViewport = viewChild>('sheetViewport', { read: ElementRef });
+
+ constructor() {
+ // Only the sheet needs the viewport filled by hand: it is sized to its own content, so a first
+ // page that doesn't reach its maximum height leaves nothing to scroll and `onScroll` — the only
+ // thing that asks for the next page — never fires. The popover panel caps its content at 20rem,
+ // which a page of options always overflows, so there it already works on its own.
+ ɵfillScrollViewport({
+ viewport: computed(() => this.sheetViewport()?.nativeElement),
+ optionCount: computed(() => this.dataSourceOptions().length),
+ loading: this.loading,
+ nextPage: () => this.panelRef.nextPage.emit(),
+ });
+ }
+
onScroll(evt: Event): void {
if (!(evt.target instanceof HTMLElement)) {
return;
diff --git a/packages/ng/styles/components/cdk/_overlay.scss b/packages/ng/styles/components/cdk/_overlay.scss
index a5212786ff..eee4e54f2f 100644
--- a/packages/ng/styles/components/cdk/_overlay.scss
+++ b/packages/ng/styles/components/cdk/_overlay.scss
@@ -41,7 +41,9 @@
max-inline-size: 30rem;
}
- &:has(> lu-select-panel) {
+ // Caps the panel anchored to the field only: below the `S` breakpoint the panel is rendered by the
+ // dialog as a sheet, which spans the whole viewport and sizes itself.
+ &:not(.dialog):has(> lu-select-panel) {
max-inline-size: 35rem !important;
}
}
diff --git a/packages/scss/src/components/dialog/component.scss b/packages/scss/src/components/dialog/component.scss
index d16a20f086..30c5fc658d 100644
--- a/packages/scss/src/components/dialog/component.scss
+++ b/packages/scss/src/components/dialog/component.scss
@@ -6,7 +6,7 @@
@mixin component($atRoot: namespace.$defaultAtRoot) {
animation-name: var(--components-dialog-animationOpening);
- animation-duration: var(--commons-animations-durations-standard);
+ animation-duration: var(--components-dialog-animationDuration);
inset: var(--components-dialog-inset);
inline-size: var(--components-dialog-width);
block-size: var(--components-dialog-height);
@@ -21,7 +21,7 @@
max-inline-size: var(--components-dialog-maxWidth) !important;
max-block-size: var(--components-dialog-maxHeight) !important;
transition-property: scale, translate, inline-size, block-size, inset;
- transition-duration: var(--commons-animations-durations-standard);
+ transition-duration: var(--components-dialog-animationDuration);
overflow: hidden;
translate: var(--components-dialog-translateX) var(--components-dialog-translateY);
scale: var(--components-dialog-scale);
@@ -147,10 +147,12 @@
position: var(--components-dialog-inside-header-button-position);
inset-block-start: var(--components-dialog-inside-header-button-insetBlockStart);
inset-inline-end: var(--components-dialog-inside-header-button-insetInlineEnd);
+ justify-self: var(--components-dialog-inside-header-button-justifySelf);
}
.dialog-inside-header-content {
grid-area: content;
+ padding-block-start: var(--components-dialog-inside-header-content-paddingBlockStart);
&:empty {
display: none;
@@ -166,6 +168,7 @@
flex-grow: var(--components-dialog-insideContent-flexGrow);
display: block;
outline: none;
+ isolation: isolate;
&:empty {
display: none;
@@ -177,7 +180,7 @@
}
.dialog-inside-header-container-title {
- font: var(--pr-t-font-heading-2);
+ font: var(--components-dialog-insideHeaderTitleFont);
padding: var(--components-dialog-insideHeaderTitlePadding);
text-align: var(--components-dialog-insideHeaderTitleAlign);
margin: 0;
diff --git a/packages/scss/src/components/dialog/mods.scss b/packages/scss/src/components/dialog/mods.scss
index 005061d602..183c4099e5 100644
--- a/packages/scss/src/components/dialog/mods.scss
+++ b/packages/scss/src/components/dialog/mods.scss
@@ -79,10 +79,10 @@
@mixin sheet {
--components-dialog-width: 100%;
--components-dialog-maxWidth: none;
- --components-dialog-maxHeight: calc(100% - var(--pr-t-spacings-150));
+ --components-dialog-maxHeight: min(calc(100% - var(--pr-t-spacings-150)), calc(var(--components-dialog-visibleViewportBlockSize) - var(--pr-t-spacings-150)));
--components-dialog-maxHeightFallback: var(--components-dialog-maxHeight);
--components-dialog-borderRadius: var(--pr-t-border-radius-structure) var(--pr-t-border-radius-structure) 0 0;
- --components-dialog-inset: auto 0 0;
+ --components-dialog-inset: auto 0 var(--components-dialog-insetBlockEnd) 0;
}
@mixin sheetNarrow {
diff --git a/packages/scss/src/components/dialog/vars.scss b/packages/scss/src/components/dialog/vars.scss
index 79299abc41..24a10d97dc 100644
--- a/packages/scss/src/components/dialog/vars.scss
+++ b/packages/scss/src/components/dialog/vars.scss
@@ -8,11 +8,15 @@
--components-dialog-maxHeightFallback: calc(100vh - (var(--pr-t-spacings-200) * 2));
--components-dialog-borderRadius: var(--pr-t-border-radius-structure);
--components-dialog-inset: 0 var(--commons-pushPanel-inlineSize) 0 0;
+ --components-dialog-insetBlockEnd: 0px;
+ --components-dialog-visibleViewportBlockSize: 100%;
--components-dialog-animationOpening: scaleIn;
+ --components-dialog-animationDuration: var(--commons-animations-durations-standard);
--components-dialog-insideHeaderAreas:
'container close'
'content content';
--components-dialog-insideHeaderTitleAlign: left;
+ --components-dialog-insideHeaderTitleFont: var(--pr-t-font-heading-2);
--components-dialog-insideHeaderTitlePadding: calc(var(--pr-t-spacings-50) / 2) 0;
--components-dialog-insideHeaderButtonDisplay: flex;
--components-dialog-insideHeaderLinkDisplay: none;
@@ -32,6 +36,7 @@
--components-dialog-inside-header-position: relative;
--components-dialog-inside-header-paddingBlock: var(--pr-t-spacings-200);
--components-dialog-inside-header-paddingInline: var(--pr-t-spacings-300);
+ --components-dialog-inside-header-content-paddingBlockStart: 0px;
--components-dialog-inside-header-button-position: static;
--components-dialog-inside-backgroundImage: none;
--components-dialog-minBlockSize: 0px;
@@ -41,6 +46,7 @@
--components-dialog-inside-fancyIllustrations-inlineSize: var(--components-dialog-inside-paddingInlineEnd);
--components-dialog-inside-header-button-insetBlockStart: var(--pr-t-spacings-200);
--components-dialog-inside-header-button-insetInlineEnd: var(--pr-t-spacings-300);
+ --components-dialog-inside-header-button-justifySelf: auto;
--components-dialog-inside-header-marginBlock: 0px;
--components-dialog-inside-footer-marginBlock: 0px;
--components-dialog-inside-footer-actions-button-icon-menuDots-display: block;
diff --git a/packages/scss/src/components/multiSelect/component.scss b/packages/scss/src/components/multiSelect/component.scss
index 2f01f1b849..834bb264a3 100644
--- a/packages/scss/src/components/multiSelect/component.scss
+++ b/packages/scss/src/components/multiSelect/component.scss
@@ -70,6 +70,22 @@
&::placeholder {
color: var(--components-multiSelect-placeholder);
}
+
+ // Below the S breakpoint the trigger is a button instead of a text input, so the native
+ // button chrome has to be stripped and its text — standing in for the placeholder, which a
+ // button has no equivalent of — coloured and aligned the way the placeholder was.
+ &.mod-button {
+ appearance: none;
+ text-align: start;
+ color: var(--components-multiSelect-placeholder);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:empty {
+ @include a11y.mask;
+ }
+ }
}
.multipleSelect-displayer-label {
diff --git a/packages/scss/src/components/multiSelect/index.scss b/packages/scss/src/components/multiSelect/index.scss
index 09850cd905..1a706607f6 100644
--- a/packages/scss/src/components/multiSelect/index.scss
+++ b/packages/scss/src/components/multiSelect/index.scss
@@ -49,6 +49,10 @@
&:has(.multipleSelect-displayer-search[aria-invalid='true']) {
@include invalid;
+
+ &:hover {
+ @include invalidHover;
+ }
}
&:has(.multipleSelect-displayer-search:disabled) {
@@ -81,3 +85,9 @@
}
}
}
+
+@layer mods {
+ .dialog.mod-select {
+ @include select;
+ }
+}
diff --git a/packages/scss/src/components/multiSelect/mods.scss b/packages/scss/src/components/multiSelect/mods.scss
index 0c178e500f..6ff05b2049 100644
--- a/packages/scss/src/components/multiSelect/mods.scss
+++ b/packages/scss/src/components/multiSelect/mods.scss
@@ -81,3 +81,31 @@
}
}
}
+
+@mixin select {
+ --components-dialog-animationDuration: 0s;
+ --components-dialog-insideHeaderTitleAlign: center;
+ --components-dialog-insideHeaderTitleFont: var(--pr-t-font-heading-4);
+ --components-dialog-insideHeaderColumns: 1fr auto 1fr;
+ --components-dialog-inside-header-button-justifySelf: end;
+ --components-dialog-insideHeaderAreas:
+ 'spacer container close '
+ 'content content content';
+ --components-dialog-inside-header-paddingInline: var(--pr-t-spacings-200);
+ --components-dialog-inside-header-content-paddingBlockStart: var(--pr-t-spacings-100);
+ --components-dialog-insideContent-paddingInline: var(--pr-t-spacings-50);
+ --components-dialog-insideContent-paddingBlock: var(--pr-t-spacings-50);
+
+ // The sheet is how the select is reached on a touch device, where there is no hover to reveal
+ // either the tree's onlyParent/onlyChildren buttons or a group's « select all » link: inside a
+ // sheet both are shown for good. The tree buttons also leave their absolute position on the way,
+ // so that being there all the time shortens the option's label instead of covering it.
+ .optionItem-icons {
+ position: static;
+ opacity: 1;
+ }
+
+ .listboxOption-content {
+ --components-listboxOption-content-link-opacity: 1;
+ }
+}
diff --git a/packages/scss/src/components/multiSelect/states.scss b/packages/scss/src/components/multiSelect/states.scss
index 4a28451705..3d80f4d755 100644
--- a/packages/scss/src/components/multiSelect/states.scss
+++ b/packages/scss/src/components/multiSelect/states.scss
@@ -11,6 +11,10 @@
--components-multiSelect-placeholder: var(--pr-t-color-input-text-placeholder-critical);
}
+@mixin invalidHover {
+ --components-multiSelect-border-color: var(--pr-t-color-input-border-critical-hover);
+}
+
@mixin disabled {
--components-multiSelect-background: var(--pr-t-color-input-background-disabled);
--components-multiSelect-placeholder: var(--commons-disabled-placeholder);
diff --git a/packages/scss/src/components/simpleSelect/component.scss b/packages/scss/src/components/simpleSelect/component.scss
index 5443bdcb67..7f76d357a8 100644
--- a/packages/scss/src/components/simpleSelect/component.scss
+++ b/packages/scss/src/components/simpleSelect/component.scss
@@ -49,6 +49,28 @@
&::placeholder {
color: var(--components-simpleSelect-placeholder);
}
+
+ // Below the S breakpoint the trigger is a button instead of a text input, so the native
+ // button chrome (centred text, rounded corners on iOS) has to be stripped for it to keep
+ // sitting on the value the way the input did. An empty button would collapse to no height
+ // and stop covering the value it triggers, hence the stretch.
+ &.mod-button {
+ appearance: none;
+ text-align: start;
+ align-self: stretch;
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ // Stands in for the input placeholder, which a button trigger has no equivalent of. It sets its
+ // own colour rather than inheriting so it survives `colorPicker` making the trigger transparent.
+ .simpleSelect-field-placeholder {
+ color: var(--components-simpleSelect-placeholder);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: block;
}
.simpleSelect-field-value {
diff --git a/packages/scss/src/components/simpleSelect/index.scss b/packages/scss/src/components/simpleSelect/index.scss
index fed62fc2c1..73a53f2c4f 100644
--- a/packages/scss/src/components/simpleSelect/index.scss
+++ b/packages/scss/src/components/simpleSelect/index.scss
@@ -28,6 +28,10 @@
&:has(.simpleSelect-field-input[aria-invalid='true']) {
@include invalid;
+
+ .simpleSelect-field:hover {
+ @include invalidHover;
+ }
}
&:has(.simpleSelect-field-input:disabled) {
@@ -66,3 +70,9 @@
}
}
}
+
+@layer mods {
+ .dialog.mod-select {
+ @include select;
+ }
+}
diff --git a/packages/scss/src/components/simpleSelect/mods.scss b/packages/scss/src/components/simpleSelect/mods.scss
index 60526b9d4a..ee94bc3e35 100644
--- a/packages/scss/src/components/simpleSelect/mods.scss
+++ b/packages/scss/src/components/simpleSelect/mods.scss
@@ -70,3 +70,18 @@
margin: var(--pr-t-spacings-75) calc(var(--pr-t-spacings-100) * -1) calc(var(--pr-t-spacings-100) * -1);
}
}
+
+@mixin select {
+ --components-dialog-animationDuration: 0s;
+ --components-dialog-insideHeaderTitleAlign: center;
+ --components-dialog-insideHeaderTitleFont: var(--pr-t-font-heading-4);
+ --components-dialog-insideHeaderColumns: 1fr auto 1fr;
+ --components-dialog-inside-header-button-justifySelf: end;
+ --components-dialog-insideHeaderAreas:
+ 'spacer container close '
+ 'content content content';
+ --components-dialog-inside-header-paddingInline: var(--pr-t-spacings-200);
+ --components-dialog-inside-header-content-paddingBlockStart: var(--pr-t-spacings-100);
+ --components-dialog-insideContent-paddingInline: var(--pr-t-spacings-50);
+ --components-dialog-insideContent-paddingBlock: var(--pr-t-spacings-50);
+}
diff --git a/packages/scss/src/components/simpleSelect/states.scss b/packages/scss/src/components/simpleSelect/states.scss
index 73232d740b..a3c97a2d29 100644
--- a/packages/scss/src/components/simpleSelect/states.scss
+++ b/packages/scss/src/components/simpleSelect/states.scss
@@ -63,6 +63,10 @@
--components-simpleSelect-placeholder: var(--pr-t-color-input-text-placeholder-critical);
}
+@mixin invalidHover {
+ --components-simpleSelect-border-color: var(--pr-t-color-input-border-critical-hover);
+}
+
@mixin disabled {
--components-simpleSelect-background: var(--pr-t-color-input-background-disabled);
--components-simpleSelect-placeholder: var(--commons-disabled-placeholder); // Deprecated: no placeholder with disabled state
diff --git a/stories/documentation/forms/fields/simple-select/angular/simple-select.stories.ts b/stories/documentation/forms/fields/simple-select/angular/simple-select.stories.ts
index d0445d1f2a..ff99c8f193 100644
--- a/stories/documentation/forms/fields/simple-select/angular/simple-select.stories.ts
+++ b/stories/documentation/forms/fields/simple-select/angular/simple-select.stories.ts
@@ -29,6 +29,9 @@ export default {
description: 'Modifie le label du champ.',
table: { category: 'inputs' },
},
+ required: {
+ description: 'Marque le champ comme obligatoire.',
+ },
placeholder: {
description: 'Modifie le placeholder au champ.',
table: { category: 'inputs' },
@@ -109,6 +112,7 @@ export const Basic: StoryObj<
LuSimpleSelectInputComponent &
FormFieldComponent & {
disabled: boolean;
+ required: boolean;
},
SelectCommonAliasInput
>
@@ -147,6 +151,7 @@ export const Basic: StoryObj<
hiddenLabel: false,
label: 'Label',
tooltip: 'Tooltip message',
+ required: false,
placeholder: 'Placeholder',
clearable: true,
inlineMessage: 'Helper text',
diff --git a/stories/documentation/forms/select/multi-select.stories.ts b/stories/documentation/forms/select/multi-select.stories.ts
index 0013504940..754ac233af 100644
--- a/stories/documentation/forms/select/multi-select.stories.ts
+++ b/stories/documentation/forms/select/multi-select.stories.ts
@@ -169,6 +169,37 @@ export const InputWidth = generateStory({
},
});
+export const WithFormField = generateStory({
+ name: 'With form field',
+ description: `Encapsulé dans un \`lu-form-field\`, le select est associé à son label, qui reste la façon recommandée de nommer le champ. Sous le breakpoint S, le panneau s’ouvre en bottom sheet et reprend ce label comme titre.`,
+ template: `
+
+ `,
+ neededImports: {
+ '@lucca-front/ng/form-field': ['FormFieldComponent'],
+ '@lucca-front/ng/multi-select': ['LuMultiSelectInputComponent'],
+ },
+ storyPartial: {
+ args: {
+ selectedLegumes: [],
+ keepSearchAfterSelection: false,
+ },
+ argTypes: {
+ clearable: { control: { type: 'boolean' } },
+ maxValuesShown: { control: { type: 'number' } },
+ },
+ },
+});
+
export const WithClue = generateStory({
name: 'Clue',
description: `Il est possible d'afficher une barre de recherche pour filtrer les options en écoutant l'évènement \`(clueChange)\`.`,
@@ -860,6 +891,7 @@ const meta: Meta
+
+ {{ legume.name }}
+
+`,
+ neededImports: {
+ '@lucca-front/ng/core-select': ['LuOptionDirective'],
+ '@lucca-front/ng/form-field': ['FormFieldComponent'],
+ '@lucca-front/ng/simple-select': ['LuSimpleSelectInputComponent'],
+ },
+ storyPartial: {
+ argTypes: {
+ clearable: { control: { type: 'boolean' } },
+ },
+ },
+});
+
export const ScrollOnOpen = generateStory({
name: 'Scroll on open',
description: `À l’ouverture, le panneau doit être positionné en haut de la liste (aucun défilement parasite), même si aucune valeur n’est sélectionnée.`,
@@ -798,6 +827,7 @@ const meta: Meta {
+ return of({ matches: this.matches, breakpoints: {} });
+ }
+
+ isMatched(): boolean {
+ return this.matches;
+ }
+}
+
+const forceNarrowBreakpoint = { provide: BreakpointObserver, useValue: new StaticBreakpointObserver(true) };
+
+@Component({
+ selector: 'select-narrow-panel-multi',
+ template: `
+
+
+
+ `,
+ imports: [LuMultiSelectInputComponent, FormsModule, FormFieldComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [forceNarrowBreakpoint],
+})
+class SelectNarrowPanelMultiComponent implements AfterViewInit {
+ legumes: ILegume[] = allLegumes;
+ value: ILegume[] = [];
+
+ private readonly selectRef = viewChild.required(LuMultiSelectInputComponent);
+
+ ngAfterViewInit(): void {
+ setTimeout(() => this.selectRef().openPanel());
+ }
+}
+
+export default {
+ title: 'QA/Select/Narrow/Panel/Live/Multi',
+ component: SelectNarrowPanelMultiComponent,
+} as Meta;
+
+export const Basic: StoryObj = {
+ args: {},
+ render: () => ({ template: ' ' }),
+};
diff --git a/stories/qa/select/select-narrow-panel-simple.stories.ts b/stories/qa/select/select-narrow-panel-simple.stories.ts
new file mode 100644
index 0000000000..f8e86e1754
--- /dev/null
+++ b/stories/qa/select/select-narrow-panel-simple.stories.ts
@@ -0,0 +1,54 @@
+import { allLegumes, ILegume } from '@/stories/forms/select/select.utils';
+import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout';
+import { AfterViewInit, ChangeDetectionStrategy, Component, viewChild } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { FormFieldComponent } from '@lucca-front/ng/form-field';
+import { LuSimpleSelectInputComponent } from '@lucca-front/ng/simple-select';
+import { Meta, StoryObj } from '@storybook/angular-vite';
+import { Observable, of } from 'rxjs';
+
+class StaticBreakpointObserver {
+ constructor(private readonly matches: boolean) {}
+
+ observe(): Observable {
+ return of({ matches: this.matches, breakpoints: {} });
+ }
+
+ isMatched(): boolean {
+ return this.matches;
+ }
+}
+
+const forceNarrowBreakpoint = { provide: BreakpointObserver, useValue: new StaticBreakpointObserver(true) };
+
+@Component({
+ selector: 'select-narrow-panel-simple',
+ template: `
+
+
+
+ `,
+ imports: [LuSimpleSelectInputComponent, FormsModule, FormFieldComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [forceNarrowBreakpoint],
+})
+class SelectNarrowPanelSimpleComponent implements AfterViewInit {
+ legumes: ILegume[] = allLegumes;
+ value: ILegume | null = null;
+
+ private readonly selectRef = viewChild.required(LuSimpleSelectInputComponent);
+
+ ngAfterViewInit(): void {
+ setTimeout(() => this.selectRef().openPanel());
+ }
+}
+
+export default {
+ title: 'QA/Select/Narrow/Panel/Live/Simple',
+ component: SelectNarrowPanelSimpleComponent,
+} as Meta;
+
+export const Basic: StoryObj = {
+ args: {},
+ render: () => ({ template: ' ' }),
+};
diff --git a/stories/qa/select/select-narrow-panel.stories.html b/stories/qa/select/select-narrow-panel.stories.html
new file mode 100644
index 0000000000..09d8c425ac
--- /dev/null
+++ b/stories/qa/select/select-narrow-panel.stories.html
@@ -0,0 +1,879 @@
+
+
+
+
+
+ Simple select
+
+
+ Multi select
+
+
+
+ Default
+
+
+
+
+
+
+
+
+
+ Carrot
+ Potato
+ Leek
+ Onion
+ Turnip
+ Cabbage
+ Broccoli
+ Cauliflower
+ Spinach
+ Celery
+ Pumpkin
+ Zucchini
+ Cucumber
+ Radish
+ Beet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Carrot
+ Potato
+ Leek
+ Onion
+ Turnip
+ Cabbage
+ Broccoli
+ Cauliflower
+ Spinach
+ Celery
+ Pumpkin
+ Zucchini
+ Cucumber
+ Radish
+ Beet
+
+
+
+
+
+
+
+
+
+ Required
+
+
+
+
+
+
+
+
+
+ Carrot
+ Potato
+ Leek
+ Onion
+ Turnip
+ Cabbage
+ Broccoli
+ Cauliflower
+ Spinach
+ Celery
+ Pumpkin
+ Zucchini
+ Cucumber
+ Radish
+ Beet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Carrot
+ Potato
+ Leek
+ Onion
+ Turnip
+ Cabbage
+ Broccoli
+ Cauliflower
+ Spinach
+ Celery
+ Pumpkin
+ Zucchini
+ Cucumber
+ Radish
+ Beet
+
+
+
+
+
+
+
+
+
+ Filtering
+
+
+
+
+
+
+
+
+
+ Carrot
+ Cabbage
+ Cauliflower
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Carrot
+ Cabbage
+ Cauliflower
+
+
+
+
+
+
+
+
+
+ With value
+
+
+
+
+
+
+
+
+
+ Carrot
+ Potato
+ Leek
+ Onion
+ Turnip
+ Cabbage
+ Broccoli
+ Cauliflower
+ Spinach
+ Celery
+ Pumpkin
+ Zucchini
+ Cucumber
+ Radish
+ Beet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Carrot
+ Potato
+ Leek
+ Onion
+ Turnip
+ Cabbage
+ Broccoli
+ Cauliflower
+ Spinach
+ Celery
+ Pumpkin
+ Zucchini
+ Cucumber
+ Radish
+ Beet
+
+
+
+
+
+
+
+
+
+ Hovered
+
+
+
+
+
+
+
+
+
+ Carrot
+ Potato
+ Leek
+ Onion
+ Turnip
+ Cabbage
+ Broccoli
+ Cauliflower
+ Spinach
+ Celery
+ Pumpkin
+ Zucchini
+ Cucumber
+ Radish
+ Beet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Carrot
+ Potato
+ Leek
+ Onion
+ Turnip
+ Cabbage
+ Broccoli
+ Cauliflower
+ Spinach
+ Celery
+ Pumpkin
+ Zucchini
+ Cucumber
+ Radish
+ Beet
+
+
+
+
+
+
+
+
+
+ Hovered (selected)
+
+
+
+
+
+
+
+
+
+ Carrot
+ Potato
+ Leek
+ Onion
+ Turnip
+ Cabbage
+ Broccoli
+ Cauliflower
+ Spinach
+ Celery
+ Pumpkin
+ Zucchini
+ Cucumber
+ Radish
+ Beet
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Carrot
+ Potato
+ Leek
+ Onion
+ Turnip
+ Cabbage
+ Broccoli
+ Cauliflower
+ Spinach
+ Celery
+ Pumpkin
+ Zucchini
+ Cucumber
+ Radish
+ Beet
+
+
+
+
+
+
+
+
+
+ Groups
+
+
+
+
+
+
+
+
+
+
+ Root vegetables
+
+
Carrot
+
Potato
+
Turnip
+
Radish
+
Beet
+
+
+
+ Other vegetables
+
+
Leek
+
Onion
+
Cabbage
+
Broccoli
+
Cauliflower
+
Spinach
+
Celery
+
Pumpkin
+
Zucchini
+
Cucumber
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Root vegetables
+
+
Carrot
+
Potato
+
Turnip
+
Radish
+
Beet
+
+
+
+ Other vegetables
+
+
Leek
+
Onion
+
Cabbage
+
Broccoli
+
Cauliflower
+
Spinach
+
Celery
+
Pumpkin
+
Zucchini
+
Cucumber
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/stories/qa/select/select-narrow-panel.stories.ts b/stories/qa/select/select-narrow-panel.stories.ts
new file mode 100644
index 0000000000..0a850102e7
--- /dev/null
+++ b/stories/qa/select/select-narrow-panel.stories.ts
@@ -0,0 +1,33 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { ButtonComponent } from '@lucca-front/ng/button';
+import { IconComponent } from '@lucca-front/ng/icon';
+import { ListboxComponent, OptionComponent } from '@lucca-front/ng/listbox';
+import { Meta, StoryObj } from '@storybook/angular-vite';
+
+@Component({
+ selector: 'select-narrow-panel-story',
+ templateUrl: './select-narrow-panel.stories.html',
+ imports: [ListboxComponent, OptionComponent, IconComponent, ButtonComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ styles: [
+ `
+ @layer components {
+ .dialog,
+ .dialog_backdrop {
+ position: absolute !important;
+ }
+ }
+ `,
+ ],
+})
+class SelectNarrowPanelStoryComponent {}
+
+export default {
+ title: 'QA/Select/Narrow/Panel',
+ component: SelectNarrowPanelStoryComponent,
+} as Meta;
+
+export const Basic: StoryObj = {
+ args: {},
+ render: () => ({ template: ' ' }),
+};
diff --git a/stories/qa/select/select-narrow.stories.html b/stories/qa/select/select-narrow.stories.html
new file mode 100644
index 0000000000..0bf51e0692
--- /dev/null
+++ b/stories/qa/select/select-narrow.stories.html
@@ -0,0 +1,112 @@
+
+
+
+
+ Simple select
+ Multi select
+
+
+ Default
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ With value
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Clearable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ With counter
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Disabled
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/stories/qa/select/select-narrow.stories.ts b/stories/qa/select/select-narrow.stories.ts
new file mode 100644
index 0000000000..034b17f44b
--- /dev/null
+++ b/stories/qa/select/select-narrow.stories.ts
@@ -0,0 +1,53 @@
+import { allLegumes, FilterLegumesPipe, ILegume } from '@/stories/forms/select/select.utils';
+import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout';
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { FormFieldComponent } from '@lucca-front/ng/form-field';
+import { LuMultiDisplayerDirective, LuMultiSelectCounterDisplayerComponent, LuMultiSelectInputComponent } from '@lucca-front/ng/multi-select';
+import { LuSimpleSelectInputComponent } from '@lucca-front/ng/simple-select';
+import { Meta, StoryObj } from '@storybook/angular-vite';
+import { Observable, of } from 'rxjs';
+
+class StaticBreakpointObserver {
+ constructor(private readonly matches: boolean) {}
+
+ observe(): Observable {
+ return of({ matches: this.matches, breakpoints: {} });
+ }
+
+ isMatched(): boolean {
+ return this.matches;
+ }
+}
+
+const forceNarrowBreakpoint = { provide: BreakpointObserver, useValue: new StaticBreakpointObserver(true) };
+
+@Component({
+ selector: 'select-narrow-stories',
+ templateUrl: './select-narrow.stories.html',
+ imports: [LuSimpleSelectInputComponent, LuMultiSelectInputComponent, FormsModule, FilterLegumesPipe, FormFieldComponent, LuMultiDisplayerDirective, LuMultiSelectCounterDisplayerComponent],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [forceNarrowBreakpoint],
+})
+class SelectNarrowStory {
+ legumes: ILegume[] = allLegumes;
+
+ simpleClue = '';
+ multiClue = '';
+
+ simpleValue: ILegume | null = null;
+ multiValue: ILegume[] = [];
+
+ simpleFilled: ILegume | null = allLegumes[0];
+ multiFilled: ILegume[] = allLegumes.slice(0, 3);
+}
+
+export default {
+ title: 'QA/Select/Narrow',
+ component: SelectNarrowStory,
+} as Meta;
+
+export const Basic: StoryObj = {
+ args: {},
+ render: () => ({ template: ' ' }),
+};