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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ node_modules
# Build artifacts
/examples/static
/lib

test/__screenshots__
11 changes: 7 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@
},
"dependencies": {
"@babel/runtime": "^7.26.7",
"@popperjs/core": "^2.11.8",
"@floating-ui/dom": "^1.7.4",
"@restart/hooks": "^0.6.2",
"@types/warning": "^3.0.3",
"clsx": "^2.1.1",
"dequal": "^2.0.3",
"dom-helpers": "^6.0.1",
"uncontrollable": "^9.0.0",
Expand All @@ -113,6 +114,7 @@
"@eslint/js": "^9.20.0",
"@rollup/plugin-node-resolve": "^16.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.8",
Expand All @@ -121,8 +123,8 @@
"@typescript-eslint/eslint-plugin": "^8.23.0",
"@typescript-eslint/parser": "^8.23.0",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/browser": "^3.1.3",
"@vitest/coverage-istanbul": "3.1.3",
"@vitest/browser": "3.2.4",
"@vitest/coverage-istanbul": "3.2.4",
"babel-eslint": "^10.1.0",
"babel-preset-env-modules": "^1.0.1",
"cross-env": "^7.0.3",
Expand All @@ -134,6 +136,7 @@
"gh-pages": "^3.1.0",
"globals": "^15.14.0",
"hookem": "^3.0.4",
"jsdom": "^27.0.0",
"lint-staged": "^15.4.3",
"playwright": "^1.50.1",
"prettier": "^3.4.2",
Expand All @@ -144,7 +147,7 @@
"rollup": "^4.34.6",
"typescript": "^5.7.3",
"typescript-eslint": "^8.23.0",
"vitest": "^3.1.3"
"vitest": "3.2.4"
},
"bugs": {
"url": "https://github.com/react-restart/ui/issues"
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { useDropdownItem } from './DropdownItem.js';
export { default as Modal, type ModalProps } from './Modal.js';
export { default as Overlay, type OverlayProps } from './Overlay.js';
export { default as Portal, type PortalProps } from './Portal.js';
export { default as usePopper } from './usePopper.js';
export {
default as useRootClose,
type RootCloseOptions,
Expand Down
228 changes: 197 additions & 31 deletions src/popper.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,197 @@
// Since tsconfig is set for nodenext, these imports are resolving with a { default: ... } object, which
// messes up the types.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import arrow from '@popperjs/core/lib/modifiers/arrow.js';
import computeStyles from '@popperjs/core/lib/modifiers/computeStyles.js';
import eventListeners from '@popperjs/core/lib/modifiers/eventListeners.js';
import flip from '@popperjs/core/lib/modifiers/flip.js';
import hide from '@popperjs/core/lib/modifiers/hide.js';
import offset from '@popperjs/core/lib/modifiers/offset.js';
import popperOffsets from '@popperjs/core/lib/modifiers/popperOffsets.js';
import preventOverflow from '@popperjs/core/lib/modifiers/preventOverflow.js';
import { placements } from '@popperjs/core/lib/enums.js';
import { popperGenerator } from '@popperjs/core/lib/popper-base.js';

// For the common JS build we will turn this file into a bundle with no imports.
// This is b/c the Popper lib is all esm files, and would break in a common js only environment
export const createPopper = popperGenerator({
defaultModifiers: [
hide,
popperOffsets,
computeStyles,
eventListeners,
offset,
flip,
preventOverflow,
arrow,
],
});

export { placements };
import {
computePosition,
offset,
flip,
shift,
arrow,
hide,
autoUpdate,
Placement,
Middleware,
OffsetOptions,
Strategy,
limitShift,
MiddlewareState,
AutoUpdateOptions,
autoPlacement,
} from '@floating-ui/dom';

// Mimic Popper’s placements enum
export const placements = {
top: 'top',
bottom: 'bottom',
right: 'right',
left: 'left',
'top-start': 'top-start',
'top-end': 'top-end',
'bottom-start': 'bottom-start',
'bottom-end': 'bottom-end',
'right-start': 'right-start',
'right-end': 'right-end',
'left-start': 'left-start',
'left-end': 'left-end',
};

export interface Modifier<Name, Options extends { [key: string]: any }> {
name: Name;
enabled?: boolean;
fn: (arg0: MiddlewareState) => ReturnType<Middleware['fn']>;
options?: Partial<Options>;
}

export type PopperModifierLikeMiddleware = Middleware & {
enabled?: boolean;
options?: Modifier<any, any>['options'];
};

export function createPopper(
reference: HTMLElement,
popper: HTMLElement,
options?: {
placement?: Placement;
modifiers?: PopperModifierLikeMiddleware[];
strategy?: Strategy;
},
) {
const {
placement = 'bottom',
modifiers = [],
strategy = 'absolute',
} = options || {};

// Detect arrow element from modifiers if present
const arrowModifier = modifiers.find((m) => m.name === 'arrow');

// Default Popper-like values
const defaultOffset = 0;
const defaultArrowPadding = 5;

const offsetValue = () => {
let offsetValue: OffsetOptions = defaultOffset;
const o = modifiers.find((m) => m.name === 'offset');
if (o?.options?.offset) {
if (Array.isArray(o?.options?.offset)) {
if (o.options.offset.length == 2) {
offsetValue = {
crossAxis: o.options.offset[0],
mainAxis: o.options.offset[1],
};
} else {
offsetValue = { mainAxis: o.options.offset[0] };
}
}
}
return offsetValue;
};

const flipMiddleware = () => {
const flipModifier = modifiers.find((m) => m.name === 'flip');
if (flipModifier?.enabled !== false) {
return flip({
fallbackPlacements: flipModifier?.options?.fallbackPlacements,
//Rest of the options are not supported by Floating UI
});
}
return undefined;
};

const shiftMiddleware = () => {
const preventOverflowModifier = modifiers.find(
(m) => m.name === 'preventOverflow',
);
if (preventOverflowModifier?.enabled !== false) {
const {
mainAxis = true,
altAxis = false,
boundary,
rootBoundary,
padding = 0,
tether = true,
altBoundary = false,
} = preventOverflowModifier?.options || {};

return shift({
mainAxis,
crossAxis: altAxis,
boundary,
rootBoundary,
padding,
altBoundary,
limiter: tether ? limitShift() : undefined,
});
}
return undefined;
};

// Map Popper modifiers to Floating UI middleware
const middleware: Middleware[] = [
modifiers.find((m) => m.name === 'hide') ? hide() : undefined,
offset(offsetValue()),
flipMiddleware(),
shiftMiddleware(),
arrowModifier?.options?.element
? arrow({
element: arrowModifier?.options?.element,
padding: arrowModifier?.options?.padding ?? defaultArrowPadding,
})
: undefined,
...modifiers.filter(
(m) =>
![
'offset',
'flip',
'preventOverflow',
'arrow',
'hide',
'eventListeners',
].includes(m.name),
),
(placement as Placement & 'auto') === 'auto' ? autoPlacement() : undefined,
].filter(Boolean) as Middleware[];

const update = async (): Promise<void> => {
const { x, y, middlewareData } = await computePosition(reference, popper, {
placement,
middleware,
});

Object.assign(popper.style, {
left: `${x}px`,
top: `${y}px`,
position: strategy,
});

if (arrowModifier?.options?.element && middlewareData.arrow) {
const { x: arrowX, y: arrowY } = middlewareData.arrow as {
x: number | null;
y: number | null;
};
Object.assign(arrowModifier.options.element.style, {
left: arrowX != null ? `${arrowX}px` : '',
top: arrowY != null ? `${arrowY}px` : '',
});
}
};

let autoUpdateOptions: AutoUpdateOptions = {};
const eventListenersModifier = modifiers.find(
(m) => m.name === 'eventListeners',
);

if (eventListenersModifier?.enabled === false) {
autoUpdateOptions = {
ancestorScroll: false,
ancestorResize: false,
};
} else {
autoUpdateOptions = {
ancestorScroll: eventListenersModifier?.options?.scroll ?? true,
ancestorResize: eventListenersModifier?.options?.resize ?? true,
};
}

const cleanup = autoUpdate(reference, popper, update, autoUpdateOptions);
update();

return { update, cleanup };
}
Loading