diff --git a/dist/build/build.js b/dist/build/build.js index 26762e2b25..6b18b465ae 100644 --- a/dist/build/build.js +++ b/dist/build/build.js @@ -2364,11 +2364,21 @@ ${urlInfo.url}`, const injectionSymbol = Symbol.for("jsenv_injection"); const INJECTIONS = { + /** + * Inject `Object.assign(window, { [key]: value })` at the top of the file + * (into a script for html, into the module itself for js) instead of + * replacing a placeholder: the value is read at runtime as a global. + */ global: (value) => { return { [injectionSymbol]: "global", value }; }, + /** + * Replace the placeholder when the file contains it, stay silent when it does not + * (without this a missing placeholder is reported as a warning). + */ optional: (value) => { - if (value && value[injectionSymbol] === "optional") { + if (value && value[injectionSymbol]) { + // a global injection is not a placeholder, it can't be missing from the file return value; } return { [injectionSymbol]: "optional", value }; @@ -2473,12 +2483,7 @@ return { magicSource.replace({ start, end, - replacement: - urlInfo.type === "js_classic" || - urlInfo.type === "js_module" || - urlInfo.type === "html" - ? JSON.stringify(value, null, " ") - : value, + replacement: asReplacement(value, urlInfo), }); index = content.indexOf(key, end); } @@ -2486,6 +2491,19 @@ return { return magicSource.toContentAndSourcemap(); }; +// In JS the placeholder stands for a value, so it must be substituted by a literal. +// Everywhere else (html attributes and text, css, ...) it stands for a piece of text +// and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me" +const asReplacement = (value, urlInfo) => { + if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") { + return JSON.stringify(value, null, " "); + } + if (typeof value === "string") { + return value; + } + return JSON.stringify(value, null, " "); +}; + const injectGlobals = (content, globals, urlInfo) => { if (urlInfo.type === "html") { return globalInjectorOnHtml(content, globals, urlInfo); @@ -6560,7 +6578,9 @@ const jsenvPluginVersionSearchParam = () => { * of them, the page switcher (cmd+K) opens one in the current tab. Whoever asks * gets the same answer. * - * Each page comes with what kind of page it is, read from where it sits and + * Each page comes with where its file is (so it can be opened in an editor as + * well as in the browser) and with what kind of page it is, read from where it + * sits and * what it is called — the two conventions this repo already follows: * - "experiment": something tried out, under a lab/ directory or named * *_experiment.html; @@ -6659,6 +6679,10 @@ const createHtmlPageLister = ({ rootDirectoryUrl }) => { ); return { url: `/${relativeUrl}`, + // Where the file actually is, so whoever wants to open it in an editor + // rather than in the browser has what GET /.internal/open_file/* asks + // for (a file url) without having to know the root directory. + fileUrl, kind: readKind(meta), // Relative to the root and without its trailing slash, which is how a // tree names its own nodes. @@ -7212,6 +7236,10 @@ const jsenvPluginFsRedirection = ({ } const { requestedUrl, rootDirectoryUrl, mainFilePath } = reference.ownerUrlInfo.context; + if (!requestedUrl) { + // the SPA fallback answers a request; during build there is none + return null; + } const closestHtmlRootFile = getClosestHtmlRootFile( requestedUrl, rootDirectoryUrl, @@ -7624,18 +7652,45 @@ const jsenvPluginInjections = (rawAssociations) => { { injectionsGetter: rawAssociations }, context.rootDirectoryUrl, ); - getInjections = (urlInfo) => { + const findInjectionsGetter = (urlInfo) => { const { injectionsGetter } = URL_META.applyAssociations({ url: asUrlWithoutSearch(urlInfo.url), associations: resolvedAssociations, }); - if (!injectionsGetter) { + if (injectionsGetter) { + return { injectionsGetter, isInherited: false }; + } + if (urlInfo.isInline) { + // content inlined into a file (a ` gets the JS literal, + * which is how a value is shared with every js file of the page. + * Use INJECTIONS.optional(value) for a placeholder that may be absent from the file + * and INJECTIONS.global(value) to inject `Object.assign(window, { ... })` instead of + * replacing a placeholder. * * @return {Promise} buildReturnValue * @return {Promise} [buildReturnValue.buildInlineContents] @@ -11939,6 +12025,17 @@ const build = async ({ { const unexpectedParamNames = Object.keys(rest); if (unexpectedParamNames.length > 0) { + const entryPointParamNames = unexpectedParamNames.filter((name) => + Object.hasOwn(entryPointDefaultParams, name), + ); + if (entryPointParamNames.length > 0) { + throw new TypeError( + `${entryPointParamNames.join(",")}: param(s) configured per entry point, move them into entryPoints, as in: +entryPoints: { + "./index.html": { ${entryPointParamNames.map((name) => `${name}: ...`).join(", ")} }, +}`, + ); + } throw new TypeError( `${unexpectedParamNames.join(",")}: there is no such param`, ); diff --git a/dist/js/page_switcher.js b/dist/js/page_switcher.js index 2abfa5ecf4..0801c13d40 100644 --- a/dist/js/page_switcher.js +++ b/dist/js/page_switcher.js @@ -2,6 +2,11 @@ * cmd+K / ctrl+K on any dev-served page: the .html files the server serves, as a * tree one walks, filter as you type, Enter to go there. * + * cmd+E / ctrl+E is the other half of the same question: instead of going to a + * page, open its file in the editor (the server does it, see + * GET /.internal/open_file/*). Pressed on the page itself it opens the page one + * is on; pressed inside the switcher it opens the row one is looking at. + * * A tree rather than a list of paths, because the paths are mostly the same * path: folding every directory that holds a single thing turns * "packages/frontend/navi/src/layout/demos/…" repeated forty times into one @@ -25,6 +30,10 @@ // another injected client. A function scope owes nothing to anybody. (() => { const PAGES_ENDPOINT = "/.internal/pages.json"; + // The server asks the OS to open a file in whatever editor is configured + // (VSCode here) — it takes a file url, which is why the page list carries one + // per page (see html_pages.js). + const OPEN_FILE_ENDPOINT = "/.internal/open_file/"; const STORAGE_KEY = "jsenv_page_switcher"; // Open across a reload: this is a dev tool on a page one is editing, and a hot // reload in the middle of looking for the next page should not close what one @@ -56,15 +65,20 @@ } }; - const isSwitcherKey = (event) => { - if (event.key !== "k" && event.key !== "K") { - return false; - } - // cmd on mac, ctrl elsewhere — the same split every editor makes. - return window.navigator.platform.toLowerCase().includes("mac") + // cmd on mac, ctrl elsewhere — the same split every editor makes. + const isCommandKey = (event) => + window.navigator.platform.toLowerCase().includes("mac") ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; - }; + + const isSwitcherKey = (event) => + (event.key === "k" || event.key === "K") && isCommandKey(event); + // E for edit, next to K for the same reason the two belong together: K asks + // "which page", E asks "where does this page live". Outside the switcher it + // means the page one is on; inside it, the row one is looking at — so the + // same press reads the same way in both places. + const isEditorKey = (event) => + (event.key === "e" || event.key === "E") && isCommandKey(event); const STYLE_TEXT = /* css */ ` :host { @@ -230,6 +244,32 @@ } `; + const FLASH_STYLE_TEXT = /* css */ ` + :host { + position: fixed; + right: 16px; + bottom: 16px; + /* Above the switcher's own panel: it is the switcher that triggers it. */ + z-index: 2147483647; + display: block; + font-family: system-ui, sans-serif; + pointer-events: none; + } + .flash { + padding: 8px 14px; + color: light-dark(#0f172a, #e2e8f0); + font-size: 13px; + background: light-dark(white, #1e293b); + border-radius: 8px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + color-scheme: light dark; + } + .flash[data-error] { + color: light-dark(#991b1b, #fecaca); + background: light-dark(#fee2e2, #7f1d1d); + } + `; + let pagesPromise = null; const loadPages = () => { // Once per page load: the list is a filesystem scan behind a short cache @@ -240,6 +280,54 @@ return pagesPromise; }; + // Opening a file in an editor happens in another application, on another + // screen sometimes: without a word here, a press that failed and a press that + // worked look exactly the same. Its own host and its own shadow root, so it + // can be shown whether or not the switcher is open. + let flashHost = null; + let flashBox = null; + let flashTimeout = null; + const flash = (message, isError) => { + if (!flashHost) { + flashHost = document.createElement("div"); + const shadow = flashHost.attachShadow({ mode: "open" }); + const style = document.createElement("style"); + style.textContent = FLASH_STYLE_TEXT; + flashBox = document.createElement("div"); + flashBox.className = "flash"; + shadow.append(style, flashBox); + } + flashBox.textContent = message; + flashBox.toggleAttribute("data-error", Boolean(isError)); + // Appended last every time, so it sits above the switcher's host when both + // are on the page and they share the same z-index. + document.body.append(flashHost); + window.clearTimeout(flashTimeout); + flashTimeout = window.setTimeout(() => flashHost.remove(), 2500); + }; + + const openInEditor = async (file) => { + if (!file || !file.fileUrl) { + flash("This page is not a file the server lists.", true); + return; + } + flash(`Opening ${file.name || file.url} in editor…`); + try { + const response = await fetch( + `${OPEN_FILE_ENDPOINT}${encodeURIComponent(file.fileUrl)}`, + ); + if (response.status === 404) { + // The route exists only when the server is willing to expose the + // machine it runs on (see start_server.js). + flash("This server does not open files in an editor.", true); + } else if (!response.ok) { + flash(`Editor said no (${response.status}).`, true); + } + } catch { + flash("Could not reach the dev server.", true); + } + }; + const readStoredState = () => { try { const stored = JSON.parse( @@ -396,7 +484,11 @@ panel.className = "panel"; const input = document.createElement("input"); input.type = "search"; - input.placeholder = "Go to page…"; + // The other key is written where one is already looking: a shortcut nobody + // is told about is a shortcut nobody presses. Both names, not the one this + // platform uses — the reader knows which of the two their keyboard has, and + // it keeps what the panel says the same everywhere. + input.placeholder = "Go to page… (cmd/ctrl+E to open in editor)"; input.setAttribute("aria-label", "Go to page"); const kindsRow = document.createElement("div"); kindsRow.className = "kinds"; @@ -664,6 +756,21 @@ toggleCollapsed(row.node.path); return; } + if (isEditorKey(event)) { + // Taken whatever the row is: let go of on a directory it would reach + // the page below and open the page one came from, which is not what a + // key pressed inside an open switcher can be asking for. + stop(); + const row = rows[currentIndex]; + if (!row || row.type !== "file") { + return; + } + // Done with the switcher: the answer to "where does this live" arrives + // in the editor, not here. + close(); + openInEditor(row.file); + return; + } if (isSwitcherKey(event)) { stop(); close(); @@ -708,15 +815,31 @@ // preventDefault, the key was theirs and nothing happens here. const listenSwitcherKey = () => { window.addEventListener("keydown", (event) => { - if (event.defaultPrevented || !isSwitcherKey(event)) { + if (event.defaultPrevented) { return; } - // Ours now: the browser has its own use for cmd+K (the address bar), which - // it must not get. - event.preventDefault(); - openSwitcher(); + if (isSwitcherKey(event)) { + // Ours now: the browser has its own use for cmd+K (the address bar), + // which it must not get. + event.preventDefault(); + openSwitcher(); + return; + } + if (isEditorKey(event)) { + event.preventDefault(); + openCurrentPageInEditor(); + } }); }; + // The page one is looking at, in the editor. The list is where the file url + // comes from, so a page the server does not list (an @fs url, something under + // node_modules) says so rather than opening the wrong thing. + const openCurrentPageInEditor = async () => { + const here = currentPageUrl(); + const pages = await loadPages(); + const page = pages.find((candidate) => candidate.url === here); + openInEditor(page && { ...page, name: here.split("/").pop() }); + }; const setup = () => { listenSwitcherKey(); if (wasOpen()) { diff --git a/dist/jsenv_core.js b/dist/jsenv_core.js index b07773fb58..abe86cd6db 100644 --- a/dist/jsenv_core.js +++ b/dist/jsenv_core.js @@ -3,11 +3,21 @@ import "@jsenv/sourcemap"; const injectionSymbol = Symbol.for("jsenv_injection"); const INJECTIONS = { + /** + * Inject `Object.assign(window, { [key]: value })` at the top of the file + * (into a script for html, into the module itself for js) instead of + * replacing a placeholder: the value is read at runtime as a global. + */ global: (value) => { return { [injectionSymbol]: "global", value }; }, + /** + * Replace the placeholder when the file contains it, stay silent when it does not + * (without this a missing placeholder is reported as a warning). + */ optional: (value) => { - if (value && value[injectionSymbol] === "optional") { + if (value && value[injectionSymbol]) { + // a global injection is not a placeholder, it can't be missing from the file return value; } return { [injectionSymbol]: "optional", value }; diff --git a/dist/start_dev_server/start_dev_server.js b/dist/start_dev_server/start_dev_server.js index 09484be60c..08bf527a8c 100644 --- a/dist/start_dev_server/start_dev_server.js +++ b/dist/start_dev_server/start_dev_server.js @@ -1321,7 +1321,9 @@ const jsenvPluginClientMonitoring = () => { /* * cmd+K (ctrl+K elsewhere) on any page the dev server serves opens a list of - * the .html files it serves, filter as you type, Enter to go there. + * the .html files it serves, filter as you type, Enter to go there. cmd+E + * (ctrl+E elsewhere) opens a page's file in the editor instead of going to it: + * the current page from anywhere, the selected row from inside the switcher. * * The list is the one the filesystem plugin already publishes for everyone * (GET /.internal/pages.json, see protocol_file/html_pages.js) — this only adds @@ -3327,7 +3329,9 @@ const FILE_AND_SERVER_URLS_CONVERTER = { * of them, the page switcher (cmd+K) opens one in the current tab. Whoever asks * gets the same answer. * - * Each page comes with what kind of page it is, read from where it sits and + * Each page comes with where its file is (so it can be opened in an editor as + * well as in the browser) and with what kind of page it is, read from where it + * sits and * what it is called — the two conventions this repo already follows: * - "experiment": something tried out, under a lab/ directory or named * *_experiment.html; @@ -3426,6 +3430,10 @@ const createHtmlPageLister = ({ rootDirectoryUrl }) => { ); return { url: `/${relativeUrl}`, + // Where the file actually is, so whoever wants to open it in an editor + // rather than in the browser has what GET /.internal/open_file/* asks + // for (a file url) without having to know the root directory. + fileUrl, kind: readKind(meta), // Relative to the root and without its trailing slash, which is how a // tree names its own nodes. @@ -4092,6 +4100,10 @@ const jsenvPluginFsRedirection = ({ } const { requestedUrl, rootDirectoryUrl, mainFilePath } = reference.ownerUrlInfo.context; + if (!requestedUrl) { + // the SPA fallback answers a request; during build there is none + return null; + } const closestHtmlRootFile = getClosestHtmlRootFile( requestedUrl, rootDirectoryUrl, @@ -4986,11 +4998,21 @@ const jsenvPluginDirectoryReferenceEffect = ( const injectionSymbol = Symbol.for("jsenv_injection"); const INJECTIONS = { + /** + * Inject `Object.assign(window, { [key]: value })` at the top of the file + * (into a script for html, into the module itself for js) instead of + * replacing a placeholder: the value is read at runtime as a global. + */ global: (value) => { return { [injectionSymbol]: "global", value }; }, + /** + * Replace the placeholder when the file contains it, stay silent when it does not + * (without this a missing placeholder is reported as a warning). + */ optional: (value) => { - if (value && value[injectionSymbol] === "optional") { + if (value && value[injectionSymbol]) { + // a global injection is not a placeholder, it can't be missing from the file return value; } return { [injectionSymbol]: "optional", value }; @@ -5095,12 +5117,7 @@ return { magicSource.replace({ start, end, - replacement: - urlInfo.type === "js_classic" || - urlInfo.type === "js_module" || - urlInfo.type === "html" - ? JSON.stringify(value, null, " ") - : value, + replacement: asReplacement(value, urlInfo), }); index = content.indexOf(key, end); } @@ -5108,6 +5125,19 @@ return { return magicSource.toContentAndSourcemap(); }; +// In JS the placeholder stands for a value, so it must be substituted by a literal. +// Everywhere else (html attributes and text, css, ...) it stands for a piece of text +// and is substituted as-is, so it can be concatenated: href="__BACKEND_URL__/users/me" +const asReplacement = (value, urlInfo) => { + if (urlInfo.type === "js_classic" || urlInfo.type === "js_module") { + return JSON.stringify(value, null, " "); + } + if (typeof value === "string") { + return value; + } + return JSON.stringify(value, null, " "); +}; + const injectGlobals = (content, globals, urlInfo) => { if (urlInfo.type === "html") { return globalInjectorOnHtml(content, globals, urlInfo); @@ -5181,18 +5211,45 @@ const jsenvPluginInjections = (rawAssociations) => { { injectionsGetter: rawAssociations }, context.rootDirectoryUrl, ); - getInjections = (urlInfo) => { + const findInjectionsGetter = (urlInfo) => { const { injectionsGetter } = URL_META.applyAssociations({ url: asUrlWithoutSearch(urlInfo.url), associations: resolvedAssociations, }); - if (!injectionsGetter) { + if (injectionsGetter) { + return { injectionsGetter, isInherited: false }; + } + if (urlInfo.isInline) { + // content inlined into a file (a ` shares the value with every js file of the page. See `INJECTIONS.optional` and `INJECTIONS.global`. * @param {object} [params.runtimeCompat] - Target runtimes; warns when dev code wouldn't survive the build. * @param {string} [params.sourcemaps="inline"] - Sourcemap mode. * @param {AbortSignal} [params.signal] - Abort to stop the server. diff --git a/package.json b/package.json index fa9012b163..abe041cc92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jsenv/core", - "version": "41.4.3", + "version": "41.4.4", "type": "module", "description": "Tool to develop, test and build js projects", "repository": { diff --git a/packages/frontend/navi/dist/jsenv_navi.js b/packages/frontend/navi/dist/jsenv_navi.js index 01a275b3bb..7c1f3b480b 100644 --- a/packages/frontend/navi/dist/jsenv_navi.js +++ b/packages/frontend/navi/dist/jsenv_navi.js @@ -2,19133 +2,14977 @@ * AI reading this file: read ../docs/AI_INSTRUCTIONS.md for context on * using @jsenv/navi as intended. */ -import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, coarsePointerSignal } from "./jsenv_navi_side_effects.js"; -import { isValidElement, createContext, h, toChildArray, render, Fragment, cloneElement } from "preact"; -import { useErrorBoundary, useLayoutEffect, useEffect, useContext, useMemo, useRef, useState, useCallback, useId } from "preact/hooks"; -import { jsxs, jsx, Fragment as Fragment$1 } from "preact/jsx-runtime"; -import { signal, effect, computed, batch, useSignal } from "@preact/signals"; -import { createIterableWeakSet, createEventGroupLogger, normalizeStyle, mergeOneStyle, getPositionedParent, createPubSub, findEvent, dispatchInternalCustomEvent, mergeTwoStyles, normalizeStyles, createGroupTransitionController, getElementSignature, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, dispatchCustomEvent, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, activeElementSignal, initFocusGroup, elementIsFocusable, resolveOklchLightness, contrastColor, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, scrollIntoViewScoped, measureWidestChildRow, performTabNavigation, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom"; +import { windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, installImportMetaCssBuild, coarsePointerSignal } from "./jsenv_navi_side_effects.js"; +import { createContext, isValidElement, h, Fragment, toChildArray, render, cloneElement } from "preact"; +import { useContext, useLayoutEffect, useRef, useEffect, useCallback, useState, useMemo, useId, useErrorBoundary } from "preact/hooks"; +import { jsx, jsxs, Fragment as Fragment$1 } from "preact/jsx-runtime"; +import { computed, signal, effect, batch, useSignal } from "@preact/signals"; +import { createPubSub, normalizeStyle, mergeOneStyle, getPositionedParent, findEvent, dispatchInternalCustomEvent, mergeTwoStyles, normalizeStyles, resolveCSSSize, measureLongestVisualLineWidth, hasCSSSizeUnit, resolveOklchLightness, contrastColor, createIterableWeakSet, dispatchCustomEvent, getElementSignature, createValueEffect, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, createEventGroupLogger, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, getKeyboardEventDefaultAction, chainEvent, activeElementSignal, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, findBefore, findAfter, initFocusGroup, elementIsFocusable, scrollIntoViewScoped, measureWidestChildRow, performTabNavigation, dragAfterThreshold, getScrollContainer, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement, stringifyStyle as stringifyStyle$1 } from "@jsenv/dom"; export { contrastColor, startDragToReorder } from "@jsenv/dom"; -import { prefixFirstAndIndentRemainingLines } from "@jsenv/humanize"; import { createValidity, parseDuration, durationContainsNaN, compareTwoDurations, durationToSeconds, durationToISOString } from "@jsenv/validity"; export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity"; -import { Suspense, createPortal, forwardRef } from "preact/compat"; - -const actionPrivatePropertiesWeakMap = new WeakMap(); -const getActionPrivateProperties = (action) => { - const actionPrivateProperties = actionPrivatePropertiesWeakMap.get(action); - if (!actionPrivateProperties) { - throw new Error(`Cannot find action private properties for "${action}"`); - } - return actionPrivateProperties; -}; -const setActionPrivateProperties = (action, properties) => { - actionPrivatePropertiesWeakMap.set(action, properties); -}; +import { createPortal, Suspense, forwardRef } from "preact/compat"; +import { prefixFirstAndIndentRemainingLines } from "@jsenv/humanize"; -const IDLE = { id: "idle" }; -const RUNNING = { id: "running" }; -const ABORTED = { id: "aborted" }; -const FAILED = { id: "failed" }; -const COMPLETED = { id: "completed" }; +const NextResolverContext = createContext(null); +const useNextResolver = () => useContext(NextResolverContext); -const useActionStatus = (action) => { - if (!action) { - return { - params: undefined, - runningState: IDLE, - isPrerun: false, - idle: true, - loading: false, - aborted: false, - error: null, - completed: false, - data: undefined, - }; - } - const { - paramsSignal, - runningStateSignal, - isPrerunSignal, - errorSignal, - dataSignal, - } = action; - const params = paramsSignal.value; - const isPrerun = isPrerunSignal.value; - const runningState = runningStateSignal.value; - const idle = runningState === IDLE; - const aborted = runningState === ABORTED; - const error = errorSignal.value; - const loading = runningState === RUNNING; - const completed = runningState === COMPLETED; - const data = dataSignal.value; +/** + * Creates a renderComponent function that passes props through a chain of resolvers. + * Each resolver is a Preact component rendered in sequence (hooks are allowed). + * To pass through to the next resolver, call useNextResolver() and render the + * returned Next component with the desired props. + * To terminate the chain early (e.g. render a specialized component), render + * directly without calling Next. + * + * The last entry in the array is the final/target component — it receives null + * from useNextResolver() indicating it is terminal. + * + * Usage: + * const renderButton = createComponentResolver([ResolverA, ResolverB, ButtonTarget]); + * // Then inside a component render: + * renderButton(props) + * + * NextResolverContext exposes a stable Next component so resolvers can continue + * the chain via useNextResolver(). + * ResolverIndexContext tracks which resolver is next so that when a resolver + * re-renders and calls Next, the chain resumes from the correct position. + */ +const createComponentResolver = resolvers => { + const ResolverIndexContext = createContext(0); + const ChainRunner = props => { + const index = useContext(ResolverIndexContext); + if (index >= resolvers.length) { + return null; + } + const Resolver = resolvers[index]; + const isLast = index === resolvers.length - 1; + return jsx(ResolverIndexContext.Provider, { + value: index + 1, + children: isLast ? jsx(NextResolverContext.Provider, { + value: null, + children: jsx(Resolver, { + ...props + }) + }) : jsx(Resolver, { + ...props + }) + }); + }; - return { - params, - runningState, - isPrerun, - idle, - loading, - aborted, - error, - completed, - data, + // Stable component defined once per createComponentResolver call. + // Renders ChainRunner directly — no new providers — so ResolverIndexContext + // is inherited from the parent tree. When a resolver calls , the chain + // resumes from index+1 (already set by the Provider wrapping that resolver). + const NextComponent = props => jsx(ChainRunner, { + ...props + }); + const renderComponent = props => { + return jsx(NextResolverContext.Provider, { + value: NextComponent, + children: jsx(ResolverIndexContext.Provider, { + value: 0, + children: jsx(ChainRunner, { + ...props + }) + }) + }); }; + return renderComponent; }; -installImportMetaCssBuild(import.meta);const css$W = /* css */` - .action_error { - margin-top: 0; - margin-bottom: 20px; - padding: 20px; - background: #fdd; - border: 1px solid red; - } -`; -const renderIdleDefault = () => null; -const renderLoadingDefault = () => null; -const renderAbortedDefault = () => null; -const renderErrorDefault = error => { - let routeErrorText = error && error.message ? error.message : error; - return jsxs("p", { - className: "action_error", - children: ["An error occured: ", routeErrorText] - }); +/** + * Where an action asks "are you sure?", and where whatever knows how to ask it + * registers itself. + * + * Two directions meet here: the action execution path (use_execute_action.js) + * needs to ask the question, and what asks it (confirm_popup.jsx) is a popup + * built out of navi's own controls — which are themselves built out of the + * action execution path. Neither side can import the other, so the question + * travels through this leaf module instead. Same shape as registerNaviCommand. + * + * The question itself is attached to the control that asks it, not carried as + * a prop down the action pipeline: the control that requests an action is not + * always the one that runs it (a submit button hands the send to its form), so + * the request has an element to read the question off, and nothing else. + */ + + +let confirmImplementation = null; + +const registerConfirmImplementation = (implementation) => { + confirmImplementation = implementation; }; -const renderCompletedDefault = () => null; -const ActionRenderer = ({ - action, - children, - disabled -}) => { - import.meta.css = [css$W, "@jsenv/navi/src/action/action_renderer.jsx"]; - if (action === undefined) { - throw new Error("ActionRenderer requires an action to render, but none was provided."); - } - let renderBranches; - if (typeof children === "function") { - renderBranches = { - completed: children - }; - } else if (isValidElement(children)) { - renderBranches = { - always: () => children - }; - } else if (isPlainObject$1(children)) { - renderBranches = children; - } else { - renderBranches = { - completed: children - }; - } - const { - idle: renderIdle = renderIdleDefault, - loading: renderLoading = renderLoadingDefault, - aborted: renderAborted = renderAbortedDefault, - error: renderError = renderErrorDefault, - completed: renderCompleted, - always: renderAlways - } = renderBranches; - const { - idle, - loading, - aborted, - error, - completed, - data - } = useActionStatus(action); - const UIRenderedPromise = useUIRenderedPromise(action); - const [errorBoundary, resetErrorBoundary] = useErrorBoundary(); - // Mark this action as bound to UI components (has renderers) - // This tells the action system that errors should be caught and stored - // in the action's error state rather than bubbling up +const confirmParamsWeakMap = new WeakMap(); + +/** + * Attaches a confirmation to whatever `elementRef` points at, for as long as + * it is mounted. + * + * @param {import("preact/hooks").Ref} elementRef + * @param {object} params + * @param {string|import("preact").ComponentChildren} [params.message] - The + * question. Plain text, or JSX when it needs a link, an emphasis, a list. + * @param {import("preact").ComponentChildren} [params.content] - The whole + * popup body, replacing the default question + buttons. Answer from inside it + * with the `--navi-confirm` and `--navi-cancel` commands. + */ +const useConfirmParams = (elementRef, { message, content }) => { + // No dependency array: `content` is JSX, a fresh object on every render, so + // there is nothing stable to compare — and writing to a WeakMap costs less + // than deciding whether to. useLayoutEffect(() => { - if (action) { - const { - ui - } = getActionPrivateProperties(action); - ui.hasRenderers = true; + const element = elementRef.current; + if (!element) { + return undefined; } - }, [action]); - useLayoutEffect(() => { - resetErrorBoundary(); - }, [action, loading, idle, resetErrorBoundary]); - useLayoutEffect(() => { - UIRenderedPromise.resolve(); + if (message === undefined && content === undefined) { + confirmParamsWeakMap.delete(element); + return undefined; + } + confirmParamsWeakMap.set(element, { message, content }); return () => { - actionUIRenderedPromiseWeakMap.delete(action); + confirmParamsWeakMap.delete(element); }; - }, [action]); - if (disabled) { - return null; - } - // If renderAlways is provided, it wins and handles all rendering - if (renderAlways) { - return renderAlways({ - idle, - loading, - aborted, - completed, - error, - data - }); - } - if (idle) { - return renderIdle(action); + }); +}; + +const getConfirmParams = (element) => { + if (!element) { + return undefined; } - if (errorBoundary) { - return renderError(errorBoundary, "ui_error", action); + return confirmParamsWeakMap.get(element); +}; + +/** + * @param {object} params + * @param {string|import("preact").ComponentChildren} [params.message] + * @param {import("preact").ComponentChildren} [params.content] + * @param {Element} [params.anchor] - What asked, so the question can be shown + * next to it. + * @returns {Promise} + */ +const requestConfirmation = async ({ message, content, anchor }) => { + if (!confirmImplementation) { + // Reachable only when navi is used through its own submodules rather than + // its entry point (which registers the popup). An unanswerable question + // must not silently swallow the action the user asked for. + console.warn( + `no confirm implementation registered, the confirmation is considered given`, + ); + return true; } - if (aborted) { - return renderAborted(action); + return confirmImplementation({ message, content, anchor }); +}; + +/** + * Interpolates a template string, replacing [key] placeholders with values. + * Values can be strings or JSX elements (when allowJsx is true). + * Returns a plain string when all replacements are strings, or a Preact + * fragment when JSX values are present and allowJsx is enabled. + * + * `[]` was chosen as the placeholder delimiter (rather than `{}` or `{{}}`) + * because it does not conflict with JSX syntax, JavaScript template literals, + * or common punctuation in translated strings. + * + * Pass `allowJsx: true` to enable VNode replacements (used by ). + * Without it, all values are coerced to strings. + */ +const interpolateText = ( + template, + replacements, + { allowJsx = false } = {}, +) => { + if (!replacements || typeof template !== "string") { + return template; } - let renderCompletedSafe; - if (renderCompleted) { - renderCompletedSafe = renderCompleted; - } else { - const { - ui - } = getActionPrivateProperties(action); - if (ui.renderCompleted) { - renderCompletedSafe = ui.renderCompleted; - } else { - renderCompletedSafe = renderCompletedDefault; + const parts = template.split(/(\[[^\]]+\])/); + let hasVnode = false; + const resolved = []; + for (const part of parts) { + const match = part.match(/^\[([^\]]+)\]$/); + if (!match) { + resolved.push(part); + continue; } - } - if (loading) { - if (action.canDisplayOldData && data !== undefined) { - return renderCompletedSafe(data, action); + const key = match[1]; + let value = resolveValue(replacements, key, part); + if (typeof value === "function") { + value = value(); } - return renderLoading(action); + if (isValidElement(value)) { + if (allowJsx) { + hasVnode = true; + } else { + console.warn( + `interpolateText: VNode passed for placeholder [${match[1]}] but allowJsx is false — value coerced to string`, + ); + } + } + resolved.push(value); } - if (error) { - return renderError(error, "action_error", action); + if (!hasVnode) { + return resolved.join(""); } - return renderCompletedSafe(data, action); + // h(Fragment) instead of JSX (<>{resolved}>) to keep this file as .js + return h(Fragment, null, resolved); }; -const defaultPromise = Promise.resolve(); -defaultPromise.resolve = () => {}; -const actionUIRenderedPromiseWeakMap = new WeakMap(); -const useUIRenderedPromise = action => { - if (!action) { - return defaultPromise; + +// Resolves a placeholder key against the replacements object. +// 1. Direct lookup: replacements["item.name"] +// 2. Dot-path lookup: replacements["item"]["name"] +// 3. Fallback: the original placeholder string (e.g. "[item.name]") +const resolveValue = (replacements, key, fallback) => { + if (key in replacements) { + return replacements[key]; } - const actionUIRenderedPromise = actionUIRenderedPromiseWeakMap.get(action); - if (actionUIRenderedPromise) { - return actionUIRenderedPromise; + const dotIndex = key.indexOf("."); + if (dotIndex !== -1) { + const head = key.slice(0, dotIndex); + const tail = key.slice(dotIndex + 1); + const parent = replacements[head]; + if (parent && typeof parent === "object") { + const nested = parent[tail]; + if (nested !== undefined) { + return nested; + } + } } - let resolve; - const promise = new Promise(res => { - resolve = res; - }); - promise.resolve = resolve; - actionUIRenderedPromiseWeakMap.set(action, promise); - return promise; + return fallback; }; -const isPlainObject$1 = obj => { - if (typeof obj !== "object" || obj === null) { - return false; + +const DEFAULT_LANG = "en"; + +/** + * The browser's own language preferences, most preferred first — read from + * `navigator.languages` (falling back to the single `navigator.language`, + * then to DEFAULT_LANG when neither is available, e.g. during SSR). Kept as + * its own signal, independent from what this app actually supports — see + * `supportedLanguagesSignal` below for the allow-list that filters it, and + * `languagesSignal` for the final, ready-to-use combination of the two (plus + * `preferredLanguageSignal`). + */ +const getRuntimeLanguages = () => { + if (typeof window === "undefined") { + return [DEFAULT_LANG]; } - let proto = obj; - while (Object.getPrototypeOf(proto) !== null) { - proto = Object.getPrototypeOf(proto); + const { navigator } = window; + if (typeof navigator === "undefined") { + return [DEFAULT_LANG]; } - return Object.getPrototypeOf(obj) === proto || Object.getPrototypeOf(obj) === null; + const { languages } = navigator; + if (Array.isArray(languages) && languages.length > 0) { + return languages; + } + const { language } = navigator; + if (typeof language === "string") { + return [language]; + } + return [DEFAULT_LANG]; }; -/* - * Deep structural equality for arbitrary JS values — what `===` can't do but this - * codebase constantly needs: memoization cache keys ({ id: 1 } equal to { id: 1 }), - * effect/memo dependency checks, signal/store change detection, and action - * parameter deduplication. +const runtimeLanguagesSignal = signal(getRuntimeLanguages()); + +if (typeof window !== "undefined") { + window.addEventListener("languagechange", () => { + runtimeLanguagesSignal.value = getRuntimeLanguages(); + }); +} + +/** + * The languages this app actually offers, e.g. `["en", "fr"]` — an allow-list + * `languagesSignal` below filters everything else against (runtime languages the + * browser reports, and `preferredLanguageSignal`'s own override), so a site + * that only supports English/French never ends up resolving to German just + * because that happens to be the browser's or the user's own preference. * - * Beyond recursive object/array comparison it covers the edge cases `===` gets - * "wrong" for equality purposes: NaN equals NaN, Date compared by time value, - * cycles don't loop (a seen-set guards circular refs), and same-type is required - * before descending. Cheap paths run first: reference equality, then the identity - * short-circuit below, then array length before element-by-element. + * `null` (the default) means no restriction at all: every language the + * browser/user prefers is allowed through, matching this module's previous, + * unrestricted behavior. + */ +const supportedLanguagesSignal = signal(null); + +/** + * @param {string[]|null} languages - e.g. `["en", "fr"]`. Pass `null`/`[]` + * to lift the restriction again (allow everything). + */ +const setSupportedLanguages = (languages) => { + supportedLanguagesSignal.value = + languages && languages.length ? languages : null; +}; + +/** + * A single language the user explicitly chose (e.g. via an in-app language + * picker), overriding whatever the browser itself reports — takes priority + * over `runtimeLanguagesSignal` in `languagesSignal` below, but is still subject + * to `supportedLanguagesSignal`'s own allow-list. * - * SYMBOL_IDENTITY. Two *different* object instances can be declared "conceptually - * the same" by sharing a SYMBOL_IDENTITY value; the comparison then treats them as - * equal with no deep walk. This is what lets a spread copy ({ ...params, extra }) - * still count as the same params as the original, and lets objects reconstructed - * across a serialization boundary be recognized as one entity — the cases where a - * content comparison would be too slow, or too strict to see them as equal. Use - * Symbol.for() so the marker is the same symbol across modules/contexts: + * Deliberately a single language, not an ordered list: reordering *among* + * several preferred languages is real complexity real users essentially + * never want — the practical need `languagesSignal` needs to serve is "let this + * one user pick their one preferred language instead of the browser's", + * nothing more. + */ +const preferredLanguageSignal = signal(null); + +/** + * @param {string|null} language - BCP 47 tag, e.g. "fr". Pass `null` to + * go back to following the browser's own language. + */ +const setPreferredLanguage = (language) => { + preferredLanguageSignal.value = language || null; +}; + +const getPrimarySubtag = (lang) => lang.split("-")[0]; + +const isLanguageSupported = (lang, supportedLanguages) => { + const primarySubtag = getPrimarySubtag(lang); + return supportedLanguages.some( + (supportedLanguage) => + getPrimarySubtag(supportedLanguage) === primarySubtag, + ); +}; + +/** + * The ordered, ready-to-use language preference list every navi + * component/util defaults to (naviI18n, formatNumber, the Time components, + * validation messages…), live on every read. Combines, in priority order: * - * const id = Symbol.for("params"); - * a[SYMBOL_IDENTITY] = id; - * b[SYMBOL_IDENTITY] = id; - * compareTwoJsValues(a, b); // true immediately, no property walk + * 1. `preferredLanguageSignal` (the user's own explicit pick, if any) + * 2. `runtimeLanguagesSignal` (the browser's own ordered preferences) + * + * then filters the result through `supportedLanguagesSignal` (if set) so + * only languages this app actually offers ever come out — e.g. a browser + * preferring `["de", "fr", "en"]` on a site that only supports `["en", + * "fr"]` resolves to `["fr", "en"]`, never touching German. If filtering + * would leave nothing at all (none of the browser's/user's preferences are + * supported), falls back to `supportedLanguagesSignal` itself so callers + * still get *something* usable rather than an empty array. + * + * Consumers that accept either a single lang or an ordered array (this + * package's own `matchBestLang`/`createI18n`, and native `Intl.NumberFormat`/ + * `Intl.DateTimeFormat`) can pass this straight through: anything not + * covered by the first entry falls through to the next, rather than + * jumping straight to an unrelated default like "en". */ +const languagesSignal = computed(() => { + const preferredLanguage = preferredLanguageSignal.value; + const runtimeLanguages = runtimeLanguagesSignal.value; + const supportedLanguages = supportedLanguagesSignal.value; -// Marks objects with a conceptual identity that transcends reference equality — -// see the file comment. Symbol.for keeps it one shared symbol across modules. -const SYMBOL_IDENTITY = Symbol.for("navi_object_identity"); + const orderedLanguages = preferredLanguage + ? [preferredLanguage, ...runtimeLanguages] + : runtimeLanguages; + const dedupedLanguages = [...new Set(orderedLanguages)]; + + if (!supportedLanguages) { + return dedupedLanguages; + } + const filteredLanguages = dedupedLanguages.filter((lang) => + isLanguageSupported(lang, supportedLanguages), + ); + return filteredLanguages.length > 0 ? filteredLanguages : supportedLanguages; +}); /** - * Deeply compares two values for structural equality. + * Creates a lightweight i18n instance for translating text in the current locale. * - * @param {any} rootA - First value. - * @param {any} rootB - Second value. * @param {object} [options] - * @param {(a: any, b: any, keyOrIndex: any, recurse: (a: any, b: any) => boolean) => boolean} [options.keyComparator] - * Custom comparator for object properties / array elements. Receives the internal - * `compare` as its last argument so it can defer to the default behavior. - * @param {boolean} [options.ignoreArrayOrder=false] - Compare arrays as multisets: - * equal when they contain the same elements regardless of order. - * @param {Iterable} [options.lightKeySet] - Object keys to compare first — - * cheaper or likelier-to-differ ones — to short-circuit before the remaining keys. - * @returns {boolean} true if the values are deeply equal. + * @param {string} [options.keyLang] + * When set, each key also serves as its own translation for `keyLang`. + * This allows writing keys directly in that language (typically English) so + * only other languages need to be registered: + * + * ```js + * const i18n = createI18n({ keyLang: "en" }); + * i18n.add("Hello [name]!", { fr: "Bonjour [name] !" }); + * i18n("Hello [name]!", { name: "Alice" }); // "Hello Alice!" (en — key is template) + * i18n("Hello [name]!", { name: "Alice" }); // "Bonjour Alice !" (fr) + * ``` + * + * Without `keyLang`, keys are opaque identifiers and all languages (including + * the fallback) must be registered explicitly: + * + * ```js + * const i18n = createI18n(); + * i18n.add("greeting", { en: "Hello [name]!", fr: "Bonjour [name] !" }); + * i18n("greeting", { name: "Alice" }); // "Hello Alice!" (en) + * ``` + * + * @param {string|string[]} [options.runtimeLang] + * The active language (BCP 47 tag or ordered array of tags) — named + * "runtime" rather than "system" because there is no actual access to the + * OS/user's system language from a browser, only `navigator.languages` (or + * an explicit override) at runtime. Defaults to `languagesSignal.value`, read + * fresh on every `format()`/`has()` call (not frozen at creation time) — + * so overriding the language app-wide via `setPreferredLanguage()`/ + * `setSupportedLanguages()` (see lang_signal.js) is picked up here too. + * Passing an explicit `runtimeLang` opts out of that and stays fixed for + * this instance's whole lifetime. + * + * --- + * + * ## Bulk registration + * + * **`i18n.add(key, { lang: "translation" })`** — one key, multiple languages. + * + * **`i18n.addAll({ key: { lang: "translation" }, ... })`** — multiple keys at once. + * + * **`i18n.addLangKeys(lang, { key: "translation", ... })`** — full language pack + * (useful when loading a JSON translation file). + * + * A regional variant (e.g. `"fr-CA"`) automatically inherits all keys from its + * parent (`"fr"`) that it does not explicitly override: + * ```js + * i18n.addLangKeys("fr", { hello: "Bonjour !" }); + * i18n.addLangKeys("fr-CA", { hello: "Allo !" }); // other "fr" keys inherited + * ``` + * + * --- + * + * @returns {Function & { add, addAll, addLangKeys, format, languageMap }} + * A callable function — `i18n(key, values?, { lang? })` — with the same + * signature as `i18n.format()`. `format` is kept as an alias. */ -const compareTwoJsValues = ( - rootA, - rootB, - { keyComparator, ignoreArrayOrder = false, lightKeySet } = {}, -) => { - const seenSet = new Set(); - const compare = (a, b) => { - if (a === b) { - return true; - } - const aIsIsTruthy = Boolean(a); - const bIsTruthy = Boolean(b); - if (aIsIsTruthy && !bIsTruthy) { - return false; - } - if (!aIsIsTruthy && !bIsTruthy) { - // null, undefined, 0, false, NaN - if (isNaN(a) && isNaN(b)) { - return true; - } - return a === b; +const createI18n = ({ keyLang, fallbackLang, runtimeLang } = {}) => { + const languageMap = new Map(); + // Bumped by addLangKeys — the only thing besides the active lang itself + // that could change what getActiveLang()/getResolvedFallbackLang() below + // resolve to, so it's what invalidates their own small caches. + let languageMapVersion = 0; + + // Explicit runtimeLang stays fixed for this instance's lifetime (matches + // the previous behavior exactly). Without one, re-read languagesSignal.value + // fresh on every call instead of freezing it here via languagesSignal.peek() + // once — that would silently ignore setPreferredLanguage()/ + // setSupportedLanguages() (see lang_signal.js) for the rest of this + // instance's life. + const hasExplicitRuntimeLang = runtimeLang !== undefined; + + // matchBestLang does real work (a Map lookup per candidate, a possible + // "fr-CA" → "fr" split-and-retry loop) — worth skipping on every single + // format()/has() call in the common case, since what it resolves to only + // ever changes when languageMap itself changes (addLangKeys) or, for the + // non-explicit case, when languagesSignal.value itself changes (preferred + // language, supported languages, or "languagechange" — see lang_signal.js, + // languagesSignal is a computed() so its reference is stable when none of its + // own dependencies actually changed) — comparing those two cheaply + // (===) is enough to know the cached result below is still valid. + let cachedActiveLang; + let cachedActiveLangRuntimeLang; + let cachedActiveLangVersion = -1; + const getActiveLang = () => { + const currentRuntimeLang = hasExplicitRuntimeLang + ? runtimeLang + : languagesSignal.value; + if ( + cachedActiveLangVersion === languageMapVersion && + cachedActiveLangRuntimeLang === currentRuntimeLang + ) { + return cachedActiveLang; } - const aType = typeof a; - const bType = typeof b; - if (aType !== bType) { - return false; + cachedActiveLang = matchBestLang(currentRuntimeLang, languageMap); + cachedActiveLangVersion = languageMapVersion; + cachedActiveLangRuntimeLang = currentRuntimeLang; + return cachedActiveLang; + }; + + // fallbackLang is a plain, never-reactive option set once at creation — + // its own resolution only ever needs recomputing when languageMap does. + let cachedResolvedFallbackLang; + let cachedResolvedFallbackLangVersion = -1; + const getResolvedFallbackLang = () => { + if (!fallbackLang) { + return null; } - const aIsPrimitive = - a === null || (aType !== "object" && aType !== "function"); - const bIsPrimitive = - b === null || (bType !== "object" && bType !== "function"); - if (aIsPrimitive !== bIsPrimitive) { - return false; + if (cachedResolvedFallbackLangVersion === languageMapVersion) { + return cachedResolvedFallbackLang; } - if (aIsPrimitive && bIsPrimitive) { - return a === b; + cachedResolvedFallbackLang = matchBestLang(fallbackLang, languageMap); + cachedResolvedFallbackLangVersion = languageMapVersion; + return cachedResolvedFallbackLang; + }; + + const addLangKeys = (lang, translations) => { + // Accumulate: merge with any existing translations for this lang + const existing = languageMap.get(lang); + if (existing) { + translations = { ...existing, ...translations }; } - if (seenSet.has(a)) { - return false; + // A regional variant inherits all keys not explicitly overridden + // e.g. "fr-CA" inherits from "fr" + const dashIndex = lang.indexOf("-"); + if (dashIndex !== -1) { + const parentLang = lang.slice(0, dashIndex); + const parentTranslations = languageMap.get(parentLang); + if (parentTranslations) { + translations = { ...parentTranslations, ...translations }; + } } - if (seenSet.has(b)) { - return false; + languageMap.set(lang, translations); + languageMapVersion++; + }; + + const add = (key, langTranslations) => { + if (keyLang && !(keyLang in langTranslations)) { + // Auto-register the key itself as the translation for keyLang + addLangKeys(keyLang, { [key]: key }); } - seenSet.add(a); - seenSet.add(b); - const aIsArray = Array.isArray(a); - const bIsArray = Array.isArray(b); - if (aIsArray !== bIsArray) { - return false; + for (const [lang, value] of Object.entries(langTranslations)) { + addLangKeys(lang, { [key]: value }); } - if (aIsArray) { - // compare arrays - if (a.length !== b.length) { - return false; - } - if (ignoreArrayOrder) { - // Unordered array comparison: each element in 'a' must have a match in 'b' - const usedIndices = new Set(); - for (let i = 0; i < a.length; i++) { - const aValue = a[i]; - let foundMatch = false; + }; - for (let j = 0; j < b.length; j++) { - if (usedIndices.has(j)) { - continue; // Already matched with another element - } - const bValue = b[j]; - if (compareAt(aValue, bValue, i)) { - foundMatch = true; - usedIndices.add(j); - break; - } - } - if (!foundMatch) { - return false; - } - } - return true; - } - // Ordered array comparison (original behavior) - let i = 0; - while (i < a.length) { - const aValue = a[i]; - const bValue = b[i]; - if (!compareAt(aValue, bValue, i)) { - return false; - } - i++; - } - return true; - } - // compare objects - const aIdentity = a[SYMBOL_IDENTITY]; - const bIdentity = b[SYMBOL_IDENTITY]; - if ( - aIdentity === bIdentity && - SYMBOL_IDENTITY in a && - SYMBOL_IDENTITY in b - ) { - return true; + const addAll = (keyMap) => { + for (const [key, langTranslations] of Object.entries(keyMap)) { + add(key, langTranslations); } - // Date objects must be compared by time value, not by enumerable keys (which are empty) - { - const aIsDate = a instanceof Date; - const bIsDate = b instanceof Date; - if (aIsDate !== bIsDate) { - return false; - } - if (aIsDate && bIsDate) { - const aTime = a.getTime(); - const bTime = b.getTime(); - if (aTime !== bTime) { - return false; - } + }; + + const _getTemplate = (key, lang) => { + // matchBestLang, not matchLang directly: lang can be an array (e.g. + // languagesSignal.value is always an ordered array — see lang_signal.js) and + // matchLang alone assumes a plain string, throwing + // on .split() otherwise. + const resolvedLang = lang ? matchBestLang(lang, languageMap) : null; + if (resolvedLang) { + const translations = languageMap.get(resolvedLang); + const translated = translations[key]; + if (translated !== undefined) { + return translated; } } - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) { - return false; - } - if (lightKeySet) { - // compare light keys first, then remaining keys - // (optimization for cases where some keys are more likely to differ and/or faster to compare) - const keySet = new Set(aKeys); - for (const lightKey of lightKeySet) { - const aValue = a[lightKey]; - const bValue = b[lightKey]; - if (!compareAt(aValue, bValue, lightKey)) { - return false; - } - keySet.delete(lightKey); - } - for (const key of keySet) { - const aValue = a[key]; - const bValue = b[key]; - if (!compareAt(aValue, bValue, key)) { - return false; - } - } - } else { - for (const key of aKeys) { - const aValue = a[key]; - const bValue = b[key]; - if (!compareAt(aValue, bValue, key)) { - return false; - } + const resolvedFallbackLang = getResolvedFallbackLang(); + if (resolvedFallbackLang) { + const fallbackTranslations = languageMap.get(resolvedFallbackLang); + const fallbackTranslated = fallbackTranslations[key]; + if (fallbackTranslated !== undefined) { + return fallbackTranslated; } } - return true; + // No translation found — return key as-is (opaque fallback) + return key; }; - const compareAt = keyComparator - ? (a, b, keyOrArrayIndex) => keyComparator(a, b, keyOrArrayIndex, compare) - : compare; - - return compare(rootA, rootB); -}; -const debounceSignal = ( - signalToDebounce, - { delay = 300, deepCompare = true } = {}, -) => { - let timeoutId; - let latestValue = signalToDebounce.peek(); - const debouncedSignal = signal(latestValue); + const format = (key, values, { lang = getActiveLang() } = {}) => { + const template = _getTemplate(key, lang); + return interpolateText(template, values); + }; - effect(() => { - const value = signalToDebounce.value; - const debouncedValue = debouncedSignal.peek(); - if ( - deepCompare - ? compareTwoJsValues(value, debouncedValue) - : value === debouncedValue - ) { - return; + const has = (key, { lang = getActiveLang() } = {}) => { + const resolvedLang = lang ? matchBestLang(lang, languageMap) : null; + if (resolvedLang) { + const translations = languageMap.get(resolvedLang); + if (translations && key in translations) { + return true; + } } - clearTimeout(timeoutId); - latestValue = value; - timeoutId = setTimeout(() => { - debouncedSignal.value = latestValue; - }, delay); - }); - - debouncedSignal.flush = () => { - clearTimeout(timeoutId); - debouncedSignal.value = latestValue; + const resolvedFallbackLang = getResolvedFallbackLang(); + if (resolvedFallbackLang) { + const fallbackTranslations = languageMap.get(resolvedFallbackLang); + if (fallbackTranslations && key in fallbackTranslations) { + return true; + } + } + return false; }; - return debouncedSignal; -}; + // The i18n instance is itself a callable function + const i18n = (key, values, opts) => format(key, values, opts); + i18n.add = add; + i18n.addAll = addAll; + i18n.addLangKeys = addLangKeys; + i18n.has = has; + i18n.format = format; + i18n.languageMap = languageMap; -const isSignal = (value) => { - return getSignalType(value) !== null; + return i18n; }; -const BRAND_SYMBOL = Symbol.for("preact-signals"); -const getSignalType = (value) => { - if (!value || typeof value !== "object") { - return null; - } - - if (value.brand !== BRAND_SYMBOL) { - return null; +// Walk "fr-CA-variant" → "fr-CA" → "fr" until a registered lang is found +const matchLang = (lang, languageMap) => { + if (languageMap.has(lang)) { + return lang; } - - if (typeof value._fn === "function") { - return "computed"; + const parts = lang.split("-"); + while (parts.length > 1) { + parts.pop(); + const candidate = parts.join("-"); + if (languageMap.has(candidate)) { + return candidate; + } } - - return "signal"; + return null; }; -const MAX_ENTRIES = 5; - -const stringifyForDisplay = ( - value, - maxDepth = 2, - currentDepth = 0, - options = {}, -) => { - const { asFunctionArgs = false } = options; - const indent = " ".repeat(currentDepth); - const nextIndent = " ".repeat(currentDepth + 1); - - if (currentDepth >= maxDepth) { - return typeof value === "object" && value !== null - ? "[Object]" - : String(value); - } - - if (value === null) { - return "null"; - } - if (value === undefined) { - return "undefined"; - } - if (typeof value === "string") { - return `"${value}"`; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - if (typeof value === "function") { - return `[Function ${value.name || "anonymous"}]`; - } - if (value instanceof Date) { - return `Date(${value.toISOString()})`; +// lang can be a string or an ordered array of preference strings +const matchBestLang = (lang, languageMap) => { + if (!lang) { + return null; } - if (value instanceof RegExp) { - return value.toString(); + const candidates = Array.isArray(lang) ? lang : [lang]; + for (const candidate of candidates) { + const match = matchLang(candidate, languageMap); + if (match) { + return match; + } } + return null; +}; - if (Array.isArray(value)) { - const openBracket = asFunctionArgs ? "(" : "["; - const closeBracket = asFunctionArgs ? ")" : "]"; +/** + * The shared i18n instance for all @jsenv/navi components. + * + * Use `naviI18n.add(key, { lang: "translation" })` to register or override + * any text used by navi components. The active language is read from + * `languagesSignal` (see lang_signal.js — combines the browser's own + * `navigator.languages`, an optional `setPreferredLanguage()` user override, + * and an optional `setSupportedLanguages()` app-wide allow-list), live on + * every lookup. + * + * Built-in keys (can be overridden): + * - `"time.less_than_minute"` — e.g. "in less than a minute" + * - `"time.ongoing"` — e.g. "Ongoing" + * - `"time.tomorrow_at"` — e.g. "[day] at [time]" ([day] and [time] are placeholders) + * - `"time.midnight"` — e.g. "midnight" + * + * @example + * import { naviI18n } from "@jsenv/navi"; + * + * // Register unit translations for Quantity: + * naviI18n.add("minute", { en: "minute", fr: "minute" }); + * naviI18n.add("minute__plural", { en: "minutes", fr: "minutes" }); + * + * // Register multiple keys at once: + * naviI18n.addAll({ + * minute: { en: "minute", fr: "minute" }, + * minute__plural: { en: "minutes", fr: "minutes" }, + * }); + * + * // Override a built-in text: + * naviI18n.add("time.ongoing", { fr: "En cours…" }); + * + * // Load a full language pack at once: + * naviI18n.addLangKeys("fr", { minute: "minute", "minute__plural": "minutes" }); + */ +const naviI18n = createI18n(); - if (value.length === 0) return `${openBracket}${closeBracket}`; +naviI18n.addAll({ + "button.clear": { + en: "Clear", + fr: "Effacer", + }, + "button.reset": { + en: "Reset", + fr: "Réinitialiser", + }, + "button.send": { + en: "Send", + fr: "Envoyer", + }, + "button.open": { + en: "Open", + fr: "Ouvrir", + }, + "button.close": { + en: "Close", + fr: "Fermer", + }, + "button.cancel": { + en: "Cancel", + fr: "Annuler", + }, + "button.define": { + en: "Define", + fr: "Définir", + }, + "button.confirm": { + en: "Confirm", + fr: "Confirmer", + }, +}); - // Display arrays with only one element on a single line - if (value.length === 1) { - const item = stringifyForDisplay( - value[0], - maxDepth, - currentDepth + 1, - // Remove asFunctionArgs for nested calls - { ...options, asFunctionArgs: false }, - ); - return `${openBracket}${item}${closeBracket}`; - } - - if (value.length > MAX_ENTRIES) { - const preview = value - .slice(0, MAX_ENTRIES) - .map( - (v) => - `${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`, - ); - return `${openBracket}\n${preview.join(",\n")},\n${nextIndent}...${value.length - MAX_ENTRIES} more\n${indent}${closeBracket}`; - } - - const items = value.map( - (v) => - `${nextIndent}${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`, - ); - return `${openBracket}\n${items.join(",\n")}\n${indent}${closeBracket}`; - } - - if (typeof value === "object") { - const signalType = getSignalType(value); - if (signalType) { - const signalValue = value.peek(); - const prefix = signalType === "computed" ? "computed" : "signal"; - return `${prefix}(${stringifyForDisplay(signalValue, maxDepth, currentDepth, { ...options, asFunctionArgs: false })})`; - } - - const entries = Object.entries(value); - if (entries.length === 0) return "{}"; - - // ✅ Inclure les clés avec valeurs undefined/null - const allEntries = []; - for (const [key, val] of entries) { - allEntries.push([key, val]); - } - - // Ajouter les clés avec undefined (que Object.entries omet) - const descriptor = Object.getOwnPropertyDescriptors(value); - for (const [key, desc] of Object.entries(descriptor)) { - if (desc.value === undefined && !entries.some(([k]) => k === key)) { - allEntries.push([key, undefined]); - } - } - - // Display objects with only one key on a single line - if (allEntries.length === 1) { - const [key, val] = allEntries[0]; - const valueStr = stringifyForDisplay(val, maxDepth, currentDepth + 1, { - ...options, - asFunctionArgs: false, - }); - return `{ ${key}: ${valueStr} }`; - } - - if (allEntries.length > MAX_ENTRIES) { - const preview = allEntries - .slice(0, MAX_ENTRIES) - .map( - ([k, v]) => - `${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`, - ); - return `{\n${preview.join(",\n")},\n${nextIndent}...${allEntries.length - MAX_ENTRIES} more\n${indent}}`; - } - - const pairs = allEntries.map( - ([k, v]) => - `${nextIndent}${k}: ${stringifyForDisplay(v, maxDepth, currentDepth + 1, { ...options, asFunctionArgs: false })}`, - ); - return `{\n${pairs.join(",\n")}\n${indent}}`; - } - - return String(value); -}; - -/** - * jsenv/navi - createJsValueWeakMap - * - * Key/value cache with true ephemeron behavior and deep equality support. - * - * Features: - * - Mutual retention: key keeps value alive, value keeps key alive - * - Deep equality: different objects with same content are treated as identical keys - * - Automatic GC: entries are eligible for collection when unreferenced - * - Iteration support: can iterate over live entries for deep equality lookup - * - * Implementation: - * - Dual WeakMap (key->value, value->key) provides ephemeron behavior - * - WeakRef registry enables iteration without preventing GC - * - Primitives stored in Map (permanent retention - avoid for keys) - * - * Use case: Action caching where params (key) and action (value) should have - * synchronized lifetimes while allowing natural garbage collection. - */ - - -const createJsValueWeakMap = () => { - // Core ephemeron maps for mutual retention - const keyToValue = new WeakMap(); // key -> value - const valueToKey = new WeakMap(); // value -> key - - // Registry for iteration/deep equality (holds WeakRefs) - const keyRegistry = new Set(); // Set of WeakRef(key) - - // Primitive cache - const primitiveCache = new Map(); - - function cleanupKeyRegistry() { - for (const keyRef of keyRegistry) { - if (keyRef.deref() === undefined) { - keyRegistry.delete(keyRef); - } - } - } - - return { - *[Symbol.iterator]() { - cleanupKeyRegistry(); - for (const keyRef of keyRegistry) { - const key = keyRef.deref(); - if (key && keyToValue.has(key)) { - yield [key, keyToValue.get(key)]; - } - } - for (const [k, v] of primitiveCache) { - yield [k, v]; - } - }, - - get(key) { - const isObject = - key && (typeof key === "object" || typeof key === "function"); - if (isObject) { - // Fast path: exact key match - if (keyToValue.has(key)) { - return keyToValue.get(key); - } - - // Slow path: deep equality search - cleanupKeyRegistry(); - for (const keyRef of keyRegistry) { - const existingKey = keyRef.deref(); - if (existingKey && compareTwoJsValues(existingKey, key)) { - return keyToValue.get(existingKey); - } - } - return undefined; - } - return primitiveCache.get(key); - }, - - set(key, value) { - const isObject = - key && (typeof key === "object" || typeof key === "function"); - if (isObject) { - cleanupKeyRegistry(); - - // Remove existing deep-equal key - for (const keyRef of keyRegistry) { - const existingKey = keyRef.deref(); - if (existingKey && compareTwoJsValues(existingKey, key)) { - const existingValue = keyToValue.get(existingKey); - keyToValue.delete(existingKey); - valueToKey.delete(existingValue); - keyRegistry.delete(keyRef); - break; - } - } - - // Set ephemeron pair - keyToValue.set(key, value); - valueToKey.set(value, key); - keyRegistry.add(new WeakRef(key)); - } else { - primitiveCache.set(key, value); - } - }, - - delete(key) { - const isObject = - key && (typeof key === "object" || typeof key === "function"); - if (isObject) { - cleanupKeyRegistry(); - - // Try exact match first - if (keyToValue.has(key)) { - const value = keyToValue.get(key); - keyToValue.delete(key); - valueToKey.delete(value); - - // Remove from registry - for (const keyRef of keyRegistry) { - if (keyRef.deref() === key) { - keyRegistry.delete(keyRef); - break; - } - } - return true; - } - - // Try deep equality - for (const keyRef of keyRegistry) { - const existingKey = keyRef.deref(); - if (existingKey && compareTwoJsValues(existingKey, key)) { - const value = keyToValue.get(existingKey); - keyToValue.delete(existingKey); - valueToKey.delete(value); - keyRegistry.delete(keyRef); - return true; - } - } - return false; - } - return primitiveCache.delete(key); - }, - - getStats: () => { - cleanupKeyRegistry(); - const aliveKeys = Array.from(keyRegistry).filter((ref) => - ref.deref(), - ).length; - - return { - ephemeronPairs: { - total: keyRegistry.size, - alive: aliveKeys, - note: "True ephemeron: key ↔ value mutual retention via dual WeakMap", - }, - primitive: { - total: primitiveCache.size, - note: "Primitive keys never GC'd", - }, - }; - }, - }; -}; - -const MERGE_AS_PRIMITIVE_SYMBOL = Symbol("navi_merge_as_primitive"); - -const mergeTwoJsValues = (firstValue, secondValue) => { - const firstIsPrimitive = - firstValue === null || - typeof firstValue !== "object" || - MERGE_AS_PRIMITIVE_SYMBOL in firstValue; - - if (firstIsPrimitive) { - return secondValue; - } - const secondIsPrimitive = - secondValue === null || - typeof secondValue !== "object" || - MERGE_AS_PRIMITIVE_SYMBOL in secondValue; - if (secondIsPrimitive) { - return secondValue; - } - const objectMerge = {}; - const firstKeys = Object.keys(firstValue); - const secondKeys = Object.keys(secondValue); - let hasChanged = false; - - // First loop: check for keys in first object and recursively merge with second - for (const key of firstKeys) { - const firstValueForKey = firstValue[key]; - const secondHasKey = secondKeys.includes(key); +// Default built-in translations — apps can override any key via add() +naviI18n.addAll({ + "time.less_than_minute": { + en: "in less than a minute", + fr: "dans moins d'une minute", + de: "in weniger als einer Minute", + es: "en menos de un minuto", + it: "in meno di un minuto", + pt: "em menos de um minuto", + nl: "over minder dan een minuut", + }, + "time.ongoing": { + en: "Ongoing", + fr: "En cours", + de: "Laufend", + es: "En curso", + it: "In corso", + pt: "Em andamento", + nl: "Bezig", + }, + // [day] and [time] are replaced at runtime with the localized day/time strings + "time.tomorrow_at": { + en: "[day] at [time]", + fr: "[day] à [time]", + de: "[day] um [time]", + es: "[day] a las [time]", + it: "[day] alle [time]", + pt: "[day] às [time]", + nl: "[day] om [time]", + }, + // [duration] is replaced at runtime with the formatted duration string (e.g. "1h30", "45 min") + "time.in_duration": { + en: "in [duration]", + fr: "dans [duration]", + de: "in [duration]", + es: "en [duration]", + it: "tra [duration]", + pt: "em [duration]", + nl: "over [duration]", + }, + // Substituted in place of the "0 heure(s)" part of an Intl-generated + // duration string when renders midnight + // — see time.jsx's own TimeTime for why midnight can't just fall through + // to formatMinuteDuration like every other hour does, and how this word + // gets spliced in (formatToParts, not string concatenation) so the rest + // of the sentence (conjunction, minutes) still comes out in whatever + // grammar/word order this language's own Intl.DurationFormat produces. + // Languages without an entry here fall back to that language's own + // literal "0 heure(s)" wording instead (see TimeTime), never to this key. + "time.midnight": { + en: "midnight", + fr: "minuit", + de: "Mitternacht", + es: "medianoche", + it: "mezzanotte", + pt: "meia-noite", + nl: "middernacht", + }, + // Compact duration unit symbols used in "1h30", "45min", "2d", etc. + "time.duration.year_symbol": { + en: "y", + fr: "a", + de: "J", + es: "a", + it: "a", + pt: "a", + nl: "j", + ja: "年", + zh: "年", + ko: "년", + }, + "time.duration.month_symbol": { + en: "mo", + fr: "mo", + de: "Mo", + es: "mo", + it: "mo", + pt: "mo", + nl: "mo", + ja: "月", + zh: "月", + ko: "월", + }, + "time.duration.week_symbol": { + en: "w", + fr: "sem", + de: "W", + es: "sem", + it: "sett", + pt: "sem", + nl: "w", + ja: "週", + zh: "周", + ko: "주", + }, + "time.duration.day_symbol": { + en: "d", + fr: "j", + de: "T", + es: "d", + it: "g", + pt: "d", + nl: "d", + ja: "日", + zh: "天", + ko: "일", + }, + "time.duration.hour_symbol": { + en: "h", + fr: "h", + de: "h", + es: "h", + it: "h", + pt: "h", + nl: "u", + ja: "時間", + zh: "小时", + ko: "시간", + }, + "time.duration.minute_symbol": { + en: "min", + fr: "min", + de: "min", + es: "min", + it: "min", + pt: "min", + nl: "min", + ja: "分", + zh: "分", + ko: "분", + }, + "time.duration.second_symbol": { + en: "s", + fr: "s", + de: "s", + es: "s", + it: "s", + pt: "s", + nl: "s", + ja: "秒", + zh: "秒", + ko: "초", + }, + "time.duration.millisecond_symbol": { + en: "ms", + fr: "ms", + de: "ms", + es: "ms", + it: "ms", + pt: "ms", + nl: "ms", + ja: "ms", + zh: "ms", + ko: "ms", + }, +}); - if (secondHasKey) { - const secondValueForKey = secondValue[key]; - const mergedValue = mergeTwoJsValues(firstValueForKey, secondValueForKey); - objectMerge[key] = mergedValue; - if (mergedValue !== firstValueForKey) { - hasChanged = true; - } - } else { - objectMerge[key] = firstValueForKey; - } - } +// Spin messages — the ends of what one steps through, said without naming +// what it is made of: the same words fit days, months, pages or sizes. +naviI18n.addAll({ + "spin.previous": { + en: "Previous", + fr: "Précédent", + }, + "spin.next": { + en: "Next", + fr: "Suivant", + }, + "spin.nothing_before": { + en: "No item before this one.", + fr: "Pas d'élément avant celui-ci.", + }, + "spin.nothing_after": { + en: "No item after this one.", + fr: "Pas d'élément après celui-ci.", + }, +}); - for (const key of secondKeys) { - if (firstKeys.includes(key)) { - continue; - } - objectMerge[key] = secondValue[key]; - hasChanged = true; - } +// List messages — override any key to customize list messages +naviI18n.addAll({ + "list.empty": { + en: "No items in this list.", + fr: "Aucun élément dans cette liste.", + }, + "list.no_match": { + en: "No item matches this search.", + fr: "Aucun élément ne correspond à cette recherche.", + }, + "list.no_match_rest_shown": { + en: "No item matches this search. The rest is shown below.", + fr: "Aucun élément ne correspond à cette recherche. Le reste est affiché ci-dessous.", + }, + "list.rows_failed": { + en: "These elements could not be loaded.", + fr: "Ces élements n'ont pas pu être chargées.", + }, + "list.rows_retry": { + en: "Retry", + fr: "Réessayer", + }, +}); - if (!hasChanged) { - return firstValue; - } - return objectMerge; -}; +// Badge list messages +naviI18n.addAll({ + "badge_list.more": { + en: "+[count] more", + fr: "+[count] de plus", + }, +}); -/** - * Creates an effect that uses WeakRef to prevent garbage collection of referenced values. - * - * This utility is useful when you want to create reactive effects that watch objects - * without preventing those objects from being garbage collected. If any of the referenced - * values is collected, the effect automatically disposes itself. - * - * @param {Array} values - Array of values to create weak references for - * @param {Function} callback - Function to call when the effect runs, receives dereferenced values as arguments - * @returns {Function} dispose - Function to manually dispose the effect - * - * @example - * ```js - * const objectA = { name: "A" }; - * const objectB = { name: "B" }; - * const prefixSignal = signal('demo'); - * - * const dispose = weakEffect([objectA, objectB], (a, b) => { - * const prefix = prefixSignal.value - * console.log(prefix, a.name, b.name); - * }); - * - * // Effect will auto-dispose if objectA or objectB where garbage collected - * // or can be manually disposed: - * dispose(); - * ``` - */ -const weakEffect = (values, callback) => { - const weakRefSet = new Set(); - for (const value of values) { - weakRefSet.add(new WeakRef(value)); - } - const dispose = effect(() => { - const values = []; - for (const weakRef of weakRefSet) { - const value = weakRef.deref(); - if (value === undefined) { - dispose(); - return; - } - values.push(value); - } - callback(...values); - }); - return dispose; -}; - -const SYMBOL_OBJECT_SIGNAL = Symbol.for("navi_object_signal"); - -let DEBUG$3 = false; -const enableDebugActions = () => { - DEBUG$3 = true; -}; - -let dispatchActions = (params) => { - const { requestedResult } = updateActions({ - globalAbortSignal: new AbortController().signal, - abortSignal: new AbortController().signal, - ...params, - }); - return requestedResult; -}; - -const dispatchSingleAction = (action, method, options) => { - const requestedResult = dispatchActions({ - prerunSet: method === "prerun" ? new Set([action]) : undefined, - runSet: method === "run" ? new Set([action]) : undefined, - rerunSet: method === "rerun" ? new Set([action]) : undefined, - resetSet: method === "reset" ? new Set([action]) : undefined, - ...options, - }); - if (requestedResult && typeof requestedResult.then === "function") { - return requestedResult.then((resolvedResult) => - resolvedResult ? resolvedResult[0] : undefined, - ); - } - return requestedResult ? requestedResult[0] : undefined; -}; -const setActionDispatcher = (value) => { - dispatchActions = value; -}; - -const getActionDispatcher = () => dispatchActions; - -const rerunActions = async (actionSet, options) => { - return dispatchActions({ - rerunSet: actionSet, - reason: "rerunActions was calle", - ...options, - }); -}; - -/** - * Registry that prevents prerun actions from being garbage collected. - * - * When an action is prerun, it might not have any active references yet - * (e.g., the component that will use it hasn't loaded yet due to dynamic imports). - * This registry keeps a reference to prerun actions for a configurable duration - * to ensure they remain available when needed. - * - * Actions are automatically unprotected when: - * - The protection duration expires (default: 5 minutes) - * - The action is explicitly stopped via .reset() - */ -const prerunProtectionRegistry = (() => { - const protectedActionMap = new Map(); // action -> { timeoutId, timestamp } - const PROTECTION_DURATION = 5 * 60 * 1000; // 5 minutes en millisecondes - - const unprotect = (action) => { - const protection = protectedActionMap.get(action); - if (protection) { - clearTimeout(protection.timeoutId); - protectedActionMap.delete(action); - const elapsed = Date.now() - protection.timestamp; - action.debug(`"${action}": GC protection removed after ${elapsed}ms`); - } - }; - - return { - protect(action) { - // Si déjà protégée, étendre la protection - if (protectedActionMap.has(action)) { - const existing = protectedActionMap.get(action); - clearTimeout(existing.timeoutId); - } - - const timestamp = Date.now(); - const timeoutId = setTimeout(() => { - unprotect(action); - action.debug( - `"${action}": prerun protection expired after ${PROTECTION_DURATION}ms`, - ); - }, PROTECTION_DURATION); - protectedActionMap.set(action, { timeoutId, timestamp }); - action.debug( - `"${action}": protected from GC for ${PROTECTION_DURATION}ms`, - ); - }, - - unprotect, - - isProtected(action) { - return protectedActionMap.has(action); - }, - - // Pour debugging - getProtectedActions() { - return Array.from(protectedActionMap.keys()); - }, - - // Nettoyage manuel si nécessaire - clear() { - for (const [, protection] of protectedActionMap) { - clearTimeout(protection.timeoutId); - } - protectedActionMap.clear(); - }, - }; -})(); - -const formatActionSet = (actionSet, prefix = "") => { - let message = ""; - message += `${prefix}`; - for (const action of actionSet) { - message += "\n"; - message += prefixFirstAndIndentRemainingLines(String(action), { - prefix: " -", - }); - } - return message; -}; - -const actionAbortMap = new Map(); -const actionPromiseMap = new Map(); -const activationWeakSet = createIterableWeakSet("activation"); - -const getActivationInfo = () => { - const runningSet = new Set(); - const settledSet = new Set(); - - for (const action of activationWeakSet) { - const runningState = action.runningStateSignal.peek(); - - if (runningState === RUNNING) { - runningSet.add(action); - } else if ( - runningState === COMPLETED || - runningState === FAILED || - runningState === ABORTED - ) { - settledSet.add(action); - } else { - throw new Error( - `An action in the activation weak set must be RUNNING, ABORTED, FAILED or COMPLETED, found "${runningState.id}" for action "${action}"`, - ); - } - } - - return { - runningSet, - settledSet, - }; -}; - -const updateActions = ({ - globalAbortSignal, - abortSignal, - isReplace = false, - reason, - event, - prerunSet = new Set(), - runSet = new Set(), - rerunSet = new Set(), - resetSet = new Set(), - abortSignalMap = new Map(), - onComplete, - onAbort, - onError, -} = {}) => { - /* - * Action update flow: - * - * Input: 4 sets of requested operations - * - prerunSet: actions to prerun (background, low priority) - * - runSet: actions to run (user-visible, medium priority) - * - rerunSet: actions to force rerun (highest priority) - * - resetSet: actions to reset/clear - * - * Priority resolution: - * - reset always wins (explicit cleanup) - * - rerun > run > prerun (rerun forces refresh even if already running) - * - An action in multiple sets triggers warnings in dev mode - * - * Output: Internal operation sets that track what will actually happen - * - willResetSet: actions that will be reset/cleared - * - willPrerunSet: actions that will be prerun - * - willRunSet: actions that will be run - * - willPromoteSet: prerun actions that become run-requested - * - stays*Set: actions that remain in their current state - */ - - const { runningSet, settledSet } = getActivationInfo(); - - if (DEBUG$3) { - let argSource = `reason: ${JSON.stringify(reason)}`; - if (isReplace) { - argSource += `, isReplace: true`; - } - console.group(`updateActions({ ${argSource} })`); - const lines = [ - ...(prerunSet.size ? [formatActionSet(prerunSet, "- prerun:")] : []), - ...(runSet.size ? [formatActionSet(runSet, "- run:")] : []), - ...(rerunSet.size ? [formatActionSet(rerunSet, "- rerun:")] : []), - ...(resetSet.size ? [formatActionSet(resetSet, "- reset:")] : []), - ]; - console.debug( - `requested operations: -${lines.join("\n")}`, - ); - } - - // Internal sets that track what operations will actually be performed - const willResetSet = new Set(); - const willPrerunSet = new Set(); - const willRunSet = new Set(); - const willPromoteSet = new Set(); // prerun -> run requested - const staysRunningSet = new Set(); - const staysAbortedSet = new Set(); - const staysFailedSet = new Set(); - const staysCompletedSet = new Set(); - - // Step 1: Determine which actions will be reset - { - for (const actionToReset of resetSet) { - if (actionToReset.runningState !== IDLE) { - willResetSet.add(actionToReset); - } - } - } - - // Step 2: Process prerun, run, and rerun sets - { - const handleActionRequest = ( - action, - requestType, // "prerun", "run", or "rerun" - ) => { - const isPrerun = requestType === "prerun"; - const isRerun = requestType === "rerun"; - - if ( - action.runningState === RUNNING || - action.runningState === COMPLETED - ) { - // Action is already running/completed - // By default, we don't interfere with already active actions - // Unless it's a rerun or the action is also being reset - if (isRerun || willResetSet.has(action)) { - // Force reset first, then rerun/run - willResetSet.add(action); - if (isPrerun) { - willPrerunSet.add(action); - } else { - willRunSet.add(action); - } - } - // Otherwise, ignore the request (action stays as-is) - } else if (isPrerun) { - willPrerunSet.add(action); - } else { - willRunSet.add(action); - } - }; - - // Process prerunSet (lowest priority) - for (const actionToPrerun of prerunSet) { - if (runSet.has(actionToPrerun) || rerunSet.has(actionToPrerun)) { - // run/rerun wins over prerun - skip prerun - continue; - } - handleActionRequest(actionToPrerun, "prerun"); - } - - // Process runSet (medium priority) - for (const actionToRun of runSet) { - if (rerunSet.has(actionToRun)) { - // rerun wins over run - skip run - continue; - } - if (actionToRun.isPrerun && actionToRun.runningState !== IDLE) { - // Special case: action was prerun but not yet requested to run - // Just promote it to "run requested" without rerunning - willPromoteSet.add(actionToRun); - continue; - } - handleActionRequest(actionToRun, "run"); - } - - // Process rerunSet (highest priority) - for (const actionToRerun of rerunSet) { - handleActionRequest(actionToRerun, "rerun"); - } - } - const allThenableArray = []; - - // Step 3: Determine which actions will stay in their current state - { - for (const actionRunning of runningSet) { - if (willResetSet.has(actionRunning)) ; else if ( - willRunSet.has(actionRunning) || - willPrerunSet.has(actionRunning) - ) ; else { - // an action that was running and not affected by this update - const actionPromise = actionPromiseMap.get(actionRunning); - allThenableArray.push(actionPromise); - staysRunningSet.add(actionRunning); - } - } - for (const actionSettled of settledSet) { - if (willResetSet.has(actionSettled)) ; else if (actionSettled.runningState === ABORTED) { - staysAbortedSet.add(actionSettled); - } else if (actionSettled.runningState === FAILED) { - staysFailedSet.add(actionSettled); - } else { - staysCompletedSet.add(actionSettled); - } - } - } - if (DEBUG$3) { - const lines = [ - ...(willResetSet.size - ? [formatActionSet(willResetSet, "- will reset:")] - : []), - ...(willPrerunSet.size - ? [formatActionSet(willPrerunSet, "- will prerun:")] - : []), - ...(willPromoteSet.size - ? [formatActionSet(willPromoteSet, "- will promote:")] - : []), - ...(willRunSet.size ? [formatActionSet(willRunSet, "- will run:")] : []), - ...(staysRunningSet.size - ? [formatActionSet(staysRunningSet, "- stays running:")] - : []), - ...(staysAbortedSet.size - ? [formatActionSet(staysAbortedSet, "- stays aborted:")] - : []), - ...(staysFailedSet.size - ? [formatActionSet(staysFailedSet, "- stays failed:")] - : []), - ...(staysCompletedSet.size - ? [formatActionSet(staysCompletedSet, "- stays completed:")] - : []), - ]; - console.debug(`operations that will be performed: -${lines.join("\n")}`); - } - - // Step 4: Execute resets - { - for (const actionToReset of willResetSet) { - const actionToResetPrivateProperties = - getActionPrivateProperties(actionToReset); - actionToResetPrivateProperties.performReset({ - reason, - event, - willRunOrPrerun: - willRunSet.has(actionToReset) || willPrerunSet.has(actionToReset), - }); - activationWeakSet.delete(actionToReset); - } - } - - const resultArray = []; // Store results with their execution order - let hasAsync = false; - - // Step 5: Execute preruns and runs - { - const onActionToRunOrPrerun = (actionToPrerunOrRun, isPrerun) => { - const actionSpecificSignal = abortSignalMap.get(actionToPrerunOrRun); - const effectiveSignal = actionSpecificSignal || abortSignal; - - const actionToRunPrivateProperties = - getActionPrivateProperties(actionToPrerunOrRun); - const performRunResult = actionToRunPrivateProperties.performRun({ - globalAbortSignal, - abortSignal: effectiveSignal, - reason, - event, - isPrerun, - onComplete, - onAbort, - onError, - }); - activationWeakSet.add(actionToPrerunOrRun); - - if (performRunResult && typeof performRunResult.then === "function") { - actionPromiseMap.set(actionToPrerunOrRun, performRunResult); - allThenableArray.push(performRunResult); - hasAsync = true; - // Store async result with order info - resultArray.push({ - type: "async", - promise: performRunResult, - }); - } else { - // Store sync result with order info - resultArray.push({ - type: "sync", - result: performRunResult, - }); - } - }; - - // Execute preruns - for (const actionToPrerun of willPrerunSet) { - onActionToRunOrPrerun(actionToPrerun, true); - } - - // Execute runs - for (const actionToRun of willRunSet) { - onActionToRunOrPrerun(actionToRun, false); - } - - // Execute promotions (prerun -> run requested) - for (const actionToPromote of willPromoteSet) { - actionToPromote.isPrerunSignal.value = false; - } - } - if (DEBUG$3) { - console.groupEnd(); - } - - // Calculate requestedResult based on the execution results - let requestedResult; - if (resultArray.length === 0) { - requestedResult = null; - } else if (hasAsync) { - requestedResult = Promise.all( - resultArray.map((item) => - item.type === "sync" ? item.result : item.promise, - ), - ); - } else { - requestedResult = resultArray.map((item) => item.result); - } - - const allResult = allThenableArray.length - ? Promise.allSettled(allThenableArray) - : null; - const runningActionSet = new Set([...willPrerunSet, ...willRunSet]); - return { - requestedResult, - allResult, - runningActionSet, - }; -}; - -const NO_PARAMS = { __no_params__: true }; -const initialParamsDefault = NO_PARAMS; -const mergeActionParams = (currentParams, newParams) => { - if (currentParams === NO_PARAMS) { - return newParams; - } - return mergeTwoJsValues(currentParams, newParams); -}; - -const actionWeakMap = new WeakMap(); -const createAction = (callback, rootOptions = {}) => { - const existing = actionWeakMap.get(callback); - if (existing) { - return existing; - } - - let rootAction; - - const createActionCore = (options, { parentAction } = {}) => { - let { - name = callback.name || "anonymous", - params, - isPrerun = false, - runningState = IDLE, - aborted = false, - error = null, - value, - resultToValue, - valueToData, - dataDefault, - data = dataDefault, - - completed = false, - renderLoadedAsync, - sideEffect = () => {}, - meta = {}, - - outputSignal, - completeSideEffect, - } = options; - if (!Object.hasOwn(options, "params")) { - // even undefined should be respected it's only when not provided at all we use default - params = initialParamsDefault; - } - if (value === undefined && data !== undefined) { - value = data; - } - - const valueInitial = value; - const paramsSignal = signal(params); - const isPrerunSignal = signal(isPrerun); - const runningStateSignal = signal(runningState); - const errorSignal = signal(error); - const valueSignal = signal(valueInitial); - const dataSignal = valueToData - ? computed(() => { - const value = valueSignal.value; - const data = valueToData(value); - return data; - }) - : valueSignal; - - const prerun = (options) => { - action.debug(`${action}.prerun(${stringifyForDisplay(options)})`); - return dispatchSingleAction(action, "prerun", options); - }; - const run = (options) => { - action.debug(`${action}.run(${stringifyForDisplay(options)})`); - return dispatchSingleAction(action, "run", options); - }; - const rerun = (options) => { - action.debug(`${action}.rerun(${stringifyForDisplay(options)})`); - return dispatchSingleAction(action, "rerun", options); - }; - /** - * Stop the action completely - this will: - * 1. Abort if it's currently running - * 2. Reset action running signal to IDLE state - * 3. Clean up any resources and side effects - * 4. Reset data/error to initial value - */ - const reset = (options) => { - return dispatchSingleAction(action, "reset", options); - }; - const abort = (reason) => { - if (runningState !== RUNNING) { - return false; - } - const actionAbort = actionAbortMap.get(action); - if (!actionAbort) { - return false; - } - action.debug(`"${action}".abort(${reason})`); - actionAbort(reason); - return true; - }; - - let action; - - const childActionWeakSet = createIterableWeakSet("child_action"); - /* - * Ephemeron behavior is critical here: actions must keep params alive. - * Without this, bindParams(params) could create a new action while code - * still references the old action with GC'd params. This would cause: - * - Duplicate actions in activationWeakSet (old + new) - * - Cache misses when looking up existing actions - * - Subtle bugs where different parts of code use different action instances - * The ephemeron pattern ensures params and actions have synchronized lifetimes. - */ - const childActionWeakMap = createJsValueWeakMap(); - const _bindParams = (newParamsOrSignal, options = {}) => { - // ✅ CAS 1: Signal direct -> proxy - if (isSignal(newParamsOrSignal)) { - const combinedParamsSignal = computed(() => { - const newParams = newParamsOrSignal.value; - const result = mergeActionParams(params, newParams); - return result; - }); - return createActionProxyFromSignal( - action, - combinedParamsSignal, - options, - ); - } - - // ✅ CAS 2: Objet -> vérifier s'il contient des signals - if (isPlainObject(newParamsOrSignal)) { - const staticParams = {}; - const signalMap = new Map(); - - const keyArray = Object.keys(newParamsOrSignal); - for (const key of keyArray) { - const value = newParamsOrSignal[key]; - if (isSignal(value)) { - signalMap.set(key, value); - } else { - const objectSignal = value ? value[SYMBOL_OBJECT_SIGNAL] : null; - if (objectSignal) { - signalMap.set(key, objectSignal); - } else { - staticParams[key] = value; - } - } - } - - if (signalMap.size === 0) { - // Pas de signals, merge statique normal - if ( - params === null || - typeof params !== "object" || - params === NO_PARAMS - ) { - return createChildAction({ - ...options, - params: newParamsOrSignal, - }); - } - const combinedParams = mergeActionParams(params, newParamsOrSignal); - return createChildAction({ - ...options, - params: combinedParams, - }); - } - - // Combiner avec les params existants pour les valeurs statiques - const paramsSignal = computed(() => { - const params = {}; - for (const key of keyArray) { - const signalForThisKey = signalMap.get(key); - if (signalForThisKey) { - // eslint-disable-next-line signals/no-conditional-value-read - params[key] = signalForThisKey.value; - } else { - params[key] = staticParams[key]; - } - } - return params; - }); - return createActionProxyFromSignal(action, paramsSignal, options); - } - - // ✅ CAS 3: Primitive or objects like DOMEvents etc -> action enfant - return createChildAction({ - params: newParamsOrSignal, - ...options, - }); - }; - const bindParams = (newParamsOrSignal, options = {}) => { - const existingChildAction = childActionWeakMap.get(newParamsOrSignal); - if (existingChildAction) { - return existingChildAction; - } - const childAction = _bindParams(newParamsOrSignal, options); - childActionWeakMap.set(newParamsOrSignal, childAction); - childActionWeakSet.add(childAction); - - return childAction; - }; - - const createChildAction = (childOptions) => { - const childActionOptions = { - ...rootOptions, - ...childOptions, - meta: { - ...rootOptions.meta, - ...childOptions.meta, - }, - }; - const childAction = createActionCore(childActionOptions, { - parentAction: action, - }); - return childAction; - }; - - // ✅ Implement matchAllSelfOrDescendant - const matchAllSelfOrDescendant = (predicate, { includeProxies } = {}) => { - const matches = []; - - const traverse = (currentAction) => { - if (currentAction.isProxy && !includeProxies) { - // proxy action should be ignored because the underlying action will be found anyway - // and if we check the proxy action we'll end up with duplicates - // (loading the proxy would load the action it proxies) - // and as they are 2 different objects they would be added to the set - return; - } - - if (predicate(currentAction)) { - matches.push(currentAction); - } - - // Get child actions from the current action - const currentActionPrivateProps = - getActionPrivateProperties(currentAction); - const childActionWeakSet = currentActionPrivateProps.childActionWeakSet; - for (const childAction of childActionWeakSet) { - traverse(childAction); - } - }; - - traverse(action); - return matches; - }; - - const actionNameSignal = signal(name); - const actionCallSourceSignal = signal( - generateActionCallSource(name, params), - ); - - { - // Create the action as a function that can be called directly - action = function actionFunction(...args) { - if (args.length === 0) { - return action.rerun(); - } - const boundAction = bindParams(...args); - return boundAction.rerun(); - }; - Object.defineProperty(action, "name", { - configurable: true, - get() { - return actionNameSignal.value; - }, - }); - Object.defineProperty(action, "callSource", { - configurable: true, - get() { - return actionCallSourceSignal.value; - }, - set(v) { - actionCallSourceSignal.value = v; - }, - }); - actionWeakMap.set(action, action); - } - - // Assign all the action properties and methods to the function - Object.assign(action, { - isAction: true, - callback, - rootAction, - parentAction, - params, - isPrerun, - runningState, - aborted, - error, - value, - data, - completed, - prerun, - run, - rerun, - reset, - abort, - bindParams, - matchAllSelfOrDescendant, // ✅ Add the new method - replaceParams: (newParams) => { - const currentParams = paramsSignal.value; - const nextParams = mergeActionParams(currentParams, newParams); - if (nextParams === currentParams) { - return false; - } - - // Update the weak map BEFORE updating the signal - // so that any code triggered by the signal update finds this action - if (parentAction) { - const parentActionPrivateProps = - getActionPrivateProperties(parentAction); - const parentChildActionWeakMap = - parentActionPrivateProps.childActionWeakMap; - parentChildActionWeakMap.delete(currentParams); - parentChildActionWeakMap.set(nextParams, action); - } - - params = nextParams; - action.params = nextParams; - action.callSource = generateActionCallSource(name, nextParams); - paramsSignal.value = nextParams; - return true; - }, - toString: () => action.callSource, - meta, - debug: (...args) => { - if (!meta.debug || DEBUG$3) { - return; - } - console.debug(...args); - }, - - paramsSignal, - runningStateSignal, - isPrerunSignal, - valueSignal, - dataSignal, - errorSignal, - }); - Object.preventExtensions(action); - - // Effects pour synchroniser les propriétés - { - weakEffect([action], (actionRef) => { - isPrerun = isPrerunSignal.value; - actionRef.isPrerun = isPrerun; - }); - weakEffect([action], (actionRef) => { - runningState = runningStateSignal.value; - actionRef.runningState = runningState; - aborted = runningState === ABORTED; - actionRef.aborted = aborted; - completed = runningState === COMPLETED; - actionRef.completed = completed; - }); - weakEffect([action], (actionRef) => { - error = errorSignal.value; - actionRef.error = error; - }); - weakEffect([action], (actionRef) => { - value = valueSignal.value; - data = dataSignal.value; - actionRef.value = value; - actionRef.data = data; - }); - } - - // Propriétés privées - { - const ui = { - renderLoaded: null, - renderLoadedAsync, - hasRenderers: false, // Flag to track if action is bound to UI components - }; - let sideEffectCleanup; - let completeSideEffectCleanup; - - const performRun = (runParams) => { - const { - globalAbortSignal, - abortSignal, - reason, - event, - isPrerun, - onComplete, - onAbort, - onError, - } = runParams; - - if (isPrerun) { - prerunProtectionRegistry.protect(action); - } - - const internalAbortController = new AbortController(); - const internalAbortSignal = internalAbortController.signal; - const abort = (abortReason) => { - runningStateSignal.value = ABORTED; - internalAbortController.abort(abortReason); - actionAbortMap.delete(action); - if (isPrerun && (globalAbortSignal.aborted || abortSignal.aborted)) { - prerunProtectionRegistry.unprotect(action); - } - if (DEBUG$3) { - console.log(`"${action}" aborted (reason: ${abortReason})`); - } - }; - - const onAbortFromSpecific = () => { - abort(abortSignal.reason); - }; - const onAbortFromGlobal = () => { - abort(globalAbortSignal.reason); - }; - - if (abortSignal) { - abortSignal.addEventListener("abort", onAbortFromSpecific); - } - if (globalAbortSignal) { - globalAbortSignal.addEventListener("abort", onAbortFromGlobal); - } - - actionAbortMap.set(action, abort); - - batch(() => { - runningStateSignal.value = RUNNING; - if (!isPrerun) { - isPrerunSignal.value = false; - } - }); - - const args = []; - args.push(params); - args.push({ - reason, - event, - signal: internalAbortSignal, - isPrerun, - }); - const returnValue = sideEffect(...args); - if (typeof returnValue === "function") { - sideEffectCleanup = returnValue; - } - - let runResult; - let rejected = false; - let rejectedValue; - const onRunEnd = () => { - if (abortSignal) { - abortSignal.removeEventListener("abort", onAbortFromSpecific); - } - if (globalAbortSignal) { - globalAbortSignal.removeEventListener("abort", onAbortFromGlobal); - } - prerunProtectionRegistry.unprotect(action); - actionAbortMap.delete(action); - actionPromiseMap.delete(action); - /* - * Critical: dataEffect, onComplete and completeSideEffect must be batched together to prevent - * UI inconsistencies. The dataEffect might modify shared state (e.g., - * deleting items from a store), and onLoad callbacks might trigger - * dependent action state changes. - * - * Without batching, the UI could render with partially updated state: - * - dataEffect deletes a resource from the store - * - UI renders immediately and tries to display the deleted resource - * - onLoad hasn't yet updated dependent actions to loading state - * - * Example: When deleting a resource, we need to both update the store - * AND put the action that loaded that resource back into loading state - * before the UI attempts to render the now-missing resource. - */ - - batch(() => { - const value = resultToValue - ? resultToValue(runResult, action) - : runResult; - errorSignal.value = undefined; - valueSignal.value = value; - runningStateSignal.value = COMPLETED; - const data = dataSignal.value; - if (outputSignal) { - outputSignal.value = data; - } - onComplete?.(data, action); - completeSideEffectCleanup = completeSideEffect?.(action); - }); - if (DEBUG$3) { - console.log(`"${action}": completed`); - } - const data = dataSignal.peek(); - return data; - }; - const onRunError = (error) => { - if (abortSignal) { - abortSignal.removeEventListener("abort", onAbortFromSpecific); - } - if (globalAbortSignal) { - globalAbortSignal.removeEventListener("abort", onAbortFromGlobal); - } - actionAbortMap.delete(action); - actionPromiseMap.delete(action); - const isAbort = - (internalAbortSignal.aborted && - error === internalAbortSignal.reason) || - error.name === "AbortError"; - if (isAbort) { - runningStateSignal.value = ABORTED; - if (isPrerun && abortSignal.aborted) { - prerunProtectionRegistry.unprotect(action); - } - onAbort?.(error, { event, action, args }); - return error; - } - if (DEBUG$3) { - console.log( - `"${action}": failed (error: ${error}, handled by ui: ${ui.hasRenderers})`, - ); - } - batch(() => { - errorSignal.value = error; - runningStateSignal.value = FAILED; - onError?.(error, { event, action, args }); - }); - - if (ui.hasRenderers || onError) { - // When inside suspense this console.error is redundant with the error thrown by preact debug at - // https://github.com/preactjs/preact/blob/21dd6d04c1a9a43e5b60976bb5eb7d856253195b/debug/src/debug.js#L109 - console.error(error); - // For UI-bound actions: error is properly handled by logging + UI display - // Return error instead of throwing to signal it's handled and prevent: - // - jsenv error overlay from appearing - // - error being treated as unhandled by runtime - return error; - } - error.action = action; - throw error; - }; - - try { - const thenableArray = []; - const callbackResult = callback(...args); - if (callbackResult && typeof callbackResult.then === "function") { - thenableArray.push( - callbackResult.then( - (value) => { - runResult = value; - }, - (e) => { - rejected = true; - rejectedValue = e; - }, - ), - ); - } else { - runResult = callbackResult; - } - if (ui.renderLoadedAsync && !ui.renderLoaded) { - const renderLoadedPromise = ui.renderLoadedAsync(...args).then( - (renderLoaded) => { - ui.renderLoaded = renderLoaded; - }, - (e) => { - if (!rejected) { - rejected = true; - rejectedValue = e; - } - }, - ); - thenableArray.push(renderLoadedPromise); - } - if (thenableArray.length === 0) { - return onRunEnd(); - } - return Promise.all(thenableArray).then(() => { - if (rejected) { - return onRunError(rejectedValue); - } - return onRunEnd(); - }); - } catch (e) { - return onRunError(e); - } - }; - - const performReset = ({ reason, willRunOrPrerun }) => { - abort(reason); - if (DEBUG$3) { - console.log(`"${action}": resetting (reason: ${reason})`); - } - - prerunProtectionRegistry.unprotect(action); - - if (sideEffectCleanup) { - sideEffectCleanup(reason); - sideEffectCleanup = undefined; - } - if (completeSideEffectCleanup) { - completeSideEffectCleanup(reason); - completeSideEffectCleanup = undefined; - } - - actionPromiseMap.delete(action); - batch(() => { - if (!willRunOrPrerun) { - errorSignal.value = undefined; - valueSignal.value = valueInitial; - if (outputSignal) { - outputSignal.value = undefined; - } - } - isPrerunSignal.value = true; - runningStateSignal.value = IDLE; - }); - }; - - const privateProperties = { - valueInitial, - - performRun, - performReset, - ui, - - nameSignal: actionNameSignal, - callSourceSignal: actionCallSourceSignal, - - childActionWeakSet, - childActionWeakMap, - }; - setActionPrivateProperties(action, privateProperties); - } - - return action; - }; - - rootAction = createActionCore(rootOptions); - actionWeakMap.set(callback, rootAction); - return rootAction; -}; - -/** - * Creates an action proxy that automatically updates based on signal changes. - * - * @param {Object} action - The base action to proxy - * @param {Signal} paramsSignal - Signal containing parameters for the action - * @param {Object} options - Configuration options - * @param {boolean} options.rerunOnChange - Ensures the action is rerun every time a signal value is modified. - * This enables live updates - for example, performing an HTTP GET request every time - * a list of filters changes, providing real-time results without user interaction. - * @param {boolean} options.inheritData - When true, each new target action starts fresh with no inherited state. - * By default (false), the proxy carries over the previous target's value and error into the new action. - * This keeps the facade in sync with the latest known data: `action.dataSignal.value` only changes when a - * new action completes, not when it starts loading. Code that needs to distinguish loading state can still - * check `action.runningState`, while code that just reads `action.data` always sees the most recent - * available data — even while a newer action is in flight. - * This default also enables "Apply Filters" workflows where parameters change but the action only reruns - * on an explicit user trigger: the previous results remain visible until the new action completes. - * @param {function} options.onChange - Optional callback triggered when the target action changes - */ -const createActionProxyFromSignal = ( - action, - paramsSignal, - { - runOnce = false, - rerunOnChange = false, - inheritData = true, - onChange, - syncParams, - } = {}, -) => { - const actionTargetChangeCallbackSet = new Set(); - const onActionTargetChange = (callback) => { - actionTargetChangeCallbackSet.add(callback); - return () => { - actionTargetChangeCallbackSet.delete(callback); - }; - }; - const changeCleanupCallbackSet = new Set(); - const triggerTargetChange = (actionTarget, previousTarget, context) => { - for (const changeCleanupCallback of changeCleanupCallbackSet) { - changeCleanupCallback(); - } - changeCleanupCallbackSet.clear(); - for (const callback of actionTargetChangeCallbackSet) { - const returnValue = callback(actionTarget, previousTarget, context); - if (typeof returnValue === "function") { - changeCleanupCallbackSet.add(returnValue); - } - } - }; - - let actionTarget = null; - let currentAction = action; - let currentActionPrivateProperties = getActionPrivateProperties(action); - let actionTargetPreviousWeakRef = null; - - const createTarget = (params) => { - if (inheritData) { - const previousActionTarget = actionTargetPreviousWeakRef?.deref(); - const previousTarget = previousActionTarget || action; - return action.bindParams(params, { - error: previousTarget.errorSignal.peek(), - value: previousTarget.valueSignal.peek(), - }); - } - return action.bindParams(params); - }; - - let isUpdatingTarget = false; - const _updateTarget = (context) => { - if (isUpdatingTarget) { - // likely syncParams caused the paramsSignal.value to update which - // calls _updateTarget. But we are already in the middle of an update - // likely cause by an explicit call to rerun for instance - // so we want to keep that rerun intent and "ignore" this updateTarget call - // so we don't end up running the action twice (once because we dispatch change without explicitRunIntent and one for the initial run intent) - return; - } - isUpdatingTarget = true; - action.debug(`${action}._updateTarget(${stringifyForDisplay(context)})`); - if (syncParams) { - syncParams(); - } - isUpdatingTarget = false; - - const params = paramsSignal.peek(); - const proxyParams = proxyParamsSignal.peek(); - if (params !== proxyParams) { - proxyParamsSignal.value = params; - } - const previousActionTarget = actionTargetPreviousWeakRef?.deref(); - - if (params === NO_PARAMS) { - actionTarget = null; - currentAction = action; - currentActionPrivateProperties = getActionPrivateProperties(action); - } else { - actionTarget = createTarget(params); - if (previousActionTarget === actionTarget) { - return; - } - currentAction = actionTarget; - currentActionPrivateProperties = getActionPrivateProperties(actionTarget); - } - actionTargetPreviousWeakRef = actionTarget - ? new WeakRef(actionTarget) - : null; - triggerTargetChange(actionTarget, previousActionTarget, context); - }; - - const proxyMethod = (method, { explicitRunIntent } = {}) => { - return (...args) => { - /* - * Ensure the proxy targets the correct action before method execution. - * This prevents race conditions where external effects run before our - * internal parameter synchronization effect. Using peek() avoids creating - * reactive dependencies within this pass-through method. - */ - _updateTarget({ - changeCause: "method_call", - changeCauseDetail: method, - explicitRunIntent, - }); - return currentAction[method](...args); - }; - }; - - const nameSignal = signal(action.name); - const callSourceSignal = signal(`[Proxy] ${action.callSource}`); - let actionProxy; - { - actionProxy = function actionProxyFunction() { - return actionProxy.rerun(); - }; - Object.defineProperty(actionProxy, "name", { - configurable: true, - get() { - return nameSignal.value; - }, - }); - Object.defineProperty(actionProxy, "callSource", { - configurable: true, - get() { - return callSourceSignal.value; - }, - }); - actionWeakMap.set(actionProxy, actionProxy); - } - - // Create our own signal for params that we control completely - const proxyParamsSignal = signal(paramsSignal.value); - const proxySignal = (signalPropertyName, propertyName) => { - const signalProxy = signal(); - let dispose; - onActionTargetChange(() => { - if (dispose) { - dispose(); - dispose = undefined; - } - dispose = effect(() => { - const currentActionSignal = currentAction[signalPropertyName]; - const currentActionSignalValue = currentActionSignal.value; - signalProxy.value = currentActionSignalValue; - if (propertyName) { - actionProxy[propertyName] = currentActionSignalValue; - } - }); - return dispose; - }); - return signalProxy; - }; - - Object.assign(actionProxy, { - isAction: true, - isProxy: true, - callback: undefined, - params: undefined, - isPrerun: undefined, - runningState: undefined, - aborted: undefined, - error: undefined, - value: undefined, - data: undefined, - completed: undefined, - prerun: proxyMethod("prerun", { explicitRunIntent: true }), - run: proxyMethod("run", { explicitRunIntent: true }), - rerun: proxyMethod("rerun", { explicitRunIntent: true }), - reset: proxyMethod("reset", { explicitRunIntent: true }), - abort: proxyMethod("abort", { explicitRunIntent: true }), - matchAllSelfOrDescendant: proxyMethod("matchAllSelfOrDescendant"), - getCurrentAction: () => { - _updateTarget({ - changeCause: "get_current_action", - }); - return currentAction; - }, - bindParams: () => { - throw new Error( - `bindParams() is not supported on action proxies, use the underlying action instead`, - ); - }, - replaceParams: null, // Will be set below - toString: () => actionProxy.callSource, - meta: {}, - - paramsSignal: proxyParamsSignal, - isPrerunSignal: proxySignal("isPrerunSignal", "isPrerun"), - runningStateSignal: proxySignal("runningStateSignal", "runningState"), - errorSignal: proxySignal("errorSignal", "error"), - valueSignal: proxySignal("valueSignal", "value"), - dataSignal: proxySignal("dataSignal", "data"), - }); - Object.preventExtensions(actionProxy); - // Watch for changes in the original paramsSignal and update ours - // (original signal wins over any replaceParams calls) - weakEffect( - [paramsSignal, proxyParamsSignal], - (paramsSignalRef, proxyParamsSignalRef) => { - const newParams = paramsSignalRef.value; - proxyParamsSignalRef.value = newParams; - }, - ); - weakEffect([action], () => { - // eslint-disable-next-line no-unused-expressions - proxyParamsSignal.value; - _updateTarget({ - changeCause: "params_signal_change", - }); - }); - onActionTargetChange((actionTarget) => { - const currentAction = actionTarget || action; - nameSignal.value = `[Proxy] ${currentAction.name}`; - callSourceSignal.value = `[Proxy] ${currentAction.callSource}`; - actionProxy.callback = currentAction.callback; - actionProxy.params = currentAction.params; - actionProxy.isPrerun = currentAction.isPrerun; - actionProxy.runningState = currentAction.runningState; - actionProxy.aborted = currentAction.aborted; - actionProxy.error = currentAction.error; - actionProxy.value = currentAction.value; - actionProxy.data = currentAction.data; - actionProxy.completed = currentAction.completed; - }); - - { - const proxyPrivateMethod = (method) => { - return (...args) => currentActionPrivateProperties[method](...args); - }; - const proxyPrivateProperties = { - get currentAction() { - return currentAction; - }, - - performRun: proxyPrivateMethod("performRun"), - performReset: proxyPrivateMethod("performReset"), - ui: currentActionPrivateProperties.ui, - }; - onActionTargetChange((actionTarget, previousTarget) => { - proxyPrivateProperties.ui = currentActionPrivateProperties.ui; - if (previousTarget && actionTarget) { - const previousPrivateProps = getActionPrivateProperties(previousTarget); - if (previousPrivateProps.ui.hasRenderers) { - const newPrivateProps = getActionPrivateProperties(actionTarget); - newPrivateProps.ui.hasRenderers = true; - } - } - proxyPrivateProperties.childActionWeakSet = - currentActionPrivateProperties.childActionWeakSet; - }); - setActionPrivateProperties(actionProxy, proxyPrivateProperties); - } - - actionProxy.replaceParams = (newParams) => { - if (currentAction === action) { - const currentParams = proxyParamsSignal.value; - const nextParams = mergeActionParams(currentParams, newParams); - if (nextParams === currentParams) { - return false; - } - proxyParamsSignal.value = nextParams; - return true; - } - if (!currentAction.replaceParams(newParams)) { - return false; - } - proxyParamsSignal.value = currentAction.paramsSignal.peek(); - return true; - }; - - if (runOnce) { - onActionTargetChange((actionTarget, actionTargetPrevious) => { - if (!actionTargetPrevious && actionTarget) { - action.debug( - `Action proxy "${actionProxy}": target changed, running action once (reason: runOnce)`, - ); - actionTarget.run({ reason: "runOnce" }); - } - }); - } - if (rerunOnChange) { - onActionTargetChange( - (actionTarget, actionTargetPrevious, { explicitRunIntent }) => { - if (explicitRunIntent) { - return; - } - if ( - actionTarget && - actionTargetPrevious && - !actionTargetPrevious.isPrerun - ) { - action.debug( - `Action proxy "${actionProxy}": target changed, rerunning action (reason: rerunOnChange)`, - { - newTarget: actionTarget, - previousTarget: actionTargetPrevious, - }, - ); - actionTarget.rerun({ reason: "rerunOnChange (params modified)" }); - } - }, - ); - } - if (onChange) { - onActionTargetChange( - (actionTarget, actionTargetPrevious, { explicitRunIntent }) => { - onChange(actionTarget, actionTargetPrevious, { explicitRunIntent }); - }, - ); - } - - return actionProxy; -}; - -const generateActionCallSource = (name, params) => { - if (params === NO_PARAMS) { - return `${name}()`; - } - // Use stringifyForDisplay with asFunctionArgs option for the entire args array - const argsString = stringifyForDisplay([params], 3, 0, { - asFunctionArgs: true, - }); - return `${name}${argsString}`; -}; - -const isPlainObject = (obj) => { - if (typeof obj !== "object" || obj === null) { - return false; - } - let proto = obj; - while (Object.getPrototypeOf(proto) !== null) { - proto = Object.getPrototypeOf(proto); - } - return ( - Object.getPrototypeOf(obj) === proto || Object.getPrototypeOf(obj) === null - ); -}; - -const COMPLETED_ACTION = createAction(() => undefined, { - name: "ACTION.COMPLETED", -}); -getActionPrivateProperties(COMPLETED_ACTION).performRun({}); - -/** - * Reactively runs an action whenever the params derived from signals change. - * - * @param {object} action - The action to run. - * @param {Function} deriveActionParamsFromSignals - A function that reads signals and returns - * the params to pass to the action. It is re-evaluated automatically whenever a signal it - * read changes. Return `false`/`null`/`undefined` to skip running the action. - * @param {object} [options] - * @param {number} [options.debounce] - When set, the action is only run once the derived params - * have been stable for this many milliseconds. Useful to avoid firing a backend call on every - * keystroke: set `debounce: 500` and the request is sent only after the user stops interacting - * with the filters for 500 ms. - * - * Example — auto-refresh a result list while the user tweaks filters: - * ```js - * actionRunEffect(searchAction, () => ({ - * query: querySignal.value, - * page: pageSignal.value, - * }), { debounce: 500 }); - * ``` - * The action will not fire while the user is actively changing filters; it fires once - * they pause for half a second. - */ -const actionRunEffect = ( - action, - deriveActionParamsFromSignals, - { debounce, ...options } = {}, -) => { - if (typeof action === "function") { - action = createAction(action); - } - let actionParamsSignal = computed(() => { - const params = deriveActionParamsFromSignals(); - action.debug( - `Derived params for action "${action}": ${stringifyForDisplay(params)}`, - ); - if (!params) { - // normalize falsy values to undefined so that any falsy value ends up in the same state of "don't run the action" - return undefined; - } - if (params && typeof params.then === "function") { - { - console.warn( - `actionRunEffect second arg is returning a promise. This is not supported, the function should be sync and return params to give to the action`, - ); - } - } - return params; - }); - if (debounce) { - actionParamsSignal = debounceSignal(actionParamsSignal, { - delay: debounce, - }); - } - - const actionRunnedByThisEffect = action.bindParams(actionParamsSignal, { - syncParams: debounce ? actionParamsSignal.flush : undefined, - onChange: (actionTarget, actionTargetPrevious, { explicitRunIntent }) => { - if (explicitRunIntent) { - // The caller already issued an explicit run/rerun/prerun/reset/abort — - // don't attempt to also auto-run from the params change to avoid double-runs. - action.debug( - `"${actionTarget}": explicit run intent detected -> skipping auto-run from params change`, - ); - return; - } - if (!actionTargetPrevious && actionTarget) { - // first run - if (!actionTarget.params) { - // falsy params, don't run - return; - } - actionTarget.run({ reason: "truthy params first run" }); - return; - } - - if ( - actionTargetPrevious && - !actionTargetPrevious.isPrerun && - actionTarget - ) { - // params changed - if (!actionTarget.params) { - // falsy params, don't run - actionTargetPrevious.abort("abortOnFalsyParams"); - return; - } - if (!actionTargetPrevious.params) { - // coming from falsy-params state: action may already be cached, avoid unnecessary rerun - actionTarget.run({ reason: "params restored from falsy state" }); - } else { - actionTarget.rerun({ reason: "params modified" }); - } - } - }, - ...options, - }); - if (actionParamsSignal.peek()) { - actionRunnedByThisEffect.run({ reason: "initial truthy params" }); - } - return actionRunnedByThisEffect; -}; - -const useRunOnMount = (action, Component) => { - useEffect(() => { - action.run({ - reason: `<${Component.name} /> mounted`, - }); - }, []); -}; - -const DebugCommandContext = createContext(false); -const DebugInteractionContext = createContext(false); -const DebugFocusContext = createContext(false); -const DebugScrollContext = createContext(false); -const DebugPopupContext = createContext(false); -const DebugActionContext = createContext(false); -const DebugUIStateContext = createContext(false); -const debugNoop = () => {}; -const eventGroupLogger = createEventGroupLogger(); -const debugCommandDefault = eventGroupLogger.createCategory("[command]", "#8e44ad"); -const debugInteractionDefault = eventGroupLogger.createCategory("[interaction]", "#2980b9"); -const debugActionDefault = eventGroupLogger.createCategory("[action]", "#e67e22"); -const debugPopupDefault = eventGroupLogger.createCategory("[popup]", "#27ae60"); -const debugUIStateDefault = eventGroupLogger.createCategory("[uistate]", "#7f8c8d"); -const debugFocusDefault = eventGroupLogger.createCategory("[focus]", "#2980b9"); -const debugScrollDefault = eventGroupLogger.createCategory("[scroll]", "#2980b9"); - -// The hooks below expose one concern's logger to components inside . -// Each returns the logger function enabled for that concern, or a no-op when the -// concern is off — so call sites can `const debug = useDebugX()` unconditionally. -// The logger is called as `debug(message, …)` or, to group a side effect under -// the native event that caused it, `debug(event, message, …)`. - -/** Logger for navi command dispatch (`--navi-*`), or a no-op when disabled. */ -const useDebugCommand = () => { - const debug = useContext(DebugCommandContext); - return debug || debugNoop; -}; -/** Logger for gated interactions (click/scroll/select/…), or a no-op. */ -const useDebugInteraction = () => { - const debug = useContext(DebugInteractionContext); - return debug || debugNoop; -}; -/** Logger for focus moves and focus-visible decisions, or a no-op. */ -const useDebugFocus = () => { - const debug = useContext(DebugFocusContext); - return debug || debugNoop; -}; -/** Logger for virtual scroll / wheel motion (drag, momentum, glide), or a no-op. */ -const useDebugScroll = () => { - const debug = useContext(DebugScrollContext); - return debug || debugNoop; -}; -/** Logger for popover/dialog open/close/positioning, or a no-op. */ -const useDebugPopup = () => { - const debug = useContext(DebugPopupContext); - return debug || debugNoop; -}; -/** Logger for the action lifecycle (request → run → end), or a no-op. */ -const useDebugAction = () => { - const debug = useContext(DebugActionContext); - return debug || debugNoop; -}; -/** Logger for UI-state transitions, validation and synthetic events, or a no-op. */ -const useDebugUIState = () => { - const debug = useContext(DebugUIStateContext); - return debug || debugNoop; -}; - -/** - * Turns on navi's color-coded console logging for everything rendered inside it. - * Navi has many moving parts (interactions, focus, scroll, popups, commands, - * actions, ui-state); each concern logs to its own console group so you can - * watch what navi is doing and why. Components read a concern via its hook - * (`useDebugScroll`, `useDebugInteraction`, …). - * - * Every prop accepts one of: - * - `true` — log with the built-in color-coded logger (grouped by initiator event) - * - a function — log with your own callback instead - * - `false` / omitted — disabled (the concern's hook returns a no-op) - * - * `debugAll` is the default for every other prop, so `` - * turns everything on. Passing `debugInteraction` also enables `debugFocus`, - * `debugScroll` and `debugPopup` unless those are set explicitly, since they - * describe the same interaction. - * - * @param {object} props - * @param {boolean|Function} [props.debugAll] - Default for every concern below. - * @param {boolean|Function} [props.debugCommand] - navi command dispatch (`--navi-*`). - * @param {boolean|Function} [props.debugInteraction] - Gated interactions; also implies focus/scroll/popup. - * @param {boolean|Function} [props.debugFocus] - Focus moves and focus-visible decisions. - * @param {boolean|Function} [props.debugScroll] - Virtual scroll / wheel motion. - * @param {boolean|Function} [props.debugPopup] - Popover/dialog open/close/positioning. - * @param {boolean|Function} [props.debugAction] - Action lifecycle. - * @param {boolean|Function} [props.debugUIState] - UI-state transitions and validation. - * @param {import("ignore:preact").ComponentChildren} props.children - * - * @example - * // Log everything under this subtree - * - * … - * - * - * @example - * // Only wheel/scroll motion, via a custom sink - * myLogger.log(...args)}> - * … - * - */ -const NaviDebug = ({ - debugAll, - debugCommand = debugAll, - debugInteraction = debugAll, - debugFocus = debugAll, - debugScroll = debugAll, - debugPopup = debugAll, - debugAction = debugAll, - debugUIState = debugAll, - children -}) => { - if (debugCommand === true) { - debugCommand = debugCommandDefault; - } - if (debugInteraction === true) { - debugInteraction = debugInteractionDefault; - } - if (debugFocus === true || debugInteraction && debugFocus === undefined) { - debugFocus = debugFocusDefault; - } - if (debugScroll === true || debugInteraction && debugScroll === undefined) { - debugScroll = debugScrollDefault; - } - if (debugPopup === true || debugInteraction && debugPopup === undefined) { - debugPopup = debugPopupDefault; - } - if (debugAction === true) { - debugAction = debugActionDefault; - } - if (debugUIState === true) { - debugUIState = debugUIStateDefault; - } - return jsx(DebugCommandContext.Provider, { - value: debugCommand, - children: jsx(DebugInteractionContext.Provider, { - value: debugInteraction, - children: jsx(DebugFocusContext.Provider, { - value: debugFocus, - children: jsx(DebugScrollContext.Provider, { - value: debugScroll, - children: jsx(DebugPopupContext.Provider, { - value: debugPopup, - children: jsx(DebugActionContext.Provider, { - value: debugAction, - children: jsx(DebugUIStateContext.Provider, { - value: debugUIState, - children: children - }) - }) - }) - }) - }) - }) - }); -}; - -const addIntoArray = (array, ...valuesToAdd) => { - if (valuesToAdd.length === 1) { - const [valueToAdd] = valuesToAdd; - const arrayWithThisValue = []; - for (const value of array) { - if (value === valueToAdd) { - return array; - } - arrayWithThisValue.push(value); - } - arrayWithThisValue.push(valueToAdd); - return arrayWithThisValue; - } - - const existingValueSet = new Set(); - const arrayWithTheseValues = []; - for (const existingValue of array) { - arrayWithTheseValues.push(existingValue); - existingValueSet.add(existingValue); - } - let hasNewValues = false; - for (const valueToAdd of valuesToAdd) { - if (existingValueSet.has(valueToAdd)) { - continue; - } - arrayWithTheseValues.push(valueToAdd); - hasNewValues = true; - } - return hasNewValues ? arrayWithTheseValues : array; -}; - -const removeFromArray = (array, ...valuesToRemove) => { - if (valuesToRemove.length === 1) { - const [valueToRemove] = valuesToRemove; - const arrayWithoutThisValue = []; - let found = false; - for (const value of array) { - if (value === valueToRemove) { - found = true; - continue; - } - arrayWithoutThisValue.push(value); - } - if (!found) { - return array; - } - return arrayWithoutThisValue; - } - - const valuesToRemoveSet = new Set(valuesToRemove); - const arrayWithoutTheseValues = []; - let hasRemovedValues = false; - for (const value of array) { - if (valuesToRemoveSet.has(value)) { - hasRemovedValues = true; - continue; - } - arrayWithoutTheseValues.push(value); - } - return hasRemovedValues ? arrayWithoutTheseValues : array; -}; - -const useArraySignalMembership = (...args) => { - if (args.length < 2) { - throw new Error( - "useArraySignalMembership requires at least 2 arguments: [arraySignal, id]", - ); - } - - return useMemo(() => { - const [useIsMember, add, remove] = arraySignalMembership(...args); - const isMember = useIsMember(); - return [isMember, add, remove]; - }, args); -}; - -const arraySignalMembership = (...args) => { - if (args.length < 2) { - throw new Error( - "arraySignalMemberShip requires at least 2 arguments: [arraySignal, id]", - ); - } - const [arraySignal, id] = args; - - const useIsMember = () => { - const array = arraySignal.value; // use value to subscribe to signal changes - const idFoundInArray = array.includes(id); - return idFoundInArray; - }; - - const add = () => { - const arrayWithId = addIntoArray(arraySignal.peek(), id); - arraySignal.value = arrayWithId; - return arrayWithId; - }; - - const remove = () => { - const arrayWithoutId = removeFromArray(arraySignal.peek(), id); - arraySignal.value = arrayWithoutId; - return arrayWithoutId; - }; - - return [useIsMember, add, remove]; -}; - -const localStorageSignal = (key) => { - const initialValue = localStorage.getItem(key); - - const valueSignal = signal(initialValue === null ? undefined : initialValue); - effect(() => { - const value = valueSignal.value; - if (value === undefined) { - localStorage.removeItem(key); - } else { - localStorage.setItem(key, value); - } - }); - - return valueSignal; -}; - -const getCallerInfo = (targetFunction = null, additionalOffset = 0) => { - const originalPrepareStackTrace = Error.prepareStackTrace; - try { - Error.prepareStackTrace = (_, stack) => stack; - - const error = new Error(); - const stack = error.stack; - - if (!stack || stack.length === 0 || !Array.isArray(stack)) { - return { raw: "unknown" }; - } - - let targetIndex = -1; - - if (targetFunction) { - // ✅ Chercher la fonction cible par référence directe - for (let i = 0; i < stack.length; i++) { - const frame = stack[i]; - const frameFunction = frame.getFunction(); - - // ✅ Comparaison directe par référence - if (frameFunction === targetFunction) { - targetIndex = i; - break; - } - } - - if (targetIndex === -1) { - return { - raw: `target function not found in stack`, - targetFunction: targetFunction.name, - }; - } - - // ✅ Prendre la fonction qui appelle targetFunction + offset - const callerIndex = targetIndex + 1 + additionalOffset; - - if (callerIndex >= stack.length) { - return { - raw: `caller at offset ${additionalOffset} not found`, - targetFunction: targetFunction.name, - requestedIndex: callerIndex, - stackLength: stack.length, - }; - } - - const callerFrame = stack[callerIndex]; - return { - file: callerFrame.getFileName(), - line: callerFrame.getLineNumber(), - column: callerFrame.getColumnNumber(), - function: callerFrame.getFunctionName() || "", - raw: callerFrame.toString(), - targetFunction: targetFunction.name, - offset: additionalOffset, - }; - } - - // ✅ Comportement original si pas de targetFunction - if (stack.length > 2) { - const callerFrame = stack[2 + additionalOffset]; - - if (!callerFrame) { - return { - raw: `caller at offset ${additionalOffset} not found`, - requestedIndex: 2 + additionalOffset, - stackLength: stack.length, - }; - } - - return { - file: callerFrame.getFileName(), - line: callerFrame.getLineNumber(), - column: callerFrame.getColumnNumber(), - function: callerFrame.getFunctionName() || "", - raw: callerFrame.toString(), - offset: additionalOffset, - }; - } - - return { raw: "unknown" }; - } finally { - Error.prepareStackTrace = originalPrepareStackTrace; - } -}; - -const primitiveCanBeId = (value) => { - const type = typeof value; - if (type === "string" || type === "number" || type === "symbol") { - return true; - } - return false; -}; - -const arraySignalStore = ( - initialArray = [], - idKey = "id", - { - uniqueKeys = [], - name, - createItem = (props) => { - return { ...props }; - }, - }, -) => { - const store = { - name, - }; - - const createItemFromProps = (props) => { - if (props === null || typeof props !== "object") { - return props; - } - const item = createItem(props); - return item; - }; - - const arraySignal = signal(initialArray); - const derivedSignal = computed(() => { - const array = arraySignal.value; - const idSet = new Set(); // will be used to detect id changes (deletion, addition) - const idMap = new Map(); // used to speep up finding item by id - for (const item of array) { - const id = item[idKey]; - idSet.add(id); - idMap.set(id, item); - } - return [idSet, idMap]; - }); - const idSetSignal = computed(() => derivedSignal.value[0]); - const idMapSignal = computed(() => derivedSignal.value[1]); - const previousIdSetSignal = signal(new Set(idSetSignal.peek())); - const idChangeCallbackSet = new Set(); - effect(() => { - const idSet = idSetSignal.value; - const previousIdSet = previousIdSetSignal.peek(); - const setCopy = new Set(); - let modified = false; - for (const id of idSet) { - if (!previousIdSet.has(id)) { - modified = true; - } - setCopy.add(id); - } - if (modified) { - previousIdSetSignal.value = setCopy; - for (const idChangeCallback of idChangeCallbackSet) { - idChangeCallback(idSet, previousIdSet); - } - } - }); - - const itemPropertiesObserverSet = new Set(); - const observeItemProperties = (itemSignal, callback, { properties } = {}) => { - const propertiesSet = properties ? new Set(properties) : null; - const observer = { itemSignal, callback, propertiesSet }; - itemPropertiesObserverSet.add(observer); - return () => { - itemPropertiesObserverSet.delete(observer); - }; - }; - - const propertiesObserverSet = new Set(); - const observeProperties = (callback, { properties } = {}) => { - const propertiesSet = properties ? new Set(properties) : null; - const observer = { callback, propertiesSet }; - propertiesObserverSet.add(observer); - return () => { - propertiesObserverSet.delete(observer); - }; - }; - - const removalsCallbackSet = new Set(); - const observeRemovals = (callback) => { - removalsCallbackSet.add(callback); - }; - - const itemMatchLifecycleSet = new Set(); - const registerItemMatchLifecycle = (matchPredicate, { match, nomatch }) => { - const matchState = { - hasMatched: false, - hadMatchedBefore: false, - }; - const itemMatchLifecycle = { - matchPredicate, - match, - nomatch, - matchState, - }; - itemMatchLifecycleSet.add(itemMatchLifecycle); - }; - - const readIdFromItemProps = (props, array) => { - let id; - if (Object.hasOwn(props, idKey)) { - id = props[idKey]; - return id; - } - if (uniqueKeys.length === 0) { - return undefined; - } - - let uniqueKey; - for (const uniqueKeyCandidate of uniqueKeys) { - if (Object.hasOwn(props, uniqueKeyCandidate)) { - uniqueKey = uniqueKeyCandidate; - break; - } - } - if (!uniqueKey) { - throw new Error( - `item properties must have one of the following keys: -${[idKey, ...uniqueKeys].join(", ")}`, - ); - } - const uniqueKeyValue = props[uniqueKey]; - for (const itemCandidate of array) { - const uniqueKeyCandidate = itemCandidate[uniqueKey]; - if (uniqueKeyCandidate === uniqueKeyValue) { - id = itemCandidate[idKey]; - break; - } - } - if (!id) { - throw new Error( - `None of the existing item uses ${uniqueKey}: ${uniqueKeyValue}, so item properties must specify the "${idKey}" key.`, - ); - } - return id; - }; - - effect(() => { - const array = arraySignal.value; - - for (const { - matchPredicate, - match, - nomatch, - matchState, - } of itemMatchLifecycleSet) { - let currentlyHasMatch = false; - - // Check if any item currently matches - for (const item of array) { - if (matchPredicate(item)) { - currentlyHasMatch = true; - break; - } - } - - // Handle state transitions - if (currentlyHasMatch && !matchState.hasMatched) { - // New match found - matchState.hasMatched = true; - const isRematch = matchState.hadMatchedBefore; - if (match) { - match(isRematch); - } - } else if (!currentlyHasMatch && matchState.hasMatched) { - // No longer has match - matchState.hasMatched = false; - matchState.hadMatchedBefore = true; - if (nomatch) { - nomatch(); - } - } - } - }); - - const select = (...args) => { - const array = arraySignal.value; - const idMap = idMapSignal.value; - - let property; - let value; - if (args.length === 1) { - property = idKey; - value = args[0]; - if (value !== null && typeof value === "object") { - value = readIdFromItemProps(value, array); - } - } else if (args.length === 2) { - property = args[0]; - value = args[1]; - } - if (property === idKey) { - return idMap.get(value); - } - for (const itemCandidate of array) { - const valueCandidate = - typeof property === "function" - ? property(itemCandidate) - : itemCandidate[property]; - if (valueCandidate === value) { - return itemCandidate; - } - } - return null; - }; - const selectAll = (toMatchArray) => { - const array = arraySignal.value; - const result = []; - const idMap = idMapSignal.value; - for (const toMatch of toMatchArray) { - const id = - toMatch !== null && typeof toMatch === "object" - ? readIdFromItemProps(toMatch, array) - : toMatch; - const item = idMap.get(id); - if (item) { - result.push(item); - } - } - return result; - }; - const upsert = (...args) => { - const mutationsMap = new Map(); // Map - const triggerPropertyMutations = () => { - for (const itemPropertiesObserver of itemPropertiesObserverSet) { - const { itemSignal, callback, propertiesSet } = itemPropertiesObserver; - const watchedItem = itemSignal.peek(); - if (!watchedItem) { - continue; - } - const itemMutations = mutationsMap.get(watchedItem[idKey]); - if (itemMutations) { - if (propertiesSet) { - let hasRelevantMutation = false; - for (const p of propertiesSet) { - if (Object.hasOwn(itemMutations, p)) { - hasRelevantMutation = true; - break; - } - } - if (hasRelevantMutation) { - callback(itemMutations); - } - } else { - callback(itemMutations); - } - } - } - if (propertiesObserverSet.size) { - const allMutations = Array.from(mutationsMap.values()); - for (const propertiesObserver of propertiesObserverSet) { - const { callback, propertiesSet } = propertiesObserver; - if (propertiesSet) { - const filteredMutations = []; - for (const propertyMutations of allMutations) { - for (const p of propertiesSet) { - if (Object.hasOwn(propertyMutations, p)) { - filteredMutations.push(propertyMutations); - break; - } - } - } - if (filteredMutations.length > 0) { - callback(filteredMutations); - } - } else { - callback(allMutations); - } - } - } - }; - const assign = (item, props) => { - const itemOwnPropertyDescriptors = Object.getOwnPropertyDescriptors(item); - const itemOwnKeys = Object.keys(itemOwnPropertyDescriptors); - const itemWithProps = Object.create( - Object.getPrototypeOf(item), - itemOwnPropertyDescriptors, - ); - let hasChanges = false; - const propertyMutations = {}; - - for (const key of Object.keys(props)) { - const newValue = props[key]; - if (itemOwnKeys.includes(key)) { - const oldValue = item[key]; - if (newValue !== oldValue) { - hasChanges = true; - itemWithProps[key] = newValue; - propertyMutations[key] = { - oldValue, - newValue, - target: item, - newTarget: itemWithProps, - }; - } - } else { - hasChanges = true; - itemWithProps[key] = newValue; - propertyMutations[key] = { - added: true, - newValue, - target: item, - newTarget: itemWithProps, - }; - } - } - - if (!hasChanges) { - return item; - } - - // Store mutations keyed by old id - mutationsMap.set(item[idKey], propertyMutations); - return itemWithProps; - }; - - const array = arraySignal.peek(); - if (args.length === 1 && Array.isArray(args[0])) { - const propsArray = args[0]; - if (array.length === 0) { - const arrayAllCreated = []; - for (const props of propsArray) { - const item = createItemFromProps(props); - arrayAllCreated.push(item); - } - arraySignal.value = arrayAllCreated; - return arrayAllCreated; - } - let hasNew = false; - let hasUpdate = false; - const arraySomeUpdated = []; - const arrayWithOnlyAffectedItems = []; - const existingEntryMap = new Map(); - let index = 0; - while (index < array.length) { - const existingItem = array[index]; - const id = existingItem[idKey]; - existingEntryMap.set(id, { - existingItem, - existingItemIndex: index, - processed: false, - }); - index++; - } - - for (const props of propsArray) { - const id = readIdFromItemProps(props, array); - const existingEntry = existingEntryMap.get(id); - if (existingEntry) { - const { existingItem } = existingEntry; - const itemWithPropsOrItem = assign(existingItem, props); - if (itemWithPropsOrItem !== existingItem) { - hasUpdate = true; - } - arraySomeUpdated.push(itemWithPropsOrItem); - existingEntry.processed = true; - arrayWithOnlyAffectedItems.push(itemWithPropsOrItem); - } else { - hasNew = true; - const item = createItemFromProps(props); - arraySomeUpdated.push(item); - arrayWithOnlyAffectedItems.push(item); - } - } - - for (const [, existingEntry] of existingEntryMap) { - if (!existingEntry.processed) { - arraySomeUpdated.push(existingEntry.existingItem); - } - } - - if (hasNew || hasUpdate) { - arraySignal.value = arraySomeUpdated; - triggerPropertyMutations(); - return arrayWithOnlyAffectedItems; - } - return arrayWithOnlyAffectedItems; - } - let existingItem = null; - let updatedItem = null; - const arraySomeUpdated = []; - let propertyToMatch; - let valueToMatch; - let props; - if (args.length === 1) { - const firstArg = args[0]; - propertyToMatch = idKey; - if (!firstArg || typeof firstArg !== "object") { - throw new TypeError( - `Expected an object as first argument, got ${firstArg}`, - ); - } - valueToMatch = readIdFromItemProps(firstArg, array); - props = firstArg; - } else if (args.length === 2) { - propertyToMatch = idKey; - valueToMatch = args[0]; - if (typeof valueToMatch === "object") { - valueToMatch = valueToMatch[idKey]; - } - props = args[1]; - } else if (args.length === 3) { - propertyToMatch = args[0]; - valueToMatch = args[1]; - props = args[2]; - } - for (const itemCandidate of array) { - const itemCandidateValue = - typeof propertyToMatch === "function" - ? propertyToMatch(itemCandidate) - : itemCandidate[propertyToMatch]; - if (itemCandidateValue === valueToMatch) { - const itemWithPropsOrItem = assign(itemCandidate, props); - if (itemWithPropsOrItem === itemCandidate) { - existingItem = itemCandidate; - } else { - updatedItem = itemWithPropsOrItem; - } - arraySomeUpdated.push(itemWithPropsOrItem); - } else { - arraySomeUpdated.push(itemCandidate); - } - } - if (existingItem) { - return existingItem; - } - if (updatedItem) { - arraySignal.value = arraySomeUpdated; - triggerPropertyMutations(); - return updatedItem; - } - const item = createItemFromProps(props); - arraySomeUpdated.push(item); - arraySignal.value = arraySomeUpdated; - triggerPropertyMutations(); - return item; - }; - const drop = (...args) => { - const removedItemArray = []; - const triggerRemovedMutations = () => { - if (removedItemArray.length === 0) { - return; - } - // we call at the end so that itemWithProps and arraySignal.value was set too - for (const removalsCallback of removalsCallbackSet) { - removalsCallback(removedItemArray); - } - }; - - const array = arraySignal.peek(); - if (args.length === 1 && Array.isArray(args[0])) { - const firstArg = args[0]; - const arrayWithoutDroppedItems = []; - let hasFound = false; - const idToRemoveSet = new Set(); - const idRemovedArray = []; - - for (const value of firstArg) { - if (typeof value === "object" && value !== null) { - const id = readIdFromItemProps(value, array); - idToRemoveSet.add(id); - } else if (!primitiveCanBeId(value)) { - throw new TypeError(`id to drop must be an id, got ${value}`); - } - idToRemoveSet.add(value); - } - for (const existingItem of array) { - const existingItemId = existingItem[idKey]; - if (idToRemoveSet.has(existingItemId)) { - hasFound = true; - idToRemoveSet.delete(existingItemId); - idRemovedArray.push(existingItemId); - } else { - arrayWithoutDroppedItems.push(existingItem); - } - } - if (idToRemoveSet.size > 0) { - console.warn( - `arraySignalStore.drop: Some ids were not found in the array: ${Array.from(idToRemoveSet).join(", ")}`, - ); - } - if (hasFound) { - arraySignal.value = arrayWithoutDroppedItems; - triggerRemovedMutations(); - return idRemovedArray; - } - return []; - } - let propertyToMatch; - let valueToMatch; - if (args.length === 1) { - propertyToMatch = idKey; - valueToMatch = args[0]; - if (valueToMatch !== null && typeof valueToMatch === "object") { - valueToMatch = readIdFromItemProps(valueToMatch, array); - } else if (!primitiveCanBeId(valueToMatch)) { - throw new TypeError(`id to drop must be an id, got ${valueToMatch}`); - } - } else { - propertyToMatch = args[0]; - valueToMatch = args[1]; - } - const arrayWithoutItemToDrop = []; - let found = false; - let itemDropped = null; - for (const itemCandidate of array) { - const itemCandidateValue = - typeof propertyToMatch === "function" - ? propertyToMatch(itemCandidate) - : itemCandidate[propertyToMatch]; - if (itemCandidateValue === valueToMatch) { - itemDropped = itemCandidate; - found = true; - } else { - arrayWithoutItemToDrop.push(itemCandidate); - } - } - if (found) { - arraySignal.value = arrayWithoutItemToDrop; - removedItemArray.push(itemDropped); - triggerRemovedMutations(); - return itemDropped[idKey]; - } - return null; - }; - - const signalForKey = (key, keyValueSignal) => { - if (key === idKey) { - return _signalForIdKey(keyValueSignal); - } - if (uniqueKeys.includes(key)) { - return _signalForUniqueKey(key, keyValueSignal); - } - throw new Error( - `signalForKey: "${key}" is not the idKey or a uniqueKey of this store (idKey: ${idKey}, uniqueKeys: ${uniqueKeys.join(", ")})`, - ); - }; - const _signalForUniqueKey = (uniqueKey, uniqueKeyValueSignal) => { - const itemIdSignal = signal(null); - const check = (value) => { - const item = select(uniqueKey, value); - if (!item) { - return false; - } - itemIdSignal.value = item[idKey]; - return true; - }; - if (!check(uniqueKeyValueSignal.peek())) { - effect(function () { - const uniqueKeyValue = uniqueKeyValueSignal.value; - if (check(uniqueKeyValue)) { - this.dispose(); - } - }); - } - return computed(() => { - return select(itemIdSignal.value); - }); - }; - - const _signalForIdKey = (idValueSignal) => { - const itemIdSignal = signal(null); - const check = (value) => { - const item = select(idKey, value); - if (!item) { - return false; - } - itemIdSignal.value = item[idKey]; - return true; - }; - if (!check(idValueSignal.peek())) { - effect(function () { - const idValue = idValueSignal.value; - if (check(idValue)) { - this.dispose(); - } - }); - } - // When the id itself is renamed, keep itemIdSignal in sync. - observeProperties( - (mutationsArray) => { - const currentId = itemIdSignal.peek(); - if (currentId === null) return; - for (const mutations of mutationsArray) { - const mutation = mutations[idKey]; - if (mutation.oldValue === currentId) { - itemIdSignal.value = mutation.newValue; - break; - } - } - }, - { properties: [idKey] }, - ); - return computed(() => { - return select(itemIdSignal.value); - }); - }; - - const observeIdChanges = (callback) => { - idChangeCallbackSet.add(callback); - return () => { - idChangeCallbackSet.delete(callback); - }; - }; - - Object.assign(store, { - idKey, - uniqueKeys, - arraySignal, - select, - selectAll, - upsert, - drop, - - observeItemProperties, - observeProperties, - observeRemovals, - observeIdChanges, - registerItemMatchLifecycle, - signalForKey, - }); - return store; -}; - -const syncStoreToSignals = (store, propertyToSignalMap) => { - const { idKey } = store; - const cleanupCallbackSet = new Set(); - for (const [propertyName, targetSignal] of Object.entries( - propertyToSignalMap, - )) { - if (propertyName === idKey) { - const unsubscribe = store.observeProperties( - (mutationsArray) => { - for (const mutations of mutationsArray) { - const mutation = mutations[idKey]; - if (mutation.oldValue === targetSignal.peek()) { - targetSignal.value = mutation.newValue; - break; - } - } - }, - { properties: [idKey] }, - ); - cleanupCallbackSet.add(unsubscribe); - continue; - } - const itemSignal = store.signalForKey(propertyName, targetSignal); - const unsubscribe = store.observeItemProperties( - itemSignal, - (propertyMutations) => { - const mutation = propertyMutations[propertyName]; - targetSignal.value = mutation.newValue; - }, - { properties: [propertyName] }, - ); - cleanupCallbackSet.add(unsubscribe); - } - return () => { - for (const cleanup of cleanupCallbackSet) { - cleanup(); - } - cleanupCallbackSet.clear(); - }; -}; - -// WeakMap> — tracks which top-level properties were present in -// the GET response for a given action instance. Used by scoped_many_effect to check -// whether the parent GET embedded the child sub-resource. -const actionResultPropertiesMap = new WeakMap(); -const recordGetResultProperties = (action, resultKeys) => { - actionResultPropertiesMap.set(action, new Set(resultKeys)); -}; -const getActionResultProperties = (action) => { - return actionResultPropertiesMap.get(action); -}; - -/* - * Default autorerun behavior explanation: - * GET: false (RECOMMENDED) - * What happens: - * - GET actions are reset by DELETE operations (not rerun) - * - DELETE operation on the displayed item would display nothing in the UI (action is in IDLE state) - * - PUT/PATCH operations update UI via signals, no rerun needed - * - This approach minimizes unnecessary API calls - * - * How to handle: - * - Applications can provide custom UI for deleted items (e.g., "Item not found") - * - Or redirect users to appropriate pages (e.g., back to list view) - * - * Alternative (NOT RECOMMENDED): - * - Use GET: ["DELETE"] to rerun and display 404 error received from backend - * - Poor UX: users expect immediate feedback, not loading + error state - * - * GET_MANY: ["POST"] - * - POST: New items may or may not appear in lists (depends on filters, pagination, etc.) - * Backend determines visibility better than client-side logic - * - DELETE: Excluded by default because: - * • UI handles deletions via store signals (selectAll filters out deleted items) - * • DELETE operations rarely change list content beyond item removal - * • Avoids unnecessary API calls (can be overridden if needed) - */ -const defaultRerunOn = { - GET: false, - GET_MANY: [ - "POST", - // "DELETE" - ], -}; - -// This handles ALL resource lifecycle logic (rerun/reset) across all resources -const createResourceLifecycleManager = () => { - const registeredResources = new Map(); // Map - const resourceDependencies = new Map(); // Map> — user-configured - const scopedManyParents = new Map(); // Map> — auto from scopedMany - - const registerResource = (resourceScope, config) => { - const { - rerunOn = defaultRerunOn, - paramScope = null, - dependencies = [], - uniqueKeys = [], - } = config; - - registeredResources.set(resourceScope, { - rerunOn, - paramScope, - uniqueKeys, - restActionSet: new Set(), - }); - - // Register dependencies - if (dependencies.length > 0) { - for (const dependency of dependencies) { - if (!resourceDependencies.has(dependency)) { - resourceDependencies.set(dependency, new Set()); - } - resourceDependencies.get(dependency).add(resourceScope); - } - } - }; - const registerAction = (resourceScope, restAction) => { - const config = registeredResources.get(resourceScope); - if (config) { - config.restActionSet.add(restAction); - } - }; - - // Determines which actions to rerun/reset when an action completes. - const findEffectOnActions = (triggeringAction, triggeringActionContext) => { - const actionsToRerun = new Set(); - const actionsToReset = new Set(); - const reasonSet = new Set(); - - const triggerVerb = triggeringAction.meta.verb; - const triggerIsMany = triggeringAction.meta.isMany; - const triggerResourceScope = triggeringActionContext.resourceScope; - - for (const [resourceScope, config] of registeredResources) { - const shouldRerunGetMany = shouldRerunAfter( - config.rerunOn.GET_MANY, - triggerVerb, - ); - const shouldRerunGet = shouldRerunAfter(config.rerunOn.GET, triggerVerb); - const paramScope = config.paramScope; - - // Skip if no rerun or reset rules apply - const hasUniqueKeyAutorerun = - (triggerVerb === "POST" || - triggerVerb === "PUT" || - triggerVerb === "PATCH") && - config.uniqueKeys.length > 0; - - const isKnownDependency = - triggerResourceScope !== null && - triggerResourceScope !== undefined && - resourceDependencies.get(triggerResourceScope)?.has(resourceScope); - - if ( - !shouldRerunGetMany && - !shouldRerunGet && - triggerVerb !== "DELETE" && - !hasUniqueKeyAutorerun && - !isKnownDependency - ) { - continue; - } - - // Parameter scope predicate for config-driven rules - // Same scope ID or no scope = compatible, subset check for different scopes - const paramScopePredicate = (candidateAction) => { - const candidateParamScope = candidateAction.meta.paramScope; - if (candidateParamScope.id === paramScope.id) { - return true; - } - return isParamSubset(candidateParamScope.params, paramScope.params); - }; - - for (const restAction of config.restActionSet) { - // Find all instances of this action - const actionCandidateArray = restAction.matchAllSelfOrDescendant( - (action) => - !action.isPrerun && action.completed && action !== triggeringAction, - ); - - for (const actionCandidate of actionCandidateArray) { - const candidateVerb = actionCandidate.meta.verb; - if (triggerVerb === candidateVerb) { - continue; - } - const candidateIsPlural = actionCandidate.meta.isMany; - const isSameResource = triggerResourceScope === resourceScope; - - // Config-driven same-resource effects (respects param scope) - config_effect: { - if ( - !isSameResource || - triggerVerb === "GET" || - candidateVerb !== "GET" - ) { - break config_effect; - } - const shouldRerun = candidateIsPlural - ? shouldRerunGetMany - : shouldRerunGet; - if (!shouldRerun) { - break config_effect; - } - if (!paramScopePredicate(actionCandidate)) { - break config_effect; - } - actionsToRerun.add(actionCandidate); - reasonSet.add("same-resource autorerun"); - continue; - } - - // DELETE effects on same resource (ignores param scope) - delete_effect: { - if (!isSameResource || triggerVerb !== "DELETE") { - break delete_effect; - } - if (candidateIsPlural) { - if (!shouldRerunGetMany) { - break delete_effect; - } - actionsToRerun.add(actionCandidate); - reasonSet.add("same-resource DELETE rerun GET_MANY"); - continue; - } - // Get the ID(s) that were deleted - const { valueSignal } = triggeringAction; - const deleteIdSet = triggerIsMany - ? new Set(valueSignal.peek()) - : new Set([valueSignal.peek()]); - - const candidateId = actionCandidate.value; - const isAffected = deleteIdSet.has(candidateId); - if (!isAffected) { - break delete_effect; - } - if (candidateVerb === "GET" && shouldRerunGet) { - actionsToRerun.add(actionCandidate); - reasonSet.add("same-resource DELETE rerun GET"); - continue; - } - actionsToReset.add(actionCandidate); - reasonSet.add("same-resource DELETE reset"); - continue; - } - - // Unique key effects: rerun GET when matching resource created/updated - { - if ( - hasUniqueKeyAutorerun && - candidateVerb === "GET" && - !candidateIsPlural && - isSameResource - ) { - const { valueSignal } = triggeringAction; - const modifiedValue = valueSignal.peek(); - - if (modifiedValue && typeof modifiedValue === "object") { - for (const uniqueKey of config.uniqueKeys) { - const modifiedUniqueId = modifiedValue[uniqueKey]; - const candidateParams = actionCandidate.params; - - if ( - modifiedUniqueId !== undefined && - candidateParams && - typeof candidateParams === "object" && - candidateParams[uniqueKey] === modifiedUniqueId - ) { - actionsToRerun.add(actionCandidate); - reasonSet.add( - `${triggeringAction.meta.verb}-uniqueKey autorerun`, - ); - break; - } - } - } - } - } - - // Cross-resource dependency effects: rerun dependent GET / GET_MANY - // Fires on any mutating verb — user-configured dependencies express - // "this resource depends on another resource's data", so any mutation - // (POST, PUT, PATCH, DELETE) on the dependency should trigger a rerun. - { - if ( - triggerResourceScope && - resourceDependencies - .get(triggerResourceScope) - ?.has(resourceScope) && - (triggerVerb === "POST" || - triggerVerb === "PUT" || - triggerVerb === "PATCH" || - triggerVerb === "DELETE") && - candidateVerb === "GET" - ) { - actionsToRerun.add(actionCandidate); - reasonSet.add("dependency autorerun"); - continue; - } - } - - // scopedMany auto-dependency: only rerun parent singular GET on child POST, - // and only when the parent GET previously returned the sub-resource embedded - // inside its response (detected via action._resultProperties). - // GET_MANY is excluded — a list of parents is not stale just because one - // child item was added to one of them. - scoped_many_effect: { - if ( - triggerResourceScope && - triggerVerb === "POST" && - candidateVerb === "GET" && - !candidateIsPlural - ) { - const parentEntries = scopedManyParents.get(triggerResourceScope); - if (!parentEntries) { - break scoped_many_effect; - } - for (const { - resource: parentResource, - propertyName, - } of parentEntries) { - if (parentResource !== resourceScope) { - continue; - } - // Only rerun if the last GET response included the embedded sub-resource. - if ( - !getActionResultProperties(actionCandidate)?.has(propertyName) - ) { - break scoped_many_effect; - } - actionsToRerun.add(actionCandidate); - reasonSet.add("scopedMany parent autorerun"); - continue; - } - } - } - } - } - } - - return { - actionsToRerun, - actionsToReset, - reasons: Array.from(reasonSet), - }; - }; - - const onActionComplete = (restActionWhoJustCompleted, restActionContext) => { - const { actionsToRerun, actionsToReset, reasons } = findEffectOnActions( - restActionWhoJustCompleted, - restActionContext, - ); - if (actionsToRerun.size > 0 || actionsToReset.size > 0) { - const reason = `${restActionWhoJustCompleted} triggered ${reasons.join(" and ")}`; - const dispatchActions = getActionDispatcher(); - dispatchActions({ - rerunSet: actionsToRerun, - resetSet: actionsToReset, - reason, - }); - } - }; - - return { - registerResource, - registerAction, - onActionComplete, - // Registers: when `triggerResource` fires, rerun `dependentResource`'s actions. - // Used by scopedMany to make the parent GET rerun when a child mutation completes. - addDependency: (triggerResource, dependentResource, propertyName) => { - if (!scopedManyParents.has(triggerResource)) { - scopedManyParents.set(triggerResource, new Set()); - } - scopedManyParents - .get(triggerResource) - .add({ resource: dependentResource, propertyName }); - }, - }; -}; - -const shouldRerunAfter = (rerunConfig, verb) => { - if (rerunConfig === false) { - return false; - } - if (rerunConfig === "*") { - return true; - } - if (Array.isArray(rerunConfig)) { - const methodSet = new Set(rerunConfig.map((v) => v.toUpperCase())); - if (methodSet.has("*")) { - return true; - } - return methodSet.has(verb.toUpperCase()); - } - return false; -}; -const isParamSubset = (parentParams, childParams) => { - if (!parentParams || !childParams) { - return false; - } - for (const [key, value] of Object.entries(parentParams)) { - if (!(key in childParams) || !compareTwoJsValues(childParams[key], value)) { - return false; - } - } - return true; -}; - -const paramScopeWeakSet = createIterableWeakSet(); -let paramScopeIdCounter = 0; -const getParamScope = (params) => { - for (const existingParamScope of paramScopeWeakSet) { - if (compareTwoJsValues(existingParamScope.params, params)) { - return existingParamScope; - } - } - const id = Symbol(`paramScope-${++paramScopeIdCounter}`); - const newParamScope = { - params, - id, - }; - paramScopeWeakSet.add(newParamScope); - return newParamScope; -}; - -const resourceLifecycleManager = createResourceLifecycleManager(); -const debug$2 = (args) => { - { - return; - } -}; - -const resource = ( - name, - { - // configuration options - idKey, - uniqueKeys = [], - rerunOn, - dependencies, - - GET, - GET_MANY, - POST, - POST_MANY, - PUT, - PUT_MANY, - PATCH, - PATCH_MANY, - DELETE, - DELETE_MANY, - } = {}, -) => { - if (idKey === undefined) { - idKey = uniqueKeys.length === 0 ? "id" : uniqueKeys[0]; - } - const setupCallbackSet = new Set(); - const addItemSetup = (callback) => { - setupCallbackSet.add(callback); - }; - const itemPrototype = { - [Symbol.toStringTag]: name, - toString() { - let string = `${name}`; - if (uniqueKeys.length) { - for (const uniqueKey of uniqueKeys) { - const uniqueId = this[uniqueKey]; - if (uniqueId !== undefined) { - string += `[${uniqueKey}=${uniqueId}]`; - return string; - } - } - } - const id = this[idKey]; - if (id) { - string += `[${idKey}=${id}]`; - } - return string; - }, - }; - const store = arraySignalStore([], idKey, { - uniqueKeys, - name: `${name} store`, - createItem: (props) => { - const item = Object.create(itemPrototype); - Object.assign(item, props); - Object.defineProperty(item, SYMBOL_IDENTITY, { - value: item[idKey], - writable: false, - enumerable: false, - configurable: false, - }); - for (const setupCallback of setupCallbackSet) { - setupCallback(item); - } - return item; - }, - }); - const createRestActionForRoot = createRestActionFactoryForRoot(name, { - idKey, - store, - }); - return createResource(name, { - idKey, - uniqueKeys, - restCallbacks: { - GET, - GET_MANY, - POST, - POST_MANY, - PUT, - PUT_MANY, - PATCH, - PATCH_MANY, - DELETE, - DELETE_MANY, - }, - store, - addItemSetup, - createRestAction: createRestActionForRoot, - paramScope: getParamScope(undefined), - rerunOn, - dependencies, - }); -}; - -const createResource = ( - name, - { - idKey, - uniqueKeys = [], - restCallbacks, - store, - addItemSetup, - createRestAction, - paramScope, - rerunOn, - dependencies, - } = {}, -) => { - if (idKey === undefined) { - idKey = uniqueKeys.length === 0 ? "id" : uniqueKeys[0]; - } - const params = paramScope.params; - const stateFacade = { - // public - name, - idKey, - uniqueKeys, - - useArray: () => store.arraySignal.value, - useById: (id) => store.select(idKey, id), - - withParams: undefined, - one: undefined, - many: undefined, - scopedOne: undefined, - scopedMany: undefined, - - // private but exposed for convenience - store, - addItemSetup, - }; - const lifecycleCtx = { onComplete: null }; - - resourceLifecycleManager.registerResource(stateFacade, { - rerunOn, - paramScope, - dependencies, - uniqueKeys, - }); - lifecycleCtx.onComplete = (actionCompleted) => { - resourceLifecycleManager.onActionComplete(actionCompleted, { - resourceScope: stateFacade, - }); - }; - - /** - * Creates a parameterized version of the resource with isolated resource lifecycle behavior. - * - * Actions from parameterized resources only trigger rerun/reset for other actions with - * identical parameters, preventing cross-contamination between different parameter sets. - * - * @param {Object} params - Parameters to bind to all actions of this resource (required) - * @param {Object} options - Additional options for the parameterized resource - * @returns {Object} A new resource instance with parameter-bound actions and isolated lifecycle - * @see {@link ./docs/resource_with_params.md} for detailed documentation and examples - * - * @example - * const ROLE = resource("role", { GET: (params) => fetchRole(params) }); - * const adminRoles = ROLE.withParams({ canlogin: true }); - * const guestRoles = ROLE.withParams({ canlogin: false }); - * // adminRoles and guestRoles have isolated autorerun behavior - * - * @example - * // Cross-resource dependencies - * const role = resource("role"); - * const database = resource("database"); - * const tables = resource("tables"); - * const ROLE_WITH_OWNERSHIP = role.withParams({ owners: true }, { - * dependencies: [role, database, tables], - * }); - * // ROLE_WITH_OWNERSHIP.GET_MANY will autorerun when any table/database/role is POST/DELETE - */ - const withParams = ( - paramsToInject, - { dependencies: withParamsDeps, rerunOn: withParamsRerunOn } = {}, - ) => { - if (!paramsToInject || Object.keys(paramsToInject).length === 0) { - throw new Error(`resource(${name}).withParams() requires parameters`); - } - const resolvedParams = params - ? { ...params, ...paramsToInject } - : paramsToInject; - const resolvedParamScope = getParamScope(resolvedParams); - const createRestActionWithParams = createRestActionFactoryForRoot(name, { - idKey, - store, - }); - return createResource(name, { - idKey, - uniqueKeys, - restCallbacks, - store, - addItemSetup, - createRestAction: createRestActionWithParams, - paramScope: resolvedParamScope, - rerunOn: withParamsRerunOn ?? rerunOn, - dependencies: withParamsDeps ?? dependencies, - }); - }; - stateFacade.withParams = withParams; - - stateFacade.one = ( - propertyName, - childResource, - { - rerunOn: oneRerunOn, - dependencies: oneDependencies, - - GET, - PUT, - DELETE, - } = {}, - ) => { - const childName = `${name}.${propertyName}`; - addItemSetup((item) => { - const childIdKeyForSetup = childResource.idKey; - const childItemIdSignal = signal(); - const updateChildItemId = (value) => { - const currentChildItemId = childItemIdSignal.peek(); - if (isProps(value)) { - const childItem = childResource.store.upsert(value); - const childItemId = childItem[childIdKeyForSetup]; - if (currentChildItemId === childItemId) { - return false; - } - childItemIdSignal.value = childItemId; - return true; - } - if (primitiveCanBeId(value)) { - const childItemProps = { [childIdKeyForSetup]: value }; - const childItem = childResource.store.upsert(childItemProps); - const childItemId = childItem[childIdKeyForSetup]; - if (currentChildItemId === childItemId) { - return false; - } - childItemIdSignal.value = childItemId; - return true; - } - if (currentChildItemId === undefined) { - return false; - } - childItemIdSignal.value = undefined; - return true; - }; - updateChildItemId(item[propertyName]); - const childItemSignal = computed(() => - childResource.store.select(childItemIdSignal.value), - ); - const childItemFacadeSignal = computed(() => { - const childItem = childItemSignal.value; - if (childItem) { - const childItemCopy = Object.create( - Object.getPrototypeOf(childItem), - Object.getOwnPropertyDescriptors(childItem), - ); - Object.defineProperty(childItemCopy, SYMBOL_OBJECT_SIGNAL, { - value: childItemSignal, - writable: false, - enumerable: false, - configurable: false, - }); - return childItemCopy; - } - return { - [SYMBOL_OBJECT_SIGNAL]: childItemSignal, - valueOf: () => null, - }; - }); - Object.defineProperty(item, propertyName, { - get: () => childItemFacadeSignal.value, - set: updateChildItemId, - }); - debug$2( - `setup ${item}.${propertyName} is one "${childResource.name}" (current value: ${childItemSignal.peek()})`, - ); - }); - - const childIdKey = childResource.idKey; - const childStore = childResource.store; - const createRestActionForOne = (verb, callback, { lifecycleCtx }) => { - const applyResultToValue = - verb === "DELETE" - ? (itemId) => { - const item = store.select(itemId); - const childItemId = item[propertyName][childIdKey]; - store.upsert({ - [idKey]: itemId, - [propertyName]: null, - }); - return childItemId; - } - : // callback must return object with the following format: - // { - // [idKey]: 123, - // [propertyName]: { - // [childIdKey]: 456, ...childProps - // } - // } - // the following could happen too if there is no relationship - // { - // [idKey]: 123, - // [propertyName]: null - // } - (result) => { - const item = store.upsert(result); - const childItem = item[propertyName]; - const childItemId = childItem ? childItem[childIdKey] : undefined; - return childItemId; - }; - - const callerInfo = getCallerInfo(null, 2); - const locationInfo = - callerInfo.file && callerInfo.line && callerInfo.column - ? `${callerInfo.file}:${callerInfo.line}:${callerInfo.column}` - : callerInfo.raw || "unknown location"; - const originalActionName = `${name}.${verb}`; - - const actionAffectingOneItem = createAction(callback, { - meta: { - verb, - isMany: false, - paramScope, - }, - name: `${name}.${verb}`, - resultToValue: (result, action) => { - const actionLabel = action.name; - - if (verb === "DELETE") { - if (!isProps(result) && !primitiveCanBeId(result)) { - throw new TypeError( - `${actionLabel} must return an object (that will be used to drop "${name}" resource), received ${result}. -${originalActionName} source location: ${locationInfo}`, - ); - } - return applyResultToValue(result); - } - if (!isProps(result)) { - throw new TypeError( - `${actionLabel} must return an object (that will be used to upsert "${name}" resource), received ${result}. - ${originalActionName} source location: ${locationInfo}`, - ); - } - return applyResultToValue(result); - }, - valueToData: (childItemId) => childStore.select(childItemId), - completeSideEffect: (actionCompleted) => { - lifecycleCtx.onComplete(actionCompleted); - }, - }); - return actionAffectingOneItem; - }; - - return createResource(childName, { - idKey: childResource.idKey, - restCallbacks: { - GET, - PUT, - DELETE, - }, - store, - addItemSetup, - createRestAction: createRestActionForOne, - paramScope, - rerunOn: oneRerunOn ?? rerunOn, - dependencies: oneDependencies ?? dependencies, - }); - }; - - stateFacade.many = ( - propertyName, - childResource, - { - rerunOn: manyRerunOn, - dependencies: manyDependencies, - - GET, - GET_MANY, - POST, - POST_MANY, - PUT, - PUT_MANY, - PATCH, - PATCH_MANY, - DELETE, - DELETE_MANY, - } = {}, - ) => { - const childStore = childResource.store; - const childIdKey = childResource.idKey; - const childName = `${name}.${propertyName}`; - addItemSetup((item) => { - const childItemIdArraySignal = signal([]); - const updateChildItemIdArray = (valueArray) => { - const currentIdArray = childItemIdArraySignal.peek(); - if (!Array.isArray(valueArray)) { - if (currentIdArray.length === 0) return; - childItemIdArraySignal.value = []; - return; - } - let i = 0; - const idArray = []; - let modified = false; - while (i < valueArray.length) { - const value = valueArray[i]; - const currentIdAtIndex = currentIdArray[idArray.length]; - i++; - if (isProps(value)) { - const childItem = childResource.store.upsert(value); - const childItemId = childItem[childIdKey]; - if (currentIdAtIndex !== childItemId) modified = true; - idArray.push(childItemId); - continue; - } - if (primitiveCanBeId(value)) { - const childItemProps = { [childIdKey]: value }; - const childItem = childResource.store.upsert(childItemProps); - const childItemId = childItem[childIdKey]; - if (currentIdAtIndex !== childItemId) modified = true; - idArray.push(childItemId); - continue; - } - } - if (modified || currentIdArray.length !== idArray.length) { - childItemIdArraySignal.value = idArray; - } - }; - updateChildItemIdArray(item[propertyName]); - const childItemArraySignal = computed(() => { - const idArray = childItemIdArraySignal.value; - const arr = childResource.store.selectAll(idArray); - Object.defineProperty(arr, SYMBOL_OBJECT_SIGNAL, { - value: childItemArraySignal, - writable: false, - enumerable: false, - configurable: false, - }); - return arr; - }); - Object.defineProperty(item, propertyName, { - get: () => childItemArraySignal.value, - set: updateChildItemIdArray, - }); - syncIdArrayOnRename( - childResource.store, - childIdKey, - childItemIdArraySignal, - ); - }); - const createRestActionForMany = ( - verb, - callback, - { isMany, lifecycleCtx }, - ) => { - if (!isMany) { - return createRestActionAffectingOneItem(verb, callback, lifecycleCtx); - } - return createRestActionAffectingManyItems(verb, callback, lifecycleCtx); - }; - const createRestActionAffectingOneItem = (verb, callback, lifecycleCtx) => { - const applyResultToValue = - verb === "DELETE" - ? ([itemId, childItemId]) => { - const item = store.select(itemId); - const childItemArray = item[propertyName]; - const childItemArrayWithoutThisOne = []; - let found = false; - for (const childItemCandidate of childItemArray) { - const childItemCandidateId = childItemCandidate[childIdKey]; - if (childItemCandidateId === childItemId) { - found = true; - } else { - childItemArrayWithoutThisOne.push(childItemCandidate); - } - } - if (found) { - store.upsert({ - [idKey]: itemId, - [propertyName]: childItemArrayWithoutThisOne, - }); - } - return childItemId; - } - : (childData) => { - const childItem = Array.isArray(childData) - ? childStore.upsert(...childData) - : childStore.upsert(childData); - const childItemId = childItem[childIdKey]; - return childItemId; - }; - - const callerInfo = getCallerInfo(null, 2); - const locationInfo = - callerInfo.file && callerInfo.line && callerInfo.column - ? `${callerInfo.file}:${callerInfo.line}:${callerInfo.column}` - : callerInfo.raw || "unknown location"; - const originalActionName = `${name}.${verb}`; - - const actionAffectingOneItem = createAction(callback, { - meta: { verb, isMany: false, paramScope }, - name: `${name}.${verb}`, - resultToValue: (result, action) => { - const actionLabel = action.name; - - if (verb === "DELETE") { - if (!Array.isArray(result) || result.length !== 2) { - throw new TypeError( - `${actionLabel} must return an array [itemId, childItemId] (that will be used to remove relationship), received ${result}. -${originalActionName} source location: ${locationInfo}`, - ); - } - return applyResultToValue(result); - } - if (!isProps(result)) { - throw new TypeError( - `${actionLabel} must return an object (that will be used to upsert child item), received ${result}. -${originalActionName} source location: ${locationInfo}`, - ); - } - return applyResultToValue(result); - }, - valueToData: (childItemId) => childStore.select(childItemId), - completeSideEffect: (actionCompleted) => { - lifecycleCtx.onComplete(actionCompleted); - }, - }); - return actionAffectingOneItem; - }; - const createRestActionAffectingManyItems = ( - verb, - callback, - lifecycleCtx, - ) => { - const applyResultToValue = - verb === "GET" - ? (result) => { - // callback must return object with the following format: - // { - // [idKey]: 123, - // [propertyName]: [ - // { [childIdKey]: 456, ...childProps }, - // { [childIdKey]: 789, ...childProps }, - // ... - // ] - // } - // the array can be empty - const item = store.upsert(result); - const childItemArray = item[propertyName]; - const childItemIdArray = childItemArray.map( - (childItem) => childItem[childIdKey], - ); - return childItemIdArray; - } - : verb === "DELETE" - ? ([itemIdOrMutableId, childItemIdOrMutableIdArray]) => { - const item = store.select(itemIdOrMutableId); - const childItemArray = item[propertyName]; - const deletedChildItemIdArray = []; - const childItemArrayWithoutThoose = []; - let someFound = false; - const deletedChildItemArray = childStore.select( - childItemIdOrMutableIdArray, - ); - for (const childItemCandidate of childItemArray) { - if (deletedChildItemArray.includes(childItemCandidate)) { - someFound = true; - deletedChildItemIdArray.push( - childItemCandidate[childIdKey], - ); - } else { - childItemArrayWithoutThoose.push(childItemCandidate); - } - } - if (someFound) { - store.upsert({ - [idKey]: item[idKey], - [propertyName]: childItemArrayWithoutThoose, - }); - } - return deletedChildItemIdArray; - } - : (childDataArray) => { - const childItemArray = childStore.upsert(childDataArray); - const childItemIdArray = childItemArray.map( - (childItem) => childItem[childIdKey], - ); - return childItemIdArray; - }; - - const callerInfo = getCallerInfo(null, 2); - const locationInfo = - callerInfo.file && callerInfo.line && callerInfo.column - ? `${callerInfo.file}:${callerInfo.line}:${callerInfo.column}` - : callerInfo.raw || "unknown location"; - const originalActionName = `${name}.${verb}[many]`; - - const actionAffectingManyItem = createAction(callback, { - meta: { verb, isMany: true, paramScope }, - name: `${name}.${verb}[many]`, - dataDefault: [], - resultToValue: (result, action) => { - const actionLabel = action.name; - - if (verb === "GET") { - if (!isProps(result)) { - throw new TypeError( - `${actionLabel} must return an object (that will be used to upsert "${name}" resource with many relationships), received ${result}. -${originalActionName} source location: ${locationInfo}`, - ); - } - return applyResultToValue(result); - } - if (verb === "DELETE") { - if ( - !Array.isArray(result) || - result.length !== 2 || - !Array.isArray(result[1]) - ) { - throw new TypeError( - `${actionLabel} must return an array [itemId, childItemIdArray] (that will be used to remove relationships), received ${result}. -${originalActionName} source location: ${locationInfo}`, - ); - } - return applyResultToValue(result); - } - if (!Array.isArray(result)) { - throw new TypeError( - `${actionLabel} must return an array of objects (that will be used to upsert child items), received ${result}. -${originalActionName} source location: ${locationInfo}`, - ); - } - return applyResultToValue(result); - }, - valueToData: (childItemIdArray) => - childStore.selectAll(childItemIdArray), - completeSideEffect: (actionCompleted) => { - lifecycleCtx.onComplete(actionCompleted); - }, - }); - return actionAffectingManyItem; - }; - - return createResource(childName, { - idKey: childIdKey, - restCallbacks: { - GET, - GET_MANY, - POST, - POST_MANY, - PUT, - PUT_MANY, - PATCH, - PATCH_MANY, - DELETE, - DELETE_MANY, - }, - store, - addItemSetup, - createRestAction: createRestActionForMany, - paramScope, - rerunOn: manyRerunOn ?? rerunOn, - dependencies: manyDependencies ?? dependencies, - }); - }; - - stateFacade.scopedOne = ( - propertyName, - { - idKey: childIdKey = "id", - rerunOn: scopedOneRerunOn, - dependencies: scopedOneDependencies, - - GET, - POST, - PUT, - PATCH, - DELETE, - } = {}, - ) => { - const childName = `${name}.${propertyName}`; - - // setupCallbackSet: callbacks added by chained .one()/.many() - // Applied to each per-scope child item object when it is first created. - const childItemSetupCallbackSet = new Set(); - const childAddItemSetup = (callback) => - childItemSetupCallbackSet.add(callback); - const scopedItemMap = new Map(); // ownerId → stable child item object - const scopedSignalMap = new Map(); // ownerId → signal - addItemSetup((ownerItem) => { - const ownerId = ownerItem[idKey]; - // Create a stable child item — mutated in place via applyProps. - // Reactive getters/setters from chained .one() etc. are defined on this object now - // so they survive across multiple prop updates. - const childItem = {}; - for (const childSetup of childItemSetupCallbackSet) { - childSetup(childItem); - } - scopedItemMap.set(ownerId, childItem); - const childSignal = signal(null); - scopedSignalMap.set(ownerId, childSignal); - - const applyProps = (props) => { - if (!props) { - childSignal.value = null; - return; - } - // Assign each prop in place. Reactive setters (from chained .one() etc.) will fire. - for (const [key, value] of Object.entries(props)) { - childItem[key] = value; - } - if (childSignal.peek() !== childItem) { - childSignal.value = childItem; // first activation: null → childItem - } - }; - - applyProps(ownerItem[propertyName]); - - Object.defineProperty(ownerItem, propertyName, { - get: () => childSignal.value, - set: applyProps, - }); - }); - const createRestActionForScopedOne = (verb, callback, { lifecycleCtx }) => { - const childActionName = `${childName}.${verb}`; - const restAction = createAction(callback, { - name: childActionName, - meta: { verb, isMany: false, paramScope }, - resultToValue: (result) => { - if (!Array.isArray(result) || result.length !== 2) { - throw new TypeError( - `${childActionName} callback must return [ownerId, props], received ${result}`, - ); - } - const [rawOwnerId, props] = result; - const ownerId = resolveOwnerId( - rawOwnerId, - store, - idKey, - uniqueKeys, - childActionName, - ); - const childItem = scopedItemMap.get(ownerId); - if (!childItem) { - throw new Error( - `${childActionName}: no item found for scope id "${ownerId}"`, - ); - } - const childSignal = scopedSignalMap.get(ownerId); - if (props) { - for (const [key, value] of Object.entries(props)) { - childItem[key] = value; - } - if (childSignal.peek() !== childItem) { - childSignal.value = childItem; - } - } else { - childSignal.value = null; - } - return [ownerId, props]; - }, - completeSideEffect: (actionCompleted) => { - lifecycleCtx.onComplete(actionCompleted); - }, - }); - return restAction; - }; - - const childResource = createResource(childName, { - idKey: childIdKey, - restCallbacks: { - GET, - POST, - PUT, - PATCH, - DELETE, - }, - store, - addItemSetup: childAddItemSetup, - createRestAction: createRestActionForScopedOne, - paramScope, - rerunOn: scopedOneRerunOn ?? rerunOn, - dependencies: scopedOneDependencies ?? dependencies, - }); - return childResource; - }; - - stateFacade.scopedMany = ( - propertyName, - { - idKey: childIdKey = "id", - rerunOn: scopedManyRerunOn, - dependencies: scopedManyDependencies, - - GET, - GET_MANY, - POST, - POST_MANY, - PUT, - PUT_MANY, - PATCH, - PATCH_MANY, - DELETE, - DELETE_MANY, - } = {}, - ) => { - const childName = `${name}.${propertyName}`; - - // setupCallbackSet: callbacks added by chained .one()/.many() - // Applied to each child item when it is created in a per-scope store. - const childSetupCallbackSet = new Set(); - const childAddItemSetup = (callback) => childSetupCallbackSet.add(callback); - const scopedStoreMap = new Map(); // ownerId → childStore - const scopedIdArraySignalMap = new Map(); // ownerId → childItemIdArraySignal - addItemSetup((item) => { - const ownerId = item[idKey]; - - // Reuse an existing scoped store if one was already created via a uniqueKey - // (e.g. rows were fetched by tablename before the full table was loaded). - let childStore = scopedStoreMap.get(ownerId); - let childItemIdArraySignal = scopedIdArraySignalMap.get(ownerId); - if (!childStore) { - for (const uniqueKey of uniqueKeys) { - const uniqueKeyValue = item[uniqueKey]; - if (uniqueKeyValue !== undefined) { - const existing = scopedStoreMap.get(uniqueKeyValue); - if (existing) { - childStore = existing; - childItemIdArraySignal = - scopedIdArraySignalMap.get(uniqueKeyValue); - break; - } - } - } - } - if (!childStore) { - childStore = arraySignalStore([], childIdKey, { - name: `${childName}#${ownerId} store`, - createItem: (props) => { - const childItem = {}; - Object.assign(childItem, props); - for (const childSetup of childSetupCallbackSet) { - childSetup(childItem); - } - return childItem; - }, - }); - childItemIdArraySignal = signal([]); - } - scopedStoreMap.set(ownerId, childStore); - // Also register by each uniqueKey value so that resolveOwnerId works - // when a callback returns { [uniqueKey]: value } before the full item is loaded. - for (const uniqueKey of uniqueKeys) { - const uniqueKeyValue = item[uniqueKey]; - if (uniqueKeyValue !== undefined) { - scopedStoreMap.set(uniqueKeyValue, childStore); - } - } - - scopedIdArraySignalMap.set(ownerId, childItemIdArraySignal); - for (const uniqueKey of uniqueKeys) { - const uniqueKeyValue = item[uniqueKey]; - if (uniqueKeyValue !== undefined) { - scopedIdArraySignalMap.set(uniqueKeyValue, childItemIdArraySignal); - } - } - - const updateChildItemIdArray = (valueArray) => { - const currentIdArray = childItemIdArraySignal.peek(); - if (!Array.isArray(valueArray)) { - if (currentIdArray.length === 0) return; - childItemIdArraySignal.value = []; - return; - } - let i = 0; - const idArray = []; - let modified = false; - while (i < valueArray.length) { - const value = valueArray[i]; - const currentIdAtIndex = currentIdArray[idArray.length]; - i++; - if (isProps(value)) { - const childItem = childStore.upsert(value); - const childItemId = childItem[childIdKey]; - if (currentIdAtIndex !== childItemId) modified = true; - idArray.push(childItemId); - continue; - } - if (primitiveCanBeId(value)) { - const childItemProps = { [childIdKey]: value }; - const childItem = childStore.upsert(childItemProps); - const childItemId = childItem[childIdKey]; - if (currentIdAtIndex !== childItemId) modified = true; - idArray.push(childItemId); - continue; - } - } - if (modified || currentIdArray.length !== idArray.length) { - childItemIdArraySignal.value = idArray; - } - }; - - updateChildItemIdArray(item[propertyName]); - - // When an id is renamed (PUT/PATCH changes the idKey), patch the id array. - syncIdArrayOnRename(childStore, childIdKey, childItemIdArraySignal); - - const childItemArraySignal = computed(() => { - const childItemIdArray = childItemIdArraySignal.value; - const childItemArray = childStore.selectAll(childItemIdArray); - Object.defineProperty(childItemArray, SYMBOL_OBJECT_SIGNAL, { - value: childItemArraySignal, - writable: false, - enumerable: false, - configurable: false, - }); - return childItemArray; - }); - - Object.defineProperty(item, propertyName, { - get: () => childItemArraySignal.value, - set: updateChildItemIdArray, - }); - }); - const createRestActionForScopedMany = ( - verb, - callback, - { isMany, lifecycleCtx }, - ) => { - if (!callback) { - return undefined; - } - const childActionName = `${childName}.${verb}`; - const childAction = createAction(callback, { - name: childActionName, - meta: { verb, isMany, paramScope }, - resultToValue: (result) => { - if (!Array.isArray(result) || result.length < 2) { - throw new TypeError( - `${childActionName} callback must return [ownerId, ...] array, received ${result}`, - ); - } - const [rawOwnerId, ...rest] = result; - const ownerId = resolveOwnerId( - rawOwnerId, - store, - idKey, - uniqueKeys, - childActionName, - ); - let childStore = scopedStoreMap.get(ownerId); - if (!childStore) { - // Owner not yet in store — lazily create scoped store so actions can run - // before the parent item has been fully loaded (e.g. rows fetched before table). - childStore = arraySignalStore([], childIdKey, { - name: `${childName}#${ownerId} store`, - createItem: (props) => { - const childItem = {}; - Object.assign(childItem, props); - for (const childSetup of childSetupCallbackSet) { - childSetup(childItem); - } - return childItem; - }, - }); - scopedStoreMap.set(ownerId, childStore); - const newIdArraySignal = signal([]); - scopedIdArraySignalMap.set(ownerId, newIdArraySignal); - } - const childItemIdArraySignal = scopedIdArraySignalMap.get(ownerId); - - if (verb === "DELETE") { - if (isMany) { - const idArray = childStore.drop(rest[0]); - const toRemoveSet = new Set(idArray); - childItemIdArraySignal.value = childItemIdArraySignal - .peek() - .filter((id) => !toRemoveSet.has(id)); - return [ownerId, idArray]; - } - const childId = childStore.drop(rest[0]); - childItemIdArraySignal.value = childItemIdArraySignal - .peek() - .filter((id) => id !== childId); - return [ownerId, childId]; - } - - if (isMany) { - // GET_MANY, POST_MANY, PUT_MANY etc: rest[0] is the array of items - const itemArray = childStore.upsert(rest[0]); - const idArray = itemArray.map((i) => i[childIdKey]); - childItemIdArraySignal.value = idArray; - return [ownerId, idArray]; - } - - // GET, POST, PUT, PATCH: rest may be [props] or [oldId, props] for renames - const childItem = - rest.length > 1 - ? childStore.upsert(...rest) - : childStore.upsert(rest[0]); - return [ownerId, childItem[childIdKey]]; - }, - valueToData: (value) => { - if (!value) return isMany ? [] : undefined; - const [ownerId, idOrIdArray] = value; - const childStore = scopedStoreMap.get(ownerId); - if (!childStore) return isMany ? [] : undefined; - if (isMany) return childStore.selectAll(idOrIdArray); - return childStore.select(idOrIdArray); - }, - completeSideEffect: (actionCompleted) => { - lifecycleCtx.onComplete(actionCompleted); - }, - }); - return childAction; - }; - - // When a child (scopedMany) item is mutated via POST, the parent GET must - // re-fetch because the parent embeds the child array and we cannot know the - // new ordering without asking the backend again. - // (scopedOne does NOT need this: the mutation result contains the updated - // item directly, so no parent re-fetch is necessary.) - const childResource = createResource(childName, { - idKey: childIdKey, - restCallbacks: { - GET, - GET_MANY, - POST, - POST_MANY, - PUT, - PUT_MANY, - PATCH, - PATCH_MANY, - DELETE, - DELETE_MANY, - }, - store, - addItemSetup: childAddItemSetup, - createRestAction: createRestActionForScopedMany, - paramScope, - rerunOn: scopedManyRerunOn ?? rerunOn, - dependencies: scopedManyDependencies ?? dependencies, - }); - // Register: when childResource fires, rerun parent (stateFacade) GETs. - resourceLifecycleManager.addDependency( - childResource, - stateFacade, - propertyName, - ); - childResource.getChildStore = (ownerKey) => scopedStoreMap.get(ownerKey); - return childResource; - }; - - // expose rest actions on the stateFacade - for (const [restCallbackKey, restCallback] of Object.entries(restCallbacks)) { - if (restCallback === undefined) { - continue; - } - const isMany = restCallbackKey.endsWith("_MANY"); - const verb = isMany - ? restCallbackKey.replace("_MANY", "") - : restCallbackKey; - const restAction = createRestAction(verb, restCallback, { - isMany, - lifecycleCtx, - paramScope, - }); - if (!restAction) { - console.error("no action returned (here to see when it happens)"); - continue; - } - let actionToRegister; - if (params) { - const restActionBound = restAction.bindParams(params); - stateFacade[restCallbackKey] = restActionBound; - actionToRegister = restActionBound; - } else { - stateFacade[restCallbackKey] = restAction; - actionToRegister = restAction; - } - resourceLifecycleManager.registerAction(stateFacade, actionToRegister); - } - - return stateFacade; -}; - -const createRestActionFactoryForRoot = ( - name, - { - idKey, - store, // see array_signal_store.js - }, -) => { - const createActionForRoot = ( - verb, - restCallback, - { isMany, lifecycleCtx, paramScope }, - ) => { - if (!isMany) { - return createActionAffectingOneItem(verb, restCallback, { - lifecycleCtx, - paramScope, - }); - } - return createActionAffectingManyItems(verb, restCallback, { - lifecycleCtx, - paramScope, - }); - }; - const createActionAffectingOneItem = ( - verb, - callback, - { lifecycleCtx, paramScope }, - ) => { - const applyResultToValue = - verb === "DELETE" - ? (itemIdOrItemProps) => { - const itemId = store.drop(itemIdOrItemProps); - return itemId; - } - : (result) => { - let item; - if (Array.isArray(result)) { - // the callback is returning something like [property, value, props] - // this is to support a case like: - // store.upsert("name", "currentName", { name: "newName" }) - // where we want to update the idKey of an item - item = store.upsert(...result); - } else { - item = store.upsert(result); - } - const itemId = item[idKey]; - return itemId; - }; - - const callerInfo = getCallerInfo(null, 2); - // Provide more fallback options for better debugging - const locationInfo = - callerInfo.file && callerInfo.line && callerInfo.column - ? `${callerInfo.file}:${callerInfo.line}:${callerInfo.column}` - : callerInfo.raw || "unknown location"; - const originalActionName = `${name}.${verb}`; - const actionAffectingOneItem = createAction(callback, { - name: `${name}.${verb}`, - meta: { verb, isMany: false, paramScope }, - resultToValue: (result, action) => { - const actionLabel = action.name; - - if (verb === "DELETE") { - if (!isProps(result) && !primitiveCanBeId(result)) { - throw new TypeError( - `${actionLabel} must return an object (that will be used to drop "${name}" resource), received ${result}. -${originalActionName} source location: ${locationInfo}`, - ); - } - return applyResultToValue(result); - } - if (!isProps(result)) { - throw new TypeError( - `${actionLabel} must return an object (that will be used to upsert "${name}" resource), received ${result}. -${originalActionName} source location: ${locationInfo}`, - ); - } - // Track which top-level properties the GET response contained so that - // lifecycle rules can detect whether sub-resources were embedded. - if (verb === "GET") { - recordGetResultProperties(action, Object.keys(result)); - } - return applyResultToValue(result); - }, - valueToData: (itemId) => store.select(itemId), - completeSideEffect: (actionCompleted) => { - lifecycleCtx.onComplete(actionCompleted); - }, - }); - return actionAffectingOneItem; - }; - const createActionAffectingManyItems = ( - verb, - callback, - { lifecycleCtx, paramScope }, - ) => { - const applyResultToValue = - verb === "DELETE" - ? (idOrMutableIdArray) => { - const idArray = store.drop(idOrMutableIdArray); - return idArray; - } - : (dataArray) => { - const itemArray = store.upsert(dataArray); - const idArray = itemArray.map((item) => item[idKey]); - return idArray; - }; - - const actionAffectingManyItems = createAction(callback, { - meta: { verb, isMany: true, paramScope }, - name: `${name}.${verb}_MANY`, - dataDefault: [], - resultToValue: applyResultToValue, - valueToData: (idArray) => { - const items = store.selectAll(idArray); - return items; - }, - completeSideEffect: (actionCompleted) => { - lifecycleCtx.onComplete(actionCompleted); - if ( - verb === "DELETE" || - actionCompleted.valueSignal.peek().length === 0 - ) { - return null; - } - // When an id is renamed (PUT/PATCH changes the idKey), the store fires observeProperties - // with a mutation containing oldValue/newValue for that key. We patch this action's - // valueSignal (the id array) so that selectAll keeps returning the right items. - // The returned unsubscribe function is called by completeSideEffectCleanup on reset. - return syncIdArrayOnRename(store, idKey, actionCompleted.valueSignal); - }, - }); - return actionAffectingManyItems; - }; - - return createActionForRoot; -}; - -const syncIdArrayOnRename = (store, idKey, idArraySignal) => { - return store.observeProperties((mutations) => { - const idArray = idArraySignal.peek(); - if (idArray.length === 0) { - return; - } - const idSet = new Set(idArray); - const idMutationMap = new Map(); - for (const mutation of mutations) { - const idKeyMutation = mutation[idKey]; - if (!idKeyMutation) { - continue; - } - const { oldValue, newValue } = idKeyMutation; - if (!idSet.has(oldValue)) { - continue; - } - idMutationMap.set(oldValue, newValue); - } - if (idMutationMap.size === 0) { - return; - } - const idUpdatedArray = []; - for (const id of idArray) { - idUpdatedArray.push(idMutationMap.get(id) ?? id); - } - idArraySignal.value = idUpdatedArray; - }); -}; - -const isProps = (value) => { - return value !== null && typeof value === "object"; -}; - -const resolveOwnerId = (rawOwnerId, store, idKey, uniqueKeys, actionName) => { - if (!isProps(rawOwnerId)) { - // Already a primitive — use as-is. - return rawOwnerId; - } - - const keys = Object.keys(rawOwnerId); - - if (keys.length === 1) { - const [propName] = keys; - const propValue = rawOwnerId[propName]; - - if (propName === idKey) { - return propValue; - } - if (uniqueKeys.includes(propName)) { - const item = store.select(propName, propValue); - if (!item) { - // Owner not yet in store — the scoped maps may still be keyed by uniqueKey value - // (registered during addItemSetup). Return the propValue as the owner key directly. - return propValue; - } - return item[idKey]; - } - throw new TypeError( - `${actionName}: the first element of the returned array is { ${propName}: "${propValue}" } but "${propName}" is neither the idKey ("${idKey}") nor a declared uniqueKey (${uniqueKeys.length ? uniqueKeys.join(", ") : "none"}). -Return a primitive id or a single-property object whose key is the idKey or a uniqueKey.`, - ); - } - - // More than one property — try to recover via idKey, warn if successful. - if (idKey in rawOwnerId) { - const resolvedId = rawOwnerId[idKey]; - console.warn( - `${actionName}: the first element of the returned array is an object with multiple properties. -Only "${idKey}" is needed. Consider returning a primitive id or { ${idKey}: value } instead.`, - ); - return resolvedId; - } - - throw new TypeError( - `${actionName}: the first element of the returned array must be a primitive id or a single-property object equal to { [idKey]: value } or { [uniqueKey]: value }. -Received an object with keys: ${keys.join(", ")}.`, - ); -}; - -/** so that when a tracked property changes - * on an item the corresponding signal is updated automatically. - * - * Since signals are typically connected to route parameters via the route template - * syntax, this keeps the URL in sync when a store item's mutable key is renamed. - * - * @example - * const usernameSignal = stateSignal(); - * const USER_ROUTE = route(`/users/:username=${usernameSignal}/`); - * - * const USER = resource("user", { - * idKey: "id", - * uniqueKeys: ["username"], - * PUT: async ({ id, username }) => ({ id, username }), - * }); - * - * syncResourceToSignals(USER, { username: usernameSignal }); - * // Now when a user item's username is updated via USER.PUT, - * // usernameSignal.value is set to the new username, - * // which in turn triggers the route Signal->URL sync and updates the browser URL. - */ -const syncResourceToSignals = (resource, propertyToSignalMap) => { - if (resource.getChildStore) { - throw new Error( - `syncResourceToSignals: "${resource.name}" is a scoped resource (scopedMany/scopedOne). Use syncOwnedResourceToSignals instead.`, - ); - } - syncStoreToSignals(resource.store, propertyToSignalMap); -}; - -const syncOwnedResourceToSignals = ( - resource, - ownerSignal, - propertyToSignalMap, -) => { - if (!resource.getChildStore) { - throw new Error( - `syncOwnedResourceToSignals: "${resource.name}" is not a scoped resource (scopedMany/scopedOne). Use syncResourceToSignals instead.`, - ); - } - effect(() => { - // Always subscribe to the parent store so the effect re-runs when a new - // owner item is added (which creates the child store). - // eslint-disable-next-line no-unused-expressions - resource.store.arraySignal.value; - const ownerKey = ownerSignal.value; - if (ownerKey === null || ownerKey === undefined) { - return null; - } - const childStore = resource.getChildStore(ownerKey); - if (!childStore) { - return null; - } - const cleanup = syncStoreToSignals(childStore, propertyToSignalMap); - return cleanup; - }); -}; - -// Global signal registry for route template detection -const globalSignalRegistry = new Map(); -let signalIdCounter = 0; -const generateSignalId = () => { - const id = signalIdCounter++; - return id; -}; - -/** - * Creates an advanced signal with dynamic default value, local storage persistence, and validation. - * - * The first parameter can be either a static value or a signal acting as a "dynamic default": - * - If a static value: traditional default behavior - * - If a signal: acts as a dynamic default that updates the signal ONLY when no explicit value has been set - * - * Dynamic default behavior (when first param is a signal): - * 1. Initially takes value from the default signal - * 2. When explicitly set (programmatically or via localStorage), the explicit value takes precedence - * 3. When default signal changes, it only updates if no explicit value was ever set - * 4. Calling reset() or setting to undefined makes the signal use the dynamic default again - * 5. If dynamic default is undefined and options.default is provided, uses the static fallback - * - * This is useful for: - * - Backend data that can change but shouldn't override user preferences - * - Route parameters with dynamic defaults based on other state - * - Cascading configuration where defaults can be updated without losing user customizations - * - Having a static fallback when dynamic defaults might be undefined - * - * @param {any|import("@preact/signals").Signal} defaultValue - Static default value OR signal for dynamic default behavior - * @param {Object} [options={}] - Configuration options - * @param {string|number} [options.id] - Custom ID for the signal. If not provided, an auto-generated ID will be used. Used for localStorage key and route pattern detection. - * @param {any} [options.default] - Static fallback value used when defaultValue is a signal and that signal's value is undefined - * @param {boolean} [options.persists=false] - Whether to persist the signal value in localStorage using the signal ID as key - * @param {"string" | "number" | "boolean" | "object"} [options.type="string"] - Type for localStorage serialization/deserialization - * @param {number} [options.step] - For number type: step size for precision. Values will be rounded to nearest multiple of step. - * @param {Array} [options.oneOf] - Array of valid values for validation. Signal will be marked invalid if value is not in this array - * @param {boolean} [options.debug=false] - Enable debug logging for this signal's operations - * @returns {import("@preact/signals").Signal} A signal that can be synchronized with a source signal and/or persisted in localStorage. The signal includes a `validity` property for validation state. - * - * @example - * // Basic signal with default value - * const count = stateSignal(0); - * - * @example - * // Signal with custom ID and persistence - * const theme = stateSignal("light", { - * id: "user-theme", - * persists: true, - * type: "string" - * }); - * - * @example - * // Signal with validation and auto-fix - * const tab = stateSignal("overview", { - * id: "current-tab", - * oneOf: ["overview", "details", "settings"], - * autoFix: () => "overview", - * persists: true - * }); - * - * @example - * // Dynamic default that doesn't override user choices - * const backendTheme = signal("light"); - * const userTheme = stateSignal(backendTheme, { persists: true }); - * - * // Initially: userTheme.value = "light" (from dynamic default) - * // User sets: userTheme.value = "dark" (explicit choice, persisted) - * // Backend changes: backendTheme.value = "blue" - * // Result: userTheme.value = "dark" (user choice preserved) - * // Reset: userTheme.value = undefined; // Now follows dynamic default again - * - * @example - * // Dynamic default with static fallback - * const backendValue = signal(undefined); // might be undefined initially - * const userValue = stateSignal(backendValue, { - * default: "fallback", - * persists: true - * }); - * - * // Initially: userValue.value = "fallback" (static fallback since dynamic is undefined) - * // Backend loads: backendValue.value = "loaded"; userValue.value = "loaded" (follows dynamic) - * // User sets: userValue.value = "custom" (explicit choice, persisted) - * // Backend changes: backendValue.value = "updated" - * // Result: userValue.value = "custom" (user choice preserved) - * // Reset: userValue.value = undefined; userValue.value = "updated" (follows dynamic again) - * - * @example - * // Route parameter with dynamic default from parent route - * const parentTab = signal("overview"); - * const childTab = stateSignal(parentTab); - * // childTab follows parentTab changes unless explicitly set - */ -const stateSignal = (defaultValue, options = {}) => { - const { - id, - // NOTE: when adding support for a new type here, also update route_pattern.js - // (buildQueryString for encoding, extractSearchParams for decoding) - type, - min, - max, - step, - oneOf, - localStorageRepresentation, - persists = false, - debug, - default: staticFallback, - ignoreArrayOrder, - autoFix, - } = options; - - // Check if defaultValue is a signal (dynamic default) or static value - const isDynamicDefault = - defaultValue && - typeof defaultValue === "object" && - "value" in defaultValue && - "peek" in defaultValue; - const dynamicDefaultSignal = isDynamicDefault ? defaultValue : null; - const staticDefaultValue = isDynamicDefault ? staticFallback : defaultValue; - const signalId = id || generateSignalId(); - // Convert numeric IDs to strings for consistency - const signalIdString = String(signalId); - if (globalSignalRegistry.has(signalIdString)) { - const existingEntry = globalSignalRegistry.get(signalIdString); - { - throw new Error( - `Signal ID conflict: A signal with ID "${signalIdString}" already exists (existing default: ${existingEntry.options.getDefaultValue()}). If this is the same stateSignal() call site running twice, the module was evaluated twice — check the network tab for the same file requested both bare and with "?hot=".`, - ); - } - } - - // Determine localStorage key: use id if persists=true, or legacy localStorage option - const localStorageKey = signalIdString; - const [validity, updateValidity] = createValidity({ - type, - min, - max, - step, - oneOf, - localStorageRepresentation, - autoFix, - }); - const readFromLocalStorage = persists - ? () => { - const raw = window.localStorage.getItem(localStorageKey); - if (raw === null) { - return undefined; - } - return raw; - } - : () => undefined; - const updateLocalStorage = persists - ? () => { - const localStorageValue = validity.representations.localStorage.value; - if (localStorageValue === undefined) { - window.localStorage.removeItem(localStorageKey); - } else { - window.localStorage.setItem(localStorageKey, localStorageValue); - } - } - : () => {}; - const removeFromLocalStorage = persists - ? () => { - window.localStorage.removeItem(localStorageKey); - } - : () => {}; - - /** - * Returns the current default value from code logic only (static or dynamic). - * NEVER considers localStorage - used for URL building and route matching. - * - * @returns {any} The current code default value, undefined if no default - */ - const getDefaultValue = (internalCall) => { - if (dynamicDefaultSignal) { - const dynamicValue = dynamicDefaultSignal.peek(); - if (dynamicValue === undefined) { - if (staticDefaultValue === undefined) { - return undefined; - } - if (debug && internalCall) { - console.debug( - `[stateSignal:${signalIdString}] dynamic default is undefined, using static default=${staticDefaultValue}`, - ); - } - return staticDefaultValue; - } - if (debug && internalCall) { - console.debug( - `[stateSignal:${signalIdString}] using value from dynamic default signal=${dynamicValue}`, - ); - } - return dynamicValue; - } - if (debug && internalCall) { - console.debug( - `[stateSignal:${signalIdString}] using static default value=${staticDefaultValue}`, - ); - } - return staticDefaultValue; - }; - - /** - * Returns fallback value: localStorage first, then code default. - * Used for signal initialization and resets. - * - * @returns {any} The fallback value (localStorage or code default) - */ - const getFallbackValue = () => { - if (persists) { - const valueFromLocalStorage = readFromLocalStorage(); - if (valueFromLocalStorage !== undefined) { - if (debug) { - console.debug( - `[stateSignal:${signalIdString}] using value from localStorage "${localStorageKey}"=${valueFromLocalStorage}`, - ); - } - return valueFromLocalStorage; - } - } - return getDefaultValue(true); - }; - const isCustomValue = (value) => { - if (value === undefined) { - return false; - } - if (dynamicDefaultSignal) { - const dynamicValue = dynamicDefaultSignal.peek(); - if (dynamicValue === undefined) { - return !compareTwoJsValues(value, staticDefaultValue, { - ignoreArrayOrder, - }); - } - return !compareTwoJsValues(value, dynamicValue, { - ignoreArrayOrder, - }); - } - return !compareTwoJsValues(value, staticDefaultValue, { - ignoreArrayOrder, - }); - }; - - // Create signal with initial value: use stored value, or undefined to indicate no explicit value - const processValue = (value) => { - if (value === undefined) { - return undefined; - } - updateValidity(value); - // Always return the coerced value (type coercion applies), even if invalid. - // Invalid values are preserved as-is so the UI can display them and the URL - // can reflect the current input state without silently correcting it. - return validity.value; - }; - const preactSignal = signal( - processValue( - getFallbackValue() - , - ), - ); - - // Override the value setter on the instance to intercept writes and apply processValue. - // We do this on the instance (not the prototype) so preactSignal remains a real Signal - // instance — Preact's JSX integration requires instanceof Signal to render signals as children. - const signalProto = Object.getPrototypeOf(preactSignal); - const valueDescriptor = Object.getOwnPropertyDescriptor(signalProto, "value"); - Object.defineProperty(preactSignal, "value", { - get() { - return valueDescriptor.get.call(preactSignal); - }, - set(newValue) { - const processedValue = processValue(newValue); - // const currentValue = valueDescriptor.get.call(preactSignal); - // if (compareTwoJsValues(processedValue, currentValue)) { - // return; - // } - valueDescriptor.set.call(preactSignal, processedValue); - }, - enumerable: true, - configurable: true, - }); - - const facadeSignal = preactSignal; - facadeSignal.validity = validity; - facadeSignal.validSignal = computed(() => { - // Reading facadeSignal.value establishes the reactive dependency. - // eslint-disable-next-line no-unused-expressions - facadeSignal.value; - return validity.representations.valid?.value; - }); - facadeSignal.__signalId = signalIdString; - facadeSignal.toString = () => `{navi_state_signal:${signalIdString}}`; - // 1. when signal value changes to undefined, it needs to fallback to default value - // 2. when dynamic default changes and signal value is not custom, it needs to update - { - let isFirstRun = true; - effect(() => { - const value = preactSignal.value; - if (isFirstRun) { - isFirstRun = false; - return; - } - if (value !== undefined) { - return; - } - const defaultValue = getDefaultValue(true); - if (defaultValue === value) { - return; - } - if (debug) { - console.debug( - `[stateSignal:${signalIdString}] becomes undefined, reset to ${defaultValue}`, - ); - } - facadeSignal.value = defaultValue; - }); - } - dynamic_signal_effect: { - if (!dynamicDefaultSignal) { - break dynamic_signal_effect; - } - // here we listen only on the dynamic default signal - let isFirstRun = true; - let dynamicDefaultPreviousValue; - effect(() => { - const value = preactSignal.peek(); - const dynamicDefaultValue = dynamicDefaultSignal.value; - if (isFirstRun) { - isFirstRun = false; - dynamicDefaultPreviousValue = dynamicDefaultValue; - return; - } - // Check if current signal value matches the PREVIOUS dynamic default - // If so, it was following the dynamic default and should update - // Special case: if previous was undefined and we were using static fallback - let wasFollowingDefault = false; - if ( - dynamicDefaultPreviousValue === undefined && - staticDefaultValue !== undefined - ) { - // Signal might have been using static fallback - wasFollowingDefault = value === staticDefaultValue; - } else { - // Signal was following the previous dynamic default - wasFollowingDefault = value === dynamicDefaultPreviousValue; - } - - if (!wasFollowingDefault) { - // Signal has a custom value, don't update even if dynamic default changes - dynamicDefaultPreviousValue = dynamicDefaultValue; - return; - } - - // Signal was using default value, update to new default - const newDefaultValue = getDefaultValue(true); - if (newDefaultValue === value) { - dynamicDefaultPreviousValue = dynamicDefaultValue; - return; - } - if (debug) { - console.debug( - `[stateSignal:${signalIdString}] dynamic default updated, update to ${newDefaultValue}`, - ); - } - dynamicDefaultPreviousValue = dynamicDefaultValue; - facadeSignal.value = newDefaultValue; - }); - } - persist_in_local_storage: { - if (!localStorageKey) { - break persist_in_local_storage; - } - effect(() => { - const value = preactSignal.value; - - if (dynamicDefaultSignal) { - // With dynamic defaults: always persist to preserve user intent - // even when value matches dynamic defaults that may change - if (value !== undefined) { - if (debug) { - console.debug( - `[stateSignal:${signalIdString}] dynamic default: writing to localStorage "${localStorageKey}"=${value}`, - ); - } - updateLocalStorage(); - } - return; - } - // Static defaults: only persist custom values - if (isCustomValue(value)) { - if (debug) { - console.debug( - `[stateSignal:${signalIdString}] writing into localStorage "${localStorageKey}"=${value}`, - ); - } - updateLocalStorage(); - } else { - if (debug) { - console.debug( - `[stateSignal:${signalIdString}] removing "${localStorageKey}" from localStorage (value=${value})`, - ); - } - removeFromLocalStorage(); - } - }); - } - // Create isDefaultValue function for this signal - const isDefaultValue = (value) => { - const currentDefault = getDefaultValue(false); - return value === currentDefault; - }; - - // Store signal with its options (used by route_pattern.js) - const effectiveOptions = { - staticDefaultValue, - getDefaultValue, - dynamicDefaultSignal, - isCustomValue, - isDefaultValue, - type, - step, - min, - max, - persists, - localStorageKey, - debug, - ...options, - }; - globalSignalRegistry.set(signalIdString, { - signal: facadeSignal, - options: effectiveOptions, - }); - facadeSignal.options = effectiveOptions; - if (debug) { - console.debug( - `[stateSignal:${signalIdString}] created with initial value=${facadeSignal.value}`, - { - staticDefaultValue, - hasDynamicDefault: Boolean(dynamicDefaultSignal), - hasStoredValue: persists && readFromLocalStorage() !== undefined, - persists, - localStorageKey: persists ? localStorageKey : undefined, - }, - ); - } - - return facadeSignal; -}; - -/** - * Creates a signal that stays synchronized with an external value, - * only updating the signal when the value actually changes. - * - * This hook solves a common reactive UI pattern where: - * 1. A signal controls a UI element (like an input field) - * 2. The UI element can be modified by user interaction - * 3. When the external "source of truth" changes, it should take precedence - * - * @param {any} value - The external value to sync with (the "source of truth") - * @param {any} [initialValue] - Optional initial value for the signal (defaults to value) - * @returns {Signal} A signal that tracks the external value but allows temporary local changes - * - * @example - * const FileNameEditor = ({ file }) => { - * // Signal stays in sync with file.name, but allows user editing - * const nameSignal = useSignalSync(file.name); - * - * return ( - * - * ); - * }; - * - * // Scenario: - * // 1. file.name = "doc.txt", nameSignal.value = "doc.txt" - * // 2. User types "report" -> nameSignal.value = "report.txt" - * // 3. External update: file.name = "shared-doc.txt" - * // 4. Next render: nameSignal.value = "shared-doc.txt" (model wins!) - * - */ - -const useSignalSync = (value, initialValue = value) => { - const signal = useSignal(initialValue); - const previousValueRef = useRef(value); - - // Only update signal when external value actually changes - // This preserves user input between external changes - if (previousValueRef.current !== value) { - previousValueRef.current = value; - signal.value = value; // Model takes precedence - } - - return signal; -}; - -/** - * Picks the best initial value from three options using a simple priority system. - * - * @param {any} externalValue - Value from props or parent component - * @param {any} fallbackValue - Backup value if external value isn't useful - * @param {any} defaultValue - Final fallback (usually empty/neutral value) - * - * @returns {any} The chosen value using this priority: - * 1. externalValue (if provided and different from default) - * 2. fallbackValue (if external value is missing/same as default) - * 3. defaultValue (if nothing else works) - * - * @example - * resolveInitialValue("hello", "backup", "") → "hello" - * resolveInitialValue(undefined, "backup", "") → "backup" - * resolveInitialValue("", "backup", "") → "backup" (empty same as default) - */ -const resolveInitialValue = ( - externalValue, - fallbackValue, - defaultValue, -) => { - if (externalValue !== undefined && externalValue !== defaultValue) { - return externalValue; - } - if (fallbackValue !== undefined) { - return fallbackValue; - } - return defaultValue; -}; - -/** - * Hook that syncs external value changes to a setState function. - * Always syncs when external value changes, regardless of what it changes to. - * - * @param {any} externalValue - Value from props or parent component to watch for changes - * @param {any} defaultValue - Default value to use when external value is undefined - * @param {Function} setValue - Function to call when external value changes - * @param {string} name - Parameter name for debugging - */ -const useExternalValueSync = ( - externalValue, - defaultValue, - setValue, - name = "", -) => { - // Track external value changes and sync them - const previousExternalValueRef = useRef(externalValue); - if (externalValue !== previousExternalValueRef.current) { - previousExternalValueRef.current = externalValue; - // Always sync external value changes - use defaultValue only when external is undefined - const valueToSet = - externalValue === undefined ? defaultValue : externalValue; - setValue(valueToSet); - } -}; - -const FIRST_MOUNT = {}; -const useStateArray = ( - externalValue, - fallbackValue, - defaultValue = [], -) => { - const initialValueRef = useRef(FIRST_MOUNT); - if (initialValueRef.current === FIRST_MOUNT) { - const initialValue = resolveInitialValue( - externalValue, - fallbackValue, - defaultValue, - ); - initialValueRef.current = initialValue; - } - const initialValue = initialValueRef.current; - const [array, setArray] = useState(initialValue); - - // Only sync external value changes if externalValue was explicitly provided - useExternalValueSync(externalValue, defaultValue, setArray, "state_array"); - - const add = useCallback((valueToAdd) => { - setArray((array) => { - const newArray = addIntoArray(array, valueToAdd); - return newArray; - }); - }, []); - - const remove = useCallback((valueToRemove) => { - setArray((array) => { - return removeFromArray(array, valueToRemove); - }); - }, []); - - const reset = useCallback(() => { - setArray(initialValue); - }, [initialValue]); - - return [array, add, remove, reset]; -}; - -const valueInLocalStorage = (key, { type = "any" } = {}) => { - const converter = TYPE_CONVERTERS[type]; - - const get = () => { - let valueInLocalStorage = window.localStorage.getItem(key); - if (valueInLocalStorage === null) { - return undefined; - } - let valueToReturn = valueInLocalStorage; - if (converter && converter.decode) { - try { - const valueDecoded = converter.decode(valueInLocalStorage); - valueToReturn = valueDecoded; - } catch (e) { - console.error(`Error decoding localStorage "${key}" value:`, e); - return undefined; - } - } - if (type !== "any" && typeof valueToReturn !== type) { - console.warn( - `localStorage "${key}" value is invalid: should be a "${type}", got ${valueInLocalStorage}`, - ); - return undefined; - } - return valueToReturn; - }; - - const set = (value) => { - if (value === undefined) { - window.localStorage.removeItem(key); - return; - } - let valueToStore = value; - if (converter && converter.encode) { - const valueEncoded = converter.encode(valueToStore); - valueToStore = valueEncoded; - } - window.localStorage.setItem(key, valueToStore); - }; - const remove = () => { - window.localStorage.removeItem(key); - }; - - return [get, set, remove]; -}; - -const TYPE_CONVERTERS = { - any: { - decode: (valueFromLocalStorage) => JSON.parse(valueFromLocalStorage), - encode: (value) => JSON.stringify(value), - }, - boolean: { - decode: (valueFromLocalStorage) => { - if ( - valueFromLocalStorage === "true" || - valueFromLocalStorage === "on" || - valueFromLocalStorage === "1" - ) { - return true; - } - return false; - }, - encode: (value) => { - return value ? "true" : "false"; - }, - }, - number: { - decode: (valueFromLocalStorage) => { - const valueParsed = parseFloat(valueFromLocalStorage); - return valueParsed; - }, - }, - array: { - decode: (valueFromLocalStorage) => { - const valueParsed = JSON.parse(valueFromLocalStorage); - if (!Array.isArray(valueParsed)) { - throw new Error(`Expected an array, got ${valueParsed}`); - } - return valueParsed; - }, - encode: (value) => { - if (!Array.isArray(value)) { - throw new Error(`Expected an array, got ${value}`); - } - const valueStringified = JSON.stringify(value); - return valueStringified; - }, - }, - object: { - decode: (valueFromLocalStorage) => { - const valueParsed = JSON.parse(valueFromLocalStorage); - return valueParsed; - }, - encode: (value) => { - const valueStringified = JSON.stringify(value); - return valueStringified; - }, - }, -}; - -const promiseStateWeakMap = new WeakMap(); -const usePromiseAsyncData = ( - promise, - { loadingEffect, errorEffect }, -) => { - const forceRender = useForceRender(); - - let promiseState = promiseStateWeakMap.get(promise); - if (!promiseState) { - promiseState = { - data: undefined, - error: undefined, - settled: false, - }; - promiseStateWeakMap.set(promise, promiseState); - promise.then( - (data) => { - promiseState.data = data; - promiseState.settled = true; - forceRender(); - }, - (error) => { - promiseState.error = error; - promiseState.settled = true; - forceRender(); - }, - ); - } - if (!promiseState.settled) { - if (loadingEffect === "use") { - return [promiseState.data, true, undefined]; - } - throw promise; - } - if (promiseState.error) { - if (errorEffect === "use") { - return [promiseState.data, false, promiseState.error]; - } - throw promiseState.error; - } - return [promiseState.data, false, undefined]; -}; - -const useForceRender = () => { - const [, setState] = useState(null); - return () => { - setState({}); - }; -}; - -// https://github.com/preactjs/preact/issues/4756 - -const useAsyncData = (promiseOrAction, { - loading = "delegate", - error = "delegate" -} = {}) => { - const isAction = Boolean(promiseOrAction && promiseOrAction.isAction); - if (loading === true) { - loading = "use"; - } - if (error === true) { - error = "use"; - } - if (isAction) { - return useActionAsyncData(promiseOrAction, { - loadingEffect: loading, - errorEffect: error - }); - } - return usePromiseAsyncData(promiseOrAction, { - loadingEffect: loading, - errorEffect: error - }); -}; - -// ─── useAction ──────────────────────────────────────────────────────────────── - -const LoadingContext$1 = createContext(null); -const actionPendingPromiseWeakMap = new WeakMap(); -const dismissedActionWeakSet = new WeakSet(); -const dismissedActionPendingPromiseWeakMap = new WeakMap(); -const useActionAsyncData = (action, { - loadingEffect, - errorEffect -}) => { - const loadingRef = useContext(LoadingContext$1); - if (!loadingRef) { - throw new Error("Missing "); - } - - // Use peek() instead of .value to avoid subscribing this component to the signal. - // Reading .value would make Preact re-render the component reactively when the state - // changes. When the action fails while Suspense is still holding the detached stale - // DOM, this reactive re-render causes Suspense to move that stale DOM permanently - // back into the document — the stale content then coexists with the error fallback - // and never goes away. Manual subscription via useEffect + useState ensures - // re-renders only happen after the pending promise resolves, at which point Suspense - // has already processed the settlement and the detached DOM is discarded. - const runningState = action.runningStateSignal.peek(); - const [, setTick] = useState(0); - useEffect(() => { - return action.runningStateSignal.subscribe(state => { - if (state === RUNNING) { - dismissedActionWeakSet.delete(action); - } - setTick(n => n + 1); - }); - }, []); - if (runningState === COMPLETED) { - return [action.dataSignal.peek(), false, undefined]; - } - if (runningState === FAILED) { - if (dismissedActionWeakSet.has(action)) { - const staleData = action.dataSignal.peek(); - if (staleData !== undefined) { - // Dismissed with stale data — return it so children render normally - return [staleData, false, undefined]; - } - // Dismissed with no data — suspend until the action re-runs. - // A never-resolving promise would leave the component stuck forever, - // so we use an action-specific promise that resolves on RUNNING, - // which lets the component re-render and go through the normal loading path. - let dismissedPromise = dismissedActionPendingPromiseWeakMap.get(action); - if (!dismissedPromise) { - dismissedPromise = new Promise(resolve => { - const unsubscribe = action.runningStateSignal.subscribe(state => { - if (state === RUNNING) { - dismissedActionPendingPromiseWeakMap.delete(action); - unsubscribe(); - resolve(); - } - }); - }); - dismissedActionPendingPromiseWeakMap.set(action, dismissedPromise); - } - throw dismissedPromise; - } - const actionError = action.errorSignal.peek(); - if (errorEffect === "use") { - const dismissError = () => { - dismissedActionWeakSet.add(action); - setTick(n => n + 1); - }; - return [undefined, false, actionError, dismissError]; - } - actionError.action = action; - throw actionError; - } - - // RUNNING with loadingEffect: "use" — return stale data + loading flag, no suspend - if (loadingEffect === "use" && runningState === RUNNING) { - const staleData = action.dataSignal.peek(); - return [staleData, true, undefined]; - } - - // IDLE or RUNNING with loadingEffect: "delegate" — suspend - const reason = runningState === RUNNING ? "loading" : "idle"; - loadingRef.current = { - reason, - action - }; - let pendingPromise = actionPendingPromiseWeakMap.get(action); - if (!pendingPromise) { - pendingPromise = new Promise(resolve => { - const unsubscribe = action.runningStateSignal.subscribe(state => { - if (state === COMPLETED || state === FAILED) { - actionPendingPromiseWeakMap.delete(action); - unsubscribe(); - resolve(); - } else if (reason === "idle" && state === RUNNING) { - // idle→running: unblock so loadingRef reason updates to "loading" - actionPendingPromiseWeakMap.delete(action); - unsubscribe(); - resolve(); - } - }); - }); - actionPendingPromiseWeakMap.set(action, pendingPromise); - } - throw pendingPromise; -}; - -// ─── Loading ────────────────────────────────────────────────────────────────── -// Wraps Suspense. Provides LoadingContext so useAction can write the suspension -// reason. LoadingFallback reads that reason and subscribes to the action so it -// only shows the spinner when actually loading (not in the initial idle state). -const Loading = ({ - children, - fallback -}) => { - const loadingRef = useRef({ - reason: "idle", - action: null - }); - return jsx(LoadingContext$1.Provider, { - value: loadingRef, - children: jsx(Suspense, { - fallback: jsx(LoadingFallback, { - loadingRef: loadingRef, - fallback: fallback - }), - children: children - }) - }); -}; -const LoadingFallback = ({ - loadingRef, - fallback -}) => { - const [, setTick] = useState(0); - const { - action - } = loadingRef.current; - useEffect(() => { - if (!action) { - return undefined; - } - return action.runningStateSignal.subscribe(() => { - setTick(n => n + 1); - }); - }, [action]); - if (loadingRef.current.reason !== "loading") { - return null; - } - if (typeof fallback === "function") { - return h(fallback); - } - return fallback; -}; - -// ─── ErrorBoundary ──────────────────────────────────────────────────────────── -// Catches errors thrown by useAction. Subscribes to error.action so it -// auto-resets when the action runs again. -const ErrorBoundary = ({ - children, - fallback, - onReset -}) => { - const [error, resetError] = useErrorBoundary(); - const [dismissed, setDismissed] = useState(false); - const cleanupRef = useRef(); - useEffect(() => { - return () => { - cleanupRef.current?.(); - }; - }, []); - if (error) { - error.__handled_by__ = ""; // prevent jsenv from displaying it - - const action = error.action; - if (action) { - cleanupRef.current?.(); - cleanupRef.current = action.runningStateSignal.subscribe(state => { - if (state === RUNNING) { - dismissedActionWeakSet.delete(action); - setDismissed(false); - resetError(); - } - }); - const hasStaleData = action && action.dataSignal.peek() !== undefined; - if (dismissed) { - if (hasStaleData) { - // Has stale data — children will render (useAction returns stale value) - return children; - } - } - } else if (dismissed) { - // stop rendering the error - return null; - } - const dismiss = () => { - if (action) { - dismissedActionWeakSet.add(action); - } - onReset?.(); - setDismissed(true); - resetError(); - }; - if (!fallback) { - return null; - } - if (typeof fallback === "function") { - return h(fallback, { - error, - resetError: dismiss - }); - } - return fallback; - } - return children; -}; - -/** - * Creates a function that generates abort signals, automatically cancelling previous requests. - * - * This prevents race conditions when multiple fetch requests are triggered rapidly, - * ensuring only the most recent request completes while canceling outdated ones. - * - * @param {string} [reason="Request superseded"] - Custom reason for the abort signal - * @returns {() => AbortSignal} A function that returns a fresh AbortSignal and cancels the previous one - * - * @example - * // Setup the request canceller - * const cancelPrevious = createRequestCanceller(); - * - * // Use it in sequential fetch operations - * const searchUsers = async (query) => { - * const signal = cancelPrevious(); // Cancels previous search - * const response = await fetch(`/api/users?q=${query}`, { signal }); - * return response.json(); - * }; - * - * // Rapid successive calls - only the last one will complete - * searchUsers("john"); // Will be aborted - * searchUsers("jane"); // Will be aborted - * searchUsers("jack"); // Will complete - * - * @example - * // With custom reason - * const cancelPrevious = createRequestCanceller("Search cancelled"); - */ -const createRequestCanceller = (reason = "Request superseded") => { - let previousAbortController; - return () => { - if (previousAbortController) { - const abortError = new DOMException(reason, "AbortError"); - abortError.isHandled = true; - previousAbortController.abort(abortError); - } - previousAbortController = new AbortController(); - return previousAbortController.signal; - }; -}; -window.addEventListener("unhandledrejection", (event) => { - if (event.reason?.isHandled) { - event.preventDefault(); // 💥 empêche les "uncaught rejection" devtools pour nos cancellations - } -}); - -const useCancelPrevious = () => { - const cancellerRef = useRef(); - if (!cancellerRef.current) { - cancellerRef.current = createRequestCanceller(); - } - const canceller = cancellerRef.current; - return canceller; -}; - -const moveArrayItemByIndex = (array, indexA, indexB) => { - const newArray = []; - const movedItem = array[indexA]; - const movingRight = indexA < indexB; - - for (let i = 0; i < array.length; i++) { - if (movingRight) { - // Moving right: add target first, then moved item after - if (i !== indexA) { - newArray.push(array[i]); - } - if (i === indexB) { - newArray.push(movedItem); - } - } else { - // Moving left: add moved item first, then target after - if (i === indexB) { - newArray.push(movedItem); - } - if (i !== indexA) { - newArray.push(array[i]); - } - } - } - return newArray; -}; - -const swapArrayItemByIndex = (array, indexA, indexB) => { - const newArray = []; - const itemAtPositionA = array[indexA]; - const itemAtPositionB = array[indexB]; - for (let i = 0; i < array.length; i++) { - if (i === indexB) { - // At the new position, put the dragged column - newArray.push(itemAtPositionA); - continue; - } - if (i === indexA) { - // At the old position, put what was at the new position - newArray.push(itemAtPositionB); - continue; - } - // Everything else stays the same - newArray.push(array[i]); - } - return newArray; -}; - -/** - * Merges a component's base className with className received from props. - * - * ```jsx - * const MyButton = ({ className, children }) => ( - * - * {children} - * - * ); - * - * // Usage: - * // Results in "btn primary large" - * // Results in "btn" - * ``` - * - * @param {string} baseClassName - The component's base CSS class name - * @param {string} [classNameFromProps] - Additional className from props (optional) - * @returns {string} The merged className string - */ -const withPropsClassName = (baseClassName, classNameFromProps) => { - if (!classNameFromProps) { - return baseClassName; - } - - // Trim and normalize whitespace from the props className - const trimmedPropsClassName = classNameFromProps.trim(); - if (!trimmedPropsClassName) { - return baseClassName; - } - - // Normalize multiple spaces to single spaces and combine - const normalizedPropsClassName = trimmedPropsClassName.replace(/\s+/g, " "); - if (!baseClassName) { - return normalizedPropsClassName; - } - return `${baseClassName} ${normalizedPropsClassName}`; -}; - -const BoxFlowContext = createContext(); - -const PASS_THROUGH = { name: "pass_through" }; -const applyOnCSSProp = (cssStyle) => { - return (value) => { - return { [cssStyle]: value }; - }; -}; -const applyOnTwoCSSProps = (cssStyleA, cssStyleB) => { - return (value) => { - return { - [cssStyleA]: value, - [cssStyleB]: value, - }; - }; -}; -const applyOnFourCSSProps = (cssStyleA, cssStyleB, cssStyleC, cssStyleD) => { - return (value) => { - return { - [cssStyleA]: value, - [cssStyleB]: value, - [cssStyleC]: value, - [cssStyleD]: value, - }; - }; -}; -const applyToCssPropWhenTruthy = ( - cssProp, - cssPropValue, - cssPropValueOtherwise, -) => { - return (value, styleContext) => { - if (value) { - return { [cssProp]: cssPropValue }; - } - if (cssPropValueOtherwise === undefined) { - return null; - } - if (value === undefined) { - return null; - } - if (styleContext.styles[cssProp] !== undefined) { - // keep any value previously set - return null; - } - return { [cssProp]: cssPropValueOtherwise }; - }; -}; -const applyOnTwoProps = (propA, propB) => { - return (value, context) => { - const firstProp = All_PROPS[propA]; - const secondProp = All_PROPS[propB]; - const firstPropResult = firstProp(value, context); - const secondPropResult = secondProp(value, context); - if (firstPropResult && secondPropResult) { - return { - ...firstPropResult, - ...secondPropResult, - }; - } - return firstPropResult || secondPropResult; - }; -}; - -// How much of the free space an expanding item claims next to its siblings: -// expand={30} between four expand={18} is the wide one in the middle. A bare -// `expand` claims one share, like everyone else asking for their part. -const expandWeight = (value) => { - if (value === true || value === "") { - return 1; - } - return value; -}; - -const LAYOUT_PROPS = { - // all are handled by navi-attributes - inline: () => {}, - block: () => {}, - flex: () => {}, - flexWrap: applyToCssPropWhenTruthy("flexWrap", "wrap", "nowrap"), - grid: () => {}, - gridTemplateColumns: PASS_THROUGH, - display: PASS_THROUGH, // in case people write "display: none" (even if hidden prop is recommended) - row: () => {}, - column: () => {}, -}; -const OUTER_PROPS = { - // expanded into longhands (not PASS_THROUGH) so the shorthand "margin" CSS - // property is never written to the DOM: setting element.style.margin resets - // all four margin-* longhands, which would silently wipe out an explicit - // marginLeft/marginRight/marginTop/marginBottom applied alongside it. - margin: applyOnFourCSSProps( - "marginTop", - "marginRight", - "marginBottom", - "marginLeft", - ), - marginLeft: PASS_THROUGH, - marginRight: PASS_THROUGH, - marginTop: PASS_THROUGH, - marginBottom: PASS_THROUGH, - marginX: applyOnTwoCSSProps("marginLeft", "marginRight"), - marginY: applyOnTwoCSSProps("marginTop", "marginBottom"), - - // not really related to flow but should be on the container element if any - pointerEvents: PASS_THROUGH, - viewTransitionName: PASS_THROUGH, - viewTransitionClass: PASS_THROUGH, -}; -const INNER_PROPS = { - // expanded into longhands for the same reason as "margin" above: the - // shorthand would otherwise reset paddingLeft/Right/Top/Bottom when both - // are applied on the same element (e.g. padding="xs" paddingLeft="m"). - padding: applyOnFourCSSProps( - "paddingTop", - "paddingRight", - "paddingBottom", - "paddingLeft", - ), - paddingLeft: PASS_THROUGH, - paddingRight: PASS_THROUGH, - paddingTop: PASS_THROUGH, - paddingBottom: PASS_THROUGH, - paddingX: applyOnTwoCSSProps("paddingLeft", "paddingRight"), - paddingY: applyOnTwoCSSProps("paddingTop", "paddingBottom"), -}; -const hasWidthHeight = (context) => { - return ( - (context.styles.width || context.remainingProps.width) && - (context.styles.height || context.remainingProps.height) - ); -}; -const DIMENSION_PROPS = { - boxSizing: PASS_THROUGH, - width: PASS_THROUGH, - minWidth: PASS_THROUGH, - maxWidth: PASS_THROUGH, - height: PASS_THROUGH, - minHeight: PASS_THROUGH, - maxHeight: PASS_THROUGH, - fieldSizing: PASS_THROUGH, - square: (v, context) => { - if (!v) { - return null; - } - if (hasWidthHeight(context)) { - // width/height are defined, remove aspect ratio, we explicitely allow rectanglular shapes - return null; - } - return { - aspectRatio: "1/1", - }; +// Constraint validation messages — override any key to customize error messages +naviI18n.addAll({ + "constraint.available": { + fr: '"[value]" est utilisé. Veuillez entrer une autre valeur.', + en: '"[value]" is already taken. Please enter a different value.', }, - circle: (v, context) => { - if (!v) { - return null; - } - return { - aspectRatio: hasWidthHeight(context) ? undefined : "1/1", - borderRadius: "100%", - }; + "constraint.required.date": { + fr: "Veuillez sélectionner une date.", + en: "Please select a date.", }, - aspectRatio: PASS_THROUGH, - expand: applyOnTwoProps("expandX", "expandY"), - shrink: applyOnTwoProps("shrinkX", "shrinkY"), - // apply after width/height to override if both are set - expandX: (value, { parentBoxFlow }) => { - if (!value) { - return null; - } - const inHorizontalFlexFlow = - parentBoxFlow === "flex-x" || parentBoxFlow === "inline-flex-x"; - if (inHorizontalFlexFlow) { - if (value === "content") { - // flex-basis stays auto: the item still takes the free space, but its - // content size is what line-breaking sees — so in a flexWrap parent a - // neighbor that no longer fits wraps to the next line. With basis 0% - // (below) nothing ever wraps: every item claims a size of zero. - return { flexGrow: 1 }; - } - // Parent is flex-x: grow as flex item - return { flexGrow: expandWeight(value), flexBasis: "0%" }; - } - if (parentBoxFlow === "flex-y" || parentBoxFlow === "inline-flex-y") { - return { - alignSelf: "stretch", - width: "100%", - }; - } - // Can't use flexGrow — parent is not flex-x - return { width: "100%" }; + "constraint.required.month": { + fr: "Veuillez sélectionner un mois.", + en: "Please select a month.", }, - expandY: (value, { parentBoxFlow }) => { - if (!value) { - return null; - } - const inVerticalFlexFlow = - parentBoxFlow === "flex-y" || parentBoxFlow === "inline-flex-y"; - if (inVerticalFlexFlow) { - if (value === "content") { - // Same as expandX="content": grow from the content size, so a - // flexWrap parent can wrap. - return { flexGrow: 1 }; - } - // Parent is flex-y: grow as flex item - return { flexGrow: expandWeight(value), flexBasis: "0%" }; - } - if (parentBoxFlow === "flex-x" || parentBoxFlow === "inline-flex-x") { - return { - alignSelf: "stretch", - }; - } - // Can't use flexGrow — parent is not flex-y - return { height: "100%" }; + "constraint.required.week": { + fr: "Veuillez sélectionner une semaine.", + en: "Please select a week.", }, - shrinkX: (value) => { - if (!value || value === "0") { - return { flexShrink: 0 }; - } - return { flexShrink: 1, minWidth: 0 }; + "constraint.required.time": { + fr: "Veuillez sélectionner une heure.", + en: "Please select a time.", }, - shrinkY: (value) => { - if (!value || value === "0") { - return { flexShrink: 0 }; - } - return { flexShrink: 1, minHeight: 0 }; + "constraint.required.number": { + fr: "Veuillez saisir un nombre.", + en: "Please enter a number.", }, - - scaleX: (value) => { - return { transform: `scaleX(${stringifyStyle(value, "scaleX")})` }; + "constraint.required.datetime": { + fr: "Veuillez sélectionner une date et une heure.", + en: "Please select a date and time.", }, - scaleY: (value) => { - return { transform: `scaleY(${stringifyStyle(value, "scaleY")})` }; + "constraint.required.color": { + fr: "Veuillez sélectionner une couleur.", + en: "Please select a color.", }, - scale: (value) => { - if (Array.isArray(value)) { - const [x, y] = value; - return { transform: `scale(${x}, ${y})` }; - } - return { transform: `scale(${value})` }; + "constraint.required.file": { + fr: "Veuillez sélectionner un fichier.", + en: "Please select a file.", }, - scaleZ: (value) => { - return { transform: `scaleZ(${value})` }; + "constraint.required.file.multiple": { + fr: "Veuillez sélectionner au moins un fichier.", + en: "Please select at least one file.", }, -}; -const POSITION_PROPS = { - // For row, selfAlignX uses auto margins for positioning - // NOTE: Auto margins only work effectively for positioning individual items. - // When multiple adjacent items have the same auto margin alignment (e.g., selfAlignX="end"), - // only the first item will be positioned as expected because subsequent items - // will be positioned relative to the previous item's margins, not the container edge. - selfAlignX: (value, { parentBoxFlow }) => { - const inGridFlow = - parentBoxFlow === "grid" || parentBoxFlow === "inline-grid"; - if (inGridFlow) { - return { justifySelf: value }; - } - - const inVerticalFlexFlow = - parentBoxFlow === "flex-y" || parentBoxFlow === "inline-flex-y"; - if (value === "start") { - if (inVerticalFlexFlow) { - return { alignSelf: "start" }; - } - return { marginRight: "auto" }; - } - if (value === "end") { - if (inVerticalFlexFlow) { - return { alignSelf: "end" }; - } - return { marginLeft: "auto" }; - } - if (value === "center") { - if (inVerticalFlexFlow) { - return { alignSelf: "center" }; - } - return { marginLeft: "auto", marginRight: "auto" }; - } - if (inVerticalFlexFlow && value !== "stretch") { - return { alignSelf: value }; - } - return undefined; + "constraint.disabled.checkbox": { + fr: "Cette case est désactivée.", + en: "This checkbox is disabled.", }, - selfAlignY: (value, { parentBoxFlow }) => { - const inGridFlow = - parentBoxFlow === "grid" || parentBoxFlow === "inline-grid"; - if (inGridFlow) { - return { alignSelf: value }; - } - - const inHorizontalFlexFlow = - parentBoxFlow === "flex-x" || parentBoxFlow === "inline-flex-x"; - if (value === "start") { - if (inHorizontalFlexFlow) { - return { alignSelf: "start" }; - } - return { marginBottom: "auto" }; - } - if (value === "center") { - if (inHorizontalFlexFlow) { - return { alignSelf: "center" }; - } - return { marginTop: "auto", marginBottom: "auto" }; - } - if (value === "end") { - if (inHorizontalFlexFlow) { - return { alignSelf: "end" }; - } - return { marginTop: "auto" }; - } - return undefined; + "constraint.disabled.radio": { + fr: "Cette option est désactivée.", + en: "This option is disabled.", }, - position: PASS_THROUGH, - absolute: applyToCssPropWhenTruthy("position", "absolute", "static"), - relative: applyToCssPropWhenTruthy("position", "relative", "static"), - fixed: applyToCssPropWhenTruthy("position", "fixed", "static"), - sticky: applyToCssPropWhenTruthy("position", "sticky", "static"), - zIndex: PASS_THROUGH, - order: PASS_THROUGH, - left: (value) => { - return { left: value === true ? 0 : value }; + "constraint.disabled.default": { + fr: "Ce champ est désactivé.", + en: "This field is disabled.", }, - // Allow to write instead of - top: (value) => { - return { top: value === true ? 0 : value }; + "constraint.readonly.button": { + fr: "Cette action n'est pas disponible pour l'instant.", + en: "This action is not available right now.", }, - bottom: (value) => { - return { bottom: value === true ? 0 : value }; + "constraint.readonly.option": { + fr: "Cette option n'est pas disponible.", + en: "This option is not available.", }, - right: (value) => { - return { right: value === true ? 0 : value }; + "constraint.readonly.item": { + fr: "Cet élément n'est pas disponible.", + en: "This item is not available.", }, - inset: (v) => { - if (v === true) { - return { inset: 0, width: "auto", height: "auto" }; - } - return { inset: v }; + "constraint.readonly.default": { + fr: "Cet élément est en lecture seule et ne peut pas être modifié.", + en: "This element is read-only and cannot be modified.", }, - - transform: PASS_THROUGH, - translateX: (value) => { - return { transform: `translateX(${value})` }; + "constraint.readonly.awaiting_change": { + fr: "Cette action attend une modification.", + en: "This action is waiting for a change.", }, - translateY: (value) => { - return { transform: `translateY(${value})` }; + "constraint.busy.button": { + fr: "Cette action est en cours...", + en: "This action is in progress...", }, - translate: (value) => { - if (Array.isArray(value)) { - const [x, y] = value; - return { transform: `translate(${x}, ${y})` }; - } - return { transform: `translate(${stringifyStyle(value, "translateX")})` }; + "constraint.busy.item": { + fr: "Cet élément est en cours de synchronisation.", + en: "This item is being synchronized.", }, - rotateX: (value) => { - return { transform: `rotateX(${value})` }; + "constraint.busy.item.adding": { + fr: "Cet élément est en cours d'ajout.", + en: "This item is being added.", }, - rotateY: (value) => { - return { transform: `rotateY(${value})` }; + "constraint.busy.item.removing": { + fr: "Cet élément est en cours de suppression.", + en: "This item is being removed.", }, - rotateZ: (value) => { - return { transform: `rotateZ(${value})` }; + "constraint.busy.default": { + fr: "Cet élément est occupé.", + en: "This element is busy.", }, - rotate: (value) => { - return { transform: `rotate(${value})` }; + "constraint.one_of.no_match": { + fr: "Aucune suggestion ne correspond à votre saisie.", + en: "No suggestion matches your input.", }, - skewX: (value) => { - return { transform: `skewX(${value})` }; + "constraint.one_of.default": { + fr: "Veuillez choisir une valeur parmi les suggestions.", + en: "Please choose a value from the suggestions.", }, - skewY: (value) => { - return { transform: `skewY(${value})` }; + "constraint.same_as.password": { + fr: "Ce mot de passe doit être identique au précédent.", + en: "This password must match the previous one.", }, - skew: (value) => { - if (Array.isArray(value)) { - const [x, y] = value; - return { transform: `skew(${x}, ${y})` }; - } - return { transform: `skew(${value})` }; + "constraint.same_as.email": { + fr: "Cette adresse e-mail doit être identique a la précédente.", + en: "This email address must match the previous one.", }, -}; -const TYPO_PROPS = { - font: applyOnCSSProp("fontFamily"), - fontFamily: PASS_THROUGH, - fontWeight: PASS_THROUGH, - size: applyOnCSSProp("fontSize"), - fontSize: PASS_THROUGH, - bold: applyToCssPropWhenTruthy("fontWeight", "bold", "normal"), - think: applyToCssPropWhenTruthy("fontWeight", "thin", "normal"), - italic: applyToCssPropWhenTruthy("fontStyle", "italic", "normal"), - underline: applyToCssPropWhenTruthy("textDecoration", "underline", "none"), - underlineStyle: applyOnCSSProp("textDecorationStyle"), - underlineColor: applyOnCSSProp("textDecorationColor"), - textShadow: PASS_THROUGH, - lineHeight: PASS_THROUGH, - color: (value) => { - return { color: resolveColorKeyword(value) }; + "constraint.same_as.default": { + fr: "Ce champ doit être identique au précédent.", + en: "This field must match the previous one.", }, - noWrap: applyToCssPropWhenTruthy("whiteSpace", "nowrap", "normal"), - pre: applyToCssPropWhenTruthy("whiteSpace", "pre", "normal"), - preWrap: applyToCssPropWhenTruthy("whiteSpace", "pre-wrap", "normal"), - preLine: applyToCssPropWhenTruthy("whiteSpace", "pre-line", "normal"), - userSelect: PASS_THROUGH, - capitalize: applyToCssPropWhenTruthy("textTransform", "capitalize", "none"), - uppercase: applyToCssPropWhenTruthy("textTransform", "uppercase", "none"), - lowercase: applyToCssPropWhenTruthy("textTransform", "lowercase", "none"), - letterSpacing: PASS_THROUGH, - maxLines: (value) => { - if (!value) { - return null; - } - if (value === 1 || value === "1") { - return { - overflow: "hidden", - textOverflow: "ellipsis", - overflowWrap: "normal", - }; - } - return { - "overflow": "hidden", - "display": "-webkit-box", - "-webkit-box-orient": "vertical", - "-webkit-line-clamp": value, - }; + "constraint.required.checkbox": { + fr: "Veuillez cocher cette case.", + en: "Please check this box.", }, - overflowEllipsis: (value) => { - if (!value) { - return null; - } - return { - overflow: "hidden", - textOverflow: "ellipsis", - overflowWrap: "normal", - }; + "constraint.required.checkbox_group": { + fr: "Veuillez sélectionner au moins une option.", + en: "Please select at least one option.", }, - lineClamp: (value) => { - if (!value) { - return null; - } - return { - "overflow": "hidden", - "display": "-webkit-box", - "-webkit-box-orient": "vertical", - "-webkit-line-clamp": value, - }; + "constraint.required.radio": { + fr: "Veuillez sélectionner une option.", + en: "Please select an option.", }, - textAlign: PASS_THROUGH, - textBox: PASS_THROUGH, - textBoxTrim: PASS_THROUGH, - textBoxEdge: PASS_THROUGH, - // Bare boolean preset for the CSS "trim-both cap alphabetic" combo: trims - // the invisible space the font adds above/below a line (down to - // cap-height/alphabetic baseline) so text visually hugs its box — the - // combo from MDN's own text-box example, most useful for compact things - // like buttons/badges/labels. Use textBox/textBoxTrim/textBoxEdge - // directly for any other combination. - textBoxCrop: applyToCssPropWhenTruthy("textBox", "trim-both cap alphabetic"), -}; -const VISUAL_PROPS = { - outline: PASS_THROUGH, - outlineStyle: PASS_THROUGH, - outlineColor: PASS_THROUGH, - outlineWidth: PASS_THROUGH, - boxDecorationBreak: PASS_THROUGH, - boxShadow: PASS_THROUGH, - background: PASS_THROUGH, - backgroundColor: PASS_THROUGH, - backgroundImage: PASS_THROUGH, - backgroundSize: PASS_THROUGH, - border: PASS_THROUGH, - borderTop: PASS_THROUGH, - borderLeft: PASS_THROUGH, - borderRight: PASS_THROUGH, - borderBottom: PASS_THROUGH, - borderWidth: PASS_THROUGH, - borderColor: PASS_THROUGH, - borderStyle: PASS_THROUGH, - borderRadius: PASS_THROUGH, - borderTopLeftRadius: PASS_THROUGH, - borderTopRightRadius: PASS_THROUGH, - borderBottomLeftRadius: PASS_THROUGH, - borderBottomRightRadius: PASS_THROUGH, - opacity: PASS_THROUGH, - visibility: PASS_THROUGH, - filter: PASS_THROUGH, - cursor: PASS_THROUGH, - transition: PASS_THROUGH, - overflow: PASS_THROUGH, - overflowX: PASS_THROUGH, - overflowY: PASS_THROUGH, - objectFit: PASS_THROUGH, - accentColor: PASS_THROUGH, - scrollbarWidth: PASS_THROUGH, - scrollbarGutter: PASS_THROUGH, - scrollMarginBlock: PASS_THROUGH, - scrollMarginInline: PASS_THROUGH, - scrollMargin: PASS_THROUGH, -}; -const CONTENT_PROPS = { - align: applyOnTwoProps("alignX", "alignY"), - alignX: (value, { boxFlow }) => { - if (boxFlow === "flex-y" || boxFlow === "inline-flex-y") { - if (value === "stretch") { - return undefined; // this is the default - } - return { alignItems: value }; - } - if ( - boxFlow === "flex-x" || - boxFlow === "inline-flex-x" || - // A grid container's inline axis (its own columns) is always the X - // axis regardless of any row/column intent — unlike flex, grid has no - // single main axis, so justify-content here is correct independent of - // the flex-x/flex-y distinction above. See alignY's own matching - // grid branch for the analogous Y-axis case. - boxFlow === "grid" || - boxFlow === "inline-grid" - ) { - if (value === "start") { - return undefined; // this is the default - } - return { justifyContent: value }; - } - return { textAlign: value }; + "constraint.required.password": { + fr: "Veuillez saisir un mot de passe.", + en: "Please enter a password.", + }, + "constraint.required.password.confirm": { + fr: "Veuillez confirmer le mot de passe.", + en: "Please confirm the password.", + }, + "constraint.required.email": { + fr: "Veuillez saisir une adresse e-mail.", + en: "Please enter an email address.", + }, + "constraint.required.email.confirm": { + fr: "Veuillez confirmer l'adresse e-mail.", + en: "Please confirm the email address.", + }, + "constraint.required.confirm": { + fr: "Veuillez confirmer le champ précédent.", + en: "Please confirm the previous field.", + }, + "constraint.required.default": { + fr: "Veuillez remplir ce champ.", + en: "Please fill in this field.", + }, + "constraint.pattern.password": { + fr: "Ce mot de passe ne correspond pas au format requis.", + en: "This password does not match the required format.", + }, + "constraint.pattern.email": { + fr: "Cette adresse e-mail ne correspond pas au format requis.", + en: "This email address does not match the required format.", + }, + "constraint.pattern.default": { + fr: "Ce champ ne correspond pas au format requis.", + en: "This field does not match the required format.", + }, + "constraint.type.email.at": { + fr: 'Veuillez inclure "@" dans l\'adresse e-mail. Il manque un symbole "@" dans [value].', + en: 'Please include "@" in the email address. "@" is missing in [value].', + }, + "constraint.type.email.invalid": { + fr: "Veuillez saisir une adresse e-mail valide.", + en: "Please enter a valid email address.", + }, + "constraint.min_length.singular.password": { + fr: "Ce mot de passe doit contenir au moins [min] caractère (il contient actuellement un seul caractère).", + en: "This password must contain at least [min] character (it currently contains only one character).", + }, + "constraint.min_length.singular.email": { + fr: "Cette adresse e-mail doit contenir au moins [min] caractère (il contient actuellement un seul caractère).", + en: "This email address must contain at least [min] character (it currently contains only one character).", + }, + "constraint.min_length.singular.default": { + fr: "Ce champ doit contenir au moins [min] caractère (il contient actuellement un seul caractère).", + en: "This field must contain at least [min] character (it currently contains only one character).", + }, + "constraint.min_length.plural.password": { + fr: "Ce mot de passe doit contenir au moins [min] caractères (il contient actuellement [count] caractères).", + en: "This password must contain at least [min] characters (it currently contains [count] characters).", + }, + "constraint.min_length.plural.email": { + fr: "Cette adresse e-mail doit contenir au moins [min] caractères (il contient actuellement [count] caractères).", + en: "This email address must contain at least [min] characters (it currently contains [count] characters).", + }, + "constraint.min_length.plural.default": { + fr: "Ce champ doit contenir au moins [min] caractères (il contient actuellement [count] caractères).", + en: "This field must contain at least [min] characters (it currently contains [count] characters).", + }, + "constraint.max_length.password": { + fr: "Ce mot de passe doit contenir au maximum [max] caractères (il contient actuellement [count] caractères).", + en: "This password must contain at most [max] characters (it currently contains [count] characters).", + }, + "constraint.max_length.email": { + fr: "Cette adresse e-mail doit contenir au maximum [max] caractères (il contient actuellement [count] caractères).", + en: "This email address must contain at most [max] characters (it currently contains [count] characters).", }, - alignY: (value, { boxFlow }) => { - if (boxFlow === "flex-y" || boxFlow === "inline-flex-y") { - if (value === "start") { - return undefined; - } - return { justifyContent: value }; - } - if ( - boxFlow === "flex-x" || - boxFlow === "inline-flex-x" || - // A grid container's block axis is always the Y axis regardless of - // any row/column intent (see alignX's own matching comment) — - // align-items controls how each item aligns within its own row - // height, same as flex-x's cross axis. - boxFlow === "grid" || - boxFlow === "inline-grid" - ) { - if (value === "stretch") { - return undefined; - } - return { alignItems: value }; - } - const verticalAlignMap = { - center: "middle", - start: "top", - end: "bottom", - }; - return { - verticalAlign: verticalAlignMap[value] || value, - }; + "constraint.max_length.default": { + fr: "Ce champ doit contenir au maximum [max] caractères (il contient actuellement [count] caractères).", + en: "This field must contain at most [max] characters (it currently contains [count] characters).", }, - spacing: (value, { boxFlow }) => { - if (isSpacingHandledByLayout(boxFlow)) { - return { - gap: stringifySpacingStyle(value, "gap"), - }; - } - return undefined; + "constraint.type.number.default": { + fr: "Ce champ doit être un nombre.", + en: "This field must be a number.", }, - spacingX: (value, { boxFlow }) => { - if (boxFlow === "flex-x" || boxFlow === "inline-flex-x") { - return { - gap: stringifySpacingStyle(value, "gap"), - }; - } - if (boxFlow === "grid" || boxFlow === "inline-grid") { - return { - columnGap: stringifySpacingStyle(value, "columnGap"), - }; - } - return undefined; + "constraint.type.hour.default": { + fr: "Ce champ doit contenir un nombre d'heures.", + en: "This field must contain a number of hours.", }, - spacingY: (value, { boxFlow }) => { - if (boxFlow === "flex-y" || boxFlow === "inline-flex-y") { - return { - gap: stringifySpacingStyle(value, "gap"), - }; - } - if (boxFlow === "grid" || boxFlow === "inline-grid") { - return { - rowGap: stringifySpacingStyle(value, "rowGap"), - }; - } - return undefined; + "constraint.type.minute.default": { + fr: "Ce champ doit contenir un nombre de minutes.", + en: "This field must contain a number of minutes.", }, -}; -const LAYOUT_HANDLING_SPACING_SET = new Set([ - "flex-x", - "flex-y", - "inline-flex-x", - "inline-flex-y", - "grid", - "inline-grid", -]); -const isSpacingHandledByLayout = (boxFlow) => { - return LAYOUT_HANDLING_SPACING_SET.has(boxFlow); -}; - -const All_PROPS = { - ...LAYOUT_PROPS, - ...OUTER_PROPS, - ...INNER_PROPS, - ...DIMENSION_PROPS, - ...POSITION_PROPS, - ...TYPO_PROPS, - ...VISUAL_PROPS, - ...CONTENT_PROPS, -}; -const LAYOUT_PROP_NAME_SET = new Set(Object.keys(LAYOUT_PROPS)); -// const OUTER_PROP_NAME_SET = new Set(Object.keys(OUTER_PROPS)); -const INNER_PROP_NAME_SET = new Set(Object.keys(INNER_PROPS)); -// const DIMENSION_PROP_NAME_SET = new Set(Object.keys(DIMENSION_PROPS)); -// const POSITION_PROP_NAME_SET = new Set(Object.keys(POSITION_PROPS)); -const TYPO_PROP_NAME_SET = new Set(Object.keys(TYPO_PROPS)); -const VISUAL_PROP_NAME_SET = new Set(Object.keys(VISUAL_PROPS)); -const CONTENT_PROP_NAME_SET = new Set(Object.keys(CONTENT_PROPS)); -const STYLE_PROP_NAME_SET = new Set(Object.keys(All_PROPS)); -const SPACING_PROP_SET = new Set([ - "borderRadius", - "spacing", - "spacingX", - "spacingY", - "margin", - "marginLeft", - "marginRight", - "marginTop", - "marginBottom", - "marginX", - "marginY", - "padding", - "paddingLeft", - "paddingRight", - "paddingTop", - "paddingBottom", - "paddingX", - "paddingY", -]); - -const COPIED_ON_VISUAL_CHILD_PROP_SET = new Set([ - ...LAYOUT_PROP_NAME_SET, - "expand", - "expandX", - "expandY", - "shrink", - "shrinkX", - "shrinkY", - "align", - "alignX", - "alignY", - "minWidth", - "minHeight", -]); -const HANDLED_BY_VISUAL_CHILD_PROP_SET = new Set([ - ...INNER_PROP_NAME_SET, - ...VISUAL_PROP_NAME_SET, - ...CONTENT_PROP_NAME_SET, -]); -const getVisualChildStylePropStrategy = (name) => { - if (COPIED_ON_VISUAL_CHILD_PROP_SET.has(name)) { - return "copy"; - } - if (HANDLED_BY_VISUAL_CHILD_PROP_SET.has(name)) { - return "forward"; - } - return null; -}; - -const isStyleProp = (name) => STYLE_PROP_NAME_SET.has(name); - -const getStringifier = (key) => { - if (SPACING_PROP_SET.has(key)) { - return stringifySpacingStyle; - } - if (TYPO_PROP_NAME_SET.has(key)) { - return stringifyTypoStyle; - } - return stringifyStyle; -}; -const stringifySpacingStyle = (size, property = "padding") => { - return normalizeStyle(SIZE_MAP[size] || size, property, "css"); -}; -const stringifyTypoStyle = (size, property = "fontSize") => { - return normalizeStyle(TYPO_SIZE_MAP[size] || size, property, "css"); -}; -const stringifyStyle = ( - value, - name, - // styleContext, context -) => { - return normalizeStyle(value, name, "css"); -}; -const getHowToHandleStyleProp = (name) => { - const getStyle = All_PROPS[name]; - if (getStyle === PASS_THROUGH) { - return null; - } - return getStyle; -}; -const prepareStyleValue = ( - existingValue, - value, - name, - styleContext, - context, -) => { - const stringifier = getStringifier(name); - const cssValue = stringifier(value, name, styleContext, context); - const mergedValue = mergeOneStyle(existingValue, cssValue, name, context); - return mergedValue; -}; - -const negativeEntries = (map) => { - const result = {}; - for (const key of Object.keys(map)) { - result[`-${key}`] = `calc(-1 * ${map[key]})`; - } - return result; -}; - -// Unified design scale using t-shirt sizes with rem units for accessibility. -// This scale is used for spacing to create visual harmony -// and consistent proportions throughout the design system. -const SIZE_MAP = { - xxs: "var(--navi-xxs)", - xs: "var(--navi-xs)", - s: "var(--navi-s)", - m: "var(--navi-m)", - l: "var(--navi-l)", - xl: "var(--navi-xl)", - xxl: "var(--navi-xxl)", -}; -Object.assign(SIZE_MAP, negativeEntries(SIZE_MAP)); -const TYPO_SIZE_MAP = { - xxs: "var(--navi-typo-xxs)", - xs: "var(--navi-typo-xs)", - s: "var(--navi-typo-s)", - m: "var(--navi-typo-m)", - l: "var(--navi-typo-l)", - xl: "var(--navi-typo-xl)", - xxl: "var(--navi-typo-xxl)", -}; -Object.assign(TYPO_SIZE_MAP, negativeEntries(TYPO_SIZE_MAP)); -const sizeSpacingKeySet = new Set(Object.keys(SIZE_MAP)); -const isSizeSpacingKey = (key) => { - return sizeSpacingKeySet.has(key); -}; -// Viewport-relative units, resolved to pixels here because a JS consumer (popup -// positioning) needs an actual number, not a length only CSS can evaluate. -// "vvw"/"vvh" are navi's own: the *visual* viewport, which — unlike vw/dvw — -// shrinks when the mobile virtual keyboard opens (see layout/responsive.js), so -// they are what a popup meant to stay clear of the keyboard should use. -const VIEWPORT_UNIT_SIGNALS = { - vvw: visualViewportWidthSignal, - vvh: visualViewportHeightSignal, - vw: windowWidthSignal, - vh: windowHeightSignal, - dvw: windowWidthSignal, - dvh: windowHeightSignal, -}; -const VIEWPORT_LENGTH_REGEX = /^(-?\d+(?:\.\d+)?)(vvw|vvh|dvw|dvh|vw|vh)$/; -const resolveViewportLength = (size) => { - if (typeof size !== "string") { - return null; - } - const match = VIEWPORT_LENGTH_REGEX.exec(size); - if (!match) { - return null; - } - const [, amount, unit] = match; - return (parseFloat(amount) / 100) * VIEWPORT_UNIT_SIGNALS[unit].value; -}; - -// "3cqw"/"2cqh" — a share of the container the given element lives in, the way -// vvw/vvh are a share of the viewport. The caller passes the element, not the -// container: which box actually contains it is a question with one answer -// (getPositionedParent), and asking every caller to answer it themselves is how -// two of them end up disagreeing. Resolved here rather than left to CSS because -// a caller asking for a number (a placement, a slot) cannot wait for the -// cascade, and a container query unit means nothing to getComputedStyle. -const CONTAINER_LENGTH_REGEX = /^(-?[0-9.]+)cq([wh])$/; -const resolveContainerLength = (size, element) => { - if (typeof size !== "string") { - return null; - } - const match = CONTAINER_LENGTH_REGEX.exec(size.trim()); - if (!match) { - return null; - } - const [, amount, axis] = match; - const container = element - ? getPositionedParent(element) - : document.documentElement; - const containerSize = - axis === "w" ? container.clientWidth : container.clientHeight; - return (parseFloat(amount) / 100) * containerSize; -}; - -const resolveSpacingSize = (size, element, property = "padding") => { - const viewportLength = resolveViewportLength(size); - if (viewportLength !== null) { - return viewportLength; - } - const containerLength = resolveContainerLength(size, element); - if (containerLength !== null) { - return containerLength; - } - return normalizeStyle(SIZE_MAP[size] || size, property, "js", element); -}; - -const COLOR_KEYWORD_MAP = { - secondary: "var(--navi-color-secondary)", - emphasis: "var(--navi-color-emphasis)", - discrete: "var(--navi-color-discrete)", - hint: "var(--navi-color-hint)", -}; -const resolveColorKeyword = (value) => { - return COLOR_KEYWORD_MAP[value] || value; -}; - -const DEFAULT_DISPLAY_BY_TAG_NAME = { - "inline": new Set([ - "a", - "abbr", - "b", - "bdi", - "bdo", - "br", - "cite", - "code", - "dfn", - "em", - "i", - "kbd", - "label", - "mark", - "q", - "s", - "samp", - "small", - "span", - "strong", - "sub", - "sup", - "time", - "u", - "var", - "wbr", - "area", - "audio", - "img", - "map", - "track", - "video", - "embed", - "iframe", - "object", - "picture", - "portal", - "source", - "svg", - "math", - "input", - "meter", - "output", - "progress", - "select", - "textarea", - ]), - "block": new Set([ - "address", - "article", - "aside", - "blockquote", - "div", - "dl", - "fieldset", - "figure", - "footer", - "form", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "header", - "hr", - "main", - "nav", - "ol", - "p", - "pre", - "section", - "table", - "ul", - "video", - "canvas", - "details", - "dialog", - "dd", - "dt", - "figcaption", - "li", - "summary", - "caption", - "colgroup", - "tbody", - "td", - "tfoot", - "th", - "thead", - "tr", - ]), - "inline-block": new Set([ - "button", - "input", - "select", - "textarea", - "img", - "video", - "audio", - "canvas", - "embed", - "iframe", - "object", - ]), - "table-cell": new Set(["td", "th"]), - "table-row": new Set(["tr"]), - "list-item": new Set(["li"]), - "none": new Set([ - "head", - "meta", - "title", - "link", - "style", - "script", - "noscript", - "template", - "slot", - ]), -}; + "constraint.type.second.default": { + fr: "Ce champ doit contenir un nombre de secondes.", + en: "This field must contain a number of seconds.", + }, + "constraint.type.percentage.default": { + fr: "Ce champ doit contenir un pourcentage.", + en: "This field must contain a percentage.", + }, + "constraint.min.number.default": { + fr: "Ce nombre doit être [min] ou plus.", + en: "This number must be [min] or greater.", + }, + "constraint.min.hour.default": { + fr: "Le nombre d'heures doit être [min] ou plus.", + en: "The number of hours must be [min] or greater.", + }, + "constraint.min.minute.default": { + fr: "Le nombre de minutes doit être [min] ou plus.", + en: "The number of minutes must be [min] or greater.", + }, + "constraint.min.second.default": { + fr: "Le nombre de secondes doit être [min] ou plus.", + en: "The number of seconds must be [min] or greater.", + }, + "constraint.min.percentage.default": { + fr: "Le pourcentage doit être [min] ou plus.", + en: "The percentage must be [min] or greater.", + }, + "constraint.min.duration.default": { + fr: "La durée doit être d'au moins [min].", + en: "The duration must be at least [min].", + }, + "constraint.max.duration.default": { + fr: "La durée ne doit pas dépasser [max].", + en: "The duration must not exceed [max].", + }, + "constraint.step.duration.default": { + fr: "La durée doit être un multiple de [step] (par ex. [before] ou [after]).", + en: "The duration must be a multiple of [step] (e.g. [before] or [after]).", + }, + "constraint.min.time.default": { + fr: "L'heure doit être [min] ou plus.", + en: "The time must be [min] or later.", + }, + "constraint.min.date.today.default": { + fr: "La date doit être aujourd'hui ou dans le futur.", + en: "The date must be today or in the future.", + }, + "constraint.min.date.default": { + fr: "La date doit être à partir du [min].", + en: "The date must be on or after [min].", + }, + "constraint.max.date.today.default": { + fr: "La date doit être aujourd'hui ou dans le passé.", + en: "The date must be today or in the past.", + }, + "constraint.max.date.default": { + fr: "La date doit être au plus tard le [max].", + en: "The date must be on or before [max].", + }, + "constraint.max.number.default": { + fr: "Max [max].", + en: "Max [max].", + }, + "constraint.max.hour.default": { + fr: "Max [max] heures.", + en: "Max [max] hours.", + }, + "constraint.max.minute.default": { + fr: "Max [max] minutes.", + en: "Max [max] minutes.", + }, + "constraint.max.second.default": { + fr: "Max [max] secondes.", + en: "Max [max] secondes.", + }, + "constraint.max.percentage.default": { + fr: "Max [max]%.", + en: "Max [max]%.", + }, + "constraint.step.number.default": { + fr: "Ce nombre doit être un multiple de [step] (par ex. [before] ou [after]).", + en: "This number must be a multiple of [step] (e.g. [before] or [after]).", + }, + "constraint.step.hour.default": { + fr: "Le nombre d'heures doit être un multiple de [step] (par ex. [before] ou [after]).", + en: "The number of hours must be a multiple of [step] (e.g. [before] or [after]).", + }, + "constraint.step.minute.default": { + fr: "Le nombre de minutes doit être un multiple de [step] (par ex. [before] ou [after]).", + en: "The number of minutes must be a multiple of [step] (e.g. [before] or [after]).", + }, + "constraint.step.second.default": { + fr: "Le nombre de secondes doit être un multiple de [step] (par ex. [before] ou [after]).", + en: "The number of seconds must be a multiple of [step] (e.g. [before] or [after]).", + }, + "constraint.step.percentage.default": { + fr: "Le pourcentage doit être un multiple de [step] (par ex. [before] ou [after]).", + en: "The percentage must be a multiple of [step] (e.g. [before] or [after]).", + }, + "constraint.step.time.hour": { + fr: "L'heure doit être dans un intervalle de [step] heure(s) (par ex. [before] ou [after]).", + en: "The time must be within an interval of [step] hour(s) (e.g. [before] or [after]).", + }, + "constraint.step.time.minute": { + fr: "L'heure doit être dans un intervalle de [step] minute(s) (par ex. [before] ou [after]).", + en: "The time must be within an interval of [step] minute(s) (e.g. [before] or [after]).", + }, + "constraint.step.time.second": { + fr: "L'heure doit être dans un intervalle de [step] seconde(s) (par ex. [before] ou [after]).", + en: "The time must be within an interval of [step] second(s) (e.g. [before] or [after]).", + }, + "constraint.step.date.default": { + fr: "La date doit correspondre à un intervalle de [step] jour(s) (par ex. [before] ou [after]).", + en: "The date must correspond to an interval of [step] day(s) (e.g. [before] or [after]).", + }, + "constraint.max.time.default": { + fr: "L'heure doit être [max] ou moins.", + en: "The time must be [max] or earlier.", + }, + "constraint.single_space.start.default": { + fr: "Ce champ ne doit pas commencer par un espace.", + en: "This field must not start with a space.", + }, + "constraint.single_space.end.default": { + fr: "Ce champ ne doit pas finir par un espace.", + en: "This field must not end with a space.", + }, + "constraint.single_space.consecutive.default": { + fr: "Ce champ ne doit pas contenir plusieurs espaces consécutifs.", + en: "This field must not contain consecutive spaces.", + }, + "constraint.min_lower_letter.password.singular": { + fr: "Ce mot de passe doit contenir au moins une lettre minuscule.", + en: "This password must contain at least one lowercase letter.", + }, + "constraint.min_lower_letter.password.plural": { + fr: "Ce mot de passe doit contenir au moins [min] lettres minuscules.", + en: "This password must contain at least [min] lowercase letters.", + }, + "constraint.min_lower_letter.default.singular": { + fr: "Ce champ doit contenir au moins une lettre minuscule.", + en: "This field must contain at least one lowercase letter.", + }, + "constraint.min_lower_letter.default.plural": { + fr: "Ce champ doit contenir au moins [min] lettres minuscules.", + en: "This field must contain at least [min] lowercase letters.", + }, + "constraint.min_upper_letter.password.singular": { + fr: "Ce mot de passe doit contenir au moins une lettre majuscule.", + en: "This password must contain at least one uppercase letter.", + }, + "constraint.min_upper_letter.password.plural": { + fr: "Ce mot de passe doit contenir au moins [min] lettres majuscules.", + en: "This password must contain at least [min] uppercase letters.", + }, + "constraint.min_upper_letter.default.singular": { + fr: "Ce champ doit contenir au moins une lettre majuscule.", + en: "This field must contain at least one uppercase letter.", + }, + "constraint.min_upper_letter.default.plural": { + fr: "Ce champ doit contenir au moins [min] lettres majuscules.", + en: "This field must contain at least [min] uppercase letters.", + }, + "constraint.min_digit.password.singular": { + fr: "Ce mot de passe doit contenir au moins un chiffre.", + en: "This password must contain at least one digit.", + }, + "constraint.min_digit.password.plural": { + fr: "Ce mot de passe doit contenir au moins [min] chiffres.", + en: "This password must contain at least [min] digits.", + }, + "constraint.min_digit.default.singular": { + fr: "Ce champ doit contenir au moins un chiffre.", + en: "This field must contain at least one digit.", + }, + "constraint.min_digit.default.plural": { + fr: "Ce champ doit contenir au moins [min] chiffres.", + en: "This field must contain at least [min] digits.", + }, + "constraint.min_special_char.password.singular": { + fr: "Ce mot de passe doit contenir au moins un caractère spécial. ([charset])", + en: "This password must contain at least one special character. ([charset])", + }, + "constraint.min_special_char.password.plural": { + fr: "Ce mot de passe doit contenir au moins [min] caractères spéciaux. ([charset])", + en: "This password must contain at least [min] special characters. ([charset])", + }, + "constraint.min_special_char.default.singular": { + fr: "Ce champ doit contenir au moins un caractère spécial. ([charset])", + en: "This field must contain at least one special character. ([charset])", + }, + "constraint.min_special_char.default.plural": { + fr: "Ce champ doit contenir au moins [min] caractères spéciaux. ([charset])", + en: "This field must contain at least [min] special characters. ([charset])", + }, +}); -// Create a reverse map for quick lookup: tagName -> display value -const TAG_NAME_TO_DEFAULT_DISPLAY = new Map(); -for (const display of Object.keys(DEFAULT_DISPLAY_BY_TAG_NAME)) { - const displayTagnameSet = DEFAULT_DISPLAY_BY_TAG_NAME[display]; - for (const tagName of displayTagnameSet) { - TAG_NAME_TO_DEFAULT_DISPLAY.set(tagName, display); - } -} +// charGuard / maxLengthGuard callout messages +naviI18n.addAll({ + // Preset-specific char messages — more informative than the generic fallback + "constraint.guard.number": { + fr: "Ce champ ne peut contenir que des chiffres.", + en: "This field can only contain digits.", + }, + "constraint.guard.alpha": { + fr: "Ce champ ne peut contenir que des lettres.", + en: "This field can only contain letters.", + }, + "constraint.guard.alphanumeric": { + fr: "Ce champ ne peut contenir que des lettres et des chiffres.", + en: "This field can only contain letters and digits.", + }, + "constraint.guard.uppercase": { + fr: "Ce champ ne peut contenir que des lettres majuscules.", + en: "This field can only contain uppercase letters.", + }, + "constraint.guard.hex": { + fr: "Ce champ ne peut contenir que des chiffres hexadécimaux (0-9, A-F).", + en: "This field can only contain hexadecimal digits (0-9, A-F).", + }, + "constraint.guard.slug": { + fr: "Ce champ ne peut contenir que des lettres minuscules, des chiffres et des tirets.", + en: "This field can only contain lowercase letters, digits, and hyphens.", + }, + // Generic fallback for custom char classes and other presets (tel, card, postal, iban…) + "constraint.guard.chars": { + fr: "Ce champ ne peut contenir que les caractères autorisés.", + en: "This field can only contain allowed characters.", + }, + // maxLength: keydown blocked (one character would exceed the limit) + "constraint.guard.max_length.typing": { + fr: "Longueur maximale de [max] caractère[s] atteinte.", + en: "Maximum length of [max] character[s] reached.", + }, + // maxLength: paste/set truncated to maxLength (autofix always applied) + "constraint.guard.max_length.value": { + fr: "Ce champ ne peut pas contenir plus de [max] caractère[s], une partie a été tronquée.", + en: "This field cannot contain more than [max] character[s]; the value was truncated.", + }, +}); -/** - * Get the default CSS display value for a given HTML tag name - * @param {string} tagName - The HTML tag name (case-insensitive) - * @returns {string} The default display value ("block", "inline", "inline-block", etc.) or "inline" as fallback - * @example - * getDefaultDisplay("div") // "block" - * getDefaultDisplay("span") // "inline" - * getDefaultDisplay("img") // "inline-block" - * getDefaultDisplay("unknown") // "inline" (fallback) - */ -const getDefaultDisplay = (tagName) => { - const normalizedTagName = tagName.toLowerCase(); - return TAG_NAME_TO_DEFAULT_DISPLAY.get(normalizedTagName) || "inline"; -}; +// Date/time placeholder tokens — shown when no value is selected +// Override any key to adapt to your language conventions +naviI18n.addAll({ + "time.placeholder.day": { + fr: "jj", + en: "dd", + de: "TT", + es: "dd", + it: "gg", + pt: "dd", + nl: "dd", + }, + "time.placeholder.month": { + fr: "mm", + en: "mm", + de: "MM", + es: "mm", + it: "mm", + pt: "mm", + nl: "mm", + }, + "time.placeholder.year": { + fr: "aaaa", + en: "yyyy", + de: "JJJJ", + es: "aaaa", + it: "aaaa", + pt: "aaaa", + nl: "jjjj", + }, + "time.placeholder.hour": { + fr: "hh", + en: "hh", + de: "hh", + es: "hh", + it: "hh", + pt: "hh", + nl: "uu", + }, + "time.placeholder.minute": { + fr: "mm", + en: "mm", + de: "mm", + es: "mm", + it: "mm", + pt: "mm", + nl: "mm", + }, + "time.placeholder.week": { + fr: "sem.", + en: "wk", + de: "KW", + es: "sem.", + it: "sett.", + pt: "sem.", + nl: "wk", + }, +}); -/** - * DOM utilities for navigating the control element hierarchy. - * - * A control is a self-contained interactive widget. Its DOM structure can be - * either flat (host only) or layered (wrapper + host): - * - * Flat — the element is both the root and the host: - * ```html - * Click me - * ``` +const FormContext = createContext(); + +/* + * Deep structural equality for arbitrary JS values — what `===` can't do but this + * codebase constantly needs: memoization cache keys ({ id: 1 } equal to { id: 1 }), + * effect/memo dependency checks, signal/store change detection, and action + * parameter deduplication. * - * Layered — a visual wrapper surrounds a native input that is the real host: - * ```html - * ← wrapper: root of the control's DOM subtree - * ← host: holds controlProps, value, UI state, constraints - * - * ``` + * Beyond recursive object/array comparison it covers the edge cases `===` gets + * "wrong" for equality purposes: NaN equals NaN, Date compared by time value, + * cycles don't loop (a seen-set guards circular refs), and same-type is required + * before descending. Cheap paths run first: reference equality, then the identity + * short-circuit below, then array length before element-by-element. * - * Attribute roles: - * - `navi-control` boolean, on the wrapper/root; marks the control boundary - * - `navi-control-host` boolean, on the host; set automatically by `useInteractiveProps` + * SYMBOL_IDENTITY. Two *different* object instances can be declared "conceptually + * the same" by sharing a SYMBOL_IDENTITY value; the comparison then treats them as + * equal with no deep walk. This is what lets a spread copy ({ ...params, extra }) + * still count as the same params as the original, and lets objects reconstructed + * across a serialization boundary be recognized as one entity — the cases where a + * content comparison would be too slow, or too strict to see them as equal. Use + * Symbol.for() so the marker is the same symbol across modules/contexts: * - * See control_proxy.js for the `navi-control-proxy-for` pattern. + * const id = Symbol.for("params"); + * a[SYMBOL_IDENTITY] = id; + * b[SYMBOL_IDENTITY] = id; + * compareTwoJsValues(a, b); // true immediately, no property walk */ -/** - * Returns the host element inside `el` — the element that holds the control's - * value, UI state, and constraints (i.e. the element onto which - * `useInteractiveProps` spreads `controlProps` and its event handlers). - * - * Returns `null` when `el` is itself the host (no separate wrapper). - */ -const findControlHost = (el) => { - if (el.hasAttribute("navi-control-host")) { - return el; - } - return el.querySelector("[navi-control-host]"); -}; -const isControlRoot = (el) => { - return el.hasAttribute("navi-control"); -}; -const isControlHost = (el) => { - return el.hasAttribute("navi-control-host"); -}; +// Marks objects with a conceptual identity that transcends reference equality — +// see the file comment. Symbol.for keeps it one shared symbol across modules. +const SYMBOL_IDENTITY = Symbol.for("navi_object_identity"); /** - * Returns the nearest ancestor of `el` (exclusive of `el`'s own control) that - * has a `[data-action]` attribute. - * - * The search walks up `parentElement` manually (rather than using `.closest()`) - * so it can stop at hard boundaries. - * - * **`[navi-control="picker"]` boundary**: a picker is a hard stop. Elements inside a picker - * (including inside its popover content) can reach the picker itself, but nothing above it. - * This prevents an input inside a picker from accidentally submitting a parent form. + * Deeply compares two values for structural equality. * - * ```html - * ← NOT found (above picker boundary) - * ← found and search stops here - * ← el (in picker button area) - * - * ← el (in picker popover) - * - * - * - * ``` + * @param {any} rootA - First value. + * @param {any} rootB - Second value. + * @param {object} [options] + * @param {(a: any, b: any, keyOrIndex: any, recurse: (a: any, b: any) => boolean) => boolean} [options.keyComparator] + * Custom comparator for object properties / array elements. Receives the internal + * `compare` as its last argument so it can defer to the default behavior. + * @param {boolean} [options.ignoreArrayOrder=false] - Compare arrays as multisets: + * equal when they contain the same elements regardless of order. + * @param {Iterable} [options.lightKeySet] - Object keys to compare first — + * cheaper or likelier-to-differ ones — to short-circuit before the remaining keys. + * @returns {boolean} true if the values are deeply equal. */ -const findClosestControlWithAction = (el) => { - let current = el; - while (current) { - if (current.hasAttribute("data-action")) { - return current; +const compareTwoJsValues = ( + rootA, + rootB, + { keyComparator, ignoreArrayOrder = false, lightKeySet } = {}, +) => { + const seenSet = new Set(); + const compare = (a, b) => { + if (a === b) { + return true; } - // Stop at a picker boundary — nothing above the picker is reachable from within. - if (current.getAttribute("navi-control") === "picker") { - return undefined; + const aIsIsTruthy = Boolean(a); + const bIsTruthy = Boolean(b); + if (aIsIsTruthy && !bIsTruthy) { + return false; } - current = current.parentElement; - } - return undefined; -}; + if (!aIsIsTruthy && !bIsTruthy) { + // null, undefined, 0, false, NaN + if (isNaN(a) && isNaN(b)) { + return true; + } + return a === b; + } + const aType = typeof a; + const bType = typeof b; + if (aType !== bType) { + return false; + } + const aIsPrimitive = + a === null || (aType !== "object" && aType !== "function"); + const bIsPrimitive = + b === null || (bType !== "object" && bType !== "function"); + if (aIsPrimitive !== bIsPrimitive) { + return false; + } + if (aIsPrimitive && bIsPrimitive) { + return a === b; + } + if (seenSet.has(a)) { + return false; + } + if (seenSet.has(b)) { + return false; + } + seenSet.add(a); + seenSet.add(b); + const aIsArray = Array.isArray(a); + const bIsArray = Array.isArray(b); + if (aIsArray !== bIsArray) { + return false; + } + if (aIsArray) { + // compare arrays + if (a.length !== b.length) { + return false; + } + if (ignoreArrayOrder) { + // Unordered array comparison: each element in 'a' must have a match in 'b' + const usedIndices = new Set(); + for (let i = 0; i < a.length; i++) { + const aValue = a[i]; + let foundMatch = false; -/** - * Returns the closest ancestor control element of `el` — i.e. the nearest - * `[navi-control]` element that is not the control `el` belongs to. - * - * `navi-control` is only on wrapper elements, never on hosts. So - * `el.closest("[navi-control]")` from a host returns that host's own wrapper, - * and one more `.parentNode.closest("[navi-control]")` reaches a true ancestor: - * - * ```html - * ← outer control (returned) - * ← inner wrapper (skipped via parentNode) - * ← el - * - * - * ``` - */ -const getParentControl = (el) => { - const ownControlRoot = el.closest("[navi-control]"); - const parentControlRoot = ownControlRoot.parentNode.closest("[navi-control]"); - return parentControlRoot; + for (let j = 0; j < b.length; j++) { + if (usedIndices.has(j)) { + continue; // Already matched with another element + } + const bValue = b[j]; + if (compareAt(aValue, bValue, i)) { + foundMatch = true; + usedIndices.add(j); + break; + } + } + if (!foundMatch) { + return false; + } + } + return true; + } + // Ordered array comparison (original behavior) + let i = 0; + while (i < a.length) { + const aValue = a[i]; + const bValue = b[i]; + if (!compareAt(aValue, bValue, i)) { + return false; + } + i++; + } + return true; + } + // compare objects + const aIdentity = a[SYMBOL_IDENTITY]; + const bIdentity = b[SYMBOL_IDENTITY]; + if ( + aIdentity === bIdentity && + SYMBOL_IDENTITY in a && + SYMBOL_IDENTITY in b + ) { + return true; + } + // Date objects must be compared by time value, not by enumerable keys (which are empty) + { + const aIsDate = a instanceof Date; + const bIsDate = b instanceof Date; + if (aIsDate !== bIsDate) { + return false; + } + if (aIsDate && bIsDate) { + const aTime = a.getTime(); + const bTime = b.getTime(); + if (aTime !== bTime) { + return false; + } + } + } + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return false; + } + if (lightKeySet) { + // compare light keys first, then remaining keys + // (optimization for cases where some keys are more likely to differ and/or faster to compare) + const keySet = new Set(aKeys); + for (const lightKey of lightKeySet) { + const aValue = a[lightKey]; + const bValue = b[lightKey]; + if (!compareAt(aValue, bValue, lightKey)) { + return false; + } + keySet.delete(lightKey); + } + for (const key of keySet) { + const aValue = a[key]; + const bValue = b[key]; + if (!compareAt(aValue, bValue, key)) { + return false; + } + } + } else { + for (const key of aKeys) { + const aValue = a[key]; + const bValue = b[key]; + if (!compareAt(aValue, bValue, key)) { + return false; + } + } + } + return true; + }; + const compareAt = keyComparator + ? (a, b, keyOrArrayIndex) => keyComparator(a, b, keyOrArrayIndex, compare) + : compare; + + return compare(rootA, rootB); }; -/** - * Returns the root element of the control that `el` belongs to, or `null` if - * `el` is not part of a control. - * - * Use this when you have an element that may be a host (inner input) and need - * the visual boundary of its control — e.g. to anchor a callout, track - * mousedown interactions, or measure the control's bounding box. - */ -const findControlRoot = (el) => { - if (el.hasAttribute("navi-control")) { - return el; - } - if (el.hasAttribute("navi-control-host")) { - return el.closest("[navi-control]"); - } - return null; +// Global signal registry for route template detection +const globalSignalRegistry = new Map(); +let signalIdCounter = 0; +const generateSignalId = () => { + const id = signalIdCounter++; + return id; }; /** - * DOM utilities for the proxy control pattern. + * Creates an advanced signal with dynamic default value, local storage persistence, and validation. * - * Some components need a native `` internally — for form submission, - * constraint validation, or browser autofill — but the user may not want to - * display that input at all. In those cases the input is hidden and a separate - * visible element (the proxy) takes over the visual and interactive role. + * The first parameter can be either a static value or a signal acting as a "dynamic default": + * - If a static value: traditional default behavior + * - If a signal: acts as a dynamic default that updates the signal ONLY when no explicit value has been set * - * The typical use case is `SelectableList`: each list item acts as a styled - * radio button, but an actual `` lives hidden in the DOM - * so form submission and validation work natively. When users DO want to - * display the input they want full control over its appearance, so they render - * their own element and link it to the real input via `navi-control-proxy-for`: + * Dynamic default behavior (when first param is a signal): + * 1. Initially takes value from the default signal + * 2. When explicitly set (programmatically or via localStorage), the explicit value takes precedence + * 3. When default signal changes, it only updates if no explicit value was ever set + * 4. Calling reset() or setting to undefined makes the signal use the dynamic default again + * 5. If dynamic default is undefined and options.default is provided, uses the static fallback * - * ```html - * - * ← real control (hidden, drives form/validation) - * ← proxy (visible, delegates interactions to real input) - * - * ``` + * This is useful for: + * - Backend data that can change but shouldn't override user preferences + * - Route parameters with dynamic defaults based on other state + * - Cascading configuration where defaults can be updated without losing user customizations + * - Having a static fallback when dynamic defaults might be undefined * - * When the proxy is interacted with, navi events are forwarded to the real - * control so validation, state management, and form submission all work - * through the real input. + * @param {any|import("@preact/signals").Signal} defaultValue - Static default value OR signal for dynamic default behavior + * @param {Object} [options={}] - Configuration options + * @param {string|number} [options.id] - Custom ID for the signal. If not provided, an auto-generated ID will be used. Used for localStorage key and route pattern detection. + * @param {any} [options.default] - Static fallback value used when defaultValue is a signal and that signal's value is undefined + * @param {boolean} [options.persists=false] - Whether to persist the signal value in localStorage using the signal ID as key + * @param {"string" | "number" | "boolean" | "object"} [options.type="string"] - Type for localStorage serialization/deserialization + * @param {number} [options.step] - For number type: step size for precision. Values will be rounded to nearest multiple of step. + * @param {Array} [options.oneOf] - Array of valid values for validation. Signal will be marked invalid if value is not in this array + * @param {boolean} [options.debug=false] - Enable debug logging for this signal's operations + * @returns {import("@preact/signals").Signal} A signal that can be synchronized with a source signal and/or persisted in localStorage. The signal includes a `validity` property for validation state. * - * Note: an alternative design would be to require users to always instantiate - * the input explicitly — e.g. `` when they don't - * want to display it. That would remove the need for the proxy mechanism - * entirely. For now we keep the proxy pattern. - */ - -/** - * Given a proxy element, returns the real control it represents. - * Returns `null` when `el` is not a proxy. - */ -const findControlProxyTarget = (el) => { - const proxyFor = el.getAttribute("navi-control-proxy-for"); - if (!proxyFor) { - return null; - } - return document.getElementById(proxyFor); -}; - -/** - * Given a real control element, returns the proxy that visually represents it. + * @example + * // Basic signal with default value + * const count = stateSignal(0); * - * Use when you need to update or recheck the proxy's visual state after the - * real control's state changes, or when anchoring a callout to the visible - * element rather than the hidden real input. + * @example + * // Signal with custom ID and persistence + * const theme = stateSignal("light", { + * id: "user-theme", + * persists: true, + * type: "string" + * }); * - * Returns `null` when no proxy exists for `el`. + * @example + * // Signal with validation and auto-fix + * const tab = stateSignal("overview", { + * id: "current-tab", + * oneOf: ["overview", "details", "settings"], + * autoFix: () => "overview", + * persists: true + * }); + * + * @example + * // Dynamic default that doesn't override user choices + * const backendTheme = signal("light"); + * const userTheme = stateSignal(backendTheme, { persists: true }); + * + * // Initially: userTheme.value = "light" (from dynamic default) + * // User sets: userTheme.value = "dark" (explicit choice, persisted) + * // Backend changes: backendTheme.value = "blue" + * // Result: userTheme.value = "dark" (user choice preserved) + * // Reset: userTheme.value = undefined; // Now follows dynamic default again + * + * @example + * // Dynamic default with static fallback + * const backendValue = signal(undefined); // might be undefined initially + * const userValue = stateSignal(backendValue, { + * default: "fallback", + * persists: true + * }); + * + * // Initially: userValue.value = "fallback" (static fallback since dynamic is undefined) + * // Backend loads: backendValue.value = "loaded"; userValue.value = "loaded" (follows dynamic) + * // User sets: userValue.value = "custom" (explicit choice, persisted) + * // Backend changes: backendValue.value = "updated" + * // Result: userValue.value = "custom" (user choice preserved) + * // Reset: userValue.value = undefined; userValue.value = "updated" (follows dynamic again) + * + * @example + * // Route parameter with dynamic default from parent route + * const parentTab = signal("overview"); + * const childTab = stateSignal(parentTab); + * // childTab follows parentTab changes unless explicitly set */ -const findControlProxy = (el) => { - if (!el.id) { - return null; - } - return document.querySelector( - `[navi-control-proxy-for="${CSS.escape(el.id)}"]`, - ); -}; - -const addInputEffect = ( - input, - callback, - { waitForChange = false, debounce = 0, debugInteraction = () => {} } = {}, -) => { - const getState = - input.type === "checkbox" || input.type === "radio" - ? () => input.checked - : () => input.value; +const stateSignal = (defaultValue, options = {}) => { + const { + id, + // NOTE: when adding support for a new type here, also update route_pattern.js + // (buildQueryString for encoding, extractSearchParams for decoding) + type, + min, + max, + step, + oneOf, + localStorageRepresentation, + persists = false, + debug, + default: staticFallback, + ignoreArrayOrder, + autoFix, + } = options; - if (waitForChange) { - return listenInputStateChange(input, callback, { getState }); + // Check if defaultValue is a signal (dynamic default) or static value + const isDynamicDefault = + defaultValue && + typeof defaultValue === "object" && + "value" in defaultValue && + "peek" in defaultValue; + const dynamicDefaultSignal = isDynamicDefault ? defaultValue : null; + const staticDefaultValue = isDynamicDefault ? staticFallback : defaultValue; + const signalId = id || generateSignalId(); + // Convert numeric IDs to strings for consistency + const signalIdString = String(signalId); + if (globalSignalRegistry.has(signalIdString)) { + const existingEntry = globalSignalRegistry.get(signalIdString); + { + throw new Error( + `Signal ID conflict: A signal with ID "${signalIdString}" already exists (existing default: ${existingEntry.options.getDefaultValue()}). If this is the same stateSignal() call site running twice, the module was evaluated twice — check the network tab for the same file requested both bare and with "?hot=".`, + ); + } } - const [teardown, addTeardown] = createPubSub(); - let currentState = getState(); - let timeout; - let debounceTimeout; - let onEvent; - if (debounce) { - onEvent = (e, { skipDebounce } = {}) => { - clearTimeout(timeout); - clearTimeout(debounceTimeout); - const state = getState(); - if (state === currentState) { - debugInteraction( - e, - `interaction ignored (state unchanged: "${state}")`, - ); - return; + // Determine localStorage key: use id if persists=true, or legacy localStorage option + const localStorageKey = signalIdString; + const [validity, updateValidity] = createValidity({ + type, + min, + max, + step, + oneOf, + localStorageRepresentation, + autoFix, + }); + const readFromLocalStorage = persists + ? () => { + const raw = window.localStorage.getItem(localStorageKey); + if (raw === null) { + return undefined; + } + return raw; + } + : () => undefined; + const updateLocalStorage = persists + ? () => { + const localStorageValue = validity.representations.localStorage.value; + if (localStorageValue === undefined) { + window.localStorage.removeItem(localStorageKey); + } else { + window.localStorage.setItem(localStorageKey, localStorageValue); + } + } + : () => {}; + const removeFromLocalStorage = persists + ? () => { + window.localStorage.removeItem(localStorageKey); } + : () => {}; - if (skipDebounce) { - debugInteraction(e, `skip debounce, callback called with "${state}"`); - if (callback(e) !== false) { - currentState = state; + /** + * Returns the current default value from code logic only (static or dynamic). + * NEVER considers localStorage - used for URL building and route matching. + * + * @returns {any} The current code default value, undefined if no default + */ + const getDefaultValue = (internalCall) => { + if (dynamicDefaultSignal) { + const dynamicValue = dynamicDefaultSignal.peek(); + if (dynamicValue === undefined) { + if (staticDefaultValue === undefined) { + return undefined; } - } else { - debugInteraction( - e, - `debounced ${debounce}ms, pending callback with "${state}"`, - ); - debounceTimeout = setTimeout(() => { - debugInteraction( - e, - `debounce elapsed, callback called with "${state}"`, + if (debug && internalCall) { + console.debug( + `[stateSignal:${signalIdString}] dynamic default is undefined, using static default=${staticDefaultValue}`, ); - if (callback(e) !== false) { - currentState = state; - } - }, debounce); + } + return staticDefaultValue; } - }; - // no need to wait for change events (blur, enter key) - // we consider this as strong interactions requesting an immediate response - const stop = listenInputStateChange( - input, - (e) => { - onEvent(e, { skipDebounce: true }); - }, - { getState, changeOnEnter: true }, - ); - addTeardown(() => { - stop(); - }); - } else { - onEvent = (e) => { - clearTimeout(timeout); - const state = getState(); - if (state === currentState) { - debugInteraction( - e, - `interaction ignored (state unchanged: "${state}")`, + if (debug && internalCall) { + console.debug( + `[stateSignal:${signalIdString}] using value from dynamic default signal=${dynamicValue}`, ); - return; - } - debugInteraction(e, `callback called with "${state}"`); - if (callback(e) !== false) { - currentState = state; } - }; - // Autocomplete, programmatic changes, form restoration - input.addEventListener("change", onEvent); - addTeardown(() => { - input.removeEventListener("change", onEvent); - }); - } - - const onAsyncEvent = (e, options) => { - debugInteraction(e, `async event, scheduling callback`); - timeout = setTimeout(() => { - onEvent(e, options); - }, 0); - }; - - // Standard user input (typing) - const onInput = (e) => { - onEvent(e); - }; - input.addEventListener("input", onInput); - addTeardown(() => { - input.removeEventListener("input", onInput); - }); - - // Form reset - need to check the form - const form = input.form; - if (form) { - // Form reset happens asynchronously, check value after reset completes - form.addEventListener("reset", onAsyncEvent); - addTeardown(() => { - form.removeEventListener("reset", onAsyncEvent); - }); - } - - // Paste events (some browsers need special handling) - input.addEventListener("paste", onEvent); - addTeardown(() => { - input.removeEventListener("paste", onEvent); - }); - - input.addEventListener("navi_ui_state_change", (e) => { - // radios are unchecked by an internal setUIState call when another radio is checked. - // navi_ui_state_change is dispatched whenever setUIState changes state, so we - // listen here to keep currentState in sync — otherwise input_effect thinks the - // radio is still checked and ignores the next user click as "state unchanged". - if (input.type === "radio") { - currentState = e.detail.value; + return dynamicValue; } - const clearEvent = findEvent( - e, - (eInChain) => - eInChain.type === "navi_set_ui_state" && eInChain.detail.isClear, - ); - const isClear = Boolean(clearEvent); - if (isClear) { - // "navi_clear" behaves like an async event - // a bit like form reset because - // our action will be updated async after the component re-renders - // and we need to wait that to happen to properly call action with the right value - debugInteraction(e, `navi_clear received, scheduling callback`); - onAsyncEvent(e, { skipDebounce: true }); + if (debug && internalCall) { + console.debug( + `[stateSignal:${signalIdString}] using static default value=${staticDefaultValue}`, + ); } - }); - - return teardown; -}; - -const listenInputStateChange = ( - input, - callback, - { getState, changeOnEnter }, -) => { - const [teardown, addTeardown] = createPubSub(); - - let stateAtInteraction; - const oninput = () => { - stateAtInteraction = undefined; + return staticDefaultValue; }; - const onkeydown = (e) => { - if (e.key === "Enter") { - /** - * Browser trigger a "change" event right after the enter is pressed - * if the input value has changed. - * We need to prevent the next change event otherwise we would request action twice - */ - stateAtInteraction = getState(); - if (changeOnEnter) { - onchange(e); + + /** + * Returns fallback value: localStorage first, then code default. + * Used for signal initialization and resets. + * + * @returns {any} The fallback value (localStorage or code default) + */ + const getFallbackValue = () => { + if (persists) { + const valueFromLocalStorage = readFromLocalStorage(); + if (valueFromLocalStorage !== undefined) { + if (debug) { + console.debug( + `[stateSignal:${signalIdString}] using value from localStorage "${localStorageKey}"=${valueFromLocalStorage}`, + ); + } + return valueFromLocalStorage; } } - if (e.key === "Escape") { - /** - * Browser trigger a "change" event right after the escape is pressed - * if the input value has changed. - * We need to prevent the next change event otherwise we would request action when - * we actually want to cancel - */ - stateAtInteraction = getState(); + return getDefaultValue(true); + }; + const isCustomValue = (value) => { + if (value === undefined) { + return false; + } + if (dynamicDefaultSignal) { + const dynamicValue = dynamicDefaultSignal.peek(); + if (dynamicValue === undefined) { + return !compareTwoJsValues(value, staticDefaultValue, { + ignoreArrayOrder, + }); + } + return !compareTwoJsValues(value, dynamicValue, { + ignoreArrayOrder, + }); } + return !compareTwoJsValues(value, staticDefaultValue, { + ignoreArrayOrder, + }); }; - const onchange = (e) => { - if (stateAtInteraction !== undefined && getState() === stateAtInteraction) { - stateAtInteraction = undefined; - return; + + // Create signal with initial value: use stored value, or undefined to indicate no explicit value + const processValue = (value) => { + if (value === undefined) { + return undefined; } - callback(e); + updateValidity(value); + // Always return the coerced value (type coercion applies), even if invalid. + // Invalid values are preserved as-is so the UI can display them and the URL + // can reflect the current input state without silently correcting it. + return validity.value; }; - input.addEventListener("input", oninput); - input.addEventListener("keydown", onkeydown); - input.addEventListener("change", onchange); - addTeardown(() => { - input.removeEventListener("input", oninput); - input.removeEventListener("keydown", onkeydown); - input.removeEventListener("change", onchange); - }); + const preactSignal = signal( + processValue( + getFallbackValue() + , + ), + ); - { - // Handle programmatic value changes that don't trigger browser change events - // - // Problem: When input values are set programmatically (not by user typing), - // browsers don't fire the 'change' event. However, our application logic - // still needs to detect these changes. - // - // Example scenario: - // 1. User starts editing (letter key pressed, value set programmatically) - // 2. User doesn't type anything additional (this is the key part) - // 3. User clicks outside to finish editing - // 4. Without this code, no change event would fire despite the fact that the input value did change from its original state - // - // This distinction is crucial because: - // - // - If the user typed additional text after the initial programmatic value, - // the browser would fire change events normally - // - But when they don't type anything else, the browser considers it as "no user interaction" - // even though the programmatic initial value represents a meaningful change - // - // We achieve this by checking if the input value has changed between focus and blur without any user interaction - // if yes we fire the callback because input value did change - let stateAtStart = getState(); - let interacted = false; + // Override the value setter on the instance to intercept writes and apply processValue. + // We do this on the instance (not the prototype) so preactSignal remains a real Signal + // instance — Preact's JSX integration requires instanceof Signal to render signals as children. + const signalProto = Object.getPrototypeOf(preactSignal); + const valueDescriptor = Object.getOwnPropertyDescriptor(signalProto, "value"); + Object.defineProperty(preactSignal, "value", { + get() { + return valueDescriptor.get.call(preactSignal); + }, + set(newValue) { + const processedValue = processValue(newValue); + // const currentValue = valueDescriptor.get.call(preactSignal); + // if (compareTwoJsValues(processedValue, currentValue)) { + // return; + // } + valueDescriptor.set.call(preactSignal, processedValue); + }, + enumerable: true, + configurable: true, + }); - const onfocus = () => { - interacted = false; - stateAtStart = getState(); - }; - const oninput = (e) => { - if (!e.isTrusted) { - // non trusted "input" events will be ignored by the browser when deciding to fire "change" event - // we ignore them too + const facadeSignal = preactSignal; + facadeSignal.validity = validity; + facadeSignal.validSignal = computed(() => { + // Reading facadeSignal.value establishes the reactive dependency. + // eslint-disable-next-line no-unused-expressions + facadeSignal.value; + return validity.representations.valid?.value; + }); + facadeSignal.__signalId = signalIdString; + facadeSignal.toString = () => `{navi_state_signal:${signalIdString}}`; + // 1. when signal value changes to undefined, it needs to fallback to default value + // 2. when dynamic default changes and signal value is not custom, it needs to update + { + let isFirstRun = true; + effect(() => { + const value = preactSignal.value; + if (isFirstRun) { + isFirstRun = false; return; } - interacted = true; - }; - const onblur = (e) => { - if (interacted) { + if (value !== undefined) { return; } - if (stateAtStart === getState()) { + const defaultValue = getDefaultValue(true); + if (defaultValue === value) { return; } - callback(e); - }; - - input.addEventListener("focus", onfocus); - input.addEventListener("input", oninput); - input.addEventListener("blur", onblur); - addTeardown(() => { - input.removeEventListener("focus", onfocus); - input.removeEventListener("input", oninput); - input.removeEventListener("blur", onblur); + if (debug) { + console.debug( + `[stateSignal:${signalIdString}] becomes undefined, reset to ${defaultValue}`, + ); + } + facadeSignal.value = defaultValue; }); } - - return teardown; -}; - -const dispatchRequestSetUIState = (element, value, detail) => { - const controlHost = findControlHost(element) || element; - return dispatchInternalCustomEvent(controlHost, "navi_set_ui_state", { - ...detail, - value, - }); -}; -const dispatchRequestClearUIState = (element, e) => { - const controlHost = findControlHost(element) || element; - return dispatchInternalCustomEvent(controlHost, "navi_clear_ui_state", { - event: e, - }); -}; -const dispatchRequestResetUIState = (element, e) => { - const controlHost = findControlHost(element) || element; - return dispatchInternalCustomEvent(controlHost, "navi_reset_ui_state", { - event: e, - }); -}; -/** - * @param {Element} el - * @param {{ own?: boolean }} [options] `own`: what the element holds BY ITSELF. - * Only a button ever answers differently — one with no value of its own - * inherits the value of the control around it, which is what makes - * `--navi-send` on a form's button be about that form. Something asking what - * THIS element says (a travel command reading what the travel is about) wants - * the own value and would otherwise be handed the surrounding control's. - */ -const getUIStateFromElement = (el, { own } = {}) => { - let uiState; - dispatchInternalCustomEvent(el, "navi_get_ui_state", { - own, - respondWith: (v) => { - uiState = v; - }, - }); - return uiState; -}; - -const requestPseudoStateCheck = (element, detail) => { - dispatchInternalCustomEvent( - element, - "navi_pseudo_state_request_check", - detail, - ); - // When a control has a visible proxy mirroring its state (e.g. selectable - // radio with `navi-control-proxy-for`), re-check the proxy too so it stays - // in sync with the real control. - const proxy = findControlProxy(element); - if (proxy) { - dispatchInternalCustomEvent( - proxy, - "navi_pseudo_state_request_check", - detail, - ); - } -}; -const NAVI_PSEUDO_STATE_CUSTOM_EVENT = "navi_pseudo_state"; -const dispatchPseudoStateCustomEvent = (element, value, oldValue) => { - dispatchInternalCustomEvent(element, NAVI_PSEUDO_STATE_CUSTOM_EVENT, { - pseudoState: value, - oldPseudoState: oldValue, - }); -}; - -const PSEUDO_CLASSES = {}; -Object.assign(PSEUDO_CLASSES, { - ":valid": { - attribute: "data-valid", - test: (el) => el.matches(":valid"), - }, - ":invalid": { - attribute: "data-invalid", - test: (el) => el.matches(":invalid"), - }, - ":visited": { - attribute: "data-visited", - }, - // Written by whoever knows the current url — a Link from its href, a Button - // from its route — so it lives here rather than with one of them. - ":-navi-href-current": { - attribute: "data-href-current", - }, -}); -const definePseudoClass = (pseudoClass, definition) => { - PSEUDO_CLASSES[pseudoClass] = definition; -}; - -// On touch devices (hover: none), browsers synthesize mouseenter/mouseleave -// from touch events but never fire mouseleave when the finger lifts, leaving -// el.matches(":hover") stuck at true. This causes hover styles (e.g. input -// background highlight) to remain visible long after the user has stopped -// touching the element. Checking (hover: hover) lets us skip hover tracking -// entirely on touch-only devices where persistent hover makes no sense. -const hoverSupported = window.matchMedia("(hover: hover)").matches; -definePseudoClass(":hover", { - attribute: "data-hover", - setup: (el, callback) => { - if (!hoverSupported) { - return () => {}; + dynamic_signal_effect: { + if (!dynamicDefaultSignal) { + break dynamic_signal_effect; } - const recheckProxy = (e) => { - const proxy = findControlProxy(el); - if (proxy) { - requestPseudoStateCheck(proxy, { event: e }); + // here we listen only on the dynamic default signal + let isFirstRun = true; + let dynamicDefaultPreviousValue; + effect(() => { + const value = preactSignal.peek(); + const dynamicDefaultValue = dynamicDefaultSignal.value; + if (isFirstRun) { + isFirstRun = false; + dynamicDefaultPreviousValue = dynamicDefaultValue; + return; } - }; - const recheckProxyTarget = (e) => { - const proxyTarget = findControlProxyTarget(el); - if (proxyTarget) { - requestPseudoStateCheck(proxyTarget, { event: e }); + // Check if current signal value matches the PREVIOUS dynamic default + // If so, it was following the dynamic default and should update + // Special case: if previous was undefined and we were using static fallback + let wasFollowingDefault = false; + if ( + dynamicDefaultPreviousValue === undefined && + staticDefaultValue !== undefined + ) { + // Signal might have been using static fallback + wasFollowingDefault = value === staticDefaultValue; + } else { + // Signal was following the previous dynamic default + wasFollowingDefault = value === dynamicDefaultPreviousValue; } - }; - let onmouseenter = (e) => { - callback(); - recheckProxy(e); - recheckProxyTarget(e); - }; - let onmouseleave = (e) => { - callback(); - recheckProxy(e); - recheckProxyTarget(e); - }; - - if (el.tagName === "LABEL") { - // input.matches(":hover") is true when hovering the label - // so when label is hovered/not hovered we need to recheck the input too - const recheckInput = (e) => { - if (el.htmlFor) { - const input = document.getElementById(el.htmlFor); - if (!input) { - // cannot find the input for this label in the DOM - return; - } - requestPseudoStateCheck(input, { event: e }); - return; - } - const input = el.querySelector("input, textarea, select"); - if (!input) { - // label does not contain an input - return; - } - requestPseudoStateCheck(input, { event: e }); - }; - const _onmouseenter = onmouseenter; - onmouseenter = (e) => { - recheckInput(e); - _onmouseenter(e); - }; - const _onmouseleave = onmouseleave; - onmouseleave = (e) => { - recheckInput(e); - _onmouseleave(e); - }; - } - el.addEventListener("mouseenter", onmouseenter); - el.addEventListener("mouseleave", onmouseleave); - return () => { - el.removeEventListener("mouseenter", onmouseenter); - el.removeEventListener("mouseleave", onmouseleave); - }; - }, - test: (el) => { - if (!hoverSupported) { - return false; - } - if (el.matches(":hover")) { - return true; - } - const proxy = findControlProxy(el); - if (proxy && proxy.matches(":hover")) { - return true; - } - return false; - }, -}); -definePseudoClass(":disabled", { - attribute: "data-disabled", - add: (el) => { - if ( - el.tagName === "BUTTON" || - el.tagName === "INPUT" || - el.tagName === "SELECT" || - el.tagName === "TEXTAREA" - ) { - el.disabled = true; - } - }, - remove: (el) => { - if ( - el.tagName === "BUTTON" || - el.tagName === "INPUT" || - el.tagName === "SELECT" || - el.tagName === "TEXTAREA" - ) { - el.disabled = false; - } - }, -}); -definePseudoClass(":read-only", { - attribute: "data-readonly", - add: (el) => { - if ( - el.tagName === "INPUT" || - el.tagName === "SELECT" || - el.tagName === "TEXTAREA" - ) { - if (el.type === "checkbox" || el.type === "radio") { - // there is no readOnly for checkboxes/radios + if (!wasFollowingDefault) { + // Signal has a custom value, don't update even if dynamic default changes + dynamicDefaultPreviousValue = dynamicDefaultValue; return; } - // el.readOnly = true; - } - }, - remove: (el) => { - if ( - el.tagName === "INPUT" || - el.tagName === "SELECT" || - el.tagName === "TEXTAREA" - ) { - if (el.type === "checkbox" || el.type === "radio") { - // there is no readOnly for checkboxes/radios + + // Signal was using default value, update to new default + const newDefaultValue = getDefaultValue(true); + if (newDefaultValue === value) { + dynamicDefaultPreviousValue = dynamicDefaultValue; return; } - // el.readOnly = false; - } - }, -}); -definePseudoClass(":checked", { - attribute: "data-checked", - setup: (el, callback) => { - if (el.type === "checkbox") { - // Listen to user interactions - el.addEventListener("input", callback); - // Intercept programmatic changes to .checked property - const originalDescriptor = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "checked", - ); - Object.defineProperty(el, "checked", { - get: originalDescriptor.get, - set(value) { - originalDescriptor.set.call(this, value); - callback(); - }, - configurable: true, - }); - return () => { - // Restore original property descriptor - Object.defineProperty(el, "checked", originalDescriptor); - el.removeEventListener("input", callback); - }; - } - if (el.type === "radio") { - // Listen to changes on the radio - el.addEventListener("input", callback); - // Intercept programmatic changes to .checked property - const originalDescriptor = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "checked", - ); - Object.defineProperty(el, "checked", { - get: originalDescriptor.get, - set(value) { - originalDescriptor.set.call(this, value); - callback(); - }, - configurable: true, - }); - return () => { - el.removeEventListener("input", callback); - // Restore original property descriptor - Object.defineProperty(el, "checked", originalDescriptor); - }; - } - if (el.tagName === "INPUT") { - el.addEventListener("input", callback); - return () => { - el.removeEventListener("input", callback); - }; + if (debug) { + console.debug( + `[stateSignal:${signalIdString}] dynamic default updated, update to ${newDefaultValue}`, + ); + } + dynamicDefaultPreviousValue = dynamicDefaultValue; + facadeSignal.value = newDefaultValue; + }); + } + persist_in_local_storage: { + if (!localStorageKey) { + break persist_in_local_storage; } - return () => {}; - }, - test: (el) => el.matches(":checked"), -}); -definePseudoClass(":active", { - attribute: "data-active", - setup: (el, callback) => { - // I'ts recommended to use :-navi-pressed over :active for interactive elements. - const onPointerDown = () => { - const onRelease = () => { - document.removeEventListener("pointercancel", onRelease, true); - document.removeEventListener("pointerup", onRelease, true); - callback(); - }; - document.addEventListener("pointercancel", onRelease, true); - document.addEventListener("pointerup", onRelease, true); - callback(); - }; - el.addEventListener("pointerdown", onPointerDown); - return () => { - el.removeEventListener("pointerdown", onPointerDown); - }; - }, - test: (el) => el.matches(":active"), -}); + effect(() => { + const value = preactSignal.value; -// The current input modality: true after a keyboard navigation key (arrow keys, -// Escape, Enter, Ctrl, Alt, Shift, Space — Space ignored on editable fields), -// false after a pointer interaction. Updated by the listeners in the -// focus_classes block below. At module scope so isMatchingFocusVisible (used -// here and in control_hooks.jsx) can read it. -let keyboardNavigationUsed = false; + if (dynamicDefaultSignal) { + // With dynamic defaults: always persist to preserve user intent + // even when value matches dynamic defaults that may change + if (value !== undefined) { + if (debug) { + console.debug( + `[stateSignal:${signalIdString}] dynamic default: writing to localStorage "${localStorageKey}"=${value}`, + ); + } + updateLocalStorage(); + } + return; + } + // Static defaults: only persist custom values + if (isCustomValue(value)) { + if (debug) { + console.debug( + `[stateSignal:${signalIdString}] writing into localStorage "${localStorageKey}"=${value}`, + ); + } + updateLocalStorage(); + } else { + if (debug) { + console.debug( + `[stateSignal:${signalIdString}] removing "${localStorageKey}" from localStorage (value=${value})`, + ); + } + removeFromLocalStorage(); + } + }); + } + // Create isDefaultValue function for this signal + const isDefaultValue = (value) => { + const currentDefault = getDefaultValue(false); + return value === currentDefault; + }; + + // Store signal with its options (used by route_pattern.js) + const effectiveOptions = { + staticDefaultValue, + getDefaultValue, + dynamicDefaultSignal, + isCustomValue, + isDefaultValue, + type, + step, + min, + max, + persists, + localStorageKey, + debug, + ...options, + }; + globalSignalRegistry.set(signalIdString, { + signal: facadeSignal, + options: effectiveOptions, + }); + facadeSignal.options = effectiveOptions; + if (debug) { + console.debug( + `[stateSignal:${signalIdString}] created with initial value=${facadeSignal.value}`, + { + staticDefaultValue, + hasDynamicDefault: Boolean(dynamicDefaultSignal), + hasStoredValue: persists && readFromLocalStorage() !== undefined, + persists, + localStorageKey: persists ? localStorageKey : undefined, + }, + ); + } + + return facadeSignal; +}; -// HOW FOCUS-VISIBLE IS DECIDED (the details behind isMatchingFocusVisible) -// -// Two rules, depending on what `el` is: -// -// 1. An EDITABLE target (text-ish input, textarea, contenteditable — anything -// whose whole point is keyboard input, see isEditableTarget) shows its ring -// whenever it holds the focus, however the focus arrived — mouse, -// programmatic, even a focus({ focusVisible: false }). Being focused, for -// such a field, means being about to type, and that is what the ring -// announces. Its check is on :focus rather than :focus-visible on purpose: -// the native :focus-visible obeys the focusVisible option, which callers -// set from the modality without knowing what they are focusing. -// -// 2. Anything else needs BOTH the native :focus-visible match AND the current -// modality being keyboard (`keyboardNavigationUsed`). -// -// The native match alone cannot be trusted, because we routinely take the -// pointer out of the browser's hands: a picker opens on mousedown and calls -// preventDefault() so the browser does not move focus itself (see -// picker_custom.jsx). The browser therefore never registers that a pointer -// was what moved focus, and whatever focus-visible state the previous -// keyboard interaction left behind stays true — even across the -// programmatic focus({ focusVisible: false }) that follows. Close a picker -// with Escape (ring on the trigger, rightly) and click it open again: the -// popup's own focusable would come up ringed, from a mouse press. -// -// `keyboardNavigationUsed` has no such blind spot — it is set by navigation -// keydowns and cleared by pointerdown, whatever anyone prevents afterwards -// — so it is the authority for everything rule 1 does not cover. -// -// Ring INHERITANCE (a controlled element ringing because its aria-controls -// controller is focused) stays gated on the keyboard modality even for an -// editable controller — see hasIndirectFocus: propagating a ring promises -// keyboard shortcuts will drive the controlled element, a promise that only -// holds once a physical keyboard has actually been used. /** - * Whether `el` should currently show a focus ring — the enriched - * :focus-visible behind every [data-focus-visible] navi renders. - * Use this instead of a bare el.matches(":focus-visible") wherever - * focus-visible is evaluated; the comment above details the rules. - * @param {Element} el - * @returns {boolean} + * Custom route pattern matching system + * Replaces URLPattern with a simpler, more predictable approach */ -const isMatchingFocusVisible = (el) => { - if (isEditableTarget(el)) { - return el.matches(":focus"); - } - if (!el.matches(":focus-visible")) { - return false; - } - if (!keyboardNavigationUsed) { - return false; - } - return true; + + +const DEBUG$3 = + typeof process === "object" ? process.env.DEBUG === "true" : false; + +// Base URL management +let baseFileUrl; +let baseUrl; +const setBaseUrl = (value) => { + baseFileUrl = new URL( + value, + typeof window === "undefined" ? "http://localhost/" : window.location, + ).href; + baseUrl = new URL(".", baseFileUrl).href; }; +setBaseUrl( + typeof window === "undefined" + ? "/" + : window.location.origin, +); -// Elements that invite keyboard input: focusing one — even with the mouse — -// means the user is about to type, so they always warrant a visible focus. -// Also used to ignore the Space key as a navigation key while typing, and by -// programmatic focus (focus_transfer.js) to pass focusVisible: true so the -// native :focus-visible agrees with the ring rule 1 above draws. -const EDITABLE_INPUT_TYPE_SET = new Set([ - "text", - "search", - "url", - "email", - "password", - "tel", - "number", - "date", - "time", - "datetime-local", - "month", - "week", -]); -const isEditableTarget = (target) => { - if (!target) { - return false; - } - const tag = target.tagName; - if (tag === "TEXTAREA") { - return !target.readOnly; +/** + * Creates a custom route pattern matcher + */ +const createRoutePattern = (pattern, { searchParams = {} } = {}) => { + // Detect and process path signals in the pattern + const [cleanPattern, pathConnections] = detectSignals(pattern); + + // Build pathConnectionMap from path signals + const pathConnectionMap = new Map(); + const signalSet = new Set(); + for (const connection of pathConnections) { + pathConnectionMap.set(connection.paramName, connection); + signalSet.add(connection.signal); } - if (tag === "INPUT") { - if (!target.type || EDITABLE_INPUT_TYPE_SET.has(target.type)) { - return !target.readOnly; + + // Build queryConnectionMap directly from searchParams + const queryConnectionMap = new Map(); + for (const [paramName, paramSignal] of Object.entries(searchParams)) { + const signalId = paramSignal.__signalId; + const registryEntry = globalSignalRegistry.get(signalId); + if (registryEntry) { + const { signal, options } = registryEntry; + const connection = { paramName, signal, paramType: "query", ...options }; + queryConnectionMap.set(paramName, connection); + signalSet.add(signal); } } - if (target.isContentEditable) { - return true; + + // All connections (path + query) for ancestor/descendant signal resolution + const connections = [...pathConnections, ...queryConnectionMap.values()]; + + const parsedPattern = parsePattern(cleanPattern, { + pathConnectionMap, + queryConnectionMap, + }); + + if (DEBUG$3) { + console.debug(`[CustomPattern] Created pattern:`, parsedPattern); + console.debug(`[CustomPattern] Signal connections:`, connections); + console.debug(`[CustomPattern] Path connections:`, pathConnectionMap.size); + console.debug( + `[CustomPattern] Query connections:`, + queryConnectionMap.size, + ); + console.debug(`[CustomPattern] SignalSet size:`, signalSet.size); } - return false; -}; -// The current modality, for code deciding whether something it is about to -// focus should show a ring — a slide arriving, a popup opening. The question is -// "was the user on the keyboard when this was asked for", which is what this -// flag says; the element that happens to hold the focus right now says nothing -// about it (it may have been focused programmatically, ring or no ring, by the -// travel before this one). -const isKeyboardModality = () => keyboardNavigationUsed; + const applyOn = (url) => { + const result = matchUrl(parsedPattern, url, { + baseUrl, + baseFileUrl, + queryConnectionMap, + patternObj: patternObject, + }); -{ - // We implement :focus and :focus-visible with enriched semantics: - // an element is considered focused not only when it natively has focus, but also - // when a "focus proxy" element has focus (e.g. a read-only range input delegates - // focus to a sibling span) or when a controlling element has focus (e.g. a combobox - // input with aria-controls pointing to a listbox — the listbox should appear focused - // while the input is focused). - // - // We intentionally reuse the native :focus / :focus-visible names rather than - // introducing new navi-specific pseudo-classes (e.g. :-navi-focus). This is a - // deliberate exception: all existing CSS and code written as [data-focus] or - // [data-focus-visible] automatically benefits from the enriched behavior without - // any changes. A separate navi-specific class would require updating every - // component. - // - // When a controller element (e.g. combobox input) gains or loses focus, - // notify the elements it controls via aria-controls so they re-check their - // focus state. `requestPseudoStateCheck` also re-checks the controlled - // element's proxy (if any), so the visible proxy mirrors the hidden real - // input's inherited focus. - const notifyAriaControlled = (el, e) => { - const controlledIds = el.getAttribute("aria-controls"); - if (!controlledIds) { - return; + if (DEBUG$3) { + console.debug( + `[CustomPattern] Matching "${url}" against "${cleanPattern}":`, + result, + ); } - for (const id of controlledIds.split(" ")) { - const controlled = document.getElementById(id); - if (controlled) { - requestPseudoStateCheck(controlled, { event: e }); + + return result; + }; + + const resolveParams = (providedParams = {}) => { + let resolvedParams = { ...providedParams }; + + // Process path connections for parameter resolution + for (const [paramName, connection] of pathConnectionMap) { + if (paramName in providedParams) { + // Parameter was explicitly provided - always respect explicit parameters + continue; + } + const signalValue = connection.signal.value; + if (signalValue !== undefined) { + // Parameter was not provided, check signal value + resolvedParams[paramName] = signalValue; + } + } + + // Process query connections for parameter resolution + for (const [paramName, connection] of queryConnectionMap) { + if (paramName in providedParams) { + // Parameter was explicitly provided - always respect explicit parameters + continue; + } + const signalValue = connection.signal.value; + if (signalValue !== undefined) { + // Parameter was not provided, check signal value + resolvedParams[paramName] = signalValue; + } + } + + // Add defaults for path parameters that are still missing + for (const [paramName, connection] of pathConnectionMap) { + if (paramName in resolvedParams) { + continue; + } + const currentDefault = connection.getDefaultValue(); + if (currentDefault !== undefined) { + resolvedParams[paramName] = currentDefault; + } + } + + // Add defaults for query parameters that are still missing + for (const [paramName, connection] of queryConnectionMap) { + if (paramName in resolvedParams) { + continue; + } + const currentDefault = connection.getDefaultValue(); + if (currentDefault !== undefined) { + resolvedParams[paramName] = currentDefault; + } + } + + // Inherit search parameters from ancestry chain + // Search params are global and should be inherited from any matching ancestor + // regardless of path segment relationships + let ancestorPatternObj = patternObject.parent; + while (ancestorPatternObj) { + for (const [ + paramName, + ancestorConnection, + ] of ancestorPatternObj.queryConnectionMap) { + // Skip if this parameter is already resolved + if (paramName in resolvedParams) { + continue; + } + + const ancestorSignalValue = ancestorConnection.signal.value; + if ( + ancestorSignalValue !== undefined && + ancestorSignalValue !== ancestorConnection.getDefaultValue() + ) { + // Inherit non-default values from ancestors + resolvedParams[paramName] = ancestorSignalValue; + } + } + ancestorPatternObj = ancestorPatternObj.parent; + } + + // Include active non-default parameters from child routes for URL optimization + // Only include from child routes that would actually match the current parameters + const childPatternObjs = patternObject.children; + for (const childPatternObj of childPatternObjs) { + // Check if this child route would match the current resolved parameters + // by simulating URL building and seeing if the child segments align + let childWouldMatch = true; + + // Compare child segments with what would be built from current params + for (let i = 0; i < childPatternObj.pattern.segments.length; i++) { + const childSegment = childPatternObj.pattern.segments[i]; + const parentSegment = parsedPattern.segments[i]; + + if (childSegment.type === "literal") { + if (parentSegment && parentSegment.type === "param") { + // Child has literal where parent has parameter - check if values match + const paramValue = resolvedParams[parentSegment.name]; + if (paramValue !== childSegment.value) { + childWouldMatch = false; + break; + } + } else if (!parentSegment) { + // Child has literal segments beyond parent's segments + // Check if this route can be reached through intermediate routes in the hierarchy + let canReachThroughIntermediates = false; + + // Look for intermediate routes that could bridge the gap + const intermediateRoutes = patternObject.children; + for (const intermediateRoute of intermediateRoutes) { + // Check if intermediate route has a parameter at this position + const intermediateSegment = intermediateRoute.pattern.segments[i]; + if (intermediateSegment && intermediateSegment.type === "param") { + // Check if the child's literal value could match this parameter + // This means there's a potential path: parent → intermediate → child + canReachThroughIntermediates = true; + break; + } + } + + if (!canReachThroughIntermediates) { + // No viable path through intermediates - truly unreachable + childWouldMatch = false; + break; + } + } else if ( + parentSegment.type === "literal" && + parentSegment.value !== childSegment.value + ) { + // Both have literals but they don't match + childWouldMatch = false; + break; + } + // If parent also has matching literal at this position, continue + } + // Parameter segments are always compatible if parent has corresponding segment + } + + if (childWouldMatch) { + // Only check child query parameters - path parameters should not be inherited as search params + for (const [ + childParam, + childConnection, + ] of childPatternObj.queryConnectionMap) { + if (childParam in resolvedParams) { + continue; + } + const childSignalValue = childConnection.signal.value; + // Only include if not already resolved and is non-default + if ( + childSignalValue !== undefined && + childSignalValue !== childConnection.getDefaultValue() + ) { + resolvedParams[childParam] = childSignalValue; + } + } } } + + return resolvedParams; }; - // Tracks whether the user has pressed a keyboard navigation key (arrow keys, - // Escape, Enter, Ctrl, Alt, Shift, Space) since the last pointer interaction. - // Space is ignored when the target is an editable field. - // This flag is used to gate focus-visible inheritance via aria-controls: - // on mobile (or when the user hasn't used keyboard nav yet) an input that - // controls a radio should not cause the radio to show a focus ring. - // (Declared at module scope — see keyboardNavigationUsed above.) - const NAVIGATION_KEY_SET = new Set([ - "ArrowUp", - "ArrowDown", - "ArrowLeft", - "ArrowRight", - "Escape", - "Enter", - "Control", - "Alt", - "Shift", - " ", - "Tab", - ]); - document.addEventListener( - "keydown", - (e) => { - if (!NAVIGATION_KEY_SET.has(e.key)) { - return; + + /** + * Build the most precise URL by using route relationships from pattern registry. + * Each route is responsible for its own URL generation using its own signals. + */ + + /** + * Helper: Filter out default values from parameters for cleaner URLs + * + * This function removes parameters that match their default values (static or dynamic) + * while preserving custom values and inherited parameters from ancestor routes. + * Parameter inheritance from parent routes is intentional - only default values + * for the current route's own parameters are filtered out. + */ + const removeDefaultValues = (params) => { + const filtered = { ...params }; + + // Process path parameters + for (const [paramName, connection] of pathConnectionMap) { + if (paramName in filtered) { + // Parameter is explicitly provided - check if we should remove it + const paramValue = filtered[paramName]; + + if (!connection.isCustomValue(paramValue)) { + delete filtered[paramName]; + } + } else { + // Parameter not provided but signal has a value + const signalValue = connection.signal.value; + if (connection.isCustomValue(signalValue)) { + // Only include custom values + filtered[paramName] = signalValue; + } } - if (e.key === " " && isEditableTarget(e.target)) { - return; + } + + // Process query parameters + for (const [paramName, connection] of queryConnectionMap) { + if (paramName in filtered) { + // Parameter is explicitly provided - check if we should remove it + const paramValue = filtered[paramName]; + + if (!connection.isCustomValue(paramValue)) { + delete filtered[paramName]; + } + } else { + // Parameter not provided but signal has a value + const signalValue = connection.signal.value; + if (connection.isCustomValue(signalValue)) { + // Only include custom values + filtered[paramName] = signalValue; + } } - keyboardNavigationUsed = true; - }, - { capture: true }, - ); - document.addEventListener( - "pointerdown", - () => { - keyboardNavigationUsed = false; - }, - { capture: true }, - ); + } - // A keystroke flips keyboardNavigationUsed, which can turn :focus-visible on — - // but only for the element that holds focus, directly or through aria-controls - // / a proxy. Pressing a key cannot reveal a focus ring on an unfocused element. - // So a single shared handler re-checks just that focus chain (the active - // element, what it controls, and its proxy — the last two via - // requestPseudoStateCheck / notifyAriaControlled). This runs in the bubble - // phase, after the capture-phase listener above has updated the flag. - // - // The alternative — each registered :focus-visible element adding its own - // document keydown listener that re-tests itself — makes one keypress cost - // O(number-of-boxes) full-document [aria-controls] / proxy queries, since every - // unfocused element falls through matches(":focus-visible") into hasIndirectFocus. - const recheckFocusChainOnKey = (e) => { - const active = document.activeElement; - if (!active || active === document.body) { - return; + return filtered; + }; + + /** + * Helper: Check if a literal value can be reached through available parameters + */ + const canReachLiteralValue = (literalValue, params, literalPosition) => { + // Check parent's own parameters (signals and user params) + const parentCanProvide = connections.some((conn) => { + const signalValue = conn.signal.value; + const userValue = params[conn.paramName]; + const effectiveValue = userValue !== undefined ? userValue : signalValue; + return ( + effectiveValue === literalValue && conn.isCustomValue(effectiveValue) + ); + }); + if (parentCanProvide) { + return true; } - requestPseudoStateCheck(active, { event: e }); - notifyAriaControlled(active, e); + + // Check user-provided parameters + const userCanProvide = Object.entries(params).some( + ([, value]) => value === literalValue, + ); + if (userCanProvide) { + return true; + } + + // Check if any descendant path signal provides this literal value AT THE SAME position. + // A signal from /map/isochrone/:tab can provide a literal at position 2 (tab position), + // but NOT a literal at position 1 (panel position) — even if the signal value matches. + // descendantPathSignals is a Map precomputed during setupPatterns. + const connsAtPosition = + patternObject.descendantPathSignals.get(literalPosition); + if (!connsAtPosition) { + return false; + } + return connsAtPosition.some((conn) => { + const signalValue = conn.signal.value; + return signalValue === literalValue && conn.isCustomValue(signalValue); + }); }; - document.addEventListener("keydown", recheckFocusChainOnKey); - document.addEventListener("keyup", recheckFocusChainOnKey); + const checkChildRouteCompatibility = (childPatternObj, params) => { + const childParams = {}; + let isCompatible = true; + + // CRITICAL: Check if parent route can reach all child route's literal segments + // A route can only optimize to a descendant if there's a viable path through parameters + // to reach all the descendant's literal segments (e.g., "/" cannot reach "/admin" + // without a parameter that produces "admin") + const childLiterals = childPatternObj.pattern.segments.filter( + (segment) => segment.type === "literal", + ); + // Check each child literal segment + for (let i = 0; i < childLiterals.length; i++) { + const childLiteral = childLiterals[i]; + const childPosition = childLiteral.index; + const literalValue = childLiteral.value; + + // Check what the parent has at this position + const parentSegmentAtPosition = parsedPattern.segments.find( + (segment) => segment.index === childPosition, + ); + + if (parentSegmentAtPosition) { + if (parentSegmentAtPosition.type === "literal") { + // Parent has a literal at this position + if (parentSegmentAtPosition.value === literalValue) { + // Same literal - no problem + continue; + } + // Different literal - incompatible + if (DEBUG$3) { + console.debug( + `[${pattern}] INCOMPATIBLE with ${childPatternObj.originalPattern}: conflicting literal "${parentSegmentAtPosition.value}" vs "${literalValue}" at position ${childPosition}`, + ); + } + return { isCompatible: false, childParams: {} }; + } + if (parentSegmentAtPosition.type === "param") { + // Parent has a parameter at this position - child literal can satisfy this parameter + // BUT we need to check if the parent's parameter value matches the child's literal + + // Find the parent's parameter value from signals or params + const paramName = parentSegmentAtPosition.name; + let parentParamValue = params[paramName]; + + // If not in params, check signals + if (parentParamValue === undefined) { + const parentConnection = + pathConnectionMap.get(paramName) || + queryConnectionMap.get(paramName); + if (parentConnection) { + parentParamValue = parentConnection.signal.value; + } + } + + // If parent has a specific value for this parameter, it must match the child literal + if ( + parentParamValue !== undefined && + parentParamValue !== literalValue + ) { + return { isCompatible: false, childParams: {} }; + } - // Returns true when el holds focus indirectly — either because a controlling - // element (aria-controls) has focus, or because el is a proxy whose target - // is itself controlled by a focused element. - const hasIndirectFocus = (el, { requireFocusVisible = false } = {}) => { - // No ring inheritance without a keyboard: an editable target draws its own - // ring on any focus (see isMatchingFocusVisible), but propagating that ring - // to a controlled element (aria-controls) is a promise that keyboard - // shortcuts will drive it — a promise that holds only once a physical - // keyboard has actually been used. On touch devices the flag stays false - // and a focused search input keeps its ring to itself. - if (requireFocusVisible && !keyboardNavigationUsed) { - return false; - } - // A controller/proxy counts as focused for inheritance via the same rule - // used everywhere: :focus for plain inheritance, isMatchingFocusVisible for - // the focus-visible variant (so a mouse-focused controller doesn't propagate - // a ring). - const isFocusedTarget = (target) => - requireFocusVisible - ? isMatchingFocusVisible(target) - : target.matches(":focus"); - const isControlledBy = (target) => { - const id = target.id; - if (!id) { - return false; - } - const controllers = document.querySelectorAll(`[aria-controls~="${id}"]`); - for (const controller of controllers) { - // If the controller is inside the element it controls, focus is already - // native (:focus-within) — no need to inherit it. - if (target.contains(controller)) { continue; } - if (isFocusedTarget(controller)) { - return true; - } - } - return false; - }; - if (isControlledBy(el)) { - return true; - } - const proxyTarget = findControlProxyTarget(el); - if (proxyTarget) { - if (isFocusedTarget(proxyTarget)) { - return true; } - if (isControlledBy(proxyTarget)) { - return true; + // Parent doesn't have a segment at this position - child extends beyond parent + // Check if any available parameter can produce this literal value + else if (!canReachLiteralValue(literalValue, params, childPosition)) { + if (DEBUG$3) { + console.debug( + `[${pattern}] INCOMPATIBLE with ${childPatternObj.originalPattern}: cannot reach literal segment "${literalValue}" at position ${childPosition} - no viable parameter path`, + ); + } + return { isCompatible: false, childParams: {} }; } } - return false; - }; - // Shared setup for :focus and :focus-visible. Both need focusin/focusout - // listeners + a MutationObserver on aria-controls so that when the attribute - // changes while the element is focused, old and new controlled elements are - // notified to re-check their own focus state. - // extraSetup: optional (el, callback) => teardown for pseudo-class-specific - // listeners (e.g. keydown/keyup for :focus-visible). - const setupFocus = (el, callback) => { - const onFocusChange = (e) => { - callback(); - notifyAriaControlled(el, e); - }; - el.addEventListener("focusin", onFocusChange); - el.addEventListener("focusout", onFocusChange); - // Only observe aria-controls mutations when the element already has the - // attribute at setup time. If aria-controls is guaranteed to be set before - // initPseudoStyles runs (e.g. passed as a prop in box.jsx), this covers all - // real cases without paying the MutationObserver cost for every element. - let observer; - // if (el.hasAttribute("aria-controls")) { - observer = new MutationObserver((mutations) => { - if (!el.matches(":focus-within")) { - return; + // Check both parent signals AND user-provided params for child route matching + const paramsToCheck = [ + ...connections, + ...Object.entries(params).map(([key, value]) => ({ + paramName: key, + userValue: value, + isUserProvided: true, + })), + ]; + + for (const item of paramsToCheck) { + const result = processParameterForChildRoute( + item, + childPatternObj.pattern, + ); + + if (DEBUG$3) { + console.debug( + `[${pattern}] Processing param '${item.paramName}' (userProvided: ${item.isUserProvided}, value: ${item.isUserProvided ? item.userValue : item.signal?.value}) for child ${childPatternObj.originalPattern}: compatible=${result.isCompatible}, shouldInclude=${result.shouldInclude}`, + ); } - for (const mutation of mutations) { - const oldIds = (mutation.oldValue || "").split(" ").filter(Boolean); - for (const id of oldIds) { - const controlled = document.getElementById(id); - if (controlled) { - requestPseudoStateCheck(controlled, {}); - } + + if (!result.isCompatible) { + isCompatible = false; + if (DEBUG$3) { + console.debug( + `[${pattern}] Child ${childPatternObj.originalPattern} INCOMPATIBLE due to param '${item.paramName}'`, + ); } + break; } - notifyAriaControlled(el, {}); - }); - observer.observe(el, { - attributes: true, - attributeFilter: ["aria-controls"], - attributeOldValue: true, - }); - // } - return () => { - el.removeEventListener("focusin", onFocusChange); - el.removeEventListener("focusout", onFocusChange); - observer?.disconnect(); - }; - }; - definePseudoClass(":focus", { - attribute: "data-focus", - setup: (el, callback) => { - const cleanup = setupFocus(el, callback); - return () => { - cleanup(); - }; - }, - test: (el) => { - if (el.matches(":focus")) { - return true; - } - if (hasIndirectFocus(el)) { - return true; - } - return false; - }, - }); - definePseudoClass(":focus-visible", { - attribute: "data-focus-visible", - // No per-element keydown/keyup listener: the shared recheckFocusChainOnKey - // handler re-checks the focused element (the only one a keystroke can turn - // focus-visible) so a keypress stays O(1), not O(number-of-boxes). - setup: (el, callback) => { - return setupFocus(el, callback); - }, - test: (el) => { - if (isMatchingFocusVisible(el)) { - return true; - } - if (hasIndirectFocus(el, { requireFocusVisible: true })) { - return true; - } - return false; - }, - }); - definePseudoClass(":focus-within", { - attribute: "data-focus-within", - setup: (el, callback) => { - const onFocusChange = (e) => { - callback(); - notifyAriaControlled(el, e); - }; - el.addEventListener("focusin", onFocusChange); - el.addEventListener("focusout", onFocusChange); - return () => { - el.removeEventListener("focusin", onFocusChange); - el.removeEventListener("focusout", onFocusChange); - }; - }, - test: (el) => { - if (el.matches(":focus-within")) { - return true; - } - if (hasIndirectFocus(el)) { - return true; - } - if (el.contains(document.activeElement)) { - // for some reason :focus-within sometimes is false while focus is within... - // (popover with chrome for some reason) - return true; + if (result.shouldInclude) { + childParams[result.paramName] = result.paramValue; } - return false; - }, - }); -} + } -Object.assign(PSEUDO_CLASSES, { - ":-navi-pointed": { - attribute: "data-pointed", - }, - ":-navi-pointed-by-mouse": { - attribute: "data-pointed-by-mouse", - }, - ":-navi-pointed-by-keyboard": { - attribute: "data-pointed-by-keyboard", - }, - ":-navi-pointed-by-proxy": { - attribute: "data-pointed-by-proxy", - }, - ":-navi-selected": { - attribute: "data-selected", - }, - ":-navi-loading": { - attribute: "data-loading", - }, - ":-navi-status-info": { - attribute: "data-status-info", - }, - ":-navi-status-success": { - attribute: "data-status-success", - }, - ":-navi-status-warning": { - attribute: "data-status-warning", - }, - ":-navi-status-error": { - attribute: "data-status-error", - }, - ":-navi-expanded": { - attribute: "data-expanded", - }, - ":-navi-void": { - attribute: "data-void", - }, - "::highlight": {}, -}); -definePseudoClass(":-navi-has-value", { - attribute: "data-has-value", - setup: (el, callback) => { - const controlHost = findControlHost(el) || el; - return addInputEffect(controlHost, callback); - }, - test: (el) => { - if (isControlHost(el)) { - const uiState = getUIStateFromElement(el); - if (uiState === undefined || uiState === "") { - return false; - } - return true; + if (DEBUG$3) { + console.debug( + `[${pattern}] Final compatibility result for ${childPatternObj.originalPattern}: ${isCompatible}`, + ); } - if (el.value === "") { - return false; + + return { isCompatible, childParams }; + }; + + /** + * Helper: Process a single parameter for child route compatibility + */ + const processParameterForChildRoute = (item, childParsedPattern) => { + let paramName; + let paramValue; + + if (item.isUserProvided) { + paramName = item.paramName; + paramValue = item.userValue; + } else { + paramName = item.paramName; + paramValue = item.signal.value; + // Only include custom parent signal values (not using defaults) + if (paramValue === undefined || !item.isCustomValue(paramValue)) { + return { isCompatible: true, shouldInclude: false }; + } } - return true; - }, -}); -{ - const pressedElements = new WeakSet(); - definePseudoClass(":-navi-pressed", { - attribute: "data-pressed", - setup: (el, callback) => { - // Prefer :-navi-pressed over :active for interactive elements because: - // - :active only tracks the primary (left) button; right-click and touch - // long-press do not trigger :active reliably across browsers. - // - :-navi-pressed explicitly ignores non-primary buttons (e.g. right-click) - // and correctly clears pressed state when a context menu opens on long-press, - // which would otherwise leave the element stuck in a pressed appearance. - // Note: it might be tempting to use el.setPointerCapture() here so that pointerup - // always fires on el regardless of where the pointer is released. However, - // pointer capture routes all subsequent pointer events to the capturing element, - // which means any other element in the tree that expects to receive pointerup, - // mouseup, click, etc. after a pointerdown will silently not get them. - // For example a that reacts to mousedown + click, or a third-party - // library that attaches its own listeners, would break because an ancestor - // grabbed the pointer out from under them. - // To avoid forcing every such element to declare an opt-out attribute - // (e.g. navi-own-pointer-capture) we simply listen on document instead, - // which is safe and does not interfere with anyone else's event flow. - const onPointerDown = (e) => { - if (e.button !== 0) { - // only left pointer (mouse left click, touch, pen) - return; - } - pressedElements.add(el); - const onRelease = () => { - pressedElements.delete(el); - document.removeEventListener("pointercancel", onRelease, true); - document.removeEventListener("pointerup", onRelease, true); - document.removeEventListener("contextmenu", onContextMenu, true); - callback(); - }; - const onContextMenu = (e) => { - // On touch devices, a long-press triggers the context menu. - // If the context menu is not prevented, it means it will open and the - // pointer events (pointerup, lostpointercapture) won't fire normally, - // leaving the element stuck in pressed state. We clear it manually. - // e.button === -1 means the event was synthesized from a long-press (not a real mouse click). - if (e.button === -1 && !e.defaultPrevented) { - pressedElements.delete(el); - document.removeEventListener("pointercancel", onRelease, true); - document.removeEventListener("pointerup", onRelease, true); - document.removeEventListener("contextmenu", onContextMenu, true); - callback(); - } - }; - document.addEventListener("pointercancel", onRelease, true); - document.addEventListener("pointerup", onRelease, true); - document.addEventListener("contextmenu", onContextMenu, true); - callback(); - }; - el.addEventListener("pointerdown", onPointerDown); - return () => { - el.removeEventListener("pointerdown", onPointerDown); - pressedElements.delete(el); + // Check if parameter value matches a literal segment in child pattern + const matchesChildLiteral = paramMatchesChildLiteral( + paramValue, + childParsedPattern, + ); + if (matchesChildLiteral) { + // Compatible - parameter value matches child literal + return { + isCompatible: true, + shouldInclude: !item.isUserProvided, + paramName, + paramValue, }; - }, - test: (el) => pressedElements.has(el), - }); -} + } -{ - definePseudoClass(":-navi-drag-grabbed", { - attribute: "navi-drag-grabbed", - setup: (el, callback) => { - const onGrab = () => { - callback(); - const onRelease = () => { - el.removeEventListener("navi_drag_release", onRelease); - callback(); - }; - el.addEventListener("navi_drag_release", onRelease); - }; - el.addEventListener("navi_drag_grab", onGrab); - return () => { - el.removeEventListener("navi_drag_grab", onGrab); - }; - }, - test: (el) => el.hasAttribute("data-drag-grabbed"), - }); - definePseudoClass(":-navi-dragging", { - attribute: "navi-dragging", - setup: (el, callback) => { - const onStart = () => { - callback(); - const onRelease = () => { - el.removeEventListener("navi_drag_release", onRelease); - callback(); - }; - el.addEventListener("navi_drag_release", onRelease); - }; - el.addEventListener("navi_drag_start", onStart); - return () => { - el.removeEventListener("navi_drag_start", onStart); - }; - }, - test: (el) => el.hasAttribute("data-dragging"), - }); -} + // ROBUST FIX: For path parameters, check semantic compatibility by verifying + // that parent parameter values can actually produce the child route structure + const isParentPathParam = pathConnectionMap.has(paramName); + if (isParentPathParam) { + // Check if parent parameter value matches any child literal where it should + // The key insight: if parent has a specific parameter value, child route must + // be reachable with that value or they're incompatible + const parameterCanReachChild = canParameterReachChildRoute( + paramName, + paramValue, + parsedPattern, + childParsedPattern, + ); -const EMPTY_STATE = {}; -const elementToImpactWeakMap = new WeakMap(); -const initPseudoStyles = ( - element, - { - pseudoClasses, - pseudoState, // ":disabled", ":read-only", ":-navi-loading", etc... - effect, - elementToImpact = element, - elementListeningPseudoState, - }, -) => { - elementToImpactWeakMap.set(element, elementToImpact); - if (elementListeningPseudoState === element) { - console.warn( - `elementListeningPseudoState should not be the same as element to avoid infinite loop`, - ); - elementListeningPseudoState = null; - } + if (!parameterCanReachChild) { + return { isCompatible: false }; + } + } - const proxyTarget = findControlProxyTarget(element); + // Check if this is a query parameter in the parent pattern + const isParentQueryParam = queryConnectionMap.has(paramName); + if (isParentQueryParam) { + // Query parameters are always compatible and can be inherited by child routes + return { + isCompatible: true, + shouldInclude: !item.isUserProvided && !matchesChildLiteral, + paramName, + paramValue, + }; + } - const onStateChange = (value, oldValue) => { - effect?.(value, oldValue); - if (elementListeningPseudoState) { - dispatchPseudoStateCustomEvent( - elementListeningPseudoState, - value, - oldValue, - ); + // Check for generic parameter-literal conflicts (only for path parameters) + if (!matchesChildLiteral) { + // Check if this is a path parameter from parent pattern + const isParentPathParam = pathConnectionMap.has(paramName); + if (isParentPathParam) { + // Parameter value (from user or signal) doesn't match this child's literals + // Check if child has any literal segments that would conflict with this parameter + const hasConflictingLiteral = childParsedPattern.segments.some( + (segment) => + segment.type === "literal" && segment.value !== paramValue, + ); + if (hasConflictingLiteral) { + return { isCompatible: false }; + } + } } - // When this element's state changes, notify any proxy element that mirrors it - // so it can re-check and visually reflect the new state. - const proxy = findControlProxy(element); - if (proxy) { - requestPseudoStateCheck(proxy, {}); + + // Compatible but should only include if from signal (not user-provided) + return { + isCompatible: true, + shouldInclude: !item.isUserProvided && !matchesChildLiteral, + paramName, + paramValue, + }; + }; + + /** + * Helper: Determine if child route should be used based on active parameters + */ + const shouldUseChildRoute = ( + childPatternObj, + params, + compatibility, + resolvedParams, + ) => { + // CRITICAL: Check if user explicitly passed undefined for parameters that would + // normally be used to select this child route via sibling route relationships + for (const [paramName, paramValue] of Object.entries(params)) { + if (paramValue !== undefined) { + continue; + } + + // Look for sibling routes (other children of the same parent) that use this parameter + const siblingPatternObjs = patternObject.children; + for (const siblingPatternObj of siblingPatternObjs) { + if (siblingPatternObj === childPatternObj) continue; // Skip self + + // Check if sibling route uses this parameter and get the connection + const siblingConnection = + siblingPatternObj.pathConnectionMap.get(paramName) || + siblingPatternObj.queryConnectionMap.get(paramName); + if (!siblingConnection) { + continue; + } + const siblingSignalValue = siblingConnection.signal.value; + if (siblingSignalValue === undefined) { + continue; + } + // Check if this child route has a literal that matches the signal value + const signalMatchesThisChildLiteral = + childPatternObj.pattern.segments.some( + (segment) => + segment.type === "literal" && + segment.value === siblingSignalValue, + ); + if (signalMatchesThisChildLiteral) { + // This child route's literal matches the sibling's signal value + // User passed undefined to override that signal - don't use this child route + if (DEBUG$3) { + console.debug( + `[${pattern}] Blocking child route ${childPatternObj.originalPattern} because ${paramName}:undefined overrides sibling signal value "${siblingSignalValue}"`, + ); + } + return false; + } + } } - }; - if (!pseudoClasses || pseudoClasses.length === 0) { - onStateChange(EMPTY_STATE); - return () => {}; - } + // CRITICAL: Block child routes that have literal segments requiring specific parameter values + // that aren't available. Only check literal segments that replace parameter positions. + // Example: /map/flow/ replaces /:panel/ with "flow", so panel must equal "flow" + let hasIncompatibleLiterals = false; + let hasMatchingNonDefaultLiterals = false; - const [teardown, addTeardown] = createPubSub(); + for (let i = 0; i < childPatternObj.pattern.segments.length; i++) { + const childSegment = childPatternObj.pattern.segments[i]; + const parentSegment = parsedPattern.segments[i]; - let state; - const checkPseudoClasses = () => { - let someChange = false; - const currentState = {}; - for (const pseudoClass of pseudoClasses) { - const pseudoClassDefinition = PSEUDO_CLASSES[pseudoClass]; - if (!pseudoClassDefinition) { - console.warn(`Unknown pseudo class: ${pseudoClass}`); - continue; - } - let currentValue; if ( - pseudoState && - Object.hasOwn(pseudoState, pseudoClass) && - pseudoState[pseudoClass] !== undefined + childSegment.type === "literal" && + parentSegment && + parentSegment.type === "param" ) { - currentValue = pseudoState[pseudoClass]; - } else { - const { test } = pseudoClassDefinition; - if (test) { - currentValue = test(element, pseudoState); - } - } - // If this element is a proxy for another (navi-control-proxy-for="targetId"), - // inherit the target's active pseudo-state when the element itself isn't in that state. - // We check the target's elementToImpact (not the target itself) because the - // data-* attribute may be set on a different element (e.g. pseudoStateSelector). - if (!currentValue && proxyTarget) { - const { attribute } = pseudoClassDefinition; - if (attribute) { - const targetElementToImpact = - elementToImpactWeakMap.get(proxyTarget) || proxyTarget; - if (targetElementToImpact.hasAttribute(attribute)) { - currentValue = true; + // This literal segment replaces a parameter in the parent + const paramName = parentSegment.name; + const explicitValue = params[paramName]; + const connection = + pathConnectionMap.get(paramName) || queryConnectionMap.get(paramName); + const signalValue = connection ? connection.signal.value : undefined; + + // Check if the parameter has the required value + if ( + explicitValue !== childSegment.value && + signalValue !== childSegment.value + ) { + hasIncompatibleLiterals = true; + if (DEBUG$3) { + console.debug( + `[${pattern}] Blocking child route ${childPatternObj.originalPattern} because parameter "${paramName}" must be "${childSegment.value}" but current values are explicit="${explicitValue}" signal="${signalValue}"`, + ); } + break; } - } - currentState[pseudoClass] = currentValue; - const oldValue = state ? state[pseudoClass] : undefined; - if (oldValue !== currentValue || !state) { - someChange = true; - const { attribute, add, remove } = pseudoClassDefinition; - if (currentValue) { - if (attribute) { - elementToImpact.setAttribute(attribute, ""); - } - add?.(element); - } else { - if (attribute) { - elementToImpact.removeAttribute(attribute); + + // Check if this matching literal represents a non-default parameter value + // (for forcing child route selection later) + if (explicitValue === childSegment.value && connection) { + const defaultValue = connection.getDefaultValue(); + if (explicitValue !== defaultValue) { + hasMatchingNonDefaultLiterals = true; } - remove?.(element); } } } - if (!someChange) { - return; + + // Block incompatible child routes immediately + if (hasIncompatibleLiterals) { + return false; } - const oldState = state; - state = currentState; - onStateChange(state, oldState); - }; - element.addEventListener(NAVI_PSEUDO_STATE_CUSTOM_EVENT, (event) => { - const oldState = event.detail.oldPseudoState; - state = event.detail.pseudoState; - onStateChange(state, oldState); - }); - element.addEventListener("navi_pseudo_state_request_check", () => { - checkPseudoClasses(); - }); + // Check if child has active non-default signal values + let hasActiveParams = false; + const childParams = { ...compatibility.childParams }; - for (const pseudoClass of pseudoClasses) { - const pseudoClassDefinition = PSEUDO_CLASSES[pseudoClass]; - if (!pseudoClassDefinition) { - console.warn(`Unknown pseudo class: ${pseudoClass}`); - continue; + for (const [paramName, connection] of new Map([ + ...childPatternObj.pathConnectionMap, + ...childPatternObj.queryConnectionMap, + ])) { + // Check if parameter was explicitly provided by user + const hasExplicitParam = paramName in params; + const explicitValue = params[paramName]; + + if (hasExplicitParam) { + // User explicitly provided this parameter - use their value + childParams[paramName] = explicitValue; + if ( + explicitValue !== undefined && + connection.isCustomValue(explicitValue) + ) { + hasActiveParams = true; + } + } else { + const signalValue = connection.signal.value; + if (signalValue !== undefined) { + // No explicit override - use signal value + childParams[paramName] = signalValue; + if (connection.isCustomValue(signalValue)) { + hasActiveParams = true; + } + } + } } - const { setup } = pseudoClassDefinition; - if (setup) { - const cleanup = setup(element, () => { - checkPseudoClasses(); - }); - addTeardown(cleanup); + + // Check if child pattern can be fully satisfied + const initialMergedParams = { ...childParams, ...params }; + const canBuildChildCompletely = childPatternObj.pattern.segments.every( + (segment) => { + if (segment.type === "literal") return true; + if (segment.type === "param") { + return ( + segment.optional || initialMergedParams[segment.name] !== undefined + ); + } + return true; + }, + ); + + // Count only non-undefined provided parameters that are NOT default values + const nonDefaultParams = Object.entries(params).filter( + ([paramName, value]) => { + if (value === undefined) return false; + + // Check if this parameter has a default value in child's connections + const childConnection = + childPatternObj.pathConnectionMap.get(paramName) || + childPatternObj.queryConnectionMap.get(paramName); + if (childConnection) { + const childDefault = childConnection.getDefaultValue(); + return value !== childDefault; + } + + // Check if this parameter has a default value in parent's connections (current pattern) + const parentConnection = + pathConnectionMap.get(paramName) || queryConnectionMap.get(paramName); + if (parentConnection) { + const parentDefault = parentConnection.getDefaultValue(); + return value !== parentDefault; + } + + return true; // Non-connection parameters are considered non-default + }, + ); + + const hasNonDefaultProvidedParams = nonDefaultParams.length > 0; + + // Use child route if: + // 1. Child has active non-default parameters, OR + // 2. User provided non-default params AND child can be built completely, OR + // 3. User provided params that match child literal segments AND are non-default values + // EXCEPT: Don't use child if parent can produce cleaner URL by omitting defaults + let shouldUse = + hasActiveParams || + (hasNonDefaultProvidedParams && canBuildChildCompletely) || + (hasMatchingNonDefaultLiterals && canBuildChildCompletely); + + if (DEBUG$3) { + console.debug( + `[${pattern}] shouldUseChildRoute decision for ${childPatternObj.originalPattern}:`, + { + hasActiveParams, + hasNonDefaultProvidedParams, + canBuildChildCompletely, + shouldUse, + }, + ); } - } - checkPseudoClasses(); - return teardown; -}; + // Optimization: Check if child would include literal segments that represent default values + if (shouldUse) { + // Check if child pattern has literal segments that correspond to default parameter values + const childLiterals = childPatternObj.pattern.segments + .filter((seg) => seg.type === "literal") + .map((seg) => seg.value); -const applyStyle = ( - element, - style, - pseudoState, - pseudoNamedStyles, - preventInitialTransition, -) => { - if (!element) { - return; - } - const styleToApply = getStyleToApply(style, pseudoState, pseudoNamedStyles); - updateStyle(element, styleToApply, preventInitialTransition); -}; + const parentLiterals = parsedPattern.segments + .filter((seg) => seg.type === "literal") + .map((seg) => seg.value); -const PSEUDO_STATE_DEFAULT = {}; -const PSEUDO_NAMED_STYLES_DEFAULT = {}; -const getStyleToApply = (styles, pseudoState, pseudoNamedStyles) => { - if ( - !pseudoState || - pseudoState === PSEUDO_STATE_DEFAULT || - !pseudoNamedStyles || - pseudoNamedStyles === PSEUDO_NAMED_STYLES_DEFAULT - ) { - return styles; - } + // If child has more literal segments than parent, check if the extra ones are defaults + if (childLiterals.length > parentLiterals.length) { + const extraLiterals = childLiterals.slice(parentLiterals.length); - const isMatching = (pseudoKey) => { - if (pseudoKey.startsWith("::")) { - const nextColonIndex = pseudoKey.indexOf(":", 2); - if (nextColonIndex === -1) { - return true; + // Check if any extra literal matches a default parameter value + // BUT only skip if user didn't explicitly provide that parameter AND + // both conditions are true: + // 1. The parameters that would cause us to use this child route are defaults + // 2. The child route doesn't have non-default parameters that would be lost + let childSpecificParamsAreDefaults = true; + + // Check if parameters that determine child selection are non-default + // OR if any descendant parameters indicate explicit navigation + for (const [paramName, connection] of new Map([ + ...pathConnectionMap, + ...queryConnectionMap, + ])) { + const currentDefault = connection.getDefaultValue(); // Use current dynamic default + const resolvedValue = resolvedParams[paramName]; + const userProvidedParam = paramName in params; + + if (extraLiterals.includes(currentDefault)) { + // This literal corresponds to a parameter in the parent + if ( + userProvidedParam || + (resolvedValue !== undefined && + connection.isCustomValue(resolvedValue)) + ) { + // Parameter was explicitly provided or has custom value - child is needed + childSpecificParamsAreDefaults = false; + break; + } + } + } + + // Additional check: if child route has path parameters that are non-default, + // this indicates explicit navigation even if structural parameters happen to be default + // (Query parameters don't count as they don't indicate structural navigation) + if (childSpecificParamsAreDefaults) { + for (const childConnection of childPatternObj.connections) { + const childParamName = childConnection.paramName; + const childDefaultValue = childConnection.getDefaultValue(); + const childResolvedValue = resolvedParams[childParamName]; + + // Only consider path parameters, not query parameters + const isPathParam = childPatternObj.pattern.segments.some( + (seg) => seg.type === "param" && seg.name === childParamName, + ); + + if ( + isPathParam && + childResolvedValue !== undefined && + childResolvedValue !== childDefaultValue + ) { + // Child has non-default path parameters, indicating explicit navigation + childSpecificParamsAreDefaults = false; + if (DEBUG$3) { + console.debug( + `[${pattern}] Child has non-default path parameter '${childParamName}=${childResolvedValue}' (default: ${childDefaultValue}) - indicates explicit navigation`, + ); + } + break; + } + } + } + + // When structural parameters (those that determine child selection) are defaults, + // prefer parent route ONLY if child doesn't have any non-default parameters + if (childSpecificParamsAreDefaults && !hasActiveParams) { + for (const [paramName, connection] of new Map([ + ...pathConnectionMap, + ...queryConnectionMap, + ])) { + const currentDefault = connection.getDefaultValue(); // Use current dynamic default + const userProvidedParam = paramName in params; + + if (extraLiterals.includes(currentDefault) && !userProvidedParam) { + // This child includes a literal that represents a default value + // AND user didn't explicitly provide this parameter + // When structural parameters are defaults, prefer parent for cleaner URL + shouldUse = false; + if (DEBUG$3) { + console.debug( + `[${pattern}] Preferring parent over child - child includes default literal '${currentDefault}' for param '${paramName}' (structural parameter is default and no active params)`, + ); + } + break; + } + } + } else if (DEBUG$3) { + console.debug( + `[${pattern}] Using child route - parameters that determine child selection are non-default or child has active params`, + ); + } } - // Handle pseudo-elements with states like "::-navi-loader:checked:disabled" - const pseudoStatesString = pseudoKey.slice(nextColonIndex); - return isMatching(pseudoStatesString); } - const nextColonIndex = pseudoKey.indexOf(":", 1); - if (nextColonIndex === -1) { - return pseudoState[pseudoKey]; + + if (DEBUG$3 && shouldUse) { + console.debug( + `[${pattern}] Will use child route ${childPatternObj.originalPattern}`, + ); } - // Handle compound pseudo-states like ":checked:disabled" - return pseudoKey - .slice(1) - .split(":") - .every((state) => pseudoState[state]); + + return shouldUse; }; - const styleToAddSet = new Set(); - for (const pseudoKey of Object.keys(pseudoNamedStyles)) { - if (isMatching(pseudoKey)) { - const stylesToApply = pseudoNamedStyles[pseudoKey]; - styleToAddSet.add(stylesToApply); - } - } - if (styleToAddSet.size === 0) { - return styles; - } - let style = styles || {}; - for (const styleToAdd of styleToAddSet) { - style = mergeTwoStyles(style, styleToAdd, "css"); - } - return style; -}; + /** + * Helper: Build URL for selected child route with proper parameter filtering + */ + const buildChildRouteUrl = ( + childPatternObj, + params, + parentResolvedParams = {}, + ) => { + // Start with child signal values + const baseParams = {}; + for (const [paramName, connection] of new Map([ + ...childPatternObj.pathConnectionMap, + ...childPatternObj.queryConnectionMap, + ])) { + // Check if parameter was explicitly provided by user + const hasExplicitParam = paramName in params; + const explicitValue = params[paramName]; -const styleKeySetWeakMap = new WeakMap(); -const elementTransitionWeakMap = new WeakMap(); -const elementRenderedWeakSet = new WeakSet(); -const NO_STYLE_KEY_SET = new Set(); -const updateStyle = (element, style, preventInitialTransition) => { - const styleKeySet = style ? new Set(Object.keys(style)) : NO_STYLE_KEY_SET; - const oldStyleKeySet = styleKeySetWeakMap.get(element) || NO_STYLE_KEY_SET; - // TRANSITION ANTI-FLICKER STRATEGY: - // Problem: When setting both transition and styled properties simultaneously - // (e.g., el.style.transition = "border-radius 0.3s ease"; el.style.borderRadius = "20px"), - // the browser will immediately perform a transition even if no transition existed before. - // - // Solution: Temporarily disable transitions during initial style application by setting - // transition to "none", then restore the intended transition after the frame completes. - // We handle multiple updateStyle calls in the same frame gracefully - only one - // requestAnimationFrame is scheduled per element, and the final transition value wins. - let styleKeySetToApply = styleKeySet; - if (!elementRenderedWeakSet.has(element)) { - const hasTransition = styleKeySet.has("transition"); - if (hasTransition || preventInitialTransition) { - if (elementTransitionWeakMap.has(element)) { - elementTransitionWeakMap.set(element, style?.transition); + if (hasExplicitParam) { + // User explicitly provided this parameter - use their value (even if undefined) + if (explicitValue !== undefined) { + baseParams[paramName] = explicitValue; + } + // If explicitly undefined, don't include it (which means don't use child route) } else { - element.style.transition = "none"; - elementTransitionWeakMap.set(element, style?.transition); + const signalValue = connection.signal.value; + if ( + signalValue !== undefined && + connection.isCustomValue(signalValue) + ) { + // No explicit override - use signal value if non-default + baseParams[paramName] = signalValue; + } } - // Don't apply the transition property now - we've set it to "none" temporarily - styleKeySetToApply = new Set(styleKeySet); - styleKeySetToApply.delete("transition"); } - requestAnimationFrame(() => { - if (elementTransitionWeakMap.has(element)) { - const transitionToRestore = elementTransitionWeakMap.get(element); - if (transitionToRestore === undefined) { - element.style.transition = ""; - } else { - element.style.transition = transitionToRestore; + + // Collect parameters from ALL ancestor routes in the hierarchy (not just immediate parent) + const collectAncestorParameters = (currentPatternObj) => { + if (!currentPatternObj?.parent) { + return; // No more ancestors + } + + const parentPatternObj = currentPatternObj.parent; + + // Add parent's signal parameters (query params only, not path params) + // Path params from ancestors are structural path segments, not inheritable + for (const connection of parentPatternObj.connections) { + if (connection.paramType === "path") { + continue; + } + const { paramName } = connection; + + // Skip if child route already handles this parameter + if ( + childPatternObj.pathConnectionMap.has(paramName) || + childPatternObj.queryConnectionMap.has(paramName) + ) { + continue; // Child route handles this parameter directly + } + + // Skip if parameter is already collected + if (paramName in baseParams) { + continue; // Already have this parameter + } + + const signalValue = connection.signal.value; + // Only include custom signal values (not using defaults) + if ( + signalValue !== undefined && + connection.isCustomValue(signalValue) + ) { + // Skip if parameter is consumed by child's literal path segments + const isConsumedByChildPath = childPatternObj.pattern.segments.some( + (segment) => + segment.type === "literal" && segment.value === signalValue, + ); + if (!isConsumedByChildPath) { + baseParams[paramName] = signalValue; + } } - elementTransitionWeakMap.delete(element); } - elementRenderedWeakSet.add(element); - }); - } - // Apply all styles normally (excluding transition during anti-flicker) - const keysToDelete = new Set(oldStyleKeySet); - for (const key of styleKeySetToApply) { - const value = style[key]; - if (value === undefined || value === null) { - // Treat undefined/null as "remove" — leave key in keysToDelete - continue; - } - keysToDelete.delete(key); - if (key.startsWith("--")) { - element.style.setProperty(key, value); - } else { - element.style[key] = value; - } - } + // Recursively collect from higher ancestors + collectAncestorParameters(parentPatternObj); + }; - // Remove obsolete styles - for (const key of keysToDelete) { - if (key.startsWith("--")) { - element.style.removeProperty(key); - } else { - element.style[key] = ""; - } - } + // Start collecting from the child's parent + collectAncestorParameters(childPatternObj); - styleKeySetWeakMap.set(element, styleKeySet); -}; + // Add parent parameters from the immediate calling context + for (const [paramName, parentValue] of Object.entries( + parentResolvedParams, + )) { + // Skip if already collected from ancestors or child handles it + if (paramName in baseParams) { + continue; + } -/** - * Keeps a DOM element in sync with `syncElement(el)` whenever syncElement changes. - * - If element is already mounted: runs syncElement immediately during render. - * - If not yet mounted: runs syncElement in the ref callback when element arrives. - * - Calls cleanup (if returned by syncElement) before each re-run and on unmount. - * - * Wrap `syncElement` in `useCallback(fn, deps)` at the call site to control - * when re-sync happens. - * - * @param {function} syncElement - Called with the DOM element when its reference changes - * @param {function|object|null} externalRef - Optional ref to forward to - */ -const useComposeElementRef = (syncElement, externalRef) => { - const cleanupRef = useRef(null); - const elRef = useRef(null); - const prevSyncElementRef = useRef(undefined); - const refCallbackRef = useRef(null); - const externalRefRef = useRef(externalRef); - // Detect external ref identity change between renders. The refCallback is - // stable across renders, so when the parent passes a new ref object (or - // switches from null to a ref), Preact does NOT re-fire the callback while - // the DOM element is unchanged. We must manually clear the old ref and - // populate the new one with the current element to avoid leaving the new - // ref's `.current` stuck at `null`. - const prevExternalRefRef = useRef(externalRef); - if (prevExternalRefRef.current !== externalRef) { - const previous = prevExternalRefRef.current; - if (previous && typeof previous !== "function") { - previous.current = null; + // Skip if child route already handles this parameter + if ( + childPatternObj.pathConnectionMap.has(paramName) || + childPatternObj.queryConnectionMap.has(paramName) + ) { + continue; // Child route handles this parameter directly + } + + // Skip if parameter is consumed by child's literal path segments + const isConsumedByChildPath = childPatternObj.pattern.segments.some( + (segment) => + segment.type === "literal" && segment.value === parentValue, + ); + if (isConsumedByChildPath) { + continue; // Parameter is consumed by child's literal path + } + + // Check if parent parameter is at default value + const parentConnection = + pathConnectionMap.get(paramName) || queryConnectionMap.get(paramName); + const parentDefault = parentConnection + ? parentConnection.getDefaultValue() + : undefined; + if (parentValue === parentDefault) { + continue; // Don't inherit default values + } + + // Inherit this parameter as it's not handled by child and not at default + baseParams[paramName] = parentValue; } - if (externalRef && elRef.current) { - if (typeof externalRef === "function") { - externalRef(elRef.current); + + // Apply user params with filtering logic + for (const [paramName, userValue] of Object.entries(params)) { + const childConnection = + childPatternObj.pathConnectionMap.get(paramName) || + childPatternObj.queryConnectionMap.get(paramName); + + if (childConnection) { + // Only include if it's a custom value (not default) + if (childConnection.isCustomValue(userValue)) { + baseParams[paramName] = userValue; + } else { + // User provided the default value - complete omission + delete baseParams[paramName]; + } } else { - externalRef.current = elRef.current; + // Check if param corresponds to a literal segment in child pattern + const isConsumedByChildPath = childPatternObj.pattern.segments.some( + (segment) => + segment.type === "literal" && segment.value === userValue, + ); + + if (!isConsumedByChildPath) { + // Not consumed by child path, keep it as query param + baseParams[paramName] = userValue; + } } } - prevExternalRefRef.current = externalRef; - } - externalRefRef.current = externalRef; - const runSync = (el) => { - if (cleanupRef.current) { - cleanupRef.current(); - cleanupRef.current = null; - } - prevSyncElementRef.current = syncElement; - const cleanup = syncElement(el); - if (typeof cleanup === "function") { - cleanupRef.current = cleanup; + // Build child URL using buildUrl (not buildMostPreciseUrl) to prevent recursion + const childUrl = buildUrlFromPattern( + childPatternObj.pattern, + baseParams, + childPatternObj.originalPattern, + childPatternObj, + ); + + if (childUrl && !childUrl.includes(":")) { + // Check for parent optimization before returning + const optimizedUrl = checkChildParentOptimization( + childPatternObj, + childUrl, + baseParams, + ); + return optimizedUrl || childUrl; } + + return null; }; - // If element already mounted, re-sync when syncElement reference changed. - if (elRef.current && syncElement !== prevSyncElementRef.current) { - runSync(elRef.current); - } + /** + * Helper: Check if parent route optimization applies to child route + */ + const checkChildParentOptimization = ( + childPatternObj, + childUrl, + baseParams, + ) => { + const childParent = childPatternObj.parent; - if (!refCallbackRef.current) { - const refCallback = (el) => { - elRef.current = el; - // Keep .current in sync immediately so useEffect callbacks that read - // ref.current (e.g. usePartiallyHidden) see the element, not null. - refCallback.current = el; - const currentExternalRef = externalRefRef.current; - if (currentExternalRef) { - if (typeof currentExternalRef === "function") { - currentExternalRef(el); - } else { - currentExternalRef.current = el; + if (childParent && childParent.originalPattern === pattern) { + // Check if child path segments correspond to parent's default path parameters + // If so, we can optimize to use parent's path but preserve child's query parameters + + let canOptimizeToParent = true; + const parentPathDefaults = {}; + + // Check each segment in child vs parent to see if child literals match parent defaults + for ( + let i = 0; + i < childPatternObj.pattern.segments.length && + i < parsedPattern.segments.length; + i++ + ) { + const childSegment = childPatternObj.pattern.segments[i]; + const parentSegment = parsedPattern.segments[i]; + + if ( + childSegment.type === "literal" && + parentSegment && + parentSegment.type === "param" + ) { + // Child has literal where parent has parameter - check if literal matches default + const paramName = parentSegment.name; + const connection = + pathConnectionMap.get(paramName) || + queryConnectionMap.get(paramName); + + if (connection) { + const defaultValue = connection.getDefaultValue(); + if (childSegment.value === defaultValue) { + // Child literal matches parent default - this is optimizable + parentPathDefaults[paramName] = defaultValue; + } else { + // Child literal doesn't match parent default - can't optimize + canOptimizeToParent = false; + break; + } + } else { + canOptimizeToParent = false; + break; + } } } - if (el) { - runSync(el); - } else { - if (cleanupRef.current) { - cleanupRef.current(); - cleanupRef.current = null; + + if (canOptimizeToParent && Object.keys(parentPathDefaults).length > 0) { + if (DEBUG$3) { + console.debug( + `[${pattern}] checkChildParentOptimization: checking child ${childPatternObj.originalPattern}`, + { parentPathDefaults, canOptimizeToParent }, + ); } - prevSyncElementRef.current = undefined; - } - }; - refCallbackRef.current = refCallback; - } - const refCallback = refCallbackRef.current; - refCallback.current = elRef.current; - return refCallback; -}; + // CRITICAL: Check if child route has non-default path parameters + // If it does, don't optimize away the child route structure + for (const [ + paramName, + connection, + ] of childPatternObj.pathConnectionMap) { + const signalValue = connection.signal.value; + if ( + signalValue !== undefined && + connection.isCustomValue(signalValue) + ) { + // Child has non-default path parameters - don't optimize away the structure + if (DEBUG$3) { + console.debug( + `[${pattern}] Not optimizing child route because it has non-default path parameter '${paramName}=${signalValue}'`, + ); + } + return null; + } + } -/** - * Tracks whether an element is fully visible in its scroll container and sets - * the `navi-partially-hidden` attribute when any part of it is clipped. - * - * This is used to suppress `view-transition-name` on elements that are partially - * outside the viewport or a scrollable container. Without this, a partially clipped - * element would still participate in view transitions, producing ghost animations or - * incorrect cross-fade effects. - * - * CSS usage: - * ```css - * [navi-partially-hidden] { - * view-transition-name: none !important; - * } - * ``` - * - * `Box` enables this hook automatically when a `viewTransitionName` prop is provided. - * - * @param {import("preact").RefObject} ref - Ref to the element to observe. - * @param {boolean} enabled - Only observe when true (typically when view-transition-name is set). - */ -const usePartiallyHidden = (ref, enabled) => { - useEffect(() => { - const el = ref.current; - if (!el || !enabled) { - return undefined; - } - return setupPartiallyHidden(el); - }, [enabled]); -}; + // Check if child has non-default query parameters that should be preserved + const nonDefaultQueryParams = {}; -const setupPartiallyHidden = (el) => { - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.intersectionRatio >= 0.99) { - el.removeAttribute("navi-partially-hidden"); - } else { - el.setAttribute("navi-partially-hidden", ""); + for (const [ + paramName, + connection, + ] of childPatternObj.queryConnectionMap) { + const signalValue = connection.signal.value; + if ( + signalValue !== undefined && + connection.isCustomValue(signalValue) + ) { + nonDefaultQueryParams[paramName] = signalValue; + } + } + + // Also include any query parameters from baseParams + for (const [paramName, paramValue] of Object.entries(baseParams)) { + // Check if this parameter is not a path parameter that we're optimizing away + if (!(paramName in parentPathDefaults)) { + nonDefaultQueryParams[paramName] = paramValue; + } + } + + // Build optimized URL using parent path but child's query parameters + // Always optimize when we can, even if there are no query parameters + const parentParams = { ...nonDefaultQueryParams }; + + // Remove default path parameters to get clean parent URL + for (const defaultParam of Object.keys(parentPathDefaults)) { + delete parentParams[defaultParam]; + } + + const optimizedUrl = buildUrlFromPattern( + parsedPattern, + parentParams, + pattern, + patternObject, + ); + + if (DEBUG$3) { + console.debug( + `[${pattern}] Optimizing child route ${childPatternObj.originalPattern} to parent with query params:`, + { parentPathDefaults, nonDefaultQueryParams, optimizedUrl }, + ); + } + + return optimizedUrl; } - }, - { threshold: 0.99 }, - ); - observer.observe(el); - return () => { - observer.disconnect(); - }; -}; + } -installImportMetaCssBuild(import.meta);/** - * Box - A Swiss Army Knife for Layout - * - * A regular div by default, enhanced with styling props for spacing, sizing, - * and layout. The main value is a friendlier API over raw CSS Flexbox. - * - * ## Display & Layout - * - * - `flex` — horizontal flex container (items side by side) - * - `flex="y"` — vertical flex container (items stacked). The prop name makes - * the axis explicit, avoiding the classic CSS trap where `flex-direction: column` - * actually stacks items vertically despite "column" feeling horizontal. - * - `grid` — grid container - * - `inline` — switches to inline display (works with flex and grid too) - * - * ## Alignment - * - * Instead of CSS's justify-content/align-items which swap meaning based on flex-direction: - * - `alignX` — horizontal alignment, always - * - `alignY` — vertical alignment, always - * - * ## Spacing & Sizing - * - * Props for margin, padding, gap, width, height, expand, shrink, and more. - * - * ## Pseudo-class Styles - * - * The `style` prop supports pseudo-class keys alongside regular CSS properties. - * This lets you express hover, focus, and custom interaction states in one object, - * without writing CSS or adding class names: - * - * ```jsx - * - * ``` - * - * Styles are applied directly to the DOM (not via Preact's style prop) for two reasons: - * 1. **Pseudo-class support**: reacting to `:hover`, `:focus`, or custom states like - * `:-navi:pressed` without re-rendering the component on every pseudo state change. - * 2. **Correct initial render**: pseudo-class state must be read from the DOM node at - * mount time. Preact's style prop runs before the DOM exists, so the right initial - * style can only be determined once the node is available. - */ -const BoxForwardedPropsContext = createContext({}); -import.meta.css = [/* css */` - /* A scrolling area, and the three layout roles that live in one. Declared - here rather than in dialog.jsx/popover.jsx because it has nothing to do - with popups: anything that scrolls can want a title that stays put. Those - two just carry [data-scrollable] on their own root. + return null; + }; - Two shapes: - - header/footer alone: the container itself scrolls and they stick to its - edges; - - a body as well: the body is the only thing that scrolls, so the other two - simply sit outside it and need no stickiness at all. + const buildMostPreciseUrl = (params = {}) => { + if (DEBUG$3) { + console.debug(`[${pattern}] buildMostPreciseUrl called`); + } - Padding belongs on the parts, not on the scrolling box: padding on a - scroller sits INSIDE the scrollbars, so the content ends up centered - between them — and a control flush against the edge of a scrolling area - overflows it (a focus outline is drawn outside the control it belongs to) - and raises a scrollbar of its own. */ - [data-scrollable] { - overflow: var(--x-scrollable-overflow, auto); + // Use the pattern object's signalSet (updated by setupPatterns) + const effectiveSignalSet = patternObject.signalSet; - &[data-scrollable-overflow="scroll"] { - --x-scrollable-overflow: scroll; + // Access signal.value to trigger dependency tracking + if (DEBUG$3) { + console.debug( + `[${pattern}] Reading ${effectiveSignalSet.size} signals for reactive dependencies`, + ); } + // for (const signal of effectiveSignalSet) { + // // Access signal.value to trigger dependency tracking + // // eslint-disable-next-line no-unused-expressions + // signal.value; // This line is critical for signal reactivity - when commented out, routes may not update properly + // } - /* box-shadow rather than a border: it draws the separation without taking - part in the layout, so a header keeps the exact height its content asks - for and nothing shifts by a pixel when the line appears. */ - /* The corners are the container's, not the part's: a header sitting at the - top of a rounded box has to follow that curve or it paints square over - it (a dark header in a rounded popup is where this shows). inherit and - not a value of its own, so whoever rounds the box rounds these too. */ - > [data-header] { - position: sticky; - top: 0; - z-index: 1; - border-top-left-radius: inherit; - border-top-right-radius: inherit; - box-shadow: 0 1px 0 var(--navi-separator-color-default); + // Step 1: Resolve and clean parameters + const resolvedParams = resolveParams(params); + + // Step 2: Try ancestors first - find the highest ancestor that works + const parentPattern = patternObject.parent; + + if (DEBUG$3 && parentPattern) { + console.debug( + `[${pattern}] Available ancestor:`, + parentPattern.originalPattern, + ); } - > [data-footer] { - position: sticky; - bottom: 0; - z-index: 1; - border-bottom-right-radius: inherit; - border-bottom-left-radius: inherit; - box-shadow: 0 -1px 0 var(--navi-separator-color-default); + + let bestAncestorUrl = null; + if (parentPattern) { + // Skip root route - never use as optimization target + if (parentPattern.originalPattern !== "/") { + // Try to use this ancestor and traverse up to find the highest possible + const highestAncestorUrl = findHighestAncestor( + parentPattern, + resolvedParams, + ); + if (DEBUG$3) { + console.debug( + `[${pattern}] Highest ancestor from ${parentPattern.originalPattern}:`, + highestAncestorUrl, + ); + } + + if (highestAncestorUrl) { + bestAncestorUrl = highestAncestorUrl; + } + } } - &:has(> [data-body]) { - /* A column, declared here rather than expected from the caller: the three - parts only make sense stacked, and the body needs a flex context to be - told "take what is left" below. */ - display: flex; - flex-direction: column; - /* the body is the only thing that scrolls */ - --x-scrollable-overflow: hidden; - - > [data-header], - > [data-footer] { - position: static; - z-index: unset; - flex-shrink: 0; + if (bestAncestorUrl) { + if (DEBUG$3) { + console.debug(`[${pattern}] Using ancestor optimization`); } + return bestAncestorUrl; + } - > [data-body] { - /* Shrinks when there is not enough room (and then scrolls), but never - grows: a short body leaves the footer right under it rather than - pushed to the bottom of a container it does not fill. - min-height: a flex child refuses to shrink below its content unless - told it may, and without that the body grows instead of scrolling */ - min-height: 0; - flex: 0 1 auto; - /* Overflow makes it focusable via tab: apply the outline styles */ - outline-width: var(--navi-focus-outline-width); - /* Outline must appear ON the body, not outside */ - /* Because for instance when body is within dialog or slide with overflow: hidden it would not be visible */ - outline-offset: calc(-1 * var(--navi-focus-outline-width)); - overflow: auto; + // Step 3: Remove default values for normal URL building + let finalParams = removeDefaultValues(resolvedParams); - &:focus-visible { - outline-style: solid; + // Step 4: Try descendants - find the deepest descendant that works + const childPatternObjs = patternObject.children; + + let bestDescendantUrl = null; + for (const childPatternObj of childPatternObjs) { + const deepestDescendantUrl = findDeepestDescendant( + childPatternObj, + params, + resolvedParams, + ); + if (deepestDescendantUrl) { + // Take the first valid deepest descendant we find (or keep deepest among multiple) + if (!bestDescendantUrl) { + bestDescendantUrl = deepestDescendantUrl; } } } - } - @layer navi { - /* - When using square/circle/aspectRatio prop we expect box to respect the aspect ratio. - But within flex containers or stuff like that the min-width/min-height auto - will prevent the item from shrinking to respect aspect-ratio - We put that in a layer navi + a specific attribute so that it's very easy to override this - */ - [navi-aspect-ratio] { - min-width: 0; - min-height: 0; + if (bestDescendantUrl) { + if (DEBUG$3) { + console.debug(`[${pattern}] Using descendant optimization`); + } + return bestDescendantUrl; + } + if (DEBUG$3) { + console.debug(`[${pattern}] No suitable child route found`); } - } - /* We force a given display style using html attribute instead of inline style */ - /* No particular reason for this, logic could be moved to inline style like the rest */ - /* It was an attempt to see if attributes where a good candidate to set style based on props */ - /* Actullay it's not that much as it make the attribute and CSS complexity explode */ - /* For now it's kept here and must be outside layer navi to be able to override any given display - Set by navi itself on their default display */ - [navi-box-flow="inline"] { - display: inline; - } - [navi-box-flow="block"] { - display: block; - } - [navi-box-flow="inline-block"] { - display: inline-block; - } - [navi-box-flow="flex-x"] { - display: flex; - } - [navi-box-flow="flex-y"] { - display: flex; - flex-direction: column; - } - [navi-box-flow="inline-flex-x"] { - display: inline-flex; - } - [navi-box-flow="inline-flex-y"] { - display: inline-flex; - flex-direction: column; - } - [navi-box-flow="grid"] { - display: grid; - &[navi-box-flow-column] { - grid-auto-flow: column; + // Step 5: Inherit parameters from parent routes + inheritParentParameters(finalParams); + + // Step 6: Build the current route URL + const generatedUrl = buildCurrentRouteUrl(finalParams); + + return generatedUrl; + }; + + /** + * Helper: Find the highest ancestor by traversing parent chain recursively + */ + const findHighestAncestor = (startAncestor, resolvedParams) => { + // Check if we can use this ancestor directly + const directUrl = tryUseAncestor(startAncestor, resolvedParams); + if (!directUrl) { + return null; } - &[navi-box-flow-row] { - grid-auto-flow: row; + + // Look for an even higher ancestor by checking the ancestor's parent + if (startAncestor.parent) { + const higherAncestor = startAncestor.parent; + + // Skip root pattern + if (higherAncestor.originalPattern === "/") { + return directUrl; + } + + // Recursively check if we can optimize to an even higher ancestor + const higherUrl = findHighestAncestor(higherAncestor, resolvedParams); + if (higherUrl) { + return higherUrl; // Found a higher ancestor, return that + } } - &[navi-box-flow-column][navi-box-flow-row] { - grid-auto-flow: unset; + + // No higher ancestor found, return the direct optimization + return directUrl; + }; + + /** + * Helper: Find the deepest descendant that can be used for this route + */ + const findDeepestDescendant = (startChild, params, resolvedParams) => { + // Check if we can use this child directly + const directUrl = tryUseDescendant(startChild, params, resolvedParams); + if (!directUrl) { + return null; } - } - [navi-box-flow="inline-grid"] { - display: inline-grid; - &[navi-box-flow-column] { - grid-auto-flow: column; + + // Now traverse down the child chain to find the deepest possible descendant + let currentChild = startChild; + let deepestUrl = directUrl; + + while (true) { + const childChildren = currentChild.children || []; + + let foundDeeper = false; + for (const deeperChild of childChildren) { + const deeperUrl = tryUseDescendant(deeperChild, params, resolvedParams); + if (deeperUrl) { + // Found a deeper descendant that works + deepestUrl = deeperUrl; + currentChild = deeperChild; + foundDeeper = true; + break; + } + } + + if (!foundDeeper) { + break; // No deeper descendant found, we're at the bottom + } } - &[navi-box-flow-row] { - grid-auto-flow: row; + + return deepestUrl; + }; + + /** + * Helper: Check if child route can optimize to parent based on path segment matching + */ + const canChildOptimizeToParentPath = ( + childPattern, + parentPattern, + parentConnections, + ) => { + if (!childPattern || !parentPattern) { + return false; } - &[navi-box-flow-column][navi-box-flow-row] { - grid-auto-flow: unset; + + // Check each segment in child vs parent to see if child literals match parent defaults + let hasMatchingPathOptimization = false; + for ( + let i = 0; + i < childPattern.segments.length && i < parentPattern.segments.length; + i++ + ) { + const childSegment = childPattern.segments[i]; + const parentSegment = parentPattern.segments[i]; + + if ( + childSegment.type === "literal" && + parentSegment && + parentSegment.type === "param" + ) { + // Child has literal where parent has parameter - check if literal matches default + const paramName = parentSegment.name; + const connection = parentConnections.find( + (conn) => conn.paramName === paramName, + ); + + if (connection) { + const defaultValue = connection.getDefaultValue(); + if (childSegment.value === defaultValue) { + // Child literal matches parent default - this enables path-based optimization + hasMatchingPathOptimization = true; + } else { + // Child literal doesn't match parent default - can't optimize + return false; + } + } else { + return false; + } + } } - } - /* - To set display on component, code usually do something like: - .component_class { display: component_display; } - It overrides the default behavior of [hidden] attribute! - This needs to be explicitly handled with: - .component_class[hidden] { display: none; } + return hasMatchingPathOptimization; + }; - To avoid this extra work and potential mistakes we force the default behavior of [hidden] attribute. - */ - [hidden] { - display: none !important; - } + /** + * Helper: Try to use an ancestor route (only immediate parent for parameter optimization) + */ + const tryUseAncestor = (ancestorPatternObj, resolvedParams) => { + // Check if this ancestor is the immediate parent (for parameter optimization safety) + const immediateParent = patternObject.parent; - /* Partially hidden (or fully hidden) element should not participate in view transition no matter what */ - /* Otherwise they appear immedatly and fully visible from a fully/partially hidden state */ - [navi-partially-hidden] { - view-transition-name: none !important; - } -`, "@jsenv/navi/src/box/box.jsx"]; -const PSEUDO_CLASSES_DEFAULT = []; -const PSEUDO_ELEMENTS_DEFAULT = []; -const STYLE_CSS_VARS_DEFAULT = {}; -// When only pseudoStateSelector is set (no visualSelector), the box owns its -// visual identity. Only event handlers and these explicit props are forwarded -// to the inner semantic/interactive child element. -const PSEUDO_STATE_CHILD_PROP_SET = new Set(["tabIndex", "tabindex"]); + if ( + immediateParent && + immediateParent.originalPattern === ancestorPatternObj.originalPattern + ) { + // This is the immediate parent - check if we can optimize + if (DEBUG$3) { + console.debug( + `[${pattern}] tryUseAncestor: Trying immediate parent ${ancestorPatternObj.originalPattern}`, + ); + } -/** - * @type {import("ignore:preact").FunctionComponent<{ - * as?: string, - * className?: string, - * style?: import("ignore:preact").JSX.CSSProperties & { [pseudo: string]: import("ignore:preact").JSX.CSSProperties }, - * styleCSSVars?: { [stylePropName: string]: string }, - * inline?: boolean, - * block?: boolean, - * flex?: "x" | "y" | boolean, - * grid?: boolean, - * display?: "inherit", - * pseudoState?: { [stateName: string]: boolean }, - * pseudoClasses?: string[], - * pseudoElements?: string[], - * visualSelector?: string, - * pseudoStateSelector?: string, - * hasChildUsingForwardedProps?: boolean, - * childPropSet?: Set, - * preventInitialTransition?: boolean, - * separator?: import("ignore:preact").ComponentChildren | ((index: number) => import("ignore:preact").ComponentChildren), - * children?: import("ignore:preact").ComponentChildren, - * [key: string]: any, - * }>} - */ -const Box = props => { - const { - ref, - as: asProp = "div", - baseClassName, - className, - baseStyle, - // style management - style, - styleCSSVars = STYLE_CSS_VARS_DEFAULT, - basePseudoState, - pseudoState, - // for demo purposes it's possible to control pseudo state from props - pseudoClasses = PSEUDO_CLASSES_DEFAULT, - pseudoElements = PSEUDO_ELEMENTS_DEFAULT, - // visualSelector convey the following: - // The box itself is visually "invisible", one of its descendant is responsible for visual representation - // - Some styles will be used on the box itself (for instance margins) - // - Some styles will be used on the visual element (for instance paddings, backgroundColor) - // -> introduced for with transform:scale on press - visualSelector, - // pseudoStateSelector convey the following: - // The box contains content that holds pseudoState - // -> introduced for with a wrapped for loading, checkboxes, etc - pseudoStateSelector, - hasChildUsingForwardedProps, - baseChildPropSet, - childPropSet, - // preventInitialTransition can be used to prevent transition on mount - // (when transition is set via props, this is done automatically) - // so this prop is useful only when transition is enabled from "outside" (via CSS) - preventInitialTransition, - children, - separator, - // Layout roles inside a scrolling container (a Dialog, a Popover): the - // header stays at the top and the footer at the bottom while the rest - // scrolls, or — when a body is present — the body is what scrolls and the - // two others simply sit outside it. Carried as data attributes because the - // container is the one that knows how to honour them, and it only has CSS - // to reach its children with. Same words as List.Item's own header/footer. - header, - footer, - body, - ...rest - } = props; - let as = asProp; + // For immediate parent optimization, check if we can optimize based on path segments + // Even if query parameters are non-default, we should still optimize if the child's + // literal path segments correspond to the parent's default path parameter values + const canOptimizeBasedOnPath = canChildOptimizeToParentPath( + parsedPattern, + ancestorPatternObj.pattern, + ancestorPatternObj.connections, + ); - // A box that scrolls is what gives header/footer/body their meaning, and - // saying overflow="auto" is already saying it — no second prop for the same - // fact. Dialog and Popover get it the same way, by asking for that overflow. - const scrolls = ["overflow", "overflowX", "overflowY"].some(name => { - const value = rest[name]; - return value === "auto" || value === "scroll"; - }); - // /