@nepdatejs/dom 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -95,14 +95,22 @@ function hider(panel, topLayer) {
95
95
  function trackPosition(anchor, panel, options = {}) {
96
96
  if (!hasDom()) return noop$1;
97
97
  let hide = placePopover(anchor, panel, options);
98
+ let frame = null;
98
99
  const place = () => {
99
100
  hide = placePopover(anchor, panel, options);
100
101
  };
102
+ const placeOnNextFrame = () => {
103
+ frame ?? (frame = requestAnimationFrame(() => {
104
+ frame = null;
105
+ place();
106
+ }));
107
+ };
101
108
  window.addEventListener("resize", place);
102
- window.addEventListener("scroll", place, true);
109
+ window.addEventListener("scroll", placeOnNextFrame, true);
103
110
  return () => {
104
111
  window.removeEventListener("resize", place);
105
- window.removeEventListener("scroll", place, true);
112
+ window.removeEventListener("scroll", placeOnNextFrame, true);
113
+ if (frame !== null) cancelAnimationFrame(frame);
106
114
  hide();
107
115
  };
108
116
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["noop"],"sources":["../src/env.ts","../src/position.ts","../src/events.ts"],"sourcesContent":["/** True in a browser-like environment; false during server rendering. */\nexport function hasDom(): boolean {\n return typeof window !== 'undefined' && typeof document !== 'undefined';\n}\n\n/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */\nexport function supportsPopover(): boolean {\n return (\n hasDom() &&\n typeof HTMLElement !== 'undefined' &&\n typeof HTMLElement.prototype.showPopover === 'function'\n );\n}\n\n/** Whether the user asked the system for less motion. */\nexport function prefersReducedMotion(): boolean {\n return (\n hasDom() &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n );\n}\n","import { hasDom, supportsPopover } from './env';\n\nexport type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';\n\n/** 'popover' sits beside the anchor; 'sheet' is a bottom sheet; 'auto' picks the sheet on narrow screens. */\nexport type PanelLayout = 'auto' | 'popover' | 'sheet';\n\nexport interface PlaceOptions {\n /** Space between the anchor and the panel, and kept from the viewport edges. Default 6. */\n readonly gap?: number | undefined;\n /** Panel width in px; shrinks to fit narrow screens. Default 320. */\n readonly width?: number | undefined;\n /** Preferred side; flips when there is not enough room. Default 'bottom-start'. */\n readonly placement?: Placement | undefined;\n /** Default 'auto'. */\n readonly layout?: PanelLayout | undefined;\n /** 'auto' uses the sheet below this viewport width, in px. Default 480. */\n readonly sheetBelow?: number | undefined;\n}\n\n/** The layout to use for a viewport width. Pure. */\nexport function resolveLayout(\n layout: PanelLayout | undefined,\n viewportWidth: number,\n sheetBelow = 480,\n): 'popover' | 'sheet' {\n if (layout !== undefined && layout !== 'auto') {\n return layout;\n }\n\n return viewportWidth < sheetBelow ? 'sheet' : 'popover';\n}\n\nexport interface Rect {\n readonly top: number;\n readonly left: number;\n readonly bottom: number;\n readonly right: number;\n}\n\nexport interface Position {\n readonly top: number;\n readonly left: number;\n readonly width: number;\n /** Where the panel ended up after flipping. */\n readonly side: 'top' | 'bottom';\n}\n\n/**\n * Where to put a panel next to an anchor, in viewport (position: fixed)\n * coordinates. Pure, so it can be tested without a browser. Rules from the\n * institute-SaaS picker: prefer the requested side, flip when the panel does\n * not fit but does fit on the other side, and keep `gap` px from every edge\n * (the panel may overlap the anchor rather than leave the screen).\n */\nexport function computePosition(\n anchor: Rect,\n panelHeight: number,\n viewport: { readonly width: number; readonly height: number },\n options: PlaceOptions = {},\n): Position {\n const gap = options.gap ?? 6;\n const placement = options.placement ?? 'bottom-start';\n const width = Math.max(0, Math.min(options.width ?? 320, viewport.width - gap * 2));\n const roomBelow = viewport.height - anchor.bottom - gap * 2;\n const roomAbove = anchor.top - gap * 2;\n const wantsTop = placement.startsWith('top');\n const side: 'top' | 'bottom' = wantsTop\n ? roomAbove >= panelHeight || roomAbove >= roomBelow\n ? 'top'\n : 'bottom'\n : roomBelow >= panelHeight || roomBelow >= roomAbove\n ? 'bottom'\n : 'top';\n const rawTop = side === 'bottom' ? anchor.bottom + gap : anchor.top - gap - panelHeight;\n const rawLeft = placement.endsWith('end') ? anchor.right - width : anchor.left;\n\n return {\n // Keep the whole panel on screen; when it is taller than the room, its top edge wins.\n top: Math.max(gap, Math.min(rawTop, viewport.height - gap - panelHeight)),\n left: Math.max(gap, Math.min(rawLeft, viewport.width - width - gap)),\n width,\n side,\n };\n}\n\n/**\n * Show `panel` beside `anchor` once. Uses the browser's top layer when it can\n * (so dialogs and scrolling containers never clip the panel), otherwise\n * position: fixed. Returns a function that hides the panel again.\n */\nexport function placePopover(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const topLayer = supportsPopover();\n if (topLayer) {\n if (!panel.hasAttribute('popover')) {\n panel.setAttribute('popover', 'manual');\n }\n if (!panel.matches(':popover-open')) {\n panel.showPopover();\n }\n }\n const layout = resolveLayout(options.layout, window.innerWidth, options.sheetBelow);\n panel.dataset['layout'] = layout;\n if (layout === 'sheet') {\n // The stylesheet pins a sheet to the bottom edge; clear any popover coordinates.\n Object.assign(panel.style, {\n top: '',\n left: '',\n width: '',\n position: 'fixed',\n margin: '0',\n inset: 'auto 0 0 0',\n });\n delete panel.dataset['side'];\n\n return hider(panel, topLayer);\n }\n const position = computePosition(\n anchor.getBoundingClientRect(),\n panel.offsetHeight,\n { width: window.innerWidth, height: window.innerHeight },\n options,\n );\n Object.assign(panel.style, {\n position: 'fixed',\n margin: '0',\n inset: 'auto',\n top: `${position.top}px`,\n left: `${position.left}px`,\n width: `${position.width}px`,\n });\n panel.dataset['side'] = position.side;\n\n return hider(panel, topLayer);\n}\n\nfunction hider(panel: HTMLElement, topLayer: boolean): () => void {\n return () => {\n if (topLayer && panel.matches(':popover-open')) {\n panel.hidePopover();\n }\n };\n}\n\n/**\n * Show the panel and keep it beside the anchor while the window resizes or\n * any ancestor scrolls. Returns a function that stops tracking and hides it.\n */\nexport function trackPosition(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n let hide = placePopover(anchor, panel, options);\n const place = (): void => {\n hide = placePopover(anchor, panel, options);\n };\n window.addEventListener('resize', place);\n window.addEventListener('scroll', place, true);\n\n return () => {\n window.removeEventListener('resize', place);\n window.removeEventListener('scroll', place, true);\n hide();\n };\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n","import { hasDom } from './env';\n\ntype Targets =\n | readonly (Element | null | undefined)[]\n | (() => readonly (Element | null | undefined)[]);\n\n/**\n * Call `callback` when a pointer goes down outside all of `elements` (checked\n * in the capture phase, before the page reacts). Returns a cleanup function.\n */\nexport function onOutsidePointer(\n elements: Targets,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n const target = event.target;\n const list = typeof elements === 'function' ? elements() : elements;\n const inside =\n target instanceof Node &&\n list.some((element) => element?.contains(target) ?? false);\n if (!inside) {\n callback(event);\n }\n };\n document.addEventListener('pointerdown', listener, true);\n\n return () => {\n document.removeEventListener('pointerdown', listener, true);\n };\n}\n\n/**\n * Call `callback` when the pointer goes down on a panel's backdrop (the dimmed\n * area around a bottom sheet). A top-layer panel receives those events itself,\n * so they are told apart by position: on the panel, but outside its box.\n */\nexport function onBackdropPointer(\n panel: HTMLElement,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n if (event.target !== panel) {\n return;\n }\n const box = panel.getBoundingClientRect();\n const outside =\n event.clientX < box.left ||\n event.clientX > box.right ||\n event.clientY < box.top ||\n event.clientY > box.bottom;\n if (outside) {\n callback(event);\n }\n };\n panel.addEventListener('pointerdown', listener);\n\n return () => {\n panel.removeEventListener('pointerdown', listener);\n };\n}\n\n/**\n * Call `callback` on Escape. By default it listens in the capture phase and\n * stops the event, so an enclosing dialog does not close as well.\n */\nexport function onEscape(\n callback: (event: KeyboardEvent) => void,\n options: { readonly capture?: boolean; readonly stop?: boolean } = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const capture = options.capture ?? true;\n const listener = (event: KeyboardEvent): void => {\n if (event.key !== 'Escape') {\n return;\n }\n if (options.stop ?? true) {\n event.stopPropagation();\n event.preventDefault();\n }\n callback(event);\n };\n window.addEventListener('keydown', listener, capture);\n\n return () => {\n window.removeEventListener('keydown', listener, capture);\n };\n}\n\n/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */\nexport function focusFirst(\n container: ParentNode | null,\n selector = '[tabindex=\"0\"]',\n): boolean {\n const element = container?.querySelector<HTMLElement>(selector);\n if (!element) {\n return false;\n }\n element.focus();\n\n return true;\n}\n\n/** Put focus back, e.g. on the input after the panel closes. */\nexport function restoreFocus(element: HTMLElement | null | undefined): void {\n element?.focus({ preventScroll: true });\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n"],"mappings":";;;AACA,SAAgB,SAAkB;CAChC,OAAO,OAAO,WAAW,eAAe,OAAO,aAAa;AAC9D;;AAGA,SAAgB,kBAA2B;CACzC,OACE,OAAO,KACP,OAAO,gBAAgB,eACvB,OAAO,YAAY,UAAU,gBAAgB;AAEjD;;AAGA,SAAgB,uBAAgC;CAC9C,OACE,OAAO,KACP,OAAO,OAAO,eAAe,cAC7B,OAAO,WAAW,kCAAkC,CAAC,CAAC;AAE1D;;;;ACAA,SAAgB,cACd,QACA,eACA,aAAa,KACQ;CACrB,IAAI,WAAW,KAAA,KAAa,WAAW,QACrC,OAAO;CAGT,OAAO,gBAAgB,aAAa,UAAU;AAChD;;;;;;;;AAwBA,SAAgB,gBACd,QACA,aACA,UACA,UAAwB,CAAC,GACf;CACV,MAAM,MAAM,QAAQ,OAAO;CAC3B,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,IAAI,QAAQ,SAAS,KAAK,SAAS,QAAQ,MAAM,CAAC,CAAC;CAClF,MAAM,YAAY,SAAS,SAAS,OAAO,SAAS,MAAM;CAC1D,MAAM,YAAY,OAAO,MAAM,MAAM;CAErC,MAAM,OADW,UAAU,WAAW,KACA,IAClC,aAAa,eAAe,aAAa,YACvC,QACA,WACF,aAAa,eAAe,aAAa,YACvC,WACA;CACN,MAAM,SAAS,SAAS,WAAW,OAAO,SAAS,MAAM,OAAO,MAAM,MAAM;CAC5E,MAAM,UAAU,UAAU,SAAS,KAAK,IAAI,OAAO,QAAQ,QAAQ,OAAO;CAE1E,OAAO;EAEL,KAAK,KAAK,IAAI,KAAK,KAAK,IAAI,QAAQ,SAAS,SAAS,MAAM,WAAW,CAAC;EACxE,MAAM,KAAK,IAAI,KAAK,KAAK,IAAI,SAAS,SAAS,QAAQ,QAAQ,GAAG,CAAC;EACnE;EACA;CACF;AACF;;;;;;AAOA,SAAgB,aACd,QACA,OACA,UAAwB,CAAC,GACb;CACZ,IAAI,CAAC,OAAO,GACV,OAAOA;CAET,MAAM,WAAW,gBAAgB;CACjC,IAAI,UAAU;EACZ,IAAI,CAAC,MAAM,aAAa,SAAS,GAC/B,MAAM,aAAa,WAAW,QAAQ;EAExC,IAAI,CAAC,MAAM,QAAQ,eAAe,GAChC,MAAM,YAAY;CAEtB;CACA,MAAM,SAAS,cAAc,QAAQ,QAAQ,OAAO,YAAY,QAAQ,UAAU;CAClF,MAAM,QAAQ,YAAY;CAC1B,IAAI,WAAW,SAAS;EAEtB,OAAO,OAAO,MAAM,OAAO;GACzB,KAAK;GACL,MAAM;GACN,OAAO;GACP,UAAU;GACV,QAAQ;GACR,OAAO;EACT,CAAC;EACD,OAAO,MAAM,QAAQ;EAErB,OAAO,MAAM,OAAO,QAAQ;CAC9B;CACA,MAAM,WAAW,gBACf,OAAO,sBAAsB,GAC7B,MAAM,cACN;EAAE,OAAO,OAAO;EAAY,QAAQ,OAAO;CAAY,GACvD,OACF;CACA,OAAO,OAAO,MAAM,OAAO;EACzB,UAAU;EACV,QAAQ;EACR,OAAO;EACP,KAAK,GAAG,SAAS,IAAI;EACrB,MAAM,GAAG,SAAS,KAAK;EACvB,OAAO,GAAG,SAAS,MAAM;CAC3B,CAAC;CACD,MAAM,QAAQ,UAAU,SAAS;CAEjC,OAAO,MAAM,OAAO,QAAQ;AAC9B;AAEA,SAAS,MAAM,OAAoB,UAA+B;CAChE,aAAa;EACX,IAAI,YAAY,MAAM,QAAQ,eAAe,GAC3C,MAAM,YAAY;CAEtB;AACF;;;;;AAMA,SAAgB,cACd,QACA,OACA,UAAwB,CAAC,GACb;CACZ,IAAI,CAAC,OAAO,GACV,OAAOA;CAET,IAAI,OAAO,aAAa,QAAQ,OAAO,OAAO;CAC9C,MAAM,cAAoB;EACxB,OAAO,aAAa,QAAQ,OAAO,OAAO;CAC5C;CACA,OAAO,iBAAiB,UAAU,KAAK;CACvC,OAAO,iBAAiB,UAAU,OAAO,IAAI;CAE7C,aAAa;EACX,OAAO,oBAAoB,UAAU,KAAK;EAC1C,OAAO,oBAAoB,UAAU,OAAO,IAAI;EAChD,KAAK;CACP;AACF;AAEA,SAASA,SAAa,CAEtB;;;;;;;ACzKA,SAAgB,iBACd,UACA,UACY;CACZ,IAAI,CAAC,OAAO,GACV,OAAO;CAET,MAAM,YAAY,UAA8B;EAC9C,MAAM,SAAS,MAAM;EACrB,MAAM,OAAO,OAAO,aAAa,aAAa,SAAS,IAAI;EAI3D,IAAI,EAFF,kBAAkB,QAClB,KAAK,MAAM,YAAY,SAAS,SAAS,MAAM,KAAK,KAAK,IAEzD,SAAS,KAAK;CAElB;CACA,SAAS,iBAAiB,eAAe,UAAU,IAAI;CAEvD,aAAa;EACX,SAAS,oBAAoB,eAAe,UAAU,IAAI;CAC5D;AACF;;;;;;AAOA,SAAgB,kBACd,OACA,UACY;CACZ,IAAI,CAAC,OAAO,GACV,OAAO;CAET,MAAM,YAAY,UAA8B;EAC9C,IAAI,MAAM,WAAW,OACnB;EAEF,MAAM,MAAM,MAAM,sBAAsB;EAMxC,IAJE,MAAM,UAAU,IAAI,QACpB,MAAM,UAAU,IAAI,SACpB,MAAM,UAAU,IAAI,OACpB,MAAM,UAAU,IAAI,QAEpB,SAAS,KAAK;CAElB;CACA,MAAM,iBAAiB,eAAe,QAAQ;CAE9C,aAAa;EACX,MAAM,oBAAoB,eAAe,QAAQ;CACnD;AACF;;;;;AAMA,SAAgB,SACd,UACA,UAAmE,CAAC,GACxD;CACZ,IAAI,CAAC,OAAO,GACV,OAAO;CAET,MAAM,UAAU,QAAQ,WAAW;CACnC,MAAM,YAAY,UAA+B;EAC/C,IAAI,MAAM,QAAQ,UAChB;EAEF,IAAI,QAAQ,QAAQ,MAAM;GACxB,MAAM,gBAAgB;GACtB,MAAM,eAAe;EACvB;EACA,SAAS,KAAK;CAChB;CACA,OAAO,iBAAiB,WAAW,UAAU,OAAO;CAEpD,aAAa;EACX,OAAO,oBAAoB,WAAW,UAAU,OAAO;CACzD;AACF;;AAGA,SAAgB,WACd,WACA,WAAW,oBACF;CACT,MAAM,UAAU,WAAW,cAA2B,QAAQ;CAC9D,IAAI,CAAC,SACH,OAAO;CAET,QAAQ,MAAM;CAEd,OAAO;AACT;;AAGA,SAAgB,aAAa,SAA+C;CAC1E,SAAS,MAAM,EAAE,eAAe,KAAK,CAAC;AACxC;AAEA,SAAS,OAAa,CAEtB"}
1
+ {"version":3,"file":"index.cjs","names":["noop"],"sources":["../src/env.ts","../src/position.ts","../src/events.ts"],"sourcesContent":["/** True in a browser-like environment; false during server rendering. */\nexport function hasDom(): boolean {\n return typeof window !== 'undefined' && typeof document !== 'undefined';\n}\n\n/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */\nexport function supportsPopover(): boolean {\n return (\n hasDom() &&\n typeof HTMLElement !== 'undefined' &&\n typeof HTMLElement.prototype.showPopover === 'function'\n );\n}\n\n/** Whether the user asked the system for less motion. */\nexport function prefersReducedMotion(): boolean {\n return (\n hasDom() &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n );\n}\n","import { hasDom, supportsPopover } from './env';\n\nexport type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';\n\n/** 'popover' sits beside the anchor; 'sheet' is a bottom sheet; 'auto' picks the sheet on narrow screens. */\nexport type PanelLayout = 'auto' | 'popover' | 'sheet';\n\nexport interface PlaceOptions {\n /** Space between the anchor and the panel, and kept from the viewport edges. Default 6. */\n readonly gap?: number | undefined;\n /** Panel width in px; shrinks to fit narrow screens. Default 320. */\n readonly width?: number | undefined;\n /** Preferred side; flips when there is not enough room. Default 'bottom-start'. */\n readonly placement?: Placement | undefined;\n /** Default 'auto'. */\n readonly layout?: PanelLayout | undefined;\n /** 'auto' uses the sheet below this viewport width, in px. Default 480. */\n readonly sheetBelow?: number | undefined;\n}\n\n/** The layout to use for a viewport width. Pure. */\nexport function resolveLayout(\n layout: PanelLayout | undefined,\n viewportWidth: number,\n sheetBelow = 480,\n): 'popover' | 'sheet' {\n if (layout !== undefined && layout !== 'auto') {\n return layout;\n }\n\n return viewportWidth < sheetBelow ? 'sheet' : 'popover';\n}\n\nexport interface Rect {\n readonly top: number;\n readonly left: number;\n readonly bottom: number;\n readonly right: number;\n}\n\nexport interface Position {\n readonly top: number;\n readonly left: number;\n readonly width: number;\n /** Where the panel ended up after flipping. */\n readonly side: 'top' | 'bottom';\n}\n\n/**\n * Where to put a panel next to an anchor, in viewport (position: fixed)\n * coordinates. Pure, so it can be tested without a browser. Rules from the\n * institute-SaaS picker: prefer the requested side, flip when the panel does\n * not fit but does fit on the other side, and keep `gap` px from every edge\n * (the panel may overlap the anchor rather than leave the screen).\n */\nexport function computePosition(\n anchor: Rect,\n panelHeight: number,\n viewport: { readonly width: number; readonly height: number },\n options: PlaceOptions = {},\n): Position {\n const gap = options.gap ?? 6;\n const placement = options.placement ?? 'bottom-start';\n const width = Math.max(0, Math.min(options.width ?? 320, viewport.width - gap * 2));\n const roomBelow = viewport.height - anchor.bottom - gap * 2;\n const roomAbove = anchor.top - gap * 2;\n const wantsTop = placement.startsWith('top');\n const side: 'top' | 'bottom' = wantsTop\n ? roomAbove >= panelHeight || roomAbove >= roomBelow\n ? 'top'\n : 'bottom'\n : roomBelow >= panelHeight || roomBelow >= roomAbove\n ? 'bottom'\n : 'top';\n const rawTop = side === 'bottom' ? anchor.bottom + gap : anchor.top - gap - panelHeight;\n const rawLeft = placement.endsWith('end') ? anchor.right - width : anchor.left;\n\n return {\n // Keep the whole panel on screen; when it is taller than the room, its top edge wins.\n top: Math.max(gap, Math.min(rawTop, viewport.height - gap - panelHeight)),\n left: Math.max(gap, Math.min(rawLeft, viewport.width - width - gap)),\n width,\n side,\n };\n}\n\n/**\n * Show `panel` beside `anchor` once. Uses the browser's top layer when it can\n * (so dialogs and scrolling containers never clip the panel), otherwise\n * position: fixed. Returns a function that hides the panel again.\n */\nexport function placePopover(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const topLayer = supportsPopover();\n if (topLayer) {\n if (!panel.hasAttribute('popover')) {\n panel.setAttribute('popover', 'manual');\n }\n if (!panel.matches(':popover-open')) {\n panel.showPopover();\n }\n }\n const layout = resolveLayout(options.layout, window.innerWidth, options.sheetBelow);\n panel.dataset['layout'] = layout;\n if (layout === 'sheet') {\n // The stylesheet pins a sheet to the bottom edge; clear any popover coordinates.\n Object.assign(panel.style, {\n top: '',\n left: '',\n width: '',\n position: 'fixed',\n margin: '0',\n inset: 'auto 0 0 0',\n });\n delete panel.dataset['side'];\n\n return hider(panel, topLayer);\n }\n const position = computePosition(\n anchor.getBoundingClientRect(),\n panel.offsetHeight,\n { width: window.innerWidth, height: window.innerHeight },\n options,\n );\n Object.assign(panel.style, {\n position: 'fixed',\n margin: '0',\n inset: 'auto',\n top: `${position.top}px`,\n left: `${position.left}px`,\n width: `${position.width}px`,\n });\n panel.dataset['side'] = position.side;\n\n return hider(panel, topLayer);\n}\n\nfunction hider(panel: HTMLElement, topLayer: boolean): () => void {\n return () => {\n if (topLayer && panel.matches(':popover-open')) {\n panel.hidePopover();\n }\n };\n}\n\n/**\n * Show the panel and keep it beside the anchor while the window resizes or\n * any ancestor scrolls. Returns a function that stops tracking and hides it.\n */\nexport function trackPosition(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n let hide = placePopover(anchor, panel, options);\n let frame: number | null = null;\n const place = (): void => {\n hide = placePopover(anchor, panel, options);\n };\n // Scroll events can outnumber frames, and each placement measures the page.\n const placeOnNextFrame = (): void => {\n frame ??= requestAnimationFrame(() => {\n frame = null;\n place();\n });\n };\n window.addEventListener('resize', place);\n window.addEventListener('scroll', placeOnNextFrame, true);\n\n return () => {\n window.removeEventListener('resize', place);\n window.removeEventListener('scroll', placeOnNextFrame, true);\n if (frame !== null) {\n cancelAnimationFrame(frame);\n }\n hide();\n };\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n","import { hasDom } from './env';\n\ntype Targets =\n | readonly (Element | null | undefined)[]\n | (() => readonly (Element | null | undefined)[]);\n\n/**\n * Call `callback` when a pointer goes down outside all of `elements` (checked\n * in the capture phase, before the page reacts). Returns a cleanup function.\n */\nexport function onOutsidePointer(\n elements: Targets,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n const target = event.target;\n const list = typeof elements === 'function' ? elements() : elements;\n const inside =\n target instanceof Node &&\n list.some((element) => element?.contains(target) ?? false);\n if (!inside) {\n callback(event);\n }\n };\n document.addEventListener('pointerdown', listener, true);\n\n return () => {\n document.removeEventListener('pointerdown', listener, true);\n };\n}\n\n/**\n * Call `callback` when the pointer goes down on a panel's backdrop (the dimmed\n * area around a bottom sheet). A top-layer panel receives those events itself,\n * so they are told apart by position: on the panel, but outside its box.\n */\nexport function onBackdropPointer(\n panel: HTMLElement,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n if (event.target !== panel) {\n return;\n }\n const box = panel.getBoundingClientRect();\n const outside =\n event.clientX < box.left ||\n event.clientX > box.right ||\n event.clientY < box.top ||\n event.clientY > box.bottom;\n if (outside) {\n callback(event);\n }\n };\n panel.addEventListener('pointerdown', listener);\n\n return () => {\n panel.removeEventListener('pointerdown', listener);\n };\n}\n\n/**\n * Call `callback` on Escape. By default it listens in the capture phase and\n * stops the event, so an enclosing dialog does not close as well.\n */\nexport function onEscape(\n callback: (event: KeyboardEvent) => void,\n options: { readonly capture?: boolean; readonly stop?: boolean } = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const capture = options.capture ?? true;\n const listener = (event: KeyboardEvent): void => {\n if (event.key !== 'Escape') {\n return;\n }\n if (options.stop ?? true) {\n event.stopPropagation();\n event.preventDefault();\n }\n callback(event);\n };\n window.addEventListener('keydown', listener, capture);\n\n return () => {\n window.removeEventListener('keydown', listener, capture);\n };\n}\n\n/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */\nexport function focusFirst(\n container: ParentNode | null,\n selector = '[tabindex=\"0\"]',\n): boolean {\n const element = container?.querySelector<HTMLElement>(selector);\n if (!element) {\n return false;\n }\n element.focus();\n\n return true;\n}\n\n/** Put focus back, e.g. on the input after the panel closes. */\nexport function restoreFocus(element: HTMLElement | null | undefined): void {\n element?.focus({ preventScroll: true });\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -94,14 +94,22 @@ function hider(panel, topLayer) {
94
94
  function trackPosition(anchor, panel, options = {}) {
95
95
  if (!hasDom()) return noop$1;
96
96
  let hide = placePopover(anchor, panel, options);
97
+ let frame = null;
97
98
  const place = () => {
98
99
  hide = placePopover(anchor, panel, options);
99
100
  };
101
+ const placeOnNextFrame = () => {
102
+ frame ?? (frame = requestAnimationFrame(() => {
103
+ frame = null;
104
+ place();
105
+ }));
106
+ };
100
107
  window.addEventListener("resize", place);
101
- window.addEventListener("scroll", place, true);
108
+ window.addEventListener("scroll", placeOnNextFrame, true);
102
109
  return () => {
103
110
  window.removeEventListener("resize", place);
104
- window.removeEventListener("scroll", place, true);
111
+ window.removeEventListener("scroll", placeOnNextFrame, true);
112
+ if (frame !== null) cancelAnimationFrame(frame);
105
113
  hide();
106
114
  };
107
115
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":["noop"],"sources":["../src/env.ts","../src/position.ts","../src/events.ts"],"sourcesContent":["/** True in a browser-like environment; false during server rendering. */\nexport function hasDom(): boolean {\n return typeof window !== 'undefined' && typeof document !== 'undefined';\n}\n\n/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */\nexport function supportsPopover(): boolean {\n return (\n hasDom() &&\n typeof HTMLElement !== 'undefined' &&\n typeof HTMLElement.prototype.showPopover === 'function'\n );\n}\n\n/** Whether the user asked the system for less motion. */\nexport function prefersReducedMotion(): boolean {\n return (\n hasDom() &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n );\n}\n","import { hasDom, supportsPopover } from './env';\n\nexport type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';\n\n/** 'popover' sits beside the anchor; 'sheet' is a bottom sheet; 'auto' picks the sheet on narrow screens. */\nexport type PanelLayout = 'auto' | 'popover' | 'sheet';\n\nexport interface PlaceOptions {\n /** Space between the anchor and the panel, and kept from the viewport edges. Default 6. */\n readonly gap?: number | undefined;\n /** Panel width in px; shrinks to fit narrow screens. Default 320. */\n readonly width?: number | undefined;\n /** Preferred side; flips when there is not enough room. Default 'bottom-start'. */\n readonly placement?: Placement | undefined;\n /** Default 'auto'. */\n readonly layout?: PanelLayout | undefined;\n /** 'auto' uses the sheet below this viewport width, in px. Default 480. */\n readonly sheetBelow?: number | undefined;\n}\n\n/** The layout to use for a viewport width. Pure. */\nexport function resolveLayout(\n layout: PanelLayout | undefined,\n viewportWidth: number,\n sheetBelow = 480,\n): 'popover' | 'sheet' {\n if (layout !== undefined && layout !== 'auto') {\n return layout;\n }\n\n return viewportWidth < sheetBelow ? 'sheet' : 'popover';\n}\n\nexport interface Rect {\n readonly top: number;\n readonly left: number;\n readonly bottom: number;\n readonly right: number;\n}\n\nexport interface Position {\n readonly top: number;\n readonly left: number;\n readonly width: number;\n /** Where the panel ended up after flipping. */\n readonly side: 'top' | 'bottom';\n}\n\n/**\n * Where to put a panel next to an anchor, in viewport (position: fixed)\n * coordinates. Pure, so it can be tested without a browser. Rules from the\n * institute-SaaS picker: prefer the requested side, flip when the panel does\n * not fit but does fit on the other side, and keep `gap` px from every edge\n * (the panel may overlap the anchor rather than leave the screen).\n */\nexport function computePosition(\n anchor: Rect,\n panelHeight: number,\n viewport: { readonly width: number; readonly height: number },\n options: PlaceOptions = {},\n): Position {\n const gap = options.gap ?? 6;\n const placement = options.placement ?? 'bottom-start';\n const width = Math.max(0, Math.min(options.width ?? 320, viewport.width - gap * 2));\n const roomBelow = viewport.height - anchor.bottom - gap * 2;\n const roomAbove = anchor.top - gap * 2;\n const wantsTop = placement.startsWith('top');\n const side: 'top' | 'bottom' = wantsTop\n ? roomAbove >= panelHeight || roomAbove >= roomBelow\n ? 'top'\n : 'bottom'\n : roomBelow >= panelHeight || roomBelow >= roomAbove\n ? 'bottom'\n : 'top';\n const rawTop = side === 'bottom' ? anchor.bottom + gap : anchor.top - gap - panelHeight;\n const rawLeft = placement.endsWith('end') ? anchor.right - width : anchor.left;\n\n return {\n // Keep the whole panel on screen; when it is taller than the room, its top edge wins.\n top: Math.max(gap, Math.min(rawTop, viewport.height - gap - panelHeight)),\n left: Math.max(gap, Math.min(rawLeft, viewport.width - width - gap)),\n width,\n side,\n };\n}\n\n/**\n * Show `panel` beside `anchor` once. Uses the browser's top layer when it can\n * (so dialogs and scrolling containers never clip the panel), otherwise\n * position: fixed. Returns a function that hides the panel again.\n */\nexport function placePopover(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const topLayer = supportsPopover();\n if (topLayer) {\n if (!panel.hasAttribute('popover')) {\n panel.setAttribute('popover', 'manual');\n }\n if (!panel.matches(':popover-open')) {\n panel.showPopover();\n }\n }\n const layout = resolveLayout(options.layout, window.innerWidth, options.sheetBelow);\n panel.dataset['layout'] = layout;\n if (layout === 'sheet') {\n // The stylesheet pins a sheet to the bottom edge; clear any popover coordinates.\n Object.assign(panel.style, {\n top: '',\n left: '',\n width: '',\n position: 'fixed',\n margin: '0',\n inset: 'auto 0 0 0',\n });\n delete panel.dataset['side'];\n\n return hider(panel, topLayer);\n }\n const position = computePosition(\n anchor.getBoundingClientRect(),\n panel.offsetHeight,\n { width: window.innerWidth, height: window.innerHeight },\n options,\n );\n Object.assign(panel.style, {\n position: 'fixed',\n margin: '0',\n inset: 'auto',\n top: `${position.top}px`,\n left: `${position.left}px`,\n width: `${position.width}px`,\n });\n panel.dataset['side'] = position.side;\n\n return hider(panel, topLayer);\n}\n\nfunction hider(panel: HTMLElement, topLayer: boolean): () => void {\n return () => {\n if (topLayer && panel.matches(':popover-open')) {\n panel.hidePopover();\n }\n };\n}\n\n/**\n * Show the panel and keep it beside the anchor while the window resizes or\n * any ancestor scrolls. Returns a function that stops tracking and hides it.\n */\nexport function trackPosition(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n let hide = placePopover(anchor, panel, options);\n const place = (): void => {\n hide = placePopover(anchor, panel, options);\n };\n window.addEventListener('resize', place);\n window.addEventListener('scroll', place, true);\n\n return () => {\n window.removeEventListener('resize', place);\n window.removeEventListener('scroll', place, true);\n hide();\n };\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n","import { hasDom } from './env';\n\ntype Targets =\n | readonly (Element | null | undefined)[]\n | (() => readonly (Element | null | undefined)[]);\n\n/**\n * Call `callback` when a pointer goes down outside all of `elements` (checked\n * in the capture phase, before the page reacts). Returns a cleanup function.\n */\nexport function onOutsidePointer(\n elements: Targets,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n const target = event.target;\n const list = typeof elements === 'function' ? elements() : elements;\n const inside =\n target instanceof Node &&\n list.some((element) => element?.contains(target) ?? false);\n if (!inside) {\n callback(event);\n }\n };\n document.addEventListener('pointerdown', listener, true);\n\n return () => {\n document.removeEventListener('pointerdown', listener, true);\n };\n}\n\n/**\n * Call `callback` when the pointer goes down on a panel's backdrop (the dimmed\n * area around a bottom sheet). A top-layer panel receives those events itself,\n * so they are told apart by position: on the panel, but outside its box.\n */\nexport function onBackdropPointer(\n panel: HTMLElement,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n if (event.target !== panel) {\n return;\n }\n const box = panel.getBoundingClientRect();\n const outside =\n event.clientX < box.left ||\n event.clientX > box.right ||\n event.clientY < box.top ||\n event.clientY > box.bottom;\n if (outside) {\n callback(event);\n }\n };\n panel.addEventListener('pointerdown', listener);\n\n return () => {\n panel.removeEventListener('pointerdown', listener);\n };\n}\n\n/**\n * Call `callback` on Escape. By default it listens in the capture phase and\n * stops the event, so an enclosing dialog does not close as well.\n */\nexport function onEscape(\n callback: (event: KeyboardEvent) => void,\n options: { readonly capture?: boolean; readonly stop?: boolean } = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const capture = options.capture ?? true;\n const listener = (event: KeyboardEvent): void => {\n if (event.key !== 'Escape') {\n return;\n }\n if (options.stop ?? true) {\n event.stopPropagation();\n event.preventDefault();\n }\n callback(event);\n };\n window.addEventListener('keydown', listener, capture);\n\n return () => {\n window.removeEventListener('keydown', listener, capture);\n };\n}\n\n/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */\nexport function focusFirst(\n container: ParentNode | null,\n selector = '[tabindex=\"0\"]',\n): boolean {\n const element = container?.querySelector<HTMLElement>(selector);\n if (!element) {\n return false;\n }\n element.focus();\n\n return true;\n}\n\n/** Put focus back, e.g. on the input after the panel closes. */\nexport function restoreFocus(element: HTMLElement | null | undefined): void {\n element?.focus({ preventScroll: true });\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n"],"mappings":";;AACA,SAAgB,SAAkB;CAChC,OAAO,OAAO,WAAW,eAAe,OAAO,aAAa;AAC9D;;AAGA,SAAgB,kBAA2B;CACzC,OACE,OAAO,KACP,OAAO,gBAAgB,eACvB,OAAO,YAAY,UAAU,gBAAgB;AAEjD;;AAGA,SAAgB,uBAAgC;CAC9C,OACE,OAAO,KACP,OAAO,OAAO,eAAe,cAC7B,OAAO,WAAW,kCAAkC,CAAC,CAAC;AAE1D;;;;ACAA,SAAgB,cACd,QACA,eACA,aAAa,KACQ;CACrB,IAAI,WAAW,KAAA,KAAa,WAAW,QACrC,OAAO;CAGT,OAAO,gBAAgB,aAAa,UAAU;AAChD;;;;;;;;AAwBA,SAAgB,gBACd,QACA,aACA,UACA,UAAwB,CAAC,GACf;CACV,MAAM,MAAM,QAAQ,OAAO;CAC3B,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,IAAI,QAAQ,SAAS,KAAK,SAAS,QAAQ,MAAM,CAAC,CAAC;CAClF,MAAM,YAAY,SAAS,SAAS,OAAO,SAAS,MAAM;CAC1D,MAAM,YAAY,OAAO,MAAM,MAAM;CAErC,MAAM,OADW,UAAU,WAAW,KACA,IAClC,aAAa,eAAe,aAAa,YACvC,QACA,WACF,aAAa,eAAe,aAAa,YACvC,WACA;CACN,MAAM,SAAS,SAAS,WAAW,OAAO,SAAS,MAAM,OAAO,MAAM,MAAM;CAC5E,MAAM,UAAU,UAAU,SAAS,KAAK,IAAI,OAAO,QAAQ,QAAQ,OAAO;CAE1E,OAAO;EAEL,KAAK,KAAK,IAAI,KAAK,KAAK,IAAI,QAAQ,SAAS,SAAS,MAAM,WAAW,CAAC;EACxE,MAAM,KAAK,IAAI,KAAK,KAAK,IAAI,SAAS,SAAS,QAAQ,QAAQ,GAAG,CAAC;EACnE;EACA;CACF;AACF;;;;;;AAOA,SAAgB,aACd,QACA,OACA,UAAwB,CAAC,GACb;CACZ,IAAI,CAAC,OAAO,GACV,OAAOA;CAET,MAAM,WAAW,gBAAgB;CACjC,IAAI,UAAU;EACZ,IAAI,CAAC,MAAM,aAAa,SAAS,GAC/B,MAAM,aAAa,WAAW,QAAQ;EAExC,IAAI,CAAC,MAAM,QAAQ,eAAe,GAChC,MAAM,YAAY;CAEtB;CACA,MAAM,SAAS,cAAc,QAAQ,QAAQ,OAAO,YAAY,QAAQ,UAAU;CAClF,MAAM,QAAQ,YAAY;CAC1B,IAAI,WAAW,SAAS;EAEtB,OAAO,OAAO,MAAM,OAAO;GACzB,KAAK;GACL,MAAM;GACN,OAAO;GACP,UAAU;GACV,QAAQ;GACR,OAAO;EACT,CAAC;EACD,OAAO,MAAM,QAAQ;EAErB,OAAO,MAAM,OAAO,QAAQ;CAC9B;CACA,MAAM,WAAW,gBACf,OAAO,sBAAsB,GAC7B,MAAM,cACN;EAAE,OAAO,OAAO;EAAY,QAAQ,OAAO;CAAY,GACvD,OACF;CACA,OAAO,OAAO,MAAM,OAAO;EACzB,UAAU;EACV,QAAQ;EACR,OAAO;EACP,KAAK,GAAG,SAAS,IAAI;EACrB,MAAM,GAAG,SAAS,KAAK;EACvB,OAAO,GAAG,SAAS,MAAM;CAC3B,CAAC;CACD,MAAM,QAAQ,UAAU,SAAS;CAEjC,OAAO,MAAM,OAAO,QAAQ;AAC9B;AAEA,SAAS,MAAM,OAAoB,UAA+B;CAChE,aAAa;EACX,IAAI,YAAY,MAAM,QAAQ,eAAe,GAC3C,MAAM,YAAY;CAEtB;AACF;;;;;AAMA,SAAgB,cACd,QACA,OACA,UAAwB,CAAC,GACb;CACZ,IAAI,CAAC,OAAO,GACV,OAAOA;CAET,IAAI,OAAO,aAAa,QAAQ,OAAO,OAAO;CAC9C,MAAM,cAAoB;EACxB,OAAO,aAAa,QAAQ,OAAO,OAAO;CAC5C;CACA,OAAO,iBAAiB,UAAU,KAAK;CACvC,OAAO,iBAAiB,UAAU,OAAO,IAAI;CAE7C,aAAa;EACX,OAAO,oBAAoB,UAAU,KAAK;EAC1C,OAAO,oBAAoB,UAAU,OAAO,IAAI;EAChD,KAAK;CACP;AACF;AAEA,SAASA,SAAa,CAEtB;;;;;;;ACzKA,SAAgB,iBACd,UACA,UACY;CACZ,IAAI,CAAC,OAAO,GACV,OAAO;CAET,MAAM,YAAY,UAA8B;EAC9C,MAAM,SAAS,MAAM;EACrB,MAAM,OAAO,OAAO,aAAa,aAAa,SAAS,IAAI;EAI3D,IAAI,EAFF,kBAAkB,QAClB,KAAK,MAAM,YAAY,SAAS,SAAS,MAAM,KAAK,KAAK,IAEzD,SAAS,KAAK;CAElB;CACA,SAAS,iBAAiB,eAAe,UAAU,IAAI;CAEvD,aAAa;EACX,SAAS,oBAAoB,eAAe,UAAU,IAAI;CAC5D;AACF;;;;;;AAOA,SAAgB,kBACd,OACA,UACY;CACZ,IAAI,CAAC,OAAO,GACV,OAAO;CAET,MAAM,YAAY,UAA8B;EAC9C,IAAI,MAAM,WAAW,OACnB;EAEF,MAAM,MAAM,MAAM,sBAAsB;EAMxC,IAJE,MAAM,UAAU,IAAI,QACpB,MAAM,UAAU,IAAI,SACpB,MAAM,UAAU,IAAI,OACpB,MAAM,UAAU,IAAI,QAEpB,SAAS,KAAK;CAElB;CACA,MAAM,iBAAiB,eAAe,QAAQ;CAE9C,aAAa;EACX,MAAM,oBAAoB,eAAe,QAAQ;CACnD;AACF;;;;;AAMA,SAAgB,SACd,UACA,UAAmE,CAAC,GACxD;CACZ,IAAI,CAAC,OAAO,GACV,OAAO;CAET,MAAM,UAAU,QAAQ,WAAW;CACnC,MAAM,YAAY,UAA+B;EAC/C,IAAI,MAAM,QAAQ,UAChB;EAEF,IAAI,QAAQ,QAAQ,MAAM;GACxB,MAAM,gBAAgB;GACtB,MAAM,eAAe;EACvB;EACA,SAAS,KAAK;CAChB;CACA,OAAO,iBAAiB,WAAW,UAAU,OAAO;CAEpD,aAAa;EACX,OAAO,oBAAoB,WAAW,UAAU,OAAO;CACzD;AACF;;AAGA,SAAgB,WACd,WACA,WAAW,oBACF;CACT,MAAM,UAAU,WAAW,cAA2B,QAAQ;CAC9D,IAAI,CAAC,SACH,OAAO;CAET,QAAQ,MAAM;CAEd,OAAO;AACT;;AAGA,SAAgB,aAAa,SAA+C;CAC1E,SAAS,MAAM,EAAE,eAAe,KAAK,CAAC;AACxC;AAEA,SAAS,OAAa,CAEtB"}
1
+ {"version":3,"file":"index.js","names":["noop"],"sources":["../src/env.ts","../src/position.ts","../src/events.ts"],"sourcesContent":["/** True in a browser-like environment; false during server rendering. */\nexport function hasDom(): boolean {\n return typeof window !== 'undefined' && typeof document !== 'undefined';\n}\n\n/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */\nexport function supportsPopover(): boolean {\n return (\n hasDom() &&\n typeof HTMLElement !== 'undefined' &&\n typeof HTMLElement.prototype.showPopover === 'function'\n );\n}\n\n/** Whether the user asked the system for less motion. */\nexport function prefersReducedMotion(): boolean {\n return (\n hasDom() &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n );\n}\n","import { hasDom, supportsPopover } from './env';\n\nexport type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';\n\n/** 'popover' sits beside the anchor; 'sheet' is a bottom sheet; 'auto' picks the sheet on narrow screens. */\nexport type PanelLayout = 'auto' | 'popover' | 'sheet';\n\nexport interface PlaceOptions {\n /** Space between the anchor and the panel, and kept from the viewport edges. Default 6. */\n readonly gap?: number | undefined;\n /** Panel width in px; shrinks to fit narrow screens. Default 320. */\n readonly width?: number | undefined;\n /** Preferred side; flips when there is not enough room. Default 'bottom-start'. */\n readonly placement?: Placement | undefined;\n /** Default 'auto'. */\n readonly layout?: PanelLayout | undefined;\n /** 'auto' uses the sheet below this viewport width, in px. Default 480. */\n readonly sheetBelow?: number | undefined;\n}\n\n/** The layout to use for a viewport width. Pure. */\nexport function resolveLayout(\n layout: PanelLayout | undefined,\n viewportWidth: number,\n sheetBelow = 480,\n): 'popover' | 'sheet' {\n if (layout !== undefined && layout !== 'auto') {\n return layout;\n }\n\n return viewportWidth < sheetBelow ? 'sheet' : 'popover';\n}\n\nexport interface Rect {\n readonly top: number;\n readonly left: number;\n readonly bottom: number;\n readonly right: number;\n}\n\nexport interface Position {\n readonly top: number;\n readonly left: number;\n readonly width: number;\n /** Where the panel ended up after flipping. */\n readonly side: 'top' | 'bottom';\n}\n\n/**\n * Where to put a panel next to an anchor, in viewport (position: fixed)\n * coordinates. Pure, so it can be tested without a browser. Rules from the\n * institute-SaaS picker: prefer the requested side, flip when the panel does\n * not fit but does fit on the other side, and keep `gap` px from every edge\n * (the panel may overlap the anchor rather than leave the screen).\n */\nexport function computePosition(\n anchor: Rect,\n panelHeight: number,\n viewport: { readonly width: number; readonly height: number },\n options: PlaceOptions = {},\n): Position {\n const gap = options.gap ?? 6;\n const placement = options.placement ?? 'bottom-start';\n const width = Math.max(0, Math.min(options.width ?? 320, viewport.width - gap * 2));\n const roomBelow = viewport.height - anchor.bottom - gap * 2;\n const roomAbove = anchor.top - gap * 2;\n const wantsTop = placement.startsWith('top');\n const side: 'top' | 'bottom' = wantsTop\n ? roomAbove >= panelHeight || roomAbove >= roomBelow\n ? 'top'\n : 'bottom'\n : roomBelow >= panelHeight || roomBelow >= roomAbove\n ? 'bottom'\n : 'top';\n const rawTop = side === 'bottom' ? anchor.bottom + gap : anchor.top - gap - panelHeight;\n const rawLeft = placement.endsWith('end') ? anchor.right - width : anchor.left;\n\n return {\n // Keep the whole panel on screen; when it is taller than the room, its top edge wins.\n top: Math.max(gap, Math.min(rawTop, viewport.height - gap - panelHeight)),\n left: Math.max(gap, Math.min(rawLeft, viewport.width - width - gap)),\n width,\n side,\n };\n}\n\n/**\n * Show `panel` beside `anchor` once. Uses the browser's top layer when it can\n * (so dialogs and scrolling containers never clip the panel), otherwise\n * position: fixed. Returns a function that hides the panel again.\n */\nexport function placePopover(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const topLayer = supportsPopover();\n if (topLayer) {\n if (!panel.hasAttribute('popover')) {\n panel.setAttribute('popover', 'manual');\n }\n if (!panel.matches(':popover-open')) {\n panel.showPopover();\n }\n }\n const layout = resolveLayout(options.layout, window.innerWidth, options.sheetBelow);\n panel.dataset['layout'] = layout;\n if (layout === 'sheet') {\n // The stylesheet pins a sheet to the bottom edge; clear any popover coordinates.\n Object.assign(panel.style, {\n top: '',\n left: '',\n width: '',\n position: 'fixed',\n margin: '0',\n inset: 'auto 0 0 0',\n });\n delete panel.dataset['side'];\n\n return hider(panel, topLayer);\n }\n const position = computePosition(\n anchor.getBoundingClientRect(),\n panel.offsetHeight,\n { width: window.innerWidth, height: window.innerHeight },\n options,\n );\n Object.assign(panel.style, {\n position: 'fixed',\n margin: '0',\n inset: 'auto',\n top: `${position.top}px`,\n left: `${position.left}px`,\n width: `${position.width}px`,\n });\n panel.dataset['side'] = position.side;\n\n return hider(panel, topLayer);\n}\n\nfunction hider(panel: HTMLElement, topLayer: boolean): () => void {\n return () => {\n if (topLayer && panel.matches(':popover-open')) {\n panel.hidePopover();\n }\n };\n}\n\n/**\n * Show the panel and keep it beside the anchor while the window resizes or\n * any ancestor scrolls. Returns a function that stops tracking and hides it.\n */\nexport function trackPosition(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n let hide = placePopover(anchor, panel, options);\n let frame: number | null = null;\n const place = (): void => {\n hide = placePopover(anchor, panel, options);\n };\n // Scroll events can outnumber frames, and each placement measures the page.\n const placeOnNextFrame = (): void => {\n frame ??= requestAnimationFrame(() => {\n frame = null;\n place();\n });\n };\n window.addEventListener('resize', place);\n window.addEventListener('scroll', placeOnNextFrame, true);\n\n return () => {\n window.removeEventListener('resize', place);\n window.removeEventListener('scroll', placeOnNextFrame, true);\n if (frame !== null) {\n cancelAnimationFrame(frame);\n }\n hide();\n };\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n","import { hasDom } from './env';\n\ntype Targets =\n | readonly (Element | null | undefined)[]\n | (() => readonly (Element | null | undefined)[]);\n\n/**\n * Call `callback` when a pointer goes down outside all of `elements` (checked\n * in the capture phase, before the page reacts). Returns a cleanup function.\n */\nexport function onOutsidePointer(\n elements: Targets,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n const target = event.target;\n const list = typeof elements === 'function' ? elements() : elements;\n const inside =\n target instanceof Node &&\n list.some((element) => element?.contains(target) ?? false);\n if (!inside) {\n callback(event);\n }\n };\n document.addEventListener('pointerdown', listener, true);\n\n return () => {\n document.removeEventListener('pointerdown', listener, true);\n };\n}\n\n/**\n * Call `callback` when the pointer goes down on a panel's backdrop (the dimmed\n * area around a bottom sheet). A top-layer panel receives those events itself,\n * so they are told apart by position: on the panel, but outside its box.\n */\nexport function onBackdropPointer(\n panel: HTMLElement,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n if (event.target !== panel) {\n return;\n }\n const box = panel.getBoundingClientRect();\n const outside =\n event.clientX < box.left ||\n event.clientX > box.right ||\n event.clientY < box.top ||\n event.clientY > box.bottom;\n if (outside) {\n callback(event);\n }\n };\n panel.addEventListener('pointerdown', listener);\n\n return () => {\n panel.removeEventListener('pointerdown', listener);\n };\n}\n\n/**\n * Call `callback` on Escape. By default it listens in the capture phase and\n * stops the event, so an enclosing dialog does not close as well.\n */\nexport function onEscape(\n callback: (event: KeyboardEvent) => void,\n options: { readonly capture?: boolean; readonly stop?: boolean } = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const capture = options.capture ?? true;\n const listener = (event: KeyboardEvent): void => {\n if (event.key !== 'Escape') {\n return;\n }\n if (options.stop ?? true) {\n event.stopPropagation();\n event.preventDefault();\n }\n callback(event);\n };\n window.addEventListener('keydown', listener, capture);\n\n return () => {\n window.removeEventListener('keydown', listener, capture);\n };\n}\n\n/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */\nexport function focusFirst(\n container: ParentNode | null,\n selector = '[tabindex=\"0\"]',\n): boolean {\n const element = container?.querySelector<HTMLElement>(selector);\n if (!element) {\n return false;\n }\n element.focus();\n\n return true;\n}\n\n/** Put focus back, e.g. on the input after the panel closes. */\nexport function restoreFocus(element: HTMLElement | null | undefined): void {\n element?.focus({ preventScroll: true });\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nepdatejs/dom",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Tiny browser helpers for @nepdatejs adapters: popover placement, outside click, Escape, focus.",
5
5
  "keywords": [
6
6
  "nepali",
package/src/position.ts CHANGED
@@ -162,15 +162,26 @@ export function trackPosition(
162
162
  return noop;
163
163
  }
164
164
  let hide = placePopover(anchor, panel, options);
165
+ let frame: number | null = null;
165
166
  const place = (): void => {
166
167
  hide = placePopover(anchor, panel, options);
167
168
  };
169
+ // Scroll events can outnumber frames, and each placement measures the page.
170
+ const placeOnNextFrame = (): void => {
171
+ frame ??= requestAnimationFrame(() => {
172
+ frame = null;
173
+ place();
174
+ });
175
+ };
168
176
  window.addEventListener('resize', place);
169
- window.addEventListener('scroll', place, true);
177
+ window.addEventListener('scroll', placeOnNextFrame, true);
170
178
 
171
179
  return () => {
172
180
  window.removeEventListener('resize', place);
173
- window.removeEventListener('scroll', place, true);
181
+ window.removeEventListener('scroll', placeOnNextFrame, true);
182
+ if (frame !== null) {
183
+ cancelAnimationFrame(frame);
184
+ }
174
185
  hide();
175
186
  };
176
187
  }