@jsenv/navi 0.29.107 → 0.29.108

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.
@@ -38,7 +38,7 @@ installImportMetaCssBuild(import.meta);/**
38
38
  * any of these, and a number is the last resort, not the first tool.
39
39
  */
40
40
 
41
- const css$14 = /* css */`
41
+ const css$15 = /* css */`
42
42
  @layer navi {
43
43
  :root {
44
44
  /* A control that overlaps its neighbours (the members of a Group share
@@ -102,7 +102,7 @@ const css$14 = /* css */`
102
102
  }
103
103
  }
104
104
  `;
105
- import.meta.css = [css$14, "@jsenv/navi/src/navi_z_indexes.js"];
105
+ import.meta.css = [css$15, "@jsenv/navi/src/navi_z_indexes.js"];
106
106
 
107
107
  const addIntoArray = (array, ...valuesToAdd) => {
108
108
  if (valuesToAdd.length === 1) {
@@ -371,7 +371,7 @@ installImportMetaCssBuild(import.meta);/**
371
371
  * the very first render and the browser does everything on its own.
372
372
  */
373
373
  const URL_TARGET_ATTRIBUTE = "data-url-target";
374
- const css$13 = /* css */`
374
+ const css$14 = /* css */`
375
375
  @layer navi {
376
376
  [${URL_TARGET_ATTRIBUTE}] {
377
377
  animation: navi_url_target var(--navi-url-target-duration, 2000ms)
@@ -389,7 +389,7 @@ const css$13 = /* css */`
389
389
  }
390
390
  }
391
391
  `;
392
- import.meta.css = [css$13, "@jsenv/navi/src/nav/url_target/url_target.js"];
392
+ import.meta.css = [css$14, "@jsenv/navi/src/nav/url_target/url_target.js"];
393
393
  let urlTargetOptions = {
394
394
  block: "start",
395
395
  behavior: "instant",
@@ -750,7 +750,7 @@ const useActionStatus = (action) => {
750
750
  };
751
751
  };
752
752
 
753
- installImportMetaCssBuild(import.meta);const css$12 = /* css */`
753
+ installImportMetaCssBuild(import.meta);const css$13 = /* css */`
754
754
  .action_error {
755
755
  margin-top: 0;
756
756
  margin-bottom: 20px;
@@ -775,7 +775,7 @@ const ActionRenderer = ({
775
775
  children,
776
776
  disabled
777
777
  }) => {
778
- import.meta.css = [css$12, "@jsenv/navi/src/action/action_renderer.jsx"];
778
+ import.meta.css = [css$13, "@jsenv/navi/src/action/action_renderer.jsx"];
779
779
  if (action === undefined) {
780
780
  throw new Error("ActionRenderer requires an action to render, but none was provided.");
781
781
  }
@@ -3813,6 +3813,9 @@ const CONTROL_PROP_SET = new Set([
3813
3813
  "signal",
3814
3814
  "defaultValue",
3815
3815
  "defaultChecked",
3816
+ // A checked radio pressed again lets its value go — a group allowed to hold
3817
+ // nothing. Answered by the radio's own reactions (see control_hooks.jsx).
3818
+ "deselectable",
3816
3819
  "readOnly", // will depend wether readOnly is supported
3817
3820
 
3818
3821
  "loading",
@@ -4185,15 +4188,26 @@ let renderMessageText = text => text;
4185
4188
  const setCalloutMessageTextRenderer = renderer => {
4186
4189
  renderMessageText = renderer;
4187
4190
  };
4188
- const CalloutRequestCloseContext = createContext();
4191
+
4192
+ // What a callout's own JSX may know about the callout around it: how to close
4193
+ // it, and the element — whose id is what `commandFor` needs when the button
4194
+ // closing it is not inside it (inside, `--navi-close` finds it on its own).
4195
+ const CalloutContext = createContext();
4189
4196
  const useCalloutRequestClose = () => {
4190
- return useContext(CalloutRequestCloseContext);
4197
+ return useContext(CalloutContext)?.requestClose;
4198
+ };
4199
+ const useCalloutElement = () => {
4200
+ return useContext(CalloutContext)?.element;
4191
4201
  };
4192
4202
  const renderIntoCallout = (jsx$1, calloutMessageElement, {
4193
- requestClose
4203
+ requestClose,
4204
+ element
4194
4205
  }) => {
4195
- const calloutJsx = jsx(CalloutRequestCloseContext.Provider, {
4196
- value: requestClose,
4206
+ const calloutJsx = jsx(CalloutContext.Provider, {
4207
+ value: {
4208
+ requestClose,
4209
+ element
4210
+ },
4197
4211
  children: jsx$1
4198
4212
  });
4199
4213
  render(calloutJsx, calloutMessageElement);
@@ -4203,12 +4217,14 @@ const renderIntoCallout = (jsx$1, calloutMessageElement, {
4203
4217
  // text can go through renderMessageText: an emoji in a validation message must
4204
4218
  // not make the first line taller than the icon and close button beside it.
4205
4219
  const renderHtmlIntoCallout = (html, calloutMessageElement, {
4206
- requestClose
4220
+ requestClose,
4221
+ element
4207
4222
  }) => {
4208
4223
  const template = document.createElement("template");
4209
4224
  template.innerHTML = html;
4210
4225
  renderIntoCallout(domToVNodes(template.content), calloutMessageElement, {
4211
- requestClose
4226
+ requestClose,
4227
+ element
4212
4228
  });
4213
4229
  };
4214
4230
 
@@ -4241,6 +4257,98 @@ const domToVNodes = node => {
4241
4257
  return vnodes;
4242
4258
  };
4243
4259
 
4260
+ installImportMetaCssBuild(import.meta);/**
4261
+ * The icon a callout shows beside its message — a square in the status color
4262
+ * with the "!" glyph — drawable on its own, where the callout is not: on the
4263
+ * trigger that opens one (a picker in callout mode), so what one presses looks
4264
+ * like what it opens. The callout's own template draws from the same glyph
4265
+ * (see calloutTemplate in callout.js).
4266
+ */
4267
+
4268
+ const css$12 = /* css */`
4269
+ .navi_callout_status_icon {
4270
+ --x-callout-status-icon-color: var(--navi-callout-neutral-color);
4271
+
4272
+ display: inline-flex;
4273
+ box-sizing: border-box;
4274
+ aspect-ratio: 1 / 1;
4275
+ width: 1em;
4276
+ height: 1em;
4277
+ flex-shrink: 0;
4278
+ align-items: center;
4279
+ justify-content: center;
4280
+ color: white;
4281
+ vertical-align: middle;
4282
+ background-color: var(--x-callout-status-icon-color);
4283
+ border-radius: 2px;
4284
+
4285
+ &[data-status="success"] {
4286
+ --x-callout-status-icon-color: var(--navi-callout-success-color);
4287
+ }
4288
+ &[data-status="info"] {
4289
+ --x-callout-status-icon-color: var(--navi-callout-info-color);
4290
+ }
4291
+ &[data-status="warning"] {
4292
+ --x-callout-status-icon-color: var(--navi-callout-warning-color);
4293
+ }
4294
+ &[data-status="error"] {
4295
+ --x-callout-status-icon-color: var(--navi-callout-error-color);
4296
+ }
4297
+ &[data-shape="circle"] {
4298
+ border-radius: 50%;
4299
+ }
4300
+
4301
+ svg {
4302
+ width: auto;
4303
+ height: 55%;
4304
+ }
4305
+ }
4306
+ /* Inside an <Icon>, the icon box is the size: fill it. The glyph keeps its
4307
+ own share of it — the Icon's rule sizing any svg it holds to the whole
4308
+ box is for an svg that IS the icon, and this one sits in a square. */
4309
+ .navi_icon > .navi_callout_status_icon {
4310
+ width: 100%;
4311
+ height: 100%;
4312
+ }
4313
+ .navi_icon[data-icon-char] .navi_callout_status_icon svg {
4314
+ width: auto;
4315
+ height: 55%;
4316
+ }
4317
+ `;
4318
+ const CALLOUT_STATUS_GLYPH_VIEWBOX = "0 0 125 300";
4319
+ const CALLOUT_STATUS_GLYPH_PATH = "m25,1 8,196h59l8-196zm37,224a37,37 0 1,0 2,0z";
4320
+
4321
+ /**
4322
+ * @type {import("ignore:preact").FunctionComponent<{
4323
+ * status?: "info" | "warning" | "error" | "success" | "none",
4324
+ * shape?: "square" | "circle",
4325
+ * }>}
4326
+ * @param {"info"|"warning"|"error"|"success"|"none"} [status="info"] The color
4327
+ * — the callout's own for that status. `"none"` is the neutral one.
4328
+ * @param {"square"|"circle"} [shape="square"] Square like the callout's own
4329
+ * icon, or a circle.
4330
+ */
4331
+ const CalloutStatusIcon = ({
4332
+ status = "info",
4333
+ shape = "square"
4334
+ }) => {
4335
+ import.meta.css = [css$12, "@jsenv/navi/src/control/rules/callout/callout_status_icon.jsx"];
4336
+ return jsx("span", {
4337
+ className: "navi_callout_status_icon",
4338
+ "data-status": status === "none" ? undefined : status,
4339
+ "data-shape": shape === "circle" ? "circle" : undefined,
4340
+ "aria-hidden": "true",
4341
+ children: jsx("svg", {
4342
+ viewBox: CALLOUT_STATUS_GLYPH_VIEWBOX,
4343
+ xmlns: "http://www.w3.org/2000/svg",
4344
+ children: jsx("path", {
4345
+ fill: "currentColor",
4346
+ d: CALLOUT_STATUS_GLYPH_PATH
4347
+ })
4348
+ })
4349
+ });
4350
+ };
4351
+
4244
4352
  installImportMetaCssBuild(import.meta);/**
4245
4353
  * A callout component that mimics native browser validation messages.
4246
4354
  * Features:
@@ -4250,6 +4358,9 @@ installImportMetaCssBuild(import.meta);/**
4250
4358
  * - Arrow automatically shows when pointing at a valid anchor element
4251
4359
  * - Centers in viewport when no anchor element provided or anchor is too big
4252
4360
  */
4361
+
4362
+ // Unique for the page's lifetime: a caller may write the id in a commandfor.
4363
+ let calloutCount = 0;
4253
4364
  const css$11 = /* css */`
4254
4365
  @layer navi {
4255
4366
  .navi_callout {
@@ -4259,19 +4370,37 @@ const css$11 = /* css */`
4259
4370
  copies. */
4260
4371
  user-select: text;
4261
4372
 
4262
- --callout-success-color: #4caf50;
4263
- --callout-info-color: #2196f3;
4264
- --callout-warning-color: #ff9800;
4265
- --callout-error-color: #f44336;
4373
+ --callout-success-color: var(--navi-callout-success-color);
4374
+ --callout-info-color: var(--navi-callout-info-color);
4375
+ --callout-warning-color: var(--navi-callout-warning-color);
4376
+ --callout-error-color: var(--navi-callout-error-color);
4377
+ --callout-neutral-color: var(--navi-callout-neutral-color);
4266
4378
 
4267
4379
  --callout-background-color: white;
4268
4380
  --callout-icon-color: black;
4269
4381
  --callout-padding: 8px;
4270
4382
  --callout-z-index: var(--navi-z-index-callout);
4383
+ /* The callout's own, like its font: the icon and the cross are columns
4384
+ one line tall (1lh), and that line has to be the message's first
4385
+ line — which it is only if both read the same line-height, rather
4386
+ than whatever the element the callout sits in happens to use. */
4387
+ --callout-line-height: 1.5;
4388
+ /* The cross is furniture, not content: quieter than the message beside
4389
+ it, and the size of a glyph on the message's first line. */
4390
+ --callout-close-button-color: color-mix(
4391
+ in srgb,
4392
+ currentColor 45%,
4393
+ transparent
4394
+ );
4395
+ --callout-close-button-color-hover: currentColor;
4396
+ --callout-close-button-size: 0.7em;
4271
4397
  }
4272
4398
  }
4273
4399
 
4274
4400
  .navi_callout {
4401
+ /* No status until one is said (the data-status blocks below): a plain
4402
+ tooltip, framed in the neutral color and shown without an icon. */
4403
+ --x-callout-status-color: var(--callout-neutral-color);
4275
4404
  --x-callout-border-color: var(--x-callout-status-color);
4276
4405
  --x-callout-background-color: var(--callout-background-color);
4277
4406
  --x-callout-icon-color: var(--x-callout-status-color);
@@ -4297,6 +4426,9 @@ const css$11 = /* css */`
4297
4426
  color: revert; /* Do no inherit element color, callout is inside the element it should use document color though */
4298
4427
  font-weight: initial; /* Callout fells disconnected from the element, font weight should be predictible and stable */
4299
4428
  font-size: initial; /* Callout fells disconnected from the element, font size should be predictible and stable */
4429
+ line-height: var(
4430
+ --callout-line-height
4431
+ ); /* Same reason — and what the columns measure their 1lh against */
4300
4432
  background: transparent;
4301
4433
  border: none;
4302
4434
  outline: none; /* programmatic focus may land here briefly before being redirected to close button */
@@ -4326,6 +4458,11 @@ const css$11 = /* css */`
4326
4458
  &[data-status="error"] {
4327
4459
  --x-callout-status-color: var(--callout-error-color);
4328
4460
  }
4461
+ &[data-close-button="none"] {
4462
+ .navi_callout_close_button_column {
4463
+ display: none;
4464
+ }
4465
+ }
4329
4466
 
4330
4467
  .navi_callout_box {
4331
4468
  position: relative;
@@ -4376,6 +4513,15 @@ const css$11 = /* css */`
4376
4513
  background-color: var(--x-callout-icon-color);
4377
4514
  border-radius: 2px;
4378
4515
 
4516
+ /* Not drawn without a status (nothing to say about the message),
4517
+ nor when the message asked for none (icon: false). Nested in
4518
+ here rather than written beside this block: a rule one level up
4519
+ would lose to the display above on specificity alone. */
4520
+ .navi_callout:not([data-status]) &,
4521
+ .navi_callout[data-icon="none"] & {
4522
+ display: none;
4523
+ }
4524
+
4379
4525
  svg {
4380
4526
  width: 16px;
4381
4527
  height: 12px;
@@ -4408,37 +4554,52 @@ const css$11 = /* css */`
4408
4554
  .navi_callout_close_button_column {
4409
4555
  display: flex;
4410
4556
  height: var(--callout-icon-height);
4557
+ /* The button is a square one line tall around a cross of glyph size,
4558
+ so most of it is empty. Taken back on both sides: what is drawn then
4559
+ sits one gap from the text and one padding from the edge — where the
4560
+ icon sits on the other side — instead of that plus the button's own
4561
+ margin. The target keeps its full size; only the space it claims in
4562
+ the row shrinks. */
4563
+ margin-inline: calc(
4564
+ -1 * (var(--callout-icon-height) - var(--callout-close-button-size)) / 2
4565
+ );
4566
+ /* Whatever the cross measures, it sits on the middle of the first line
4567
+ — the line it is read with. */
4568
+ align-items: center;
4411
4569
  align-self: flex-start;
4412
4570
 
4413
4571
  .navi_callout_close_button {
4414
4572
  /* A square filling the column, so the whole first line is the target;
4415
- the padding keeps the cross itself at glyph size. */
4573
+ the cross itself is drawn at glyph size in the middle of it. */
4416
4574
  display: inline-flex;
4417
4575
  box-sizing: border-box;
4418
4576
  aspect-ratio: 1 / 1;
4419
4577
  height: 100%;
4420
- padding: 0.2em;
4578
+ padding: 0;
4421
4579
  align-items: center;
4422
4580
  justify-content: center;
4423
- color: currentColor;
4581
+ color: var(--callout-close-button-color);
4424
4582
  font-size: inherit;
4425
4583
  background: none;
4426
4584
  border: none;
4427
4585
  border-radius: 0.2em;
4586
+ /* transition: color 0.15s ease-in-out; */
4428
4587
  cursor: pointer;
4429
4588
 
4430
4589
  &:hover {
4431
- background: rgba(0, 0, 0, 0.1);
4590
+ color: var(--callout-close-button-color-hover);
4591
+ background: rgba(0, 0, 0, 0.06);
4432
4592
  }
4433
4593
 
4434
4594
  &:focus-visible,
4435
4595
  .navi_callout:focus-visible & {
4596
+ color: var(--callout-close-button-color-hover);
4436
4597
  outline: auto;
4437
4598
  }
4438
4599
 
4439
4600
  .navi_callout_close_button_svg {
4440
- width: 100%;
4441
- height: 100%;
4601
+ width: var(--callout-close-button-size);
4602
+ height: var(--callout-close-button-size);
4442
4603
  }
4443
4604
  }
4444
4605
  }
@@ -4453,6 +4614,11 @@ const css$11 = /* css */`
4453
4614
  * @param {string} [options.status=""] - Callout status: "info" | "warning" | "error" | "success"
4454
4615
  * @param {Function} [options.onClose] - Callback when callout is closed
4455
4616
  * @param {boolean} [options.closeOnClickOutside] - Whether to close on outside clicks (defaults to true for "info" status)
4617
+ * @param {boolean} [options.icon=true] - Whether the status icon is shown beside the message.
4618
+ * Never shown without a status either way (see the CSS).
4619
+ * @param {boolean} [options.closeButton=true] - Whether the cross is shown. Without it the callout
4620
+ * still closes on Escape, a click outside and its own `--navi-close` — for a tooltip that is
4621
+ * read rather than dismissed
4456
4622
  * @param {string} [options.reopen="toggle"] - What to do when the anchor already has an open callout:
4457
4623
  * "toggle" closes it (a second press on what opened it closes it), "update" replaces its message
4458
4624
  * in place, "replace" tears it down and opens a new one
@@ -4499,6 +4665,8 @@ const openCallout = (message, {
4499
4665
  reopen = "toggle",
4500
4666
  showErrorStack,
4501
4667
  skipFocus = false,
4668
+ icon = true,
4669
+ closeButton = true,
4502
4670
  debug = () => {}
4503
4671
  } = {}) => {
4504
4672
  import.meta.css = [css$11, "@jsenv/navi/src/control/rules/callout/callout.js"];
@@ -4655,10 +4823,27 @@ const openCallout = (message, {
4655
4823
  }
4656
4824
  requestClose(e, "click_close_button");
4657
4825
  };
4658
- const calloutId = `navi_callout_${Date.now()}`;
4826
+ calloutCount++;
4827
+ const calloutId = `navi_callout_${calloutCount}`;
4659
4828
  calloutElement.id = calloutId;
4660
4829
  calloutElement.style.opacity = 0;
4661
4830
  const update = (newMessage, options = {}) => {
4831
+ if (Object.hasOwn(options, "icon")) {
4832
+ if (options.icon === false) {
4833
+ calloutElement.setAttribute("data-icon", "none");
4834
+ } else {
4835
+ calloutElement.removeAttribute("data-icon");
4836
+ }
4837
+ }
4838
+ if (Object.hasOwn(options, "closeButton")) {
4839
+ // Per message rather than per callout: what replaces a message (a
4840
+ // constraint taking over a tooltip's callout) brings its own cross back.
4841
+ if (options.closeButton === false) {
4842
+ calloutElement.setAttribute("data-close-button", "none");
4843
+ } else {
4844
+ calloutElement.removeAttribute("data-close-button");
4845
+ }
4846
+ }
4662
4847
  const prevStatus = callout.status;
4663
4848
  // Connect callout with target element for accessibility
4664
4849
  if (options.status && options.status !== callout.status) {
@@ -4678,7 +4863,8 @@ const openCallout = (message, {
4678
4863
  if (isValidElement(newMessage)) {
4679
4864
  debug(`callout update message (jsx)`);
4680
4865
  renderIntoCallout(newMessage, calloutMessageElement, {
4681
- requestClose
4866
+ requestClose,
4867
+ element: calloutElement
4682
4868
  });
4683
4869
  } else if (newMessage instanceof Node) {
4684
4870
  // Handle DOM node (cloned from CSS selector)
@@ -4690,9 +4876,11 @@ const openCallout = (message, {
4690
4876
  clearCalloutMessage(calloutMessageElement);
4691
4877
  newMessage({
4692
4878
  renderIntoCallout: jsx => renderIntoCallout(jsx, calloutMessageElement, {
4693
- requestClose
4879
+ requestClose,
4880
+ element: calloutElement
4694
4881
  }),
4695
- requestClose
4882
+ requestClose,
4883
+ element: calloutElement
4696
4884
  });
4697
4885
  } else {
4698
4886
  if (Error.isError(newMessage)) {
@@ -4717,7 +4905,8 @@ const openCallout = (message, {
4717
4905
  } else {
4718
4906
  debug(`callout update message: ${typeof newMessage === "string" ? newMessage.slice(0, 80) : String(newMessage)}`);
4719
4907
  renderHtmlIntoCallout(String(newMessage), calloutMessageElement, {
4720
- requestClose
4908
+ requestClose,
4909
+ element: calloutElement
4721
4910
  });
4722
4911
  }
4723
4912
  }
@@ -4932,6 +5121,21 @@ const openCallout = (message, {
4932
5121
  };
4933
5122
  calloutElement.addEventListener("navi_request_close", handleCustomCloseEvent);
4934
5123
  }
5124
+ {
5125
+ // What a command said inside the callout is aimed at, once aria-expanded
5126
+ // made the callout its target (see calloutTemplate): run it, the way a
5127
+ // popup runs the commands aimed at it. Inlined rather than onNaviCommand
5128
+ // from commands.js, which already reaches this module through the callout
5129
+ // manager and must not be reached back.
5130
+ calloutElement.addEventListener("navi_command", e => {
5131
+ const {
5132
+ implementation
5133
+ } = e.detail;
5134
+ if (typeof implementation === "function") {
5135
+ implementation();
5136
+ }
5137
+ });
5138
+ }
4935
5139
  Object.assign(callout, {
4936
5140
  element: calloutElement,
4937
5141
  update,
@@ -5030,7 +5234,9 @@ const openCallout = (message, {
5030
5234
  });
5031
5235
  }
5032
5236
  update(message, {
5033
- status
5237
+ status,
5238
+ icon,
5239
+ closeButton
5034
5240
  });
5035
5241
 
5036
5242
  // positionCallout itself handles both "no anchorElement at all" and "a
@@ -5056,21 +5262,25 @@ const ARROW_WIDTH = 16;
5056
5262
  const ARROW_HEIGHT = 8;
5057
5263
  const ARROW_SPACING = 8;
5058
5264
 
5059
- // HTML template for the callout
5265
+ // aria-expanded is what --navi-close/--navi-cancel resolve their target with
5266
+ // (closest "[aria-expanded]", see commands.js): a button inside the callout
5267
+ // closes the callout, not the picker or dialog around it. Never "false" — a
5268
+ // closed callout is removed, not kept.
5060
5269
  const calloutTemplate = /* html */`
5061
5270
  <div
5062
5271
  class="navi_callout"
5063
5272
  popover="manual"
5273
+ aria-expanded="true"
5064
5274
  >
5065
5275
  <div class="navi_callout_box">
5066
5276
  <div class="navi_callout_frame"></div>
5067
5277
  <div class="navi_callout_body">
5068
5278
  <div class="navi_callout_icon">
5069
- <svg viewBox="0 0 125 300" xmlns="http://www.w3.org/2000/svg">
5070
- <path
5071
- fill="currentColor"
5072
- d="m25,1 8,196h59l8-196zm37,224a37,37 0 1,0 2,0z"
5073
- />
5279
+ <svg
5280
+ viewBox="${CALLOUT_STATUS_GLYPH_VIEWBOX}"
5281
+ xmlns="http://www.w3.org/2000/svg"
5282
+ >
5283
+ <path fill="currentColor" d="${CALLOUT_STATUS_GLYPH_PATH}" />
5074
5284
  </svg>
5075
5285
  </div>
5076
5286
  <!-- Keep .navi_callout_message so preact controls it -->
@@ -5719,6 +5929,8 @@ const createCalloutManager = (
5719
5929
  const [, remainingTokenData] = tokens.entries().next().value;
5720
5930
  callout.update(remainingTokenData.message, {
5721
5931
  status: remainingTokenData.status,
5932
+ icon: remainingTokenData.icon,
5933
+ closeButton: remainingTokenData.closeButton,
5722
5934
  });
5723
5935
  }
5724
5936
  return false;
@@ -5740,7 +5952,16 @@ const createCalloutManager = (
5740
5952
 
5741
5953
  const addOpenToken = (
5742
5954
  token,
5743
- { message, status, anchorElement, event, skipFocus, onClose } = {},
5955
+ {
5956
+ message,
5957
+ status,
5958
+ icon,
5959
+ closeButton,
5960
+ anchorElement,
5961
+ event,
5962
+ skipFocus,
5963
+ onClose,
5964
+ } = {},
5744
5965
  ) => {
5745
5966
  if (!message) {
5746
5967
  removeOpenToken(token, event);
@@ -5748,10 +5969,12 @@ const createCalloutManager = (
5748
5969
  }
5749
5970
  const calloutOptions = {
5750
5971
  status,
5972
+ icon,
5973
+ closeButton,
5751
5974
  closeOnClickOutside: status !== "error",
5752
5975
  };
5753
5976
 
5754
- tokens.set(token, { message, status, onClose });
5977
+ tokens.set(token, { message, status, icon, closeButton, onClose });
5755
5978
  if (callout) {
5756
5979
  callout.update(message, calloutOptions);
5757
5980
  return;
@@ -5768,7 +5991,11 @@ const createCalloutManager = (
5768
5991
  openingEvent: event,
5769
5992
  skipFocus,
5770
5993
  debug: debugPopup,
5771
- onClose: ({ event: closeEvent, shouldTransferFocusFromCallout }) => {
5994
+ onClose: ({
5995
+ event: closeEvent,
5996
+ reason,
5997
+ shouldTransferFocusFromCallout,
5998
+ }) => {
5772
5999
  removeCloseOnCleanup?.();
5773
6000
  for (const result of openResults) {
5774
6001
  if (typeof result === "function") {
@@ -5777,8 +6004,10 @@ const createCalloutManager = (
5777
6004
  }
5778
6005
  callout = null;
5779
6006
  // User dismissed the callout — notify all active tokens then clear.
6007
+ // Told what closed it: a token whose content is a popup of its own (a
6008
+ // picker in callout mode) closes that popup on the same event.
5780
6009
  for (const [, tokenData] of tokens) {
5781
- tokenData.onClose?.();
6010
+ tokenData.onClose?.({ event: closeEvent, reason });
5782
6011
  }
5783
6012
  tokens.clear();
5784
6013
  const element = controller.ref.current;
@@ -24826,9 +25055,11 @@ const getHrefTargetInfo = (href) => {
24826
25055
  * leave by where the reader came in, and the swipe (which replaces already, see
24827
25056
  * route_travel.jsx) and the press say the same thing.
24828
25057
  *
24829
- * `<Link replace>` is that, and it travels as an attribute because the click
24830
- * handler sees the anchor, not the component that rendered it — the same mouth
24831
- * as what a link asks of a route transition.
25058
+ * `<Link replace>` is that, and so is `<Button replace>` — on an `href`, or on
25059
+ * a `--navi-nav-to` command. It travels as an attribute because whoever
25060
+ * answers the press sees the element, not the component that rendered it: the
25061
+ * click handler reads it off the anchor, the command off its source — the same
25062
+ * mouth as what a link asks of a route transition.
24832
25063
  */
24833
25064
 
24834
25065
  const LINK_REPLACE_ATTRIBUTE = "data-navi-replace";
@@ -29529,7 +29760,25 @@ registerNaviCommand("--navi-nav-to", (source, event, { argument }) => {
29529
29760
  const target = resolveExplicitTarget(source) || source;
29530
29761
  return {
29531
29762
  target,
29532
- implementation: () => navTo(argument),
29763
+ // Which way there is worn by the source, as a link wears it (see
29764
+ // link_replace.js): `<Button command="--navi-nav-to:/done" replace>`.
29765
+ implementation: () =>
29766
+ navTo(
29767
+ argument,
29768
+ linkAsksForReplace(source) ? { replace: true } : undefined,
29769
+ ),
29770
+ };
29771
+ });
29772
+
29773
+ // Back to the screen the reader came from, with somewhere to land when there
29774
+ // is none of ours behind — a url opened cold (see navBack). The fallback is
29775
+ // the argument for the reason --navi-nav-to's destination is: it says WHAT the
29776
+ // command does when there is nothing to go back to.
29777
+ registerNaviCommand("--navi-nav-back", (source, event, { argument }) => {
29778
+ const target = resolveExplicitTarget(source) || source;
29779
+ return {
29780
+ target,
29781
+ implementation: () => navBack({ fallback: argument }),
29533
29782
  };
29534
29783
  });
29535
29784
 
@@ -33296,6 +33545,21 @@ const useControlProps = (props, {
33296
33545
  return defaultAction !== "type" && defaultAction !== "value_change";
33297
33546
  };
33298
33547
  if (controlType === "link") {
33548
+ // Nothing waits for a link's action: the press is a departure, and
33549
+ // the navigation goes whatever the action does — so its command goes
33550
+ // too, unlike a button's, which follows its action (see below).
33551
+ const onLinkInteractionAllowed = e => {
33552
+ triggerUIAction(e);
33553
+ const control = ref.current;
33554
+ if (!control) {
33555
+ return;
33556
+ }
33557
+ tryActionAfterInteractionAllowed(control, {
33558
+ event: e,
33559
+ action: boundAction,
33560
+ requester: control
33561
+ });
33562
+ };
33299
33563
  return {
33300
33564
  keyDown: e => {
33301
33565
  if (isSpaceToActivate(e)) {
@@ -33314,6 +33578,7 @@ const useControlProps = (props, {
33314
33578
  click: e => {
33315
33579
  return {
33316
33580
  name: "click",
33581
+ allowed: () => onLinkInteractionAllowed(e),
33317
33582
  prevented: () => {
33318
33583
  e.preventDefault();
33319
33584
  }
@@ -33541,6 +33806,15 @@ const useControlProps = (props, {
33541
33806
  };
33542
33807
  if (isRadio) {
33543
33808
  if (checked) {
33809
+ if (props.deselectable) {
33810
+ return {
33811
+ name: "enter to uncheck radio",
33812
+ allowed: () => dispatchRequestSetUIState(inputEl, undefined, {
33813
+ event: e
33814
+ }),
33815
+ always
33816
+ };
33817
+ }
33544
33818
  return {
33545
33819
  name: "enter on checked radio",
33546
33820
  allowed: () => triggerUIAction(e),
@@ -33566,6 +33840,14 @@ const useControlProps = (props, {
33566
33840
  if (isRadio && e.key === " ") {
33567
33841
  const inputEl = e.currentTarget;
33568
33842
  if (inputEl.checked) {
33843
+ if (props.deselectable) {
33844
+ return {
33845
+ name: "space to uncheck radio",
33846
+ allowed: () => dispatchRequestSetUIState(inputEl, undefined, {
33847
+ event: e
33848
+ })
33849
+ };
33850
+ }
33569
33851
  // allow space to still trigger uiState and commands
33570
33852
  // on checked radios (won't update the ui state but will notify of interaction)
33571
33853
  return {
@@ -33591,6 +33873,20 @@ const useControlProps = (props, {
33591
33873
  // that lands on the <label> (not the <input>) never fires "mousedown" on
33592
33874
  // the input at all, so a DOM-snapshot-at-mousedown approach misses it.
33593
33875
  if (isRadio && uiStateController.uiState !== undefined) {
33876
+ if (props.deselectable) {
33877
+ // Not prevented: a prevented click has the browser put the
33878
+ // radio back to checked once the listeners are done. Left to
33879
+ // run, it changes nothing (the radio was checked already) and
33880
+ // reports nothing — a radio getting unchecked fires no input
33881
+ // event — so this is the one place the uncheck is said.
33882
+ return {
33883
+ name: `click to uncheck radio`,
33884
+ allowed: () => dispatchRequestSetUIState(ref.current, undefined, {
33885
+ event: e
33886
+ }),
33887
+ prevented: () => e.preventDefault()
33888
+ };
33889
+ }
33594
33890
  return {
33595
33891
  name: `click on checked radio`,
33596
33892
  allowed: () => triggerUIAction(e),
@@ -38584,6 +38880,14 @@ Object.assign(PSEUDO_CLASSES, {
38584
38880
  * way there changes. What a row of tabs wants — the neighbour is a lateral
38585
38881
  * move, not a step deeper, so the whole row weighs one entry and the back
38586
38882
  * button leaves by where the reader came in.
38883
+ * @param {string} [props.command] - What the press asks of a control around
38884
+ * the link — `"--navi-close"` on a link that leaves the sheet it is in.
38885
+ * Triggered on the press, before the navigation.
38886
+ * @param {Function} [props.action] - Work the press runs, before the
38887
+ * navigation. Nothing waits for it — not the navigation, not `command`: what
38888
+ * the next page must find has to be written synchronously (a draft in a
38889
+ * signal), and a request goes on its own while the page changes. Work that
38890
+ * decides the destination navigates itself, from a `<Button action>`.
38587
38891
  * @param {boolean} [props.preventDefault] - Call `event.preventDefault()` on
38588
38892
  * click (navigation suppressed; `onClick` still runs).
38589
38893
  * @param {(event: MouseEvent) => void} [props.onClick]
@@ -38884,7 +39188,7 @@ const LinkPlain = props => {
38884
39188
  "data-current-effect-shadow": currentEffectShadow ? "" : undefined,
38885
39189
  "data-current-indicator-position": currentIndicatorPosition,
38886
39190
  "data-anchor": anchor ? "" : undefined,
38887
- "data-interactive": onClick ? "" : undefined,
39191
+ "data-interactive": onClick || props.command || props.action ? "" : undefined,
38888
39192
  "data-reveal-on-interaction": revealOnInteraction ? "" : undefined,
38889
39193
  baseClassName: "navi_link",
38890
39194
  styleCSSVars: LinkStyleCSSVars,
@@ -45160,6 +45464,7 @@ const ButtonUI = props => {
45160
45464
  href,
45161
45465
  target,
45162
45466
  rel,
45467
+ replace,
45163
45468
  // visual
45164
45469
  variant,
45165
45470
  pressEffect,
@@ -45206,6 +45511,12 @@ const ButtonUI = props => {
45206
45511
  ...basePseudoState,
45207
45512
  ":-navi-href-current": innerCurrent
45208
45513
  };
45514
+
45515
+ // Worn as an attribute, like a link's (see link_replace.js): read off the
45516
+ // anchor by the click handler, off the source by --navi-nav-to.
45517
+ const replaceRequest = replace ? {
45518
+ [LINK_REPLACE_ATTRIBUTE]: ""
45519
+ } : null;
45209
45520
  const visualSelector = ".navi_button_content";
45210
45521
  useAccentColorAttributes(ref, null, {
45211
45522
  elementSelector: visualSelector
@@ -45232,11 +45543,11 @@ const ButtonUI = props => {
45232
45543
  as: as,
45233
45544
  href: href,
45234
45545
  target: innerTarget,
45235
- rel: innerRel
45546
+ rel: innerRel,
45547
+ replace: undefined,
45548
+ ...replaceRequest,
45236
45549
  // Respond with the JS prop value directly so callers (e.g. resolveCommandValue)
45237
45550
  // get the original type instead of the DOM-coerced string (e.g. "[object Object]").
45238
- ,
45239
-
45240
45551
  onnavi_get_value: e => {
45241
45552
  e.detail.respondWith(props.value);
45242
45553
  },
@@ -45454,8 +45765,13 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
45454
45765
  * @type {import("ignore:preact").FunctionComponent<{
45455
45766
  * ownTarget?: boolean | "refuse" | "always",
45456
45767
  * emojiAsIcon?: boolean,
45768
+ * replace?: boolean,
45457
45769
  * [key: string]: any,
45458
45770
  * }>}
45771
+ * @param {boolean} [replace] Go where the press leads — an `href`, a
45772
+ * `--navi-nav-to` command — by TAKING THE PLACE of the current history entry
45773
+ * rather than stacking on it: what `<Link replace>` says, for a press drawn
45774
+ * as a button.
45459
45775
  * @param {boolean} [emojiAsIcon=true] Renders the emoji of the label as icons
45460
45776
  * so the button keeps the height of its text — `Text`'s prop, on by default
45461
45777
  * here. Pass `false` to let an emoji draw at its natural size.
@@ -56105,6 +56421,40 @@ const PickerCustomResolver = props => {
56105
56421
  ...props
56106
56422
  });
56107
56423
  }
56424
+ if (props.mode === "callout") {
56425
+ // A tooltip is an icon one presses, unless told otherwise. Own-property
56426
+ // rather than undefined: an explicit variant={undefined} asks for the
56427
+ // field-like drawing back. "circle" is the icon variant with the status
56428
+ // icon drawn round — a word about the trigger, not a drawing of its own.
56429
+ if (!Object.hasOwn(props, "variant")) {
56430
+ props.variant = "icon";
56431
+ }
56432
+ const circle = props.variant === "circle";
56433
+ if (circle) {
56434
+ props.variant = "icon";
56435
+ }
56436
+ // A word in a sentence asks for a plain tooltip — no icon in the callout,
56437
+ // no status color; an icon one presses is the callout's own status icon,
56438
+ // and says "info" like the callout it opens.
56439
+ if (props.calloutStatus === undefined) {
56440
+ props.calloutStatus = props.variant === "text" ? "none" : "info";
56441
+ }
56442
+ if (props.calloutIcon === undefined) {
56443
+ props.calloutIcon = props.variant !== "text";
56444
+ }
56445
+ if (props.rightSlotIcon === undefined) {
56446
+ props.rightSlotIcon = jsx(CalloutStatusIcon, {
56447
+ status: props.calloutStatus,
56448
+ shape: circle ? "circle" : "square"
56449
+ });
56450
+ }
56451
+ // The arrow on the middle of what was pressed — an icon, a word — rather
56452
+ // than on where its text starts, which is where a callout points at a
56453
+ // field by default (see the anchor attributes in callout.js).
56454
+ if (props["data-callout-arrow-x"] === undefined) {
56455
+ props["data-callout-arrow-x"] = "center";
56456
+ }
56457
+ }
56108
56458
  if (props.type === undefined) {
56109
56459
  // A picker with a popup of its own holds whatever the control inside it
56110
56460
  // holds — a boolean, a number, an id — and a field with no type is read
@@ -56448,7 +56798,9 @@ const PickerCustom = props => {
56448
56798
  // opens anything. Told a value — even an empty one — the picker owns it
56449
56799
  // and pushes it down instead, leaving the popup free to build its
56450
56800
  // content only when it is first opened (see popup_content_mount.js).
56451
- mountWhenClosed: !isControlValueGivenByProps(props),
56801
+ // A caller who knows better says so with the popup's own props.
56802
+ mountWhenClosed: props.mountWhenClosed ?? !isControlValueGivenByProps(props),
56803
+ unmountWhenClosed: props.unmountWhenClosed,
56452
56804
  // Not on pickerProps (the trigger): commands.js's own
56453
56805
  // resolveClosestExpandable() does `el.closest("[aria-expanded]")` to
56454
56806
  // find where to dispatch navi_request_open/navi_request_close — and
@@ -56687,6 +57039,9 @@ const PickerContentInsidePopup = props => {
56687
57039
  // but popupProps is built explicitly here, so it only travels if named.
56688
57040
  // "popupLayer" rather than "layer": the picker itself is not the popup.
56689
57041
  popupLayer,
57042
+ // Same reason: a `data-testid` on the picker names the trigger (see
57043
+ // docs/testid.md) — this one names the popup.
57044
+ popupTestId,
56690
57045
  positionArea,
56691
57046
  popoverMode = "nearby",
56692
57047
  popoverSpacing = popoverMode === "nearby" ? 5 : 0,
@@ -56708,13 +57063,26 @@ const PickerContentInsidePopup = props => {
56708
57063
  // marginWithAnchor.
56709
57064
  dockedOnSmallTouchScreen,
56710
57065
  animation,
57066
+ // mode="callout": what the callout says about what it holds, and paints
57067
+ // in its border and icon — "none" for a plain tooltip (see the callout
57068
+ // defaults in PickerCustomResolver). And whether it wears a cross: without
57069
+ // one it still closes on Escape, a click outside, or a --navi-close of the
57070
+ // content's own.
57071
+ calloutStatus,
57072
+ calloutIcon,
57073
+ calloutCloseButton,
56711
57074
  ...rest
56712
57075
  } = props;
56713
57076
  const isPopover = mode === "popover";
57077
+ const isCallout = mode === "callout";
56714
57078
  return jsx(Next, {
56715
57079
  "aria-haspopup": isPopover ? "listbox" : "dialog",
56716
57080
  "navi-popover-mode": isPopover ? popoverMode : undefined,
56717
57081
  ...rest,
57082
+ // On popupProps already (see the picker's popup assembly); they mean
57083
+ // nothing to the picker element.
57084
+ mountWhenClosed: undefined,
57085
+ unmountWhenClosed: undefined,
56718
57086
  onFocusOut: e => {
56719
57087
  if (!isPopover || !closeOnFocusOut) {
56720
57088
  return;
@@ -56739,8 +57107,19 @@ const PickerContentInsidePopup = props => {
56739
57107
  }
56740
57108
  });
56741
57109
  },
56742
- children: jsx(Popup, {
57110
+ children: isCallout ? jsx(PickerCalloutPopup, {
56743
57111
  ...popupProps,
57112
+ pickerRef: props.ref,
57113
+ status: calloutStatus,
57114
+ icon: calloutIcon,
57115
+ closeButton: calloutCloseButton,
57116
+ children: jsx(PopupModeContext.Provider, {
57117
+ value: mode,
57118
+ children: children
57119
+ })
57120
+ }) : jsx(Popup, {
57121
+ ...popupProps,
57122
+ "data-testid": popupTestId,
56744
57123
  mode: mode,
56745
57124
  layer: popupLayer,
56746
57125
  animation: animation,
@@ -56763,6 +57142,112 @@ const PickerContentInsidePopup = props => {
56763
57142
  });
56764
57143
  };
56765
57144
 
57145
+ // One token per picker rather than per instance: a callout manager belongs to
57146
+ // one control, so the key only has to be distinct from the other reasons that
57147
+ // control may have to show a callout (a failing constraint, a busy refusal, its
57148
+ // `error` prop). Those keep working on top of this one: opened while the
57149
+ // content is up they take the callout over, and give it back when they go.
57150
+ const PICKER_CALLOUT_CONTENT_TOKEN = createOpenToken();
57151
+
57152
+ /**
57153
+ * The popup of a `mode="callout"` picker: the picker's own callout — the one
57154
+ * its constraints speak in — showing the picker's children instead of a
57155
+ * message. A speech bubble on the trigger, for a tooltip that opens on a press.
57156
+ *
57157
+ * Wired the way Popover and Dialog are, through `openController.openEffect`:
57158
+ * opening adds a token to the picker's callout manager, whose cleanup removes
57159
+ * it. The callout has ways out of its own (its cross, a click outside, Escape,
57160
+ * focus leaving the picker) and says so through the token's `onClose`, which
57161
+ * closes the controller for real — the popup is already gone, there is no
57162
+ * choice left to offer `requestClose`.
57163
+ *
57164
+ * The content is rendered through a portal into an element this component
57165
+ * owns, handed to the callout as its message (a Node, appended as-is). It is
57166
+ * rendered whether the callout is open or not, so what the content holds
57167
+ * survives a close, the way a popup's `mountWhenClosed` keeps it. The element
57168
+ * carries data-picker-content: the callout is appended inside the picker root,
57169
+ * and a press in there must read as inside the popup, not on the trigger.
57170
+ */
57171
+ const PickerCalloutPopup = ({
57172
+ ref,
57173
+ id,
57174
+ anchor,
57175
+ openController,
57176
+ pickerRef,
57177
+ status,
57178
+ icon,
57179
+ closeButton,
57180
+ onnavi_request_open,
57181
+ onnavi_request_close,
57182
+ onnavi_request_confirm,
57183
+ children
57184
+ }) => {
57185
+ const hostRef = useRef(null);
57186
+ if (!hostRef.current) {
57187
+ const host = document.createElement("div");
57188
+ host.setAttribute("data-picker-content", "");
57189
+ hostRef.current = host;
57190
+ }
57191
+ // Reassigned on every render, like Popover's own, so it closes over the
57192
+ // latest props.
57193
+ openController.openEffect = openEvent => {
57194
+ const pickerEl = pickerRef.current;
57195
+ const calloutManager = getPickerInput(pickerEl).__uiStateController__.rules.callout;
57196
+ // Only an anchor the caller named: left unsaid, the manager anchors on the
57197
+ // picker's own input — which is where the data-callout-* attributes a
57198
+ // caller puts on the picker land, and where the callout reads them.
57199
+ const anchorElement = anchor === pickerRef ? undefined : anchor && "current" in anchor ? anchor.current : anchor;
57200
+ calloutManager.addOpenToken(PICKER_CALLOUT_CONTENT_TOKEN, {
57201
+ message: hostRef.current,
57202
+ // "none" is the picker's word for it; the callout's is no status at all.
57203
+ status: status === "none" ? undefined : status,
57204
+ icon,
57205
+ closeButton,
57206
+ anchorElement,
57207
+ // The request, chained to the press that made it: the callout reads the
57208
+ // mousedown off it to wait for the release before listening for a click
57209
+ // outside — the same gesture's own click would close it otherwise.
57210
+ event: openEvent,
57211
+ // Not skipped: the callout moves the focus into the picker when it is
57212
+ // elsewhere, which is what lets Escape find the callout right away.
57213
+ skipFocus: false,
57214
+ onClose: ({
57215
+ event
57216
+ }) => {
57217
+ openController.close(event);
57218
+ }
57219
+ });
57220
+ // A --navi-confirm said inside the callout is aimed at the callout (its
57221
+ // aria-expanded), not at the element the picker listens on: carried over,
57222
+ // for a confirm picker whose question is a speech bubble.
57223
+ const calloutElement = calloutManager.callout.element;
57224
+ const forwardConfirm = e => {
57225
+ onnavi_request_confirm?.(e);
57226
+ };
57227
+ calloutElement.addEventListener("navi_request_confirm", forwardConfirm);
57228
+ return closeEvent => {
57229
+ calloutElement.removeEventListener("navi_request_confirm", forwardConfirm);
57230
+ calloutManager.removeOpenToken(PICKER_CALLOUT_CONTENT_TOKEN, closeEvent);
57231
+ };
57232
+ };
57233
+ return (
57234
+ // What the picker addresses (aria-controls, the request events it
57235
+ // forwards); the callout itself lives where the callout manager puts it.
57236
+ jsx(Box, {
57237
+ as: "span",
57238
+ ref: ref,
57239
+ id: id,
57240
+ style: {
57241
+ display: "contents"
57242
+ },
57243
+ onnavi_request_open: onnavi_request_open,
57244
+ onnavi_request_close: onnavi_request_close,
57245
+ onnavi_request_confirm: onnavi_request_confirm,
57246
+ children: createPortal(children, hostRef.current)
57247
+ })
57248
+ );
57249
+ };
57250
+
56766
57251
  installImportMetaCssBuild(import.meta);/**
56767
57252
  * A trigger that asks "are you sure?" before doing what it stands for.
56768
57253
  *
@@ -56811,6 +57296,8 @@ const PickerConfirmResolver = props => {
56811
57296
  message,
56812
57297
  confirmLabel,
56813
57298
  cancelLabel,
57299
+ confirmTestId,
57300
+ cancelTestId,
56814
57301
  focusOnOpen = "confirm",
56815
57302
  // A popover whatever the screen: the question is short and about the
56816
57303
  // control it points at, and a sheet sliding up for one sentence is too
@@ -56854,15 +57341,25 @@ const PickerConfirmResolver = props => {
56854
57341
  mode: mode,
56855
57342
  focusCapture: focusCapture,
56856
57343
  openWhileReadOnly: openWhileReadOnly,
56857
- onConfirm: onConfirm,
57344
+ onConfirm: onConfirm
57345
+ // A question holds no value to read before it is asked: the popup is
57346
+ // built on the first open, like that of a picker told its value (see
57347
+ // mountWhenClosed in picker_custom.jsx).
57348
+ ,
57349
+
57350
+ mountWhenClosed: false,
56858
57351
  message: undefined,
56859
57352
  confirmLabel: undefined,
56860
57353
  cancelLabel: undefined,
57354
+ confirmTestId: undefined,
57355
+ cancelTestId: undefined,
56861
57356
  focusOnOpen: undefined,
56862
57357
  children: children === undefined ? jsx(PickerConfirmBody, {
56863
57358
  message: message,
56864
57359
  confirmLabel: confirmLabel,
56865
57360
  cancelLabel: cancelLabel,
57361
+ confirmTestId: confirmTestId,
57362
+ cancelTestId: cancelTestId,
56866
57363
  focusOnOpen: focusOnOpen
56867
57364
  }) : children
56868
57365
  });
@@ -56871,6 +57368,8 @@ const PickerConfirmBody = ({
56871
57368
  message,
56872
57369
  confirmLabel,
56873
57370
  cancelLabel,
57371
+ confirmTestId,
57372
+ cancelTestId,
56874
57373
  focusOnOpen
56875
57374
  }) => {
56876
57375
  return jsxs("div", {
@@ -56882,10 +57381,12 @@ const PickerConfirmBody = ({
56882
57381
  children: [jsx(Button, {
56883
57382
  command: "--navi-cancel",
56884
57383
  autoFocus: focusOnOpen === "cancel",
57384
+ "data-testid": cancelTestId,
56885
57385
  children: cancelLabel
56886
57386
  }), jsx(Button, {
56887
57387
  command: "--navi-confirm",
56888
57388
  autoFocus: focusOnOpen === "confirm",
57389
+ "data-testid": confirmTestId,
56889
57390
  children: confirmLabel
56890
57391
  })]
56891
57392
  })]
@@ -57847,6 +58348,9 @@ installImportMetaCssBuild(import.meta);const css$z = /* css */`
57847
58348
  }
57848
58349
  `;
57849
58350
  const SelectableListMultipleContext = createContext(false);
58351
+ // A single-select list whose selected row, pressed again, lets go: what its
58352
+ // rows' radios need to know to answer the press (see `deselectable` on Input).
58353
+ const SelectableListDeselectableContext = createContext(false);
57850
58354
  // A row of a selectable list is selectable — the list is what decides, and a
57851
58355
  // row says nothing unless it wants out (`selectable={false}` on a row that is
57852
58356
  // only there to be read). Also set to false by a non-selectable list, so a list
@@ -57878,6 +58382,7 @@ const ListSelectable = props => {
57878
58382
  const {
57879
58383
  ref,
57880
58384
  multiple,
58385
+ deselectable,
57881
58386
  focusGroupDirection,
57882
58387
  focusGroupWrap
57883
58388
  } = props;
@@ -58014,6 +58519,7 @@ const ListSelectable = props => {
58014
58519
  defaultValue: undefined,
58015
58520
  selectable: undefined,
58016
58521
  multiple: undefined,
58522
+ deselectable: undefined,
58017
58523
  focusGroupDirection: undefined,
58018
58524
  focusGroupWrap: undefined
58019
58525
  // Track focus inside the list: whichever item gets focus becomes current.
@@ -58136,7 +58642,7 @@ const ListSelectable = props => {
58136
58642
  if (!currentId) {
58137
58643
  return;
58138
58644
  }
58139
- if (multiple) {
58645
+ if (multiple || deselectable) {
58140
58646
  const inputId = `${currentId}_input`;
58141
58647
  const childController = uiGroupStateController.findChildById(inputId);
58142
58648
  const isSelected = childController && childController.uiState;
@@ -58160,7 +58666,10 @@ const ListSelectable = props => {
58160
58666
  value: true,
58161
58667
  children: jsx(SelectableListMultipleContext.Provider, {
58162
58668
  value: multiple,
58163
- children: listVnode
58669
+ children: jsx(SelectableListDeselectableContext.Provider, {
58670
+ value: Boolean(deselectable),
58671
+ children: listVnode
58672
+ })
58164
58673
  })
58165
58674
  });
58166
58675
  };
@@ -58209,6 +58718,8 @@ const ListItemSelectable = props => {
58209
58718
  ...rest
58210
58719
  } = props;
58211
58720
  const multiple = useContext(SelectableListMultipleContext);
58721
+ // A checkbox toggles on its own; only a radio has to be told it may let go.
58722
+ const deselectable = useContext(SelectableListDeselectableContext) && !multiple;
58212
58723
  // Whose reason it is that this row cannot be taken. Read-only reaching it
58213
58724
  // from above is the LIST's, and what is settled is then the whole answer —
58214
58725
  // said as the selection where several things are taken, as the choice where
@@ -58231,6 +58742,7 @@ const ListItemSelectable = props => {
58231
58742
  ref: inputRef,
58232
58743
  id: inputId,
58233
58744
  type: inputType,
58745
+ deselectable,
58234
58746
  defaultChecked: defaultSelected,
58235
58747
  ...(hasSelectedProp ? {
58236
58748
  checked: selected
@@ -58249,9 +58761,10 @@ const ListItemSelectable = props => {
58249
58761
  type: inputType,
58250
58762
  checked,
58251
58763
  readOnly,
58252
- value
58764
+ value,
58765
+ deselectable
58253
58766
  };
58254
- }, [inputId, inputType, checked, readOnly, value]);
58767
+ }, [inputId, inputType, checked, readOnly, value, deselectable]);
58255
58768
  return jsxs(Next, {
58256
58769
  id: id,
58257
58770
  index: index,
@@ -59489,6 +60002,7 @@ const ListFirstResolver = props => {
59489
60002
  * @type {import("ignore:preact").FunctionComponent<{
59490
60003
  * selectable?: boolean,
59491
60004
  * multiple?: boolean,
60005
+ * deselectable?: boolean,
59492
60006
  * maxLength?: number,
59493
60007
  * maxLengthGuard?: number,
59494
60008
  * action?: (value: any) => void,
@@ -59613,6 +60127,11 @@ const ListFirstResolver = props => {
59613
60127
  * Pass `true` for a list whose rows must stay live under the pointer while
59614
60128
  * it scrolls. The trade of the default is the mirror one: right after a
59615
60129
  * scroll, the row under the pointer lights up only once the pointer moves.
60130
+ * @param {boolean} [props.deselectable]
60131
+ * A single-select list allowed to hold nothing: the selected row, pressed
60132
+ * again, lets go. Without it the list is a radio group — a choice, once
60133
+ * made, moves to another row but never goes away. A `multiple` list toggles
60134
+ * its rows already.
59616
60135
  * @param {number} [props.maxLength]
59617
60136
  * How many items a `selectable multiple` list accepts — the same word, and
59618
60137
  * the same behaviour, as `maxLength` on a text field: a rule the list is
@@ -64625,6 +65144,31 @@ installImportMetaCssBuild(import.meta);const css$u = /* css */`
64625
65144
 
64626
65145
  text-align: center;
64627
65146
  }
65147
+ /* text: a word in a sentence, marked by the dotted line under it — the way
65148
+ a term one can ask about is marked. No box, no slot; the font is the
65149
+ sentence's own, so the word sits in its line like the ones around it. */
65150
+ &[data-variant="text"] {
65151
+ --picker-padding-x-default: 0;
65152
+ --picker-padding-y-default: 0;
65153
+ --picker-border-width: 0px; /* must carry a unit (px) — used in calc() to offset the custom input overlay */
65154
+ --picker-border-color: transparent;
65155
+ --picker-border-color-hover: var(--picker-border-color);
65156
+ --picker-border-color-readonly: var(--picker-border-color);
65157
+ --picker-border-color-disabled: var(--picker-border-color);
65158
+ --picker-background-color: transparent;
65159
+ --picker-background-color-hover: var(--picker-background-color);
65160
+ --picker-background-color-readonly: var(--picker-background-color);
65161
+ --picker-background-color-disabled: var(--picker-background-color);
65162
+
65163
+ font-size: inherit;
65164
+ font-family: inherit;
65165
+ text-decoration: underline dotted;
65166
+ text-underline-offset: 0.2em;
65167
+
65168
+ &[data-hover] {
65169
+ text-decoration-style: solid;
65170
+ }
65171
+ }
64628
65172
  }
64629
65173
  `;
64630
65174
  const PickerButton = props => {
@@ -64668,6 +65212,8 @@ const PickerButton = props => {
64668
65212
  error
64669
65213
  } = props;
64670
65214
  const isSingleLine = maxLines === 1;
65215
+ // Same rule as the root: phrasing content inside a sentence.
65216
+ const ContentTag = variant === "text" ? "span" : "div";
64671
65217
  const inputRef = useRef(null);
64672
65218
  const [pickerRemainingProps, inputProps, facadeChildrenProps] = useControlFacadeProps({
64673
65219
  ...props,
@@ -64705,8 +65251,11 @@ const PickerButton = props => {
64705
65251
  as a value nobody may change. */
64706
65252
  jsx(ReadOnlyContext.Provider, {
64707
65253
  value: readOnlyResolved,
64708
- children: jsxs(Box, {
64709
- as: "div",
65254
+ children: jsxs(Box
65255
+ // A word in a sentence (variant="text") sits in a <p>, where a <div> is
65256
+ // not allowed: phrasing content there, block content elsewhere.
65257
+ , {
65258
+ as: variant === "text" ? "span" : "div",
64710
65259
  ref: ref
64711
65260
  // The flow this element really has (.navi_picker is display:inline-flex).
64712
65261
  // Left unsaid, Box reads a <div> as block and resolves alignX into a
@@ -64856,7 +65405,7 @@ const PickerButton = props => {
64856
65405
  // picker behind it is.
64857
65406
  ,
64858
65407
 
64859
- "navi-placeholder": variant !== "button" && uiStateHoldsNothing(value) ? "" : undefined,
65408
+ "navi-placeholder": variant !== "button" && variant !== "text" && uiStateHoldsNothing(value) ? "" : undefined,
64860
65409
  maxLines: maxLines,
64861
65410
  children: jsx(PickerOwnContent, {
64862
65411
  children: jsx(PickerContext.Provider, {
@@ -64880,7 +65429,7 @@ const PickerButton = props => {
64880
65429
  })
64881
65430
  })
64882
65431
  })
64883
- }), variant === "icon" || variant === "headless" || variant === "button" || ui === "default" ? null : jsx("span", {
65432
+ }), variant === "icon" || variant === "headless" || variant === "button" || variant === "text" || ui === "default" ? null : jsx("span", {
64884
65433
  className: "navi_picker_right_slot",
64885
65434
  children: jsx(PickerOwnContent, {
64886
65435
  children: clearable && interactive && value !== undefined && value !== "" && clearConfirm !== undefined ?
@@ -64946,7 +65495,7 @@ const PickerButton = props => {
64946
65495
  })]
64947
65496
  }), jsx(ControlFacadeChildrenWrapper, {
64948
65497
  ...facadeChildrenProps,
64949
- children: jsx("div", {
65498
+ children: jsx(ContentTag, {
64950
65499
  className: "navi_picker_content",
64951
65500
  "data-picker-content": "",
64952
65501
  children: children
@@ -68027,6 +68576,7 @@ const css$n = /* css */`
68027
68576
  * pointerInteractionOutsideEffect?: "close" | "cancel" | "capture",
68028
68577
  * escapeEffect?: "cancel" | "close",
68029
68578
  * popupLayer?: "top" | "local",
68579
+ * popupTestId?: string,
68030
68580
  * [key: string]: any,
68031
68581
  * }>}
68032
68582
  * @param {Array<{value: any, label: import("ignore:preact").ComponentChildren}>} options
@@ -68068,7 +68618,7 @@ const css$n = /* css */`
68068
68618
  * `dockedOnSmallTouchScreen`, `dialogExpand*`, `dialogMinWidth`/`Height`,
68069
68619
  * `dialogMaxWidth`/`Height`,
68070
68620
  * `marginWithContainer`, `popoverMode`, `popoverSpacing`, `popupLayer`,
68071
- * `popupWidthFitContent`, `popoverMaxHeight`, `backdropVariant`,
68621
+ * `popupTestId`, `popupWidthFitContent`, `popoverMaxHeight`, `backdropVariant`,
68072
68622
  * `pointerInteractionOutsideEffect`, `escapeEffect`, `closeOnFocusOut`,
68073
68623
  * `scrollCapture`, `focusCapture`, `popupBackgroundColor`,
68074
68624
  * `popupBorderRadius`, `animation`. See picker.jsx for what each one says.
@@ -68270,7 +68820,7 @@ const SplitButton = props => {
68270
68820
  // What the Picker's popup answers to — Picker's own popup props, named here so
68271
68821
  // a caller reaches all of them through the split button (see picker.jsx's JSDoc
68272
68822
  // for what each one says).
68273
- const POPUP_PROP_SET = new Set(["mode", "popupLayer", "positionArea", "popoverMode", "popoverSpacing", "popupWidthFitContent", "popoverMaxHeight", "dialogMinWidth", "dialogMinHeight", "dialogMaxWidth", "dialogMaxHeight", "dialogExpand", "dialogExpandX", "dialogExpandY", "dockedOnSmallTouchScreen", "marginWithContainer", "backdropVariant", "pointerInteractionOutsideEffect", "escapeEffect", "closeOnFocusOut", "scrollCapture", "focusCapture", "popupBackgroundColor", "popupBorderRadius", "animation"]);
68823
+ const POPUP_PROP_SET = new Set(["mode", "popupLayer", "popupTestId", "positionArea", "popoverMode", "popoverSpacing", "popupWidthFitContent", "popoverMaxHeight", "dialogMinWidth", "dialogMinHeight", "dialogMaxWidth", "dialogMaxHeight", "dialogExpand", "dialogExpandX", "dialogExpandY", "dockedOnSmallTouchScreen", "marginWithContainer", "backdropVariant", "pointerInteractionOutsideEffect", "escapeEffect", "closeOnFocusOut", "scrollCapture", "focusCapture", "popupBackgroundColor", "popupBorderRadius", "animation"]);
68274
68824
  const splitPopupProps = props => {
68275
68825
  const popupProps = {};
68276
68826
  const boxProps = {};
@@ -75869,7 +76419,7 @@ const ErrorSvg = () => {
75869
76419
  })
75870
76420
  });
75871
76421
  };
75872
- const InfoSvg = () => {
76422
+ const InfoSvg$1 = () => {
75873
76423
  return jsx("svg", {
75874
76424
  viewBox: "0 0 16 16",
75875
76425
  fill: "currentColor",
@@ -75952,7 +76502,7 @@ const MessageBox = ({
75952
76502
  const [hasTitleChild, setHasTitleChild] = useState(false);
75953
76503
  const innerLeftStripe = leftStripe === undefined ? hasTitleChild : leftStripe;
75954
76504
  if (icon === true) {
75955
- icon = status === "info" ? jsx(InfoSvg, {}) : status === "success" ? jsx(SuccessSvg, {}) : status === "warning" ? jsx(WarningSvg, {}) : status === "error" ? jsx(ErrorSvg, {}) : null;
76505
+ icon = status === "info" ? jsx(InfoSvg$1, {}) : status === "success" ? jsx(SuccessSvg, {}) : status === "warning" ? jsx(WarningSvg, {}) : status === "error" ? jsx(ErrorSvg, {}) : null;
75956
76506
  } else if (typeof icon === "function") {
75957
76507
  const Comp = icon;
75958
76508
  icon = jsx(Comp, {});
@@ -78026,6 +78576,29 @@ const ExclamationSvg = () => {
78026
78576
  });
78027
78577
  };
78028
78578
 
78579
+ const InfoSvg = () => {
78580
+ return jsxs("svg", {
78581
+ viewBox: "0 0 24 24",
78582
+ xmlns: "http://www.w3.org/2000/svg",
78583
+ children: [jsx("circle", {
78584
+ cx: "12",
78585
+ cy: "12",
78586
+ r: "10",
78587
+ fill: "none",
78588
+ stroke: "currentColor",
78589
+ strokeWidth: "2"
78590
+ }), jsx("circle", {
78591
+ cx: "12",
78592
+ cy: "7.6",
78593
+ r: "1.4",
78594
+ fill: "currentColor"
78595
+ }), jsx("path", {
78596
+ fill: "currentColor",
78597
+ d: "M10.6 10.6h2.8V18h-2.8z"
78598
+ })]
78599
+ });
78600
+ };
78601
+
78029
78602
  const EyeClosedSvg = () => {
78030
78603
  return jsx("svg", {
78031
78604
  viewBox: "0 0 24 24",
@@ -78101,5 +78674,5 @@ const UserSvg = () => jsx("svg", {
78101
78674
  })
78102
78675
  });
78103
78676
 
78104
- export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markErrorAsDisplayedBy, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, renderEmojiAsIcon, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
78677
+ export { ActionRenderer, ActiveKeyboardShortcuts, Address, Badge, BadgeCount, BadgeList, Binder, Box, Button, ButtonCopyToClipboard, CalloutStatusIcon, Caption, CardLayout, CheckSvg, CheckboxGroup, CloseSvg, Code, Col, Colgroup, Color, ConstructionSvg, ControlGroup, DaySpin, Details, Dialog, Editable, ErrorBoundary, ErrorBoundaryContext, ExclamationSvg, Expandable, EyeClosedSvg, EyeSvg, Field, FixedBar, Form, Group, Head, HeartSvg, HomeSvg, Icon, Image, InfoSvg, Input, InputDuration, Interpolate, Label, Link, LinkAnchorSvg, LinkBlankTargetSvg, LinkCurrentSvg, List, ListItem, ListItemGroup, ListItems, Loading, LoadingDotsSvg, LoadingIndicator, LoadingIndicatorFluid, LoadingOutline, MessageBox, Meter, Nav, NaviDebug, NumberSpin, Paragraph, Picker, Popover, Popup, Quantity, RadioGroup, Route, RouteTransitionArea, RouteTravel, RowNumberCol, RowNumberTableCell, SVGMaskOverlay, SearchSvg, Select, SelectableInput, SelectionContext, Separator, SettingsSvg, SidePanel, Slide, SlideContainer, Spin, SpinGroup, SplitButton, StarSvg, Step, StepList, SummaryMarker, Svg, Table, TableCell, Tbody, Text, TextBox, Textarea, TextareaCharCount, Thead, Time, TimeRangeSpin, TimeRangeWheel, TimeSpin, TimeWheel, Title, Tr, UITransition, Unit, UserSvg, ViewportLayout, Wheel, WheelGroup, WheelItem, actionRunEffect, anyMatchingRouteSignal, applySearch, arraySignalMembership, canNavBackSignal, canNavForwardSignal, coarsePointerSignal, compareTwoJsValues, createAction, createAvailableConstraint, createI18n, createRequestCanceller, createSearch, createSelectionKeyboardShortcuts, createSlot, defineInteractionDetector, defineRouteDefaultTransition, defineRouteTransition, detectHorizontalOverflow, enableDebugActions, enableDebugOnDocumentLoading, ensureDocumentStartViewTransition, errorIsDisplayed, filterTableSelection, formatDatetime, formatDay, formatDayRelative, formatMonth, formatNumber, formatTime, formatTimeRelative, getNowHours, getNowHoursRoundedToStep, interpolateText, isCellSelected, isColumnSelected, isRowSelected, isScrolling, isToday, languagesSignal, localStorageSignal, markErrorAsDisplayedBy, moveArrayItemByIndex, navBack, navForward, navIntegratedVia, navTo, naviI18n, openCallout, rawUrlPart, registerGlobalConstraint, reload, renderEmojiAsIcon, rerunActions, resource, route, routeAction, scrollActivitySignal, setBaseUrl, setPreferredLanguage, setSupportedLanguages, setUrlTargetOptions, setupRoutes, smallTouchScreenSignal, stateSignal, stopLoad, stringifyTableSelectionValue, swapArrayItemByIndex, syncOwnedResourceToSignals, syncResourceToSignals, triggerNaviCommand, updateActions, useActionStatus, useArraySignalMembership, useAsyncData, useCalloutElement, useCalloutRequestClose, useCanNavBack, useCanNavForward, useCancelPrevious, useCellGridFromRows, useConstraintValidityState, useDependenciesDiff, useDisplayedLayoutEffect, useDocumentResource, useDocumentState, useDocumentUrl, useEditionController, useFocusGroup, useInputGroup, useKeyboardShortcuts, useNavState, useOrderedColumns, usePopupMode, useRouteStatus, useRunOnMount, useSearchText, useSelectableElement, useSelectionController, useSignalSync, useSlideValue, useStateArray, useTitleLevel, useUrlSearchParam, useUrlTargetId, valueInLocalStorage, windowWidthSignal };
78105
78678
  //# sourceMappingURL=jsenv_navi.js.map