Skip to content

Commit ec257f0

Browse files
committed
work
1 parent 515760e commit ec257f0

6 files changed

Lines changed: 191 additions & 58 deletions

File tree

packages/frontend/navi/dist/jsenv_navi.js

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
66
export { disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
77
import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, getScrollContainer, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, initFocusGroup, scrollIntoViewScoped, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, isTouchDrivenEvent, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8-
export { clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
8+
export { chainEvent, clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
99
import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
1010
import { isValidElement, h, Fragment, createContext, render, toChildArray, options, cloneElement } from "preact";
1111
import { useErrorBoundary, useLayoutEffect, useContext, useCallback, useRef, useState, useEffect, useMemo, useId } from "preact/hooks";
@@ -30465,7 +30465,16 @@ const anyMatchingRouteSignal = (routes) => {
3046530465
* @param {Element} element The element asking — the command's source, and the
3046630466
* anchor a popup opens on unless `anchor` says otherwise.
3046730467
* @param {string} command
30468-
* @param {Event} event What the user did.
30468+
* @param {Event} event What caused this. Mandatory: it is what makes a command
30469+
* traceable back to its origin — the debug panel groups everything a gesture
30470+
* set off under it, and the gates below read it to know whether the default
30471+
* was already prevented and which mouse button was pressed. When nothing was
30472+
* handed over — a timer firing, an action settling, a signal changing — build
30473+
* a `CustomEvent` that names what happened and chain it to whatever preceded
30474+
* it, rather than leaving the origin unsaid:
30475+
* const expiredEvent = new CustomEvent("session_expired");
30476+
* chainEvent(expiredEvent, causeEvent); // when something did precede it
30477+
* triggerNaviCommand(dialogEl, "--navi-open", expiredEvent);
3046930478
* @param {object} [options]
3047030479
* @param {boolean} [options.optional] No suitable target is not a warning.
3047130480
* @param {any} [options.value] What the command is about, carried to whoever
@@ -30482,6 +30491,11 @@ const triggerNaviCommand = (
3048230491
event,
3048330492
{ optional, value, anchor } = {},
3048430493
) => {
30494+
if (!event) {
30495+
throw new Error(
30496+
`"${command}" triggered without an event: it is mandatory, a command must say what caused it. Pass the gesture, or a CustomEvent naming the cause when no gesture did — see triggerNaviCommand's jsdoc.`,
30497+
);
30498+
}
3048530499
const naviCommand =
3048630500
NAVI_COMMANDS[command] || NAVI_COMMANDS[commandName(command)];
3048730501
if (!naviCommand) {
@@ -33597,6 +33611,12 @@ const GROUP_DEFAULTS = {
3359733611
*
3359833612
* **Filtering**: `childControlFilter` can exclude certain child types from aggregation
3359933613
* (e.g. ignoring buttons inside a selectable list).
33614+
*
33615+
* **Aggregating**: `aggregateChildStates(children, fallbackState, stateNow)` — what the
33616+
* children add up to. `fallbackState` is the empty of the declared `stateType`;
33617+
* `stateNow` is what the group is worth as it is asked, which an aggregate reads to
33618+
* answer for children that are not there (a selectable list whose selected row is not
33619+
* drawn).
3360033620
*/
3360133621
const useUIGroupStateController = (
3360233622
props,
@@ -33753,16 +33773,22 @@ const useUIGroupStateController = (
3375333773
// taking that for an answer is how a value handed to it evaporates on
3375433774
// the way in, and how that emptiness then travels back up to whoever
3375533775
// handed it (a picker showing its row as unanswered).
33756-
const aggregateGroupUIState = (whenNobodyCanAnswer) => {
33776+
//
33777+
// `stateNow` is what the group is worth as it is asked: what it keeps
33778+
// when there is nobody to ask, and what an aggregate reads to answer for
33779+
// what its children do not say — a selection whose row is not drawn (see
33780+
// ListSelectable) lives there and nowhere else.
33781+
const aggregateGroupUIState = (stateNow) => {
3375733782
const someChildCanAnswer = childUIStateControllerArray.some(
3375833783
shouldPropagateStateToChild,
3375933784
);
3376033785
if (!someChildCanAnswer) {
33761-
return whenNobodyCanAnswer;
33786+
return stateNow;
3376233787
}
3376333788
const aggChildState = resolvedAggregateChildStates(
3376433789
childUIStateControllerArray,
3376533790
fallbackState,
33791+
stateNow,
3376633792
);
3376733793
if (aggChildState !== undefined) {
3376833794
return aggChildState;
@@ -34286,8 +34312,6 @@ const useUIGroupStateController = (
3428634312
// ── update: runs every render after the first ─────────────────────────
3428734313
(s) => {
3428834314
const { controller } = s;
34289-
const prevValue = controller.value;
34290-
const prevHasValueProp = controller.hasValueProp;
3429134315
const prevDefaultValue = controller.defaultValue;
3429234316
controller.props = props;
3429334317
controller.ref = ref;
@@ -34305,10 +34329,14 @@ const useUIGroupStateController = (
3430534329
controller.placeChildrenUIState(groupUIState, propagateDownEvent);
3430634330
controller.syncInternalState(groupUIState);
3430734331
};
34308-
if (
34309-
hasValueProp &&
34310-
(!prevHasValueProp || !compareTwoJsValues(value, prevValue))
34311-
) {
34332+
// A controlled group goes on showing the value it is given. A child
34333+
// answering for itself moves what the group is worth — that is what
34334+
// `uiAction` reports — but the value stays the owner's, so the test is
34335+
// against what the children are showing, not against the value handed
34336+
// down last time. A popup reopened on another subject hands down the very
34337+
// same empty value it did before, and the selection left inside it from
34338+
// the previous opening is what has to go.
34339+
if (hasValueProp && !compareTwoJsValues(value, controller.uiState)) {
3431234340
placeChildrenFrom(value);
3431334341
}
3431434342
if (
@@ -61346,23 +61374,22 @@ const ListSelectable = props => {
6134661374
focusGroupDirection,
6134761375
focusGroupWrap
6134861376
} = props;
61349-
// What the list holds, which is not the same as what its rows say. A list
61350-
// draws the rows it needs and no more: the selected one may be scrolled out
61351-
// of the window, or filtered out of the view. Aggregating over the rows that
61352-
// happen to be mounted would then lose the selection — a row that is not
61353-
// there cannot say it is not selected.
61354-
const selectionRef = useRef(undefined);
61355-
if (selectionRef.current === undefined) {
61356-
selectionRef.current = Object.hasOwn(props, "value") ? props.value : props.defaultValue;
61357-
}
61377+
// `kept` is what the list holds as it is asked, which is not the same as what
61378+
// its rows say: a list draws the rows it needs and no more, so the selected
61379+
// one may be scrolled out of the window or filtered out of the view, and a
61380+
// row that is not there cannot say it is not selected. Reading it off the
61381+
// group rather than remembering it here is what makes a value put ON the list
61382+
// (a `value` prop, a signal, a reopened popup) replace the whole selection —
61383+
// a private memory of its own would go on holding the rows it could not see
61384+
// being unselected.
61385+
//
6135861386
// `fallbackState` is the empty of the shape the list declared below
6135961387
// (`stateType`): `[]` for a multiple list, nothing for a single one. Taking
6136061388
// it is what lets an emptied list say "empty" — `undefined` is the word for
6136161389
// "unset", and a bound stateSignal reads that as "nothing decided here, go
6136261390
// back to the default" (see docs/control_value.md), which is how a list
6136361391
// emptied down to its last row puts that row back on reload.
61364-
const aggregateChildStates = (children, fallbackState) => {
61365-
const kept = selectionRef.current;
61392+
const aggregateChildStates = (children, fallbackState, kept) => {
6136661393
if (multiple) {
6136761394
const drawnValues = new Set(children.map(child => child.props.value));
6136861395
const stillSelected = Array.isArray(kept) ? kept.filter(value => !drawnValues.has(value)) : [];
@@ -61371,21 +61398,17 @@ const ListSelectable = props => {
6137161398
stillSelected.push(child.uiState);
6137261399
}
6137361400
}
61374-
const values = stillSelected.length === 0 ? fallbackState : stillSelected;
61375-
selectionRef.current = values;
61376-
return values;
61401+
return stillSelected.length === 0 ? fallbackState : stillSelected;
6137761402
}
6137861403
for (const child of children) {
6137961404
if (child.uiState !== undefined) {
61380-
selectionRef.current = child.uiState;
6138161405
return child.uiState;
6138261406
}
6138361407
}
6138461408
// No drawn row claims it. If the row that held it IS drawn, it was really
6138561409
// deselected; if it is not, the list keeps what it holds.
6138661410
const keptIsDrawn = children.some(child => child.props.value === kept);
6138761411
if (keptIsDrawn) {
61388-
selectionRef.current = undefined;
6138961412
return undefined;
6139061413
}
6139161414
return kept;

packages/frontend/navi/dist/jsenv_navi.js.map

Lines changed: 4 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/frontend/navi/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@jsenv/navi",
3-
"version": "0.29.141",
3+
"version": "0.29.142",
44
"type": "module",
55
"description": "Library of components including navigation to create frontend applications",
66
"repository": {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" href="data:," />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>List value in dialog repro</title>
8+
</head>
9+
<body>
10+
<div id="root"></div>
11+
<script type="module" jsenv-type="module/jsx">
12+
import { render } from "preact";
13+
import { useState } from "preact/hooks";
14+
import {
15+
applySearch,
16+
Button,
17+
Dialog,
18+
Input,
19+
List,
20+
useSearchText,
21+
} from "@jsenv/navi";
22+
23+
const PLAYERS = [
24+
{ id: "u-1", name: "Marie" },
25+
{ id: "u-2", name: "Paul" },
26+
{ id: "u-3", name: "Jacques" },
27+
{ id: "u-4", name: "Sophie" },
28+
];
29+
30+
const App = () => {
31+
const [seat, setSeat] = useState(null);
32+
const [seats, setSeats] = useState({});
33+
const [picked, setPicked] = useState(null);
34+
const [searchText, setSearchText] = useState("");
35+
const [players] = useState(PLAYERS);
36+
const [orderedPlayers, getMatchInfo] = useSearchText(
37+
searchText,
38+
players,
39+
(text, player) => applySearch(text, player.name),
40+
);
41+
42+
return (
43+
<div>
44+
{[1, 2, 3, 4].map((n) => (
45+
<Button
46+
key={n}
47+
data-testid={`seat_${n}`}
48+
command="--navi-open"
49+
commandFor="seat_dialog"
50+
value={n}
51+
onClick={() => {
52+
setSeat(n);
53+
setPicked(seats[n] || null);
54+
}}
55+
>
56+
seat {n}: {seats[n] || "—"}
57+
</Button>
58+
))}
59+
<pre id="state">{JSON.stringify(seats)}</pre>
60+
<Dialog id="seat_dialog">
61+
<div id="footer">
62+
picked: {picked || "aucun"} (seat {seat})
63+
</div>
64+
<Input
65+
type="search"
66+
navi-list="seat_list"
67+
placeholder="Rechercher"
68+
value={searchText}
69+
uiAction={(v) => setSearchText(v || "")}
70+
/>
71+
<List
72+
id="seat_list"
73+
selectable
74+
multiple
75+
maxLengthGuard={1}
76+
value={picked ? [picked] : []}
77+
uiAction={(v) => {
78+
const first = v && v[0];
79+
setPicked(first || null);
80+
if (first) {
81+
setSeats((s) => ({ ...s, [seat]: first }));
82+
}
83+
}}
84+
>
85+
{orderedPlayers.map((player) => (
86+
<List.Item
87+
key={player.id}
88+
value={player.id}
89+
matchInfo={getMatchInfo(player)}
90+
>
91+
{player.name}
92+
</List.Item>
93+
))}
94+
</List>
95+
<Button command="--navi-close">Fermer</Button>
96+
</Dialog>
97+
</div>
98+
);
99+
};
100+
101+
render(<App />, document.querySelector("#root"));
102+
</script>
103+
</body>
104+
</html>

packages/frontend/navi/src/control/list/list_selectable.jsx

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -250,25 +250,22 @@ const ListSelectable = (props) => {
250250
props.name = props.name || `listbox_${defaultName}`;
251251
const { ref, multiple, deselectable, focusGroupDirection, focusGroupWrap } =
252252
props;
253-
// What the list holds, which is not the same as what its rows say. A list
254-
// draws the rows it needs and no more: the selected one may be scrolled out
255-
// of the window, or filtered out of the view. Aggregating over the rows that
256-
// happen to be mounted would then lose the selection — a row that is not
257-
// there cannot say it is not selected.
258-
const selectionRef = useRef(undefined);
259-
if (selectionRef.current === undefined) {
260-
selectionRef.current = Object.hasOwn(props, "value")
261-
? props.value
262-
: props.defaultValue;
263-
}
253+
// `kept` is what the list holds as it is asked, which is not the same as what
254+
// its rows say: a list draws the rows it needs and no more, so the selected
255+
// one may be scrolled out of the window or filtered out of the view, and a
256+
// row that is not there cannot say it is not selected. Reading it off the
257+
// group rather than remembering it here is what makes a value put ON the list
258+
// (a `value` prop, a signal, a reopened popup) replace the whole selection —
259+
// a private memory of its own would go on holding the rows it could not see
260+
// being unselected.
261+
//
264262
// `fallbackState` is the empty of the shape the list declared below
265263
// (`stateType`): `[]` for a multiple list, nothing for a single one. Taking
266264
// it is what lets an emptied list say "empty" — `undefined` is the word for
267265
// "unset", and a bound stateSignal reads that as "nothing decided here, go
268266
// back to the default" (see docs/control_value.md), which is how a list
269267
// emptied down to its last row puts that row back on reload.
270-
const aggregateChildStates = (children, fallbackState) => {
271-
const kept = selectionRef.current;
268+
const aggregateChildStates = (children, fallbackState, kept) => {
272269
if (multiple) {
273270
const drawnValues = new Set(children.map((child) => child.props.value));
274271
const stillSelected = Array.isArray(kept)
@@ -279,21 +276,17 @@ const ListSelectable = (props) => {
279276
stillSelected.push(child.uiState);
280277
}
281278
}
282-
const values = stillSelected.length === 0 ? fallbackState : stillSelected;
283-
selectionRef.current = values;
284-
return values;
279+
return stillSelected.length === 0 ? fallbackState : stillSelected;
285280
}
286281
for (const child of children) {
287282
if (child.uiState !== undefined) {
288-
selectionRef.current = child.uiState;
289283
return child.uiState;
290284
}
291285
}
292286
// No drawn row claims it. If the row that held it IS drawn, it was really
293287
// deselected; if it is not, the list keeps what it holds.
294288
const keptIsDrawn = children.some((child) => child.props.value === kept);
295289
if (keptIsDrawn) {
296-
selectionRef.current = undefined;
297290
return undefined;
298291
}
299292
return kept;

packages/frontend/navi/src/control/ui_state_controller.js

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,12 @@ const GROUP_DEFAULTS = {
10131013
*
10141014
* **Filtering**: `childControlFilter` can exclude certain child types from aggregation
10151015
* (e.g. ignoring buttons inside a selectable list).
1016+
*
1017+
* **Aggregating**: `aggregateChildStates(children, fallbackState, stateNow)` — what the
1018+
* children add up to. `fallbackState` is the empty of the declared `stateType`;
1019+
* `stateNow` is what the group is worth as it is asked, which an aggregate reads to
1020+
* answer for children that are not there (a selectable list whose selected row is not
1021+
* drawn).
10161022
*/
10171023
export const useUIGroupStateController = (
10181024
props,
@@ -1176,16 +1182,22 @@ export const useUIGroupStateController = (
11761182
// taking that for an answer is how a value handed to it evaporates on
11771183
// the way in, and how that emptiness then travels back up to whoever
11781184
// handed it (a picker showing its row as unanswered).
1179-
const aggregateGroupUIState = (whenNobodyCanAnswer) => {
1185+
//
1186+
// `stateNow` is what the group is worth as it is asked: what it keeps
1187+
// when there is nobody to ask, and what an aggregate reads to answer for
1188+
// what its children do not say — a selection whose row is not drawn (see
1189+
// ListSelectable) lives there and nowhere else.
1190+
const aggregateGroupUIState = (stateNow) => {
11801191
const someChildCanAnswer = childUIStateControllerArray.some(
11811192
shouldPropagateStateToChild,
11821193
);
11831194
if (!someChildCanAnswer) {
1184-
return whenNobodyCanAnswer;
1195+
return stateNow;
11851196
}
11861197
const aggChildState = resolvedAggregateChildStates(
11871198
childUIStateControllerArray,
11881199
fallbackState,
1200+
stateNow,
11891201
);
11901202
if (aggChildState !== undefined) {
11911203
return aggChildState;
@@ -1709,8 +1721,6 @@ export const useUIGroupStateController = (
17091721
// ── update: runs every render after the first ─────────────────────────
17101722
(s) => {
17111723
const { controller } = s;
1712-
const prevValue = controller.value;
1713-
const prevHasValueProp = controller.hasValueProp;
17141724
const prevDefaultValue = controller.defaultValue;
17151725
controller.props = props;
17161726
controller.ref = ref;
@@ -1728,10 +1738,14 @@ export const useUIGroupStateController = (
17281738
controller.placeChildrenUIState(groupUIState, propagateDownEvent);
17291739
controller.syncInternalState(groupUIState);
17301740
};
1731-
if (
1732-
hasValueProp &&
1733-
(!prevHasValueProp || !compareTwoJsValues(value, prevValue))
1734-
) {
1741+
// A controlled group goes on showing the value it is given. A child
1742+
// answering for itself moves what the group is worth — that is what
1743+
// `uiAction` reports — but the value stays the owner's, so the test is
1744+
// against what the children are showing, not against the value handed
1745+
// down last time. A popup reopened on another subject hands down the very
1746+
// same empty value it did before, and the selection left inside it from
1747+
// the previous opening is what has to go.
1748+
if (hasValueProp && !compareTwoJsValues(value, controller.uiState)) {
17351749
placeChildrenFrom(value);
17361750
}
17371751
if (

0 commit comments

Comments
 (0)