Skip to content

Commit bc8983c

Browse files
committed
fix(dropdown): add anchor positioning fallback
1 parent 116903a commit bc8983c

File tree

8 files changed

+165
-64
lines changed

8 files changed

+165
-64
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "fix(dropdown): add anchor positioning fallback logic",
4+
"packageName": "@fluentui/web-components",
5+
"email": "863023+radium-v@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}

packages/web-components/docs/web-components.api.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,8 +590,6 @@ export class BaseDropdown extends FASTElement {
590590
changeHandler(e: Event): boolean | void;
591591
checkValidity(): boolean;
592592
clickHandler(e: PointerEvent): boolean | void;
593-
// (undocumented)
594-
connectedCallback(): void;
595593
// @internal
596594
control: HTMLInputElement;
597595
// @internal

packages/web-components/src/dropdown/dropdown.base.ts

Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import type { Listbox } from '../listbox/listbox.js';
33
import { isListbox } from '../listbox/listbox.options.js';
44
import type { DropdownOption } from '../option/option.js';
55
import { isDropdownOption } from '../option/option.options.js';
6+
import { getDirection } from '../utils/direction.js';
67
import { toggleState } from '../utils/element-internals.js';
78
import { getLanguage } from '../utils/language.js';
9+
import { AnchorPositioningCSSSupported } from '../utils/support.js';
810
import { uniqueId } from '../utils/unique-id.js';
911
import { DropdownType } from './dropdown.options.js';
1012
import { dropdownButtonTemplate, dropdownInputTemplate } from './dropdown.template.js';
@@ -239,6 +241,14 @@ export class BaseDropdown extends FASTElement {
239241

240242
this.setValidity();
241243
});
244+
245+
if (AnchorPositioningCSSSupported) {
246+
// The `anchor-name` property seems to not be isolated between instances in Safari Technology Preview 220 (18.4).
247+
// It's unclear if the spec requires the `anchor-name` to be unique when styled on the `:host`.
248+
const anchorName = uniqueId('--dropdown-anchor-');
249+
this.style.setProperty('anchor-name', anchorName);
250+
this.listbox.style.setProperty('position-anchor', anchorName);
251+
}
242252
}
243253
}
244254

@@ -320,12 +330,9 @@ export class BaseDropdown extends FASTElement {
320330
this.elementInternals.ariaExpanded = next ? 'true' : 'false';
321331
this.activeIndex = this.selectedIndex ?? -1;
322332

323-
if (next) {
324-
BaseDropdown.AnchorPositionFallbackObserver?.observe(this.listbox);
325-
return;
333+
if (!AnchorPositioningCSSSupported) {
334+
this.anchorPositionFallback(next);
326335
}
327-
328-
BaseDropdown.AnchorPositionFallbackObserver?.unobserve(this.listbox);
329336
}
330337

331338
/**
@@ -386,6 +393,18 @@ export class BaseDropdown extends FASTElement {
386393
*/
387394
public controlSlot!: HTMLSlotElement;
388395

396+
/**
397+
* Event handler for scroll and resize events. Used when the browser does not support CSS anchor positioning.
398+
*
399+
* @internal
400+
*/
401+
private debouncedReposition = () => {
402+
if (this.frameId) {
403+
cancelAnimationFrame(this.frameId);
404+
}
405+
this.frameId = requestAnimationFrame(this.repositionListbox);
406+
};
407+
389408
/**
390409
* The internal {@link https://developer.mozilla.org/docs/Web/API/ElementInternals | `ElementInternals`} instance for the component.
391410
*
@@ -410,27 +429,11 @@ export class BaseDropdown extends FASTElement {
410429
public static formAssociated = true;
411430

412431
/**
413-
* Resets the form value to its initial value when the form is reset.
432+
* The ID of the frame used for repositioning the listbox when the browser does not support CSS anchor positioning.
414433
*
415434
* @internal
416435
*/
417-
formResetCallback(): void {
418-
this.enabledOptions.forEach((x, i) => {
419-
if (this.multiple) {
420-
x.selected = !!x.defaultSelected;
421-
return;
422-
}
423-
424-
if (!x.defaultSelected) {
425-
x.selected = false;
426-
return;
427-
}
428-
429-
this.selectOption(i);
430-
});
431-
432-
this.setValidity();
433-
}
436+
private frameId?: number;
434437

435438
/**
436439
* A reference to the first freeform option, if present.
@@ -482,6 +485,31 @@ export class BaseDropdown extends FASTElement {
482485
return this.listbox?.options ?? [];
483486
}
484487

488+
/**
489+
* Repositions the listbox to align with the control element. Used when the browser does not support CSS anchor positioning.
490+
*
491+
* @internal
492+
*/
493+
private repositionListbox = () => {
494+
const controlRect = this.getBoundingClientRect();
495+
const right = window.innerWidth - controlRect.right;
496+
const left = controlRect.left;
497+
498+
this.listbox.style.minWidth = `${controlRect.width}px`;
499+
this.listbox.style.top = `${controlRect.top}px`;
500+
501+
if (
502+
left + controlRect.width > window.innerWidth ||
503+
(getDirection(this) === 'rtl' && right - controlRect.width > 0)
504+
) {
505+
this.listbox.style.right = `${right}px`;
506+
this.listbox.style.left = 'unset';
507+
} else {
508+
this.listbox.style.left = `${left}px`;
509+
this.listbox.style.right = 'unset';
510+
}
511+
};
512+
485513
/**
486514
* The index of the first selected option, scoped to the enabled options.
487515
*
@@ -716,6 +744,29 @@ export class BaseDropdown extends FASTElement {
716744
return true;
717745
}
718746

747+
/**
748+
* Resets the form value to its initial value when the form is reset.
749+
*
750+
* @internal
751+
*/
752+
formResetCallback(): void {
753+
this.enabledOptions.forEach((x, i) => {
754+
if (this.multiple) {
755+
x.selected = !!x.defaultSelected;
756+
return;
757+
}
758+
759+
if (!x.defaultSelected) {
760+
x.selected = false;
761+
return;
762+
}
763+
764+
this.selectOption(i);
765+
});
766+
767+
this.setValidity();
768+
}
769+
719770
/**
720771
* Ensures the active index is within bounds of the enabled options. Out-of-bounds indices are wrapped to the opposite
721772
* end of the range.
@@ -948,13 +999,11 @@ export class BaseDropdown extends FASTElement {
948999
this.freeformOption.hidden = false;
9491000
}
9501001

951-
connectedCallback(): void {
952-
super.connectedCallback();
953-
this.anchorPositionFallback();
954-
}
955-
9561002
disconnectedCallback(): void {
957-
BaseDropdown.AnchorPositionFallbackObserver?.unobserve(this.listbox);
1003+
BaseDropdown.AnchorPositionFallbackObserver?.disconnect();
1004+
1005+
window.removeEventListener('scroll', this.debouncedReposition, { capture: true });
1006+
window.removeEventListener('resize', this.debouncedReposition);
9581007

9591008
super.disconnectedCallback();
9601009
}
@@ -979,25 +1028,43 @@ export class BaseDropdown extends FASTElement {
9791028
*
9801029
* @internal
9811030
*/
982-
private anchorPositionFallback(): void {
983-
BaseDropdown.AnchorPositionFallbackObserver =
984-
BaseDropdown.AnchorPositionFallbackObserver ??
985-
new IntersectionObserver(
1031+
private anchorPositionFallback(shouldObserve?: boolean): void {
1032+
if (!BaseDropdown.AnchorPositionFallbackObserver) {
1033+
BaseDropdown.AnchorPositionFallbackObserver = new IntersectionObserver(
9861034
(entries: IntersectionObserverEntry[]): void => {
9871035
entries.forEach(({ boundingClientRect, isIntersecting, target }) => {
988-
if (isListbox(target) && !isIntersecting) {
1036+
if (isListbox(target)) {
9891037
if (boundingClientRect.bottom > window.innerHeight) {
990-
toggleState(target.dropdown!.elementInternals, 'flip-block', true);
1038+
toggleState(target.elementInternals, 'flip-block', true);
9911039
return;
9921040
}
9931041

9941042
if (boundingClientRect.top < 0) {
995-
toggleState(target.dropdown!.elementInternals, 'flip-block', false);
1043+
toggleState(target.elementInternals, 'flip-block', false);
9961044
}
9971045
}
9981046
});
9991047
},
10001048
{ threshold: 1 },
10011049
);
1050+
}
1051+
1052+
if (shouldObserve) {
1053+
BaseDropdown.AnchorPositionFallbackObserver.observe(this.listbox);
1054+
1055+
window.addEventListener('scroll', this.debouncedReposition, { passive: true, capture: true });
1056+
window.addEventListener('resize', this.debouncedReposition, { passive: true });
1057+
this.debouncedReposition();
1058+
return;
1059+
}
1060+
1061+
BaseDropdown.AnchorPositionFallbackObserver.unobserve(this.listbox);
1062+
1063+
window.removeEventListener('scroll', this.debouncedReposition, { capture: true });
1064+
window.removeEventListener('resize', this.debouncedReposition);
1065+
if (this.frameId) {
1066+
cancelAnimationFrame(this.frameId);
1067+
this.frameId = undefined;
1068+
}
10021069
}
10031070
}

packages/web-components/src/dropdown/dropdown.stories.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,3 +480,14 @@ export const Required: Story = {
480480
`,
481481
},
482482
};
483+
484+
export const OverflowScroll: Story = {
485+
render: renderComponent(html<StoryArgs<FluentDropdown>>`
486+
<div style="height: 300px; width: 50vw; overflow: scroll; outline: 1px solid black;">
487+
<div style="height: 400px;">Scroll down to see the dropdown ↓</div>
488+
${storyTemplate}
489+
<div style="height: 400px;"></div>
490+
</div>
491+
`),
492+
args: { ...Default.args },
493+
};

packages/web-components/src/dropdown/dropdown.styles.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -220,38 +220,22 @@ export const styles = css`
220220
color: ${colorNeutralForegroundDisabled};
221221
}
222222
223-
::slotted([popover]) {
224-
inset: unset;
225-
position: absolute;
226-
position-anchor: --dropdown-trigger;
227-
position-area: block-end span-inline-end;
228-
position-try-fallbacks: flip-inline, flip-block, block-start;
229-
max-height: var(--listbox-max-height, calc(50vh - anchor-size(self-block)));
230-
min-width: anchor-size(width);
231-
overflow: auto;
232-
}
233-
234223
::slotted([popover]:not(:popover-open)) {
235224
display: none;
236225
}
237226
238227
@supports not (anchor-name: --anchor) {
239-
::slotted([popover]) {
240-
margin-block-start: calc(${lineHeightBase300} + (${spacingVerticalSNudge} * 2) + ${strokeWidthThin});
241-
max-height: 50vh;
242-
}
243-
244-
:host([size='small']) ::slotted([popover]) {
245-
margin-block-start: calc(${lineHeightBase200} + (${spacingVerticalXS} * 2) + ${strokeWidthThin});
228+
:host {
229+
--listbox-max-height: 50vh;
230+
--margin-offset: calc(${lineHeightBase300} + (${spacingVerticalSNudge} * 2) + ${strokeWidthThin});
246231
}
247232
248-
:host([size='large']) ::slotted([popover]) {
249-
margin-block-start: calc(${lineHeightBase400} + (${spacingVerticalS} * 2) + ${strokeWidthThin});
233+
:host([size='small']) {
234+
--margin-offset: calc(${lineHeightBase200} + (${spacingVerticalXS} * 2) + ${strokeWidthThin});
250235
}
251236
252-
:host(${flipBlockState}) ::slotted([popover]) {
253-
margin-block-start: revert;
254-
transform: translate(0, -100%);
237+
:host([size='large']) {
238+
--margin-offset: calc(${lineHeightBase400} + (${spacingVerticalS} * 2) + ${strokeWidthThin});
255239
}
256240
}
257241
`;

packages/web-components/src/field/field.styles.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ export const styles = css`
5353
align-items: center;
5454
gap: 0 ${spacingHorizontalM};
5555
justify-items: start;
56-
position: relative;
5756
}
5857
5958
:has([slot='message']) {
@@ -101,8 +100,6 @@ export const styles = css`
101100
102101
::slotted([slot='input']) {
103102
grid-area: input;
104-
position: relative;
105-
z-index: 1;
106103
}
107104
108105
::slotted([slot='message']) {

packages/web-components/src/listbox/listbox.styles.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { css } from '@microsoft/fast-element';
2+
import { flipBlockState } from '../styles/states/index.js';
23
import {
34
borderRadiusMedium,
45
colorNeutralBackground1,
@@ -29,4 +30,39 @@ export const styles = css`
2930
row-gap: ${spacingHorizontalXXS};
3031
width: auto;
3132
}
33+
34+
:host([popover]) {
35+
inset: unset;
36+
overflow: auto;
37+
}
38+
39+
@supports (anchor-name: --anchor) {
40+
:host([popover]) {
41+
position: absolute;
42+
margin-block-start: 0;
43+
max-height: var(--listbox-max-height, calc(50vh - anchor-size(self-block)));
44+
min-width: anchor-size(width);
45+
position-anchor: --dropdown;
46+
position-area: block-end span-inline-end;
47+
position-try-fallbacks: flip-inline, flip-block, --flip-block, block-start;
48+
}
49+
50+
@position-try --flip-block {
51+
bottom: anchor(top);
52+
top: unset;
53+
}
54+
}
55+
56+
@supports not (anchor-name: --anchor) {
57+
:host([popover]) {
58+
margin-block-start: var(--margin-offset, 0);
59+
max-height: var(--listbox-max-height, 50vh);
60+
position: fixed;
61+
}
62+
63+
:host([popover]${flipBlockState}) {
64+
margin-block-start: revert;
65+
translate: 0 -100%;
66+
}
67+
}
3268
`;

packages/web-components/src/option/option.styles.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const styles = css`
5050
grid-template-columns: auto auto 1fr;
5151
min-height: 32px;
5252
padding: ${spacingHorizontalSNudge};
53+
text-align: start;
5354
}
5455
5556
.content {

0 commit comments

Comments
 (0)