Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,44 @@ 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

- 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"
14 changes: 13 additions & 1 deletion .storybook/msw/handlers.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -42,7 +42,19 @@ const usersSearchHandler = genericHandler(
},
);

const PLACEHOLDER_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1" viewBox="0 0 1 1"></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(
Expand Down
8 changes: 8 additions & 0 deletions .storybook/msw/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,11 @@ export function applyV3Fields<T>(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;
}
12 changes: 11 additions & 1 deletion .storybook/plugins/msw.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { worker } from '../msw/browser';
import { isVitestBrowser } from '../msw/helpers';

export default {
init() {
Expand All @@ -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();
},
});
}
},
Expand Down
15 changes: 15 additions & 0 deletions .storybook/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
100 changes: 95 additions & 5 deletions packages/ng/core-select/input/select-input.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
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';
Expand Down Expand Up @@ -56,13 +56,25 @@ export abstract class ALuSelectInputComponent<TOption, TValue> implements OnDest

protected labelElement: HTMLElement | undefined = inject(SELECT_LABEL);
protected labelId: string = inject(SELECT_LABEL_ID);
protected formField = inject<FormFieldComponent>(FORM_FIELD_INSTANCE, { optional: true });

protected abstract intl: Signal<LuCoreSelectLabel>;

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 });

Expand All @@ -81,7 +93,9 @@ export abstract class ALuSelectInputComponent<TOption, TValue> implements OnDest

public readonly highlightedOption = output<TOption>();

private readonly inputElementRef = viewChild<ElementRef<HTMLInputElement>>('inputElement');
// Below the `S` breakpoint the trigger is a `<button>` rather than the text input, so this is only
// ever focused, never read as an input.
private readonly inputElementRef = viewChild<ElementRef<HTMLElement>>('inputElement');

readonly disabled$ = new BehaviorSubject(false);
readonly filterPillDisabled = toSignal(this.disabled$, { initialValue: false });
Expand Down Expand Up @@ -129,6 +143,30 @@ export abstract class ALuSelectInputComponent<TOption, TValue> implements OnDest
// TODO Might be temporary, check after merging signalize PR
readonly panelOpenSignal = toSignal(this.isPanelOpen$);

private readonly belowSmallBreakpoint = injectMediaMinBreakpoint('S', true);

/**
* Below the `S` breakpoint (800px) the panel opens as a dialog in `sheet` mode that embeds its own
* search input, instead of a popover anchored to the field — mirroring how filter pills move the input
* inside their overlay. Filter pills already provide their own overlay, so they keep their behavior.
*/
readonly bottomSheetMode = computed(() => (this.belowSmallBreakpoint() ?? false) && !this.filterPillMode);

/**
* Whether the currently open panel is a sheet, snapshotted when it opens rather than tracking
* `bottomSheetMode()` live: the panel's own ref (`SelectPanelSheetRef` vs `SelectPanelRef`) is
* chosen once at open time too, so resizing across the breakpoint while the panel stays open must
* not flip which surface its template renders — that surface was never actually attached as a
* dialog, so switching to it crashes with a missing `LuDialogRef` provider.
*/
readonly panelBottomSheetMode = signal(false);

/** Field label echoed as the bottom sheet's title, snapshotted when the sheet opens. */
readonly panelTitle = signal('');

/** Whether that field label carries the required marker, so the sheet's title can echo it too. */
readonly panelTitleRequired = computed(() => this.formField?.isInputRequired() ?? false);

readonly activeDescendant$ = new BehaviorSubject('');

protected manualOptions$ = new ReplaySubject<readonly TOption[]>(1);
Expand Down Expand Up @@ -287,6 +325,7 @@ export abstract class ALuSelectInputComponent<TOption, TValue> implements OnDest
protected readonly destroyed$ = new Subject<void>();

private readonly injector = inject(Injector);
private readonly hostElementRef = inject<ElementRef<HTMLElement>>(ElementRef);

constructor() {
if (this.filterPillHost) {
Expand All @@ -299,6 +338,14 @@ export abstract class ALuSelectInputComponent<TOption, TValue> implements OnDest
}
});

// Rather than leaving an open panel stuck in a stale sheet/overlay style once `bottomSheetMode()`
// no longer matches it, close it outright — the next open picks the right style for the new breakpoint.
ɵeffectWithDeps([this.bottomSheetMode], (bottomSheetMode) => {
if (this.isPanelOpen && bottomSheetMode !== this.panelBottomSheetMode()) {
this.closePanel();
}
});

// When options arrive asynchronously via a dataSource, dataSourceOptions changes but options doesn't.
// We need to reposition the panel after the DOM updates in both cases.
ɵeffectWithDeps([this.dataSourceOptions], (_options, onCleanup) => {
Expand Down Expand Up @@ -333,6 +380,11 @@ export abstract class ALuSelectInputComponent<TOption, TValue> implements OnDest
this.panelRef?.close();
break;
case 'Tab':
// A bottom sheet is modal: its focus trap cycles Tab between the sheet's own controls, so
// tabbing must not close it the way leaving the field does on desktop.
if (this.bottomSheetMode()) {
break;
}
// If we are in a filterpill, this will close it on tab press, but we want it to not lose any
// displayed stuff and properly close on focus exit
this.panelRef?.close();
Expand Down Expand Up @@ -421,7 +473,17 @@ export abstract class ALuSelectInputComponent<TOption, TValue> implements OnDest

this.#isOpeningPanel = true;
try {
this.focusInput();
const isSheet = this.bottomSheetMode();
this.panelBottomSheetMode.set(isSheet);

if (isSheet) {
// The sheet shows the field label as its title and embeds its own search input (auto-focused by
// `openSelectPanelSheet`); focusing the covered host input would pop the mobile keyboard on a
// hidden field.
this.panelTitle.set(this.resolvePanelTitle());
} else {
this.focusInput();
}

const isSearchable = this.searchable;
this.isPanelOpen$.next(true);
Expand Down Expand Up @@ -466,6 +528,34 @@ export abstract class ALuSelectInputComponent<TOption, TValue> implements OnDest
}
}

// The bottom sheet echoes the field label as its title. Standalone selects may be wrapped in a <label>,
// but inside a form-field the label is a separate element referenced through the control's aria-labelledby.
private resolvePanelTitle(): string {
if (this.labelElement) {
return this.getLabelText(this.labelElement);
}

// The field points its own `aria-labelledby` at its value displayer, so only an actual `<label>`
// counts here — otherwise a select without any label would echo its selected value as the title.
// `aria-labelledby` can list several ids (label, hint, …), and some can be stale — e.g. leftover
// from before the trigger was torn down and rebuilt as the breakpoint flipped between the button
// and input variants — so every id is checked instead of trusting the first one to be the label.
const host = this.hostElementRef.nativeElement;
const labelledByIds = host.querySelector('[aria-labelledby]')?.getAttribute('aria-labelledby')?.split(' ') ?? [];
const label = labelledByIds.map((id) => host.ownerDocument.getElementById(id)).find((element): element is HTMLElement => element?.tagName === 'LABEL');
return label ? this.getLabelText(label) : '';
}

// Read the label text without its adornments (help tooltip, required marker, screen-reader-only copy)
// nor, when the label wraps the select, the field's own rendered content — its selected value or the
// placeholder standing in for it — so the title stays the plain field label.
private getLabelText(label: HTMLElement): string {
const clone = label.cloneNode(true) as HTMLElement;
const selectTag = this.hostElementRef.nativeElement.tagName.toLowerCase();
clone.querySelectorAll(`${selectTag}, [role="button"], .pr-u-mask, .formLabel-required`).forEach((node) => node.remove());
return (clone.textContent ?? '').trim();
}

protected emptyClue(): void {
if (this.clue) {
this.clue = null;
Expand Down
3 changes: 2 additions & 1 deletion packages/ng/core-select/panel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ export * from './key-manager';
export * from './panel-layout/index';
export * from './panel-header-template.directive';
export * from './panel.models';
export { getGroupTemplateLocation as ɵgetGroupTemplateLocation, scrollIntoViewOnceReady as ɵscrollIntoViewOnceReady } from './panel.utils';
export { openSelectPanelSheet as ɵopenSelectPanelSheet } from './panel-sheet';
export { fillScrollViewport as ɵfillScrollViewport, getGroupTemplateLocation as ɵgetGroupTemplateLocation, scrollIntoViewOnceReady as ɵscrollIntoViewOnceReady } from './panel.utils';
export { CoreSelectPanelElement as ɵCoreSelectPanelElement } from './selectable-item';
export * from './panel.instance';
export { injectPointerNavigation as ɵinjectPointerNavigation } from './pointer-navigation';
Loading
Loading