diff --git a/.claude/skills/contributing/references/new-component.md b/.claude/skills/contributing/references/new-component.md index 38dbeda68..7f8e73abe 100644 --- a/.claude/skills/contributing/references/new-component.md +++ b/.claude/skills/contributing/references/new-component.md @@ -54,14 +54,18 @@ Follow all patterns from best-practices: 1. Create barrel export in `index.ts` 2. Add export to the parent category `index.ts` (e.g., `tedi/components/form/index.ts`) -## Step 6: Verify +## Step 6: Code Review + +Run `/simplify` to review all changed code for reuse opportunities, code quality issues, and efficiency problems. Fix all valid findings before proceeding. + +## Step 7: Verify 1. Run tests: `npx jest tedi/components///` 2. Fix any failures. 3. Run lint: `npm run lint` 4. Fix any lint errors. -## Step 7: Update Consumer Catalog +## Step 8: Update Consumer Catalog Update `skills/tedi-angular/references/components.md` with the new component: 1. Add an entry to the appropriate section (TEDI-Ready or Community) with selector, key inputs/outputs, and a usage example. diff --git a/.claude/skills/contributing/references/refactoring.md b/.claude/skills/contributing/references/refactoring.md index edffdc6aa..8e29340b4 100644 --- a/.claude/skills/contributing/references/refactoring.md +++ b/.claude/skills/contributing/references/refactoring.md @@ -40,18 +40,22 @@ Apply changes in this order: 5. **Tests** — update spec files to match new API/behavior 6. **Stories** — update Storybook stories to match new API -## Step 5: Verify +## Step 5: Code Review + +Run `/simplify` to review all changed code for reuse opportunities, code quality issues, and efficiency problems. Fix all valid findings before proceeding. + +## Step 6: Verify 1. Run the specific component test: `npx jest ` 2. Run the full test suite: `npm test` 3. Run lint: `npm run lint` 4. Compare test results with the baseline from Step 2 — no new failures allowed. -## Step 6: Update Consumer Catalog +## Step 7: Update Consumer Catalog If the refactor changed the public API (renamed selector, inputs/outputs, removed or deprecated a component), update `skills/tedi-angular/references/components.md` to match. -## Step 7: Report +## Step 8: Report Summarize: - Files changed (with brief description of each change) diff --git a/.github/workflows/angular-test-and-lint.yml b/.github/workflows/angular-test-and-lint.yml index 03cce03cf..5d31a87bb 100644 --- a/.github/workflows/angular-test-and-lint.yml +++ b/.github/workflows/angular-test-and-lint.yml @@ -98,7 +98,7 @@ jobs: --skip-git --skip-tests --style=scss --ssr=false --skip-install cd test-app npm install - npm install ../dist/*.tgz @angular/animations@${{ matrix.angular-version }} @angular/forms@${{ matrix.angular-version }} @angular/cdk@${{ matrix.angular-version }} ngx-float-ui@${{ matrix.angular-version }} + npm install ../dist/*.tgz @angular/animations@${{ matrix.angular-version }} @angular/forms@${{ matrix.angular-version }} @angular/cdk@${{ matrix.angular-version }} # Import a component to verify the library is consumable cat > src/app/app.component.ts << 'EOF' diff --git a/.stylelintrc.json b/.stylelintrc.json index 9bf4fccb9..4c6fb8a34 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -6,9 +6,9 @@ ], "rules": { "selector-class-pattern": [ - "^((tedi|cdk)-[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:__[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*(?:--[a-z][a-z0-9]+(?:-[a-z0-9]+)*)?|ng-[a-z]+(?:-[a-z]+)*|float-ui-[a-z]+(?:-[a-z]+)*)$", + "^((tedi|cdk)-[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:__[a-z][a-z0-9]*(?:-[a-z0-9]+)*)*(?:--[a-z][a-z0-9]+(?:-[a-z0-9]+)*)?|ng-[a-z]+(?:-[a-z]+)*)$", { - "message": "Class selector must start with 'tedi-' prefix and follow BEM naming (e.g., .tedi-button, .tedi-button__icon, .tedi-button--primary). Selector: \"%s\"", + "message": "Class selector must start with 'tedi-', 'cdk-' or 'ng-' prefix and follow BEM naming (e.g., .tedi-button, .tedi-button__icon, .tedi-button--primary). Selector: \"%s\"", "resolveNestedSelectors": true } ], diff --git a/README.md b/README.md index 83bc201fc..b5387003c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ CI runs build and test jobs against all supported versions using a matrix strate When a new Angular major is released (e.g. v22): -1. **`package.json`** — add `|| ^22.0.0` to every Angular peer dependency and `ngx-float-ui` +1. **`package.json`** — add `|| ^22.0.0` to every Angular peer dependency 2. **`.github/workflows/angular-test-and-lint.yml`** — add `22` to the `angular-version` matrix in the `build` and `test` jobs 3. **`.github/workflows/angular-release.yml`** — add `22` to the `angular-version` matrix in the `test` job @@ -50,7 +50,7 @@ When a new Angular major is released (e.g. v22): When an Angular major reaches end-of-life (e.g. v19): -1. **`package.json`** — remove `^19.0.0 ||` from every Angular peer dependency and `ngx-float-ui` +1. **`package.json`** — remove `^19.0.0 ||` from every Angular peer dependency 2. **`.github/workflows/angular-test-and-lint.yml`** — remove `19` from the `angular-version` matrix in the `build` and `test` jobs 3. **`.github/workflows/angular-release.yml`** — remove `19` from the `angular-version` matrix in the `test` job 4. Bump `devDependencies` to the new minimum supported Angular version so the library is always built and developed against a supported release diff --git a/package-lock.json b/package-lock.json index f8cf94ec6..3e0fbecdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,6 @@ "jest-preset-angular": "^14.5.3", "lint-staged": "^16.1.6", "ng-packagr": "^19.2.0", - "ngx-float-ui": "19.0.1", "prettier": "^3.4.2", "readdirp": "4.1.2", "replace-in-file": "^8.3.0", @@ -83,8 +82,7 @@ "@angular/common": "^19.0.0 || ^20.0.0 || ^21.0.0", "@angular/core": "^19.0.0 || ^20.0.0 || ^21.0.0", "@angular/forms": "^19.0.0 || ^20.0.0 || ^21.0.0", - "@angular/platform-browser": "^19.0.0 || ^20.0.0 || ^21.0.0", - "ngx-float-ui": "^19.0.1 || ^20.0.0 || ^21.0.0" + "@angular/platform-browser": "^19.0.0 || ^20.0.0 || ^21.0.0" } }, "node_modules/@actions/core": { @@ -4867,34 +4865,6 @@ "lodash": "^4.17.21" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -22854,23 +22824,6 @@ "semver": "bin/semver.js" } }, - "node_modules/ngx-float-ui": { - "version": "19.0.1", - "resolved": "https://registry.npmjs.org/ngx-float-ui/-/ngx-float-ui-19.0.1.tgz", - "integrity": "sha512-ST5fLsByQoT65CXiPFhnncQZjai8rCNqHC9rNDUh722a/UoummJaFGIC/SIM8tMkE6OK/sVOFqfskMre+7Nh8Q==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.6.12", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^19.0.0", - "@angular/core": "^19.0.0", - "rxjs": "^7.4.0" - } - }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", diff --git a/package.json b/package.json index 1d95a6ef9..fd4088008 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,7 @@ "@angular/common": "^19.0.0 || ^20.0.0 || ^21.0.0", "@angular/core": "^19.0.0 || ^20.0.0 || ^21.0.0", "@angular/forms": "^19.0.0 || ^20.0.0 || ^21.0.0", - "@angular/platform-browser": "^19.0.0 || ^20.0.0 || ^21.0.0", - "ngx-float-ui": "^19.0.1 || ^20.0.0 || ^21.0.0" + "@angular/platform-browser": "^19.0.0 || ^20.0.0 || ^21.0.0" }, "dependencies": { "@tedi-design-system/core": "^3.3.0" @@ -84,7 +83,6 @@ "jest-preset-angular": "^14.5.3", "lint-staged": "^16.1.6", "ng-packagr": "^19.2.0", - "ngx-float-ui": "19.0.1", "prettier": "^3.4.2", "readdirp": "4.1.2", "replace-in-file": "^8.3.0", diff --git a/skills/tedi-angular/SKILL.md b/skills/tedi-angular/SKILL.md index 8df325c23..6fc5c4d4a 100644 --- a/skills/tedi-angular/SKILL.md +++ b/skills/tedi-angular/SKILL.md @@ -25,7 +25,6 @@ npm install @tedi-design-system/angular @tedi-design-system/core @angular/cdk: ^19.0.0 || ^20.0.0 || ^21.0.0 @angular/animations: ^19.0.0 || ^20.0.0 || ^21.0.0 @angular/platform-browser: ^19.0.0 || ^20.0.0 || ^21.0.0 -ngx-float-ui: ^19.0.1 || ^20.0.0 || ^21.0.0 ``` ## Setup diff --git a/skills/tedi-angular/references/components.md b/skills/tedi-angular/references/components.md index 96fddcf34..06be42e75 100644 --- a/skills/tedi-angular/references/components.md +++ b/skills/tedi-angular/references/components.md @@ -363,7 +363,6 @@ Implements `ControlValueAccessor`. Value type is `T` (single) or `T[]` (multisel - `showClear: boolean = false` — show clear action in dropdown - `selectAllLabel: string = "Vali kõik"` - `clearLabel: string = "Tühjenda valik"` -- `appendTo: string = ""` — append dropdown to selector (e.g., "body") **Outputs:** - `cleared: void` — emitted when clear button is clicked in custom content mode **Slots:** @@ -377,14 +376,14 @@ Implements `ControlValueAccessor`. Value type depends on mode: `boolean` (toggle - + - + + [searchable]="true" [showSelectAll]="true" [showClear]="true" /> @@ -718,7 +717,6 @@ The `[(open)]` binding approach is deprecated. Use `ModalService.open()` for new **Inputs:** - `position: DropdownPosition = "bottom-start"` - `preventOverflow: boolean = true` -- `appendTo: string` ```html @@ -737,7 +735,6 @@ The `[(open)]` binding approach is deprecated. Use `ModalService.open()` for new - `dismissible: boolean = true` - `withArrow: boolean = true` - `lockScroll: boolean = false` -- `appendTo: string = "body"` ### Tooltip **Selector:** `tedi-tooltip` @@ -745,7 +742,6 @@ The `[(open)]` binding approach is deprecated. Use `ModalService.open()` for new - `position: TooltipPosition = "top"` - `preventOverflow: boolean = true` - `openWith: TooltipOpenWith = "both"` — hover, focus, or both -- `appendTo: string = "body"` ```html diff --git a/tedi/components/form/date-picker/date-picker.component.html b/tedi/components/form/date-picker/date-picker.component.html index 47807775c..cf14aca43 100644 --- a/tedi/components/form/date-picker/date-picker.component.html +++ b/tedi/components/form/date-picker/date-picker.component.html @@ -10,7 +10,7 @@ [class.tedi-date-picker__input--error]="inputState() === 'error'" [attr.id]="inputId()" [attr.placeholder]="inputPlaceholder()" - [attr.aria-expanded]="!fieldDisabled() && popover().floatUiComponent().state" + [attr.aria-expanded]="!fieldDisabled() && popover().isOpen()" [attr.aria-controls]="uniqueId" [attr.aria-readonly]="!allowManualInput()" [readOnly]="!allowManualInput()" diff --git a/tedi/components/form/date-picker/date-picker.component.spec.ts b/tedi/components/form/date-picker/date-picker.component.spec.ts index 16e8ef9a8..e2edfc53c 100644 --- a/tedi/components/form/date-picker/date-picker.component.spec.ts +++ b/tedi/components/form/date-picker/date-picker.component.spec.ts @@ -4,7 +4,6 @@ import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { By } from "@angular/platform-browser"; import { DatePickerComponent } from "./date-picker.component"; import { TediTranslationService } from "../../../services/translation/translation.service"; -import { NgxFloatUiContentComponent } from "ngx-float-ui"; import { DatePickerCalendarGridComponent } from "./date-picker-calendar-grid/date-picker-calendar-grid.component"; class TranslationMock { @@ -43,21 +42,6 @@ describe("DatePickerComponent", () => { component = fixture.componentInstance; el = fixture.nativeElement; - const mockFloatUiElement = document.createElement("div"); - const mockContainer = document.createElement("div"); - mockContainer.className = "float-ui-container-popover"; - mockContainer.id = "mock-popover-container"; - mockFloatUiElement.appendChild(mockContainer); - - jest.spyOn(component.popover(), "floatUiComponent").mockReturnValue({ - state: false, - show: jest.fn(), - hide: jest.fn(), - elRef: { - nativeElement: mockFloatUiElement, - }, - } as unknown as NgxFloatUiContentComponent); - fixture.detectChanges(); }); @@ -226,10 +210,8 @@ describe("DatePickerComponent", () => { }); it("Escape in day grid should hide popover and focus input", () => { - const hideMock = jest.fn(); - const pop = component.popover().floatUiComponent(); - pop.hide = hideMock; - pop.state = true; + const hideSpy = jest.spyOn(component.popover(), "hidePopover"); + component.popover().isOpen.set(true); const input = component.inputElement().nativeElement; const focusSpy = jest.spyOn(input, "focus"); @@ -239,7 +221,7 @@ describe("DatePickerComponent", () => { component.onDayKeydown(event, today); - expect(hideMock).toHaveBeenCalled(); + expect(hideSpy).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); }); @@ -262,9 +244,8 @@ describe("DatePickerComponent", () => { it("onInputClick should do nothing when allowManualInput=true", () => { fixture.componentRef.setInput("allowManualInput", true); - const pop = component.popover().floatUiComponent(); - const hideSpy = jest.spyOn(pop, "hide"); - const showSpy = jest.spyOn(pop, "show"); + const hideSpy = jest.spyOn(component.popover(), "hidePopover"); + const showSpy = jest.spyOn(component.popover(), "showPopover"); component.onInputClick(); @@ -275,10 +256,9 @@ describe("DatePickerComponent", () => { it("onInputClick should hide popover and focus input when popover is open", () => { fixture.componentRef.setInput("allowManualInput", false); - const pop = component.popover().floatUiComponent(); - pop.state = true; + component.popover().isOpen.set(true); - const hideSpy = jest.spyOn(pop, "hide"); + const hideSpy = jest.spyOn(component.popover(), "hidePopover"); const focusSpy = jest.spyOn( component.inputElement().nativeElement, "focus", @@ -293,10 +273,9 @@ describe("DatePickerComponent", () => { it("onInputClick should show popover and call openCalendar when popover closed", () => { fixture.componentRef.setInput("allowManualInput", false); - const pop = component.popover().floatUiComponent(); - pop.state = false; + component.popover().isOpen.set(false); - const showSpy = jest.spyOn(pop, "show"); + const showSpy = jest.spyOn(component.popover(), "showPopover"); const openSpy = jest.spyOn(component, "openCalendar"); component.onInputClick(); @@ -822,10 +801,7 @@ describe("DatePickerComponent", () => { describe("closeOnSelect behavior", () => { it("should close popover after selection when closeOnSelect is true", () => { - const hideSpy = jest.spyOn( - component.popover().floatUiComponent(), - "hide", - ); + const hideSpy = jest.spyOn(component.popover(), "hidePopover"); component.selectDay({ date: new Date(2024, 4, 15), @@ -840,10 +816,7 @@ describe("DatePickerComponent", () => { fixture.componentRef.setInput("closeOnSelect", false); fixture.detectChanges(); - const hideSpy = jest.spyOn( - component.popover().floatUiComponent(), - "hide", - ); + const hideSpy = jest.spyOn(component.popover(), "hidePopover"); component.selectDay({ date: new Date(2024, 4, 15), @@ -1052,20 +1025,6 @@ describe("DatePickerComponent", () => { const newFixture = TestBed.createComponent(DatePickerComponent); const newComponent = newFixture.componentInstance; - const mockFloatUiElement = document.createElement("div"); - const mockContainer = document.createElement("div"); - mockContainer.className = "float-ui-container-popover"; - mockFloatUiElement.appendChild(mockContainer); - - jest.spyOn(newComponent.popover(), "floatUiComponent").mockReturnValue({ - state: false, - show: jest.fn(), - hide: jest.fn(), - elRef: { - nativeElement: mockFloatUiElement, - }, - } as unknown as NgxFloatUiContentComponent); - newFixture.componentRef.setInput("disabled", { before: new Date(2030, 0, 1), }); @@ -1566,20 +1525,6 @@ describe("DatePickerComponent NG_VALUE_ACCESSOR integration", () => { By.directive(DatePickerComponent), ).componentInstance as DatePickerComponent; - const mockFloatUiElement = document.createElement("div"); - const mockContainer = document.createElement("div"); - mockContainer.className = "float-ui-container-popover"; - mockFloatUiElement.appendChild(mockContainer); - - jest.spyOn(datePicker.popover(), "floatUiComponent").mockReturnValue({ - state: false, - show: jest.fn(), - hide: jest.fn(), - elRef: { - nativeElement: mockFloatUiElement, - }, - } as unknown as NgxFloatUiContentComponent); - fixture.detectChanges(); }); diff --git a/tedi/components/form/date-picker/date-picker.component.ts b/tedi/components/form/date-picker/date-picker.component.ts index a99cd9cf2..93190f9de 100644 --- a/tedi/components/form/date-picker/date-picker.component.ts +++ b/tedi/components/form/date-picker/date-picker.component.ts @@ -609,11 +609,11 @@ export class DatePickerComponent implements OnInit, ControlValueAccessor { onInputClick() { if (this.allowManualInput()) return; - if (this.popover().floatUiComponent().state) { - this.popover().floatUiComponent().hide(); + if (this.popover().isOpen()) { + this.popover().hidePopover(); this.inputElement().nativeElement.focus(); } else { - this.popover().floatUiComponent().show(); + this.popover().showPopover(); this.openCalendar(); } } @@ -625,7 +625,7 @@ export class DatePickerComponent implements OnInit, ControlValueAccessor { } closeCalendar() { - this.popover().floatUiComponent().hide(); + this.popover().hidePopover(); this.inputElement().nativeElement.focus(); this.onTouched(); } diff --git a/tedi/components/form/filter/filter.component.html b/tedi/components/form/filter/filter.component.html index 5dea260e3..4d6e668bf 100644 --- a/tedi/components/form/filter/filter.component.html +++ b/tedi/components/form/filter/filter.component.html @@ -1,5 +1,5 @@ @if (hasDropdown()) { - + @@ -176,14 +165,13 @@ export const WithMeta: Story = { args: { position: "bottom-start", preventOverflow: true, - appendTo: "body", dropdownRole: "listbox", ariaHasPopup: "listbox", }, render: (args) => ({ props: args, template: ` - + @@ -217,14 +205,13 @@ export const WithIcons: Story = { args: { position: "bottom-start", preventOverflow: true, - appendTo: "body", dropdownRole: "menu", ariaHasPopup: "menu", }, render: (args) => ({ props: args, template: ` - + @@ -258,14 +245,13 @@ export const VerticalLayout: Story = { args: { position: "bottom-start", preventOverflow: true, - appendTo: "body", dropdownRole: "listbox", ariaHasPopup: "listbox", }, render: (args) => ({ props: args, template: ` - + diff --git a/tedi/components/overlay/dropdown/dropdown.tokens.ts b/tedi/components/overlay/dropdown/dropdown.tokens.ts index 86b59f1bb..1b23656a6 100644 --- a/tedi/components/overlay/dropdown/dropdown.tokens.ts +++ b/tedi/components/overlay/dropdown/dropdown.tokens.ts @@ -3,7 +3,7 @@ import { InjectionToken, Signal, WritableSignal } from "@angular/core"; export interface DropdownApi { /** Current value of the dropdown. Used to track the selected option in listbox mode. */ value: WritableSignal; - /** ID of the float-ui container element. Used to link trigger and content for accessibility. */ + /** ID of the overlay container element. Used to link trigger and content for accessibility. */ containerId: WritableSignal; /** Move focus to the next enabled item after the given element. */ focusNextItem(fromEl: HTMLLIElement): void; diff --git a/tedi/components/overlay/index.ts b/tedi/components/overlay/index.ts index 012165183..753977a3d 100644 --- a/tedi/components/overlay/index.ts +++ b/tedi/components/overlay/index.ts @@ -1,3 +1,4 @@ +export * from "./overlay-position.util"; export * from "./dropdown"; export * from "./modal"; export * from "./tooltip"; diff --git a/tedi/components/overlay/overlay-position.util.ts b/tedi/components/overlay/overlay-position.util.ts new file mode 100644 index 000000000..ca4b820bc --- /dev/null +++ b/tedi/components/overlay/overlay-position.util.ts @@ -0,0 +1,309 @@ +import { + ConnectedPosition, + ConnectedOverlayPositionChange, +} from "@angular/cdk/overlay"; + +export type OverlayPosition = + | "top" + | "top-start" + | "top-end" + | "bottom" + | "bottom-start" + | "bottom-end" + | "left" + | "left-start" + | "left-end" + | "right" + | "right-start" + | "right-end" + | "auto" + | "auto-start" + | "auto-end"; + +const POSITION_MAP: Record = { + top: { + originX: "center", + originY: "top", + overlayX: "center", + overlayY: "bottom", + offsetY: -8, + }, + "top-start": { + originX: "start", + originY: "top", + overlayX: "start", + overlayY: "bottom", + offsetY: -8, + }, + "top-end": { + originX: "end", + originY: "top", + overlayX: "end", + overlayY: "bottom", + offsetY: -8, + }, + bottom: { + originX: "center", + originY: "bottom", + overlayX: "center", + overlayY: "top", + offsetY: 8, + }, + "bottom-start": { + originX: "start", + originY: "bottom", + overlayX: "start", + overlayY: "top", + offsetY: 8, + }, + "bottom-end": { + originX: "end", + originY: "bottom", + overlayX: "end", + overlayY: "top", + offsetY: 8, + }, + left: { + originX: "start", + originY: "center", + overlayX: "end", + overlayY: "center", + offsetX: -8, + }, + "left-start": { + originX: "start", + originY: "top", + overlayX: "end", + overlayY: "top", + offsetX: -8, + }, + "left-end": { + originX: "start", + originY: "bottom", + overlayX: "end", + overlayY: "bottom", + offsetX: -8, + }, + right: { + originX: "end", + originY: "center", + overlayX: "start", + overlayY: "center", + offsetX: 8, + }, + "right-start": { + originX: "end", + originY: "top", + overlayX: "start", + overlayY: "top", + offsetX: 8, + }, + "right-end": { + originX: "end", + originY: "bottom", + overlayX: "start", + overlayY: "bottom", + offsetX: 8, + }, +}; + +const OPPOSITE_DIRECTION: Record = { + top: "bottom", + bottom: "top", + left: "right", + right: "left", +}; + +export function toConnectedPositions( + placement: OverlayPosition, + preventOverflow: boolean, + extraOffset = 0, +): ConnectedPosition[] { + const applyExtra = (pos: ConnectedPosition): ConnectedPosition => { + if (extraOffset === 0) return pos; + return { + ...pos, + offsetX: pos.offsetX + ? pos.offsetX + Math.sign(pos.offsetX) * extraOffset + : pos.offsetX, + offsetY: pos.offsetY + ? pos.offsetY + Math.sign(pos.offsetY) * extraOffset + : pos.offsetY, + }; + }; + + if (placement.startsWith("auto")) { + const suffix = placement === "auto" ? "" : placement.substring(5); + const directions = ["top", "bottom", "right", "left"]; + + return directions.map((dir) => { + const key = suffix ? `${dir}-${suffix}` : dir; + return applyExtra(POSITION_MAP[key] ?? POSITION_MAP[dir]); + }); + } + + const primary = POSITION_MAP[placement]; + if (!primary) return [applyExtra(POSITION_MAP["bottom"])]; + + const positions: ConnectedPosition[] = [applyExtra(primary)]; + + const direction = placement.split("-")[0]; + const suffix = placement.includes("-") + ? placement.substring(placement.indexOf("-") + 1) + : ""; + + // Add cross-axis fallbacks (start/end variants) so CDK can shift + // horizontally/vertically without push covering the trigger + const crossAxisSuffixes = ["start", "end"].filter((s) => s !== suffix); + for (const s of crossAxisSuffixes) { + const key = `${direction}-${s}`; + const fallback = POSITION_MAP[key]; + if (fallback) positions.push(applyExtra(fallback)); + } + + if (preventOverflow) { + const opposite = OPPOSITE_DIRECTION[direction]; + + if (opposite) { + const key = suffix ? `${opposite}-${suffix}` : opposite; + const fallback = POSITION_MAP[key]; + if (fallback) positions.push(applyExtra(fallback)); + + // Also add cross-axis fallbacks for the opposite direction + for (const s of crossAxisSuffixes) { + const oppKey = `${opposite}-${s}`; + const oppFallback = POSITION_MAP[oppKey]; + if (oppFallback) positions.push(applyExtra(oppFallback)); + } + } + } + + return positions; +} + +export function getPlacementFromPositionChange( + change: ConnectedOverlayPositionChange, +): string { + const { originY, overlayY, originX, overlayX } = change.connectionPair; + + if (overlayX === "end" && originX === "start") return "left"; + if (overlayX === "start" && originX === "end") return "right"; + if (overlayY === "bottom") return "top"; + if (overlayY === "top" && originY === "bottom") return "bottom"; + + return "bottom"; +} + +export interface ArrowOffset { + left: number | null; + top: number | null; +} + +/** + * Horizontal-only push: shifts the overlay pane left/right so it stays + * within the viewport, replicating floating-ui's shift() middleware. + * Unlike CDK's built-in push, this does NOT affect the vertical axis, + * so overlays scroll naturally with their triggers. + */ +function applyHorizontalPush(overlayEl: HTMLElement): void { + const content = overlayEl.firstElementChild as HTMLElement | null; + if (!content) return; + + // Reset previous shift before measuring + content.style.translate = ""; + + const rect = content.getBoundingClientRect(); + const viewportWidth = document.documentElement.clientWidth; + + let shift = 0; + if (rect.right > viewportWidth) { + shift = -(rect.right - viewportWidth); + } + // After shifting for right overflow, ensure left edge stays at 0 or above + if (rect.left + shift < 0) { + shift = -rect.left; + } + if (shift !== 0) { + content.style.translate = `${shift}px`; + } +} + +/** + * Manages horizontal-only push and resize recalculation for CDK overlays. + */ +export class HorizontalPushHandler { + private resizeCleanup?: () => void; + private rafId?: number; + + constructor( + private readonly getOverlayEl: () => HTMLElement | undefined, + private readonly onAfterPush?: () => void, + ) {} + + apply(): void { + const el = this.getOverlayEl(); + if (el) applyHorizontalPush(el); + } + + attach(): void { + this.apply(); + if (!this.resizeCleanup) { + const handler = () => { + if (this.rafId != null) cancelAnimationFrame(this.rafId); + this.rafId = requestAnimationFrame(() => { + this.rafId = undefined; + this.apply(); + this.onAfterPush?.(); + }); + }; + window.addEventListener("resize", handler); + this.resizeCleanup = () => window.removeEventListener("resize", handler); + } + } + + detach(): void { + if (this.rafId != null) cancelAnimationFrame(this.rafId); + this.rafId = undefined; + this.resizeCleanup?.(); + this.resizeCleanup = undefined; + } +} + +export function calculateArrowOffset( + placement: string, + triggerEl: HTMLElement, + overlayEl: HTMLElement, + arrowSize: number, + padding = 4, +): ArrowOffset { + const triggerRect = triggerEl.getBoundingClientRect(); + // Use the content container's rect (first child) which reflects horizontal push + const container = overlayEl.firstElementChild as HTMLElement | null; + const overlayRect = container + ? container.getBoundingClientRect() + : overlayEl.getBoundingClientRect(); + // The rotated arrow's visual extent is larger than half the element size + const edgeMargin = padding + arrowSize * 0.7; + + const isVertical = placement === "top" || placement === "bottom"; + + if (isVertical) { + const triggerCenter = triggerRect.left + triggerRect.width / 2; + const left = triggerCenter - overlayRect.left; + return { + left: Math.round( + Math.max(edgeMargin, Math.min(overlayRect.width - edgeMargin, left)), + ), + top: null, + }; + } + + const triggerCenter = triggerRect.top + triggerRect.height / 2; + const top = triggerCenter - overlayRect.top; + return { + left: null, + top: Math.round( + Math.max(edgeMargin, Math.min(overlayRect.height - edgeMargin, top)), + ), + }; +} diff --git a/tedi/components/overlay/popover/popover-content/popover-content.component.spec.ts b/tedi/components/overlay/popover/popover-content/popover-content.component.spec.ts index 5e5b587b2..12b552dce 100644 --- a/tedi/components/overlay/popover/popover-content/popover-content.component.spec.ts +++ b/tedi/components/overlay/popover/popover-content/popover-content.component.spec.ts @@ -6,7 +6,6 @@ import { PopoverWidth, } from "./popover-content.component"; import { PopoverComponent } from "../popover.component"; -import { NgxFloatUiContentComponent } from "ngx-float-ui"; import { TEDI_TRANSLATION_DEFAULT_TOKEN } from "../../../../tokens/translation.token"; @Component({ @@ -39,11 +38,6 @@ describe("PopoverContentComponent", () => { popoverMock = { hidePopover: hidePopoverSpy, - floatUiComponent: (() => - ({ - hide: jest.fn(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as unknown as NgxFloatUiContentComponent) as any, }; TestBed.configureTestingModule({ diff --git a/tedi/components/overlay/popover/popover-trigger/popover-trigger.directive.ts b/tedi/components/overlay/popover/popover-trigger/popover-trigger.directive.ts index 5a0424100..628e5817d 100644 --- a/tedi/components/overlay/popover/popover-trigger/popover-trigger.directive.ts +++ b/tedi/components/overlay/popover/popover-trigger/popover-trigger.directive.ts @@ -5,17 +5,19 @@ import { inject, input, } from "@angular/core"; +import { CdkOverlayOrigin } from "@angular/cdk/overlay"; import { PopoverComponent } from "../popover.component"; @Directive({ standalone: true, selector: "[tedi-popover-trigger]", + hostDirectives: [CdkOverlayOrigin], host: { tabindex: "0", role: "button", "aria-haspopup": "dialog", "[id]": "popover.containerId() + '_trigger'", - "[attr.aria-expanded]": "popover.floatUiComponent().state", + "[attr.aria-expanded]": "popover.isOpen()", "[attr.aria-controls]": "popover.containerId()", "[class.tedi-popover-trigger__text]": "underline()", }, @@ -29,6 +31,7 @@ export class PopoverTriggerDirective { readonly host = inject>(ElementRef); readonly popover = inject(PopoverComponent); + readonly overlayOrigin = inject(CdkOverlayOrigin, { self: true }); @HostListener("click") onClick() { diff --git a/tedi/components/overlay/popover/popover.component.html b/tedi/components/overlay/popover/popover.component.html index 634e3df0f..4c0fc46bf 100644 --- a/tedi/components/overlay/popover/popover.component.html +++ b/tedi/components/overlay/popover/popover.component.html @@ -1,15 +1,27 @@ - - - +
+ @if (withArrow()) { +
+ } + +
+ diff --git a/tedi/components/overlay/popover/popover.component.scss b/tedi/components/overlay/popover/popover.component.scss index 5e9541d5a..b890b18e0 100644 --- a/tedi/components/overlay/popover/popover.component.scss +++ b/tedi/components/overlay/popover/popover.component.scss @@ -18,93 +18,92 @@ tedi-popover { } } -float-ui-content { - .float-ui-container-popover { - z-index: var(--z-index-dropdown); - padding: 0; - border: var(--borders-01) solid var(--popover-border); - border-radius: var(--popover-radius-rounded); - box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%)); - - &--arrow { - .float-ui-arrow { - z-index: var(--z-index-dropdown); - width: 24px; - height: 24px; - background: var(--popover-background); - filter: drop-shadow(0 0 5px var(--tedi-alpha-20)); - clip-path: inset(0 -5px -5px 0); - } +.tedi-popover__container { + position: relative; + z-index: var(--z-index-dropdown); + border: var(--borders-01) solid var(--popover-border); + border-radius: var(--popover-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + + &--arrow { + .tedi-popover__arrow { + position: absolute; + z-index: 0; + width: 24px; + height: 24px; + background: var(--popover-background); + filter: drop-shadow(0 0 5px var(--tedi-alpha-20)); + clip-path: inset(0 -5px -5px 0); } - &--border { - border-radius: var(--popover-radius-rounded); - - .float-ui-arrow { - width: 18px; - height: 18px; - border-right: 4px solid var(--header-popover-border-top); - border-bottom: 4px solid var(--header-popover-border-top); - } + .tedi-popover-content { + z-index: 1; + } - &[data-float-ui-placement="top"] { - border-bottom: 4px solid var(--header-popover-border-top); + &[data-placement="top"] .tedi-popover__arrow { + bottom: -12px; + transform: translateX(-50%) rotate(45deg); + } - .float-ui-arrow { - bottom: -12px !important; - } - } + &[data-placement="bottom"] .tedi-popover__arrow { + top: -12px; + transform: translateX(-50%) rotate(-135deg); + } - &[data-float-ui-placement="bottom"] { - border-top: 4px solid var(--header-popover-border-top); + &[data-placement="left"] .tedi-popover__arrow { + right: -12px; + transform: translateY(-50%) rotate(-45deg); + } - .float-ui-arrow { - top: -12px !important; - } - } + &[data-placement="right"] .tedi-popover__arrow { + left: -12px; + transform: translateY(-50%) rotate(135deg); + } + } - &[data-float-ui-placement="left"] { - border-right: 4px solid var(--header-popover-border-top); + &--border { + border-radius: var(--popover-radius-rounded); - .float-ui-arrow { - right: -12px !important; - } - } + .tedi-popover__arrow { + width: 18px; + height: 18px; + border-right: 4px solid var(--header-popover-border-top); + border-bottom: 4px solid var(--header-popover-border-top); + } - &[data-float-ui-placement="right"] { - border-left: 4px solid var(--header-popover-border-top); + &[data-placement="top"] { + border-bottom: 4px solid var(--header-popover-border-top); - .float-ui-arrow { - left: -12px !important; - } + .tedi-popover__arrow { + bottom: -12px; + transform: translateX(-50%) rotate(45deg); } } - &[data-float-ui-placement="top"] { - transform: translateY(-4px); - } - - &[data-float-ui-placement="bottom"] { - transform: translateY(4px); + &[data-placement="bottom"] { + border-top: 4px solid var(--header-popover-border-top); - .float-ui-arrow { - transform: rotate(-135deg); + .tedi-popover__arrow { + top: -12px; + transform: translateX(-50%) rotate(-135deg); } } - &[data-float-ui-placement="left"] { - transform: translateX(-4px); + &[data-placement="left"] { + border-right: 4px solid var(--header-popover-border-top); - .float-ui-arrow { - transform: rotate(-45deg); + .tedi-popover__arrow { + right: -12px; + transform: translateY(-60%) rotate(-45deg); } } - &[data-float-ui-placement="right"] { - transform: translateX(4px); + &[data-placement="right"] { + border-left: 4px solid var(--header-popover-border-top); - .float-ui-arrow { - transform: rotate(135deg); + .tedi-popover__arrow { + left: -12px; + transform: translateY(-60%) rotate(135deg); } } } diff --git a/tedi/components/overlay/popover/popover.component.spec.ts b/tedi/components/overlay/popover/popover.component.spec.ts index b4147c87d..cbf91e47b 100644 --- a/tedi/components/overlay/popover/popover.component.spec.ts +++ b/tedi/components/overlay/popover/popover.component.spec.ts @@ -1,7 +1,7 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { OverlayContainer } from "@angular/cdk/overlay"; import { PopoverComponent, PopoverPosition } from "./popover.component"; -import { NgxFloatUiContentComponent } from "ngx-float-ui"; import { PopoverTriggerDirective } from "./popover-trigger/popover-trigger.directive"; import { PopoverContentComponent } from "./popover-content/popover-content.component"; @@ -12,7 +12,6 @@ import { PopoverContentComponent } from "./popover-content/popover-content.compo { let hostComponent: TestHostComponent; let component: PopoverComponent; let hostEl: HTMLElement; + let overlayContainerElement: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({ @@ -57,6 +56,13 @@ describe("PopoverComponent", () => { (el) => el.componentInstance instanceof PopoverComponent, ); component = popoverDebugEl?.componentInstance as PopoverComponent; + + const overlayContainer = TestBed.inject(OverlayContainer); + overlayContainerElement = overlayContainer.getContainerElement(); + }); + + afterEach(() => { + component.hidePopover(); }); it("should create component", () => { @@ -77,9 +83,8 @@ describe("PopoverComponent", () => { expect(component.preventOverflow()).toBe(true); }); - it("should initialize the ViewChild floatUiComponent", () => { - const instance = component.floatUiComponent(); - expect(instance).toBeInstanceOf(NgxFloatUiContentComponent); + it("should have isOpen default to false", () => { + expect(component.isOpen()).toBe(false); }); it("should render trigger button", () => { @@ -93,32 +98,22 @@ describe("PopoverComponent", () => { expect(trigger?.getAttribute("aria-haspopup")).toBe("dialog"); }); - it('should have default appendTo="body" on float-ui-content', () => { - expect(component.appendTo()).toBe("body"); - }); - - it("should update appendTo when input changes", () => { - hostComponent.appendTo = ""; - fixture.detectChanges(); - expect(component.appendTo()).toBe(""); - }); - it("should not include the border class by default", () => { - expect(component.floatUiContainerClass()).not.toContain("border"); + expect(component.panelClasses()).not.toContain("border"); }); it("should apply the border class when withBorder is true", () => { hostComponent.withBorder = true; fixture.detectChanges(); - expect(component.floatUiContainerClass()).toContain( - "float-ui-container-popover--border", + expect(component.panelClasses()).toContain( + "tedi-popover__container--border", ); }); it("should include arrow class when withArrow is true", () => { - expect(component.floatUiContainerClass()).toContain( - "float-ui-container-popover--arrow", + expect(component.panelClasses()).toContain( + "tedi-popover__container--arrow", ); }); @@ -126,7 +121,7 @@ describe("PopoverComponent", () => { hostComponent.withArrow = false; fixture.detectChanges(); - expect(component.floatUiContainerClass()).not.toContain("arrow"); + expect(component.panelClasses()).not.toContain("arrow"); }); it("should update position when input changes", () => { @@ -166,23 +161,20 @@ describe("PopoverComponent", () => { describe("showPopover()", () => { it("should not show popover if already open", () => { - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: true, writable: true }); - const showSpy = jest.spyOn(floatUi, "show"); + component.isOpen.set(true); + const previousState = component.isOpen(); component.showPopover(); - expect(showSpy).not.toHaveBeenCalled(); + expect(component.isOpen()).toBe(previousState); }); - it("should call floatUiComponent.show() when closed", () => { - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: false, writable: true }); - const showSpy = jest.spyOn(floatUi, "show"); + it("should set isOpen to true when closed", () => { + expect(component.isOpen()).toBe(false); component.showPopover(); - expect(showSpy).toHaveBeenCalled(); + expect(component.isOpen()).toBe(true); }); it("should set body overflow:hidden when lockScroll is true", () => { @@ -191,9 +183,6 @@ describe("PopoverComponent", () => { hostComponent.lockScroll = true; fixture.detectChanges(); - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: false, writable: true }); - const renderer = component["renderer"]; const setStyleSpy = jest.spyOn(renderer, "setStyle"); @@ -205,7 +194,6 @@ describe("PopoverComponent", () => { "hidden", ); - Object.defineProperty(floatUi, "state", { value: true, writable: true }); component.hidePopover(); }); @@ -214,14 +202,14 @@ describe("PopoverComponent", () => { hostComponent.dismissible = false; fixture.detectChanges(); - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: false, writable: true }); - component.showPopover(); + fixture.detectChanges(); + + // Trigger attach manually since CDK overlay may not render in unit tests + component.onOverlayAttach(); expect(component["scrollListener"]).toBeDefined(); - Object.defineProperty(floatUi, "state", { value: true, writable: true }); component.hidePopover(); }); @@ -230,44 +218,40 @@ describe("PopoverComponent", () => { hostComponent.hideOnScroll = false; fixture.detectChanges(); - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: false, writable: true }); - component.showPopover(); + fixture.detectChanges(); + + component.onOverlayAttach(); expect(component["focusinListener"]).toBeDefined(); expect(component["mousedownListener"]).toBeDefined(); - Object.defineProperty(floatUi, "state", { value: true, writable: true }); component.hidePopover(); }); }); describe("hidePopover()", () => { beforeEach(() => { - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: false, writable: true }); component.showPopover(); - Object.defineProperty(floatUi, "state", { value: true, writable: true }); + fixture.detectChanges(); + component.onOverlayAttach(); }); it("should not hide popover if already closed", () => { - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: false, writable: true }); - const hideSpy = jest.spyOn(floatUi, "hide"); + component.isOpen.set(false); component.hidePopover(); - expect(hideSpy).not.toHaveBeenCalled(); + // It was called but returned early + expect(component.isOpen()).toBe(false); }); - it("should call floatUiComponent.hide() when open", () => { - const floatUi = component.floatUiComponent(); - const hideSpy = jest.spyOn(floatUi, "hide"); + it("should set isOpen to false when open", () => { + expect(component.isOpen()).toBe(true); component.hidePopover(); - expect(hideSpy).toHaveBeenCalled(); + expect(component.isOpen()).toBe(false); }); it("should remove body overflow style when lockScroll is true", () => { @@ -312,8 +296,7 @@ describe("PopoverComponent", () => { describe("togglePopover()", () => { it("should call hidePopover(true) when popover is open", () => { - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: true, writable: true }); + component.isOpen.set(true); const hideSpy = jest.spyOn(component, "hidePopover"); component.togglePopover(); @@ -322,8 +305,7 @@ describe("PopoverComponent", () => { }); it("should call showPopover() when popover is closed", () => { - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: false, writable: true }); + component.isOpen.set(false); const showSpy = jest.spyOn(component, "showPopover"); component.togglePopover(); @@ -334,19 +316,14 @@ describe("PopoverComponent", () => { describe("Keyboard navigation", () => { beforeEach(() => { - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: false, writable: true }); component.showPopover(); - Object.defineProperty(floatUi, "state", { value: true, writable: true }); - }); - - afterEach(() => { - component.hidePopover(); + fixture.detectChanges(); + component.onOverlayAttach(); }); it("should close popover and focus trigger on Escape key", () => { - const container = document.querySelector( - ".float-ui-container-popover", + const container = overlayContainerElement.querySelector( + ".tedi-popover__container", ) as HTMLElement; const trigger = component.popoverTrigger().host.nativeElement; const focusSpy = jest.spyOn(trigger, "focus"); @@ -361,8 +338,8 @@ describe("PopoverComponent", () => { }); it("should handle Tab key when at last focusable element", () => { - const container = document.querySelector( - ".float-ui-container-popover", + const container = overlayContainerElement.querySelector( + ".tedi-popover__container", ) as HTMLElement; if (container) { @@ -383,8 +360,8 @@ describe("PopoverComponent", () => { }); it("should handle Shift+Tab when at first focusable element", () => { - const container = document.querySelector( - ".float-ui-container-popover", + const container = overlayContainerElement.querySelector( + ".tedi-popover__container", ) as HTMLElement; if (container) { @@ -414,10 +391,9 @@ describe("PopoverComponent", () => { hostComponent.dismissible = false; fixture.detectChanges(); - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: false, writable: true }); component.showPopover(); - Object.defineProperty(floatUi, "state", { value: true, writable: true }); + fixture.detectChanges(); + component.onOverlayAttach(); const hideSpy = jest.spyOn(component, "hidePopover"); @@ -434,14 +410,9 @@ describe("PopoverComponent", () => { hostComponent.hideOnScroll = false; fixture.detectChanges(); - const floatUi = component.floatUiComponent(); - Object.defineProperty(floatUi, "state", { value: false, writable: true }); component.showPopover(); - Object.defineProperty(floatUi, "state", { value: true, writable: true }); - }); - - afterEach(() => { - component.hidePopover(); + fixture.detectChanges(); + component.onOverlayAttach(); }); it("should close popover on mousedown outside", () => { @@ -469,8 +440,8 @@ describe("PopoverComponent", () => { it("should NOT close popover on mousedown inside container", () => { const hideSpy = jest.spyOn(component, "hidePopover"); - const container = document.querySelector( - ".float-ui-container-popover", + const container = overlayContainerElement.querySelector( + ".tedi-popover__container", ) as HTMLElement; const event = new MouseEvent("mousedown", { bubbles: true }); diff --git a/tedi/components/overlay/popover/popover.component.ts b/tedi/components/overlay/popover/popover.component.ts index 21eb8afbb..7e0367d2c 100644 --- a/tedi/components/overlay/popover/popover.component.ts +++ b/tedi/components/overlay/popover/popover.component.ts @@ -5,74 +5,77 @@ import { ChangeDetectionStrategy, input, inject, + DestroyRef, Renderer2, computed, viewChild, signal, contentChild, - AfterContentChecked, } from "@angular/core"; import { - NgxFloatUiContentComponent, - NgxFloatUiModule, - NgxFloatUiPlacements, -} from "ngx-float-ui"; + OverlayModule, + CdkConnectedOverlay, + ConnectedOverlayPositionChange, +} from "@angular/cdk/overlay"; +import { + OverlayPosition, + toConnectedPositions, + getPlacementFromPositionChange, + calculateArrowOffset, + HorizontalPushHandler, +} from "../overlay-position.util"; import { PopoverTriggerDirective } from "./popover-trigger/popover-trigger.directive"; import { getFocusableElements } from "../../../utils/elements.util"; import { PopoverContentComponent } from "./popover-content/popover-content.component"; -export type PopoverPosition = `${NgxFloatUiPlacements}`; +export type PopoverPosition = OverlayPosition; + +let popoverIdCounter = 0; @Component({ standalone: true, selector: "tedi-popover", - imports: [NgxFloatUiModule], + imports: [OverlayModule], templateUrl: "./popover.component.html", styleUrl: "./popover.component.scss", encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class PopoverComponent implements AfterContentChecked { +export class PopoverComponent { /** * The position of the popover relative to the trigger element. * @default top */ - position = input("top"); + readonly position = input("top"); /** * Should position flip to opposite direction when overflowing screen? * @default false */ - preventOverflow = input(false); + readonly preventOverflow = input(false); /** * Is dismissible by clicking outside of content? * @default true */ - dismissible = input(true); + readonly dismissible = input(true); /** * Does popover content hide on scroll? * @default false */ - hideOnScroll = input(false); + readonly hideOnScroll = input(false); /** * Does popover have illustrative border on the arrow side? * @default false */ - withBorder = input(false); + readonly withBorder = input(false); /** * Should show arrow? */ - withArrow = input(true); + readonly withArrow = input(true); /** * Lock scrolling on rest of the page? * @default false */ - lockScroll = input(false); - /** - * Append floating element to given selector. - * Use 'body' to append at the end of DOM or empty string to append next to trigger element. - * @default "body" - */ - readonly appendTo = input("body"); + readonly lockScroll = input(false); /** Delay time (in ms) for closing popover when not hovering trigger or content. * @default 100 */ @@ -81,14 +84,44 @@ export class PopoverComponent implements AfterContentChecked { private readonly document = inject(DOCUMENT); private readonly renderer = inject(Renderer2); - readonly floatUiComponent = viewChild.required(NgxFloatUiContentComponent); + private readonly connectedOverlay = viewChild(CdkConnectedOverlay); readonly popoverTrigger = contentChild.required(PopoverTriggerDirective); private readonly popoverContent = contentChild.required( PopoverContentComponent, ); - readonly containerId = signal(""); + readonly isOpen = signal(false); + readonly currentPlacement = signal("top"); + readonly containerId = signal(`tedi-popover-${popoverIdCounter++}`); readonly isContentHovered = signal(false); + readonly arrowLeft = signal(null); + readonly arrowTop = signal(null); + + readonly overlayOrigin = computed(() => this.popoverTrigger().overlayOrigin); + private readonly arrowOffset = computed(() => + this.withArrow() ? (this.withBorder() ? 9 : 12) : 0, + ); + + readonly overlayPositions = computed(() => + toConnectedPositions( + this.position(), + this.preventOverflow(), + this.arrowOffset(), + ), + ); + + readonly panelClasses = computed(() => { + const classList = ["tedi-popover__container"]; + if (this.withBorder()) classList.push("tedi-popover__container--border"); + if (this.withArrow()) classList.push("tedi-popover__container--arrow"); + return classList.join(" "); + }); + + readonly ariaLabelledBy = computed(() => { + return this.popoverContent().title() + ? this.popoverContent().titleId + : this.containerId() + "_trigger"; + }); hideTimeout?: ReturnType; private keydownListener?: () => void; @@ -96,44 +129,47 @@ export class PopoverComponent implements AfterContentChecked { private focusinListener?: () => void; private mousedownListener?: () => void; - ngAfterContentChecked() { - const floatUiEl = this.floatUiComponent().elRef - .nativeElement as HTMLElement; - const container = floatUiEl.querySelector( - ".float-ui-container-popover", - ); + private readonly horizontalPush = new HorizontalPushHandler( + () => this.connectedOverlay()?.overlayRef?.overlayElement, + () => this.updateArrowPosition(), + ); - if (container) { - const labelledBy = this.popoverContent().title() - ? this.popoverContent().titleId - : container.id + "_trigger"; - container.setAttribute("tabindex", "-1"); - container.setAttribute("aria-labelledby", labelledBy); - this.containerId.set(container.id); - } + constructor() { + inject(DestroyRef).onDestroy(() => { + clearTimeout(this.hideTimeout); + this.horizontalPush.detach(); + this.cleanupKeyboardNavigation(); + this.cleanupScrollListener(); + this.cleanupDismissListeners(); + }); } showPopover() { - if (this.floatUiComponent().state) return; + if (this.isOpen()) return; clearTimeout(this.hideTimeout); - this.floatUiComponent().show(); - - const floatUiEl = this.floatUiComponent().elRef - .nativeElement as HTMLElement; - const container = floatUiEl.querySelector( - ".float-ui-container-popover", - ); + this.isOpen.set(true); if (this.lockScroll()) { this.renderer.setStyle(this.document.body, "overflow", "hidden"); } + } + + onOverlayAttach() { + const overlayEl = this.connectedOverlay()?.overlayRef?.overlayElement; + if (!overlayEl) return; + const container = overlayEl.querySelector( + ".tedi-popover__container", + ) as HTMLElement; if (container) { setTimeout(() => container.focus({ preventScroll: true })); this.setupKeyboardNavigation(container); } + this.horizontalPush.attach(); + this.updateArrowPosition(); + if (this.hideOnScroll()) { this.setupScrollListener(); } @@ -143,13 +179,20 @@ export class PopoverComponent implements AfterContentChecked { } } + onPositionChange(change: ConnectedOverlayPositionChange) { + this.currentPlacement.set(getPlacementFromPositionChange(change)); + this.horizontalPush.apply(); + this.updateArrowPosition(); + } + hidePopover(focusTrigger?: boolean) { - if (!this.floatUiComponent().state) return; + if (!this.isOpen()) return; this.cleanupKeyboardNavigation(); this.cleanupScrollListener(); + this.horizontalPush.detach(); this.cleanupDismissListeners(); - this.floatUiComponent().hide(); + this.isOpen.set(false); if (this.lockScroll()) { this.renderer.removeStyle(this.document.body, "overflow"); @@ -161,26 +204,29 @@ export class PopoverComponent implements AfterContentChecked { } togglePopover() { - if (this.floatUiComponent().state) { + if (this.isOpen()) { this.hidePopover(true); } else { this.showPopover(); } } - readonly floatUiContainerClass = computed(() => { - const classList = ["float-ui-container-popover"]; - - if (this.withBorder()) { - classList.push("float-ui-container-popover--border"); - } - - if (this.withArrow()) { - classList.push("float-ui-container-popover--arrow"); - } - - return classList.join(","); - }); + private updateArrowPosition() { + if (!this.withArrow()) return; + const overlayEl = this.connectedOverlay()?.overlayRef?.overlayElement; + const triggerEl = this.popoverTrigger().host.nativeElement; + if (!overlayEl || !triggerEl) return; + + const arrowSize = this.withBorder() ? 18 : 24; + const offset = calculateArrowOffset( + this.currentPlacement(), + triggerEl, + overlayEl, + arrowSize, + ); + this.arrowLeft.set(offset.left); + this.arrowTop.set(offset.top); + } private setupKeyboardNavigation(container: HTMLElement) { this.cleanupKeyboardNavigation(); @@ -189,7 +235,7 @@ export class PopoverComponent implements AfterContentChecked { container, "keydown", (e: KeyboardEvent) => { - if (e.key === "Escape" && this.floatUiComponent().state) { + if (e.key === "Escape" && this.isOpen()) { e.preventDefault(); this.hidePopover(true); } @@ -233,7 +279,7 @@ export class PopoverComponent implements AfterContentChecked { this.document, "scroll", () => { - if (this.floatUiComponent().state) { + if (this.isOpen()) { this.hidePopover(false); } }, @@ -304,11 +350,11 @@ export class PopoverComponent implements AfterContentChecked { private handleClosePopoverEvent(e: Event) { const triggerEl = this.popoverTrigger().host.nativeElement; - const containerEl = this.floatUiComponent().elRef - .nativeElement as HTMLElement; + const containerEl = + this.connectedOverlay()?.overlayRef?.overlayElement as HTMLElement; const target = e.target as HTMLElement | null; - if (!target || triggerEl.contains(target) || containerEl.contains(target)) { + if (!target || triggerEl.contains(target) || containerEl?.contains(target)) { return; } diff --git a/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts b/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts index 07beaa4cb..8ef23287c 100644 --- a/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts +++ b/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts @@ -10,6 +10,7 @@ import { signal, ViewEncapsulation, } from "@angular/core"; +import { CdkOverlayOrigin } from "@angular/cdk/overlay"; import { TooltipComponent } from "../tooltip.component"; @Component({ @@ -18,10 +19,12 @@ import { TooltipComponent } from "../tooltip.component"; template: "", styleUrl: "../tooltip.component.scss", encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, + hostDirectives: [CdkOverlayOrigin], }) export class TooltipTriggerComponent implements AfterContentChecked { readonly host = inject>(ElementRef); + readonly overlayOrigin = inject(CdkOverlayOrigin, { self: true }); private renderer = inject(Renderer2); readonly tooltip = inject(TooltipComponent); private interactiveElement = signal(null); diff --git a/tedi/components/overlay/tooltip/tooltip.component.html b/tedi/components/overlay/tooltip/tooltip.component.html index 2723efa73..4469b0bfb 100644 --- a/tedi/components/overlay/tooltip/tooltip.component.html +++ b/tedi/components/overlay/tooltip/tooltip.component.html @@ -1,21 +1,21 @@ -
- -
+ @if (contentText()) { {{ contentText() }} } - + + + diff --git a/tedi/components/overlay/tooltip/tooltip.component.scss b/tedi/components/overlay/tooltip/tooltip.component.scss index 4d138d827..9768119a1 100644 --- a/tedi/components/overlay/tooltip/tooltip.component.scss +++ b/tedi/components/overlay/tooltip/tooltip.component.scss @@ -9,52 +9,47 @@ tedi-tooltip { display: contents; } -float-ui-content { - .float-ui-container-tooltip { - z-index: var(--z-index-tooltip); - width: max-content; - padding: 0; - border: 0; - border-radius: var(--popover-radius-rounded); - box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%)); - - .float-ui-arrow { - width: 8px; - height: 8px; - background: var(--tooltip-background); - clip-path: inset(0 -5px -5px 0); - } - - &[data-float-ui-placement="top"] { - transform: translateY(-4px); +.tedi-tooltip__container { + position: relative; + z-index: var(--z-index-tooltip); + width: max-content; + max-width: calc(100dvw - 1rem); + border-radius: var(--popover-radius-rounded); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); + + .tedi-tooltip__arrow { + position: absolute; + width: 8px; + height: 8px; + background: var(--tooltip-background); + clip-path: inset(0 -5px -5px 0); + } - .float-ui-arrow { - transform: rotate(-135deg); - } + &[data-placement="top"] { + .tedi-tooltip__arrow { + bottom: 0; + transform: translateX(-50%) translateY(50%) rotate(45deg); } + } - &[data-float-ui-placement="bottom"] { - transform: translateY(4px); - - .float-ui-arrow { - transform: rotate(-135deg); - } + &[data-placement="bottom"] { + .tedi-tooltip__arrow { + top: 0; + transform: translateX(-50%) translateY(-50%) rotate(-135deg); } + } - &[data-float-ui-placement="left"] { - transform: translateX(-4px); - - .float-ui-arrow { - transform: rotate(-45deg); - } + &[data-placement="left"] { + .tedi-tooltip__arrow { + right: 0; + transform: translateY(-50%) translateX(50%) rotate(-45deg); } + } - &[data-float-ui-placement="right"] { - transform: translateX(4px); - - .float-ui-arrow { - transform: rotate(135deg); - } + &[data-placement="right"] { + .tedi-tooltip__arrow { + left: 0; + transform: translateY(-50%) translateX(-50%) rotate(135deg); } } } @@ -83,7 +78,7 @@ tedi-tooltip-trigger { color: var(--tooltip-text); background: var(--tooltip-background); border-radius: var(--tooltip-radius); - box-shadow: 0 1px 5px 0 var(--tedi-alpha-20, rgb(0 0 0 / 20%)); + box-shadow: 0 1px 5px 0 var(--tedi-alpha-20); @each $name, $width in $tooltip-max-width { &--#{$name} { diff --git a/tedi/components/overlay/tooltip/tooltip.component.spec.ts b/tedi/components/overlay/tooltip/tooltip.component.spec.ts index f3b5584c1..505ab4c0c 100644 --- a/tedi/components/overlay/tooltip/tooltip.component.spec.ts +++ b/tedi/components/overlay/tooltip/tooltip.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { TooltipComponent, TooltipPosition } from "./tooltip.component"; -import { NgxFloatUiContentComponent } from "ngx-float-ui"; import { Component, input, viewChild } from "@angular/core"; +import { OverlayContainer } from "@angular/cdk/overlay"; import { TooltipTriggerComponent } from "./tooltip-trigger/tooltip-trigger.component"; import { TooltipContentComponent } from "./tooltip-content/tooltip-content.component"; @@ -12,7 +12,6 @@ import { TooltipContentComponent } from "./tooltip-content/tooltip-content.compo Trigger @@ -23,7 +22,6 @@ import { TooltipContentComponent } from "./tooltip-content/tooltip-content.compo class TestTooltipComponent { position = input("top"); preventOverflow = input(true); - appendTo = input("body"); timeoutDelay = input(100); tooltip = viewChild.required(TooltipComponent); @@ -32,6 +30,7 @@ class TestTooltipComponent { describe("TooltipComponent", () => { let fixture: ComponentFixture; let component: TestTooltipComponent; + let overlayContainer: OverlayContainer; beforeEach(() => { TestBed.configureTestingModule({ @@ -40,9 +39,14 @@ describe("TooltipComponent", () => { fixture = TestBed.createComponent(TestTooltipComponent); component = fixture.componentInstance; + overlayContainer = TestBed.inject(OverlayContainer); fixture.detectChanges(); }); + afterEach(() => { + overlayContainer.ngOnDestroy(); + }); + it("should create component", () => { expect(component).toBeTruthy(); }); @@ -50,14 +54,11 @@ describe("TooltipComponent", () => { it("should have default input values", () => { expect(component.position()).toBe("top"); expect(component.preventOverflow()).toBe(true); - expect(component.appendTo()).toBe("body"); expect(component.timeoutDelay()).toBe(100); }); - it("should initialize the viewChild floatUiComponent", () => { - expect(component.tooltip().floatUiComponent()).toBeInstanceOf( - NgxFloatUiContentComponent, - ); + it("should have isOpen initially false", () => { + expect(component.tooltip().isOpen()).toBe(false); }); it("should clear hide timeout when showing tooltip", () => { @@ -90,12 +91,6 @@ describe("TooltipComponent", () => { } }); - it("should update appendTo when input changes", () => { - fixture.componentRef.setInput("appendTo", "custom-container"); - fixture.detectChanges(); - expect(component.appendTo()).toBe("custom-container"); - }); - it("should update preventOverflow when input changes", () => { fixture.componentRef.setInput("preventOverflow", false); fixture.detectChanges(); @@ -109,53 +104,46 @@ describe("TooltipComponent", () => { }); it("should show tooltip when not visible", () => { - const floatUi = component.tooltip().floatUiComponent(); - floatUi.state = false; - const showSpy = jest.spyOn(floatUi, "show"); - const clearSpy = jest.spyOn(global, "clearTimeout"); + const tooltip = component.tooltip(); + expect(tooltip.isOpen()).toBe(false); - component.tooltip().showTooltip(); + const clearSpy = jest.spyOn(global, "clearTimeout"); + tooltip.showTooltip(); - expect(clearSpy).toHaveBeenCalledWith(component.tooltip().hideTimeout); - expect(showSpy).toHaveBeenCalled(); - expect(component.tooltip().floatUiDisplay()).toBe("block"); + expect(clearSpy).toHaveBeenCalledWith(tooltip.hideTimeout); + expect(tooltip.isOpen()).toBe(true); }); - it("should not call showTooltip again if already visible", () => { - const floatUi = component.tooltip().floatUiComponent(); - floatUi.state = true; - const showSpy = jest.spyOn(floatUi, "show"); + it("should not show tooltip again if already visible", () => { + const tooltip = component.tooltip(); + tooltip.isOpen.set(true); - component.tooltip().showTooltip(); + tooltip.showTooltip(); - expect(showSpy).not.toHaveBeenCalled(); + expect(tooltip.isOpen()).toBe(true); }); it("should hide tooltip when visible", () => { - const floatUi = component.tooltip().floatUiComponent(); - floatUi.state = true; - const hideSpy = jest.spyOn(floatUi, "hide"); + const tooltip = component.tooltip(); + tooltip.isOpen.set(true); - component.tooltip().hideTooltip(); + tooltip.hideTooltip(); - expect(hideSpy).toHaveBeenCalled(); - expect(component.tooltip().floatUiDisplay()).toBe("inline"); + expect(tooltip.isOpen()).toBe(false); }); it("should not hide tooltip if not visible", () => { - const floatUi = component.tooltip().floatUiComponent(); - floatUi.state = false; - const hideSpy = jest.spyOn(floatUi, "hide"); + const tooltip = component.tooltip(); + expect(tooltip.isOpen()).toBe(false); - component.tooltip().hideTooltip(); + tooltip.hideTooltip(); - expect(hideSpy).not.toHaveBeenCalled(); + expect(tooltip.isOpen()).toBe(false); }); it("should call hideTooltip when tooltip is visible", () => { const tooltip = component.tooltip(); - const floatUi = tooltip.floatUiComponent(); - floatUi.state = true; + tooltip.isOpen.set(true); const hideSpy = jest.spyOn(tooltip, "hideTooltip"); const showSpy = jest.spyOn(tooltip, "showTooltip"); @@ -168,8 +156,7 @@ describe("TooltipComponent", () => { it("should call showTooltip when tooltip is hidden", () => { const tooltip = component.tooltip(); - const floatUi = tooltip.floatUiComponent(); - floatUi.state = false; + expect(tooltip.isOpen()).toBe(false); const hideSpy = jest.spyOn(tooltip, "hideTooltip"); const showSpy = jest.spyOn(tooltip, "showTooltip"); diff --git a/tedi/components/overlay/tooltip/tooltip.component.ts b/tedi/components/overlay/tooltip/tooltip.component.ts index 33a13e890..47863e397 100644 --- a/tedi/components/overlay/tooltip/tooltip.component.ts +++ b/tedi/components/overlay/tooltip/tooltip.component.ts @@ -1,23 +1,33 @@ import { AfterContentChecked, Component, + computed, + contentChild, + DestroyRef, + ElementRef, + inject, input, + signal, + viewChild, ViewEncapsulation, ChangeDetectionStrategy, - viewChild, - contentChild, - signal, - ElementRef, } from "@angular/core"; import { - NgxFloatUiContentComponent, - NgxFloatUiModule, - NgxFloatUiPlacements, -} from "ngx-float-ui"; + OverlayModule, + CdkConnectedOverlay, + ConnectedOverlayPositionChange, +} from "@angular/cdk/overlay"; +import { + OverlayPosition, + toConnectedPositions, + getPlacementFromPositionChange, + calculateArrowOffset, + HorizontalPushHandler, +} from "../overlay-position.util"; import { TooltipTriggerComponent } from "./tooltip-trigger/tooltip-trigger.component"; import { TooltipContentComponent } from "./tooltip-content/tooltip-content.component"; -export type TooltipPosition = `${NgxFloatUiPlacements}`; +export type TooltipPosition = OverlayPosition; export type TooltipOpenWith = "hover" | "click" | "both"; let tooltipIdCounter = 0; @@ -25,7 +35,7 @@ let tooltipIdCounter = 0; @Component({ standalone: true, selector: "tedi-tooltip", - imports: [NgxFloatUiModule], + imports: [OverlayModule], templateUrl: "./tooltip.component.html", styleUrl: "./tooltip.component.scss", encapsulation: ViewEncapsulation.None, @@ -50,13 +60,6 @@ export class TooltipComponent implements AfterContentChecked { */ readonly openWith = input("both"); - /** - * Append floating element to given selector. - * Use 'body' to append at the end of DOM or empty string to append next to trigger element. - * @default body - */ - readonly appendTo = input("body"); - /** Delay time (in ms) for closing tooltip when not hovering trigger or content. * @default 100 */ @@ -70,41 +73,93 @@ export class TooltipComponent implements AfterContentChecked { read: ElementRef, }); + private readonly connectedOverlay = viewChild(CdkConnectedOverlay); + readonly descriptionId = `tedi-tooltip-${++tooltipIdCounter}`; readonly contentText = signal(""); readonly isOpen = signal(false); + readonly currentPlacement = signal("top"); + readonly arrowLeft = signal(null); + readonly arrowTop = signal(null); + + readonly overlayPositions = computed(() => + toConnectedPositions(this.position(), this.preventOverflow(), 4), + ); + + readonly overlayOrigin = computed( + () => this.tooltipTrigger().overlayOrigin, + ); - isContentHovered = signal(false); - floatUiDisplay = signal<"inline" | "block">("inline"); - floatUiComponent = viewChild.required(NgxFloatUiContentComponent); + readonly isContentHovered = signal(false); hideTimeout?: ReturnType; - showTooltip() { - if (!this.floatUiComponent().state) { + private readonly horizontalPush = new HorizontalPushHandler( + () => this.connectedOverlay()?.overlayRef?.overlayElement, + () => this.updateArrowPosition(), + ); + + constructor() { + inject(DestroyRef).onDestroy(() => { clearTimeout(this.hideTimeout); - this.floatUiComponent().show(); - this.floatUiDisplay.set("block"); + this.horizontalPush.detach(); + }); + } + + showTooltip() { + clearTimeout(this.hideTimeout); + if (!this.isOpen()) { this.isOpen.set(true); } } hideTooltip() { - if (this.floatUiComponent().state) { - this.floatUiComponent().hide(); - this.floatUiDisplay.set("inline"); + if (this.isOpen()) { + clearTimeout(this.hideTimeout); this.isOpen.set(false); + this.horizontalPush.detach(); } } toggleTooltip() { - if (this.floatUiComponent().state) { + if (this.isOpen()) { this.hideTooltip(); } else { this.showTooltip(); } } + onPositionChange(change: ConnectedOverlayPositionChange) { + this.currentPlacement.set(getPlacementFromPositionChange(change)); + this.horizontalPush.apply(); + this.updateArrowPosition(); + } + + onOverlayAttach() { + this.syncContentText(); + this.horizontalPush.attach(); + this.updateArrowPosition(); + } + + private updateArrowPosition() { + const overlayEl = this.connectedOverlay()?.overlayRef?.overlayElement; + const triggerEl = this.tooltipTrigger().host?.nativeElement; + if (!overlayEl || !triggerEl) return; + + const offset = calculateArrowOffset( + this.currentPlacement(), + triggerEl, + overlayEl, + 8, + ); + this.arrowLeft.set(offset.left); + this.arrowTop.set(offset.top); + } + ngAfterContentChecked(): void { + this.syncContentText(); + } + + private syncContentText(): void { const contentEl = this.tooltipContent()?.nativeElement as HTMLElement; if (contentEl) { const text = contentEl.textContent?.trim() ?? ""; diff --git a/tedi/components/overlay/tooltip/tooltip.stories.ts b/tedi/components/overlay/tooltip/tooltip.stories.ts index 4cf7c55e9..65a9f1b2d 100644 --- a/tedi/components/overlay/tooltip/tooltip.stories.ts +++ b/tedi/components/overlay/tooltip/tooltip.stories.ts @@ -101,20 +101,6 @@ export default { }, }, }, - appendTo: { - control: "text", - description: - "Append floating element to given selector. Use 'body' to append at the end of DOM or empty string to append next to trigger element.", - table: { - category: "tooltip", - type: { - summary: "string", - }, - defaultValue: { - summary: "body", - }, - }, - }, timeoutDelay: { control: "number", description: @@ -157,7 +143,6 @@ export const Default: Story = { args: { position: "top", preventOverflow: true, - appendTo: "body", timeoutDelay: 100, maxWidth: "medium", openWith: "both", @@ -165,7 +150,7 @@ export const Default: Story = { render: (args) => ({ props: args, template: ` - +