diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index b5d0fa2202..694527bb2e 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -2,17 +2,27 @@ name: E2E Tests on: [push] +# Pushing several times in a row on the same branch used to stack full 6-minute runs +# that competed for runners; only the latest push is worth testing. +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright@sha256:5b8f294aff9041b7191c34a4bab3ac270157a28774d4b0660e9743297b697e48 # v1.61.1-noble + # Docker defaults /dev/shm to 64MB, which makes Chromium crash mid-run with + # "Browser connection was closed while running tests". See https://playwright.dev/docs/docker + options: --ipc=host --shm-size=2gb steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.15.0 + cache: npm - name: Install dependencies run: npm ci @@ -20,8 +30,16 @@ jobs: - name: Build Storybook run: npm run build-storybook -- --quiet + # Every story file loads into a single browser page (see `isolate` in + # .storybook/vitest.config.ts), and one browser cannot carry all ~757 of them: it dies near + # the end of the run with "Browser connection was closed while running tests", failing the + # job with zero failed tests. Each shard is a fresh vitest process with a fresh browser, + # which caps the accumulation at ~250 files per page. - name: Serve Storybook and run tests run: | npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \ "npx http-server storybook-static --port 6006 --silent" \ - "npx wait-on tcp:127.0.0.1:6006 && yarn test-storybook" + "npx wait-on tcp:127.0.0.1:6006 \ + && npm run test-storybook -- --shard=1/3 \ + && npm run test-storybook -- --shard=2/3 \ + && npm run test-storybook -- --shard=3/3" diff --git a/.storybook/msw/handlers.ts b/.storybook/msw/handlers.ts index 4423134456..018a3fe60c 100644 --- a/.storybook/msw/handlers.ts +++ b/.storybook/msw/handlers.ts @@ -1,5 +1,5 @@ import { delay, http, HttpResponse } from 'msw'; -import { applyFilter, applyV3Fields, applyV3Paging, applyV4Paging, applyV4Sorting, genericHandler, handleFieldsRoot } from './helpers'; +import { applyFilter, applyV3Fields, applyV3Paging, applyV4Paging, applyV4Sorting, genericHandler, handleFieldsRoot, isVitestBrowser } from './helpers'; import { mockAxisSectionsV3, mockDepartmentsTree, @@ -42,7 +42,19 @@ const usersSearchHandler = genericHandler( }, ); +const PLACEHOLDER_SVG = ''; + +/** + * Illustrations, flags and decorative bubbles are fetched from an external CDN. Letting those + * requests reach the network makes the Storybook test run depend on the CI container's outbound + * connectivity, adds hundreds of round-trips to the critical path, and turns a single CDN hiccup + * into a failed run, so tests get an empty placeholder instead. Only under Vitest: the dev + * Storybook must keep showing the real illustrations. + */ +const cdnHandlers = isVitestBrowser() ? [http.get('https://cdn.lucca.fr/*', () => new HttpResponse(PLACEHOLDER_SVG, { headers: { 'Content-Type': 'image/svg+xml' } }))] : []; + export const handlers = [ + ...cdnHandlers, http.get( '/organization/structure/api/legal-units', genericHandler( diff --git a/.storybook/msw/helpers.ts b/.storybook/msw/helpers.ts index c1949bae04..892c1d9ff8 100644 --- a/.storybook/msw/helpers.ts +++ b/.storybook/msw/helpers.ts @@ -88,3 +88,11 @@ export function applyV3Fields(count: number): (items: T[], params: { fields?: }; }; } + +/** + * True while the stories run as Vitest browser tests, false in the dev Storybook. Lets the + * mocks tighten up for CI (no real network) without changing what developers see locally. + */ +export function isVitestBrowser(): boolean { + return '__vitest_browser__' in globalThis; +} diff --git a/.storybook/plugins/msw.ts b/.storybook/plugins/msw.ts index 1e85e4c4ff..32a049b463 100644 --- a/.storybook/plugins/msw.ts +++ b/.storybook/plugins/msw.ts @@ -1,4 +1,5 @@ import { worker } from '../msw/browser'; +import { isVitestBrowser } from '../msw/helpers'; export default { init() { @@ -24,7 +25,16 @@ export default { scope: `${pathname}/`, }, }, - onUnhandledRequest: 'bypass', + onUnhandledRequest(request, print) { + // Same-origin requests are Storybook's own assets, chunks and fonts: let them + // through silently. Under Vitest, anything external is a real network call, which + // is what makes the CI run flaky, so it has to be visible; the dev Storybook is + // expected to reach the CDN and stays quiet. + if (!isVitestBrowser() || new URL(request.url).origin === location.origin) { + return; + } + print.warning(); + }, }); } }, diff --git a/.storybook/vitest.config.ts b/.storybook/vitest.config.ts index 5ff6321cdd..574ea024ea 100644 --- a/.storybook/vitest.config.ts +++ b/.storybook/vitest.config.ts @@ -19,6 +19,14 @@ export default mergeConfig( test: { watch: false, fileParallelism: true, + // Must stay on the root config: the browser pool reads `isolate` from the root, not from + // the project, so setting it per project silently leaves isolation on. Isolation would be + // desirable — story files currently share one browser page, so the DOM, Angular apps, + // overlays and subscriptions of ~750 files pile up in a single tab, and an async error + // thrown by one story is reported against whichever unrelated file is running — but it + // deadlocks the run: the per-file tester URL is not escaped, so any story under a + // `html&css/` directory (311 of them) truncates it at the `&` and the tester never + // reports back. Do not flip this before that is fixed upstream. isolate: false, passWithNoTests: true, pool: 'threads', @@ -59,6 +67,13 @@ export default mergeConfig( ], test: { name: 'storybook', + // The CI runner has 4 vCPUs; more concurrent pages than this starves the workers + // and makes even static stories hit the test timeout. Raise it if the suite gets + // too slow and the runner gets bigger. + maxWorkers: 2, + testTimeout: 30_000, + // Last resort so a single browser-level flake doesn't red the whole build. + retry: 1, browser: { enabled: true, headless: true, diff --git a/packages/ng/core-select/input/select-input.component.ts b/packages/ng/core-select/input/select-input.component.ts index d88d43e63e..0fadb640c5 100644 --- a/packages/ng/core-select/input/select-input.component.ts +++ b/packages/ng/core-select/input/select-input.component.ts @@ -21,9 +21,9 @@ import { } from '@angular/core'; import { outputFromObservable, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { ControlValueAccessor } from '@angular/forms'; -import { isNotNil, luBooleanAttribute, luNullableNumberAttribute, luNumberAttribute, PortalContent, ɵeffectWithDeps } from '@lucca-front/ng/core'; +import { injectMediaMinBreakpoint, isNotNil, luBooleanAttribute, luNullableNumberAttribute, luNumberAttribute, PortalContent, ɵeffectWithDeps } from '@lucca-front/ng/core'; import { FILTER_PILL_HOST_COMPONENT, FILTER_PILL_INPUT_COMPONENT, FilterPillInputComponent } from '@lucca-front/ng/filter-pills/core'; -import { FormFieldWidth } from '@lucca-front/ng/form-field'; +import { FORM_FIELD_INSTANCE, FormFieldComponent, FormFieldWidth } from '@lucca-front/ng/form-field'; import { BehaviorSubject, defer, map, of, ReplaySubject, startWith, Subject, switchMap } from 'rxjs'; import { LuSimpleSelectDefaultOptionComponent } from '../option'; import { LuSelectPanelRef } from '../panel'; @@ -56,13 +56,25 @@ export abstract class ALuSelectInputComponent implements OnDest protected labelElement: HTMLElement | undefined = inject(SELECT_LABEL); protected labelId: string = inject(SELECT_LABEL_ID); + protected formField = inject(FORM_FIELD_INSTANCE, { optional: true }); protected abstract intl: Signal; protected filterPillHost = inject(FILTER_PILL_HOST_COMPONENT, { optional: true }); protected afterCloseFn?: () => void; protected updatePositionFn?: () => void; - public filterPillMode = false; + + // Signal-backed because a filter pill takes the select over *after* it has rendered, and + // `bottomSheetMode` — which gates the trigger element itself — is computed from it. + readonly #filterPillMode = signal(false); + + public get filterPillMode(): boolean { + return this.#filterPillMode(); + } + + public set filterPillMode(filterPillMode: boolean) { + this.#filterPillMode.set(filterPillMode); + } public readonly ignorePresentation = input(false, { transform: luBooleanAttribute }); @@ -81,7 +93,9 @@ export abstract class ALuSelectInputComponent implements OnDest public readonly highlightedOption = output(); - private readonly inputElementRef = viewChild>('inputElement'); + // Below the `S` breakpoint the trigger is a ` + } @else { + + } + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class FormFieldSwapTestComponent { + readonly useButton = input(false); +} + describe('FormFieldComponent', () => { let fixture: ComponentFixture; @@ -324,4 +342,29 @@ describe('FormFieldComponent', () => { expect(outside?.getAttribute('aria-describedby')).toBe('consumer-description'); }); }); + + describe('when the projected luInput is torn down and replaced', () => { + // Mirrors a select's bottom sheet trigger, which swaps between a button and a text input as the + // viewport crosses a breakpoint — each swap destroys one `luInput` and creates another. + it('should not leave stale ids behind in aria-labelledby', () => { + // Arrange + const swapFixture = TestBed.createComponent(FormFieldSwapTestComponent); + swapFixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); + + // Act — flip back and forth a few times, as resizing across the breakpoint repeatedly would + swapFixture.componentRef.setInput('useButton', true); + swapFixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); + swapFixture.componentRef.setInput('useButton', false); + swapFixture.detectChanges(); + TestBed.inject(ApplicationRef).tick(); + + // Assert + const current = (swapFixture.nativeElement as HTMLElement).querySelector('[luInput]'); + const ids = current?.getAttribute('aria-labelledby')?.split(' ') ?? []; + expect(ids).not.toHaveLength(0); + expect(ids.every((id) => document.getElementById(id) !== null)).toBe(true); + }); + }); }); diff --git a/packages/ng/form-field/form-field.component.ts b/packages/ng/form-field/form-field.component.ts index 1ebdfb80ea..2e5f96edbd 100644 --- a/packages/ng/form-field/form-field.component.ts +++ b/packages/ng/form-field/form-field.component.ts @@ -173,6 +173,24 @@ export class FormFieldComponent implements OnDestroy, DoCheck { ); } + /** + * Unregisters an input that's being destroyed — e.g. one side of an `@if`/`@else` toggling which + * element carries `luInput` (a text field swapping for a button below a breakpoint, say). Without + * this, the detached element lingers in `#inputs` forever: `prepareInput` keeps reassigning it a + * fresh id on every future change, and each one gets added to `aria-labelledby` and never removed. + */ + public removeInput(input: InputDirective): void { + this.#inputs = this.#inputs.filter((existing) => existing !== input); + if (this.#inputs.length > 0) { + afterNextRender( + () => { + this.prepareInput(); + }, + { injector: this.#injector }, + ); + } + } + public get inputs(): InputDirective[] { return this.#inputs; } @@ -203,6 +221,11 @@ export class FormFieldComponent implements OnDestroy, DoCheck { return [...message, ...this.extraDescribedBy().split(/\s+/).filter(Boolean)]; }); + // The `-label` id `updateAria` contributes to `#ariaLabelledBy` on this field's own behalf, tracked so + // that a later, different id (`this.id()` changing, e.g. `prepareInput` reassigning ids after an input + // was added/removed) replaces it instead of piling up next to it. + #selfLabelledById: string | null = null; + constructor() { ɵeffectWithDeps([this.isInputRequired, this.invalidStatus], () => { this.updateAria(); @@ -246,8 +269,15 @@ export class FormFieldComponent implements OnDestroy, DoCheck { this.#renderer.setAttribute(input.host.nativeElement, 'aria-invalid', this.invalidStatus()?.toString()); this.#renderer.setAttribute(input.host.nativeElement, 'aria-required', this.isInputRequired()?.toString()); }); - if (this.id() && !this.#ariaLabelledBy().includes(`${this.id()}-label`)) { - this.addLabelledBy(`${this.id()}-label`); + const selfLabelledById = this.id() ? `${this.id()}-label` : null; + if (selfLabelledById !== this.#selfLabelledById) { + if (this.#selfLabelledById) { + this.removeLabelledBy(this.#selfLabelledById); + } + if (selfLabelledById) { + this.addLabelledBy(selfLabelledById); + } + this.#selfLabelledById = selfLabelledById; } } diff --git a/packages/ng/form-field/input.directive.ts b/packages/ng/form-field/input.directive.ts index 44e16d4dcb..f7d184efd0 100644 --- a/packages/ng/form-field/input.directive.ts +++ b/packages/ng/form-field/input.directive.ts @@ -1,4 +1,4 @@ -import { computed, Directive, effect, ElementRef, inject, input, OnInit, Renderer2 } from '@angular/core'; +import { computed, Directive, effect, ElementRef, inject, input, OnDestroy, OnInit, Renderer2 } from '@angular/core'; import { luBooleanAttribute } from '@lucca-front/ng/core'; import { FORM_FIELD_INSTANCE } from './form-field.token'; @@ -17,7 +17,7 @@ function mergeIds(...lists: string[][]): string { class: 'luNativeInput', }, }) -export class InputDirective implements OnInit { +export class InputDirective implements OnInit, OnDestroy { public readonly host = inject>(ElementRef); public readonly formFieldRef = inject(FORM_FIELD_INSTANCE, { optional: true }); @@ -60,6 +60,10 @@ export class InputDirective implements OnInit { } } + ngOnDestroy(): void { + this.formFieldRef?.removeInput(this); + } + /** * This directive is the only writer of the attribute while it has ids to set. * It never touches the attribute otherwise, so a value bound by the consumer on a standalone input is kept. diff --git a/packages/ng/multi-select/displayer/content-displayer/content-displayer.component.ts b/packages/ng/multi-select/displayer/content-displayer/content-displayer.component.ts index e66fb5ce72..cf4e864458 100644 --- a/packages/ng/multi-select/displayer/content-displayer/content-displayer.component.ts +++ b/packages/ng/multi-select/displayer/content-displayer/content-displayer.component.ts @@ -7,7 +7,11 @@ import { LuMultiSelectDisplayerInputDirective } from '../displayer-input.directi imports: [LuMultiSelectDisplayerInputDirective], template: `
- + @if (select.bottomSheetMode()) { + + } @else { + + }
diff --git a/packages/ng/multi-select/displayer/counter-displayer/counter-displayer.component.ts b/packages/ng/multi-select/displayer/counter-displayer/counter-displayer.component.ts index f156714ffa..912b684aee 100644 --- a/packages/ng/multi-select/displayer/counter-displayer/counter-displayer.component.ts +++ b/packages/ng/multi-select/displayer/counter-displayer/counter-displayer.component.ts @@ -15,7 +15,11 @@ import { LuMultiSelectDisplayerInputDirective } from '../displayer-input.directi imports: [AsyncPipe, LuTooltipModule, ChipComponent, ɵLuOptionOutletDirective, FormsModule, LuMultiSelectDisplayerInputDirective], template: `
- + @if (select.bottomSheetMode()) { + + } @else { + + } @if (selectedOptions$ | async; as selectedOptions) {
@if (selectedOptions?.length === 1) { diff --git a/packages/ng/multi-select/displayer/default-displayer.component.ts b/packages/ng/multi-select/displayer/default-displayer.component.ts index f1315f708f..c0be53814e 100644 --- a/packages/ng/multi-select/displayer/default-displayer.component.ts +++ b/packages/ng/multi-select/displayer/default-displayer.component.ts @@ -20,7 +20,11 @@ let nextID = 0; template: `
- + @if (select.bottomSheetMode()) { + + } @else { + + } @if (select.filterPillMode) { } diff --git a/packages/ng/multi-select/displayer/displayer-input.directive.ts b/packages/ng/multi-select/displayer/displayer-input.directive.ts index 1c31d6f370..ee7247ae51 100644 --- a/packages/ng/multi-select/displayer/displayer-input.directive.ts +++ b/packages/ng/multi-select/displayer/displayer-input.directive.ts @@ -9,17 +9,18 @@ import { LuMultiSelectContentDisplayerComponent } from './content-displayer/cont @Directive({ selector: '[luMultiSelectDisplayerInput]', + exportAs: 'luMultiSelectDisplayerInput', host: { - 'aria-haspopup': 'listbox', - role: 'combobox', class: 'multipleSelect-displayer-search', - type: 'text', + '[class.mod-button]': 'isButton', + '[attr.role]': 'isButton ? null : "combobox"', + '[attr.aria-haspopup]': 'isButton ? "dialog" : "listbox"', '[attr.aria-expanded]': 'panelOpen', - '[attr.aria-activedescendant]': 'activeDescendant', + '[attr.aria-activedescendant]': 'isButton ? null : activeDescendant', '[attr.aria-controls]': 'controls', '[attr.disabled]': 'disabled', - '[attr.placeholder]': 'placeholder', - '[attr.readonly]': 'readonly', + '[attr.placeholder]': 'isButton ? null : placeholder', + '[attr.readonly]': 'isButton ? null : readonly', '(input)': 'onInput()', }, hostDirectives: [{ directive: InputDirective, inputs: ['luInputLabelledBy'] }], @@ -37,6 +38,16 @@ export class LuMultiSelectDisplayerInputDirective implements OnInit { readonly #injector = inject(Injector); + /** + * Below the `S` breakpoint the panel opens as a modal sheet carrying its own search input, so this + * element is only ever a trigger and the displayers render it as a ` + } @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: ` + + `, +}) +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()) { + + + } @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 +
+
+ +
+
+
+
+ +
+
Required +
+
+ +
+
+
+
+ +
+
Filtering +
+
+ +
+
+
+
+ +
+
With value +
+
+ +
+
+
+
+ +
+
Hovered +
+
+ +
+
+
+
+ +
+
Hovered (selected) +
+
+ +
+
+
+
+ +
+
Groups +
+
+ +
+
+
+
+ +
+
+ + + 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 selectMulti 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: '' }), +};