+1
-1
| { | ||
| "kerfjsVersion": "4.2.0", | ||
| "kerfjsVersion": "4.3.0", | ||
| "files": [ | ||
@@ -4,0 +4,0 @@ { |
+162
-139
@@ -43,141 +43,2 @@ import { SafeHtml } from './jsx-runtime.js'; | ||
| /** | ||
| * `toast()` for `kerfjs/overlay` — a non-modal, auto-dismissing notification that | ||
| * stacks in a shared body-level region. Split out of `overlay.ts` (KF-513) since | ||
| * it's a distinct transient-UI concern from the modal dialogs; re-exported from | ||
| * `overlay.ts` so the public `kerfjs/overlay` surface is unchanged. Structural | ||
| * only — kerf ships no CSS; you style the region / toast / animations. | ||
| */ | ||
| /** Content for a {@link toast}: text, `SafeHtml`, or a render function. */ | ||
| type ToastContent = string | SafeHtml | (() => MountResult); | ||
| /** Accent variant for a {@link toast} — mapped to a `${className}--${variant}` class. */ | ||
| type ToastVariant = 'info' | 'success' | 'warning'; | ||
| /** Options for {@link toast}. */ | ||
| interface ToastOptions { | ||
| /** Where toasts stack. Default: a lazily-created `<div class="kerf-toasts">` on `document.body`. */ | ||
| container?: Element; | ||
| /** Class on the toast element. Default `'kerf-toast'`. */ | ||
| className?: string; | ||
| /** Auto-dismiss after this many ms. `0` keeps it until dismissed by hand. Default `4000`. */ | ||
| duration?: number; | ||
| /** ARIA role. Default `'status'`. */ | ||
| role?: string; | ||
| /** | ||
| * `'stack'` (default) shows toasts stacked in the region; `'replace'` dismisses | ||
| * the region's current toast(s) first (collapse-to-latest for a rapid sequence). | ||
| */ | ||
| mode?: 'stack' | 'replace'; | ||
| /** | ||
| * How `mode: 'replace'` drops the prior toast(s): `'fade'` (default) runs their | ||
| * full exit transition (nice for a STACKING region), or `'instant'` removes them | ||
| * synchronously with no exit — what a single, exactly-centered toast slot wants, | ||
| * so the outgoing and incoming messages never cross-fade in the same spot. | ||
| */ | ||
| collapse?: 'fade' | 'instant'; | ||
| /** Accent variant — adds a `${className}--${variant}` class (kerf ships no CSS; you style it). */ | ||
| variant?: ToastVariant; | ||
| /** Class added on the next animation frame after mount, so a CSS **entrance** transition can run. */ | ||
| enterClass?: string; | ||
| /** | ||
| * Class added when dismissing, so CSS owns the **exit**. On dismiss the | ||
| * `enterClass` (if any) is also REMOVED, so `exitClass` doesn't have to | ||
| * out-specify it — and a symmetric single-class fade (entrance = add | ||
| * `enterClass`, exit = remove it) works by setting only `enterClass` + | ||
| * `exitDuration`. The node is removed `exitDuration` ms later. | ||
| */ | ||
| exitClass?: string; | ||
| /** ms to wait before removing the node on dismiss — applies when `exitClass` is set OR when it's > 0 (to let a removed `enterClass` transition out). Default `0`. */ | ||
| exitDuration?: number; | ||
| } | ||
| /** Handle returned by {@link toast}. */ | ||
| interface ToastHandle { | ||
| /** The toast element — inspect it, or run your own entrance/exit transitions. */ | ||
| el: HTMLElement; | ||
| /** | ||
| * Dismiss it early. Default runs the `exitClass` transition (removed after | ||
| * `exitDuration`); pass `{ instant: true }` to remove it **synchronously** with | ||
| * no exit — for an action button that immediately shows a replacement toast in a | ||
| * single centered slot (no cross-fade). Idempotent. | ||
| */ | ||
| dismiss(options?: { | ||
| instant?: boolean; | ||
| }): void; | ||
| } | ||
| /** | ||
| * Show a non-modal, auto-dismissing notification. Stacks in a shared body-level | ||
| * region (or your `container`). Returns a {@link ToastHandle} (`{ el, dismiss }`) | ||
| * so you can run entrance/exit transitions, wire an action button, or inspect the | ||
| * node. `mode: 'replace'` collapses a rapid sequence to the latest; `variant` | ||
| * adds an accent class; `enterClass`/`exitClass` let CSS own the animation. | ||
| */ | ||
| declare function toast(content: ToastContent, options?: ToastOptions): ToastHandle; | ||
| /** A user-initiated dismissal trigger. */ | ||
| type DismissTrigger = 'escape' | 'backdrop' | 'outside'; | ||
| /** Content for an overlay: static `SafeHtml`, or a render function `mount()` drives reactively. */ | ||
| type OverlayContent = SafeHtml | (() => MountResult); | ||
| /** Options for {@link overlay}. */ | ||
| interface OverlayOptions { | ||
| /** Where to append the overlay wrapper. Default `document.body`. */ | ||
| container?: Element; | ||
| /** Class on the wrapper element (you style it — kerf ships no CSS). Default `'kerf-overlay'`. */ | ||
| className?: string; | ||
| /** | ||
| * Which user actions dismiss the overlay. Default `['escape', 'backdrop']`. | ||
| * `'backdrop'` = a click on the wrapper itself (not its content); `'outside'` | ||
| * = a click anywhere outside the wrapper (for anchored popovers). `false` | ||
| * disables user dismissal (close it programmatically). | ||
| */ | ||
| dismiss?: DismissTrigger | DismissTrigger[] | false; | ||
| /** | ||
| * Where focus lands on open: a selector, `true` (first focusable element, or | ||
| * the wrapper if none), or `false` (leave focus alone). Default `true`. | ||
| */ | ||
| initialFocus?: string | boolean; | ||
| /** | ||
| * Trap Tab / Shift+Tab within the overlay while open and mark it | ||
| * `role="dialog"` / `aria-modal="true"`. Default `true`. Set `false` for a | ||
| * non-modal popover. | ||
| */ | ||
| trap?: boolean; | ||
| /** ARIA role for the wrapper when `trap` is on. Default `'dialog'`. */ | ||
| role?: string; | ||
| /** Called on any user-initiated dismissal (before `close()` runs). */ | ||
| onDismiss?: () => void; | ||
| /** For `'outside'` dismissal: clicks on these elements do NOT count as outside (e.g. the trigger button). */ | ||
| outsideIgnore?: Element | readonly Element[]; | ||
| /** | ||
| * Opt into the browser **top layer** (`docs/19-native-overlay-backing.md`). | ||
| * When `true` and the engine supports it, a modal overlay (`trap: true`) is | ||
| * hosted in a `<dialog>` opened with `.showModal()` — real inerting of the rest | ||
| * of the document + guaranteed stacking above any `z-index` — and a non-modal | ||
| * one (`trap: false`) uses the Popover API (`[popover]` + `showPopover()`). | ||
| * Feature-detected; falls back to today's plain `<div>` where unsupported. | ||
| * | ||
| * The `render` slot + promise API are unchanged — kerf just hosts your markup | ||
| * in a `<dialog>` / `[popover]` instead of a `<div>`. Two caveats: native | ||
| * `<dialog>` / `[popover]` carry **UA default styles** (a `::backdrop`, | ||
| * centering, border, padding) that kerf does not reset — style the element (and | ||
| * its `::backdrop`) via `className`; and `container` is effectively a **no-op** | ||
| * for visual position, since the top layer ignores where the element lives in | ||
| * the DOM. Default `false`. | ||
| */ | ||
| native?: boolean; | ||
| } | ||
| /** Handle returned by {@link overlay}. Holds no framework state — it's a closure. */ | ||
| interface OverlayHandle { | ||
| /** The wrapper element (mounted into, appended to `container`). */ | ||
| el: HTMLElement; | ||
| /** Tear down: dispose the mount, remove listeners + the node, restore focus, resolve `result`. Idempotent. */ | ||
| close(result?: unknown): void; | ||
| /** Resolves with the value passed to `close()` (or `undefined` on user dismissal). */ | ||
| result: Promise<unknown>; | ||
| } | ||
| /** | ||
| * Open an overlay: append a wrapper to `container`, `mount()` `content` inside | ||
| * it, wire the requested dismissals + (optionally) a focus trap, and return a | ||
| * handle. See {@link OverlayOptions}. | ||
| */ | ||
| declare function overlay(content: OverlayContent, options?: OverlayOptions): OverlayHandle; | ||
| /** | ||
| * Wiring slots passed to a {@link ConfirmOptions.render} — spread `ok` / `cancel` | ||
@@ -388,2 +249,164 @@ * onto your own clickable elements so `confirm()` still resolves them (they are | ||
| /** | ||
| * `toast()` for `kerfjs/overlay` — a non-modal, auto-dismissing notification that | ||
| * stacks in a shared body-level region. Split out of `overlay.ts` (KF-513) since | ||
| * it's a distinct transient-UI concern from the modal dialogs; re-exported from | ||
| * `overlay.ts` so the public `kerfjs/overlay` surface is unchanged. Structural | ||
| * only — kerf ships no CSS; you style the region / toast / animations. | ||
| */ | ||
| /** Content for a {@link toast}: text, `SafeHtml`, or a render function. */ | ||
| type ToastContent = string | SafeHtml | (() => MountResult); | ||
| /** Accent variant for a {@link toast} — mapped to a `${className}--${variant}` class. */ | ||
| type ToastVariant = 'info' | 'success' | 'warning'; | ||
| /** Options for {@link toast}. */ | ||
| interface ToastOptions { | ||
| /** Where toasts stack. Default: a lazily-created `<div class="kerf-toasts">` on `document.body`. */ | ||
| container?: Element; | ||
| /** Class on the toast element. Default `'kerf-toast'`. */ | ||
| className?: string; | ||
| /** Auto-dismiss after this many ms. `0` keeps it until dismissed by hand. Default `4000`. */ | ||
| duration?: number; | ||
| /** ARIA role. Default `'status'`. */ | ||
| role?: string; | ||
| /** | ||
| * `'stack'` (default) shows toasts stacked in the region; `'replace'` dismisses | ||
| * the region's current toast(s) first (collapse-to-latest for a rapid sequence). | ||
| */ | ||
| mode?: 'stack' | 'replace'; | ||
| /** | ||
| * How `mode: 'replace'` drops the prior toast(s): `'fade'` (default) runs their | ||
| * full exit transition (nice for a STACKING region), or `'instant'` removes them | ||
| * synchronously with no exit — what a single, exactly-centered toast slot wants, | ||
| * so the outgoing and incoming messages never cross-fade in the same spot. | ||
| */ | ||
| collapse?: 'fade' | 'instant'; | ||
| /** Accent variant — adds a `${className}--${variant}` class (kerf ships no CSS; you style it). */ | ||
| variant?: ToastVariant; | ||
| /** Class added on the next animation frame after mount, so a CSS **entrance** transition can run. */ | ||
| enterClass?: string; | ||
| /** | ||
| * Class added when dismissing, so CSS owns the **exit**. On dismiss the | ||
| * `enterClass` (if any) is also REMOVED, so `exitClass` doesn't have to | ||
| * out-specify it — and a symmetric single-class fade (entrance = add | ||
| * `enterClass`, exit = remove it) works by setting only `enterClass` + | ||
| * `exitDuration`. The node is removed `exitDuration` ms later. | ||
| */ | ||
| exitClass?: string; | ||
| /** ms to wait before removing the node on dismiss — applies when `exitClass` is set OR when it's > 0 (to let a removed `enterClass` transition out). Default `0`. */ | ||
| exitDuration?: number; | ||
| } | ||
| /** Handle returned by {@link toast}. */ | ||
| interface ToastHandle { | ||
| /** The toast element — inspect it, or run your own entrance/exit transitions. */ | ||
| el: HTMLElement; | ||
| /** | ||
| * Dismiss it early. Default runs the `exitClass` transition (removed after | ||
| * `exitDuration`); pass `{ instant: true }` to remove it **synchronously** with | ||
| * no exit — for an action button that immediately shows a replacement toast in a | ||
| * single centered slot (no cross-fade). Idempotent. | ||
| */ | ||
| dismiss(options?: { | ||
| instant?: boolean; | ||
| }): void; | ||
| } | ||
| /** | ||
| * Show a non-modal, auto-dismissing notification. Stacks in a shared body-level | ||
| * region (or your `container`). Returns a {@link ToastHandle} (`{ el, dismiss }`) | ||
| * so you can run entrance/exit transitions, wire an action button, or inspect the | ||
| * node. `mode: 'replace'` collapses a rapid sequence to the latest; `variant` | ||
| * adds an accent class; `enterClass`/`exitClass` let CSS own the animation. | ||
| */ | ||
| declare function toast(content: ToastContent, options?: ToastOptions): ToastHandle; | ||
| /** | ||
| * `kerfjs/overlay` — the modal / overlay + dismiss manager. | ||
| * | ||
| * Every real kerf app hand-rolls this: `toElement → body.appendChild → mount → | ||
| * wire dismissal → remove`, plus the fiddly parts (Escape, backdrop / outside | ||
| * click, focus trap, restoring focus on close). `window.confirm` is a no-op in | ||
| * Tauri WKWebViews, so a hand-built overlay is mandatory there. This subpath | ||
| * blesses the pattern as three functions over `mount()` — `overlay()`, and the | ||
| * `confirm()` / `toast()` conveniences built on it. No per-instance framework | ||
| * state: each call owns its DOM + listeners in a closure and returns a handle. | ||
| * | ||
| * import { overlay, confirm, toast } from 'kerfjs/overlay'; | ||
| * | ||
| * const ok = await confirm('Delete this file?', { danger: true }); | ||
| * toast('Saved'); | ||
| * const dialog = overlay(<Settings />, { dismiss: ['escape', 'backdrop'] }); | ||
| * // …later: dialog.close(); or await dialog.result; | ||
| * | ||
| * Structural only — kerf ships no CSS. The wrapper gets your `className`; style | ||
| * the backdrop / centering / animation yourself. | ||
| */ | ||
| /** A user-initiated dismissal trigger. */ | ||
| type DismissTrigger = 'escape' | 'backdrop' | 'outside'; | ||
| /** Content for an overlay: static `SafeHtml`, or a render function `mount()` drives reactively. */ | ||
| type OverlayContent = SafeHtml | (() => MountResult); | ||
| /** Options for {@link overlay}. */ | ||
| interface OverlayOptions { | ||
| /** Where to append the overlay wrapper. Default `document.body`. */ | ||
| container?: Element; | ||
| /** Class on the wrapper element (you style it — kerf ships no CSS). Default `'kerf-overlay'`. */ | ||
| className?: string; | ||
| /** | ||
| * Which user actions dismiss the overlay. Default `['escape', 'backdrop']`. | ||
| * `'backdrop'` = a click on the wrapper itself (not its content); `'outside'` | ||
| * = a click anywhere outside the wrapper (for anchored popovers). `false` | ||
| * disables user dismissal (close it programmatically). | ||
| */ | ||
| dismiss?: DismissTrigger | DismissTrigger[] | false; | ||
| /** | ||
| * Where focus lands on open: a selector, `true` (first focusable element, or | ||
| * the wrapper if none), or `false` (leave focus alone). Default `true`. | ||
| */ | ||
| initialFocus?: string | boolean; | ||
| /** | ||
| * Trap Tab / Shift+Tab within the overlay while open and mark it | ||
| * `role="dialog"` / `aria-modal="true"`. Default `true`. Set `false` for a | ||
| * non-modal popover. | ||
| */ | ||
| trap?: boolean; | ||
| /** ARIA role for the wrapper when `trap` is on. Default `'dialog'`. */ | ||
| role?: string; | ||
| /** Called on any user-initiated dismissal (before `close()` runs). */ | ||
| onDismiss?: () => void; | ||
| /** For `'outside'` dismissal: clicks on these elements do NOT count as outside (e.g. the trigger button). */ | ||
| outsideIgnore?: Element | readonly Element[]; | ||
| /** | ||
| * Opt into the browser **top layer** (`docs/19-native-overlay-backing.md`). | ||
| * When `true` and the engine supports it, a modal overlay (`trap: true`) is | ||
| * hosted in a `<dialog>` opened with `.showModal()` — real inerting of the rest | ||
| * of the document + guaranteed stacking above any `z-index` — and a non-modal | ||
| * one (`trap: false`) uses the Popover API (`[popover]` + `showPopover()`). | ||
| * Feature-detected; falls back to today's plain `<div>` where unsupported. | ||
| * | ||
| * The `render` slot + promise API are unchanged — kerf just hosts your markup | ||
| * in a `<dialog>` / `[popover]` instead of a `<div>`. Two caveats: native | ||
| * `<dialog>` / `[popover]` carry **UA default styles** (a `::backdrop`, | ||
| * centering, border, padding) that kerf does not reset — style the element (and | ||
| * its `::backdrop`) via `className`; and `container` is effectively a **no-op** | ||
| * for visual position, since the top layer ignores where the element lives in | ||
| * the DOM. Default `false`. | ||
| */ | ||
| native?: boolean; | ||
| } | ||
| /** Handle returned by {@link overlay}. Holds no framework state — it's a closure. */ | ||
| interface OverlayHandle { | ||
| /** The wrapper element (mounted into, appended to `container`). */ | ||
| el: HTMLElement; | ||
| /** Tear down: dispose the mount, remove listeners + the node, restore focus, resolve `result`. Idempotent. */ | ||
| close(result?: unknown): void; | ||
| /** Resolves with the value passed to `close()` (or `undefined` on user dismissal). */ | ||
| result: Promise<unknown>; | ||
| } | ||
| /** | ||
| * Open an overlay: append a wrapper to `container`, `mount()` `content` inside | ||
| * it, wire the requested dismissals + (optionally) a focus trap, and return a | ||
| * handle. See {@link OverlayOptions}. | ||
| */ | ||
| declare function overlay(content: OverlayContent, options?: OverlayOptions): OverlayHandle; | ||
| /** Options for {@link popover}. */ | ||
@@ -390,0 +413,0 @@ interface PopoverOptions { |
+224
-222
@@ -40,224 +40,3 @@ import { mount } from './chunk-SRWQKB33.js'; | ||
| // src/overlay-toast.ts | ||
| var TOAST_SET = /* @__PURE__ */ Symbol("kerf.toasts"); | ||
| function toastRegion(container) { | ||
| if (container !== void 0) return container; | ||
| const existing = document.querySelector(".kerf-toasts"); | ||
| if (existing !== null) return existing; | ||
| const region = document.createElement("div"); | ||
| region.className = "kerf-toasts"; | ||
| region.setAttribute("aria-live", "polite"); | ||
| document.body.appendChild(region); | ||
| return region; | ||
| } | ||
| function toast(content, options = {}) { | ||
| const { | ||
| container, | ||
| className = "kerf-toast", | ||
| duration = 4e3, | ||
| role = "status", | ||
| mode = "stack", | ||
| collapse = "fade", | ||
| variant, | ||
| enterClass, | ||
| exitClass, | ||
| exitDuration = 0 | ||
| } = options; | ||
| const region = toastRegion(container); | ||
| const active = region[TOAST_SET] ??= /* @__PURE__ */ new Set(); | ||
| if (mode === "replace") { | ||
| for (const d of [...active]) { | ||
| if (collapse === "instant") d.removeNow(); | ||
| else d(); | ||
| } | ||
| } | ||
| const el = document.createElement("div"); | ||
| el.className = className; | ||
| if (variant !== void 0) el.classList.add(`${className}--${variant}`); | ||
| el.setAttribute("role", role); | ||
| region.appendChild(el); | ||
| const disposeMount = mount(el, typeof content === "function" ? content : () => content); | ||
| const state = { dismissed: false, removed: false, timer: void 0, exitTimer: void 0 }; | ||
| if (enterClass !== void 0) { | ||
| globalThis.requestAnimationFrame(() => { | ||
| if (!state.dismissed) el.classList.add(enterClass); | ||
| }); | ||
| } | ||
| const remove = () => { | ||
| if (state.removed) return; | ||
| state.removed = true; | ||
| state.dismissed = true; | ||
| if (state.exitTimer !== void 0) clearTimeout(state.exitTimer); | ||
| disposeMount(); | ||
| el.remove(); | ||
| active.delete(dismiss); | ||
| }; | ||
| const removeNow = () => { | ||
| if (state.timer !== void 0) clearTimeout(state.timer); | ||
| remove(); | ||
| }; | ||
| const fadeOut = () => { | ||
| if (state.dismissed) return; | ||
| state.dismissed = true; | ||
| if (state.timer !== void 0) clearTimeout(state.timer); | ||
| if (enterClass !== void 0) el.classList.remove(enterClass); | ||
| if (exitClass !== void 0) el.classList.add(exitClass); | ||
| if (exitClass !== void 0 || exitDuration > 0) { | ||
| state.exitTimer = setTimeout(remove, exitDuration); | ||
| } else { | ||
| remove(); | ||
| } | ||
| }; | ||
| function dismiss(options2) { | ||
| if (options2?.instant === true) removeNow(); | ||
| else fadeOut(); | ||
| } | ||
| dismiss.removeNow = removeNow; | ||
| active.add(dismiss); | ||
| if (duration > 0) state.timer = setTimeout(fadeOut, duration); | ||
| return { el, dismiss }; | ||
| } | ||
| // src/overlay.ts | ||
| var FOCUSABLE = 'a[href],area[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),iframe,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'; | ||
| function focusable(root) { | ||
| return Array.from(root.querySelectorAll(FOCUSABLE)).filter( | ||
| (el) => !el.hasAttribute("hidden") | ||
| ); | ||
| } | ||
| function supportsDialog() { | ||
| return typeof HTMLDialogElement !== "undefined" && typeof HTMLDialogElement.prototype.showModal === "function"; | ||
| } | ||
| function supportsPopover() { | ||
| return typeof HTMLElement !== "undefined" && typeof HTMLElement.prototype.showPopover === "function"; | ||
| } | ||
| function overlay(content, options = {}) { | ||
| const { | ||
| container = document.body, | ||
| className = "kerf-overlay", | ||
| dismiss = ["escape", "backdrop"], | ||
| initialFocus = true, | ||
| trap = true, | ||
| role = "dialog", | ||
| onDismiss, | ||
| outsideIgnore, | ||
| native = false | ||
| } = options; | ||
| const triggers = dismiss === false ? [] : Array.isArray(dismiss) ? dismiss : [dismiss]; | ||
| const restoreTo = document.activeElement; | ||
| const useDialog = native && trap && supportsDialog(); | ||
| const usePopover = native && !trap && supportsPopover(); | ||
| const wrapper = useDialog ? document.createElement("dialog") : document.createElement("div"); | ||
| wrapper.className = className; | ||
| if (usePopover) wrapper.setAttribute("popover", "manual"); | ||
| if (trap && !useDialog) { | ||
| wrapper.setAttribute("role", role); | ||
| wrapper.setAttribute("aria-modal", "true"); | ||
| } | ||
| container.appendChild(wrapper); | ||
| const disposeMount = mount(wrapper, typeof content === "function" ? content : () => content); | ||
| let nativeOpened = false; | ||
| if (useDialog) { | ||
| wrapper.showModal(); | ||
| nativeOpened = true; | ||
| } else if (usePopover) { | ||
| wrapper.showPopover(); | ||
| wrapper.style.inset = "auto"; | ||
| nativeOpened = true; | ||
| } | ||
| const removers = []; | ||
| const resultBox = {}; | ||
| const result = new Promise((resolve) => { | ||
| resultBox.resolve = resolve; | ||
| }); | ||
| const state = { closed: false }; | ||
| function close(value) { | ||
| if (state.closed) return; | ||
| state.closed = true; | ||
| for (const remove of removers) remove(); | ||
| disposeMount(); | ||
| if (nativeOpened) { | ||
| nativeOpened = false; | ||
| if (useDialog) wrapper.close(); | ||
| else wrapper.hidePopover(); | ||
| } | ||
| wrapper.remove(); | ||
| if (restoreTo instanceof HTMLElement && restoreTo.isConnected) restoreTo.focus(); | ||
| resultBox.resolve?.(value); | ||
| } | ||
| function userDismiss() { | ||
| onDismiss?.(); | ||
| close(); | ||
| } | ||
| const wantEscape = triggers.includes("escape"); | ||
| if (useDialog) { | ||
| const onCancel = (event) => { | ||
| event.preventDefault(); | ||
| if (wantEscape) userDismiss(); | ||
| }; | ||
| wrapper.addEventListener("cancel", onCancel); | ||
| removers.push(() => wrapper.removeEventListener("cancel", onCancel)); | ||
| } else if (wantEscape || trap) { | ||
| const onKeydown = (event) => { | ||
| if (wantEscape && event.key === "Escape") { | ||
| event.stopPropagation(); | ||
| userDismiss(); | ||
| return; | ||
| } | ||
| if (trap && event.key === "Tab") { | ||
| const items = focusable(wrapper); | ||
| if (items.length === 0) { | ||
| event.preventDefault(); | ||
| return; | ||
| } | ||
| const first = items[0]; | ||
| const last = items[items.length - 1]; | ||
| const active = document.activeElement; | ||
| const outside = !wrapper.contains(active); | ||
| if (event.shiftKey && (active === first || outside)) { | ||
| event.preventDefault(); | ||
| last.focus(); | ||
| } else if (!event.shiftKey && (active === last || outside)) { | ||
| event.preventDefault(); | ||
| first.focus(); | ||
| } | ||
| } | ||
| }; | ||
| document.addEventListener("keydown", onKeydown, true); | ||
| removers.push(() => document.removeEventListener("keydown", onKeydown, true)); | ||
| } | ||
| if (triggers.includes("backdrop")) { | ||
| const onClick = (event) => { | ||
| if (event.target === wrapper) userDismiss(); | ||
| }; | ||
| wrapper.addEventListener("click", onClick); | ||
| removers.push(() => wrapper.removeEventListener("click", onClick)); | ||
| } | ||
| if (triggers.includes("outside")) { | ||
| const ignore = outsideIgnore === void 0 ? [] : Array.isArray(outsideIgnore) ? outsideIgnore : [outsideIgnore]; | ||
| const onDocClick = (event) => { | ||
| const target = event.target; | ||
| if (target === null) return; | ||
| if (wrapper.contains(target)) return; | ||
| if (ignore.some((el) => el === target || el.contains(target))) return; | ||
| userDismiss(); | ||
| }; | ||
| document.addEventListener("click", onDocClick, true); | ||
| removers.push(() => document.removeEventListener("click", onDocClick, true)); | ||
| } | ||
| if (initialFocus !== false) { | ||
| if (typeof initialFocus === "string") { | ||
| wrapper.querySelector(initialFocus)?.focus(); | ||
| } else { | ||
| const first = focusable(wrapper)[0]; | ||
| if (first !== void 0) { | ||
| first.focus(); | ||
| } else { | ||
| wrapper.tabIndex = -1; | ||
| wrapper.focus(); | ||
| } | ||
| } | ||
| } | ||
| return { el: wrapper, close, result }; | ||
| } | ||
| // src/overlay-dialogs.ts | ||
| function confirm(message, options = {}) { | ||
@@ -550,2 +329,225 @@ const { | ||
| } | ||
| // src/overlay-toast.ts | ||
| var TOAST_SET = /* @__PURE__ */ Symbol("kerf.toasts"); | ||
| function toastRegion(container) { | ||
| if (container !== void 0) return container; | ||
| const existing = document.querySelector(".kerf-toasts"); | ||
| if (existing !== null) return existing; | ||
| const region = document.createElement("div"); | ||
| region.className = "kerf-toasts"; | ||
| region.setAttribute("aria-live", "polite"); | ||
| document.body.appendChild(region); | ||
| return region; | ||
| } | ||
| function toast(content, options = {}) { | ||
| const { | ||
| container, | ||
| className = "kerf-toast", | ||
| duration = 4e3, | ||
| role = "status", | ||
| mode = "stack", | ||
| collapse = "fade", | ||
| variant, | ||
| enterClass, | ||
| exitClass, | ||
| exitDuration = 0 | ||
| } = options; | ||
| const region = toastRegion(container); | ||
| const active = region[TOAST_SET] ??= /* @__PURE__ */ new Set(); | ||
| if (mode === "replace") { | ||
| for (const d of [...active]) { | ||
| if (collapse === "instant") d.removeNow(); | ||
| else d(); | ||
| } | ||
| } | ||
| const el = document.createElement("div"); | ||
| el.className = className; | ||
| if (variant !== void 0) el.classList.add(`${className}--${variant}`); | ||
| el.setAttribute("role", role); | ||
| region.appendChild(el); | ||
| const disposeMount = mount(el, typeof content === "function" ? content : () => content); | ||
| const state = { dismissed: false, removed: false, timer: void 0, exitTimer: void 0 }; | ||
| if (enterClass !== void 0) { | ||
| globalThis.requestAnimationFrame(() => { | ||
| if (!state.dismissed) el.classList.add(enterClass); | ||
| }); | ||
| } | ||
| const remove = () => { | ||
| if (state.removed) return; | ||
| state.removed = true; | ||
| state.dismissed = true; | ||
| if (state.exitTimer !== void 0) clearTimeout(state.exitTimer); | ||
| disposeMount(); | ||
| el.remove(); | ||
| active.delete(dismiss); | ||
| }; | ||
| const removeNow = () => { | ||
| if (state.timer !== void 0) clearTimeout(state.timer); | ||
| remove(); | ||
| }; | ||
| const fadeOut = () => { | ||
| if (state.dismissed) return; | ||
| state.dismissed = true; | ||
| if (state.timer !== void 0) clearTimeout(state.timer); | ||
| if (enterClass !== void 0) el.classList.remove(enterClass); | ||
| if (exitClass !== void 0) el.classList.add(exitClass); | ||
| if (exitClass !== void 0 || exitDuration > 0) { | ||
| state.exitTimer = setTimeout(remove, exitDuration); | ||
| } else { | ||
| remove(); | ||
| } | ||
| }; | ||
| function dismiss(options2) { | ||
| if (options2?.instant === true) removeNow(); | ||
| else fadeOut(); | ||
| } | ||
| dismiss.removeNow = removeNow; | ||
| active.add(dismiss); | ||
| if (duration > 0) state.timer = setTimeout(fadeOut, duration); | ||
| return { el, dismiss }; | ||
| } | ||
| // src/overlay.ts | ||
| var FOCUSABLE = 'a[href],area[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),iframe,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'; | ||
| function focusable(root) { | ||
| return Array.from(root.querySelectorAll(FOCUSABLE)).filter( | ||
| (el) => !el.hasAttribute("hidden") | ||
| ); | ||
| } | ||
| function supportsDialog() { | ||
| return typeof HTMLDialogElement !== "undefined" && typeof HTMLDialogElement.prototype.showModal === "function"; | ||
| } | ||
| function supportsPopover() { | ||
| return typeof HTMLElement !== "undefined" && typeof HTMLElement.prototype.showPopover === "function"; | ||
| } | ||
| function overlay(content, options = {}) { | ||
| const { | ||
| container = document.body, | ||
| className = "kerf-overlay", | ||
| dismiss = ["escape", "backdrop"], | ||
| initialFocus = true, | ||
| trap = true, | ||
| role = "dialog", | ||
| onDismiss, | ||
| outsideIgnore, | ||
| native = false | ||
| } = options; | ||
| const triggers = dismiss === false ? [] : Array.isArray(dismiss) ? dismiss : [dismiss]; | ||
| const restoreTo = document.activeElement; | ||
| const useDialog = native && trap && supportsDialog(); | ||
| const usePopover = native && !trap && supportsPopover(); | ||
| const wrapper = useDialog ? document.createElement("dialog") : document.createElement("div"); | ||
| wrapper.className = className; | ||
| if (usePopover) wrapper.setAttribute("popover", "manual"); | ||
| if (trap && !useDialog) { | ||
| wrapper.setAttribute("role", role); | ||
| wrapper.setAttribute("aria-modal", "true"); | ||
| } | ||
| container.appendChild(wrapper); | ||
| const disposeMount = mount(wrapper, typeof content === "function" ? content : () => content); | ||
| let nativeOpened = false; | ||
| if (useDialog) { | ||
| wrapper.showModal(); | ||
| nativeOpened = true; | ||
| } else if (usePopover) { | ||
| wrapper.showPopover(); | ||
| wrapper.style.inset = "auto"; | ||
| nativeOpened = true; | ||
| } | ||
| const removers = []; | ||
| const resultBox = {}; | ||
| const result = new Promise((resolve) => { | ||
| resultBox.resolve = resolve; | ||
| }); | ||
| const state = { closed: false }; | ||
| function close(value) { | ||
| if (state.closed) return; | ||
| state.closed = true; | ||
| for (const remove of removers) remove(); | ||
| disposeMount(); | ||
| if (nativeOpened) { | ||
| nativeOpened = false; | ||
| if (useDialog) wrapper.close(); | ||
| else wrapper.hidePopover(); | ||
| } | ||
| wrapper.remove(); | ||
| if (restoreTo instanceof HTMLElement && restoreTo.isConnected) restoreTo.focus(); | ||
| resultBox.resolve?.(value); | ||
| } | ||
| function userDismiss() { | ||
| onDismiss?.(); | ||
| close(); | ||
| } | ||
| const wantEscape = triggers.includes("escape"); | ||
| if (useDialog) { | ||
| const onCancel = (event) => { | ||
| event.preventDefault(); | ||
| if (wantEscape) userDismiss(); | ||
| }; | ||
| wrapper.addEventListener("cancel", onCancel); | ||
| removers.push(() => wrapper.removeEventListener("cancel", onCancel)); | ||
| } else if (wantEscape || trap) { | ||
| const onKeydown = (event) => { | ||
| if (wantEscape && event.key === "Escape") { | ||
| event.stopPropagation(); | ||
| userDismiss(); | ||
| return; | ||
| } | ||
| if (trap && event.key === "Tab") { | ||
| const items = focusable(wrapper); | ||
| if (items.length === 0) { | ||
| event.preventDefault(); | ||
| return; | ||
| } | ||
| const first = items[0]; | ||
| const last = items[items.length - 1]; | ||
| const active = document.activeElement; | ||
| const outside = !wrapper.contains(active); | ||
| if (event.shiftKey && (active === first || outside)) { | ||
| event.preventDefault(); | ||
| last.focus(); | ||
| } else if (!event.shiftKey && (active === last || outside)) { | ||
| event.preventDefault(); | ||
| first.focus(); | ||
| } | ||
| } | ||
| }; | ||
| document.addEventListener("keydown", onKeydown, true); | ||
| removers.push(() => document.removeEventListener("keydown", onKeydown, true)); | ||
| } | ||
| if (triggers.includes("backdrop")) { | ||
| const onClick = (event) => { | ||
| if (event.target === wrapper) userDismiss(); | ||
| }; | ||
| wrapper.addEventListener("click", onClick); | ||
| removers.push(() => wrapper.removeEventListener("click", onClick)); | ||
| } | ||
| if (triggers.includes("outside")) { | ||
| const ignore = outsideIgnore === void 0 ? [] : Array.isArray(outsideIgnore) ? outsideIgnore : [outsideIgnore]; | ||
| const onDocClick = (event) => { | ||
| const target = event.target; | ||
| if (target === null) return; | ||
| if (wrapper.contains(target)) return; | ||
| if (ignore.some((el) => el === target || el.contains(target))) return; | ||
| userDismiss(); | ||
| }; | ||
| document.addEventListener("click", onDocClick, true); | ||
| removers.push(() => document.removeEventListener("click", onDocClick, true)); | ||
| } | ||
| if (initialFocus !== false) { | ||
| if (typeof initialFocus === "string") { | ||
| wrapper.querySelector(initialFocus)?.focus(); | ||
| } else { | ||
| const first = focusable(wrapper)[0]; | ||
| if (first !== void 0) { | ||
| first.focus(); | ||
| } else { | ||
| wrapper.tabIndex = -1; | ||
| wrapper.focus(); | ||
| } | ||
| } | ||
| } | ||
| return { el: wrapper, close, result }; | ||
| } | ||
| function popover(anchor, content, options = {}) { | ||
@@ -552,0 +554,0 @@ const { |
+3
-2
@@ -5,3 +5,3 @@ # kerf | ||
| kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "list"/"mixed" segments where `each(...)` was used) and reconciles it against the live tree with a custom segment-aware morph. Static surrounds go through a general-purpose tree-morph; list contents go through a keyed reconciler that operates directly on live children — partial-update on huge lists is O(changes), not O(rows). Reactivity is provided by [@preact/signals-core](https://github.com/preactjs/signals). It pairs well with server-rendered HTML, embedded widgets, and any UI where preserving focus / selection across re-renders matters. Public API is one import: `signal`, `computed`, `effect`, `batch`, `defineStore`, `resetAllStores`, `mount`, `morph`, `each`, `attr`, `delegate`, `delegateCapture`, `toElement`, `renderDocument`, `SafeHtml`, `isSafeHtml`, `raw`, `Fragment`. (Two more subpaths: `kerfjs/testing` exposes `clearStoreRegistry` for unit-test isolation; `kerfjs/jsx-runtime` exposes the typed JSX building blocks for declaration-merging custom-element types.) An optional subpath at `kerfjs/array-signal` adds `arraySignal()` — a granular keyed-list signal whose patch events let `each()` reconcile in O(patches) instead of O(N). An optional subpath at `kerfjs/dev` installs the development diagnostics — kerf does NOT infer dev mode, so you import it behind your own build's dev flag (`if (import.meta.env.DEV) await import('kerfjs/dev');`); omitting it is production and sheds ~4.7 KB min+gzip. Another optional subpath at `kerfjs/html` adds the `html` tagged template — JSX-identical runtime semantics with no JSX transform, so CDN/importmap projects can author kerf UIs with literally no build step. A family of optional, tree-shakeable **companion-utility** subpaths cover patterns real apps hand-roll: `kerfjs/list` (`bindList` — a keyed list with per-row fine-grained mounts and fixed / declared / measured-height viewport virtualization, plus `observeRowHeights`), `kerfjs/overlay` (`overlay` / `confirm` / `prompt` / `form` / `choice` / `popover` / `tooltip` / `toast` + `positionAnchored` / `autoReposition`), `kerfjs/scope` (`disposeScope` / `disposeSubtree` / `observeRemovals`), `kerfjs/async` (`resource` async-state with a stale-response guard + SWR cache), `kerfjs/timing` (`debounce` / `throttle` / `debouncedSignal`), `kerfjs/remount` (`remountOn` — key-driven wholesale subtree replacement), `kerfjs/attach` (`attach` — bind a non-kerf widget's lifecycle to one node), and `kerfjs/actions` (`action` / `delegateActions` — the delegated `data-action` table idiom). | ||
| kerf renders JSX to a structured `SafeHtml` (string for static content; tagged "list"/"mixed" segments where `each(...)` was used) and reconciles it against the live tree with a custom segment-aware morph. Static surrounds go through a general-purpose tree-morph; list contents go through a keyed reconciler that operates directly on live children — partial-update on huge lists is O(changes), not O(rows). Reactivity is provided by [@preact/signals-core](https://github.com/preactjs/signals). It pairs well with server-rendered HTML, embedded widgets, and any UI where preserving focus / selection across re-renders matters. Public API is one import: `signal`, `computed`, `effect`, `batch`, `defineStore`, `resetAllStores`, `mount`, `morph`, `each`, `attr`, `delegate`, `delegateCapture`, `toElement`, `renderDocument`, `SafeHtml`, `isSafeHtml`, `raw`, `Fragment`. (Two more subpaths: `kerfjs/testing` exposes `clearStoreRegistry` for unit-test isolation; `kerfjs/jsx-runtime` exposes the typed JSX building blocks for declaration-merging custom-element types.) An optional subpath at `kerfjs/array-signal` adds `arraySignal()` — a granular keyed-list signal whose patch events let `each()` reconcile in O(patches) instead of O(N). An optional subpath at `kerfjs/dev` installs the development diagnostics — kerf does NOT infer dev mode, so you import it behind your own build's dev flag (`if (import.meta.env.DEV) await import('kerfjs/dev');`); omitting it is production and sheds ~4.7 KB min+gzip. Another optional subpath at `kerfjs/html` adds the `html` tagged template — JSX-identical runtime semantics with no JSX transform, so CDN/importmap projects can author kerf UIs with literally no build step. A family of optional, tree-shakeable **companion-utility** subpaths cover patterns real apps hand-roll: `kerfjs/list` (`bindList` — a keyed list with per-row fine-grained mounts and fixed / declared / measured-height viewport virtualization, plus `observeRowHeights`), `kerfjs/overlay` (`overlay` / `confirm` / `prompt` / `form` / `choice` / `popover` / `tooltip` / `toast` + `positionAnchored` / `autoReposition`, with opt-in `native: true` top-layer backing via `<dialog>` / the Popover API), `kerfjs/scope` (`disposeScope` / `disposeSubtree` / `observeRemovals`), `kerfjs/async` (`resource` async-state with a stale-response guard + SWR cache), `kerfjs/timing` (`debounce` / `throttle` / `debouncedSignal`), `kerfjs/remount` (`remountOn` — key-driven wholesale subtree replacement), `kerfjs/attach` (`attach` — bind a non-kerf widget's lifecycle to one node), and `kerfjs/actions` (`action` / `delegateActions` — the delegated `data-action` table idiom). | ||
@@ -40,4 +40,5 @@ ## For humans new to the codebase | ||
| - [List identity](https://github.com/brianwestphal/kerf/blob/main/docs/16-list-identity.md): why an `each()` list's call-order identity is not stable, what the source guard fixes and what it doesn't, the five constraints any scheme must survive, and the explicit-key recommendation now shipped as `each(items, render, { key })`. | ||
| - [List virtualization](https://github.com/brianwestphal/kerf/blob/main/docs/17-list-virtualization.md): `bindList`'s virtualization height models — fixed `number`, app-declared `(item, index) => number`, and measured `{ estimate }` + `setHeight` — with kerf owning the cumulative-offset math and scroll anchoring while the app owns measurement (the `observeRowHeights` helper), plus the `minRows` render-all threshold and the container/resize ergonomics. | ||
| - [List virtualization](https://github.com/brianwestphal/kerf/blob/main/docs/17-list-virtualization.md): `bindList`'s virtualization — the `window` (default) height models (fixed `number`, app-declared `(item, index) => number`, and measured `{ estimate }` + `setHeight`) with kerf owning the cumulative-offset math and scroll anchoring while the app owns measurement (the `observeRowHeights` helper), the `minRows` render-all threshold and container/resize ergonomics, and the `content-visibility` mode that keeps every row in the DOM (full find-in-page / a11y) while the browser skips off-screen layout, plus the findability/a11y tradeoff of the default `window` mode. | ||
| - [State-preserving moves](https://github.com/brianwestphal/kerf/blob/main/docs/18-state-preserving-moves.md): connected-row reorders (every `each()` / `bindList` / `morph` move site) use `Node.prototype.moveBefore()` where the engine supports it — an atomic move that keeps focus, selection, `<iframe>` state, playing media, and running CSS animations across the reorder — falling back to `insertBefore()` otherwise. Transparent internal `moveNode` helper; no API change. | ||
| - [Native overlay backing](https://github.com/brianwestphal/kerf/blob/main/docs/19-native-overlay-backing.md): opt-in `native: true` on every `kerfjs/overlay` surface hosts the overlay in the browser top layer — a `<dialog>.showModal()` for modal surfaces, the Popover API for non-modal — feature-detected, falling back to today's plain `<div>` where unsupported. Fixes stacking (top layer beats any `z-index`), real inerting, and native light-dismiss; opt-in because the native elements carry UA styles kerf's zero-CSS contract won't reset. | ||
@@ -44,0 +45,0 @@ ## Examples |
+1
-1
| { | ||
| "name": "kerfjs", | ||
| "version": "4.3.0-beta.1", | ||
| "version": "4.3.0", | ||
| "description": "Tiny reactive UI framework — fine-grained signals + DOM morphing + JSX. Apply the smallest possible cut to update your DOM.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+1
-1
@@ -56,3 +56,3 @@ <p align="center"> | ||
| 8. **Batteries on their own subpaths.** Nine optional, tree-shakeable subpaths cover the patterns every real app otherwise hand-rolls — **`kerfjs/list`** (a keyed list with per-row fine-grained mounts and fixed / app-declared / measured-height viewport **virtualization**), **`kerfjs/overlay`** (modals, `confirm` / `prompt` / `form` / `choice`, anchored popovers + tooltips, toasts), **`kerfjs/async`** (`resource` async-state with a built-in stale-response guard + SWR cache), **`kerfjs/scope`** (dispose-scopes that tie teardown to a DOM node's lifetime), plus `timing`, `remount`, `attach`, and `actions`. None of them grows the ~12 KB core until you import it. | ||
| 8. **Batteries on their own subpaths.** Eight optional, tree-shakeable subpaths cover the patterns every real app otherwise hand-rolls — **`kerfjs/list`** (a keyed list with per-row fine-grained mounts and fixed / app-declared / measured-height viewport **virtualization**, plus a `content-visibility` mode that keeps every row find-in-page-able), **`kerfjs/overlay`** (modals, `confirm` / `prompt` / `form` / `choice`, anchored popovers + tooltips, toasts — with opt-in native **top-layer** backing that stacks above any `z-index`), **`kerfjs/async`** (`resource` async-state with a built-in stale-response guard + SWR cache), **`kerfjs/scope`** (dispose-scopes that tie teardown to a DOM node's lifetime), plus `timing`, `remount`, `attach`, and `actions`. None of them grows the ~12 KB core until you import it. | ||
@@ -59,0 +59,0 @@ 9. **Plain TS, plain JSX, plain ESM.** Drops into anything using esbuild / Vite / tsup. No plugin chain. And with the `html` tagged template (`import { html } from 'kerfjs/html'` — identical runtime semantics to JSX), a CDN / importmap project needs no build step at all. |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
1143364
0.34%7891
0.28%0
-100%