@jsenv/navi 0.29.100 → 0.29.102

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.
@@ -49,6 +49,16 @@ const css$15 = /* css */`
49
49
  --navi-z-index-control-hovered: 1;
50
50
  --navi-z-index-control-focused: 2;
51
51
 
52
+ /* A control the user is not on and yet still has to paint in front: one
53
+ holding something open (a picker showing its list, an expandable
54
+ header). Its border keeps the color the open state gives it, while
55
+ hover and focus have both moved on — into the popup, or onto whatever
56
+ the pointer travelled to next — so neither of the two values above is
57
+ there to raise it, and the neighbour painted after it cuts the very
58
+ border that says what is open. Above both: the member holding the
59
+ popup open outranks a member merely hovered or focused. */
60
+ --navi-z-index-control-expanded: 3;
61
+
52
62
  /* Kept stuck while something scrolls under it: a list header, the head
53
63
  and foot of a side panel, a table's sticky cells, the header and
54
64
  footer of any scrolling Box. Above raised controls — a control
@@ -2819,7 +2829,10 @@ const readSignalForUrlBuild = (connection) => {
2819
2829
  /**
2820
2830
  * Creates a custom route pattern matcher
2821
2831
  */
2822
- const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
2832
+ const createRoutePattern = (
2833
+ pattern,
2834
+ { searchParams = {}, params: paramConstraints = {} } = {},
2835
+ ) => {
2823
2836
  // Detect and process path signals in the pattern
2824
2837
  const [cleanPattern, pathConnections] = detectSignals(pattern);
2825
2838
 
@@ -2844,9 +2857,11 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
2844
2857
  // All connections (path + query) for ancestor/descendant signal resolution
2845
2858
  const connections = [...pathConnections, ...queryConnectionMap.values()];
2846
2859
 
2860
+ const paramConstraintMap = createParamConstraintMap(paramConstraints);
2847
2861
  const parsedPattern = parsePattern(cleanPattern, {
2848
2862
  pathConnectionMap,
2849
2863
  queryConnectionMap,
2864
+ paramConstraintMap,
2850
2865
  });
2851
2866
 
2852
2867
  debug$3(`[CustomPattern] Created pattern:`, parsedPattern);
@@ -2854,11 +2869,21 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
2854
2869
  debug$3(`[CustomPattern] Path connections:`, pathConnectionMap.size);
2855
2870
  debug$3(`[CustomPattern] Query connections:`, queryConnectionMap.size);
2856
2871
 
2857
- const applyOn = (url) => {
2872
+ /**
2873
+ * @param {string} url
2874
+ * @param {object} [options]
2875
+ * @param {boolean} [options.exact] - Only match the url this pattern is the
2876
+ * address of: a trailing slash or a wildcard stops catching what lies below
2877
+ * it. What "/" means for a container ("everything under me") and what it
2878
+ * means for a redirection ("that very address") are not the same question.
2879
+ */
2880
+ const applyOn = (url, { exact = false } = {}) => {
2858
2881
  const result = matchUrl(parsedPattern, url, {
2859
2882
  baseUrl,
2860
2883
  baseFileUrl,
2861
2884
  queryConnectionMap,
2885
+ paramConstraintMap,
2886
+ exact,
2862
2887
  patternObj: patternObject,
2863
2888
  });
2864
2889
 
@@ -3524,7 +3549,9 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
3524
3549
  const entryIsMeaningful = (conn) => {
3525
3550
  const entry = intended.get(conn.paramName);
3526
3551
  return (
3527
- Boolean(entry) && entry.value !== undefined && conn.isCustomValue(entry.value)
3552
+ Boolean(entry) &&
3553
+ entry.value !== undefined &&
3554
+ conn.isCustomValue(entry.value)
3528
3555
  );
3529
3556
  };
3530
3557
  if (ancestorPattern !== patternObject.parent) {
@@ -3714,6 +3741,7 @@ const createRoutePattern = (pattern, { searchParams = {} } = {}) => {
3714
3741
  connections,
3715
3742
  pathConnectionMap, // Separate map for path parameters
3716
3743
  queryConnectionMap, // Separate map for query parameters
3744
+ paramConstraintMap, // Which values each param accepts (Map<paramName, test>)
3717
3745
  parsedPattern,
3718
3746
  children: [],
3719
3747
  parent: null,
@@ -3821,10 +3849,60 @@ const detectSignals = (routePattern) => {
3821
3849
  return [updatedPattern, signalConnections];
3822
3850
  };
3823
3851
 
3852
+ const EMPTY_PARAM_CONSTRAINT_MAP = new Map();
3853
+
3854
+ /**
3855
+ * Turns `{ gameId: /^W-[A-Z0-9]{8}$/i }` into `Map<paramName, (value) => boolean>`.
3856
+ * A constraint is a regexp, a list of accepted values, or a predicate.
3857
+ * A constraint decides which url segments the param accepts; a segment it
3858
+ * declines makes the whole route a non-match, so only path params can carry
3859
+ * one — a search param is extracted from a url the path already matched.
3860
+ */
3861
+ const createParamConstraintMap = (paramConstraints) => {
3862
+ const entries = Object.entries(paramConstraints);
3863
+ if (entries.length === 0) {
3864
+ return EMPTY_PARAM_CONSTRAINT_MAP;
3865
+ }
3866
+ const paramConstraintMap = new Map();
3867
+ for (const [paramName, constraint] of entries) {
3868
+ if (constraint instanceof RegExp) {
3869
+ paramConstraintMap.set(paramName, (value) => {
3870
+ constraint.lastIndex = 0; // a /g or /y regexp would otherwise resume where the previous test stopped
3871
+ return constraint.test(value);
3872
+ });
3873
+ continue;
3874
+ }
3875
+ if (Array.isArray(constraint)) {
3876
+ // a url segment is a string, so the list is compared as strings:
3877
+ // `oneOf: [1, 2, 3]` accepts "/2"
3878
+ const acceptedValueSet = new Set(
3879
+ constraint.map((value) => String(value)),
3880
+ );
3881
+ paramConstraintMap.set(paramName, (value) => acceptedValueSet.has(value));
3882
+ continue;
3883
+ }
3884
+ if (typeof constraint === "function") {
3885
+ paramConstraintMap.set(paramName, (value) => Boolean(constraint(value)));
3886
+ continue;
3887
+ }
3888
+ throw new TypeError(
3889
+ `params.${paramName} must be a regexp, an array of values or a function, got ${constraint}`,
3890
+ );
3891
+ }
3892
+ return paramConstraintMap;
3893
+ };
3894
+
3824
3895
  /**
3825
3896
  * Parse a route pattern string into structured segments
3826
3897
  */
3827
- const parsePattern = (pattern, { pathConnectionMap, queryConnectionMap }) => {
3898
+ const parsePattern = (
3899
+ pattern,
3900
+ {
3901
+ pathConnectionMap,
3902
+ queryConnectionMap,
3903
+ paramConstraintMap = EMPTY_PARAM_CONSTRAINT_MAP,
3904
+ },
3905
+ ) => {
3828
3906
  // Build queryParams from queryConnectionMap
3829
3907
  const queryParams = [];
3830
3908
  for (const [paramName, connection] of queryConnectionMap) {
@@ -3878,13 +3956,14 @@ const parsePattern = (pattern, { pathConnectionMap, queryConnectionMap }) => {
3878
3956
  // 1. Explicitly marked with ?
3879
3957
  // 2. Has a default value
3880
3958
  // 3. Connected signal has undefined value and no explicit default (allows /map to match /map/:panel)
3959
+ // — unless the param is constrained: "no segment" is not one of the values it accepts
3881
3960
  const connection =
3882
3961
  pathConnectionMap.get(paramName) || queryConnectionMap.get(paramName);
3883
3962
  const hasDefault =
3884
3963
  connection && connection.getDefaultValue() !== undefined;
3885
3964
  let isOptional = seg.endsWith("?") || hasDefault;
3886
3965
 
3887
- if (!isOptional) {
3966
+ if (!isOptional && !paramConstraintMap.has(paramName)) {
3888
3967
  // Check if connected signal has undefined value (making parameter optional for index routes)
3889
3968
  if (
3890
3969
  connection &&
@@ -3995,7 +4074,14 @@ const tryExtractChildParameters = (
3995
4074
  // We need to verify that this parameter isn't already captured by parent
3996
4075
  if (!(segment.name in existingParams)) {
3997
4076
  const urlSegment = remainingSegments[remainingIndex];
3998
- childParams[segment.name] = decodeURIComponent(urlSegment);
4077
+ const paramValue = decodeURIComponent(urlSegment);
4078
+ const paramConstraint = childPattern.paramConstraintMap.get(
4079
+ segment.name,
4080
+ );
4081
+ if (paramConstraint && !paramConstraint(paramValue)) {
4082
+ return null; // the child declines this value, it cannot explain these segments
4083
+ }
4084
+ childParams[segment.name] = paramValue;
3999
4085
  remainingIndex++;
4000
4086
  }
4001
4087
  } else if (
@@ -4022,7 +4108,14 @@ const tryExtractChildParameters = (
4022
4108
  const matchUrl = (
4023
4109
  parsedPattern,
4024
4110
  url,
4025
- { baseUrl, baseFileUrl, queryConnectionMap, patternObj = null },
4111
+ {
4112
+ baseUrl,
4113
+ baseFileUrl,
4114
+ queryConnectionMap,
4115
+ paramConstraintMap = EMPTY_PARAM_CONSTRAINT_MAP,
4116
+ exact = false,
4117
+ patternObj = null,
4118
+ },
4026
4119
  ) => {
4027
4120
  // Parse the URL
4028
4121
  const urlObj = new URL(url, baseUrl);
@@ -4064,7 +4157,7 @@ const matchUrl = (
4064
4157
  }
4065
4158
 
4066
4159
  // Root route with trailing slash matches all sub-paths (prefix matching, like other trailing-slash routes)
4067
- if (parsedPattern.trailingSlash) {
4160
+ if (parsedPattern.trailingSlash && !exact) {
4068
4161
  return extractSearchParams(urlObj, queryConnectionMap);
4069
4162
  }
4070
4163
 
@@ -4133,7 +4226,12 @@ const matchUrl = (
4133
4226
 
4134
4227
  // Capture URL segment as parameter value
4135
4228
  const urlSeg = urlSegments[urlSegmentIndex];
4136
- params[patternSeg.name] = decodeURIComponent(urlSeg);
4229
+ const paramValue = decodeURIComponent(urlSeg);
4230
+ const paramConstraint = paramConstraintMap.get(patternSeg.name);
4231
+ if (paramConstraint && !paramConstraint(paramValue)) {
4232
+ return null; // the param declines this value: the route does not match
4233
+ }
4234
+ params[patternSeg.name] = paramValue;
4137
4235
  urlSegmentIndex++;
4138
4236
  }
4139
4237
  }
@@ -4142,8 +4240,7 @@ const matchUrl = (
4142
4240
  // Patterns with trailing slashes can match additional URL segments (like wildcards)
4143
4241
  // Patterns without trailing slashes should match exactly (unless they're wildcards)
4144
4242
  if (
4145
- !parsedPattern.wildcard &&
4146
- !parsedPattern.trailingSlash &&
4243
+ (exact || (!parsedPattern.wildcard && !parsedPattern.trailingSlash)) &&
4147
4244
  urlSegmentIndex < urlSegments.length
4148
4245
  ) {
4149
4246
  return null; // Pattern without trailing slash/wildcard should not match extra segments
@@ -4862,8 +4959,61 @@ const getRoutePrivateProperties = (route) => {
4862
4959
  const ROUTE_NOT_MATCHING_PARAMS = {};
4863
4960
  // Flag to prevent signal-to-URL synchronization during URL-to-signal synchronization
4864
4961
  let isUpdatingRoutesFromUrl = false;
4865
- const route = (pattern, { searchParams } = {}) => {
4866
- const routePattern = createRoutePattern(pattern, { searchParams });
4962
+ /**
4963
+ * Declares a route from an url pattern.
4964
+ *
4965
+ * @param {string} pattern - The url pattern, where `:name` is a path param and
4966
+ * `:name=${signal}` binds that param to a signal.
4967
+ * @param {object} [options]
4968
+ * @param {Object<string, import("@preact/signals").Signal>} [options.searchParams]
4969
+ * Search params this route two-way syncs with, by name.
4970
+ * @param {Object<string, RegExp | Array | ((value: string) => boolean)>} [options.params]
4971
+ * Which values a path param accepts, by param name: a regexp tested against the
4972
+ * decoded segment, the list of accepted values (compared as strings, so it can
4973
+ * be the `oneOf` of the signal bound to that param), or a predicate. A route
4974
+ * whose param declines the segment does not match at all: no signal is
4975
+ * written, `<Route fallback>` is reachable, and `/:gameId` can sit at the root
4976
+ * without swallowing `/cgu`. A constrained param is also required — no segment
4977
+ * is not one of the values it accepts — so `/:gameId` does not match `/`.
4978
+ *
4979
+ * Constrain the shape, not the existence: whether that game exists is for the
4980
+ * route action and the page to answer, on a route that did match.
4981
+ *
4982
+ * ```js
4983
+ * route(`/:gameId=${gamePageIdSignal}`, { params: { gameId: /^W-[A-Z0-9]{8}$/i } });
4984
+ * route(`/games/:section=${sectionSignal}`, { params: { section: SECTIONS } });
4985
+ * ```
4986
+ * @param {object} [options.redirectRoute]
4987
+ * This address only sends elsewhere: the route it resolves to. The
4988
+ * redirection happens at the door of the navigation, so this address is never
4989
+ * displayed, never enters the history, runs no route action and writes no
4990
+ * signal — going back lands on the page before it, not on the redirection
4991
+ * again. It fires on this route's own address only: a trailing slash still
4992
+ * catches what lies below it for rendering, never for redirecting.
4993
+ *
4994
+ * The params found in the url carry over to the ones the target route
4995
+ * declares under the same name; what it cannot place is left behind.
4996
+ *
4997
+ * ```js
4998
+ * route("/", { redirectRoute: MY_GAMES_PAGE });
4999
+ * // gameId carries over on its own, shareState is not a param of GAME_PAGE
5000
+ * route("/:gameId/:shareState", { redirectRoute: GAME_PAGE });
5001
+ * ```
5002
+ * @param {object|Function|null} [options.redirectRouteParams]
5003
+ * What to change about the params carried over: an object, or a function of
5004
+ * the params found in the url returning one. Written params win over inherited
5005
+ * ones, `undefined` drops one, and `null` carries nothing over at all.
5006
+ *
5007
+ * ```js
5008
+ * route("/legacy/:id", { redirectRoute: GAME_PAGE, redirectRouteParams: ({ id }) => ({ gameId: id }) });
5009
+ * route("/:gameId/invite", { redirectRoute: HOME_PAGE, redirectRouteParams: null });
5010
+ * ```
5011
+ */
5012
+ const route = (
5013
+ pattern,
5014
+ { searchParams, params, redirectRoute, redirectRouteParams } = {},
5015
+ ) => {
5016
+ const routePattern = createRoutePattern(pattern, { searchParams, params });
4867
5017
  const { cleanPattern } = routePattern;
4868
5018
  const [publishStatus, subscribeStatus] = createPubSub();
4869
5019
 
@@ -4906,6 +5056,8 @@ const route = (pattern, { searchParams } = {}) => {
4906
5056
  {
4907
5057
  const routePrivateProperties = {
4908
5058
  routePattern,
5059
+ redirectRoute,
5060
+ redirectRouteParams,
4909
5061
  setup: null,
4910
5062
  updateStatus: null,
4911
5063
  cleanup: null,
@@ -5229,6 +5381,113 @@ const route = (pattern, { searchParams } = {}) => {
5229
5381
 
5230
5382
  const [publishRouteMutations, observeRouteMutations] = createPubSub();
5231
5383
 
5384
+ let redirectingRouteSet = null;
5385
+ /**
5386
+ * Where does this url really lead?
5387
+ *
5388
+ * Asked at the door of every navigation, before the url is written anywhere —
5389
+ * an address that only sends elsewhere must not become a page, an entry in the
5390
+ * history, or a route anything can see matching. Answered without reading a
5391
+ * single signal: the url alone says it.
5392
+ *
5393
+ * The chain is followed here rather than by letting each redirection navigate
5394
+ * in turn, so one navigation happens and the addresses in between leave no
5395
+ * trace at all.
5396
+ *
5397
+ * @param {string} url
5398
+ * @returns {string|null} The url to go to instead, or null.
5399
+ */
5400
+ const resolveRouteRedirection = (url) => {
5401
+ if (!redirectingRouteSet || redirectingRouteSet.size === 0) {
5402
+ return null;
5403
+ }
5404
+ let urlToResolve = url;
5405
+ let redirectionUrl = null;
5406
+ const urlChain = [url];
5407
+ while (true) {
5408
+ const nextUrl = resolveRedirectionOnce(urlToResolve);
5409
+ if (!nextUrl || nextUrl === urlToResolve) {
5410
+ break;
5411
+ }
5412
+ if (urlChain.includes(nextUrl)) {
5413
+ throw new Error(
5414
+ `Redirection cycle: ${[...urlChain, nextUrl].join(" -> ")}`,
5415
+ );
5416
+ }
5417
+ urlChain.push(nextUrl);
5418
+ redirectionUrl = nextUrl;
5419
+ urlToResolve = nextUrl;
5420
+ }
5421
+ return redirectionUrl;
5422
+ };
5423
+ const resolveRedirectionOnce = (url) => {
5424
+ let redirectingRoute = null;
5425
+ let redirectingRouteParams = null;
5426
+ let maxDepth = -1;
5427
+ for (const route of redirectingRouteSet) {
5428
+ const { routePattern } = getRoutePrivateProperties(route);
5429
+ // exact: what a trailing slash catches is what a container renders, not
5430
+ // what an address redirects — "/" redirecting must not take "/cgu" with it
5431
+ const urlParams = routePattern.applyOn(url, { exact: true });
5432
+ if (!urlParams) {
5433
+ continue;
5434
+ }
5435
+ // Deeper = more specific, the reading the whole router shares (see
5436
+ // replaceParams): "/:gameId/invite" is a child of "/:gameId/:shareState",
5437
+ // so it answers for an url both of them match.
5438
+ if (routePattern.depth > maxDepth) {
5439
+ maxDepth = routePattern.depth;
5440
+ redirectingRoute = route;
5441
+ redirectingRouteParams = urlParams;
5442
+ }
5443
+ }
5444
+ if (!redirectingRoute) {
5445
+ return null;
5446
+ }
5447
+ return buildRedirectionUrl(redirectingRoute, redirectingRouteParams);
5448
+ };
5449
+ const buildRedirectionUrl = (route, urlParams) => {
5450
+ const { redirectRoute, redirectRouteParams } =
5451
+ getRoutePrivateProperties(route);
5452
+ const paramsCarriedOver =
5453
+ redirectRouteParams === null
5454
+ ? {}
5455
+ : paramsTargetCanPlace(redirectRoute, urlParams);
5456
+ if (!redirectRouteParams) {
5457
+ return redirectRoute.buildUrl(paramsCarriedOver);
5458
+ }
5459
+ const paramsWritten =
5460
+ typeof redirectRouteParams === "function"
5461
+ ? redirectRouteParams(urlParams)
5462
+ : redirectRouteParams;
5463
+ // A param written as undefined is a param dropped: `paramName in params` is
5464
+ // what tells a url build "this one is decided", value included (resolveParams)
5465
+ return redirectRoute.buildUrl({ ...paramsCarriedOver, ...paramsWritten });
5466
+ };
5467
+
5468
+ /**
5469
+ * The params of the url being left that the target route can put somewhere:
5470
+ * its own path segments and the search params it declares. What it cannot
5471
+ * place would otherwise be appended to the url as a query param, and a share
5472
+ * link resolving to "/W-ABC234PQ?shareState=1" is not the address anyone meant.
5473
+ */
5474
+ const paramsTargetCanPlace = (redirectRoute, urlParams) => {
5475
+ const { routePattern } = getRoutePrivateProperties(redirectRoute);
5476
+ const { parsedPattern, queryConnectionMap } = routePattern;
5477
+ const paramsCarriedOver = {};
5478
+ for (const paramName of Object.keys(urlParams)) {
5479
+ const canPlace =
5480
+ queryConnectionMap.has(paramName) ||
5481
+ parsedPattern.segments.some(
5482
+ (segment) => segment.type === "param" && segment.name === paramName,
5483
+ );
5484
+ if (canPlace) {
5485
+ paramsCarriedOver[paramName] = urlParams[paramName];
5486
+ }
5487
+ }
5488
+ return paramsCarriedOver;
5489
+ };
5490
+
5232
5491
  let setupRoutesCalled = false;
5233
5492
  const setupRoutes = (routes) => {
5234
5493
  if (setupRoutesCalled) {
@@ -5258,6 +5517,22 @@ This prevents cross-test pollution and ensures clean state.`,
5258
5517
  setup({ routeSet, getUrl });
5259
5518
  }
5260
5519
 
5520
+ // Checked here rather than at declaration: a route may redirect to one
5521
+ // declared after it, and reading the target then would forbid that order.
5522
+ redirectingRouteSet = new Set();
5523
+ for (const route of routeSet) {
5524
+ const { redirectRoute } = getRoutePrivateProperties(route);
5525
+ if (!redirectRoute) {
5526
+ continue;
5527
+ }
5528
+ if (!redirectRoute.isRoute) {
5529
+ throw new TypeError(
5530
+ `${route} redirects to ${redirectRoute}, expecting a route object`,
5531
+ );
5532
+ }
5533
+ redirectingRouteSet.add(route);
5534
+ }
5535
+
5261
5536
  // Store previous route states to detect changes
5262
5537
  const routePreviousStateMap = new WeakMap();
5263
5538
  const updateRoutes = (
@@ -5501,6 +5776,7 @@ This prevents cross-test pollution and ensures clean state.`,
5501
5776
  routePrivatePropertiesMap.delete(route);
5502
5777
  }
5503
5778
  routeSet.clear();
5779
+ redirectingRouteSet = null;
5504
5780
  setupRoutesCalled = false;
5505
5781
  };
5506
5782
  return {
@@ -9711,7 +9987,7 @@ const formatDateIso = (iso, inputType) => {
9711
9987
  const READONLY_CONSTRAINT = {
9712
9988
  name: "readonly",
9713
9989
  messageAttribute: "data-readonly-message",
9714
- check: (field) => {
9990
+ check: (field, { intent } = {}) => {
9715
9991
  const readOnly = Boolean(
9716
9992
  field.controlHostProps.readOnly ||
9717
9993
  field.controlHostProps["aria-readonly"] === "true",
@@ -9720,6 +9996,18 @@ const READONLY_CONSTRAINT = {
9720
9996
  return null;
9721
9997
  }
9722
9998
 
9999
+ // Read-only, and what it opens still opens: a picker's answer often lives
10000
+ // in a shape only its popup draws — a plan with one tile ringed, a wheel
10001
+ // stopped on a time — and refusing to open leaves that shape unreadable.
10002
+ // Opening reads and nothing more, so it goes through; everything that would
10003
+ // write is refused below, and the popup content is handed the same
10004
+ // read-only state so each control in there refuses on its own terms. Which
10005
+ // controls say so, and when, is theirs to answer (see createControlInfo's
10006
+ // readOnlyOpens).
10007
+ if (intent === "read" && field.readOnlyOpens) {
10008
+ return null;
10009
+ }
10010
+
9723
10011
  // A selection guarding its length (see maxLengthGuard) is what holds this
9724
10012
  // one back, so max_length is what refuses it: same name, same message, same
9725
10013
  // `maxLengthMessage` to say it in the caller's own words. Read-only is only
@@ -9784,6 +10072,8 @@ const readOnlyMessage = (field) => {
9784
10072
  * → "navi_request_interaction" event
9785
10073
  * → onRequestInteraction
9786
10074
  * → check disabled / read-only / busy (via controller.controlInteraction)
10075
+ * against the interaction's `intent` ("write" by default, "read" for one
10076
+ * that only shows what is already there — see READONLY_CONSTRAINT)
9787
10077
  * → if blocked → prevented()
9788
10078
  * → if allowed → allowed()
9789
10079
  * → (in allowed callback) setUIState(value)
@@ -9817,10 +10107,10 @@ const createControlInteraction = (
9817
10107
  // The title this rule put on the element, if any (see checkInteractivity).
9818
10108
  let titleWritten = null;
9819
10109
 
9820
- const checkInteractivity = ({ event } = {}) => {
10110
+ const checkInteractivity = ({ event, intent = "write" } = {}) => {
9821
10111
  interactionFailedConstraintInfo = null;
9822
10112
  for (const constraint of INTERACTION_CONSTRAINT_SET) {
9823
- const checkResult = constraint.check(controller);
10113
+ const checkResult = constraint.check(controller, { intent });
9824
10114
  if (!checkResult) {
9825
10115
  continue;
9826
10116
  }
@@ -9847,7 +10137,7 @@ const createControlInteraction = (
9847
10137
  if (!mci) {
9848
10138
  continue;
9849
10139
  }
9850
- const canInteract = mci.checkInteractivity({ event });
10140
+ const canInteract = mci.checkInteractivity({ event, intent });
9851
10141
  if (canInteract) {
9852
10142
  continue;
9853
10143
  }
@@ -9861,8 +10151,14 @@ const createControlInteraction = (
9861
10151
  }
9862
10152
 
9863
10153
  // Keep title attribute in sync for accessibility.
10154
+ // Only off a check that asked the general question: a title is read
10155
+ // whenever a pointer rests on the element, so it says what is true of the
10156
+ // control as a whole. A check made for an interaction that only reads (a
10157
+ // read-only picker asked to open its popup) answers about that one
10158
+ // interaction — writing the title from it would take away the "read-only"
10159
+ // the first time someone opened the popup.
9864
10160
  const titleLess = !controller.controlHostProps?.title;
9865
- if (titleLess) {
10161
+ if (intent === "write" && titleLess) {
9866
10162
  const element = controller.ref.current;
9867
10163
  if (element) {
9868
10164
  if (interactionFailedConstraintInfo) {
@@ -9968,6 +10264,12 @@ const onRequestInteraction = (
9968
10264
  const {
9969
10265
  event,
9970
10266
  name,
10267
+ // What this interaction would do to the control: write it, or only read it.
10268
+ // Everything writes unless it says otherwise — an interaction that merely
10269
+ // shows what is already there (opening a picker's popup, closing it again)
10270
+ // says "read", and that is what a control held read-only can still let
10271
+ // through (see READONLY_CONSTRAINT).
10272
+ intent = "write",
9971
10273
  bypassInteractivity = false,
9972
10274
  prevented,
9973
10275
  allowed,
@@ -9998,7 +10300,7 @@ const onRequestInteraction = (
9998
10300
  if (controller && !bypassInteractivity) {
9999
10301
  const ci = controller?.rules.interaction;
10000
10302
  if (ci) {
10001
- const canInteract = ci.checkInteractivity({ event });
10303
+ const canInteract = ci.checkInteractivity({ event, intent });
10002
10304
  if (!canInteract) {
10003
10305
  const failedInfo =
10004
10306
  ci.interactionFailedConstraintInfo ??
@@ -21522,6 +21824,24 @@ const setupBrowserIntegrationViaHistory = ({
21522
21824
  // the single door every navigation goes through, rather than by each caller
21523
21825
  // (navBack's fallback in particular arrives here raw).
21524
21826
  const url = new URL(target, window.location.href).href;
21827
+ // An address that only sends elsewhere never becomes anything here: asked
21828
+ // before the announcement, before the history write and before the routes,
21829
+ // so that what follows is entirely about where the reader is going (see
21830
+ // resolveRouteRedirection).
21831
+ const redirectionUrl = resolveRouteRedirection(url);
21832
+ if (redirectionUrl) {
21833
+ if (redirectionUrl === window.location.href) {
21834
+ // Asked to go where we already are: an address that redirects is never
21835
+ // the one being displayed, so there is nothing to go to and nothing to
21836
+ // stack on the history.
21837
+ return undefined;
21838
+ }
21839
+ return handleRoutingTask(redirectionUrl, {
21840
+ ...options,
21841
+ reason: `${options.reason} (redirected from ${url})`,
21842
+ redirected: true,
21843
+ });
21844
+ }
21525
21845
  // Decided before anything is announced: an elided push IS the traversal it
21526
21846
  // becomes, and the traversal will make its own announcements when the
21527
21847
  // browser answers — a before/after cycle here would be about a navigation
@@ -21570,6 +21890,7 @@ const setupBrowserIntegrationViaHistory = ({
21570
21890
  reason,
21571
21891
  navigationType, // "load", "reload", "replace", "push", "traverse"
21572
21892
  state,
21893
+ redirected,
21573
21894
  } = options;
21574
21895
 
21575
21896
  // Where the entry being reached stands in this document's own stack —
@@ -21612,6 +21933,14 @@ const setupBrowserIntegrationViaHistory = ({
21612
21933
  } else {
21613
21934
  // traverse / reload: state comes from the history entry, no push/replace needed.
21614
21935
  markUrlAsVisited(url);
21936
+ if (redirected) {
21937
+ // The entry the browser is on names an address that only sends
21938
+ // elsewhere — a cold load on it, or a back into it. Written over where
21939
+ // it stands (the entry keeps its place in the stack, hence its state
21940
+ // and the depth in it): pressing back must not walk into it again.
21941
+ window.history.replaceState(state, null, url);
21942
+ rememberEntryIsOfThisDocument();
21943
+ }
21615
21944
  updateDocumentUrl(url);
21616
21945
  updateDocumentState(state);
21617
21946
  }
@@ -26412,6 +26741,7 @@ const useControlProps = (props, {
26412
26741
  }
26413
26742
  const {
26414
26743
  name,
26744
+ intent,
26415
26745
  bypassInteractivity = false,
26416
26746
  allowed,
26417
26747
  prevented,
@@ -26421,6 +26751,7 @@ const useControlProps = (props, {
26421
26751
  return dispatchRequestInteraction(control, {
26422
26752
  event: e,
26423
26753
  name,
26754
+ intent,
26424
26755
  bypassInteractivity,
26425
26756
  prevented: () => {
26426
26757
  debugInteraction(e, `interaction not allowed`);
@@ -26560,6 +26891,7 @@ const createControlInfo = (props, {
26560
26891
  let defaultStatePropName;
26561
26892
  let stateInitial;
26562
26893
  let readOnlySupported = false;
26894
+ let readOnlyOpens = false;
26563
26895
  let disabledSupported = false;
26564
26896
  let hasStateProp;
26565
26897
  let value;
@@ -26646,6 +26978,14 @@ const createControlInfo = (props, {
26646
26978
  // aria-readonly plus a refused interaction — see the select reactions in
26647
26979
  // getDefaultEventReactionDefinitions.
26648
26980
  readOnlySupported = controlType === "picker" && INPUT_TYPE_SUPPORTING_READONLY_SET.has(typeProp);
26981
+ // A picker's popup is content of its own — a plan with one tile ringed, a
26982
+ // wheel stopped on a time, a list showing what was chosen — so read-only
26983
+ // does not close it: it opens, and everything in it is held read-only in
26984
+ // turn (see the ReadOnlyContext in picker.jsx). Two pickers this is not
26985
+ // true of: one with no popup of its own, which opens the browser's and
26986
+ // cannot hold that read-only (see PickerNative), and one whose caller says
26987
+ // its popup is a form with nothing to read (openWhileReadOnly={false}).
26988
+ readOnlyOpens = controlType === "picker" && props.children !== undefined && props.openWhileReadOnly !== false;
26649
26989
  }
26650
26990
 
26651
26991
  // The suggestion the control starts on, as opposed to what it holds — what a
@@ -26672,6 +27012,7 @@ const createControlInfo = (props, {
26672
27012
  signalHoldsChecked,
26673
27013
  stateFromSignal,
26674
27014
  readOnlySupported,
27015
+ readOnlyOpens,
26675
27016
  disabledSupported
26676
27017
  };
26677
27018
  };
@@ -27022,6 +27363,10 @@ const useInteractiveProps = (props, {
27022
27363
  const actionLoading = optimistic ? false : actionStatus.loading;
27023
27364
  const loadingResolved = loadingBase || actionLoading;
27024
27365
  const readOnlyResolved = readOnlyBase || actionLoading;
27366
+ // Read-only, and what this control opens still opens: reading what is in
27367
+ // there changes nothing. Read by READONLY_CONSTRAINT, which lets an
27368
+ // interaction that only reads through on it.
27369
+ uiStateController.readOnlyOpens = Boolean(controlInfo.readOnlyOpens);
27025
27370
  // Both halves of "busy" that do not come from the bound action, kept apart
27026
27371
  // from each other and from it: BUSY_CONSTRAINT answers each from its own
27027
27372
  // live source rather than from the rendered aria-busy, which conflates all
@@ -47385,7 +47730,27 @@ installImportMetaCssBuild(import.meta);const css$J = /* css */`
47385
47730
  border-width: var(--border-width);
47386
47731
  border-style: solid;
47387
47732
  border-color: var(--x-border-color);
47388
- border-radius: var(--border-radius);
47733
+ /* Squared from the outside, corner by corner: a checkbox in a group can
47734
+ arrive wrapped (in a label, in a row carrying a state), and the ask then
47735
+ travels down as inherited custom properties rather than as a radius
47736
+ landing on this element. Each corner falls back to the checkbox's own
47737
+ radius when nothing asks for anything. */
47738
+ border-top-left-radius: var(
47739
+ --x-corner-top-left-radius,
47740
+ var(--border-radius)
47741
+ );
47742
+ border-top-right-radius: var(
47743
+ --x-corner-top-right-radius,
47744
+ var(--border-radius)
47745
+ );
47746
+ border-bottom-right-radius: var(
47747
+ --x-corner-bottom-right-radius,
47748
+ var(--border-radius)
47749
+ );
47750
+ border-bottom-left-radius: var(
47751
+ --x-corner-bottom-left-radius,
47752
+ var(--border-radius)
47753
+ );
47389
47754
  outline-width: var(--outline-width);
47390
47755
  outline-style: none;
47391
47756
  outline-color: var(--outline-color);
@@ -48351,7 +48716,26 @@ installImportMetaCssBuild(import.meta);const css$H = /* css */`
48351
48716
  border-width: var(--button-border-width);
48352
48717
  border-style: solid;
48353
48718
  border-color: var(--x-border-color);
48354
- border-radius: var(--button-border-radius);
48719
+ /* Squared from the outside, corner by corner: a row of these is the
48720
+ segmented control a Group is for, and one of them can arrive wrapped
48721
+ (in a tooltip, in a label) — so the ask travels down as inherited
48722
+ custom properties rather than as a radius landing on this element. */
48723
+ border-top-left-radius: var(
48724
+ --x-corner-top-left-radius,
48725
+ var(--button-border-radius)
48726
+ );
48727
+ border-top-right-radius: var(
48728
+ --x-corner-top-right-radius,
48729
+ var(--button-border-radius)
48730
+ );
48731
+ border-bottom-right-radius: var(
48732
+ --x-corner-bottom-right-radius,
48733
+ var(--button-border-radius)
48734
+ );
48735
+ border-bottom-left-radius: var(
48736
+ --x-corner-bottom-left-radius,
48737
+ var(--button-border-radius)
48738
+ );
48355
48739
 
48356
48740
  .navi_icon,
48357
48741
  img {
@@ -48660,6 +49044,28 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
48660
49044
  --x-thumb-color: var(--thumb-color);
48661
49045
  --x-thumb-border: none;
48662
49046
  --x-thumb-cursor: var(--thumb-cursor);
49047
+ /* Squared from the outside, corner by corner — resolved here once because
49048
+ what draws the frame is not this element but the three layers stacked
49049
+ below it (background, track, fill), which cannot take it by inherit the
49050
+ way a control with a single frame does. A range can arrive wrapped, so
49051
+ the ask travels down as inherited custom properties; each corner falls
49052
+ back to the track's own radius when nothing asks for anything. */
49053
+ --x-range-corner-top-left: var(
49054
+ --x-corner-top-left-radius,
49055
+ var(--border-radius)
49056
+ );
49057
+ --x-range-corner-top-right: var(
49058
+ --x-corner-top-right-radius,
49059
+ var(--border-radius)
49060
+ );
49061
+ --x-range-corner-bottom-right: var(
49062
+ --x-corner-bottom-right-radius,
49063
+ var(--border-radius)
49064
+ );
49065
+ --x-range-corner-bottom-left: var(
49066
+ --x-corner-bottom-left-radius,
49067
+ var(--border-radius)
49068
+ );
48663
49069
 
48664
49070
  position: relative;
48665
49071
  box-sizing: border-box;
@@ -48668,10 +49074,26 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
48668
49074
  margin: 2px;
48669
49075
  flex-direction: row;
48670
49076
  align-items: center;
48671
- /* Just for the outline, the real border radius of the range is fixed */
48672
49077
  font-size: var(--font-size);
48673
49078
  font-family: var(--font-family);
48674
- border-radius: var(--outline-border-radius);
49079
+ /* The ring around the whole control, which follows the claim too: a range
49080
+ squared along a seam must not keep a rounded ring over it. */
49081
+ border-top-left-radius: var(
49082
+ --x-corner-top-left-radius,
49083
+ var(--outline-border-radius)
49084
+ );
49085
+ border-top-right-radius: var(
49086
+ --x-corner-top-right-radius,
49087
+ var(--outline-border-radius)
49088
+ );
49089
+ border-bottom-right-radius: var(
49090
+ --x-corner-bottom-right-radius,
49091
+ var(--outline-border-radius)
49092
+ );
49093
+ border-bottom-left-radius: var(
49094
+ --x-corner-bottom-left-radius,
49095
+ var(--outline-border-radius)
49096
+ );
48675
49097
  outline-width: var(--outline-width);
48676
49098
  outline-style: none;
48677
49099
  outline-color: var(--outline-color);
@@ -48712,7 +49134,10 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
48712
49134
  border-width: var(--border-width);
48713
49135
  border-style: solid;
48714
49136
  border-color: var(--x-border-color);
48715
- border-radius: var(--border-radius);
49137
+ border-top-left-radius: var(--x-range-corner-top-left);
49138
+ border-top-right-radius: var(--x-range-corner-top-right);
49139
+ border-bottom-right-radius: var(--x-range-corner-bottom-right);
49140
+ border-bottom-left-radius: var(--x-range-corner-bottom-left);
48716
49141
  }
48717
49142
  .navi_input_range_track {
48718
49143
  position: absolute;
@@ -48722,7 +49147,10 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
48722
49147
  border-width: var(--border-width);
48723
49148
  border-style: solid;
48724
49149
  border-color: var(--x-track-border-color);
48725
- border-radius: var(--border-radius);
49150
+ border-top-left-radius: var(--x-range-corner-top-left);
49151
+ border-top-right-radius: var(--x-range-corner-top-right);
49152
+ border-bottom-right-radius: var(--x-range-corner-bottom-right);
49153
+ border-bottom-left-radius: var(--x-range-corner-bottom-left);
48726
49154
  }
48727
49155
  .navi_input_range_fill {
48728
49156
  position: absolute;
@@ -48730,7 +49158,10 @@ installImportMetaCssBuild(import.meta);const css$G = /* css */`
48730
49158
  height: var(--height);
48731
49159
  background: var(--x-fill-color);
48732
49160
  background-clip: content-box;
48733
- border-radius: var(--border-radius);
49161
+ border-top-left-radius: var(--x-range-corner-top-left);
49162
+ border-top-right-radius: var(--x-range-corner-top-right);
49163
+ border-bottom-right-radius: var(--x-range-corner-bottom-right);
49164
+ border-bottom-left-radius: var(--x-range-corner-bottom-left);
48734
49165
  clip-path: inset(0 calc((1 - var(--x-fill-ratio)) * 100%) 0 0);
48735
49166
  }
48736
49167
  .navi_input_range_thumb {
@@ -49802,7 +50233,27 @@ const inputCss = /* css */`
49802
50233
  border-width: var(--border-width);
49803
50234
  border-style: solid;
49804
50235
  border-color: var(--x-border-color);
49805
- border-radius: var(--border-radius);
50236
+ /* Squared from the outside, corner by corner: an input is not always the
50237
+ member a Group joins — it can arrive wrapped (in a Box carrying a state,
50238
+ in a tooltip) — so the ask travels down as inherited custom properties
50239
+ rather than as a radius landing on this element. Each corner falls back
50240
+ to the input's own radius when nothing asks for anything. */
50241
+ border-top-left-radius: var(
50242
+ --x-corner-top-left-radius,
50243
+ var(--border-radius)
50244
+ );
50245
+ border-top-right-radius: var(
50246
+ --x-corner-top-right-radius,
50247
+ var(--border-radius)
50248
+ );
50249
+ border-bottom-right-radius: var(
50250
+ --x-corner-bottom-right-radius,
50251
+ var(--border-radius)
50252
+ );
50253
+ border-bottom-left-radius: var(
50254
+ --x-corner-bottom-left-radius,
50255
+ var(--border-radius)
50256
+ );
49806
50257
  outline-width: var(--outline-width);
49807
50258
  outline-color: var(--outline-color);
49808
50259
  outline-offset: var(--outline-offset);
@@ -51543,7 +51994,7 @@ const css$E = /* css */`
51543
51994
  positioned element to mean anything, hence position: relative.
51544
51995
  Deliberately not paired with isolation: isolate — a stacking context
51545
51996
  here would also trap the popup of a picker held in the group, which
51546
- counts on its own band reaching the whole page. What keeps these two
51997
+ counts on its own band reaching the whole page. What keeps these
51547
51998
  values from escaping is instead that everything they could reach is a
51548
51999
  band above them (see navi_z_indexes.js). */
51549
52000
  > *:hover,
@@ -51563,6 +52014,25 @@ const css$E = /* css */`
51563
52014
  position: relative;
51564
52015
  z-index: var(--navi-z-index-control-focused);
51565
52016
  }
52017
+ /* The member holding something open. Neither of the two above covers it:
52018
+ the click that opened the popup gives no focus ring, the focus itself
52019
+ left for the popup's content, and the pointer is free to travel to a
52020
+ neighbour — yet the member keeps the border color its open state gives
52021
+ it, and that border is exactly what the neighbour painted after it
52022
+ slices. Read as a state, not as a pseudo-class: :active only lasts as
52023
+ long as the button is held down, and while it is held :hover is true
52024
+ anyway, so it would add nothing here.
52025
+
52026
+ :has, for the same reason as focus-visible above — the group member can
52027
+ be an enrobage around the control that expands — and reaching a popup
52028
+ held inline (a Popover with layer="local" renders inside its member)
52029
+ costs nothing: that popup only reads expanded while its own member is,
52030
+ which is the member this raises. */
52031
+ > *[aria-expanded="true"],
52032
+ > *:has([aria-expanded="true"]) {
52033
+ position: relative;
52034
+ z-index: var(--navi-z-index-control-expanded);
52035
+ }
51566
52036
 
51567
52037
  /* Horizontal (default): Cumulative margin for border overlap */
51568
52038
  &:not([data-vertical]) {
@@ -55490,6 +55960,14 @@ const PickerNative = props => {
55490
55960
  dispatchRequestInteraction(pickerInput, {
55491
55961
  event: e,
55492
55962
  name: "navi_request_open to show native picker",
55963
+ // No "read" intent here, unlike a picker holding a popup of its own
55964
+ // (see PickerCustom): the browser's picker cannot be held read-only,
55965
+ // whichever way its type falls. Where `readonly` applies (date, time,
55966
+ // month, number…) the input is not mutable and showPicker() refuses
55967
+ // it — there is nothing to open. Where it does not (color, file) the
55968
+ // browser opens all the same and writes whatever is chosen straight
55969
+ // into the input, which is read-only in name only. So a read-only
55970
+ // native picker says why instead, on the trigger.
55493
55971
  prevented: () => {
55494
55972
  e.preventDefault();
55495
55973
  },
@@ -55680,14 +56158,20 @@ const PickerCustom = props => {
55680
56158
  // already what it is, and this only re-runs the same reaction.
55681
56159
  const inputEl = getPickerInput(ref.current);
55682
56160
  const valueAtClose = getUIStateFromElement(inputEl);
55683
- if (valueAtOpen === undefined && compareTwoJsValues(valueAtClose, valueAtOpen)) {
56161
+ const controller = inputEl?.__uiStateController__;
56162
+ if (controller?.controlHostProps.readOnly) {
56163
+ // Opened only to be read: what it shows stays the suggestion it
56164
+ // was. A look is not an answer, and the signal behind it is not
56165
+ // written by one.
56166
+ debugPopup(closeEvent, `picker is read-only -> nothing to commit`);
56167
+ } else if (valueAtOpen === undefined && compareTwoJsValues(valueAtClose, valueAtOpen)) {
55684
56168
  // Same third case onRequestClose steps around: nothing held,
55685
56169
  // nothing shown, nothing picked. There is no suggestion here to
55686
56170
  // turn into an answer.
55687
56171
  debugPopup(closeEvent, `picker showed nothing -> nothing to commit`);
55688
56172
  } else {
55689
56173
  debugPopup(closeEvent, `picker defined a suggestion -> commit it`);
55690
- commitUIStateAsAnswer(inputEl?.__uiStateController__, closeEvent);
56174
+ commitUIStateAsAnswer(controller, closeEvent);
55691
56175
  }
55692
56176
  }
55693
56177
  leaveExpanded({
@@ -55776,6 +56260,13 @@ const PickerCustom = props => {
55776
56260
  requestInteraction({
55777
56261
  event: e,
55778
56262
  name: "navi_request_open_event",
56263
+ // Showing what the picker already holds, in the shape only the popup
56264
+ // draws it in — nothing of the value is written on the way in, nor on
56265
+ // the way out. Every interaction below says the same, which is what
56266
+ // lets a read-only picker be opened and read while everything that
56267
+ // would write it (paste, cut, the clear cross) stays refused. See
56268
+ // READONLY_CONSTRAINT.
56269
+ intent: "read",
55779
56270
  allowed: () => {
55780
56271
  requestOpen(e);
55781
56272
  }
@@ -55784,6 +56275,7 @@ const PickerCustom = props => {
55784
56275
  onnavi_request_close: e => {
55785
56276
  requestInteraction({
55786
56277
  event: e,
56278
+ intent: "read",
55787
56279
  allowed: () => {
55788
56280
  requestClose(e, {
55789
56281
  isCancel: e.detail.isCancel
@@ -55802,6 +56294,7 @@ const PickerCustom = props => {
55802
56294
  "a-z": e => {
55803
56295
  return {
55804
56296
  name: "letter key to open",
56297
+ intent: "read",
55805
56298
  allowed: () => {
55806
56299
  requestOpen(e);
55807
56300
  }
@@ -55810,6 +56303,7 @@ const PickerCustom = props => {
55810
56303
  "0-9": e => {
55811
56304
  return {
55812
56305
  name: "numeric key to open",
56306
+ intent: "read",
55813
56307
  allowed: () => {
55814
56308
  requestOpen(e);
55815
56309
  }
@@ -55818,6 +56312,7 @@ const PickerCustom = props => {
55818
56312
  "arrowdown": e => {
55819
56313
  return {
55820
56314
  name: "arrow_down_to_open",
56315
+ intent: "read",
55821
56316
  allowed: () => {
55822
56317
  requestOpen(e);
55823
56318
  e.preventDefault(); // prevent container scroll
@@ -55827,6 +56322,7 @@ const PickerCustom = props => {
55827
56322
  "arrowup": e => {
55828
56323
  return {
55829
56324
  name: "arrow_up_to_open",
56325
+ intent: "read",
55830
56326
  allowed: () => {
55831
56327
  requestOpen(e);
55832
56328
  e.preventDefault(); // prevent container scroll
@@ -55836,6 +56332,7 @@ const PickerCustom = props => {
55836
56332
  "space": e => {
55837
56333
  return {
55838
56334
  name: "space_to_open",
56335
+ intent: "read",
55839
56336
  allowed: () => {
55840
56337
  requestOpen(e);
55841
56338
  e.preventDefault(); // prevent scroll
@@ -55850,6 +56347,7 @@ const PickerCustom = props => {
55850
56347
  }
55851
56348
  return {
55852
56349
  name: "enter_to_open",
56350
+ intent: "read",
55853
56351
  allowed: () => {
55854
56352
  requestOpen(e);
55855
56353
  e.preventDefault(); // prevent form submission
@@ -55863,6 +56361,7 @@ const PickerCustom = props => {
55863
56361
  const isCancel = escapeEffect === "cancel";
55864
56362
  return {
55865
56363
  name: isCancel ? "escape_to_cancel" : "escape_to_close",
56364
+ intent: "read",
55866
56365
  allowed: () => {
55867
56366
  requestClose(e, {
55868
56367
  isCancel
@@ -55894,6 +56393,7 @@ const PickerCustom = props => {
55894
56393
  // choice being taken from anyone.
55895
56394
  return {
55896
56395
  name: "mousedown to close picker",
56396
+ intent: "read",
55897
56397
  allowed: () => requestClose(e, {
55898
56398
  isCancel: true
55899
56399
  })
@@ -55904,6 +56404,7 @@ const PickerCustom = props => {
55904
56404
  }
55905
56405
  return {
55906
56406
  name: "mousedown to open picker",
56407
+ intent: "read",
55907
56408
  allowed: () => {
55908
56409
  debugFocus(e, `prevent browser giving focus to button (mousedown.preventDefault())`);
55909
56410
  requestOpen(e);
@@ -55921,6 +56422,7 @@ const PickerCustom = props => {
55921
56422
  // above), this is where the picker opens for real.
55922
56423
  return {
55923
56424
  name: e.detail === 0 ? "click (keyboard or progammatic) to open picker" : "click to open picker",
56425
+ intent: "read",
55924
56426
  prevented: () => {
55925
56427
  e.preventDefault();
55926
56428
  },
@@ -62885,10 +63387,39 @@ installImportMetaCssBuild(import.meta);const css$u = /* css */`
62885
63387
  /* The frame is drawn by the box, but its radius is declared here, on the
62886
63388
  control root, like every other navi control does — so anything styling
62887
63389
  the picker from the outside (a Group squaring the corners it joins) has
62888
- one element to talk to, and the box follows. */
62889
- border-radius: var(--picker-border-radius);
63390
+ one element to talk to, and the box follows.
63391
+ Corner by corner rather than as the shorthand: a picker is not always
63392
+ the member a Group joins — it can arrive wrapped (in a Box carrying a
63393
+ state, in a link, in a tooltip), and the ask then travels down as
63394
+ inherited custom properties instead of landing on this element as a
63395
+ radius. Each corner falls back to the picker's own radius when nothing
63396
+ asks for anything. */
63397
+ border-top-left-radius: var(
63398
+ --x-corner-top-left-radius,
63399
+ var(--picker-border-radius)
63400
+ );
63401
+ border-top-right-radius: var(
63402
+ --x-corner-top-right-radius,
63403
+ var(--picker-border-radius)
63404
+ );
63405
+ border-bottom-right-radius: var(
63406
+ --x-corner-bottom-right-radius,
63407
+ var(--picker-border-radius)
63408
+ );
63409
+ border-bottom-left-radius: var(
63410
+ --x-corner-bottom-left-radius,
63411
+ var(--picker-border-radius)
63412
+ );
62890
63413
 
62891
63414
  .navi_picker_box {
63415
+ /* The ask stops here: this element is the picker's frame, so nothing it
63416
+ holds is at the seam — the chevron and the clear cross in the slot, a
63417
+ button in a custom UI. */
63418
+ --x-corner-top-left-radius: initial;
63419
+ --x-corner-top-right-radius: initial;
63420
+ --x-corner-bottom-right-radius: initial;
63421
+ --x-corner-bottom-left-radius: initial;
63422
+
62892
63423
  position: relative;
62893
63424
  display: inline-flex;
62894
63425
  box-sizing: border-box;
@@ -62940,14 +63471,6 @@ installImportMetaCssBuild(import.meta);const css$u = /* css */`
62940
63471
  }
62941
63472
  }
62942
63473
  .navi_picker_right_slot {
62943
- /* A corner claimed from the outside (see group.jsx) is the frame's, and
62944
- what lives in here is not the frame — a button in a slot, the content
62945
- of a popup — so the ask stops at this boundary. */
62946
- --x-corner-top-left-radius: initial;
62947
- --x-corner-top-right-radius: initial;
62948
- --x-corner-bottom-right-radius: initial;
62949
- --x-corner-bottom-left-radius: initial;
62950
-
62951
63474
  display: inline-flex;
62952
63475
  height: 1em;
62953
63476
  height: 1lh;
@@ -63042,6 +63565,15 @@ installImportMetaCssBuild(import.meta);const css$u = /* css */`
63042
63565
  }
63043
63566
 
63044
63567
  .navi_picker_content {
63568
+ /* The other side of the frame: what a picker holds here is what its
63569
+ popup shows, and a popup is never at a seam. Popover and Dialog stop
63570
+ the ask at their own root too — this covers content that reaches the
63571
+ popup through neither. */
63572
+ --x-corner-top-left-radius: initial;
63573
+ --x-corner-top-right-radius: initial;
63574
+ --x-corner-bottom-right-radius: initial;
63575
+ --x-corner-bottom-left-radius: initial;
63576
+
63045
63577
  display: contents;
63046
63578
  text-align: initial; /* Don't inherit picker text align */
63047
63579
  }
@@ -63059,6 +63591,12 @@ installImportMetaCssBuild(import.meta);const css$u = /* css */`
63059
63591
  --x-picker-icon-color: var(--picker-icon-color-readonly);
63060
63592
  --x-picker-cursor: default;
63061
63593
  }
63594
+ /* Read-only and still opening, so it still says so under the pointer.
63595
+ Before the disabled block below on purpose: a disabled picker opens
63596
+ nothing, read-only or not. */
63597
+ &[data-readonly-opens] {
63598
+ --x-picker-cursor: pointer;
63599
+ }
63062
63600
  /* Focus */
63063
63601
  &[data-focus-within]:has(.navi_picker_input[data-focus-visible]) {
63064
63602
  --x-picker-border-color: transparent;
@@ -63187,6 +63725,7 @@ const PickerButton = props => {
63187
63725
  // untouched (see the --navi-clear command).
63188
63726
  clearConfirm,
63189
63727
  clearConfirmPopupContent,
63728
+ readOnly,
63190
63729
  error
63191
63730
  } = props;
63192
63731
  const isSingleLine = maxLines === 1;
@@ -63204,6 +63743,19 @@ const PickerButton = props => {
63204
63743
  children
63205
63744
  } = inputProps;
63206
63745
  const loading = basePseudoState[":-navi-loading"];
63746
+ // The same chain useControlProps resolves (own prop first, then what the
63747
+ // group above says), for the two things it does not carry: the popup content,
63748
+ // which is read-only along with the picker, and the cursor, which stays a
63749
+ // pointer on a picker that still opens.
63750
+ const readOnlyFromAbove = useContext(ReadOnlyContext);
63751
+ const readOnlyResolved = readOnly || readOnlyFromAbove;
63752
+ // Read off the controller rather than worked out again: what makes a
63753
+ // read-only picker open is settled once, where the gate reads it (see
63754
+ // createControlInfo's readOnlyOpens). Needed here for the cursor.
63755
+ const readOnlyOpens = Boolean(readOnlyResolved) && uiStateController.readOnlyOpens;
63756
+ // Whether anything can still be changed — read by the clear cross below,
63757
+ // clearing being a modification like any other.
63758
+ const interactive = !basePseudoState[":disabled"] && !basePseudoState[":read-only"] && !loading;
63207
63759
  usePickerErrorCallout(uiStateController, error);
63208
63760
  return jsxs(Box, {
63209
63761
  as: "div",
@@ -63222,6 +63774,7 @@ const PickerButton = props => {
63222
63774
  "navi-picker": "",
63223
63775
  "navi-single-line": isSingleLine ? "" : undefined,
63224
63776
  "navi-ui-custom": ui === "default" ? undefined : "",
63777
+ "data-readonly-opens": readOnlyOpens ? "" : undefined,
63225
63778
  "data-popup-width-fit-content": popupWidthFitContent ? "" : undefined,
63226
63779
  ...pickerRemainingProps,
63227
63780
  basePseudoState: basePseudoState,
@@ -63232,6 +63785,7 @@ const PickerButton = props => {
63232
63785
  rightSlot: undefined,
63233
63786
  clearConfirm: undefined,
63234
63787
  clearConfirmPopupContent: undefined,
63788
+ openWhileReadOnly: undefined,
63235
63789
  ui: undefined,
63236
63790
  maxLines: undefined,
63237
63791
  popupWidthFitContent: undefined,
@@ -63350,7 +63904,7 @@ const PickerButton = props => {
63350
63904
  value: undefined,
63351
63905
  children: jsx(ControlNameContext.Provider, {
63352
63906
  value: undefined,
63353
- children: clearable && value !== undefined && value !== "" ? jsx(Button, {
63907
+ children: clearable && interactive && value !== undefined && value !== "" ? jsx(Button, {
63354
63908
  command: "--navi-clear",
63355
63909
  commandFor: inputProps.id
63356
63910
  // The question, asked before the clear rather than by the
@@ -63403,9 +63957,12 @@ const PickerButton = props => {
63403
63957
  })]
63404
63958
  }), jsx(ControlFacadeChildrenWrapper, {
63405
63959
  ...facadeChildrenProps,
63406
- children: jsx("div", {
63407
- className: "navi_picker_content",
63408
- children: children
63960
+ children: jsx(ReadOnlyContext.Provider, {
63961
+ value: readOnlyResolved,
63962
+ children: jsx("div", {
63963
+ className: "navi_picker_content",
63964
+ children: children
63965
+ })
63409
63966
  })
63410
63967
  })]
63411
63968
  });
@@ -63664,7 +64221,27 @@ const css$t = /* css */`
63664
64221
  of one's own still wins — an inline style beats a stylesheet. */
63665
64222
  border: var(--navi-control-border-width) solid
63666
64223
  var(--navi-control-border-color);
63667
- border-radius: var(--navi-control-border-radius);
64224
+ /* Squared from the outside, corner by corner: a spin is not always the
64225
+ member a Group joins — it can arrive wrapped — so the ask travels down
64226
+ as inherited custom properties rather than as a radius landing on this
64227
+ element. The chevrons take these corners back by inherit (see below), so
64228
+ they follow. */
64229
+ border-top-left-radius: var(
64230
+ --x-corner-top-left-radius,
64231
+ var(--navi-control-border-radius)
64232
+ );
64233
+ border-top-right-radius: var(
64234
+ --x-corner-top-right-radius,
64235
+ var(--navi-control-border-radius)
64236
+ );
64237
+ border-bottom-right-radius: var(
64238
+ --x-corner-bottom-right-radius,
64239
+ var(--navi-control-border-radius)
64240
+ );
64241
+ border-bottom-left-radius: var(
64242
+ --x-corner-bottom-left-radius,
64243
+ var(--navi-control-border-radius)
64244
+ );
63668
64245
  outline-width: var(--navi-focus-outline-width);
63669
64246
  /* Just outside the border, never on it: the ring belongs to the whole
63670
64247
  control — the two chevrons included, since pressing one lands the
@@ -63721,6 +64298,13 @@ const css$t = /* css */`
63721
64298
  opens its calendar. Around the whole control it would open under a chevron;
63722
64299
  around the value it opens under the value one pressed. */
63723
64300
  .navi_picker_spin_middle {
64301
+ /* The ask stops here: the frame is the spin's box, and the picker or the
64302
+ field standing in the middle of it is behind that frame, not at a seam. */
64303
+ --x-corner-top-left-radius: initial;
64304
+ --x-corner-top-right-radius: initial;
64305
+ --x-corner-bottom-right-radius: initial;
64306
+ --x-corner-bottom-left-radius: initial;
64307
+
63724
64308
  position: relative;
63725
64309
  display: flex;
63726
64310
  min-width: 0;
@@ -63898,7 +64482,26 @@ const css$t = /* css */`
63898
64482
  font-family: var(--navi-control-font-family);
63899
64483
  border: var(--navi-control-border-width) solid
63900
64484
  var(--navi-control-border-color);
63901
- border-radius: var(--navi-control-border-radius);
64485
+ /* Squared from the outside, corner by corner (see .navi_picker_spin
64486
+ above): the group is the member a Group joins, and it can arrive
64487
+ wrapped. The spins inside give their radius up and take the corners of
64488
+ this one back through inherit, so they follow. */
64489
+ border-top-left-radius: var(
64490
+ --x-corner-top-left-radius,
64491
+ var(--navi-control-border-radius)
64492
+ );
64493
+ border-top-right-radius: var(
64494
+ --x-corner-top-right-radius,
64495
+ var(--navi-control-border-radius)
64496
+ );
64497
+ border-bottom-right-radius: var(
64498
+ --x-corner-bottom-right-radius,
64499
+ var(--navi-control-border-radius)
64500
+ );
64501
+ border-bottom-left-radius: var(
64502
+ --x-corner-bottom-left-radius,
64503
+ var(--navi-control-border-radius)
64504
+ );
63902
64505
  outline-width: var(--navi-focus-outline-width);
63903
64506
  outline-color: var(--navi-focus-outline-color);
63904
64507
  outline-offset: 0px;