@jsenv/navi 0.29.102 → 0.29.103

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.
@@ -1339,6 +1339,10 @@ naviI18n.addAll({
1339
1339
  en: "More actions",
1340
1340
  fr: "Autres actions",
1341
1341
  },
1342
+ "button.remove": {
1343
+ en: "Remove",
1344
+ fr: "Retirer",
1345
+ },
1342
1346
  });
1343
1347
 
1344
1348
  // Default built-in translations — apps can override any key via add()
@@ -1638,6 +1642,14 @@ naviI18n.addAll({
1638
1642
  fr: "Cette option n'est pas disponible.",
1639
1643
  en: "This option is not available.",
1640
1644
  },
1645
+ "constraint.readonly.selection": {
1646
+ fr: "La sélection ne peut plus être modifiée.",
1647
+ en: "This selection cannot be changed.",
1648
+ },
1649
+ "constraint.readonly.choice": {
1650
+ fr: "Ce choix ne peut plus être changé.",
1651
+ en: "This choice cannot be changed.",
1652
+ },
1641
1653
  "constraint.readonly.item": {
1642
1654
  fr: "Cet élément n'est pas disponible.",
1643
1655
  en: "This item is not available.",
@@ -2114,6 +2126,276 @@ naviI18n.addAll({
2114
2126
 
2115
2127
  const FormContext = createContext();
2116
2128
 
2129
+ const CONSTRAINT_ATTRIBUTE_SET = new Set();
2130
+
2131
+ const CONSTRAINT_NAME_TO_PROP = {
2132
+ disabled: "disabledMessage",
2133
+ required: "requiredMessage",
2134
+ pattern: "patternMessage",
2135
+ type_email: "typeMessage",
2136
+ type_number: "typeMessage",
2137
+ min_length: "minLengthMessage",
2138
+ max_length: "maxLengthMessage",
2139
+ min: "minMessage",
2140
+ max: "maxMessage",
2141
+ single_space: "singleSpaceMessage",
2142
+ same_as: "sameAsMessage",
2143
+ min_lower_letter: "minLowerLetterMessage",
2144
+ min_upper_letter: "minUpperLetterMessage",
2145
+ min_digit: "minDigitMessage",
2146
+ min_special_char: "minSpecialCharMessage",
2147
+ one_of: "oneOfMessage",
2148
+ readonly: "readOnlyMessage",
2149
+ available: "availableMessage",
2150
+ };
2151
+
2152
+ const CONSTRAINT_MESSAGE_PROP_NAME_SET = new Set(
2153
+ Object.values(CONSTRAINT_NAME_TO_PROP),
2154
+ );
2155
+
2156
+ const extractMessageAndRemainingProps = (props) => {
2157
+ const ownMessages = {};
2158
+ const remaining = {};
2159
+ const keyToVisit = new Set(Object.keys(props));
2160
+ for (const key of keyToVisit) {
2161
+ if (CONSTRAINT_MESSAGE_PROP_NAME_SET.has(key)) {
2162
+ ownMessages[key] = props[key];
2163
+ } else {
2164
+ remaining[key] = props[key];
2165
+ }
2166
+ }
2167
+ return [ownMessages, remaining];
2168
+ };
2169
+
2170
+ const getConstraintMessage = (
2171
+ controller,
2172
+ constraint,
2173
+ generatedMessage,
2174
+ { requester },
2175
+ ) => {
2176
+ const { name: constraintName } = constraint;
2177
+ const propName = CONSTRAINT_NAME_TO_PROP[constraintName];
2178
+
2179
+ // 1. Search first on the requester (e.g. the <li> that was clicked),
2180
+ // then fall back to element (e.g. the hidden <input>).
2181
+ if (requester) {
2182
+ const requesterController = requester.__uiStateController__;
2183
+ if (requesterController && requesterController !== controller) {
2184
+ const requesterControllerMessage = requesterController.props[propName];
2185
+ if (requesterControllerMessage) {
2186
+ return {
2187
+ message: requesterControllerMessage,
2188
+ origin: "requester controller",
2189
+ };
2190
+ }
2191
+ }
2192
+ }
2193
+
2194
+ const controllerMessage = controller.props[propName];
2195
+ if (controllerMessage) {
2196
+ return {
2197
+ message: controllerMessage,
2198
+ origin: "controller",
2199
+ };
2200
+ }
2201
+
2202
+ return {
2203
+ message: generatedMessage,
2204
+ origin: "generated message",
2205
+ };
2206
+ };
2207
+
2208
+ // prop that we'll set on the control
2209
+ const CONTROL_ATTRIBUTE_SET = new Set([
2210
+ ...CONSTRAINT_ATTRIBUTE_SET,
2211
+
2212
+ "ref",
2213
+ "children",
2214
+ "id",
2215
+ "name",
2216
+ "type",
2217
+ "value",
2218
+ "checked",
2219
+ "placeholder",
2220
+ "inputMode",
2221
+ "autoComplete",
2222
+ "spellcheck",
2223
+ "autoCorrect",
2224
+ "aria-controls",
2225
+ "tabIndex",
2226
+ "command",
2227
+ "commandFor",
2228
+ "command-value", // not standard but make sense, allow to give param to the command in question
2229
+ "list",
2230
+
2231
+ // "ui-action-target",
2232
+ "navi-input-type",
2233
+ "navi-value-pad",
2234
+ "navi-control-proxy-for",
2235
+ "navi-command-proxy-for",
2236
+ "navi-command-target",
2237
+ "onnavi_command",
2238
+ "onnavi_request_open",
2239
+ "onnavi_request_close",
2240
+
2241
+ "data-callout-arrow-x",
2242
+ "data-callout-point-to-border-box",
2243
+ "data-callout-point-to-content-box",
2244
+ "data-callout-viewport-spacing",
2245
+ "data-callout-position",
2246
+ "data-callout-position-fixed",
2247
+
2248
+ "data-testid", // playwright, cypress
2249
+ "data-separator", // used by InputGroup paste-to-fill
2250
+ ]);
2251
+ // prop concerning control but that won't end up in the DOM if not inside CONTROL_ATTRIBUTE_SET
2252
+ const CONTROL_PROP_SET = new Set([
2253
+ ...CONTROL_ATTRIBUTE_SET,
2254
+ ...CONSTRAINT_MESSAGE_PROP_NAME_SET,
2255
+
2256
+ "action",
2257
+ "confirm",
2258
+ "confirmPopupContent",
2259
+ "actionEvent",
2260
+ "actionAfterChange",
2261
+ "actionOnMouseDown",
2262
+ "actionDebounce",
2263
+ // A signal bound two-way to the control: its value seeds the control's state and
2264
+ // is written back on every uiAction. Precludes value/checked (see createControlInfo).
2265
+ "signal",
2266
+ "defaultValue",
2267
+ "defaultChecked",
2268
+ "readOnly", // will depend wether readOnly is supported
2269
+
2270
+ "loading",
2271
+ "basePseudoState",
2272
+ "constraints",
2273
+
2274
+ // A real target inside a zone that belongs to another control — see
2275
+ // own_target.js.
2276
+ "ownTarget",
2277
+
2278
+ "autoFocus",
2279
+ "autoFocusVisible",
2280
+ "autoFocusSelect",
2281
+
2282
+ "onMouseDown",
2283
+ "onClick",
2284
+ "onKeyDown",
2285
+ "onPaste",
2286
+ "onInput",
2287
+ "eventReactionDefinitions",
2288
+
2289
+ "onCancel",
2290
+ "cancelOnBlurInvalid",
2291
+ "cancelOnEscape",
2292
+ "onActionPrevented",
2293
+ "onActionStart",
2294
+ "onActionAborted",
2295
+ "onActionError",
2296
+ "actionErrorEffect",
2297
+ "errorMapping",
2298
+ "onActionEnd",
2299
+
2300
+ "resetOnCancel",
2301
+ "resetOnAbort",
2302
+ "resetOnError",
2303
+ "optimistic",
2304
+
2305
+ "charGuard",
2306
+ "maxLengthGuard",
2307
+ ]);
2308
+
2309
+ const MessagePropsRefContext = createContext();
2310
+
2311
+ const ControlIdContext = createContext();
2312
+ const ControlNameContext = createContext();
2313
+ const DisabledContext = createContext();
2314
+ const ReadOnlyContext = createContext();
2315
+ const RequiredContext = createContext();
2316
+ const LoadingContext$1 = createContext();
2317
+ createContext();
2318
+
2319
+ const ActionContext = createContext();
2320
+ const ActionRequesterContext = createContext();
2321
+
2322
+ /**
2323
+ * A target of its own inside a zone that belongs to another control.
2324
+ *
2325
+ * A pressable row, a picker's façade, a slide that travels under the finger:
2326
+ * each of them answers a press that lands anywhere in its box. An affordance an
2327
+ * application draws in there — a chip's cross, an eye that opens a profile, a
2328
+ * diskette that saves a guest — is aimed AT, not merely inside, and the press
2329
+ * belongs to it alone.
2330
+ *
2331
+ * Saying that by hand takes three guards, one per moment of the same press: the
2332
+ * pointerdown where gestures are arbitrated, the mousedown where a picker opens,
2333
+ * the click where it opens too when a gesture disputed the press. All three are
2334
+ * navi's own knowledge of navi's own event flow, and an application that gets
2335
+ * one wrong finds out by opening a popup it meant to keep shut. `ownTarget` is
2336
+ * that knowledge, said once.
2337
+ *
2338
+ * The other half is interactivity: the affordance is a control, so it already
2339
+ * refuses on its own terms once the zone around it holds it read-only — but a
2340
+ * caller's `onClick` is plain DOM and fires before any gate. So an own target
2341
+ * withholds the caller's handler until its own gate has allowed it, and by
2342
+ * default goes rather than greys: a remove cross that still removes is worse
2343
+ * than no cross, and one that refuses politely still says "there is something to
2344
+ * remove here" on a row that is only being read.
2345
+ */
2346
+
2347
+
2348
+ const OWN_TARGET_ATTRIBUTE = "data-navi-own-target";
2349
+
2350
+ /**
2351
+ * Whether `event` was aimed at an own target sitting below this control — in
2352
+ * which case the control is not what the press was for and must not answer it.
2353
+ *
2354
+ * Read from the event's target rather than from a mark left by a handler: the
2355
+ * question is "who is this press for", and the DOM between the pointer and the
2356
+ * control is the whole answer. Nothing is asked of the own target itself, which
2357
+ * is what lets it be anything — a button, a link, a field.
2358
+ */
2359
+ const isAimedAtOwnTargetBelow = (event, controlHost) => {
2360
+ const target = event?.target;
2361
+ if (!target || typeof target.closest !== "function") {
2362
+ return false;
2363
+ }
2364
+ const ownTarget = target.closest(`[${OWN_TARGET_ATTRIBUTE}]`);
2365
+ if (!ownTarget) {
2366
+ return false;
2367
+ }
2368
+ // The claim is against what is ABOVE it: the control that IS the own target,
2369
+ // and any control living inside it, are being aimed at like anything else.
2370
+ if (ownTarget === controlHost || ownTarget.contains(controlHost)) {
2371
+ return false;
2372
+ }
2373
+ // From the root rather than the host: a layered control (a picker holding an
2374
+ // input) has its gate on the input, and what the application drew sits beside
2375
+ // it, not in it.
2376
+ const controlRoot = controlHost.closest("[navi-control]") || controlHost;
2377
+ return controlRoot.contains(ownTarget);
2378
+ };
2379
+
2380
+ /**
2381
+ * Whether an own target has nothing to offer where it sits: the zone around it
2382
+ * is read-only, disabled or busy, so the affordance goes.
2383
+ *
2384
+ * `ownTarget="refuse"` keeps it on screen instead, refusing with a callout like
2385
+ * every other navi control — for an affordance whose presence is information in
2386
+ * itself (an eye that opens a profile is worth seeing on a row being read).
2387
+ */
2388
+ const useOwnTargetHidden = (props) => {
2389
+ const disabled = useContext(DisabledContext);
2390
+ const readOnly = useContext(ReadOnlyContext);
2391
+ const loading = useContext(LoadingContext$1);
2392
+ const { ownTarget } = props;
2393
+ if (!ownTarget || ownTarget === "refuse") {
2394
+ return false;
2395
+ }
2396
+ return Boolean(disabled || readOnly || loading);
2397
+ };
2398
+
2117
2399
  /*
2118
2400
  * Deep structural equality for arbitrary JS values — what `===` can't do but this
2119
2401
  * codebase constantly needs: memoization cache keys ({ id: 1 } equal to { id: 1 }),
@@ -6490,83 +6772,6 @@ const isInertOnClick = (element) => {
6490
6772
  return true;
6491
6773
  };
6492
6774
 
6493
- const CONSTRAINT_NAME_TO_PROP = {
6494
- disabled: "disabledMessage",
6495
- required: "requiredMessage",
6496
- pattern: "patternMessage",
6497
- type_email: "typeMessage",
6498
- type_number: "typeMessage",
6499
- min_length: "minLengthMessage",
6500
- max_length: "maxLengthMessage",
6501
- min: "minMessage",
6502
- max: "maxMessage",
6503
- single_space: "singleSpaceMessage",
6504
- same_as: "sameAsMessage",
6505
- min_lower_letter: "minLowerLetterMessage",
6506
- min_upper_letter: "minUpperLetterMessage",
6507
- min_digit: "minDigitMessage",
6508
- min_special_char: "minSpecialCharMessage",
6509
- one_of: "oneOfMessage",
6510
- readonly: "readOnlyMessage",
6511
- available: "availableMessage",
6512
- };
6513
-
6514
- const CONSTRAINT_MESSAGE_PROP_NAME_SET = new Set(
6515
- Object.values(CONSTRAINT_NAME_TO_PROP),
6516
- );
6517
-
6518
- const extractMessageAndRemainingProps = (props) => {
6519
- const ownMessages = {};
6520
- const remaining = {};
6521
- const keyToVisit = new Set(Object.keys(props));
6522
- for (const key of keyToVisit) {
6523
- if (CONSTRAINT_MESSAGE_PROP_NAME_SET.has(key)) {
6524
- ownMessages[key] = props[key];
6525
- } else {
6526
- remaining[key] = props[key];
6527
- }
6528
- }
6529
- return [ownMessages, remaining];
6530
- };
6531
-
6532
- const getConstraintMessage = (
6533
- controller,
6534
- constraint,
6535
- generatedMessage,
6536
- { requester },
6537
- ) => {
6538
- const { name: constraintName } = constraint;
6539
- const propName = CONSTRAINT_NAME_TO_PROP[constraintName];
6540
-
6541
- // 1. Search first on the requester (e.g. the <li> that was clicked),
6542
- // then fall back to element (e.g. the hidden <input>).
6543
- if (requester) {
6544
- const requesterController = requester.__uiStateController__;
6545
- if (requesterController && requesterController !== controller) {
6546
- const requesterControllerMessage = requesterController.props[propName];
6547
- if (requesterControllerMessage) {
6548
- return {
6549
- message: requesterControllerMessage,
6550
- origin: "requester controller",
6551
- };
6552
- }
6553
- }
6554
- }
6555
-
6556
- const controllerMessage = controller.props[propName];
6557
- if (controllerMessage) {
6558
- return {
6559
- message: controllerMessage,
6560
- origin: "controller",
6561
- };
6562
- }
6563
-
6564
- return {
6565
- message: generatedMessage,
6566
- origin: "generated message",
6567
- };
6568
- };
6569
-
6570
6775
  /**
6571
6776
  * DOM utilities for the proxy control pattern.
6572
6777
  *
@@ -8241,8 +8446,6 @@ const ABORTED = { id: "aborted" };
8241
8446
  const FAILED = { id: "failed" };
8242
8447
  const COMPLETED = { id: "completed" };
8243
8448
 
8244
- const CONSTRAINT_ATTRIBUTE_SET = new Set();
8245
-
8246
8449
  const BUSY_CONSTRAINT = {
8247
8450
  name: "busy",
8248
8451
  messageAttribute: "data-busy-message",
@@ -10295,6 +10498,20 @@ const onRequestInteraction = (
10295
10498
 
10296
10499
  const currentTarget = requestInteractionCustomEvent.currentTarget;
10297
10500
  const controlHost = findControlHost(currentTarget) || currentTarget;
10501
+
10502
+ // Aimed at something else that lives in this control's box: a chip's cross, an
10503
+ // eye, a diskette. The press is that affordance's alone (see own_target.js),
10504
+ // and stepping back here — rather than stopping the propagation over there —
10505
+ // is what leaves the event whole for everything that is not a navi
10506
+ // interaction. Stepping back, not refusing: the reaction never happened, so
10507
+ // its `prevented`/`always` (an `e.preventDefault()`, for most of them) have
10508
+ // nothing to undo and would take the press from the affordance itself.
10509
+ if (isAimedAtOwnTargetBelow(event, controlHost)) {
10510
+ debugInteraction(event, `"${name}" is for an own target below`);
10511
+ requestInteractionCustomEvent.preventDefault();
10512
+ return false;
10513
+ }
10514
+
10298
10515
  const controller = controlHost.__uiStateController__;
10299
10516
 
10300
10517
  if (controller && !bypassInteractivity) {
@@ -16085,6 +16302,26 @@ const POSITION_PROPS = {
16085
16302
  return { transform: `skew(${value})` };
16086
16303
  },
16087
16304
  };
16305
+ const singleLineEllipsisStyles = () => {
16306
+ return {
16307
+ overflow: "hidden",
16308
+ textOverflow: "ellipsis",
16309
+ overflowWrap: "normal",
16310
+ };
16311
+ };
16312
+ // The lines beyond the clamp are still laid out, so the clip edge decides
16313
+ // whether the top of the first hidden one is visible: "overflow: hidden" clips
16314
+ // at the padding box and lets it show inside the block-end padding. Clipping at
16315
+ // the content box instead ends the element right after its last visible line.
16316
+ const lineClampStyles = (value) => {
16317
+ return {
16318
+ "overflow": "clip",
16319
+ "overflowClipMargin": "content-box",
16320
+ "display": "-webkit-box",
16321
+ "-webkit-box-orient": "vertical",
16322
+ "-webkit-line-clamp": value,
16323
+ };
16324
+ };
16088
16325
  const TYPO_PROPS = {
16089
16326
  font: applyOnCSSProp("fontFamily"),
16090
16327
  fontFamily: PASS_THROUGH,
@@ -16122,39 +16359,21 @@ const TYPO_PROPS = {
16122
16359
  return null;
16123
16360
  }
16124
16361
  if (value === 1 || value === "1") {
16125
- return {
16126
- overflow: "hidden",
16127
- textOverflow: "ellipsis",
16128
- overflowWrap: "normal",
16129
- };
16362
+ return singleLineEllipsisStyles();
16130
16363
  }
16131
- return {
16132
- "overflow": "hidden",
16133
- "display": "-webkit-box",
16134
- "-webkit-box-orient": "vertical",
16135
- "-webkit-line-clamp": value,
16136
- };
16364
+ return lineClampStyles(value);
16137
16365
  },
16138
16366
  overflowEllipsis: (value) => {
16139
16367
  if (!value) {
16140
16368
  return null;
16141
16369
  }
16142
- return {
16143
- overflow: "hidden",
16144
- textOverflow: "ellipsis",
16145
- overflowWrap: "normal",
16146
- };
16370
+ return singleLineEllipsisStyles();
16147
16371
  },
16148
16372
  lineClamp: (value) => {
16149
16373
  if (!value) {
16150
16374
  return null;
16151
16375
  }
16152
- return {
16153
- "overflow": "hidden",
16154
- "display": "-webkit-box",
16155
- "-webkit-box-orient": "vertical",
16156
- "-webkit-line-clamp": value,
16157
- };
16376
+ return lineClampStyles(value);
16158
16377
  },
16159
16378
  textAlign: PASS_THROUGH,
16160
16379
  textBox: PASS_THROUGH,
@@ -16200,6 +16419,7 @@ const VISUAL_PROPS = {
16200
16419
  overflow: PASS_THROUGH,
16201
16420
  overflowX: PASS_THROUGH,
16202
16421
  overflowY: PASS_THROUGH,
16422
+ overflowClipMargin: PASS_THROUGH,
16203
16423
  objectFit: PASS_THROUGH,
16204
16424
  accentColor: PASS_THROUGH,
16205
16425
  scrollbarWidth: PASS_THROUGH,
@@ -23467,116 +23687,6 @@ registerNaviCommand("--navi-unselect", (source, event) => {
23467
23687
  };
23468
23688
  });
23469
23689
 
23470
- // prop that we'll set on the control
23471
- const CONTROL_ATTRIBUTE_SET = new Set([
23472
- ...CONSTRAINT_ATTRIBUTE_SET,
23473
-
23474
- "ref",
23475
- "children",
23476
- "id",
23477
- "name",
23478
- "type",
23479
- "value",
23480
- "checked",
23481
- "placeholder",
23482
- "inputMode",
23483
- "autoComplete",
23484
- "spellcheck",
23485
- "autoCorrect",
23486
- "aria-controls",
23487
- "tabIndex",
23488
- "command",
23489
- "commandFor",
23490
- "command-value", // not standard but make sense, allow to give param to the command in question
23491
- "list",
23492
-
23493
- // "ui-action-target",
23494
- "navi-input-type",
23495
- "navi-value-pad",
23496
- "navi-control-proxy-for",
23497
- "navi-command-proxy-for",
23498
- "navi-command-target",
23499
- "onnavi_command",
23500
- "onnavi_request_open",
23501
- "onnavi_request_close",
23502
-
23503
- "data-callout-arrow-x",
23504
- "data-callout-point-to-border-box",
23505
- "data-callout-point-to-content-box",
23506
- "data-callout-viewport-spacing",
23507
- "data-callout-position",
23508
- "data-callout-position-fixed",
23509
-
23510
- "data-testid", // playwright, cypress
23511
- "data-separator", // used by InputGroup paste-to-fill
23512
- ]);
23513
- // prop concerning control but that won't end up in the DOM if not inside CONTROL_ATTRIBUTE_SET
23514
- const CONTROL_PROP_SET = new Set([
23515
- ...CONTROL_ATTRIBUTE_SET,
23516
- ...CONSTRAINT_MESSAGE_PROP_NAME_SET,
23517
-
23518
- "action",
23519
- "confirm",
23520
- "confirmPopupContent",
23521
- "actionEvent",
23522
- "actionAfterChange",
23523
- "actionOnMouseDown",
23524
- "actionDebounce",
23525
- // A signal bound two-way to the control: its value seeds the control's state and
23526
- // is written back on every uiAction. Precludes value/checked (see createControlInfo).
23527
- "signal",
23528
- "defaultValue",
23529
- "defaultChecked",
23530
- "readOnly", // will depend wether readOnly is supported
23531
-
23532
- "loading",
23533
- "basePseudoState",
23534
- "constraints",
23535
-
23536
- "autoFocus",
23537
- "autoFocusVisible",
23538
- "autoFocusSelect",
23539
-
23540
- "onMouseDown",
23541
- "onClick",
23542
- "onKeyDown",
23543
- "onPaste",
23544
- "onInput",
23545
- "eventReactionDefinitions",
23546
-
23547
- "onCancel",
23548
- "cancelOnBlurInvalid",
23549
- "cancelOnEscape",
23550
- "onActionPrevented",
23551
- "onActionStart",
23552
- "onActionAborted",
23553
- "onActionError",
23554
- "actionErrorEffect",
23555
- "errorMapping",
23556
- "onActionEnd",
23557
-
23558
- "resetOnCancel",
23559
- "resetOnAbort",
23560
- "resetOnError",
23561
- "optimistic",
23562
-
23563
- "charGuard",
23564
- "maxLengthGuard",
23565
- ]);
23566
-
23567
- const MessagePropsRefContext = createContext();
23568
-
23569
- const ControlIdContext = createContext();
23570
- const ControlNameContext = createContext();
23571
- const DisabledContext = createContext();
23572
- const ReadOnlyContext = createContext();
23573
- const RequiredContext = createContext();
23574
- const LoadingContext$1 = createContext();
23575
- createContext();
23576
-
23577
- const ActionContext = createContext();
23578
- const ActionRequesterContext = createContext();
23579
-
23580
23690
  /**
23581
23691
  * How a control tells the labels pointing at it what it is (disabled, readOnly,
23582
23692
  * required) and when it goes away.
@@ -25798,18 +25908,18 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25798
25908
  if (child !== firstChildControllerRef.current) {
25799
25909
  return;
25800
25910
  }
25801
- if (
25802
- silent &&
25803
- uiStateHoldsNothing(child.uiState) &&
25804
- !uiStateHoldsNothing(s.realUIStateController.uiState)
25805
- ) {
25911
+ if (silent && uiStateHoldsNothing(child.uiState)) {
25806
25912
  // A silent sync means the child's own structure changed (children
25807
25913
  // mounted/unmounted), not that the user acted. A child that ends up
25808
25914
  // with no value there is one that currently *cannot* express one —
25809
25915
  // a <List loading> holds no items yet, a popup whose items are gone
25810
25916
  // aggregates to the empty array its stateType falls back to — which
25811
25917
  // must not read as the user clearing the picker, nor fire its
25812
- // uiAction.
25918
+ // uiAction. And when the picker holds nothing either, there is
25919
+ // still nothing to adopt: the sync would only respell one nothing
25920
+ // as another (an array picker's [] becoming undefined, say) and
25921
+ // hand that to uiAction — an empty array picker opened its popup
25922
+ // and told its owner the value changed.
25813
25923
  return;
25814
25924
  }
25815
25925
  updatingRef.current = true;
@@ -25822,7 +25932,18 @@ const useUIFacadeStateController = (props, realUIStateController) => {
25822
25932
  detail: {},
25823
25933
  });
25824
25934
  chainEvent(propagateUpEvent, e);
25825
- s.realUIStateController.setUIState(child.uiState, propagateUpEvent);
25935
+ // What the popup aggregates arrives in the popup's terms, where an
25936
+ // empty multiple list is `undefined`. The picker answers a question of
25937
+ // its own shape (see resolveEmptyUIState): a <Picker type="array">
25938
+ // whose last item was unselected holds [], the same thing clearing it
25939
+ // leaves — not a value that changes type on its owner the moment it
25940
+ // empties.
25941
+ const { emptyUIState } = s.realUIStateController;
25942
+ const uiStateToAdopt =
25943
+ child.uiState === undefined && emptyUIState !== undefined
25944
+ ? emptyUIState
25945
+ : child.uiState;
25946
+ s.realUIStateController.setUIState(uiStateToAdopt, propagateUpEvent);
25826
25947
  updatingRef.current = false;
25827
25948
  },
25828
25949
  };
@@ -26732,11 +26853,29 @@ const useControlProps = (props, {
26732
26853
  }
26733
26854
  return dispatched;
26734
26855
  };
26735
- const applyEventReaction = (eventName, e) => {
26856
+
26857
+ // What the caller wrote runs from inside the gate when this control is an
26858
+ // own target: a plain `onClick` is DOM, and it would fire from a cross drawn
26859
+ // greyed by the read-only control the affordance sits in (see own_target.js).
26860
+ const callerHandlerIsGated = Boolean(props.ownTarget);
26861
+ const gateCallerHandler = (handler, e) => {
26862
+ if (!handler) {
26863
+ return undefined;
26864
+ }
26865
+ if (callerHandlerIsGated) {
26866
+ return handler;
26867
+ }
26868
+ handler(e);
26869
+ return undefined;
26870
+ };
26871
+ const applyEventReaction = (eventName, e, callerHandler) => {
26736
26872
  const defaultEventReactionDefinition = defaultEventReactionDefinitions?.[eventName];
26737
26873
  const customEventReactionDefinition = eventReactionDefinitions?.[eventName];
26738
26874
  const reaction = customEventReactionDefinition?.(e) ?? defaultEventReactionDefinition?.(e);
26739
26875
  if (!reaction) {
26876
+ // No reaction means no gate: there is nothing here that could refuse the
26877
+ // caller's handler, so withholding it would only lose it.
26878
+ callerHandler?.(e);
26740
26879
  return false;
26741
26880
  }
26742
26881
  const {
@@ -26761,19 +26900,18 @@ const useControlProps = (props, {
26761
26900
  prevented?.();
26762
26901
  },
26763
26902
  allowed: () => {
26903
+ callerHandler?.(e);
26764
26904
  allowed?.();
26765
26905
  },
26766
26906
  always
26767
26907
  });
26768
26908
  };
26769
26909
  const onMouseDown = e => {
26770
- props.onMouseDown?.(e);
26771
- applyEventReaction("mouseDown", e);
26910
+ applyEventReaction("mouseDown", e, gateCallerHandler(props.onMouseDown, e));
26772
26911
  transferFocusToTarget(e);
26773
26912
  };
26774
26913
  const onClick = e => {
26775
- props.onClick?.(e);
26776
- applyEventReaction("click", e);
26914
+ applyEventReaction("click", e, gateCallerHandler(props.onClick, e));
26777
26915
  transferFocusToTarget(e);
26778
26916
  };
26779
26917
  const onKeyDown = e => {
@@ -27296,6 +27434,15 @@ const useInteractiveProps = (props, {
27296
27434
  } = props;
27297
27435
  const [controlRootProps, controlHostProps] = splitControlProps(props);
27298
27436
  controlRootProps["navi-control"] = controlInfo.controlType;
27437
+ if (props.ownTarget) {
27438
+ // "This press is mine" said in the DOM, because that is where it is read
27439
+ // from the outside: by the controls above (see own_target.js), and by the
27440
+ // two gesture readers below — the one that travels a box and the one that
27441
+ // carries a piece, each with its own way of being told to keep out.
27442
+ controlRootProps[OWN_TARGET_ATTRIBUTE] = "";
27443
+ controlRootProps["data-no-drag-travel"] = "";
27444
+ controlRootProps["data-drag-ignore"] = "";
27445
+ }
27299
27446
  const {
27300
27447
  "navi-control-proxy-for": naviProxyFor
27301
27448
  } = props;
@@ -28412,6 +28559,7 @@ const ButtonFirstResolver = props => {
28412
28559
  const Next = useNextResolver();
28413
28560
  const defaultRef = useRef(null);
28414
28561
  props.ref = props.ref || defaultRef;
28562
+ const ownTargetHidden = useOwnTargetHidden(props);
28415
28563
 
28416
28564
  // Attached to the element rather than kept as a prop: the action a button
28417
28565
  // requests is not always run by the button (a submit button hands the send to
@@ -28421,6 +28569,9 @@ const ButtonFirstResolver = props => {
28421
28569
  message: props.confirm,
28422
28570
  content: props.confirmPopupContent
28423
28571
  });
28572
+ if (ownTargetHidden) {
28573
+ return null;
28574
+ }
28424
28575
  return jsx(Next, {
28425
28576
  ...props
28426
28577
  });
@@ -28511,6 +28662,21 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
28511
28662
  children: naviI18n("button.open")
28512
28663
  })
28513
28664
  };
28665
+
28666
+ /**
28667
+ * @type {import("ignore:preact").FunctionComponent<{
28668
+ * ownTarget?: boolean | "refuse",
28669
+ * [key: string]: any,
28670
+ * }>}
28671
+ * @param {boolean|"refuse"} [ownTarget] A real target inside a zone that belongs
28672
+ * to another control — a chip's cross on a picker's façade, an eye on a
28673
+ * pressable row, a diskette inside a slide that travels. The press is this
28674
+ * button's alone (no travel starts, no popup opens, nothing above answers) and
28675
+ * its `onClick` waits for its own interaction gate instead of firing from the
28676
+ * DOM. Where the zone around it is read-only, disabled or busy the button
28677
+ * goes; `"refuse"` keeps it on screen refusing with a callout, for an
28678
+ * affordance whose presence is information in itself.
28679
+ */
28514
28680
  const Button = createComponentResolver([ButtonFirstResolver, ButtonRouteResolver, ButtonCommandPropResolver, ButtonUI]);
28515
28681
 
28516
28682
  // How long a popup waits before handing the focus to a field, when giving it
@@ -30071,6 +30237,10 @@ const css$_ = /* css */`
30071
30237
  padding: 0;
30072
30238
  flex-direction: column;
30073
30239
 
30240
+ /* A new surface writes in its own ink, not in its container's — see
30241
+ --navi-popup-color. Declared here rather than left to the UA's own
30242
+ dialog { color: CanvasText } so the ink is themed along with the paper. */
30243
+ color: var(--navi-popup-color);
30074
30244
  background-color: var(--dialog-background-color);
30075
30245
  border-width: var(--dialog-border-width);
30076
30246
  border-style: solid;
@@ -31600,6 +31770,10 @@ const css$Z = /* css */`
31600
31770
  var(--x-popover-max-height)
31601
31771
  );
31602
31772
  max-height: var(--x-popover-max-height);
31773
+ /* A new surface writes in its own ink, not in its container's — see
31774
+ --navi-popup-color. The UA only resets color on a [popover] element,
31775
+ and the local renderer is a plain div. */
31776
+ color: var(--navi-popup-color);
31603
31777
  background-color: var(--popover-background-color);
31604
31778
  border-width: var(--popover-border-width);
31605
31779
  border-style: solid;
@@ -55463,6 +55637,16 @@ const isTextInputElement = (el) => {
55463
55637
  );
55464
55638
  };
55465
55639
 
55640
+ // How many lines the thing around a component gives it, when that thing caps
55641
+ // its own height and cannot cap what it holds.
55642
+ //
55643
+ // A <Picker> is the case it exists for: its value is clamped with maxLines,
55644
+ // which is CSS line-clamp — it counts line boxes of inline text and has no idea
55645
+ // what a wrapped flex row is. So a <BadgeList> rendered as a picker's ui reads
55646
+ // the number from here and caps its own rows to it, and the picker turns its
55647
+ // own clamp off (see .navi_picker_value:has(.navi_badge_list) in picker.jsx).
55648
+ const MaxLinesContext = createContext(undefined);
55649
+
55466
55650
  // When a component render a prop that can be anything (js value of preact element)
55467
55651
  // make sure it cannot throw during render by converting it to a string if it's not a valid preact element or a primitive value
55468
55652
  const renderSafe = (value) => {
@@ -57861,12 +58045,24 @@ const ListItemSelectable = props => {
57861
58045
  ...rest
57862
58046
  } = props;
57863
58047
  const multiple = useContext(SelectableListMultipleContext);
58048
+ // Whose reason it is that this row cannot be taken. Read-only reaching it
58049
+ // from above is the LIST's, and what is settled is then the whole answer —
58050
+ // said as the selection where several things are taken, as the choice where
58051
+ // one thing is. Said of each row in turn, "this option is not available"
58052
+ // describes something else entirely: a list where each row happens to be
58053
+ // unavailable for its own reasons, which goes on being said that way. Busy
58054
+ // is not settled either: a list waiting on something says nothing about what
58055
+ // will be possible once it is done, so the row keeps its own words.
58056
+ const readOnlyFromAbove = useContext(ReadOnlyContext);
58057
+ const loadingFromAbove = useContext(LoadingContext$1);
58058
+ const answerIsSettled = Boolean(readOnlyFromAbove) && !loadingFromAbove;
58059
+ const readOnlyMessageKey = answerIsSettled ? multiple ? `constraint.readonly.selection` : `constraint.readonly.choice` : `constraint.readonly.option`;
57864
58060
  const inputRef = useRef();
57865
58061
  const inputType = multiple ? "checkbox" : "radio";
57866
58062
  const inputId = `${id}_input`;
57867
58063
  inputRef.nullCanHappen = true; // virtualization
57868
58064
  const [checkableRootProps, checkableProps, controlChildrenWrapperProps] = useCheckableProps({
57869
- readOnlyMessage: naviI18n(`constraint.readonly.option`, props),
58065
+ readOnlyMessage: naviI18n(readOnlyMessageKey, props),
57870
58066
  ...rest,
57871
58067
  ref: inputRef,
57872
58068
  id: inputId,
@@ -62511,6 +62707,51 @@ const PickerPresetResolver = props => {
62511
62707
  });
62512
62708
  };
62513
62709
 
62710
+ // Put around its children by BadgeList so a Badge below knows it is inside one.
62711
+ // A badge then renders nothing at all: it hands its props to the list and the
62712
+ // list renders it. Badges register in tree order, so by the time the list gets
62713
+ // to its own content it holds them all, in source order, and knows how many
62714
+ // there are before deciding what to show — without ever walking children
62715
+ // vnodes, and without rendering a badge it then has to take back.
62716
+ const BadgeListContext = createContext(null);
62717
+
62718
+ const createBadgeRegistry = () => {
62719
+ let pass = 0;
62720
+ let passCount = 0;
62721
+ let entries = [];
62722
+ let childrenAreNew = true;
62723
+
62724
+ return {
62725
+ // Called by BadgeList at the top of every render. childrenAreNew says
62726
+ // whether it was handed fresh children vnodes: when it re-renders on its
62727
+ // own (its own state changed) Preact hands the untouched children straight
62728
+ // back and skips them, so none of the badges registers again. That empty
62729
+ // pass means "unchanged", not "no badge left" — see getEntries.
62730
+ startPass: (areNew) => {
62731
+ pass++;
62732
+ passCount = 0;
62733
+ childrenAreNew = areNew;
62734
+ },
62735
+ // entryState is the badge's own memory. A badge that re-renders on its own
62736
+ // must update its entry, not append a second one.
62737
+ register: (entryState, props) => {
62738
+ if (entryState.pass !== pass) {
62739
+ entryState.pass = pass;
62740
+ entryState.index = passCount;
62741
+ passCount++;
62742
+ }
62743
+ entries[entryState.index] = props;
62744
+ },
62745
+ getEntries: () => {
62746
+ if (passCount === 0 && !childrenAreNew) {
62747
+ return entries;
62748
+ }
62749
+ entries.length = passCount;
62750
+ return entries;
62751
+ },
62752
+ };
62753
+ };
62754
+
62514
62755
  installImportMetaCssBuild(import.meta);const css$x = /* css */`
62515
62756
  @layer navi {
62516
62757
  }
@@ -62569,6 +62810,11 @@ installImportMetaCssBuild(import.meta);const css$x = /* css */`
62569
62810
  align-items: stretch;
62570
62811
  color: var(--x-color);
62571
62812
  font-size: var(--font-size);
62813
+ /* Cuts the font's half-leading above the first line and below the last one,
62814
+ down to cap-height/baseline: the padding becomes the only vertical space
62815
+ and the text is exactly centered. Space between wrapped lines is left
62816
+ untouched, unlike a line-height tweak. */
62817
+ text-box: trim-both cap alphabetic;
62572
62818
  background: var(--x-background);
62573
62819
  background-color: var(--x-background-color);
62574
62820
  border-radius: 1em;
@@ -62618,7 +62864,22 @@ installImportMetaCssBuild(import.meta);const css$x = /* css */`
62618
62864
  }
62619
62865
  }
62620
62866
  `;
62621
- const Badge = ({
62867
+ const Badge = props => {
62868
+ const badgeList = useContext(BadgeListContext);
62869
+ const entryStateRef = useRef();
62870
+ if (badgeList) {
62871
+ // Inside a BadgeList the badge renders nothing: it hands itself over and
62872
+ // the list renders it, which is how the list gets to see them all before
62873
+ // deciding how many it shows. See badge_list_context.js.
62874
+ const entryState = entryStateRef.current || (entryStateRef.current = {});
62875
+ badgeList.register(entryState, props);
62876
+ return null;
62877
+ }
62878
+ return jsx(BadgeUI, {
62879
+ ...props
62880
+ });
62881
+ };
62882
+ const BadgeUI = ({
62622
62883
  children,
62623
62884
  className,
62624
62885
  ...props
@@ -62633,7 +62894,14 @@ const Badge = ({
62633
62894
  return jsx(Text, {
62634
62895
  className: withPropsClassName("navi_badge", className),
62635
62896
  bold: true,
62636
- maxLines: 1,
62897
+ maxLines: 1
62898
+ // The text-box trim ends the content box at the baseline, and a clamped
62899
+ // badge is clipped there: descenders of the last visible line would be
62900
+ // cut. Halfway to the next line's cap top keeps them, still above any
62901
+ // ink from the line the clamp hides.
62902
+ ,
62903
+
62904
+ overflowClipMargin: "content-box calc((1lh - 1cap) / 2)",
62637
62905
  ...props,
62638
62906
  styleCSSVars: BadgeStyleCSSVars,
62639
62907
  spacing: jsx("span", {}),
@@ -62657,6 +62925,15 @@ const BadgeStyleCSSVars = {
62657
62925
  fontSize: "--font-size"
62658
62926
  };
62659
62927
  const BadgeButton = props => {
62928
+ const ownTargetHidden = useOwnTargetHidden(props);
62929
+ if (ownTargetHidden) {
62930
+ return null;
62931
+ }
62932
+ return jsx(BadgeButtonUI, {
62933
+ ...props
62934
+ });
62935
+ };
62936
+ const BadgeButtonUI = props => {
62660
62937
  const defaultRef = useRef();
62661
62938
  props.ref = props.ref || defaultRef;
62662
62939
  const [buttonRootProps, buttonHostProps] = useControlProps(props, {
@@ -62687,20 +62964,208 @@ installImportMetaCssBuild(import.meta);const css$w = /* css */`
62687
62964
  visibility: hidden;
62688
62965
  pointer-events: none;
62689
62966
  }
62967
+
62968
+ /* maxLines renders every badge for one layout, reads where the rows fell,
62969
+ then renders again with only what fits. The in-between is hidden rather
62970
+ than clipped: the badges that don't make it must leave the DOM, not sit
62971
+ there cut in half. Both renders land in the same frame (the second one
62972
+ is queued from a layout effect), so nothing shows up half measured. */
62973
+ &[navi-badge-list-measuring] {
62974
+ visibility: hidden;
62975
+ }
62690
62976
  }
62691
62977
 
62692
62978
  .navi_badge.navi_badge_more {
62693
62979
  white-space: nowrap;
62694
62980
  }
62695
62981
  `;
62696
- const BadgeList = ({
62982
+
62983
+ // Groups badges by the row they wrapped onto.
62984
+ // The signal we look for is horizontal: inside a row each badge starts further
62985
+ // right than the previous one, at a wrap the next one starts back at the row
62986
+ // start. Vertical positions can't be used because "align-items: center" gives
62987
+ // badges of different heights different tops within a single row.
62988
+ const groupRectsByRow = elements => {
62989
+ const rows = [];
62990
+ let previousLeft = -Infinity;
62991
+ for (const element of elements) {
62992
+ const rect = element.getBoundingClientRect();
62993
+ if (rect.width === 0 && rect.height === 0) {
62994
+ continue;
62995
+ }
62996
+ if (rows.length === 0 || rect.left <= previousLeft) {
62997
+ rows.push([]);
62998
+ }
62999
+ rows[rows.length - 1].push(rect);
63000
+ previousLeft = rect.left;
63001
+ }
63002
+ return rows;
63003
+ };
63004
+
63005
+ // Reads a list that currently holds every badge plus the "+N" badge and tells
63006
+ // how many badges fit in maxLines rows.
63007
+ const measureRowFit = (listEl, maxLines) => {
63008
+ const elements = Array.from(listEl.children);
63009
+ if (elements.length < 2) {
63010
+ return elements.length;
63011
+ }
63012
+ // The "+N" badge is rendered last. It sits after every badge so it moves none
63013
+ // of them, which is what lets it be measured in the same layout it is left
63014
+ // out of.
63015
+ const moreRect = elements[elements.length - 1].getBoundingClientRect();
63016
+ const badgeElements = elements.slice(0, -1);
63017
+ const rows = groupRectsByRow(badgeElements);
63018
+ if (rows.length <= maxLines) {
63019
+ return badgeElements.length;
63020
+ }
63021
+ const styles = getComputedStyle(listEl);
63022
+ const gap = parseFloat(styles.columnGap) || 0;
63023
+ const contentRight = listEl.getBoundingClientRect().right - (parseFloat(styles.paddingRight) || 0) - (parseFloat(styles.borderRightWidth) || 0);
63024
+
63025
+ // The "+N" badge lands right after the last kept badge, so it eats into the
63026
+ // last visible row: drop badges from that row until it fits. Emptying that
63027
+ // row entirely is a valid outcome — the badge then wraps onto it and takes it
63028
+ // for itself, which is still within maxLines.
63029
+ const rowsKept = rows.slice(0, maxLines);
63030
+ const lastRow = rowsKept[rowsKept.length - 1];
63031
+ let lastRowCount = lastRow.length;
63032
+ while (lastRowCount > 0 && lastRow[lastRowCount - 1].right + gap + moreRect.width > contentRight + 0.5) {
63033
+ lastRowCount--;
63034
+ }
63035
+ return rowsKept.slice(0, -1).reduce((count, row) => count + row.length, 0) + lastRowCount;
63036
+ };
63037
+ const BADGE_LIST_PROPS = {
63038
+ inline: true,
63039
+ flex: "x",
63040
+ alignY: "center",
63041
+ spacing: "xs"
63042
+ };
63043
+
63044
+ // The badges hand themselves over as they render instead of being read upfront
63045
+ // from the children vnodes: see badge_list_context.js. Only worth it when the
63046
+ // list has something to decide — how many to show, or whether there is none at
63047
+ // all. Otherwise the badges render themselves and nothing has to be collected.
63048
+ const useBadgeRegistry = (children, enabled) => {
63049
+ const registryRef = useRef();
63050
+ const previousChildrenRef = useRef();
63051
+ if (!enabled) {
63052
+ return null;
63053
+ }
63054
+ const registry = registryRef.current || (registryRef.current = createBadgeRegistry());
63055
+ // Whether Preact is about to render the badges again or hand back the ones it
63056
+ // already has, which it does when the list re-renders on its own.
63057
+ registry.startPass(previousChildrenRef.current !== children);
63058
+ previousChildrenRef.current = children;
63059
+ return registry;
63060
+ };
63061
+
63062
+ /**
63063
+ * A row of badges that wraps.
63064
+ *
63065
+ * Four shapes behind one name, because what the list has to do at runtime is
63066
+ * not the same in each. Nothing is set up for a case that cannot happen: a
63067
+ * plain list is one element holding its children as-is — no registry, no
63068
+ * effect —, a capped one collects its badges but measures nothing, and only
63069
+ * shrinkWrap ever builds the measurement ghost.
63070
+ *
63071
+ * @param {import("ignore:preact").ComponentChildren} [fallback]
63072
+ * Rendered in place of the badges when there is none. Without it an empty
63073
+ * list renders nothing at all. In a <Picker> this is the only placeholder
63074
+ * the user gets — a picker given a `ui` does not draw its own — so pass the
63075
+ * placeholder text: `fallback="Select skills…"`. Plain text is the right
63076
+ * choice there: it reads at the picker's size, in the placeholder color the
63077
+ * picker gives its empty value slot, and the box stays the same height. A
63078
+ * transparent <Badge> matches the slot to the pixel instead, at the price of
63079
+ * badge-sized text. See docs/badge_list.md.
63080
+ * @param {boolean} [shrinkWrap]
63081
+ * Narrows the list down to its widest row so the last row isn't ragged.
63082
+ * Defaults to true inside a <Picker> — the trigger draws a border around the
63083
+ * list, so the ragged edge shows — and false elsewhere, where the work would
63084
+ * often go unseen: opt in where an edge is visible. Ignored when maxLines is
63085
+ * in play, which needs the full width to know where the rows fall.
63086
+ * @param {number} [max]
63087
+ * Caps how many badges are rendered; the surplus becomes a "+N" badge, which
63088
+ * takes one of the max slots.
63089
+ * @param {number} [maxLines]
63090
+ * Caps how many rows are shown, measured. Falls back to what the surrounding
63091
+ * component grants (a <Picker>, see max_lines_context.js).
63092
+ */
63093
+ const BadgeList = props => {
63094
+ import.meta.css = [css$w, "@jsenv/navi/src/text/badge_list.jsx"];
63095
+ const {
63096
+ maxLines,
63097
+ max,
63098
+ fallback
63099
+ } = props;
63100
+ const maxLinesFromAbove = useContext(MaxLinesContext);
63101
+ const maxLinesResolved = maxLines === undefined ? maxLinesFromAbove : maxLines;
63102
+ // Granted lines mean a clamping container — a <Picker> — whose border makes
63103
+ // the ragged last row show; nothing of the sort around us, and shrink
63104
+ // wrapping is work nobody sees unless asked for.
63105
+ const {
63106
+ shrinkWrap = maxLinesFromAbove !== undefined
63107
+ } = props;
63108
+ if (maxLinesResolved !== undefined) {
63109
+ // shrinkWrap is dropped on purpose: it narrows the list down to its widest
63110
+ // row, which would re-wrap the badges under the cap just measured.
63111
+ return jsx(BadgeListMaxLines, {
63112
+ ...props,
63113
+ maxLines: maxLinesResolved
63114
+ });
63115
+ }
63116
+ if (shrinkWrap) {
63117
+ return jsx(BadgeListShrinkWrap, {
63118
+ ...props
63119
+ });
63120
+ }
63121
+ if (max !== undefined || fallback !== undefined) {
63122
+ return jsx(BadgeListCounted, {
63123
+ ...props
63124
+ });
63125
+ }
63126
+ return jsx(BadgeListPlain, {
63127
+ ...props
63128
+ });
63129
+ };
63130
+
63131
+ // Nothing to measure, cap, or count: the badges render themselves — no
63132
+ // registry, no context, no effect, one element.
63133
+ const BadgeListPlain = props => {
63134
+ return jsx(Box, {
63135
+ baseClassName: "navi_badge_list",
63136
+ ...BADGE_LIST_PROPS,
63137
+ ...props
63138
+ });
63139
+ };
63140
+
63141
+ // max/fallback need the badge count, so the badges are collected — but nothing
63142
+ // is measured and no DOM is watched.
63143
+ const BadgeListCounted = ({
62697
63144
  fallback,
62698
63145
  children,
62699
- shrinkWrap = true,
62700
63146
  max,
62701
- ...props
63147
+ ...boxProps
62702
63148
  }) => {
62703
- import.meta.css = [css$w, "@jsenv/navi/src/text/badge_list.jsx"];
63149
+ const registry = useBadgeRegistry(children, true);
63150
+ return jsx(Box, {
63151
+ baseClassName: "navi_badge_list",
63152
+ ...BADGE_LIST_PROPS,
63153
+ ...boxProps,
63154
+ children: jsx(BadgeListChildren, {
63155
+ registry: registry,
63156
+ max: max,
63157
+ fallback: fallback,
63158
+ children: children
63159
+ })
63160
+ });
63161
+ };
63162
+ const BadgeListShrinkWrap = ({
63163
+ fallback,
63164
+ children,
63165
+ max,
63166
+ ...restProps
63167
+ }) => {
63168
+ const registry = useBadgeRegistry(children, max !== undefined || fallback !== undefined);
62704
63169
  const measureRef = useRef();
62705
63170
  const visibleRef = useRef();
62706
63171
  useLayoutEffect(() => {
@@ -62713,18 +63178,25 @@ const BadgeList = ({
62713
63178
  let rafId;
62714
63179
  const measure = () => {
62715
63180
  visibleEl.style.width = "";
62716
- if (shrinkWrap) {
62717
- // Clone the already-rendered DOM nodes instead of letting React/Preact
62718
- // render the children a second time into the ghost: re-rendering would
62719
- // instantiate Badge/Badge.Button a second time, double-registering
62720
- // their controllers (and any other mount side effect) under the same id.
62721
- measureEl.replaceChildren(...Array.from(visibleEl.children, child => child.cloneNode(true)));
62722
- const optimalWidth = measureWidestChildRow(measureEl);
62723
- if (optimalWidth !== null) {
62724
- visibleEl.style.width = `${Math.ceil(optimalWidth)}px`;
62725
- }
63181
+ // Clone the already-rendered DOM nodes instead of letting React/Preact
63182
+ // render the children a second time into the ghost: re-rendering would
63183
+ // instantiate Badge/Badge.Button a second time, double-registering their
63184
+ // controllers (and any other mount side effect) under the same id.
63185
+ measureEl.replaceChildren(...Array.from(visibleEl.children, child => child.cloneNode(true)));
63186
+ const optimalWidth = measureWidestChildRow(measureEl);
63187
+ if (optimalWidth !== null) {
63188
+ visibleEl.style.width = `${Math.ceil(optimalWidth)}px`;
62726
63189
  }
62727
63190
  };
63191
+
63192
+ // A single badge is a single row, and a single row is already the widest
63193
+ // one: there is nothing to narrow, and no width the list could be given
63194
+ // that would change that.
63195
+ if (visibleEl.children.length < 2) {
63196
+ visibleEl.style.width = "";
63197
+ measureEl.replaceChildren();
63198
+ return undefined;
63199
+ }
62728
63200
  measure();
62729
63201
  const onResize = () => {
62730
63202
  cancelAnimationFrame(rafId);
@@ -62741,36 +63213,201 @@ const BadgeList = ({
62741
63213
  observer?.disconnect();
62742
63214
  window.removeEventListener("resize", onResize);
62743
63215
  };
62744
- }, [shrinkWrap, children]);
62745
- const childArray = toChildArray(children);
62746
- const hasMax = max !== undefined && childArray.length > max;
62747
- const visibleChildren = hasMax ? childArray.slice(0, max - 1) : childArray;
62748
- const hiddenCount = hasMax ? childArray.length - (max - 1) : 0;
62749
- const sharedProps = {
62750
- inline: true,
62751
- flex: "x",
62752
- alignY: "center",
62753
- spacing: "xs",
62754
- ...props
63216
+ }, [children]);
63217
+ const boxProps = {
63218
+ ...BADGE_LIST_PROPS,
63219
+ ...restProps
62755
63220
  };
62756
- return jsxs(Box, {
62757
- relative: true,
62758
- children: [jsx(Box, {
62759
- baseClassName: "navi_badge_list",
62760
- ...sharedProps,
62761
- ref: measureRef,
62762
- "aria-hidden": "true",
62763
- "navi-badge-list-clone": ""
62764
- }), jsxs(Box, {
62765
- baseClassName: "navi_badge_list",
62766
- ...sharedProps,
62767
- ref: visibleRef,
62768
- children: [visibleChildren.length ? visibleChildren : fallback, hiddenCount > 0 && jsx(Badge, {
62769
- className: "navi_badge_more",
62770
- children: naviI18n("badge_list.more", {
62771
- count: hiddenCount
63221
+ return (
63222
+ // inline flex, not a plain block: the wrapper must sit on the line the way
63223
+ // the list itself would, otherwise the list lands a pixel low in some
63224
+ // containers.
63225
+ jsxs(Box, {
63226
+ relative: true,
63227
+ inline: true,
63228
+ flex: "x",
63229
+ children: [jsx(Box, {
63230
+ baseClassName: "navi_badge_list",
63231
+ ...boxProps,
63232
+ ref: measureRef,
63233
+ "aria-hidden": "true",
63234
+ "navi-badge-list-clone": ""
63235
+ }), jsx(Box, {
63236
+ baseClassName: "navi_badge_list",
63237
+ ...boxProps,
63238
+ ref: visibleRef,
63239
+ children: jsx(BadgeListChildren, {
63240
+ registry: registry,
63241
+ max: max,
63242
+ fallback: fallback,
63243
+ children: children
62772
63244
  })
62773
63245
  })]
63246
+ })
63247
+ );
63248
+ };
63249
+ const BadgeListMaxLines = ({
63250
+ fallback,
63251
+ children,
63252
+ max,
63253
+ maxLines,
63254
+ ...boxProps
63255
+ }) => {
63256
+ const registry = useBadgeRegistry(children, true);
63257
+ const visibleRef = useRef();
63258
+
63259
+ // What the measure found: how many badges there were and how many of them fit
63260
+ // in maxLines rows. null means "not measured yet": the list then renders them
63261
+ // all, hidden, for the layout effect to read.
63262
+ const [fit, setFit] = useState(null);
63263
+ // The two widths this list gives whatever is around it: the one it takes with
63264
+ // every badge rendered, and the one it settles on once the surplus is gone.
63265
+ // Neither is a reason to measure again — see the resize watch below.
63266
+ const selfWidthsRef = useRef({
63267
+ full: -1,
63268
+ settled: -1
63269
+ });
63270
+ const measuring = fit === null;
63271
+ // A single badge is a single row whatever the width, so nothing can move it
63272
+ // out of the cap and nothing has to be watched.
63273
+ const watchesResize = fit !== null && fit.count > 1;
63274
+
63275
+ // Runs after every render, which is when the badges have registered and the
63276
+ // DOM holds whatever this render asked for.
63277
+ useLayoutEffect(() => {
63278
+ const visibleEl = visibleRef.current;
63279
+ if (!visibleEl) {
63280
+ return;
63281
+ }
63282
+ const count = registry.getEntries().length;
63283
+ const width = visibleEl.parentElement?.getBoundingClientRect().width ?? -1;
63284
+ if (fit === null) {
63285
+ selfWidthsRef.current.full = width;
63286
+ setFit({
63287
+ count,
63288
+ shown: count < 2 ? count : measureRowFit(visibleEl, maxLines)
63289
+ });
63290
+ return;
63291
+ }
63292
+ selfWidthsRef.current.settled = width;
63293
+ if (fit.count !== count) {
63294
+ // Badges came or went: what was measured no longer describes them.
63295
+ setFit(null);
63296
+ }
63297
+ });
63298
+ useLayoutEffect(() => {
63299
+ const outerParent = visibleRef.current?.parentElement;
63300
+ if (!watchesResize || !outerParent) {
63301
+ return undefined;
63302
+ }
63303
+ let rafId;
63304
+ const remeasure = () => {
63305
+ // Only a width change can move the rows — a height change is this list
63306
+ // growing or shrinking, never the room it was given.
63307
+ //
63308
+ // And not every width change either. Nothing guarantees an ancestor whose
63309
+ // width does not follow its content (a column with align-items: start
63310
+ // sizes every row to what is inside it), so rendering every badge widens
63311
+ // what is being watched and dropping the surplus narrows it right back.
63312
+ // Those two widths are this list talking to itself; measuring again on
63313
+ // them never ends. Any other width is the room around it changing.
63314
+ const width = outerParent.getBoundingClientRect().width;
63315
+ const {
63316
+ full,
63317
+ settled
63318
+ } = selfWidthsRef.current;
63319
+ if (Math.abs(width - full) < 0.5 || Math.abs(width - settled) < 0.5) {
63320
+ return;
63321
+ }
63322
+ cancelAnimationFrame(rafId);
63323
+ rafId = requestAnimationFrame(() => setFit(null));
63324
+ };
63325
+ const observer = new ResizeObserver(remeasure);
63326
+ observer.observe(outerParent);
63327
+ window.addEventListener("resize", remeasure);
63328
+ return () => {
63329
+ cancelAnimationFrame(rafId);
63330
+ observer.disconnect();
63331
+ window.removeEventListener("resize", remeasure);
63332
+ };
63333
+ }, [watchesResize, maxLines]);
63334
+ return jsx(Box, {
63335
+ baseClassName: "navi_badge_list",
63336
+ ...BADGE_LIST_PROPS,
63337
+ ...boxProps,
63338
+ ref: visibleRef,
63339
+ "navi-badge-list-measuring": measuring ? "" : undefined,
63340
+ children: jsx(BadgeListChildren, {
63341
+ registry: registry,
63342
+ max: max,
63343
+ fallback: fallback,
63344
+ measuring: measuring,
63345
+ rowFit: fit?.shown ?? null,
63346
+ children: children
63347
+ })
63348
+ });
63349
+ };
63350
+ const BadgeListChildren = ({
63351
+ registry,
63352
+ children,
63353
+ fallback,
63354
+ max,
63355
+ measuring,
63356
+ rowFit
63357
+ }) => {
63358
+ if (!registry) {
63359
+ // The badges are on their own: nothing here decides which of them render.
63360
+ return children;
63361
+ }
63362
+ return jsxs(Fragment$1, {
63363
+ children: [jsx(BadgeListContext.Provider, {
63364
+ value: registry,
63365
+ children: children
63366
+ }), jsx(BadgeListContent, {
63367
+ registry: registry,
63368
+ fallback: fallback,
63369
+ max: max,
63370
+ measuring: measuring,
63371
+ rowFit: rowFit
63372
+ })]
63373
+ });
63374
+ };
63375
+ const BadgeListContent = ({
63376
+ registry,
63377
+ fallback,
63378
+ max,
63379
+ measuring,
63380
+ rowFit
63381
+ }) => {
63382
+ const entries = registry.getEntries();
63383
+ const count = entries.length;
63384
+ if (count === 0) {
63385
+ return fallback;
63386
+ }
63387
+
63388
+ // The "+N" badge stands among the badges, so it takes one of the max slots
63389
+ // when there is a surplus to name. A list of exactly `max` badges has nothing
63390
+ // to name and keeps all of them.
63391
+ let shownCount = max !== undefined && count > max ? max - 1 : count;
63392
+ if (!measuring && rowFit !== null && rowFit !== undefined && rowFit < shownCount) {
63393
+ shownCount = rowFit;
63394
+ }
63395
+ // While measuring, everything above is on screen (hidden) along with the "+N"
63396
+ // badge, so the layout effect can see where the rows fall and how much room
63397
+ // that badge asks for. Its label then reads the worst case — every badge
63398
+ // hidden — so the room reserved is never short.
63399
+ const hasMore = measuring || shownCount < count;
63400
+ return jsxs(Fragment$1, {
63401
+ children: [entries.slice(0, shownCount).map((badgeProps, index) =>
63402
+ // Keyed by position: a badge's own key went to the registering vnode
63403
+ // above and doesn't reach here, and badges keep no state worth moving.
63404
+ jsx(BadgeUI, {
63405
+ ...badgeProps
63406
+ }, index)), hasMore && jsx(BadgeUI, {
63407
+ className: "navi_badge_more",
63408
+ children: naviI18n("badge_list.more", {
63409
+ count: measuring ? count : count - shownCount
63410
+ })
62774
63411
  })]
62775
63412
  });
62776
63413
  };
@@ -63001,7 +63638,6 @@ const PickerObjectUI = () => {
63001
63638
  const PickerArray = props => {
63002
63639
  const Next = useNextResolver();
63003
63640
  return jsx(Next, {
63004
- maxLines: "3",
63005
63641
  ui: jsx(PickerArrayUI, {}),
63006
63642
  ...props,
63007
63643
  type: "navi_js",
@@ -63031,6 +63667,57 @@ const PickerArrayUI = () => {
63031
63667
  })
63032
63668
  });
63033
63669
  };
63670
+
63671
+ /**
63672
+ * One value the picker holds, drawn as a chip with a cross that takes it back
63673
+ * out. Sits wherever the application draws what was picked — on the picker's
63674
+ * façade (`ui`) or inside its popup — and both behave the same.
63675
+ *
63676
+ * The cross asks with `--navi-unselect` rather than writing a new list, and it
63677
+ * asks the picker — which holds what was picked, and hands it down to whatever
63678
+ * draws it in the popup. Nothing to name: the picker is the nearest control
63679
+ * around the chip. `commandFor` is for a chip that stands outside the picker it
63680
+ * speaks for.
63681
+ *
63682
+ * The cross is an own target (see own_target.js), so the press belongs to it
63683
+ * and not to the picker underneath, and it goes when the picker turns read-only
63684
+ * — a row being read still says what was picked, it just no longer offers to
63685
+ * unpick it.
63686
+ *
63687
+ * @type {import("ignore:preact").FunctionComponent<{
63688
+ * value: any,
63689
+ * commandFor?: string,
63690
+ * children?: import("ignore:preact").ComponentChildren,
63691
+ * [key: string]: any,
63692
+ * }>}
63693
+ * @param {any} value The value this chip stands for — one entry of what the
63694
+ * picker holds, and what `--navi-unselect` carries.
63695
+ * @param {string} [commandFor] The id of the picker to take the value out of,
63696
+ * when the chip does not sit inside it.
63697
+ */
63698
+ const PickerChip = ({
63699
+ value,
63700
+ commandFor,
63701
+ children,
63702
+ ...rest
63703
+ }) => {
63704
+ return jsxs(Badge, {
63705
+ inline: true,
63706
+ flex: true,
63707
+ ...rest,
63708
+ children: [children, jsx(Badge.Button, {
63709
+ ownTarget: true,
63710
+ command: "--navi-unselect",
63711
+ commandFor: commandFor,
63712
+ value: value,
63713
+ "aria-label": naviI18n("button.remove"),
63714
+ children: jsx(Icon, {
63715
+ lineOverflow: "allow",
63716
+ children: jsx(CloseSvg, {})
63717
+ })
63718
+ })]
63719
+ });
63720
+ };
63034
63721
  const PickerColor = props => {
63035
63722
  const Next = useNextResolver();
63036
63723
  return jsx(Next, {
@@ -63469,6 +64156,30 @@ installImportMetaCssBuild(import.meta);const css$u = /* css */`
63469
64156
  &[navi-placeholder] {
63470
64157
  color: var(--picker-placeholder-color);
63471
64158
  }
64159
+
64160
+ /* A <BadgeList> caps its own rows: it renders the badges that fit and a
64161
+ "+N" badge for the rest, reading the number from MaxLinesContext right
64162
+ below. The picker must then not clamp on top of it — line-clamp turns
64163
+ the value into a -webkit-box and single-line truncation into a
64164
+ nowrap block, either of which takes the badge list out of the
64165
+ inline-flex layout it needs. maxLines writes those as inline styles on
64166
+ the element, hence !important. */
64167
+ &:has(.navi_badge_list) {
64168
+ display: inline-flex !important;
64169
+ -webkit-line-clamp: none !important;
64170
+ overflow: visible !important;
64171
+ -webkit-box-orient: horizontal !important;
64172
+ text-overflow: clip !important;
64173
+ white-space: normal !important;
64174
+ }
64175
+
64176
+ /* The façade is transparent to the pointer — a press on what it draws
64177
+ means "open the picker". An own target is the exception, the same way
64178
+ the clear cross is one in the slot below: it says the press is aimed at
64179
+ IT, so it has to be reachable at all. */
64180
+ [data-navi-own-target] {
64181
+ pointer-events: auto;
64182
+ }
63472
64183
  }
63473
64184
  .navi_picker_right_slot {
63474
64185
  display: inline-flex;
@@ -63757,214 +64468,284 @@ const PickerButton = props => {
63757
64468
  // clearing being a modification like any other.
63758
64469
  const interactive = !basePseudoState[":disabled"] && !basePseudoState[":read-only"] && !loading;
63759
64470
  usePickerErrorCallout(uiStateController, error);
63760
- return jsxs(Box, {
63761
- as: "div",
63762
- ref: ref
63763
- // The flow this element really has (.navi_picker is display:inline-flex).
63764
- // Left unsaid, Box reads a <div> as block and resolves alignX into a
63765
- // text-align which is a different intention entirely (that one is the
63766
- // textAlign prop, placing the text INSIDE the value slot).
63767
- ,
64471
+ return (
64472
+ /* Read-only crosses into everything the picker is made of: what it really
64473
+ holds is drawn by controls of their own — in the popup, and on the façade
64474
+ where an application puts its own affordances and each of them refuses
64475
+ in its own words once told. Said from the read-only state alone, never
64476
+ from the busy one an action running for a moment is not the same thing
64477
+ as a value nobody may change. */
64478
+ jsx(ReadOnlyContext.Provider, {
64479
+ value: readOnlyResolved,
64480
+ children: jsxs(Box, {
64481
+ as: "div",
64482
+ ref: ref
64483
+ // The flow this element really has (.navi_picker is display:inline-flex).
64484
+ // Left unsaid, Box reads a <div> as block and resolves alignX into a
64485
+ // text-align — which is a different intention entirely (that one is the
64486
+ // textAlign prop, placing the text INSIDE the value slot).
64487
+ ,
63768
64488
 
63769
- inline: true,
63770
- flex: "x",
63771
- baseClassName: "navi_picker",
63772
- pseudoClasses: PICKER_BUTTON_PSEUDO_CLASSES,
63773
- "data-variant": variant,
63774
- "navi-picker": "",
63775
- "navi-single-line": isSingleLine ? "" : undefined,
63776
- "navi-ui-custom": ui === "default" ? undefined : "",
63777
- "data-readonly-opens": readOnlyOpens ? "" : undefined,
63778
- "data-popup-width-fit-content": popupWidthFitContent ? "" : undefined,
63779
- ...pickerRemainingProps,
63780
- basePseudoState: basePseudoState,
63781
- styleCSSVars: PickerStyleCSSVars,
63782
- variant: undefined,
63783
- rightSlotIcon: undefined,
63784
- rightSlotIconSize: undefined,
63785
- rightSlot: undefined,
63786
- clearConfirm: undefined,
63787
- clearConfirmPopupContent: undefined,
63788
- openWhileReadOnly: undefined,
63789
- ui: undefined,
63790
- maxLines: undefined,
63791
- popupWidthFitContent: undefined,
63792
- error: undefined,
63793
- dayLabel: undefined
63794
- // This wrapper will receive keyboard event bubbling from the picker popup content
63795
- // we re-dispatch on the input (to get escape to close for instance)
63796
- ,
64489
+ inline: true,
64490
+ flex: "x",
64491
+ baseClassName: "navi_picker",
64492
+ pseudoClasses: PICKER_BUTTON_PSEUDO_CLASSES,
64493
+ "data-variant": variant,
64494
+ "navi-picker": "",
64495
+ "navi-single-line": isSingleLine ? "" : undefined,
64496
+ "navi-ui-custom": ui === "default" ? undefined : "",
64497
+ "data-readonly-opens": readOnlyOpens ? "" : undefined,
64498
+ "data-popup-width-fit-content": popupWidthFitContent ? "" : undefined,
64499
+ ...pickerRemainingProps,
64500
+ basePseudoState: basePseudoState,
64501
+ styleCSSVars: PickerStyleCSSVars,
64502
+ variant: undefined,
64503
+ rightSlotIcon: undefined,
64504
+ rightSlotIconSize: undefined,
64505
+ rightSlot: undefined,
64506
+ clearConfirm: undefined,
64507
+ clearConfirmPopupContent: undefined,
64508
+ openWhileReadOnly: undefined,
64509
+ ui: undefined,
64510
+ maxLines: undefined,
64511
+ popupWidthFitContent: undefined,
64512
+ error: undefined,
64513
+ dayLabel: undefined
64514
+ // This wrapper will receive keyboard event bubbling from the picker popup content
64515
+ // we re-dispatch on the input (to get escape to close for instance)
64516
+ ,
63797
64517
 
63798
- onKeyDown: inputProps.onKeyDown
63799
- // in case request open/close are dispatched on the control root ->
63800
- // redispatch them to the host
63801
- ,
64518
+ onKeyDown: inputProps.onKeyDown
64519
+ // in case request open/close are dispatched on the control root ->
64520
+ // redispatch them to the host
64521
+ ,
63802
64522
 
63803
- onnavi_request_open: inputProps.onnavi_request_open,
63804
- onnavi_request_close: inputProps.onnavi_request_close,
63805
- children: [jsxs("span", {
63806
- className: "navi_picker_box",
63807
- children: [variant === "headless" ? null : jsx(LoadingOutline, {
63808
- loading: loading,
63809
- color: "var(--picker-loader-color)",
63810
- inset: -2
63811
- }), jsx(PickerInput, {
63812
- tabIndex: variant === "headless" ? -1 : undefined,
63813
- "aria-hidden": variant === "headless" ? "true" : undefined,
63814
- ...inputProps,
63815
- // eslint-disable-next-line react/no-children-prop
63816
- children: undefined // we will render children into the div
64523
+ onnavi_request_open: inputProps.onnavi_request_open,
64524
+ onnavi_request_close: inputProps.onnavi_request_close
64525
+ // `--navi-select`/`--navi-unselect` about one entry of the list the
64526
+ // picker holds — a chip on the façade, a suggestion beside the field.
64527
+ // Answered here rather than by the control drawing that list in the
64528
+ // popup, even though rows are what such a control owns: a picker given
64529
+ // its own value builds its popup only on first open (see
64530
+ // popup_content_mount.js), so before that there is no such control at
64531
+ // all — and building the whole popup to have someone to talk to, for a
64532
+ // cross, is the wrong price. The picker holds the value in the first
64533
+ // place and hands it down whenever the popup is built.
63817
64534
  ,
63818
64535
 
63819
- ui: ui,
63820
- onFocus: e => {
63821
- inputProps.onFocus?.(e);
63822
- e.target.select();
64536
+ onnavi_request_select: e => {
64537
+ requestPickerListEntry(ref.current, inputRef.current, e, "select");
63823
64538
  },
63824
- onCopy: e => {
63825
- const pickerEl = ref.current;
63826
- if (isWithinPickerContent(e.target, pickerEl)) {
63827
- return;
63828
- }
63829
- const uiState = uiStateController.uiState;
63830
- if (uiState === undefined) {
63831
- return;
63832
- }
63833
- e.preventDefault();
63834
- const displayText = pickerEl.querySelector(".navi_picker_value")?.textContent ?? String(uiState);
63835
- e.clipboardData.setData("text/plain", displayText);
63836
- e.clipboardData.setData("application/x-navi", JSON.stringify(uiState));
64539
+ onnavi_request_unselect: e => {
64540
+ requestPickerListEntry(ref.current, inputRef.current, e, "unselect");
63837
64541
  },
63838
- onCut: e => {
63839
- const pickerEl = ref.current;
63840
- if (isWithinPickerContent(e.target, pickerEl)) {
63841
- return;
63842
- }
63843
- const uiState = uiStateController.uiState;
63844
- if (uiState === undefined) {
63845
- return;
63846
- }
63847
- // the copy part don't need control to be interactable
63848
- const displayText = pickerEl.querySelector(".navi_picker_value")?.textContent ?? String(uiState);
63849
- e.clipboardData.setData("text/plain", displayText);
63850
- e.clipboardData.setData("application/x-navi", JSON.stringify(uiState));
63851
- // the clear ui state part need control to be interactable
63852
- dispatchRequestInteraction(pickerEl, {
63853
- event: e,
63854
- name: "cut",
63855
- allowed: () => {
63856
- dispatchRequestClearUIState(inputRef.current, e);
63857
- }
63858
- });
63859
- e.preventDefault();
63860
- },
63861
- onPaste: e => {
63862
- const pickerEl = ref.current;
63863
- if (isWithinPickerContent(e.target, pickerEl)) {
63864
- // Don't intercept inside the picker popup content.
63865
- return;
63866
- }
63867
- const naviData = e.clipboardData.getData("application/x-navi");
63868
- let pasteValue;
63869
- if (naviData) {
63870
- try {
63871
- pasteValue = JSON.parse(naviData);
63872
- } catch {
63873
- pasteValue = naviData;
63874
- }
63875
- } else {
63876
- pasteValue = e.clipboardData.getData("text/plain");
63877
- }
63878
- dispatchRequestInteraction(pickerEl, {
63879
- event: e,
63880
- name: "paste",
63881
- allowed: () => {
63882
- dispatchRequestSetUIState(inputRef.current, pasteValue, {
63883
- event: e
64542
+ children: [jsxs("span", {
64543
+ className: "navi_picker_box",
64544
+ children: [variant === "headless" ? null : jsx(LoadingOutline, {
64545
+ loading: loading,
64546
+ color: "var(--picker-loader-color)",
64547
+ inset: -2
64548
+ }), jsx(PickerInput, {
64549
+ tabIndex: variant === "headless" ? -1 : undefined,
64550
+ "aria-hidden": variant === "headless" ? "true" : undefined,
64551
+ ...inputProps,
64552
+ // eslint-disable-next-line react/no-children-prop
64553
+ children: undefined // we will render children into the div
64554
+ ,
64555
+
64556
+ ui: ui,
64557
+ onFocus: e => {
64558
+ inputProps.onFocus?.(e);
64559
+ e.target.select();
64560
+ },
64561
+ onCopy: e => {
64562
+ const pickerEl = ref.current;
64563
+ if (isWithinPickerContent(e.target, pickerEl)) {
64564
+ return;
64565
+ }
64566
+ const uiState = uiStateController.uiState;
64567
+ if (uiState === undefined) {
64568
+ return;
64569
+ }
64570
+ e.preventDefault();
64571
+ const displayText = pickerEl.querySelector(".navi_picker_value")?.textContent ?? String(uiState);
64572
+ e.clipboardData.setData("text/plain", displayText);
64573
+ e.clipboardData.setData("application/x-navi", JSON.stringify(uiState));
64574
+ },
64575
+ onCut: e => {
64576
+ const pickerEl = ref.current;
64577
+ if (isWithinPickerContent(e.target, pickerEl)) {
64578
+ return;
64579
+ }
64580
+ const uiState = uiStateController.uiState;
64581
+ if (uiState === undefined) {
64582
+ return;
64583
+ }
64584
+ // the copy part don't need control to be interactable
64585
+ const displayText = pickerEl.querySelector(".navi_picker_value")?.textContent ?? String(uiState);
64586
+ e.clipboardData.setData("text/plain", displayText);
64587
+ e.clipboardData.setData("application/x-navi", JSON.stringify(uiState));
64588
+ // the clear ui state part need control to be interactable
64589
+ dispatchRequestInteraction(pickerEl, {
64590
+ event: e,
64591
+ name: "cut",
64592
+ allowed: () => {
64593
+ dispatchRequestClearUIState(inputRef.current, e);
64594
+ }
64595
+ });
64596
+ e.preventDefault();
64597
+ },
64598
+ onPaste: e => {
64599
+ const pickerEl = ref.current;
64600
+ if (isWithinPickerContent(e.target, pickerEl)) {
64601
+ // Don't intercept inside the picker popup content.
64602
+ return;
64603
+ }
64604
+ const naviData = e.clipboardData.getData("application/x-navi");
64605
+ let pasteValue;
64606
+ if (naviData) {
64607
+ try {
64608
+ pasteValue = JSON.parse(naviData);
64609
+ } catch {
64610
+ pasteValue = naviData;
64611
+ }
64612
+ } else {
64613
+ pasteValue = e.clipboardData.getData("text/plain");
64614
+ }
64615
+ dispatchRequestInteraction(pickerEl, {
64616
+ event: e,
64617
+ name: "paste",
64618
+ allowed: () => {
64619
+ dispatchRequestSetUIState(inputRef.current, pasteValue, {
64620
+ event: e
64621
+ });
64622
+ }
63884
64623
  });
64624
+ e.preventDefault();
63885
64625
  }
63886
- });
63887
- e.preventDefault();
63888
- }
63889
- }), variant === "icon" || variant === "headless" || ui === "default" ? null : jsx(Text, {
63890
- className: "navi_picker_value",
63891
- "navi-placeholder": value === undefined || value === "" ? "" : undefined,
63892
- maxLines: maxLines,
63893
- children: jsx(PickerContext.Provider, {
63894
- value: {
63895
- value,
63896
- placeholder,
63897
- maxLines
63898
- },
63899
- children: ui === undefined ? jsx(PickerDefaultUI, {}) : ui
63900
- })
63901
- }), variant === "headless" || ui === "default" ? null : jsx("span", {
63902
- className: "navi_picker_right_slot",
63903
- children: jsx(ControlIdContext.Provider, {
63904
- value: undefined,
63905
- children: jsx(ControlNameContext.Provider, {
63906
- value: undefined,
63907
- children: clearable && interactive && value !== undefined && value !== "" ? jsx(Button, {
63908
- command: "--navi-clear",
63909
- commandFor: inputProps.id
63910
- // The question, asked before the clear rather than by the
63911
- // action the clear sends — see the --navi-clear command.
63912
- ,
63913
-
63914
- confirm: clearConfirm,
63915
- confirmPopupContent: clearConfirmPopupContent,
63916
- tabIndex: "-1"
63917
- // No navi-focus-delegate, unlike the identical button inside an
63918
- // input: handing focus back to the picker's own input is what
63919
- // opens the popup, and clearing is the opposite intention.
63920
- ,
63921
-
63922
- icon: true,
63923
- variant: "discrete"
63924
- // What is busy once the clear is sent is the picker — the value
63925
- // being removed is the whole field's, and the picker already
63926
- // draws the wait around all of it. Two outlines for one wait is
63927
- // one too many.
63928
- ,
63929
-
63930
- loadingOutline: false
63931
- // preventDefault, not just tabIndex="-1": a mousedown focuses
63932
- // its target before any click happens, and this button should
63933
- // never hold focus at all — the field keeps it.
63934
- ,
63935
-
63936
- onMouseDown: e => {
63937
- e.preventDefault();
63938
- },
63939
- flex: true,
63940
- align: "center",
63941
- children: jsx(Icon, {
64626
+ }), variant === "icon" || variant === "headless" || ui === "default" ? null : jsx(Text, {
64627
+ className: "navi_picker_value",
64628
+ "navi-placeholder": uiStateHoldsNothing(value) ? "" : undefined,
64629
+ maxLines: maxLines,
64630
+ children: jsx(PickerOwnContent, {
64631
+ children: jsx(PickerContext.Provider, {
64632
+ value: {
64633
+ value,
64634
+ placeholder,
64635
+ maxLines
64636
+ },
64637
+ children: jsx(MaxLinesContext.Provider, {
64638
+ value: maxLines,
64639
+ children: ui === undefined ? jsx(PickerDefaultUI, {}) : ui
64640
+ })
64641
+ })
64642
+ })
64643
+ }), variant === "headless" || ui === "default" ? null : jsx("span", {
64644
+ className: "navi_picker_right_slot",
64645
+ children: jsx(PickerOwnContent, {
64646
+ children: clearable && interactive && value !== undefined && value !== "" ? jsx(Button, {
64647
+ command: "--navi-clear",
64648
+ commandFor: inputProps.id
64649
+ // The question, asked before the clear rather than by the
64650
+ // action the clear sends see the --navi-clear command.
64651
+ ,
64652
+
64653
+ confirm: clearConfirm,
64654
+ confirmPopupContent: clearConfirmPopupContent,
64655
+ tabIndex: "-1"
64656
+ // No navi-focus-delegate, unlike the identical button inside an
64657
+ // input: handing focus back to the picker's own input is what
64658
+ // opens the popup, and clearing is the opposite intention.
64659
+ ,
64660
+
64661
+ icon: true,
64662
+ variant: "discrete"
64663
+ // What is busy once the clear is sent is the picker — the value
64664
+ // being removed is the whole field's, and the picker already
64665
+ // draws the wait around all of it. Two outlines for one wait is
64666
+ // one too many.
64667
+ ,
64668
+
64669
+ loadingOutline: false
64670
+ // preventDefault, not just tabIndex="-1": a mousedown focuses
64671
+ // its target before any click happens, and this button should
64672
+ // never hold focus at all the field keeps it.
64673
+ ,
64674
+
64675
+ onMouseDown: e => {
64676
+ e.preventDefault();
64677
+ },
64678
+ flex: true,
64679
+ align: "center",
64680
+ children: jsx(Icon, {
64681
+ size: rightSlotIconSize,
64682
+ lineOverflow: "allow",
64683
+ children: jsx(CloseSvg, {})
64684
+ })
64685
+ }) : rightSlot === undefined ?
64686
+ // lineOverflow: what sits in the slot is an affordance, not a
64687
+ // character — a caller asking for a bigger one wants it bigger,
64688
+ // not capped at the height of the line it sits on
64689
+ jsx(Icon, {
63942
64690
  size: rightSlotIconSize,
63943
64691
  lineOverflow: "allow",
63944
- children: jsx(CloseSvg, {})
63945
- })
63946
- }) : rightSlot === undefined ?
63947
- // lineOverflow: what sits in the slot is an affordance, not a
63948
- // character — a caller asking for a bigger one wants it bigger,
63949
- // not capped at the height of the line it sits on
63950
- jsx(Icon, {
63951
- size: rightSlotIconSize,
63952
- lineOverflow: "allow",
63953
- children: rightSlotIcon === undefined ? jsx(ChevronDownSvg$1, {}) : rightSlotIcon
63954
- }) : rightSlot
64692
+ children: rightSlotIcon === undefined ? jsx(ChevronDownSvg$1, {}) : rightSlotIcon
64693
+ }) : rightSlot
64694
+ })
64695
+ })]
64696
+ }), jsx(ControlFacadeChildrenWrapper, {
64697
+ ...facadeChildrenProps,
64698
+ children: jsx("div", {
64699
+ className: "navi_picker_content",
64700
+ children: children
63955
64701
  })
63956
- })
63957
- })]
63958
- }), jsx(ControlFacadeChildrenWrapper, {
63959
- ...facadeChildrenProps,
63960
- children: jsx(ReadOnlyContext.Provider, {
63961
- value: readOnlyResolved,
63962
- children: jsx("div", {
63963
- className: "navi_picker_content",
63964
- children: children
63965
- })
64702
+ })]
63966
64703
  })
63967
- })]
64704
+ })
64705
+ );
64706
+ };
64707
+ // What the picker draws itself — the value it shows, the furniture in its slot,
64708
+ // and whatever a caller puts in either — is not another control of the field
64709
+ // around it: none of it may take the id (nor the name) a <Field> hands down,
64710
+ // which is the picker's. Two controls under one id is one registry entry, and
64711
+ // the one that unmounts first — the clear cross the moment the field it emptied
64712
+ // is empty, a chip the moment its value is taken out — takes the picker's own
64713
+ // entry with it, leaving the picker looking for a controller that is gone.
64714
+ const PickerOwnContent = ({
64715
+ children
64716
+ }) => jsx(ControlIdContext.Provider, {
64717
+ value: undefined,
64718
+ children: jsx(ControlNameContext.Provider, {
64719
+ value: undefined,
64720
+ children: children
64721
+ })
64722
+ });
64723
+
64724
+ // `id` is what --navi-select/--navi-unselect carry (a list addresses its rows by
64725
+ // id); asked of a picker, what they carry is one entry of the list the picker
64726
+ // holds — the same thing a `<Picker.Chip value>` stands for.
64727
+ const requestPickerListEntry = (pickerEl, pickerInputEl, e, goal) => {
64728
+ const uiState = getUIStateFromElement(pickerInputEl);
64729
+ if (!Array.isArray(uiState)) {
64730
+ return;
64731
+ }
64732
+ const {
64733
+ id: entry
64734
+ } = e.detail;
64735
+ const isThere = uiState.some(item => compareTwoJsValues(item, entry));
64736
+ if (goal === "select" ? isThere : !isThere) {
64737
+ return;
64738
+ }
64739
+ const uiStateNext = goal === "select" ? [...uiState, entry] : uiState.filter(item => !compareTwoJsValues(item, entry));
64740
+ dispatchRequestInteraction(pickerEl, {
64741
+ event: e,
64742
+ name: goal,
64743
+ prevented: () => e.preventDefault(),
64744
+ allowed: () => {
64745
+ dispatchRequestSetUIState(pickerInputEl, uiStateNext, {
64746
+ event: e
64747
+ });
64748
+ }
63968
64749
  });
63969
64750
  };
63970
64751
  const isWithinPickerContent = (el, pickerEl) => {
@@ -64109,6 +64890,7 @@ const PickerFirstResolver = props => {
64109
64890
  });
64110
64891
  };
64111
64892
  const Picker = createComponentResolver([PickerFirstResolver, PickerPresetResolver, PickerCustomResolver, PickerTypeResolver, PickerButton]);
64893
+ Picker.Chip = PickerChip;
64112
64894
  Picker.UI = PickerDefaultUI;
64113
64895
  Picker.UI.Date = PickerDateUI;
64114
64896
  Picker.UI.Time = PickerTimeUI;