@jsenv/navi 0.29.481 → 0.29.482

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.
@@ -43058,6 +43058,14 @@ const useParallelGuard = (max) => {
43058
43058
  * `getAssociatedLabels` in control_hooks.jsx. Both channels carry the same
43059
43059
  * values and land on the same setters, so a control reachable through both is
43060
43060
  * simply told twice.
43061
+ *
43062
+ * The link also runs the other way, for a control whose role is on its root
43063
+ * (`nameOn: "root"`, a wheel): a label[for] can only point at its host, an
43064
+ * aria-hidden carrier, and names nothing. That control says so in the state it
43065
+ * publishes; its label then drops `for` and publishes its own id under the
43066
+ * control's id instead, and the control names its root with the ids it finds
43067
+ * there (aria-labelledby). A label[for] kept as well would name the carrier
43068
+ * too, and a lookup by label would find two elements.
43061
43069
  */
43062
43070
 
43063
43071
  const stateByControlId = new Map();
@@ -43110,6 +43118,68 @@ const subscribeToControlState = (controlId, callback) => {
43110
43118
  };
43111
43119
  };
43112
43120
 
43121
+ const labelIdSetByControlId = new Map();
43122
+ const labelCallbackSetByControlId = new Map();
43123
+
43124
+ const notifyLabelIds = (controlId) => {
43125
+ const callbackSet = labelCallbackSetByControlId.get(controlId);
43126
+ if (!callbackSet) {
43127
+ return;
43128
+ }
43129
+ const labelIds = readLabelIds(controlId);
43130
+ for (const callback of callbackSet) {
43131
+ callback(labelIds);
43132
+ }
43133
+ };
43134
+ // One string ("label_a label_b"), the shape aria-labelledby takes, and equal
43135
+ // from one call to the next while nothing changed.
43136
+ const readLabelIds = (controlId) => {
43137
+ const labelIdSet = labelIdSetByControlId.get(controlId);
43138
+ return labelIdSet ? Array.from(labelIdSet).join(" ") : undefined;
43139
+ };
43140
+
43141
+ /**
43142
+ * Publishes `labelId` as a label of the control identified by `controlId`.
43143
+ * Returns the teardown.
43144
+ */
43145
+ const publishLabelToControl = (controlId, labelId) => {
43146
+ let labelIdSet = labelIdSetByControlId.get(controlId);
43147
+ if (!labelIdSet) {
43148
+ labelIdSet = new Set();
43149
+ labelIdSetByControlId.set(controlId, labelIdSet);
43150
+ }
43151
+ labelIdSet.add(labelId);
43152
+ notifyLabelIds(controlId);
43153
+ return () => {
43154
+ labelIdSet.delete(labelId);
43155
+ if (labelIdSet.size === 0) {
43156
+ labelIdSetByControlId.delete(controlId);
43157
+ }
43158
+ notifyLabelIds(controlId);
43159
+ };
43160
+ };
43161
+
43162
+ /**
43163
+ * Subscribes to the ids of the labels published for `controlId`. The callback
43164
+ * is called right away (with `undefined` when there is none), then on every
43165
+ * change. Returns the teardown.
43166
+ */
43167
+ const subscribeToControlLabels = (controlId, callback) => {
43168
+ let callbackSet = labelCallbackSetByControlId.get(controlId);
43169
+ if (!callbackSet) {
43170
+ callbackSet = new Set();
43171
+ labelCallbackSetByControlId.set(controlId, callbackSet);
43172
+ }
43173
+ callbackSet.add(callback);
43174
+ callback(readLabelIds(controlId));
43175
+ return () => {
43176
+ callbackSet.delete(callback);
43177
+ if (callbackSet.size === 0) {
43178
+ labelCallbackSetByControlId.delete(controlId);
43179
+ }
43180
+ };
43181
+ };
43182
+
43113
43183
  const FormContext = createContext();
43114
43184
 
43115
43185
  /**
@@ -47522,17 +47592,11 @@ const useControlProps = (props, {
47522
47592
  };
47523
47593
  }
47524
47594
  if (defaultAction === "activate") {
47525
- // activating the control (e.g. space on a button/range)
47526
47595
  return {
47527
47596
  name: `keydown to activate`,
47528
47597
  prevented: () => e.preventDefault(),
47529
47598
  allowed: () => {
47530
47599
  triggerUIAction(e);
47531
- if (controlType === "button" && e.key === " ") {
47532
- // prevent browser dispatching click
47533
- // it could lead to duplicates as we explicitely handled the space to click here
47534
- e.preventDefault();
47535
- }
47536
47600
  }
47537
47601
  };
47538
47602
  }
@@ -47669,7 +47733,15 @@ const useControlProps = (props, {
47669
47733
  runWhenActionSucceeded(completion, deferredCommand);
47670
47734
  };
47671
47735
  return {
47672
- keyDown: keyDownDefault,
47736
+ keyDown: e => {
47737
+ if (getKeyboardEventDefaultAction(e) === "activate") {
47738
+ // The browser presses the button itself — Enter clicks on
47739
+ // keydown, Space on keyup — and the click reaction below answers
47740
+ // that press, once: ui action, action and command together.
47741
+ return null;
47742
+ }
47743
+ return keyDownDefault(e);
47744
+ },
47673
47745
  mouseDown: e => {
47674
47746
  if (actsOnMouseDown) {
47675
47747
  return {
@@ -47688,7 +47760,9 @@ const useControlProps = (props, {
47688
47760
  return null;
47689
47761
  },
47690
47762
  click: e => {
47691
- if (actsOnMouseDown) {
47763
+ // A click no mousedown came before (the keyboard, a label) is a
47764
+ // press the mousedown reaction never saw.
47765
+ if (actsOnMouseDown && e.detail !== 0) {
47692
47766
  return null;
47693
47767
  }
47694
47768
  return {
@@ -48932,7 +49006,8 @@ const useInteractiveProps = (props, {
48932
49006
  const controlState = {
48933
49007
  disabled: disabledResolved,
48934
49008
  readOnly,
48935
- required: requiredResolved
49009
+ required: requiredResolved,
49010
+ nameOn
48936
49011
  };
48937
49012
  for (const label of getAssociatedLabels(element)) {
48938
49013
  label.dispatchEvent(new CustomEvent("navi_control_state", {
@@ -48953,6 +49028,22 @@ const useInteractiveProps = (props, {
48953
49028
  unpublishControlStateToLabels(publishedIdRef.current);
48954
49029
  };
48955
49030
  }, []);
49031
+ // Named on its root, the control is named by its labels' ids: they drop
49032
+ // their `for` for it (see control_label_state.js). A name the caller gives
49033
+ // wins.
49034
+ const [labelledBy, setLabelledBy] = useState();
49035
+ const {
49036
+ id
49037
+ } = props;
49038
+ useLayoutEffect(() => {
49039
+ if (nameOn !== "root" || !id) {
49040
+ return undefined;
49041
+ }
49042
+ return subscribeToControlLabels(id, setLabelledBy);
49043
+ }, [id]);
49044
+ if (labelledBy && controlRootProps["aria-label"] === undefined && controlRootProps["aria-labelledby"] === undefined) {
49045
+ controlRootProps["aria-labelledby"] = labelledBy;
49046
+ }
48956
49047
  }
48957
49048
  {
48958
49049
  const isCheckable = isCheckableInput(uiStateController.controlType, props.type);
@@ -60123,6 +60214,7 @@ const Label = props => {
60123
60214
  const [readOnly, setReadOnly] = useState(false);
60124
60215
  const [required, setRequired] = useState(false);
60125
60216
  const [connected, setConnected] = useState(false);
60217
+ const [namesControlRoot, setNamesControlRoot] = useState(false);
60126
60218
  // Set htmlFor only when we know the correct target id:
60127
60219
  // - caller explicitly provided one (even undefined to opt out)
60128
60220
  // - a parent Field provided one via ControlIdContext
@@ -60149,8 +60241,37 @@ const Label = props => {
60149
60241
  setDisabled(Boolean(controlState?.disabled));
60150
60242
  setReadOnly(Boolean(controlState?.readOnly));
60151
60243
  setRequired(Boolean(controlState?.required));
60244
+ setNamesControlRoot(controlState?.nameOn === "root");
60152
60245
  });
60153
60246
  }, [htmlFor]);
60247
+ // A control whose role is on its root (a wheel) is named by this label's id
60248
+ // rather than by `for`, which can only point at its aria-hidden host (see
60249
+ // control_label_state.js).
60250
+ const labelId = props.id;
60251
+ useLayoutEffect(() => {
60252
+ if (!htmlFor || !namesControlRoot) {
60253
+ return undefined;
60254
+ }
60255
+ return publishLabelToControl(htmlFor, labelId);
60256
+ }, [htmlFor, namesControlRoot, labelId]);
60257
+ // …and without `for`, the press that focuses the control is this label's
60258
+ // to answer. A press on something interactive inside the label stays that
60259
+ // thing's, as the browser does for a label[for].
60260
+ const onClickFocusingControlRoot = e => {
60261
+ props.onClick?.(e);
60262
+ if (e.defaultPrevented) {
60263
+ return;
60264
+ }
60265
+ const interactive = e.target.closest("a[href], button, input, select, textarea, [tabindex]");
60266
+ if (interactive && e.currentTarget.contains(interactive)) {
60267
+ return;
60268
+ }
60269
+ const host = document.getElementById(htmlFor);
60270
+ const controlRoot = host && findControlRoot(host);
60271
+ if (controlRoot) {
60272
+ moveFocusTo(controlRoot);
60273
+ }
60274
+ };
60154
60275
  const [messageProps, remainingProps] = extractMessageAndRemainingProps({
60155
60276
  ...props,
60156
60277
  requiredIndicator: undefined
@@ -60167,6 +60288,8 @@ const Label = props => {
60167
60288
  ":read-only": readOnly
60168
60289
  },
60169
60290
  ...remainingProps,
60291
+ htmlFor: namesControlRoot ? undefined : htmlFor,
60292
+ onClick: namesControlRoot ? onClickFocusingControlRoot : props.onClick,
60170
60293
  onnavi_control_state: e => {
60171
60294
  setConnected(true);
60172
60295
  setDisabled(e.detail.disabled);
@@ -67508,10 +67631,12 @@ const TimeHour = ({
67508
67631
  children: String(children)
67509
67632
  });
67510
67633
  }
67634
+ const dateTime = formatClock(hours * 3600);
67511
67635
  if (format === "timestring") {
67512
67636
  const totalMinutes = Math.round(hours * 60);
67513
67637
  const date = new Date(1970, 0, 1, Math.floor(totalMinutes / 60), totalMinutes % 60, 0);
67514
67638
  return jsx(TimeText, {
67639
+ dateTime: dateTime,
67515
67640
  ...props,
67516
67641
  children: formatTime(date, {
67517
67642
  lang
@@ -67524,6 +67649,7 @@ const TimeHour = ({
67524
67649
  forceUnit
67525
67650
  });
67526
67651
  return jsx(TimeText, {
67652
+ dateTime: dateTime,
67527
67653
  ...props,
67528
67654
  children: text
67529
67655
  });