@jsenv/navi 0.29.157 → 0.29.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/jsenv_navi.js +519 -185
- package/dist/jsenv_navi.js.map +77 -50
- package/dist/jsenv_navi_side_effects.js +1 -1
- package/dist/jsenv_navi_side_effects.js.map +1 -1
- package/docs/AI_INSTRUCTIONS.md +11 -3
- package/docs/actions.md +65 -3
- package/docs/control_object.md +13 -6
- package/docs/error_handling.md +39 -7
- package/docs/form_changed.md +24 -8
- package/docs/interactions.md +32 -10
- package/docs/popup_open.md +1 -1
- package/docs/resource.md +32 -12
- package/package.json +2 -2
|
@@ -421,8 +421,8 @@ const css = /* css */`@layer navi {
|
|
|
421
421
|
--navi-typo-xl: 1.25rem;
|
|
422
422
|
--navi-typo-xxl: 1.5rem;
|
|
423
423
|
--navi-line-height: 1.25;
|
|
424
|
-
line-height: var(--navi-line-height);
|
|
425
424
|
font-family: ${controlDefaultFontFamily};
|
|
425
|
+
line-height: var(--navi-line-height);
|
|
426
426
|
--navi-control-line-height: round(calc(var(--navi-line-height) * 1em),
|
|
427
427
|
1px);
|
|
428
428
|
--navi-color-secondary-mix: 80%;
|
|
@@ -16,6 +16,6 @@
|
|
|
16
16
|
"import {\n getVirtualKeyboardOverlayHeight,\n subscribeVisualViewportResizeSettled,\n subscribeWindowResizeSettled,\n} from \"@jsenv/dom\";\nimport { computed, signal } from \"@preact/signals\";\n\nexport const windowWidthSignal = signal(window.innerWidth);\nexport const windowHeightSignal = signal(window.innerHeight);\n\n// Debounced (not a raw \"resize\" listener) — see window_size.js's own\n// module comment: mobile fires a transient \"resize\" when the browser's own\n// UI chrome (address bar, etc.) briefly shows/hides, and this needs to\n// settle on the exact same tick as visualViewport's own debounced resize\n// below and Popover/Dialog's own repositioning, or one flickers a moment\n// out of sync with the others.\nsubscribeWindowResizeSettled(() => {\n windowWidthSignal.value = window.innerWidth;\n windowHeightSignal.value = window.innerHeight;\n});\n\n// Visual viewport dimensions — update when the virtual keyboard opens/closes or\n// when the browser UI (address bar) shows/hides.\n// When visualViewport is not available, derived from window signals so they\n// stay live without any extra listeners.\nconst vv = window.visualViewport;\nexport const visualViewportWidthSignal = vv\n ? signal(vv.width)\n : computed(() => windowWidthSignal.value);\nexport const visualViewportHeightSignal = vv\n ? signal(vv.height)\n : computed(() => windowHeightSignal.value);\n\nif (vv) {\n const update = () => {\n visualViewportWidthSignal.value = vv.width;\n visualViewportHeightSignal.value = vv.height;\n };\n subscribeVisualViewportResizeSettled(update);\n vv.addEventListener(\"scroll\", update);\n}\n\n// The app's own screen — the visual viewport, unless the app declared a\n// narrower one with --navi-app-max-width/--navi-app-height (see\n// navi_css_vars.js, which derives --navi-app-width/--navi-app-height from\n// these in CSS). Anything escaping normal flow is sized against this rather\n// than the viewport: an app that simulates a handheld screen keeps that width\n// even for what paints on top of it.\n//\n// The declaration stays in CSS and is read back from there rather than handed\n// to navi a second time in JS — a JS copy would be the one that goes stale.\n// Read on the spot rather than cached in a signal: the only caller is a popup\n// resolving its own margin as it places itself, which already reads far more\n// of the DOM than this, and nothing then has to be invalidated when the value\n// changes.\nconst unresolvableWarned = new Set();\nconst readAppMax = (propertyName) => {\n const declared = getComputedStyle(document.documentElement)\n .getPropertyValue(propertyName)\n .trim();\n if (!declared) {\n return Infinity;\n }\n // A custom property computes to a token stream, not to a length: \"40rem\"\n // arrives here as the string \"40rem\", and parseFloat would read it as 40\n // pixels. Only px is accepted — an app declaring the screen it simulates has\n // a pixel number to give, and resolving arbitrary lengths would mean laying\n // out a probe element on every read.\n const inPixels = /^([0-9.]+)px$/.exec(declared);\n if (!inPixels) {\n if (!unresolvableWarned.has(propertyName)) {\n unresolvableWarned.add(propertyName);\n // Not silently wrong, just partially applied: CSS still caps the popup's\n // size with the declared length, only the margin it keeps with the edges\n // falls back to a share of the viewport (the pre-token behavior).\n console.warn(\n `${propertyName}=\"${declared}\" must be a length in pixels (\"600px\"). Until then popups keep viewport-sized margins.`,\n );\n }\n return Infinity;\n }\n return parseFloat(inPixels[1]);\n};\nexport const getAppWidth = () =>\n Math.min(visualViewportWidthSignal.value, readAppMax(\"--navi-app-max-width\"));\n// Minus what the keyboard covers, so this stays the JS reading of the very\n// same rectangle --navi-app-height describes in CSS (see safe_area.js's own\n// --navi-keyboard-inset-bottom). Zero unless the app opted into the keyboard\n// overlaying its content — otherwise the shrinking visual viewport above has\n// already accounted for it, and subtracting again would count it twice.\nexport const getAppHeight = () =>\n Math.max(\n 0,\n Math.min(\n visualViewportHeightSignal.value,\n readAppMax(\"--navi-app-max-height\"),\n ) - getVirtualKeyboardOverlayHeight(),\n );\n\n// Whether the primary input is a finger rather than a mouse. A pointer type is\n// not a size: a narrow desktop window is still a mouse, and a large tablet is\n// still a finger — so anything sized for the on-screen keyboard must key off\n// this, never off windowWidthSignal. Thumb reach needs both, which is what\n// smallTouchScreenSignal below answers.\nconst coarsePointerQuery = window.matchMedia\n ? window.matchMedia(\"(pointer: coarse)\")\n : null;\nexport const coarsePointerSignal = signal(\n coarsePointerQuery ? coarsePointerQuery.matches : false,\n);\nif (coarsePointerQuery) {\n coarsePointerQuery.addEventListener(\"change\", () => {\n coarsePointerSignal.value = coarsePointerQuery.matches;\n });\n}\n\n// Whether the screen is one a bottom sheet actually suits: a finger *and* a\n// screen whose bottom edge stays where the thumb already is. Touch alone is not\n// enough — a tall touch screen (a tablet, a kiosk panel) docks a sheet a whole\n// screen away from where the finger just tapped, which is worse than the\n// centered box it replaced.\n//\n// A phone is recognized by its SHAPE, not by a box of maximum dimensions: what\n// makes the bottom edge reachable is holding a narrow slab, and phones keep\n// growing along their long side (20:9, 21:9) while staying just as narrow. So\n// each orientation is answered on the short side plus the elongation:\n// - upright: narrow enough to be held in one hand, and taller than it is wide.\n// Its height is deliberately unbounded — a very tall narrow screen is the\n// case a bottom sheet is most for, not the case to exclude.\n// - on its side: short enough that the bottom edge is a thumb away whatever the\n// width, and wider than it is tall.\n// A tablet fails both: it is too wide upright, and too tall on its side — the\n// smallest one already starts around 740 CSS px on its short side, and the\n// bound below leaves that gap deliberately wide rather than cutting close to\n// the largest phone.\n//\n// Read off window, not visualViewport: the virtual keyboard shrinks the visual\n// viewport while the user types, and a dialog must not undock mid-interaction\n// because a keyboard opened under it.\nconst HANDHELD_MAX_SHORT_SIDE = 600;\n// Enough elongation to tell a slab from a square-ish panel; a phone is well\n// past it (1.7 and up) in either orientation.\nconst HANDHELD_MIN_RATIO = 1.2;\nexport const smallTouchScreenSignal = computed(() => {\n if (!coarsePointerSignal.value) {\n return false;\n }\n const width = windowWidthSignal.value;\n const height = windowHeightSignal.value;\n if (width <= HANDHELD_MAX_SHORT_SIDE) {\n return height >= width * HANDHELD_MIN_RATIO;\n }\n if (height <= HANDHELD_MAX_SHORT_SIDE) {\n return width >= height * HANDHELD_MIN_RATIO;\n }\n return false;\n});\n",
|
|
17
17
|
"/**\n * The part of the window an app actually has, in two levels.\n *\n * Two, and not one, for a reason worth stating up front: a fixed bar is one of\n * the things that reduce the free region, so it cannot ALSO be placed against\n * that region — it would push itself off the edge it is pinned to. What is\n * anchored and what is anchored-inside are two different rectangles.\n *\n * 1. `--navi-app-inset-{top,right,bottom,left}` — from the window's edges to\n * the app's own rectangle. Whatever is pinned to an edge (a fixed bar, a\n * side panel, a popup aimed at a corner) is pinned to THAT, so an app\n * pretending to be a 600px handheld inside a 1500px window stays one\n * rectangle instead of a column with its furniture spread across the glass.\n *\n * 2. `--navi-safe-area-inset-{top,right,bottom,left}` — from the window's edges\n * to the band left free INSIDE that rectangle. Whatever flows, scrolls, or\n * gets painted keeps to it.\n *\n * Level 2 is a sum, and the contract for taking part in it is only \"publish\n * what you take on one edge\": the device's own notch (`env(safe-area-inset-*)`,\n * which is the browser's version of this very idea), the fixed bars\n * (fixed_bar_space.js), and anything an app adds. That is the point of naming\n * it at all — a component that must stay clear of what covers the screen reads\n * ONE set of numbers, and never has to learn what is covering it.\n *\n * `max()` between the notch and the bars rather than a sum: a bar pinned to an\n * edge already reaches under the notch and counts it in its own size (see\n * fixed_bar.jsx), so adding both would reserve it twice.\n *\n * Sizes only, not placement, for the time being — see \"Current limitations\" in\n * docs/css_architecture.md.\n */\n\nconst SAFE_AREA_CSS = /* css */ `\n /* Declared as lengths so that they COMPUTE to one: an unregistered custom\n property keeps the calc() it was written as, and the sum below is then a\n string no one can read back. Reading it off the computed style — which a\n route transition does, to keep the band the page being left had (see\n nav/transition_window.js) — only works for a registered property. */\n @property --navi-safe-area-inset-top {\n syntax: \"<length>\";\n inherits: true;\n initial-value: 0px;\n }\n @property --navi-safe-area-inset-right {\n syntax: \"<length>\";\n inherits: true;\n initial-value: 0px;\n }\n @property --navi-safe-area-inset-bottom {\n syntax: \"<length>\";\n inherits: true;\n initial-value: 0px;\n }\n @property --navi-safe-area-inset-left {\n syntax: \"<length>\";\n inherits: true;\n initial-value: 0px;\n }\n\n @layer navi {\n /* Layered whole, rules included: the two rules below are offers, not\n structure. [data-navi-safe-area] is an attribute the app puts on its own\n scroller, so the app's own padding on that element has to win over what\n navi suggests for it. */\n :root {\n /* The room each kind of furniture takes, declared here at zero and\n written by whoever takes it. A slot rather than a value: the sum below\n has to be readable whether or not the app ever mounts a fixed bar. */\n --navi-fixed-bar-space-top: 0px;\n --navi-fixed-bar-space-right: 0px;\n --navi-fixed-bar-space-bottom: 0px;\n --navi-fixed-bar-space-left: 0px;\n\n /* What the on-screen keyboard covers — and ONLY where it overlays the\n content rather than shrinking the viewport, which is navi's default\n wherever the browser has the VirtualKeyboard API (see\n layout/virtual_keyboard.js). Zero on Firefox/Safari, which have no\n such API, and zero for an app that called\n disableVirtualKeyboardOverlay(): both get a keyboard that shrinks the\n visual viewport instead, which --navi-vvh already tracks. Reading\n env() rather than a JS-written value keeps it live: the keyboard\n slides in over several frames and this follows it without a\n listener. */\n --navi-keyboard-inset-bottom: env(keyboard-inset-height, 0px);\n\n /* Level 1. Centered bands, so that declaring one ceiling\n (--navi-app-max-width) is all an app has to do to be a narrow screen in\n a wide window; an app that wants them uneven writes these directly. */\n --navi-app-inset-top: max(\n 0px,\n (var(--navi-vvh) - var(--navi-app-max-height, var(--navi-vvh))) / 2\n );\n /* The keyboard on top of the band, and on this edge only: it eats into\n the app's own rectangle exactly like a viewport that shrank, which is\n what makes both paths end up at the same --navi-app-height (and so at\n the same dialog/popover ceiling). Not part of the centering, hence\n added here rather than folded into --navi-app-inset-top: a keyboard\n takes the bottom, it doesn't re-center anything. */\n --navi-app-inset-bottom: calc(\n var(--navi-app-inset-top) + var(--navi-keyboard-inset-bottom)\n );\n --navi-app-inset-left: max(\n 0px,\n (var(--navi-vvw) - var(--navi-app-max-width, var(--navi-vvw))) / 2\n );\n --navi-app-inset-right: var(--navi-app-inset-left);\n\n /* Level 2. */\n --navi-safe-area-inset-top: calc(\n var(--navi-app-inset-top) +\n max(env(safe-area-inset-top), var(--navi-fixed-bar-space-top))\n );\n --navi-safe-area-inset-right: calc(\n var(--navi-app-inset-right) +\n max(env(safe-area-inset-right), var(--navi-fixed-bar-space-right))\n );\n --navi-safe-area-inset-bottom: calc(\n var(--navi-app-inset-bottom) +\n max(env(safe-area-inset-bottom), var(--navi-fixed-bar-space-bottom))\n );\n --navi-safe-area-inset-left: calc(\n var(--navi-app-inset-left) +\n max(env(safe-area-inset-left), var(--navi-fixed-bar-space-left))\n );\n\n /* The document is the scrollport in the common case, and something the\n browser scrolls to — an anchor, a focused field, a restored position —\n landing under a bar is never what anyone wants. */\n scroll-padding-top: var(--navi-safe-area-inset-top);\n scroll-padding-right: var(--navi-safe-area-inset-right);\n scroll-padding-bottom: var(--navi-safe-area-inset-bottom);\n scroll-padding-left: var(--navi-safe-area-inset-left);\n }\n\n /* Put this on whatever scrolls under the furniture.\n\n There are TWO rooms to give back, and forgetting the second one is the\n classic bug:\n\n - padding, so the end of the content can be scrolled out from under it.\n Without it the last screenful stays covered, unreachable.\n - scroll-padding, so anything the browser scrolls TO lands in front of it\n rather than under. The padding above does not help here: it moves the\n content, not the place the browser scrolls the target to.\n\n Marked by the app rather than picked by navi: which element scrolls is\n the app's business, and an app with more than one would have to fight a\n component that chose for it. An app is free to read the variables itself\n instead. */\n [data-navi-safe-area] {\n padding-top: var(--navi-safe-area-inset-top);\n padding-right: var(--navi-safe-area-inset-right);\n padding-bottom: var(--navi-safe-area-inset-bottom);\n padding-left: var(--navi-safe-area-inset-left);\n\n scroll-padding-top: var(--navi-safe-area-inset-top);\n scroll-padding-right: var(--navi-safe-area-inset-right);\n scroll-padding-bottom: var(--navi-safe-area-inset-bottom);\n scroll-padding-left: var(--navi-safe-area-inset-left);\n }\n }\n`;\nimport.meta.css = SAFE_AREA_CSS;\n",
|
|
18
18
|
"/**\n * navi's stance on the on-screen keyboard: it overlays the app rather than\n * resizing the viewport, wherever the browser can be told so (the\n * VirtualKeyboard API — Chromium only). See virtual_keyboard.js in @jsenv/dom\n * for what that trades away and what it gives back.\n *\n * Turned on rather than offered, because navi already sizes everything that\n * escapes normal flow against the app's own rectangle rather than against the\n * window (--navi-app-width/height, see navi_css_vars.js), and\n * --navi-keyboard-inset-bottom (safe_area.js) puts the keyboard into exactly\n * that rectangle. So the two mechanisms reach the same numbers here, and the\n * overlay reaches them without reflowing the page underneath — a resizing\n * viewport is a resize of everything, fired transiently every time focus goes\n * from one input to the next.\n *\n * An app that built its own layout around the viewport shrinking can say so\n * with disableVirtualKeyboardOverlay(), and gets the behavior Firefox and\n * Safari give it anyway.\n *\n * The one thing it hands back to the app: scrolling the focused field into\n * view. A viewport that shrinks makes the browser do it; a keyboard that\n * merely paints over the page leaves whatever is under it under it. navi\n * answers that for what it places itself — a popup is sized and positioned\n * against the app rectangle the keyboard was just subtracted from — and for\n * anything marked [data-navi-safe-area], whose scroll-padding-bottom counts\n * the keyboard in (safe_area.js). A field in a scroller the app never marked\n * is the app's own to handle.\n */\n\nimport { setVirtualKeyboardOverlaysContent } from \"@jsenv/dom\";\n\nsetVirtualKeyboardOverlaysContent(true);\n\nexport const disableVirtualKeyboardOverlay = () => {\n setVirtualKeyboardOverlaysContent(false);\n};\n",
|
|
19
|
-
"/**\n * Regroup CSS vars that makes sense to share across all navi components.\n */\nimport { effect } from \"@preact/signals\";\n\nimport {\n visualViewportHeightSignal,\n visualViewportWidthSignal,\n} from \"./layout/responsive.js\";\nimport \"./layout/safe_area.js\";\n// Side-effect import: turns the on-screen keyboard into something that\n// overlays the app instead of resizing the viewport, which is navi's default\n// (see that module for why, and safe_area.js's own\n// --navi-keyboard-inset-bottom for what then reads the geometry). Here rather\n// than in each component: it is one decision about the whole window, and this\n// is the module that already makes those.\nimport \"./layout/virtual_keyboard.js\";\n\nconst button = document.createElement(\"button\");\nbutton.style.display = \"none\";\ndocument.body.appendChild(button);\nconst computedStyle = getComputedStyle(button);\nconst controlDefaultFontFamily = computedStyle.fontFamily;\nconst controlDefaultFontSize = computedStyle.fontSize;\ndocument.body.removeChild(button);\n\nconst css = /* css */ `\n @layer navi {\n :root {\n /* Overridden at runtime with precise VisualViewport pixel values so that dvw/dvh \n (which don't track the virtual keyboard dimensions) are never used in practice on supported browsers. */\n --navi-vvw: 100dvw;\n --navi-vvh: 100dvh;\n\n /* What navi treats as \"the screen\" when it sizes something that escapes\n normal flow (a dialog in the top layer, a popover, anything built on\n them): the app's own rectangle, which is the visual viewport minus the\n bands an app asks for. An app that never spans the whole window says so\n ONCE, without ever naming a component:\n\n :root {\n --navi-app-max-width: 600px;\n }\n\n In pixels — see readAppMax in layout/responsive.js for why.\n\n Typically an app simulating a handheld screen: a column centered in a\n wide window with bands on the sides. A dialog is in the top layer, so\n it answers to the viewport, not to that column — left alone it would\n paint 1500px of modal over a 600px app. Declaring the ceiling here is\n what makes the app's own width apply everywhere, including on top.\n\n It stays a ceiling and nothing else: on a screen narrower than the app\n it never binds, so popups keep shrinking with the phone, and the gap\n each popup keeps with the edges (marginWithContainer) is subtracted\n from it as before. A single popup that genuinely needs more can still\n raise its own maxWidth/maxHeight prop.\n\n Read from the insets rather than as a min() of its own so that the\n width and the placement come from ONE description of where the app is\n (see layout/safe_area.js). Placement does not follow yet everywhere —\n see \"Current limitations\" in docs/css_architecture.md. */\n --navi-app-width: calc(\n var(--navi-vvw) - var(--navi-app-inset-left) - var(\n --navi-app-inset-right\n )\n );\n --navi-app-height: calc(\n var(--navi-vvh) - var(--navi-app-inset-top) - var(\n --navi-app-inset-bottom\n )\n );\n\n --navi-focus-outline-width: 2px;\n --navi-focus-outline-color: light-dark(#4476ff, #3b82f6);\n --navi-loader-color: light-dark(#355fcc, #3b82f6);\n /* The grey rectangle a mobile browser flashes under a finger: we draw\n what a press does ourselves (background, ring, chevron), and the\n browser's rectangle is a square painted over rounded corners.\n Declared here alone because the property is inherited: :root covers\n every element, in and out of navi, so nothing has to remember it.\n Set the token to a color to get the flash back, on the whole app or\n on one subtree. */\n -webkit-tap-highlight-color: var(--navi-tap-highlight-color);\n --navi-tap-highlight-color: transparent;\n\n /* A face of their own for the controls, and nothing else on the page.\n Undeclared here on purpose: controls write in inherit, so what a\n container says reaches the button, the field and the picker inside it,\n and a value on :root would resolve here and wall that off again. Set\n it to give the controls their own face, on the whole app or on one\n subtree:\n\n :root {\n --navi-control-font-family: system-ui;\n }\n */\n --navi-control-font-size: ${controlDefaultFontSize};\n --navi-control-border-radius: 2px;\n /* A checkbox is only ~1em wide: applying a control radius meant for\n buttons/inputs (say 8px) would turn it into a circle and make it read\n as a radio. So it follows the control radius but capped at a quarter\n of its own size. Override this token alone to opt out of the cap. */\n --navi-checkbox-border-radius: min(\n var(--navi-control-border-radius),\n 0.25em\n );\n /* The color a control uses to say \"this one is on\": a checked checkbox,\n a checked radio, an enabled switch. Kept apart from --navi-accent-color\n (the brand color for CTA and selection) because a control that is on\n must stay readable as a control, which usually means a brighter, more\n saturated color than a brand accent.\n The light value matches the browser's own accent so a navi control and\n a native one can sit side by side without reading as two blues. */\n --navi-control-accent-color: light-dark(rgb(24, 117, 255), #3b82f6);\n --navi-control-border-width: 1px;\n --navi-control-border-color: light-dark(#767676, #8e8e93);\n --navi-control-padding-x-default: 2px;\n --navi-control-padding-y-default: 1px;\n /* Global padding defaults — override these to change all button paddings. */\n /* Use --button-padding, --button-padding-x, --button-padding-y for per-button overrides. */\n --navi-button-padding-x-default: 6px;\n --navi-button-padding-y-default: 1px;\n /* Read by Button, and by whatever is drawn as one (a picker under\n variant=\"button\"), so the two keep the same surface. */\n --navi-button-background-color: light-dark(#f3f4f6, #2d3748);\n\n /* What a callout says about its message, as a color — and what draws\n as one outside the callout too (the icon a picker in callout mode\n presses, see CalloutStatusIcon). */\n --navi-callout-success-color: #4caf50;\n --navi-callout-info-color: #2196f3;\n --navi-callout-warning-color: #ff9800;\n --navi-callout-error-color: #f44336;\n /* No status: the message is an aside, said without an icon and framed\n like a control rather than like a warning. */\n --navi-callout-neutral-color: var(--navi-control-border-color);\n /* For list item we need slightly more padding to be able to see radio/checkbox outline */\n --navi-list-item-padding-x-default: 4px;\n --navi-list-item-padding-y-default: 1px;\n /* default */\n --navi-picker-padding-x-default: var(--navi-control-padding-x-default);\n --navi-picker-padding-y-default: var(--navi-control-padding-y-default);\n\n --navi-popup-border-radius: 8px;\n --navi-popup-border-color: light-dark(#d0d0d0, #3b3b3b);\n --navi-popup-box-shadow:\n 0 4px 8px rgba(0, 0, 0, 0.08), 0 12px 40px rgba(0, 0, 0, 0.22);\n /* A popup's paper is the same paper as everything else's: derived from\n the surface token so one override themes fields, layouts and popups\n together. Still its own token, so popups alone can be re-papered. */\n --navi-popup-background-color: var(--navi-surface-color);\n /* The ink written on that paper. A popup declares both on itself (see\n dialog.jsx / popover.jsx): it is a new surface, so it does not write\n in whatever the container it was declared in writes in. */\n --navi-popup-color: var(--navi-surface-text-color);\n --navi-backdrop-close-background: rgba(0, 0, 0, 0.08);\n /* backdropVariant=\"discrete\": the popup still catches every outside\n click, it just stops announcing that it did. For an affordance one\n reaches past rather than through — the dim is there to mark the layer,\n not to push the page behind it away. */\n --navi-backdrop-discrete-background: rgba(0, 0, 0, 0.02);\n /* \"capture\" means the rest of the page is fully non-interactive —\n blurred, not just dimmed, so it reads as clearly secondary and\n pulls visual focus onto the popover's own content. */\n --navi-backdrop-capture-background: rgb(255 255 255 / 0.08);\n --navi-backdrop-capture-backdrop-filter: blur(30px) saturate(180%);\n\n /* Link colors. They live here rather than only on .navi_link because a\n var declared on the element itself always beats the same var inherited\n from an ancestor: a page setting --link-color-pressed on :root would\n never reach a link. These :root tokens are the theme-level surface;\n --link-color-* stays the per-link (or per-subtree) override. */\n --navi-link-color: rgb(0, 0, 238);\n /* --navi-link-color-visited is intentionally left undefined: by default a\n visited link is derived from whatever --link-color ended up being on\n the link itself (see link.jsx), which a :root token cannot see. Set it\n here from an app to pin one visited color for every link. */\n --navi-link-color-pressed: red;\n --navi-link-current-indicator-color: rgb(205, 52, 37);\n\n --navi-selection-border-color: #0078d4;\n --navi-selection-background-color: #eaf1fd;\n /* Accent color — used for call-to-action buttons and selected list items.\n Override this single variable to apply a consistent brand color across\n all components that need to stand out. */\n --navi-accent-color: rgb(3, 30, 60);\n /* The surface a control's content sits on — the \"paper\" behind the text.\n Fields use it whenever they need a solid background (their default\n background, a transparent field being edited, …). */\n --navi-surface-color: light-dark(#ffffff, #1c1c1e);\n /* What is written on that paper. The browser's own text color rather\n than a literal, so it follows color-scheme the way the paper does. */\n --navi-surface-text-color: CanvasText;\n /* The line that separates two regions of one surface — a scrolling area's\n header from what scrolls under it, for instance. Not a border: the\n separation belongs to the layout, not to the box that draws it. */\n --navi-separator-color-default: #d1d9e0;\n --navi-color-white: white;\n --navi-color-dark: rgb(55, 60, 69);\n\n --navi-info-color-light: #eaf6fc;\n --navi-info-color: #376cc2;\n --navi-success-color-light: #ecf9ef;\n --navi-success-color: #50c464;\n --navi-warning-color-light: #fdf6e3;\n --navi-warning-color: #f19c05;\n --navi-error-color-light: #fcebed;\n --navi-error-color: #eb364b;\n\n --navi-xxs: 0.125em; /* = 2px at 16px base */\n --navi-xs: 0.25em; /* = 4px at 16px base */\n --navi-s: 0.5em; /* = 8px at 16px base */\n --navi-m: 1em; /* = 16px at 16px base (base font size) */\n --navi-l: 1.5em; /* = 24px at 16px base */\n --navi-xl: 2em; /* = 32px at 16px base */\n --navi-xxl: 3em; /* 48px at 16px base */\n\n --navi-typo-xxs: 0.625rem; /* 10px at 16px base */\n --navi-typo-xs: 0.75rem; /* 12px at 16px base */\n --navi-typo-s: 0.875rem; /* 14px at 16px base */\n --navi-typo-m: 1rem; /* 16px at 16px base (base font size) */\n --navi-typo-l: 1.125rem; /* 18px at 16px base */\n --navi-typo-xl: 1.25rem; /* 20px at 16px base */\n --navi-typo-xxl: 1.5rem; /* 24px at 16px base */\n\n /* The line every text is written on, controls included. A number\n rather than \"normal\", because of the emoji: its box is taller than a\n letter's, so under \"normal\" the one line carrying one stands taller\n than the lines around it, and under a tighter line its top is cut.\n 1.25 is where neither happens — 1 clips the glyph, 1.5 spaces the rows\n out more than reading them asks for — which also makes it the floor\n for an app that displays what people typed.\n The document is written on it; the components that come with a line of\n their own from the browser (Button, Input, Textarea, Select) are handed\n it by name. One number everywhere is what keeps a value on the same\n line whether it is typed in a field or drawn in the page.\n See docs/typography.md. */\n --navi-line-height: 1.25;\n /* Layered: the document's line is what navi writes on when the app says\n nothing, so the app's own body rule wins. */\n line-height: var(--navi-line-height);\n /* The face everything is written in, controls included — they inherit\n it rather than carrying one of their own, so an app names its typeface\n once, wherever it wants it to start: on the document, on a section, on\n a popup opened with font=\"…\". The browser's own control face rather\n than the document's default, so that a page saying nothing gets its\n text and its buttons in one face instead of two.\n Layered like the line above, so the app's own rule wins. */\n font-family: ${controlDefaultFontFamily};\n /* The same line for a control, snapped to the pixel. The browser lays a\n line out at its exact height but paints the glyph on a pixel row: at\n the default control size (13.333px) the line is 16.666px, and the\n two-thirds that do not fit go entirely under the glyph, which then\n sits a pixel above the middle of its field. A whole number of pixels\n has no remainder to put anywhere. Written with em so it resolves on\n the control that uses it, at that control's own size. */\n --navi-control-line-height: round(\n calc(var(--navi-line-height) * 1em),\n 1px\n );\n\n /* Color keywords.\n primary: the ink of the paper, at full strength. An absolute\n rather than currentColor: it is what brings a run back\n to plain text inside a muted one (a value in a secondary\n label). Themed through --navi-surface-text-color.\n secondary: supporting text, captions, less important labels\n emphasis: reinforce meaning, make content stand out more\n discrete: unobtrusive elements that shouldn't compete for attention\n hint: barely-there color, watermarks, ghost placeholders\n The last four mix currentColor toward transparent or black, so they\n follow whatever ink a container writes in: a dark card sets color and\n nothing else. The share of ink in each is the theme's knob, a -mix\n token read wherever the formulas are declared — here and on each\n surface that re-declares them (rules below) — so a ratio set on :root\n reaches the page and its popups alike. Set on a container it reaches\n none of that container's own text, a var() resolving where its custom\n property is declared; a container that wants one keyword otherwise\n for ITS paper (a white at 88% where 80% of white reads too faint on a\n colored resin) pins the keyword itself, and that value stops at the\n next surface. */\n --navi-color-secondary-mix: 80%;\n --navi-color-emphasis-mix: 50%;\n --navi-color-discrete-mix: 60%;\n --navi-color-hint-mix: 25%;\n --navi-color-primary: var(--navi-surface-text-color);\n\n /* What a control shows while it holds nothing: the ::placeholder of an\n input, the empty value slot of a picker. One place for the whole app:\n\n :root {\n --navi-placeholder-color: #8a94a6;\n --navi-placeholder-font-style: italic;\n }\n */\n --navi-placeholder-color: var(--navi-color-discrete);\n --navi-placeholder-font-style: normal;\n }\n\n /* A surface is a new paper: it writes in an ink of its own, and the\n keywords below are computed against that ink rather than the\n container's. A popup writes in --navi-popup-color. */\n .navi_popover,\n .navi_dialog {\n --navi-color-primary: var(--navi-popup-color);\n }\n /* A callout writes in the UA's own ink: callout.js sets color: revert on\n a [popover] element, which the UA styles CanvasText. */\n .navi_callout {\n --navi-color-primary: CanvasText;\n }\n\n /* The keywords derived from the ink, declared on every paper — :root, and\n each surface that is a new one — so what a container pinned for its own\n paper stops at the surface. The -mix ratios (see :root) are read where\n this is declared, and each paper resolves currentColor as its own. On\n the element itself, so a surface beats the container's value whatever\n the layer; an app that wants a surface to keep its container's ink says\n so on the surface, unlayered, and wins in turn. */\n :root,\n .navi_popover,\n .navi_dialog,\n .navi_callout {\n --navi-color-secondary: color-mix(\n in srgb,\n currentColor var(--navi-color-secondary-mix),\n transparent\n );\n --navi-color-emphasis: color-mix(\n in srgb,\n currentColor var(--navi-color-emphasis-mix),\n black\n );\n --navi-color-discrete: color-mix(\n in srgb,\n currentColor var(--navi-color-discrete-mix),\n transparent\n );\n --navi-color-hint: color-mix(\n in srgb,\n currentColor var(--navi-color-hint-mix),\n transparent\n );\n }\n }\n\n /* Hidden appearance */\n input[navi-visually-hidden],\n button[navi-visually-hidden],\n div[navi-visually-hidden] {\n position: absolute;\n top: 0;\n left: 0;\n z-index: -1;\n /* Important to take full size so that scrollIntoView work as expected */\n width: 100%;\n height: 100%;\n margin: 0;\n padding: 0;\n white-space: nowrap;\n border: 0;\n clip-path: inset(50%);\n appearance: none;\n overflow: hidden;\n\n &[navi-debug] {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n clip-path: none;\n appearance: auto;\n }\n }\n`;\nimport.meta.css = css;\n\neffect(() => {\n document.documentElement.style.setProperty(\n \"--navi-vvw\",\n `${visualViewportWidthSignal.value}px`,\n );\n document.documentElement.style.setProperty(\n \"--navi-vvh\",\n `${visualViewportHeightSignal.value}px`,\n );\n});\n"
|
|
19
|
+
"/**\n * Regroup CSS vars that makes sense to share across all navi components.\n */\nimport { effect } from \"@preact/signals\";\n\nimport {\n visualViewportHeightSignal,\n visualViewportWidthSignal,\n} from \"./layout/responsive.js\";\nimport \"./layout/safe_area.js\";\n// Side-effect import: turns the on-screen keyboard into something that\n// overlays the app instead of resizing the viewport, which is navi's default\n// (see that module for why, and safe_area.js's own\n// --navi-keyboard-inset-bottom for what then reads the geometry). Here rather\n// than in each component: it is one decision about the whole window, and this\n// is the module that already makes those.\nimport \"./layout/virtual_keyboard.js\";\n\nconst button = document.createElement(\"button\");\nbutton.style.display = \"none\";\ndocument.body.appendChild(button);\nconst computedStyle = getComputedStyle(button);\nconst controlDefaultFontFamily = computedStyle.fontFamily;\nconst controlDefaultFontSize = computedStyle.fontSize;\ndocument.body.removeChild(button);\n\nconst css = /* css */ `\n @layer navi {\n :root {\n /* Overridden at runtime with precise VisualViewport pixel values so that dvw/dvh \n (which don't track the virtual keyboard dimensions) are never used in practice on supported browsers. */\n --navi-vvw: 100dvw;\n --navi-vvh: 100dvh;\n\n /* What navi treats as \"the screen\" when it sizes something that escapes\n normal flow (a dialog in the top layer, a popover, anything built on\n them): the app's own rectangle, which is the visual viewport minus the\n bands an app asks for. An app that never spans the whole window says so\n ONCE, without ever naming a component:\n\n :root {\n --navi-app-max-width: 600px;\n }\n\n In pixels — see readAppMax in layout/responsive.js for why.\n\n Typically an app simulating a handheld screen: a column centered in a\n wide window with bands on the sides. A dialog is in the top layer, so\n it answers to the viewport, not to that column — left alone it would\n paint 1500px of modal over a 600px app. Declaring the ceiling here is\n what makes the app's own width apply everywhere, including on top.\n\n It stays a ceiling and nothing else: on a screen narrower than the app\n it never binds, so popups keep shrinking with the phone, and the gap\n each popup keeps with the edges (marginWithContainer) is subtracted\n from it as before. A single popup that genuinely needs more can still\n raise its own maxWidth/maxHeight prop.\n\n Read from the insets rather than as a min() of its own so that the\n width and the placement come from ONE description of where the app is\n (see layout/safe_area.js). Placement does not follow yet everywhere —\n see \"Current limitations\" in docs/css_architecture.md. */\n --navi-app-width: calc(\n var(--navi-vvw) - var(--navi-app-inset-left) - var(\n --navi-app-inset-right\n )\n );\n --navi-app-height: calc(\n var(--navi-vvh) - var(--navi-app-inset-top) - var(\n --navi-app-inset-bottom\n )\n );\n\n --navi-focus-outline-width: 2px;\n --navi-focus-outline-color: light-dark(#4476ff, #3b82f6);\n --navi-loader-color: light-dark(#355fcc, #3b82f6);\n /* The grey rectangle a mobile browser flashes under a finger: we draw\n what a press does ourselves (background, ring, chevron), and the\n browser's rectangle is a square painted over rounded corners.\n Declared here alone because the property is inherited: :root covers\n every element, in and out of navi, so nothing has to remember it.\n Set the token to a color to get the flash back, on the whole app or\n on one subtree. */\n -webkit-tap-highlight-color: var(--navi-tap-highlight-color);\n --navi-tap-highlight-color: transparent;\n\n /* A face of their own for the controls, and nothing else on the page.\n Undeclared here on purpose: controls write in inherit, so what a\n container says reaches the button, the field and the picker inside it,\n and a value on :root would resolve here and wall that off again. Set\n it to give the controls their own face, on the whole app or on one\n subtree:\n\n :root {\n --navi-control-font-family: system-ui;\n }\n */\n --navi-control-font-size: ${controlDefaultFontSize};\n --navi-control-border-radius: 2px;\n /* A checkbox is only ~1em wide: applying a control radius meant for\n buttons/inputs (say 8px) would turn it into a circle and make it read\n as a radio. So it follows the control radius but capped at a quarter\n of its own size. Override this token alone to opt out of the cap. */\n --navi-checkbox-border-radius: min(\n var(--navi-control-border-radius),\n 0.25em\n );\n /* The color a control uses to say \"this one is on\": a checked checkbox,\n a checked radio, an enabled switch. Kept apart from --navi-accent-color\n (the brand color for CTA and selection) because a control that is on\n must stay readable as a control, which usually means a brighter, more\n saturated color than a brand accent.\n The light value matches the browser's own accent so a navi control and\n a native one can sit side by side without reading as two blues. */\n --navi-control-accent-color: light-dark(rgb(24, 117, 255), #3b82f6);\n --navi-control-border-width: 1px;\n --navi-control-border-color: light-dark(#767676, #8e8e93);\n --navi-control-padding-x-default: 2px;\n --navi-control-padding-y-default: 1px;\n /* Global padding defaults — override these to change all button paddings. */\n /* Use --button-padding, --button-padding-x, --button-padding-y for per-button overrides. */\n --navi-button-padding-x-default: 6px;\n --navi-button-padding-y-default: 1px;\n /* Read by Button, and by whatever is drawn as one (a picker under\n variant=\"button\"), so the two keep the same surface. */\n --navi-button-background-color: light-dark(#f3f4f6, #2d3748);\n\n /* What a callout says about its message, as a color — and what draws\n as one outside the callout too (the icon a picker in callout mode\n presses, see CalloutStatusIcon). */\n --navi-callout-success-color: #4caf50;\n --navi-callout-info-color: #2196f3;\n --navi-callout-warning-color: #ff9800;\n --navi-callout-error-color: #f44336;\n /* No status: the message is an aside, said without an icon and framed\n like a control rather than like a warning. */\n --navi-callout-neutral-color: var(--navi-control-border-color);\n /* For list item we need slightly more padding to be able to see radio/checkbox outline */\n --navi-list-item-padding-x-default: 4px;\n --navi-list-item-padding-y-default: 1px;\n /* default */\n --navi-picker-padding-x-default: var(--navi-control-padding-x-default);\n --navi-picker-padding-y-default: var(--navi-control-padding-y-default);\n\n --navi-popup-border-radius: 8px;\n --navi-popup-border-color: light-dark(#d0d0d0, #3b3b3b);\n --navi-popup-box-shadow:\n 0 4px 8px rgba(0, 0, 0, 0.08), 0 12px 40px rgba(0, 0, 0, 0.22);\n /* A popup's paper is the same paper as everything else's: derived from\n the surface token so one override themes fields, layouts and popups\n together. Still its own token, so popups alone can be re-papered. */\n --navi-popup-background-color: var(--navi-surface-color);\n /* The ink written on that paper. A popup declares both on itself (see\n dialog.jsx / popover.jsx): it is a new surface, so it does not write\n in whatever the container it was declared in writes in. */\n --navi-popup-color: var(--navi-surface-text-color);\n --navi-backdrop-close-background: rgba(0, 0, 0, 0.08);\n /* backdropVariant=\"discrete\": the popup still catches every outside\n click, it just stops announcing that it did. For an affordance one\n reaches past rather than through — the dim is there to mark the layer,\n not to push the page behind it away. */\n --navi-backdrop-discrete-background: rgba(0, 0, 0, 0.02);\n /* \"capture\" means the rest of the page is fully non-interactive —\n blurred, not just dimmed, so it reads as clearly secondary and\n pulls visual focus onto the popover's own content. */\n --navi-backdrop-capture-background: rgb(255 255 255 / 0.08);\n --navi-backdrop-capture-backdrop-filter: blur(30px) saturate(180%);\n\n /* Link colors. They live here rather than only on .navi_link because a\n var declared on the element itself always beats the same var inherited\n from an ancestor: a page setting --link-color-pressed on :root would\n never reach a link. These :root tokens are the theme-level surface;\n --link-color-* stays the per-link (or per-subtree) override. */\n --navi-link-color: rgb(0, 0, 238);\n /* --navi-link-color-visited is intentionally left undefined: by default a\n visited link is derived from whatever --link-color ended up being on\n the link itself (see link.jsx), which a :root token cannot see. Set it\n here from an app to pin one visited color for every link. */\n --navi-link-color-pressed: red;\n --navi-link-current-indicator-color: rgb(205, 52, 37);\n\n --navi-selection-border-color: #0078d4;\n --navi-selection-background-color: #eaf1fd;\n /* Accent color — used for call-to-action buttons and selected list items.\n Override this single variable to apply a consistent brand color across\n all components that need to stand out. */\n --navi-accent-color: rgb(3, 30, 60);\n /* The surface a control's content sits on — the \"paper\" behind the text.\n Fields use it whenever they need a solid background (their default\n background, a transparent field being edited, …). */\n --navi-surface-color: light-dark(#ffffff, #1c1c1e);\n /* What is written on that paper. The browser's own text color rather\n than a literal, so it follows color-scheme the way the paper does. */\n --navi-surface-text-color: CanvasText;\n /* The line that separates two regions of one surface — a scrolling area's\n header from what scrolls under it, for instance. Not a border: the\n separation belongs to the layout, not to the box that draws it. */\n --navi-separator-color-default: #d1d9e0;\n --navi-color-white: white;\n --navi-color-dark: rgb(55, 60, 69);\n\n --navi-info-color-light: #eaf6fc;\n --navi-info-color: #376cc2;\n --navi-success-color-light: #ecf9ef;\n --navi-success-color: #50c464;\n --navi-warning-color-light: #fdf6e3;\n --navi-warning-color: #f19c05;\n --navi-error-color-light: #fcebed;\n --navi-error-color: #eb364b;\n\n --navi-xxs: 0.125em; /* = 2px at 16px base */\n --navi-xs: 0.25em; /* = 4px at 16px base */\n --navi-s: 0.5em; /* = 8px at 16px base */\n --navi-m: 1em; /* = 16px at 16px base (base font size) */\n --navi-l: 1.5em; /* = 24px at 16px base */\n --navi-xl: 2em; /* = 32px at 16px base */\n --navi-xxl: 3em; /* 48px at 16px base */\n\n --navi-typo-xxs: 0.625rem; /* 10px at 16px base */\n --navi-typo-xs: 0.75rem; /* 12px at 16px base */\n --navi-typo-s: 0.875rem; /* 14px at 16px base */\n --navi-typo-m: 1rem; /* 16px at 16px base (base font size) */\n --navi-typo-l: 1.125rem; /* 18px at 16px base */\n --navi-typo-xl: 1.25rem; /* 20px at 16px base */\n --navi-typo-xxl: 1.5rem; /* 24px at 16px base */\n\n /* The line every text is written on, controls included. A number\n rather than \"normal\", because of the emoji: its box is taller than a\n letter's, so under \"normal\" the one line carrying one stands taller\n than the lines around it, and under a tighter line its top is cut.\n 1.25 is where neither happens — 1 clips the glyph, 1.5 spaces the rows\n out more than reading them asks for — which also makes it the floor\n for an app that displays what people typed.\n The document is written on it; the components that come with a line of\n their own from the browser (Button, Input, Textarea, Select) are handed\n it by name. One number everywhere is what keeps a value on the same\n line whether it is typed in a field or drawn in the page.\n See docs/typography.md. */\n --navi-line-height: 1.25;\n /* The face everything is written in, controls included — they inherit\n it rather than carrying one of their own, so an app names its typeface\n once, wherever it wants it to start: on the document, on a section, on\n a popup opened with font=\"…\". The browser's own control face rather\n than the document's default, so that a page saying nothing gets its\n text and its buttons in one face instead of two.\n Layered like the line above, so the app's own rule wins. */\n font-family: ${controlDefaultFontFamily};\n /* Layered: the document's line is what navi writes on when the app says\n nothing, so the app's own body rule wins. */\n line-height: var(--navi-line-height);\n /* The same line for a control, snapped to the pixel. The browser lays a\n line out at its exact height but paints the glyph on a pixel row: at\n the default control size (13.333px) the line is 16.666px, and the\n two-thirds that do not fit go entirely under the glyph, which then\n sits a pixel above the middle of its field. A whole number of pixels\n has no remainder to put anywhere. Written with em so it resolves on\n the control that uses it, at that control's own size. */\n --navi-control-line-height: round(\n calc(var(--navi-line-height) * 1em),\n 1px\n );\n\n /* Color keywords.\n primary: the ink of the paper, at full strength. An absolute\n rather than currentColor: it is what brings a run back\n to plain text inside a muted one (a value in a secondary\n label). Themed through --navi-surface-text-color.\n secondary: supporting text, captions, less important labels\n emphasis: reinforce meaning, make content stand out more\n discrete: unobtrusive elements that shouldn't compete for attention\n hint: barely-there color, watermarks, ghost placeholders\n The last four mix currentColor toward transparent or black, so they\n follow whatever ink a container writes in: a dark card sets color and\n nothing else. The share of ink in each is the theme's knob, a -mix\n token read wherever the formulas are declared — here and on each\n surface that re-declares them (rules below) — so a ratio set on :root\n reaches the page and its popups alike. Set on a container it reaches\n none of that container's own text, a var() resolving where its custom\n property is declared; a container that wants one keyword otherwise\n for ITS paper (a white at 88% where 80% of white reads too faint on a\n colored resin) pins the keyword itself, and that value stops at the\n next surface. */\n --navi-color-secondary-mix: 80%;\n --navi-color-emphasis-mix: 50%;\n --navi-color-discrete-mix: 60%;\n --navi-color-hint-mix: 25%;\n --navi-color-primary: var(--navi-surface-text-color);\n\n /* What a control shows while it holds nothing: the ::placeholder of an\n input, the empty value slot of a picker. One place for the whole app:\n\n :root {\n --navi-placeholder-color: #8a94a6;\n --navi-placeholder-font-style: italic;\n }\n */\n --navi-placeholder-color: var(--navi-color-discrete);\n --navi-placeholder-font-style: normal;\n }\n\n /* A surface is a new paper: it writes in an ink of its own, and the\n keywords below are computed against that ink rather than the\n container's. A popup writes in --navi-popup-color. */\n .navi_popover,\n .navi_dialog {\n --navi-color-primary: var(--navi-popup-color);\n }\n /* A callout writes in the UA's own ink: callout.js sets color: revert on\n a [popover] element, which the UA styles CanvasText. */\n .navi_callout {\n --navi-color-primary: CanvasText;\n }\n\n /* The keywords derived from the ink, declared on every paper — :root, and\n each surface that is a new one — so what a container pinned for its own\n paper stops at the surface. The -mix ratios (see :root) are read where\n this is declared, and each paper resolves currentColor as its own. On\n the element itself, so a surface beats the container's value whatever\n the layer; an app that wants a surface to keep its container's ink says\n so on the surface, unlayered, and wins in turn. */\n :root,\n .navi_popover,\n .navi_dialog,\n .navi_callout {\n --navi-color-secondary: color-mix(\n in srgb,\n currentColor var(--navi-color-secondary-mix),\n transparent\n );\n --navi-color-emphasis: color-mix(\n in srgb,\n currentColor var(--navi-color-emphasis-mix),\n black\n );\n --navi-color-discrete: color-mix(\n in srgb,\n currentColor var(--navi-color-discrete-mix),\n transparent\n );\n --navi-color-hint: color-mix(\n in srgb,\n currentColor var(--navi-color-hint-mix),\n transparent\n );\n }\n }\n\n /* Hidden appearance */\n input[navi-visually-hidden],\n button[navi-visually-hidden],\n div[navi-visually-hidden] {\n position: absolute;\n top: 0;\n left: 0;\n z-index: -1;\n /* Important to take full size so that scrollIntoView work as expected */\n width: 100%;\n height: 100%;\n margin: 0;\n padding: 0;\n white-space: nowrap;\n border: 0;\n clip-path: inset(50%);\n appearance: none;\n overflow: hidden;\n\n &[navi-debug] {\n position: static;\n width: auto;\n height: auto;\n margin: 0;\n clip-path: none;\n appearance: auto;\n }\n }\n`;\nimport.meta.css = css;\n\neffect(() => {\n document.documentElement.style.setProperty(\n \"--navi-vvw\",\n `${visualViewportWidthSignal.value}px`,\n );\n document.documentElement.style.setProperty(\n \"--navi-vvh\",\n `${visualViewportHeightSignal.value}px`,\n );\n});\n"
|
|
20
20
|
]
|
|
21
21
|
}
|
package/docs/AI_INSTRUCTIONS.md
CHANGED
|
@@ -82,7 +82,8 @@ consistency across the app, not from any single call site.
|
|
|
82
82
|
- `docs/error_handling.md` — the two kinds of error and how navi keeps them
|
|
83
83
|
apart: where an error is shown depending on where it came from (a control's
|
|
84
84
|
action shows it on what was clicked, a route action replaces the page, a
|
|
85
|
-
refused value is validation and not an error at all),
|
|
85
|
+
refused value is validation and not an error at all), what a failing run
|
|
86
|
+
rejects with and who is allowed to swallow that rejection,
|
|
86
87
|
the `__handled_by__` mark that means "this error is on screen somewhere" (and
|
|
87
88
|
what `preact/debug` throws over your app without it), what becomes of an error
|
|
88
89
|
nobody displays, and the two rules any error boundary must follow — mark only
|
|
@@ -212,7 +213,12 @@ consistency across the app, not from any single call site.
|
|
|
212
213
|
which fields count as already held (a `defaultValue` is a suggestion, a
|
|
213
214
|
`signal` carrying something is an answer). Read it before a screen that
|
|
214
215
|
modifies an existing resource (`pristineKey`), and before reaching for
|
|
215
|
-
`canSendWhileUnchanged` because a submit "does nothing".
|
|
216
|
+
`canSendWhileUnchanged` because a submit "does nothing". It also holds
|
|
217
|
+
`standalone`, the prop EVERY control takes to stay out of the form, picker or
|
|
218
|
+
group around it — a door that opens something, a picker that commits on its
|
|
219
|
+
own, a list acting on every touch. Read it before writing a control inside a
|
|
220
|
+
group whose value it has no business joining; and note what it does not say
|
|
221
|
+
(`disabled`/`readOnly`/`loading` go on reaching it).
|
|
216
222
|
- `distributeChildStates` on a group — the way down asked once for ALL the
|
|
217
223
|
children, when they cannot be placed one at a time (four seats where who sits
|
|
218
224
|
down decides who moves), and asked again whenever one of them speaks, so the
|
|
@@ -283,7 +289,9 @@ consistency across the app, not from any single call site.
|
|
|
283
289
|
that picks one of its three modes (does it write to the control it sits in?),
|
|
284
290
|
the `Box` form that claims interactions and nothing more (so an affordance
|
|
285
291
|
keeps its own drawing instead of becoming a control), and what the claim does
|
|
286
|
-
NOT stop (a plain `onClick` on an ancestor).
|
|
292
|
+
NOT stop (a plain `onClick` on an ancestor). It also draws the line with
|
|
293
|
+
`standalone`: those two answer about the GESTURE, `standalone` about the
|
|
294
|
+
VALUE, and they are three separate props because they come apart.
|
|
287
295
|
Read it before reading the pointer by hand — who owns a press between nested
|
|
288
296
|
boxes, and what a touch may do, are decided before the first pixel moves and
|
|
289
297
|
cannot be got right from outside navi — and before stopping the propagation of
|
package/docs/actions.md
CHANGED
|
@@ -34,6 +34,14 @@ Two `bindParams` with equal params give **the same instance** (deep equality),
|
|
|
34
34
|
which is what makes state shared between two components asking for the same
|
|
35
35
|
thing, and what deduplicates their requests.
|
|
36
36
|
|
|
37
|
+
It is the params that are compared, never what produced them: four components
|
|
38
|
+
deriving the same question through four `computed` of their own still ask it
|
|
39
|
+
once, and read one state. So a derived params signal can be built wherever it is
|
|
40
|
+
convenient — a `useMemo` per caller included — and nothing has to arrange for
|
|
41
|
+
one instance of it. What this rests on is params being plain data: a `Set`, a
|
|
42
|
+
`Map`, an element, a function or a signal inside them is compared by reference,
|
|
43
|
+
and four callers then really do make four requests.
|
|
44
|
+
|
|
37
45
|
An action is callable, and calling it is the short way to bind and run in one
|
|
38
46
|
go:
|
|
39
47
|
|
|
@@ -49,9 +57,18 @@ flow — where the params are known at that moment and the run is the point:
|
|
|
49
57
|
const deleteGame = (game) => GAME.DELETE({ id: game.id });
|
|
50
58
|
```
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
A run that fails hands the failure back — it **rejects**, or **throws
|
|
61
|
+
synchronously** when the callback was synchronous — so a gesture behaves like any
|
|
62
|
+
other call that can fail: an `await` on it throws, and an action awaiting it
|
|
63
|
+
fails with it, which is what puts the error on the control that asked for the
|
|
64
|
+
gesture, with nothing to write for it. See [error_handling.md](./error_handling.md#what-a-failing-action-does)
|
|
65
|
+
for the runs that have nobody to reject at, and what they do instead.
|
|
66
|
+
|
|
67
|
+
Use `bindParams` when what you need is the **instance**, not the run: to keep a
|
|
68
|
+
handle on it, or so that a run started in one place is visible from another —
|
|
69
|
+
that shared identity is the whole of what it adds, and a control does not need it
|
|
70
|
+
to draw its own busy and error states (see
|
|
71
|
+
[resource.md](./resource.md#a-function-calling-the-verb-or-the-instance)).
|
|
55
72
|
Note that calling the action `rerun()`s it — the run happens even if that
|
|
56
73
|
instance already holds data, which is what you want from a gesture and not what
|
|
57
74
|
you want from a component asking for data.
|
|
@@ -288,6 +305,51 @@ one that asks something of a control near it is a `command` (a value proposed is
|
|
|
288
305
|
[control_value.md](./control_value.md#a-button-that-proposes-a-value-is---navi-update)).
|
|
289
306
|
Reaching for a plain `onClick` usually means one of those was missed.
|
|
290
307
|
|
|
308
|
+
An `action` and a `command` on the same press are one gesture in two halves, and
|
|
309
|
+
the command is the second one: it waits for the action and runs only if it
|
|
310
|
+
succeeded. "Leave the group, then go back to the list" therefore never leaves
|
|
311
|
+
anyone on a list they are still in — a failure or an abort drops the command and
|
|
312
|
+
the error stays on the control, where the user is looking. `<Picker
|
|
313
|
+
type="confirm">` follows the same rule with its `action` and `command`, and a
|
|
314
|
+
form says it as `data-after-send`.
|
|
315
|
+
|
|
316
|
+
### Deleting something, then leaving the page it was on
|
|
317
|
+
|
|
318
|
+
The everyday form of that pair, and the press is a `<Picker type="confirm">`
|
|
319
|
+
rather than a `<Button>`: what is destroyed is asked about first, the question
|
|
320
|
+
is a popup the picker already owns, and the work runs back on the trigger.
|
|
321
|
+
|
|
322
|
+
```jsx
|
|
323
|
+
<Picker
|
|
324
|
+
type="confirm"
|
|
325
|
+
ui="Leave this group"
|
|
326
|
+
message="Leave this group? An invitation will be needed to come back."
|
|
327
|
+
action={() => PLAYER_GROUP.LEAVE({ id: group.id })}
|
|
328
|
+
command="--navi-nav-to:/me/groups"
|
|
329
|
+
/>
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Yes closes the question and the request goes from the trigger — busy while it
|
|
333
|
+
runs, an error callout on it if it fails — and the list is reached only once the
|
|
334
|
+
group is really gone. A failure leaves the reader on the page of a group they
|
|
335
|
+
are still in, which is the truth, with the reason in front of them.
|
|
336
|
+
|
|
337
|
+
Navigating first and awaiting after — `navTo(...)` then `await leave(...)` — buys
|
|
338
|
+
the frame where this page would render without what it displays, and pays for it
|
|
339
|
+
with a reader standing in a list that still holds the group they think they left,
|
|
340
|
+
told nothing. There is usually no frame to buy anyway: the response and the
|
|
341
|
+
navigation land in the same task.
|
|
342
|
+
|
|
343
|
+
Where to go afterwards is the app's decision, not navi's. Staying on the page to
|
|
344
|
+
say it is done, or letting the route land on its empty state, are as good as
|
|
345
|
+
leaving for the list; what the `command` settles is only that the success is
|
|
346
|
+
what decides.
|
|
347
|
+
|
|
348
|
+
A function calling the verb, not the instance: the trigger is busy and draws the
|
|
349
|
+
error either way, and `bindParams` earns its place only when something else on
|
|
350
|
+
screen must see this very run — see
|
|
351
|
+
[resource.md](./resource.md#a-function-calling-the-verb-or-the-instance).
|
|
352
|
+
|
|
291
353
|
On a link — a `Link`, a `<Button href>` or `<Button route>` — the three fire on
|
|
292
354
|
the press, before the navigation, and the navigation waits for none of them: a
|
|
293
355
|
`command="--navi-close"` closes the sheet the link leaves, an `action` that
|
package/docs/control_object.md
CHANGED
|
@@ -80,9 +80,10 @@ there.
|
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
A nameless **leaf** is a different story: it is a control whose value has
|
|
83
|
-
nowhere to go, and it is warned about.
|
|
84
|
-
only opens something
|
|
85
|
-
|
|
83
|
+
nowhere to go, and it is warned about. A control that is not answering this
|
|
84
|
+
group's question at all — one that only opens something, one that commits on
|
|
85
|
+
its own — says so with `standalone` and leaves the group entirely (see
|
|
86
|
+
[form_changed.md](./form_changed.md#a-control-that-answers-for-itself)).
|
|
86
87
|
|
|
87
88
|
## A picker whose value is an object
|
|
88
89
|
|
|
@@ -117,20 +118,26 @@ Two things to get right:
|
|
|
117
118
|
url.
|
|
118
119
|
- **A control that helps FIND the answer is not the answer.** A search box above
|
|
119
120
|
a long list, a "select all" beside it: they are tools, and a tool says so with
|
|
120
|
-
`
|
|
121
|
+
`standalone`. The picker then walks past it and talks to the list, which is
|
|
121
122
|
what a popup made of one choice and the means to reach it needs. Never put it
|
|
122
123
|
on the control that IS the value — the picker would have nobody left to fill,
|
|
123
|
-
and the popup would open blank on a value it holds
|
|
124
|
+
and the popup would open blank on a value it holds.
|
|
124
125
|
|
|
125
126
|
```jsx
|
|
126
127
|
<Picker name="place_ids" type="array">
|
|
127
|
-
<Input
|
|
128
|
+
<Input standalone placeholder="chercher" navi-list="places" />
|
|
128
129
|
<List id="places" selectable multiple>
|
|
129
130
|
…
|
|
130
131
|
</List>
|
|
131
132
|
</Picker>
|
|
132
133
|
```
|
|
133
134
|
|
|
135
|
+
A popup can also hold **no** answer for the picker: several lists that are the
|
|
136
|
+
same one choice, each acting on every touch, nothing read back on close. Mark
|
|
137
|
+
them all `standalone` and the picker talks to none of them — the shape to reach
|
|
138
|
+
for when one `ControlGroup` around them would be wrong, because its value would
|
|
139
|
+
be one key per list where the answer is a single value.
|
|
140
|
+
|
|
134
141
|
## A group holds what it was given
|
|
135
142
|
|
|
136
143
|
A group's value looks like it is made of its children, and mostly it is — but
|
package/docs/error_handling.md
CHANGED
|
@@ -169,15 +169,47 @@ server runs too, or both — is `docs/field_validation.md`.
|
|
|
169
169
|
|
|
170
170
|
## What a failing action does
|
|
171
171
|
|
|
172
|
-
It writes the error into `errorSignal`, moves to `FAILED`, and
|
|
172
|
+
It writes the error into `errorSignal`, moves to `FAILED`, and hands the failure
|
|
173
|
+
back to whoever asked for the run.
|
|
173
174
|
|
|
174
|
-
**A run
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
175
|
+
**A failing run fails the way it ran.** `run()`, `rerun()`, `prerun()` — and
|
|
176
|
+
`ACTION(params)`, which is `bindParams(params).rerun()` — reject with the error
|
|
177
|
+
when the callback was asynchronous, and **throw it synchronously** when it was
|
|
178
|
+
not: code that knows its action is synchronous writes a plain `try/catch` around
|
|
179
|
+
the call and has to find the failure there, not in a rejected promise it never
|
|
180
|
+
awaited. Either way it fails like any other call that can fail, and an action
|
|
181
|
+
awaiting another one fails with it:
|
|
179
182
|
|
|
180
|
-
|
|
183
|
+
```js
|
|
184
|
+
// GROUP.LEAVE fails → this rejects → the button's action fails → the button
|
|
185
|
+
// shows the error, and a command following it does not run
|
|
186
|
+
<Button action={() => GROUP.LEAVE({ id: group.id })} />
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The error is in `errorSignal` all the same: a screen reads it there whether or
|
|
190
|
+
not anything was waiting on the promise. `useAsyncData`, `errorSignal` and an
|
|
191
|
+
`onError` passed to the run are still how a render asks for it.
|
|
192
|
+
|
|
193
|
+
Code that lets such a rejection float gets the runtime's own unhandled-error
|
|
194
|
+
report — window `error`, the jsenv overlay in dev — and that is the right answer
|
|
195
|
+
for it: nothing had to guess whether someone was waiting.
|
|
196
|
+
|
|
197
|
+
**Some runs have nobody to reject at**, and they say so where they start: one
|
|
198
|
+
started from a signal effect because its params changed, one whose failure the
|
|
199
|
+
control that started it already draws, a routing whose result every caller drops.
|
|
200
|
+
They pass through [`runUnwatched()`](../src/action/run_unwatched.js), which takes
|
|
201
|
+
the rejection without hiding the error — it stays in `errorSignal`. That is the
|
|
202
|
+
whole list of places navi swallows one, and taking it is what obliges that place
|
|
203
|
+
to ask the next question: did anything display this error? (below).
|
|
204
|
+
|
|
205
|
+
Two things must never become a rejection, and do not. **A navigation**: the
|
|
206
|
+
routing's promise is what the browser calls the navigation itself, and rejecting
|
|
207
|
+
it would abort a navigation whose page is busy explaining what failed. **The end
|
|
208
|
+
of an update**: several actions run for one update, so their outcomes are
|
|
209
|
+
gathered with `allSettled` — one failure is an outcome to record, not something
|
|
210
|
+
that takes the other five down.
|
|
211
|
+
|
|
212
|
+
Why the failure does no more than that: **at the instant an action fails, nothing can
|
|
181
213
|
know whether a screen will display it.** A route action runs before its page
|
|
182
214
|
renders — often before that page exists — so any decision made there about "will
|
|
183
215
|
someone show this?" is a guess, and it is wrong for the most common case in the
|
package/docs/form_changed.md
CHANGED
|
@@ -157,21 +157,37 @@ holds it back and says what it is waiting for.
|
|
|
157
157
|
</Button>
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
-
## A control that
|
|
160
|
+
## A control that answers for itself
|
|
161
161
|
|
|
162
162
|
A control inside a form is expected to carry a value under its name, and a
|
|
163
163
|
nameless one is warned about — its state would silently stay out of what is
|
|
164
|
-
sent.
|
|
165
|
-
|
|
166
|
-
`
|
|
164
|
+
sent. But some controls inside a form are not answering the form's question at
|
|
165
|
+
all: a picker that commits what it holds on its own, a door that only opens
|
|
166
|
+
something, a list acting on every touch. They say so with `standalone`:
|
|
167
167
|
|
|
168
168
|
```jsx
|
|
169
|
-
<Picker
|
|
169
|
+
<Picker standalone ui={…} action={commit} />
|
|
170
170
|
```
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
172
|
+
`standalone` is not a filter, it is the absence of a relationship: the control
|
|
173
|
+
does not register with the group around it. Nothing goes up — its value is not
|
|
174
|
+
collected, and the form stays unchanged whatever it does — and nothing comes
|
|
175
|
+
down either: the form's reset does not reach it, nor does its validation
|
|
176
|
+
cascade.
|
|
177
|
+
|
|
178
|
+
What it does **not** say is "ignore everything around me". `disabled`,
|
|
179
|
+
`readOnly` and `loading` travel on their own contexts and go on reaching it,
|
|
180
|
+
and that is deliberate: a door that carries no value of its own can still
|
|
181
|
+
write into the form through what its popup does, and a read-only form must
|
|
182
|
+
still shut it. An affordance that genuinely writes nowhere says that
|
|
183
|
+
separately, with `whenSelfInteractionsBlocked="ignore"` — as it says whose
|
|
184
|
+
press it is with `selfInteractions`. Three questions, three props (see
|
|
185
|
+
[interactions.md](./interactions.md#the-third-question-whose-value-is-it)).
|
|
186
|
+
|
|
187
|
+
Every control takes the prop, groups included — `<Form standalone>` is the
|
|
188
|
+
same sentence, and so is a `<List standalone>` inside a picker's popup, which
|
|
189
|
+
is what lets a search box or a second list sit beside the one that IS the
|
|
190
|
+
answer (see
|
|
175
191
|
[control_object.md](./control_object.md#a-picker-whose-value-is-an-object)).
|
|
176
192
|
|
|
177
193
|
## See also
|
package/docs/interactions.md
CHANGED
|
@@ -558,11 +558,11 @@ the edge one grabs to carry it.
|
|
|
558
558
|
|
|
559
559
|
So the claim names its interactions, and what it does not name stays the zone's:
|
|
560
560
|
|
|
561
|
-
| written
|
|
562
|
-
|
|
|
563
|
-
| `selfInteractions="click"`
|
|
564
|
-
| `selfInteractions="click drag"
|
|
565
|
-
| `selfInteractions="*"`
|
|
561
|
+
| written | takes | leaves |
|
|
562
|
+
| ------------------------------- | ---------------------------- | ------------------------------------------ |
|
|
563
|
+
| `selfInteractions="click"` | the press | the grab — the card is still carried by it |
|
|
564
|
+
| `selfInteractions="click drag"` | both | — |
|
|
565
|
+
| `selfInteractions="*"` | every gesture, now and later | — |
|
|
566
566
|
|
|
567
567
|
`"*"` is there for the case where it is true, not as a shorthand: it is the one
|
|
568
568
|
value that will silently swallow a gesture navi has not shipped yet.
|
|
@@ -578,11 +578,11 @@ becomes of the affordance where the zone around it is disabled or read-only.
|
|
|
578
578
|
The claimed interactions are its subject, and only them: the ones left to the
|
|
579
579
|
zone were never this element's to block.
|
|
580
580
|
|
|
581
|
-
| what it does | written
|
|
582
|
-
| ------------------------------------------------------------ |
|
|
583
|
-
| writes to it (a cross that removes, a stepper) | nothing — `"hide"` is the default
|
|
584
|
-
| writes to it, and its presence says there is something there | `whenSelfInteractionsBlocked="refuse"`
|
|
585
|
-
| never touches it (a diskette saving into MY address book) | `whenSelfInteractionsBlocked="ignore"`
|
|
581
|
+
| what it does | written | on a blocked zone |
|
|
582
|
+
| ------------------------------------------------------------ | -------------------------------------- | ----------------------------------------- |
|
|
583
|
+
| writes to it (a cross that removes, a stepper) | nothing — `"hide"` is the default | it goes |
|
|
584
|
+
| writes to it, and its presence says there is something there | `whenSelfInteractionsBlocked="refuse"` | it stays and refuses with a callout |
|
|
585
|
+
| never touches it (a diskette saving into MY address book) | `whenSelfInteractionsBlocked="ignore"` | nothing changes: still lit, still pressed |
|
|
586
586
|
|
|
587
587
|
A greyed cross that still removes is worse than no cross — hence the default.
|
|
588
588
|
`"ignore"` is the other extreme and the caller owns it: the zone's read-only is
|
|
@@ -593,6 +593,28 @@ when that is really the case.
|
|
|
593
593
|
Busy is not on the list because busy does not block: it is the read-only a
|
|
594
594
|
running action sets on its way that does.
|
|
595
595
|
|
|
596
|
+
#### The third question: whose value is it?
|
|
597
|
+
|
|
598
|
+
`selfInteractions` and `whenSelfInteractionsBlocked` are about the **gesture** —
|
|
599
|
+
who a pointer event belongs to, and what a block on the box around it does to
|
|
600
|
+
that gesture. Whose **value** an element carries is a separate question, and
|
|
601
|
+
`standalone` answers it: the control does not register with the form, picker or
|
|
602
|
+
group around it, so what it holds never joins that value (see
|
|
603
|
+
[form_changed.md](./form_changed.md#a-control-that-answers-for-itself)).
|
|
604
|
+
|
|
605
|
+
The three come apart, which is why they are three props:
|
|
606
|
+
|
|
607
|
+
| the element | says |
|
|
608
|
+
| ------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
609
|
+
| a chip's cross | `selfInteractions` — it is a button, it never carried a value |
|
|
610
|
+
| a door opening a sheet that writes into the form | `standalone` — no value of its own, but a read-only form must still shut it |
|
|
611
|
+
| a diskette filing a name into the reader's address book | all three — own press, block is not about it, own value |
|
|
612
|
+
|
|
613
|
+
Reading `standalone` as "ignore everything around me" is the trap: `disabled`,
|
|
614
|
+
`readOnly` and `loading` travel on their own contexts and go on reaching it,
|
|
615
|
+
because "what do I hold" and "may anything be changed here" are not the same
|
|
616
|
+
question.
|
|
617
|
+
|
|
596
618
|
#### On something you draw yourself
|
|
597
619
|
|
|
598
620
|
`selfInteractions` is a `Box` prop too, so an affordance does not have to become
|
package/docs/popup_open.md
CHANGED
|
@@ -298,7 +298,7 @@ about components, not about the DOM.
|
|
|
298
298
|
### Composing a value, or doing work
|
|
299
299
|
|
|
300
300
|
A picker mirrors **one** control in its popup — the first one that is not a
|
|
301
|
-
button, a link or a control
|
|
301
|
+
button, a link or a control that answers for itself (`standalone`). That
|
|
302
302
|
mirror is what makes `<Picker><List selectable/></Picker>` work with nothing
|
|
303
303
|
wired: the picker's value IS the list's, both ways, and the picker's `action`
|
|
304
304
|
runs on it when the popup closes.
|
package/docs/resource.md
CHANGED
|
@@ -400,33 +400,53 @@ graph: what used to be refreshed by the parent's own response is now refreshed
|
|
|
400
400
|
only by these rules. When a parent field genuinely depends on a child mutation,
|
|
401
401
|
say so with `dependencies` rather than relying on a rerun that will not happen.
|
|
402
402
|
|
|
403
|
-
##
|
|
403
|
+
## A function calling the verb, or the instance
|
|
404
404
|
|
|
405
|
-
|
|
406
|
-
own state. Pass that instance to a component:
|
|
405
|
+
Both forms are real, and what separates them is narrower than it looks:
|
|
407
406
|
|
|
408
407
|
```jsx
|
|
409
|
-
//
|
|
408
|
+
// a function that calls the verb — the nominal form
|
|
409
|
+
<Button action={() => GAME_CANDIDATES.POST({ id: game.id, user_id })}>
|
|
410
|
+
Accept
|
|
411
|
+
</Button>
|
|
412
|
+
|
|
413
|
+
// the instance, bound to its params
|
|
410
414
|
<Button action={GAME_CANDIDATES.POST.bindParams({ id: game.id, user_id })}>
|
|
411
415
|
Accept
|
|
412
416
|
</Button>
|
|
413
417
|
```
|
|
414
418
|
|
|
419
|
+
The function gives up almost nothing. Either way the button is busy while the
|
|
420
|
+
write runs, shows the error if it fails and goes back to what it held; either way
|
|
421
|
+
what runs inside is the same resource run, so the store is updated and the
|
|
422
|
+
actions this mutation invalidates rerun the same. The failure travels too — the
|
|
423
|
+
call fails, so the action around it fails
|
|
424
|
+
([error_handling.md](./error_handling.md#what-a-failing-action-does)).
|
|
425
|
+
|
|
426
|
+
What `bindParams` adds, and it adds only this, is **identity**: everyone binding
|
|
427
|
+
the same params holds one instance, so a run started in one place is visible from
|
|
428
|
+
another. Reach for it when something else on screen has to see this very run — a
|
|
429
|
+
user row in a side explorer going loading while the main page renames that user,
|
|
430
|
+
two affordances for the same delete that must both go busy at once. Nothing else
|
|
431
|
+
in the stack answers that question: the store holds the data, not who is
|
|
432
|
+
currently writing it.
|
|
433
|
+
|
|
434
|
+
Where it does not pay, and most call sites are here: one affordance, in one
|
|
435
|
+
place, that nobody else watches. A `bindParams({ id })` written per row builds an
|
|
436
|
+
identity nothing reads, and a plain function says the gesture more directly.
|
|
437
|
+
|
|
438
|
+
What genuinely loses what a resource gives is neither of those two, but a
|
|
439
|
+
callback that goes around the verb:
|
|
440
|
+
|
|
415
441
|
```jsx
|
|
416
|
-
// ✗
|
|
442
|
+
// ✗ no resource run at all: nothing updates the store, nothing is invalidated
|
|
417
443
|
<Button
|
|
418
|
-
action={
|
|
419
|
-
await acceptCandidate(game.id, user_id);
|
|
420
|
-
}}
|
|
444
|
+
action={() => fetch(`/games/${game.id}/candidates`, { method: "POST" })}
|
|
421
445
|
>
|
|
422
446
|
Accept
|
|
423
447
|
</Button>
|
|
424
448
|
```
|
|
425
449
|
|
|
426
|
-
The arrow works, but nothing tracks it: no per-row spinner, no error surfaced on
|
|
427
|
-
the button that caused it, no deduplication of concurrent runs, no autorerun of
|
|
428
|
-
the actions this mutation should invalidate. See [actions.md](./actions.md).
|
|
429
|
-
|
|
430
450
|
Away from a component, where the run is a gesture and not something to render,
|
|
431
451
|
an action is callable: `GAME.DELETE({ id })` is
|
|
432
452
|
`GAME.DELETE.bindParams({ id }).rerun()`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jsenv/navi",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.158",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Library of components including navigation to create frontend applications",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"prepublishOnly": "npm run build"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@jsenv/dom": "0.17.
|
|
32
|
+
"@jsenv/dom": "0.17.37",
|
|
33
33
|
"@jsenv/humanize": "1.7.8",
|
|
34
34
|
"@jsenv/validity": "0.4.3"
|
|
35
35
|
},
|