@planningcenter/topbar 12.3.0-qa-899.1 → 12.3.0

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.
@@ -27,7 +27,7 @@
27
27
  var people__namespace = /*#__PURE__*/_interopNamespaceDefault(people);
28
28
  var toolbar__namespace = /*#__PURE__*/_interopNamespaceDefault(toolbar);
29
29
 
30
- var styles$i = {"accessibility-toolbar":"accessibility-toolbar-jgjUG"};
30
+ var styles$l = {"accessibility-toolbar":"accessibility-toolbar-jgjUG"};
31
31
 
32
32
  const AccessibilityToolbar = ({
33
33
  children
@@ -39,7 +39,7 @@
39
39
  (_a = document.getElementById("main-content")) === null || _a === void 0 ? void 0 : _a.focus();
40
40
  };
41
41
  return /*#__PURE__*/React.createElement("div", {
42
- className: styles$i["accessibility-toolbar"]
42
+ className: styles$l["accessibility-toolbar"]
43
43
  }, /*#__PURE__*/React.createElement(tapestry.Link, {
44
44
  href: "#main-content",
45
45
  kind: "primary",
@@ -356,11 +356,11 @@
356
356
  fill: fill
357
357
  })));
358
358
 
359
- var styles$h = {"announcement":"announcement-pI5tM","announcement--platform":"announcement--platform-EcUn2","announcement--promo":"announcement--promo-FarUk","announcement-icon":"announcement-icon-Ov39-","announcement-content":"announcement-content-q2ad6","dismiss":"dismiss-wUAoU","dismiss--promo":"dismiss--promo-7zvO4"};
359
+ var styles$k = {"announcement":"announcement-pI5tM","announcement--platform":"announcement--platform-EcUn2","announcement--promo":"announcement--promo-FarUk","announcement-icon":"announcement-icon-Ov39-","announcement-content":"announcement-content-q2ad6","dismiss":"dismiss-wUAoU","dismiss--promo":"dismiss--promo-7zvO4"};
360
360
 
361
361
  function AnnouncementIcon() {
362
362
  return /*#__PURE__*/React.createElement("svg", {
363
- className: styles$h["announcement-icon"],
363
+ className: styles$k["announcement-icon"],
364
364
  width: "16",
365
365
  height: "16",
366
366
  viewBox: "0 0 16 16",
@@ -485,7 +485,7 @@
485
485
  } = this.props;
486
486
  const isPromo = variant === "promo";
487
487
  return /*#__PURE__*/React.createElement("div", {
488
- className: classnames(styles$h["announcement"], styles$h[`announcement--${variant !== null && variant !== void 0 ? variant : "platform"}`]),
488
+ className: classnames(styles$k["announcement"], styles$k[`announcement--${variant !== null && variant !== void 0 ? variant : "platform"}`]),
489
489
  "data-announcement-variant": variant !== null && variant !== void 0 ? variant : "platform",
490
490
  style: isPromo ? {
491
491
  backgroundImage: promoBackgroundUrl,
@@ -494,7 +494,7 @@
494
494
  backgroundSize: "cover"
495
495
  } : undefined
496
496
  }, !isPromo && /*#__PURE__*/React.createElement(AnnouncementIcon, null), /*#__PURE__*/React.createElement("div", {
497
- className: styles$h["announcement-content"],
497
+ className: styles$k["announcement-content"],
498
498
  style: style,
499
499
  ...platformProps
500
500
  }));
@@ -543,7 +543,7 @@
543
543
  const isPromo = variant === "promo";
544
544
  return /*#__PURE__*/React.createElement("button", {
545
545
  type: "button",
546
- className: classnames(styles$h.dismiss, isPromo && styles$h["dismiss--promo"]),
546
+ className: classnames(styles$k.dismiss, isPromo && styles$k["dismiss--promo"]),
547
547
  ...props
548
548
  }, /*#__PURE__*/React.createElement(X, {
549
549
  fill: isPromo ? "currentColor" : "white",
@@ -623,9 +623,9 @@
623
623
  ...platformProps
624
624
  } = this.props;
625
625
  return /*#__PURE__*/React.createElement("div", {
626
- className: styles$h["announcement"]
626
+ className: styles$k["announcement"]
627
627
  }, /*#__PURE__*/React.createElement(AnnouncementIcon, null), /*#__PURE__*/React.createElement("div", {
628
- className: styles$h["announcement-content"],
628
+ className: styles$k["announcement-content"],
629
629
  style: style,
630
630
  ...platformProps
631
631
  }));
@@ -636,7 +636,7 @@
636
636
  }) {
637
637
  return /*#__PURE__*/React.createElement("button", {
638
638
  type: "button",
639
- className: styles$h.dismiss,
639
+ className: styles$k.dismiss,
640
640
  ...props
641
641
  }, /*#__PURE__*/React.createElement(X, {
642
642
  fill: "white",
@@ -1758,7 +1758,7 @@
1758
1758
  })) : (/*#__PURE__*/React.createElement("div", null, "appName not supported")));
1759
1759
  }
1760
1760
 
1761
- var styles$g = {"icon-slot":"icon-slot-m3ZIk","name":"name-UNqbW"};
1761
+ var styles$j = {"icon-slot":"icon-slot-m3ZIk","name":"name-UNqbW"};
1762
1762
 
1763
1763
  const AppRow = ({
1764
1764
  appName,
@@ -1767,24 +1767,24 @@
1767
1767
  }) => {
1768
1768
  if (appName === "Accounts") {
1769
1769
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", {
1770
- className: styles$g["icon-slot"]
1770
+ className: styles$j["icon-slot"]
1771
1771
  }, /*#__PURE__*/React.createElement(MonoAppIcon, {
1772
1772
  appName: "Accounts",
1773
1773
  colors: colors,
1774
1774
  size: iconSize
1775
1775
  })), /*#__PURE__*/React.createElement("span", {
1776
- className: styles$g.name
1776
+ className: styles$j.name
1777
1777
  }, "Account settings"));
1778
1778
  }
1779
1779
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(ColorAppIcon, {
1780
1780
  appName: appName.replace(/[\s-]/, ""),
1781
1781
  size: iconSize
1782
1782
  }), /*#__PURE__*/React.createElement("span", {
1783
- className: styles$g.name
1783
+ className: styles$j.name
1784
1784
  }, appName));
1785
1785
  };
1786
1786
 
1787
- var styles$f = {"button":"button-XMbeQ","label":"label-884of","chevron":"chevron-RRg9k"};
1787
+ var styles$i = {"button":"button-XMbeQ","label":"label-884of","chevron":"chevron-RRg9k"};
1788
1788
 
1789
1789
  const AppsButton = /*#__PURE__*/React.forwardRef(({
1790
1790
  appName,
@@ -1796,10 +1796,10 @@
1796
1796
  ...restProps,
1797
1797
  ref: ref,
1798
1798
  "aria-label": `App switcher: ${appName}`,
1799
- className: styles$f.button,
1799
+ className: styles$i.button,
1800
1800
  kind: "neutral-inline",
1801
1801
  label: /*#__PURE__*/React.createElement("span", {
1802
- className: styles$f.label
1802
+ className: styles$i.label
1803
1803
  }, isAccounts ? "Account settings" : appName),
1804
1804
  prefix: isAccounts ? (/*#__PURE__*/React.createElement(MonoAppIcon, {
1805
1805
  appName: "Accounts",
@@ -1810,13 +1810,13 @@
1810
1810
  size: 22
1811
1811
  })),
1812
1812
  suffix: /*#__PURE__*/React.createElement(DisclosureChevronIcon, {
1813
- className: `${styles$f.chevron} symbol`
1813
+ className: `${styles$i.chevron} symbol`
1814
1814
  })
1815
1815
  });
1816
1816
  });
1817
1817
  AppsButton.displayName = "AppsButton";
1818
1818
 
1819
- var styles$e = {"nav":"nav-y-6ML","popover-content":"popover-content-jOAAY","list":"list-jUxvp","link":"link-LMp2h","current":"current-4U4VL","accounts-item":"accounts-item-erJW0","accounts-link":"accounts-link-4jwPW"};
1819
+ var styles$h = {"nav":"nav-y-6ML","popover-content":"popover-content-jOAAY","list":"list-jUxvp","link":"link-LMp2h","current":"current-4U4VL","accounts-item":"accounts-item-erJW0","accounts-link":"accounts-link-4jwPW"};
1820
1820
 
1821
1821
  const AppsMenu = ({
1822
1822
  appName,
@@ -1837,8 +1837,8 @@
1837
1837
  }, /*#__PURE__*/React.createElement("a", {
1838
1838
  "aria-current": isCurrent ? "page" : undefined,
1839
1839
  "aria-label": name,
1840
- className: classnames(styles$e.link, {
1841
- [styles$e.current]: isCurrent
1840
+ className: classnames(styles$h.link, {
1841
+ [styles$h.current]: isCurrent
1842
1842
  }),
1843
1843
  "data-pendo": `app-switcher--change-app--${name}`,
1844
1844
  "data-turbolinks": false,
@@ -1854,22 +1854,22 @@
1854
1854
  colors: colors
1855
1855
  })), /*#__PURE__*/React.createElement(tapestry.PopoverContent, {
1856
1856
  "aria-label": "Switch app",
1857
- className: styles$e["popover-content"],
1857
+ className: styles$h["popover-content"],
1858
1858
  placement: "bottom start"
1859
1859
  }, /*#__PURE__*/React.createElement("nav", {
1860
- className: styles$e.nav,
1860
+ className: styles$h.nav,
1861
1861
  "data-pendo": "app-switcher"
1862
1862
  }, /*#__PURE__*/React.createElement("ul", {
1863
- className: styles$e.list,
1863
+ className: styles$h.list,
1864
1864
  role: "list"
1865
1865
  }, homeProduct && renderProductLink(homeProduct.name), restProducts.map(({
1866
1866
  name
1867
1867
  }) => renderProductLink(name)), accountsProduct && (/*#__PURE__*/React.createElement("li", {
1868
- className: styles$e["accounts-item"]
1868
+ className: styles$h["accounts-item"]
1869
1869
  }, /*#__PURE__*/React.createElement("a", {
1870
1870
  "aria-current": appName === accountsProduct.name ? "page" : undefined,
1871
1871
  "aria-label": "Account settings",
1872
- className: classnames(styles$e.link, styles$e["accounts-link"]),
1872
+ className: classnames(styles$h.link, styles$h["accounts-link"]),
1873
1873
  "data-pendo": "app-switcher--change-app--Accounts",
1874
1874
  "data-turbolinks": false,
1875
1875
  href: pcoUrl$1(env)("accounts") + getAppsSwitchProductPath(accountsProduct.name)
@@ -2077,7 +2077,7 @@
2077
2077
  })) : (/*#__PURE__*/React.createElement("div", null, "appName not supported"));
2078
2078
  }
2079
2079
 
2080
- var styles$d = {"unbutton":"unbutton-lEbTR"};
2080
+ var styles$g = {"unbutton":"unbutton-lEbTR"};
2081
2081
 
2082
2082
  function Unbutton({
2083
2083
  className,
@@ -2086,7 +2086,7 @@
2086
2086
  ...nativeProps
2087
2087
  }) {
2088
2088
  return /*#__PURE__*/React.createElement("button", {
2089
- className: classnames(styles$d.unbutton, className),
2089
+ className: classnames(styles$g.unbutton, className),
2090
2090
  style: {
2091
2091
  ...style
2092
2092
  },
@@ -2095,7 +2095,7 @@
2095
2095
  });
2096
2096
  }
2097
2097
 
2098
- var styles$c = {"button":"button-wsipc","button-accounts":"button-accounts-zQmBM","chevron":"chevron-obyBF","is-expanded":"is-expanded-PoCQ9"};
2098
+ var styles$f = {"button":"button-wsipc","button-accounts":"button-accounts-zQmBM","chevron":"chevron-obyBF","is-expanded":"is-expanded-PoCQ9"};
2099
2099
 
2100
2100
  const LegacyAppsButton = ({
2101
2101
  appName,
@@ -2110,8 +2110,8 @@
2110
2110
  "aria-haspopup": "true",
2111
2111
  "aria-label": `App switcher: ${appName}`,
2112
2112
  onClick: onClick,
2113
- className: classnames(styles$c.button, {
2114
- [styles$c["is-expanded"]]: expanded
2113
+ className: classnames(styles$f.button, {
2114
+ [styles$f["is-expanded"]]: expanded
2115
2115
  }),
2116
2116
  type: "button",
2117
2117
  style: style,
@@ -2120,7 +2120,7 @@
2120
2120
  appName: "Accounts",
2121
2121
  colors: colors
2122
2122
  }), /*#__PURE__*/React.createElement("div", {
2123
- className: styles$c["button-accounts"]
2123
+ className: styles$f["button-accounts"]
2124
2124
  }, "Account settings"))) : (/*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(ColorAppIcon, {
2125
2125
  appName: appName.replace(/[\s-]/, "")
2126
2126
  }), /*#__PURE__*/React.createElement(MonoAppName, {
@@ -2128,7 +2128,7 @@
2128
2128
  key: "mono-app-name",
2129
2129
  size: 24
2130
2130
  }))), /*#__PURE__*/React.createElement(DisclosureChevronIcon, {
2131
- className: styles$c.chevron,
2131
+ className: styles$f.chevron,
2132
2132
  key: "disclosure-chevron-icon"
2133
2133
  }));
2134
2134
  };
@@ -2261,7 +2261,7 @@
2261
2261
  }
2262
2262
  }
2263
2263
 
2264
- var styles$b = {"popup-root":"popup-root-SeL5u","popup":"popup-nQXYW","is-visible":"is-visible-3DAUP"};
2264
+ var styles$e = {"popup-root":"popup-root-SeL5u","popup":"popup-nQXYW","is-visible":"is-visible-3DAUP"};
2265
2265
 
2266
2266
  const Popup = ({
2267
2267
  className,
@@ -2269,8 +2269,8 @@
2269
2269
  isVisible,
2270
2270
  ...nativeProps
2271
2271
  }) => (/*#__PURE__*/React.createElement(C, {
2272
- className: classnames(styles$b.popup, {
2273
- [styles$b["is-visible"]]: isVisible
2272
+ className: classnames(styles$e.popup, {
2273
+ [styles$e["is-visible"]]: isVisible
2274
2274
  }, className),
2275
2275
  ...nativeProps
2276
2276
  }));
@@ -2278,11 +2278,11 @@
2278
2278
  const PopupRoot = ({
2279
2279
  ...nativeProps
2280
2280
  }) => (/*#__PURE__*/React.createElement("div", {
2281
- className: styles$b["popup-root"],
2281
+ className: styles$e["popup-root"],
2282
2282
  ...nativeProps
2283
2283
  }));
2284
2284
 
2285
- var styles$a = {"popup":"popup-bVhSa"};
2285
+ var styles$d = {"popup":"popup-bVhSa"};
2286
2286
 
2287
2287
  const LegacyAppsMenu = ({
2288
2288
  appName,
@@ -2320,7 +2320,7 @@
2320
2320
  cleanup: !visible,
2321
2321
  component: Outsider,
2322
2322
  onOutsideClick: toggle,
2323
- className: styles$a.popup,
2323
+ className: styles$d.popup,
2324
2324
  isVisible: visible
2325
2325
  }, /*#__PURE__*/React.createElement("menu", {
2326
2326
  ref: menuRef,
@@ -3230,7 +3230,97 @@ true;
3230
3230
  });
3231
3231
  }, []);
3232
3232
 
3233
+ const isMac = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform);
3234
+ const hasCommandKey = event => isMac() ? event.metaKey : event.ctrlKey;
3235
+ const commandKeyLabel = () => isMac() ? "⌘" : "Ctrl+";
3236
+
3237
+ const STORAGE_KEY = "topbar.product_search.last_product";
3238
+ function readLastProduct() {
3239
+ try {
3240
+ return localStorage.getItem(STORAGE_KEY);
3241
+ } catch {
3242
+ return null;
3243
+ }
3244
+ }
3245
+ function saveLastProduct(slug) {
3246
+ try {
3247
+ localStorage.setItem(STORAGE_KEY, slug);
3248
+ } catch {
3249
+ // Storage can be full or blocked; search still works without it.
3250
+ }
3251
+ }
3252
+
3253
+ var styles$c = {"visuallyHidden":"visuallyHidden-C28ju"};
3254
+
3255
+ var styles$b = {"tab":"tab-lQLDx"};
3256
+
3257
+ // useId isn't available on React 17, which our peer range still supports.
3258
+ let instanceCount$1 = 0;
3259
+ function useInstanceId(prefix) {
3260
+ const [id] = React.useState(() => `${prefix}-${++instanceCount$1}`);
3261
+ return id;
3262
+ }
3263
+
3264
+ function ProductTabs({
3265
+ children,
3266
+ onKeyDown,
3267
+ onSelect,
3268
+ products,
3269
+ selected
3270
+ }) {
3271
+ const id = useInstanceId("product-tabs");
3272
+ const tabId = slug => `${id}-tab-${slug}`;
3273
+ const tabRefs = React.useRef(new Map());
3274
+ const handleKeyDown = event => {
3275
+ var _a;
3276
+ const offset = {
3277
+ ArrowLeft: -1,
3278
+ ArrowRight: 1
3279
+ }[event.key];
3280
+ if (offset) {
3281
+ event.preventDefault();
3282
+ const index = products.indexOf(selected);
3283
+ const next = products[(index + offset + products.length) % products.length];
3284
+ onSelect(next.slug);
3285
+ (_a = tabRefs.current.get(next.slug)) === null || _a === void 0 ? void 0 : _a.focus();
3286
+ } else {
3287
+ onKeyDown === null || onKeyDown === void 0 ? void 0 : onKeyDown(event);
3288
+ }
3289
+ };
3290
+ return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
3291
+ "aria-label": "Product",
3292
+ onKeyDown: handleKeyDown,
3293
+ role: "tablist"
3294
+ }, products.map(product => (/*#__PURE__*/React.createElement("button", {
3295
+ "aria-controls": `${id}-panel`,
3296
+ "aria-selected": product === selected,
3297
+ className: styles$b.tab,
3298
+ id: tabId(product.slug),
3299
+ key: product.slug,
3300
+ onClick: () => onSelect(product.slug),
3301
+ ref: element => {
3302
+ if (element) {
3303
+ tabRefs.current.set(product.slug, element);
3304
+ } else {
3305
+ tabRefs.current.delete(product.slug);
3306
+ }
3307
+ },
3308
+ role: "tab",
3309
+ tabIndex: product === selected ? 0 : -1,
3310
+ type: "button"
3311
+ }, product.appName)))), /*#__PURE__*/React.createElement("div", {
3312
+ "aria-labelledby": tabId(selected.slug),
3313
+ id: `${id}-panel`,
3314
+ role: "tabpanel"
3315
+ }, children));
3316
+ }
3317
+
3318
+ var styles$a = {"result":"result-WCnxr"};
3319
+
3233
3320
  function SearchResult({
3321
+ highlighted,
3322
+ id,
3323
+ onHighlight,
3234
3324
  result
3235
3325
  }) {
3236
3326
  const {
@@ -3238,62 +3328,222 @@ true;
3238
3328
  title,
3239
3329
  url
3240
3330
  } = result.attributes;
3331
+ const ref = React.useRef(null);
3332
+ React.useEffect(() => {
3333
+ var _a;
3334
+ if (highlighted) (_a = ref.current) === null || _a === void 0 ? void 0 : _a.scrollIntoView({
3335
+ block: "nearest"
3336
+ });
3337
+ }, [highlighted]);
3338
+ // Focus stays in the search input, which points at the highlighted result
3339
+ // with aria-activedescendant, so results are kept out of the tab order.
3340
+ // Mouse move rather than enter: arrowing scrolls results under a resting
3341
+ // pointer, and that shouldn't steal the highlight.
3241
3342
  return /*#__PURE__*/React.createElement("a", {
3242
- href: url
3343
+ "aria-selected": highlighted,
3344
+ className: styles$a.result,
3345
+ href: url,
3346
+ id: id,
3347
+ onMouseMove: onHighlight,
3348
+ ref: ref,
3349
+ role: "option",
3350
+ tabIndex: -1
3243
3351
  }, /*#__PURE__*/React.createElement("div", null, title), /*#__PURE__*/React.createElement("div", null, summarize(metadata)));
3244
3352
  }
3245
3353
  function summarize(metadata) {
3246
3354
  return Object.values(metadata !== null && metadata !== void 0 ? metadata : {}).filter(value => typeof value === "string" && value !== "").join(" · ");
3247
3355
  }
3248
3356
 
3249
- const SEARCH_DEBOUNCE_MS = 300;
3250
- function useProductSearch(term) {
3251
- const query = term.trim();
3252
- const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
3253
- const [search, setSearch] = React.useState({
3254
- status: "idle"
3357
+ const resultId = (listboxId, index) => `${listboxId}-result-${index}`;
3358
+ function SearchResults({
3359
+ search,
3360
+ term,
3361
+ ...navigation
3362
+ }) {
3363
+ var _a;
3364
+ switch (search.status) {
3365
+ case "idle":
3366
+ return null;
3367
+ case "loading":
3368
+ if ((_a = search.groups) === null || _a === void 0 ? void 0 : _a.length) {
3369
+ return /*#__PURE__*/React.createElement(ResultGroups, {
3370
+ busy: true,
3371
+ groups: search.groups,
3372
+ ...navigation
3373
+ });
3374
+ }
3375
+ return /*#__PURE__*/React.createElement("p", {
3376
+ role: "status"
3377
+ }, "Searching\u2026");
3378
+ case "error":
3379
+ return /*#__PURE__*/React.createElement("p", {
3380
+ role: "alert"
3381
+ }, "Search isn\u2019t working right now. Try again.");
3382
+ case "done":
3383
+ if (search.groups.length === 0) {
3384
+ return /*#__PURE__*/React.createElement("p", {
3385
+ role: "status"
3386
+ }, "No results for \u201C", term.trim(), "\u201D");
3387
+ }
3388
+ return /*#__PURE__*/React.createElement(ResultGroups, {
3389
+ groups: search.groups,
3390
+ ...navigation
3391
+ });
3392
+ }
3393
+ }
3394
+ function ResultGroups({
3395
+ busy = false,
3396
+ groups,
3397
+ highlightedIndex,
3398
+ id,
3399
+ label,
3400
+ onHighlight
3401
+ }) {
3402
+ let index = 0;
3403
+ return /*#__PURE__*/React.createElement("div", {
3404
+ "aria-busy": busy,
3405
+ "aria-label": label,
3406
+ id: id,
3407
+ role: "listbox"
3408
+ }, groups.map(({
3409
+ results,
3410
+ type
3411
+ }, groupIndex) => {
3412
+ const headingId = `${id}-group-${groupIndex}`;
3413
+ return /*#__PURE__*/React.createElement("div", {
3414
+ "aria-labelledby": headingId,
3415
+ key: type,
3416
+ role: "group"
3417
+ }, /*#__PURE__*/React.createElement("div", {
3418
+ id: headingId
3419
+ }, type), results.map(result => {
3420
+ const resultIndex = index++;
3421
+ return /*#__PURE__*/React.createElement(SearchResult, {
3422
+ highlighted: resultIndex === highlightedIndex,
3423
+ id: resultId(id, resultIndex),
3424
+ key: result.id,
3425
+ onHighlight: () => onHighlight(resultIndex),
3426
+ result: result
3427
+ });
3428
+ }));
3429
+ }));
3430
+ }
3431
+
3432
+ const CACHE_TTL_MS = 60000;
3433
+ const MAX_ENTRIES$1 = 50;
3434
+ const cache = new Map();
3435
+ function cachedRequest(url) {
3436
+ const cached = cache.get(url);
3437
+ if (cached && cached.expiresAt > Date.now()) {
3438
+ // Re-inserting keeps the Map in recently-used order for eviction.
3439
+ cache.delete(url);
3440
+ cache.set(url, cached);
3441
+ return cached.promise;
3442
+ } else {
3443
+ return fetchIntoCache(url);
3444
+ }
3445
+ }
3446
+ function fetchIntoCache(url) {
3447
+ const promise = apiRequest(defaultFetch, url).then(({
3448
+ json,
3449
+ ok
3450
+ }) => {
3451
+ if (!ok || json === null) throw new Error(`Request failed: ${url}`);
3452
+ return json;
3255
3453
  });
3454
+ // In-flight entries never expire, so every caller shares the one fetch.
3455
+ const entry = {
3456
+ expiresAt: Infinity,
3457
+ promise
3458
+ };
3459
+ cache.set(url, entry);
3460
+ promise.then(() => entry.expiresAt = Date.now() + CACHE_TTL_MS, () => cache.get(url) === entry && cache.delete(url));
3461
+ if (cache.size > MAX_ENTRIES$1) cache.delete(cache.keys().next().value);
3462
+ return promise;
3463
+ }
3464
+ function useCachedRequest(url) {
3465
+ const [settled, setSettled] = React.useState();
3256
3466
  React.useEffect(() => {
3257
- if (!debouncedQuery) {
3258
- setSearch({
3259
- status: "idle"
3260
- });
3467
+ if (url) {
3468
+ let current = true;
3469
+ cachedRequest(url).then(data => current && setSettled({
3470
+ data,
3471
+ url
3472
+ }), () => current && setSettled({
3473
+ failed: true,
3474
+ url
3475
+ }));
3476
+ return () => {
3477
+ current = false;
3478
+ };
3479
+ } else {
3261
3480
  return undefined;
3262
3481
  }
3263
- let current = true;
3264
- setSearch({
3482
+ }, [url]);
3483
+ if (!url) {
3484
+ return {
3485
+ status: "idle"
3486
+ };
3487
+ } else if ((settled === null || settled === void 0 ? void 0 : settled.url) !== url) {
3488
+ return {
3489
+ data: settled && "data" in settled ? settled.data : undefined,
3265
3490
  status: "loading"
3266
- });
3267
- apiRequest(defaultFetch, productSearchUrl(debouncedQuery)).then(({
3268
- json,
3269
- ok
3270
- }) => {
3271
- if (!current) return;
3272
- setSearch(ok && json ? {
3273
- groups: groupByType(json),
3274
- status: "done"
3275
- } : {
3276
- status: "error"
3277
- });
3278
- });
3279
- return () => {
3280
- current = false;
3281
3491
  };
3282
- }, [debouncedQuery]);
3283
- if (!query) return {
3284
- status: "idle"
3285
- };
3286
- if (query !== debouncedQuery) return {
3287
- status: "loading"
3288
- };
3289
- return search;
3492
+ } else if ("data" in settled) {
3493
+ return {
3494
+ data: settled.data,
3495
+ status: "done"
3496
+ };
3497
+ } else {
3498
+ return {
3499
+ status: "error"
3500
+ };
3501
+ }
3502
+ }
3503
+
3504
+ const SEARCH_DEBOUNCE_MS = 300;
3505
+ function useProductSearch(product, term) {
3506
+ const query = term.trim();
3507
+ const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
3508
+ const request = useCachedRequest(debouncedQuery ? productSearchUrl(product, debouncedQuery) : null);
3509
+ const response = "data" in request ? request.data : undefined;
3510
+ const settledProduct = useSettledProduct(product, request.status);
3511
+ // While a request loads, the previous response stays on screen, but only
3512
+ // if it was for this product: People results must never show under the
3513
+ // Services tab.
3514
+ const visibleResponse = request.status === "done" || settledProduct === product ? response : undefined;
3515
+ const groups = React.useMemo(() => visibleResponse && groupByType(visibleResponse), [visibleResponse]);
3516
+ if (!query) {
3517
+ return {
3518
+ status: "idle"
3519
+ };
3520
+ } else if (query !== debouncedQuery) {
3521
+ return {
3522
+ groups,
3523
+ status: "loading"
3524
+ };
3525
+ } else if (request.status === "done") {
3526
+ return {
3527
+ groups: groups !== null && groups !== void 0 ? groups : [],
3528
+ status: "done"
3529
+ };
3530
+ } else if (request.status === "error") {
3531
+ return {
3532
+ status: "error"
3533
+ };
3534
+ } else {
3535
+ return {
3536
+ groups,
3537
+ status: "loading"
3538
+ };
3539
+ }
3290
3540
  }
3291
- function productSearchUrl(query) {
3541
+ function productSearchUrl(product, query) {
3292
3542
  const params = new URLSearchParams({
3293
3543
  per_page: "100",
3294
3544
  "where[term]": query
3295
3545
  });
3296
- return `${window.location.origin}/~api/people/v2/product_search_results?${params}`;
3546
+ return `${window.location.origin}/~api/${product}/v2/product_search_results?${params}`;
3297
3547
  }
3298
3548
  function groupByType({
3299
3549
  data,
@@ -3318,6 +3568,14 @@ true;
3318
3568
  };
3319
3569
  return [...groups.values()].sort((a, b) => rank(a.type) - rank(b.type));
3320
3570
  }
3571
+ // The product the most recent finished request was for.
3572
+ function useSettledProduct(product, status) {
3573
+ const [settledProduct, setSettledProduct] = React.useState();
3574
+ React.useEffect(() => {
3575
+ if (status === "done") setSettledProduct(product);
3576
+ }, [product, status]);
3577
+ return settledProduct;
3578
+ }
3321
3579
  function useDebouncedValue(value, delay) {
3322
3580
  const [debounced, setDebounced] = React.useState(value);
3323
3581
  React.useEffect(() => {
@@ -3327,53 +3585,199 @@ true;
3327
3585
  return debounced;
3328
3586
  }
3329
3587
 
3330
- function SearchResults({
3331
- term
3588
+ const COPIED_FEEDBACK_MS = 1500;
3589
+ const verticalStep = key => ({
3590
+ ArrowDown: 1,
3591
+ ArrowUp: -1
3592
+ })[key];
3593
+ function useResultNavigation(results) {
3594
+ // Remembering which results the index belongs to puts the highlight back on
3595
+ // the first result whenever new ones arrive, without an effect to reset it.
3596
+ const [highlight, setHighlight] = React.useState({
3597
+ index: 0,
3598
+ results
3599
+ });
3600
+ const highlightedIndex = highlight.results === results ? highlight.index : 0;
3601
+ const highlighted = results[highlightedIndex];
3602
+ const [copiedUrl, setCopiedUrl] = React.useState(null);
3603
+ React.useEffect(() => {
3604
+ if (copiedUrl) {
3605
+ const timeout = setTimeout(() => setCopiedUrl(null), COPIED_FEEDBACK_MS);
3606
+ return () => clearTimeout(timeout);
3607
+ } else {
3608
+ return undefined;
3609
+ }
3610
+ }, [copiedUrl]);
3611
+ const highlightIndex = React.useCallback(index => setHighlight(current => current.results === results && current.index === index ? current : {
3612
+ index,
3613
+ results
3614
+ }), [results]);
3615
+ const handleKeyDown = event => {
3616
+ const step = verticalStep(event.key);
3617
+ if (step && results.length > 0) {
3618
+ event.preventDefault();
3619
+ highlightIndex((highlightedIndex + step + results.length) % results.length);
3620
+ } else if (event.key === "Enter" && highlighted && !event.nativeEvent.isComposing) {
3621
+ event.preventDefault();
3622
+ open(highlighted.attributes.url, {
3623
+ newTab: hasCommandKey(event)
3624
+ });
3625
+ } else if (isCopyShortcut(event) && highlighted && !hasSelectedText(event.currentTarget)) {
3626
+ event.preventDefault();
3627
+ const {
3628
+ url
3629
+ } = highlighted.attributes;
3630
+ navigator.clipboard.writeText(url).then(() => setCopiedUrl(url));
3631
+ }
3632
+ };
3633
+ return {
3634
+ copied: copiedUrl !== null && copiedUrl === (highlighted === null || highlighted === void 0 ? void 0 : highlighted.attributes.url),
3635
+ handleKeyDown,
3636
+ highlightIndex,
3637
+ highlighted,
3638
+ highlightedIndex: highlighted ? highlightedIndex : null
3639
+ };
3640
+ }
3641
+ function open(url, {
3642
+ newTab
3332
3643
  }) {
3333
- const search = useProductSearch(term);
3334
- switch (search.status) {
3335
- case "idle":
3336
- return null;
3337
- case "loading":
3338
- return /*#__PURE__*/React.createElement("p", {
3339
- role: "status"
3340
- }, "Searching\u2026");
3341
- case "error":
3342
- return /*#__PURE__*/React.createElement("p", {
3343
- role: "alert"
3344
- }, "Search isn\u2019t working right now. Try again.");
3345
- case "done":
3346
- if (search.groups.length === 0) {
3347
- return /*#__PURE__*/React.createElement("p", {
3348
- role: "status"
3349
- }, "No results for \u201C", term.trim(), "\u201D");
3644
+ if (newTab) {
3645
+ window.open(url, "_blank", "noopener");
3646
+ } else {
3647
+ window.location.assign(url);
3648
+ }
3649
+ }
3650
+ const isCopyShortcut = event => event.key.toLowerCase() === "c" && !event.altKey && !event.shiftKey && hasCommandKey(event);
3651
+ // With text selected, ⌘C should copy that text as usual, not the link.
3652
+ function hasSelectedText(element) {
3653
+ var _a, _b;
3654
+ const selectedInField = element instanceof HTMLInputElement && element.selectionStart !== element.selectionEnd;
3655
+ return selectedInField || ((_b = (_a = window.getSelection()) === null || _a === void 0 ? void 0 : _a.toString()) !== null && _b !== void 0 ? _b : "") !== "";
3656
+ }
3657
+
3658
+ const SEARCHABLE_PRODUCTS = [{
3659
+ appName: "Groups",
3660
+ slug: "groups"
3661
+ }, {
3662
+ appName: "People",
3663
+ slug: "people"
3664
+ }, {
3665
+ appName: "Services",
3666
+ slug: "services"
3667
+ }];
3668
+ const PERMISSION_CHECKS = {
3669
+ people: () => fetchMe("people", "people_permissions,site_administrator").then(({
3670
+ people_permissions,
3671
+ site_administrator
3672
+ }) => site_administrator || people_permissions !== null),
3673
+ services: () => fetchMe("services", "can_search").then(({
3674
+ can_search
3675
+ }) => can_search)
3676
+ };
3677
+ function fetchMe(product, fields) {
3678
+ const params = new URLSearchParams({
3679
+ "fields[Person]": fields
3680
+ });
3681
+ return apiRequest(defaultFetch, `${window.location.origin}/~api/${product}/v2/me?${params}`).then(({
3682
+ json,
3683
+ ok
3684
+ }) => {
3685
+ if (!ok || json === null) throw new Error("Permission check failed");
3686
+ return json.data.attributes;
3687
+ });
3688
+ }
3689
+ // Permissions rarely change mid-session, so a successful check is kept until
3690
+ // the page reloads. A failed one is dropped so a remount tries again.
3691
+ const permissionCache = new Map();
3692
+ function canSearch(slug) {
3693
+ const check = PERMISSION_CHECKS[slug];
3694
+ const cached = permissionCache.get(slug);
3695
+ if (!check) {
3696
+ return Promise.resolve(true);
3697
+ } else if (cached) {
3698
+ return cached;
3699
+ } else {
3700
+ const promise = check().catch(() => {
3701
+ permissionCache.delete(slug);
3702
+ return false;
3703
+ });
3704
+ permissionCache.set(slug, promise);
3705
+ return promise;
3706
+ }
3707
+ }
3708
+ function useSearchableProducts(apps) {
3709
+ // Keyed by name because providers hand over a fresh `apps` array on renders
3710
+ // where nothing changed.
3711
+ const accessibleKey = inSwitcherOrder(apps).map(({
3712
+ slug
3713
+ }) => slug).join(",");
3714
+ const [settled, setSettled] = React.useState();
3715
+ React.useEffect(() => {
3716
+ let current = true;
3717
+ const accessible = accessibleKey.split(",").flatMap(slug => SEARCHABLE_PRODUCTS.filter(product => product.slug === slug));
3718
+ Promise.all(accessible.map(({
3719
+ slug
3720
+ }) => canSearch(slug))).then(allowed => {
3721
+ if (current) {
3722
+ setSettled({
3723
+ accessibleKey,
3724
+ products: accessible.filter((_, index) => allowed[index])
3725
+ });
3350
3726
  }
3351
- return /*#__PURE__*/React.createElement(React.Fragment, null, search.groups.map(({
3352
- results,
3353
- type
3354
- }) => (/*#__PURE__*/React.createElement("section", {
3355
- "aria-label": type,
3356
- key: type
3357
- }, /*#__PURE__*/React.createElement("h2", null, type), /*#__PURE__*/React.createElement("ul", null, results.map(result => (/*#__PURE__*/React.createElement("li", {
3358
- key: result.id
3359
- }, /*#__PURE__*/React.createElement(SearchResult, {
3360
- result: result
3361
- })))))))));
3727
+ });
3728
+ return () => {
3729
+ current = false;
3730
+ };
3731
+ }, [accessibleKey]);
3732
+ if ((settled === null || settled === void 0 ? void 0 : settled.accessibleKey) === accessibleKey) {
3733
+ return {
3734
+ products: settled.products,
3735
+ status: "done"
3736
+ };
3737
+ } else {
3738
+ return {
3739
+ status: "loading"
3740
+ };
3362
3741
  }
3363
3742
  }
3743
+ function inSwitcherOrder(apps) {
3744
+ return groupApps(apps).restProducts.flatMap(app => SEARCHABLE_PRODUCTS.filter(product => product.appName === app.name));
3745
+ }
3364
3746
 
3365
- const isMac = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform);
3366
- const isSearchShortcut = event => event.key.toLowerCase() === "k" && !event.altKey && !event.shiftKey && (isMac() ? event.metaKey : event.ctrlKey);
3367
- function ProductSearch() {
3747
+ const isSearchShortcut = event => event.key.toLowerCase() === "k" && !event.altKey && !event.shiftKey && hasCommandKey(event);
3748
+ // Products opened their own search with `/` before this one replaced it.
3749
+ const isLegacySearchShortcut = event => event.key === "/" && !event.altKey && !event.ctrlKey && !event.metaKey && !isTypingIn(event.target);
3750
+ const isTypingKey = event => event.key.length === 1 && event.key !== " " && !event.altKey && !event.ctrlKey && !event.metaKey;
3751
+ const isTypingIn = target => target instanceof Element && target.closest('input, select, textarea, [contenteditable]:not([contenteditable="false"])') !== null;
3752
+ function ProductSearch({
3753
+ apps
3754
+ }) {
3755
+ const searchable = useSearchableProducts(apps);
3756
+ if (searchable.status === "done" && searchable.products.length > 0) {
3757
+ return /*#__PURE__*/React.createElement(SearchButtonAndDialog, {
3758
+ products: searchable.products
3759
+ });
3760
+ } else {
3761
+ return null;
3762
+ }
3763
+ }
3764
+ function SearchButtonAndDialog({
3765
+ products
3766
+ }) {
3368
3767
  const [open, setOpen] = React.useState(false);
3369
3768
  React.useEffect(() => {
3769
+ // Rich text editors like Trix bind ⌘K to "insert link"; capturing at the
3770
+ // window and stopping propagation keeps them from ever seeing it.
3370
3771
  const handleKeyDown = event => {
3371
- if (!isSearchShortcut(event)) return;
3372
- // Rich text editors like Trix bind ⌘K to "insert link"; capturing at
3373
- // the window and stopping propagation keeps them from ever seeing it.
3374
- event.preventDefault();
3375
- event.stopPropagation();
3376
- setOpen(wasOpen => !wasOpen);
3772
+ if (isSearchShortcut(event)) {
3773
+ event.preventDefault();
3774
+ event.stopPropagation();
3775
+ setOpen(wasOpen => !wasOpen);
3776
+ } else if (isLegacySearchShortcut(event)) {
3777
+ event.preventDefault();
3778
+ event.stopPropagation();
3779
+ setOpen(true);
3780
+ }
3377
3781
  };
3378
3782
  window.addEventListener("keydown", handleKeyDown, {
3379
3783
  capture: true
@@ -3383,7 +3787,7 @@ true;
3383
3787
  });
3384
3788
  }, []);
3385
3789
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("button", {
3386
- "aria-keyshortcuts": isMac() ? "Meta+K" : "Control+K",
3790
+ "aria-keyshortcuts": `${isMac() ? "Meta+K" : "Control+K"} /`,
3387
3791
  "aria-label": "Search",
3388
3792
  onClick: () => setOpen(true),
3389
3793
  type: "button"
@@ -3394,21 +3798,357 @@ true;
3394
3798
  dismissible: true,
3395
3799
  onClose: () => setOpen(false),
3396
3800
  open: open
3397
- }, /*#__PURE__*/React.createElement(SearchPanel, null)));
3801
+ }, /*#__PURE__*/React.createElement(SearchPanel, {
3802
+ products: products
3803
+ })));
3398
3804
  }
3399
- function SearchPanel() {
3805
+ function SearchPanel({
3806
+ products
3807
+ }) {
3808
+ var _a, _b;
3809
+ const listboxId = useInstanceId("product-search-results");
3400
3810
  const [term, setTerm] = React.useState("");
3811
+ const [selectedSlug, setSelectedSlug] = React.useState(() => initialProduct(products).slug);
3812
+ const product = (_a = products.find(({
3813
+ slug
3814
+ }) => slug === selectedSlug)) !== null && _a !== void 0 ? _a : products[0];
3815
+ const hasTerm = term.trim() !== "";
3816
+ const search = useProductSearch(product.slug, term);
3817
+ const groups = "groups" in search ? search.groups : undefined;
3818
+ const results = React.useMemo(() => {
3819
+ var _a;
3820
+ return (_a = groups === null || groups === void 0 ? void 0 : groups.flatMap(group => group.results)) !== null && _a !== void 0 ? _a : [];
3821
+ }, [groups]);
3822
+ const navigation = useResultNavigation(results);
3823
+ const {
3824
+ highlightedIndex
3825
+ } = navigation;
3826
+ const inputRef = React.useRef(null);
3827
+ const [announceHighlight, setAnnounceHighlight] = React.useState(false);
3828
+ // The tabs keep focus while arrowing through results, but only the focused
3829
+ // input can point screen readers at the highlight, so from the tabs it's
3830
+ // announced instead. Typing goes back to the input, keystroke and all.
3831
+ const handleTabsKeyDown = event => {
3832
+ var _a;
3833
+ if (isTypingKey(event)) {
3834
+ (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
3835
+ } else if (verticalStep(event.key)) {
3836
+ setAnnounceHighlight(true);
3837
+ navigation.handleKeyDown(event);
3838
+ } else {
3839
+ navigation.handleKeyDown(event);
3840
+ }
3841
+ };
3842
+ React.useEffect(() => {
3843
+ if (hasTerm) saveLastProduct(product.slug);
3844
+ }, [hasTerm, product.slug]);
3401
3845
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("input", {
3846
+ "aria-activedescendant": highlightedIndex === null ? undefined : resultId(listboxId, highlightedIndex),
3847
+ "aria-autocomplete": "list",
3848
+ "aria-controls": results.length > 0 ? listboxId : undefined,
3849
+ "aria-expanded": results.length > 0,
3402
3850
  "aria-label": "Search",
3403
3851
  autoFocus: true,
3404
3852
  onChange: event => setTerm(event.target.value),
3853
+ onFocus: () => setAnnounceHighlight(false),
3854
+ onKeyDown: navigation.handleKeyDown,
3405
3855
  placeholder: "Search",
3856
+ ref: inputRef,
3857
+ role: "combobox",
3406
3858
  type: "search",
3407
3859
  value: term
3408
- }), /*#__PURE__*/React.createElement(SearchResults, {
3860
+ }), /*#__PURE__*/React.createElement(ProductTabs, {
3861
+ onKeyDown: handleTabsKeyDown,
3862
+ onSelect: setSelectedSlug,
3863
+ products: products,
3864
+ selected: product
3865
+ }, /*#__PURE__*/React.createElement(SearchResults, {
3866
+ highlightedIndex: highlightedIndex,
3867
+ id: listboxId,
3868
+ label: `${product.appName} results`,
3869
+ onHighlight: navigation.highlightIndex,
3870
+ search: search,
3409
3871
  term: term
3872
+ })), /*#__PURE__*/React.createElement("div", {
3873
+ "aria-live": "polite",
3874
+ className: styles$c.visuallyHidden
3875
+ }, announceHighlight ? (_b = navigation.highlighted) === null || _b === void 0 ? void 0 : _b.attributes.title : null), /*#__PURE__*/React.createElement(KeyboardHints, {
3876
+ copied: navigation.copied
3410
3877
  }));
3411
3878
  }
3879
+ function KeyboardHints({
3880
+ copied
3881
+ }) {
3882
+ const command = commandKeyLabel();
3883
+ return /*#__PURE__*/React.createElement("p", null, "\u2191\u2193 Select \u00B7 \u21B5 Open \u00B7 ", command, "\u21B5 Open in new tab \u00B7 ", command, "C", " ", /*#__PURE__*/React.createElement("span", {
3884
+ role: "status"
3885
+ }, copied ? "Copied!" : "Copy link"), " \u00B7 ", command, "K Open search");
3886
+ }
3887
+ function initialProduct(products) {
3888
+ var _a, _b, _c;
3889
+ const lastProduct = readLastProduct();
3890
+ const bySlug = slug => products.find(product => product.slug === slug);
3891
+ return (_c = (_b = (_a = bySlug(lastProduct)) !== null && _a !== void 0 ? _a : bySlug("people")) !== null && _b !== void 0 ? _b : bySlug("services")) !== null && _c !== void 0 ? _c : products[0];
3892
+ }
3893
+
3894
+ function parseValue(element) {
3895
+ const datatype = element.getAttribute("datatype");
3896
+ switch (datatype) {
3897
+ case "boolean":
3898
+ return element.content === "true" || element.content === "1";
3899
+ default:
3900
+ return element.content;
3901
+ }
3902
+ }
3903
+ function getAllPcoMeta() {
3904
+ const elements = document.head.querySelectorAll(`meta[property^="pco:"]`);
3905
+ const meta = {};
3906
+ elements.forEach(e => {
3907
+ const property = e.getAttribute("property");
3908
+ meta[property.substring("pco:".length)] = parseValue(e);
3909
+ });
3910
+ return meta;
3911
+ }
3912
+ // usePcoMeta only reads each tag's content. Recently Visited also needs the
3913
+ // pco:recently_visitable tag's `metadata` attribute, which can change while
3914
+ // the content stays the same, so it is tracked in its own store.
3915
+ function getRecentlyVisitableMetadata() {
3916
+ var _a, _b;
3917
+ return (_b = (_a = document.head.querySelector(`meta[property="pco:recently_visitable"]`)) === null || _a === void 0 ? void 0 : _a.getAttribute("metadata")) !== null && _b !== void 0 ? _b : null;
3918
+ }
3919
+ const store = createStore(getAllPcoMeta());
3920
+ const recentlyVisitableMetadataStore = createStore({
3921
+ metadata: getRecentlyVisitableMetadata()
3922
+ });
3923
+ function sync() {
3924
+ const nextState = getAllPcoMeta();
3925
+ if (!shallowCompare(store.getState(), nextState)) store.setState(nextState);
3926
+ const metadata = getRecentlyVisitableMetadata();
3927
+ if (recentlyVisitableMetadataStore.getState().metadata !== metadata) {
3928
+ recentlyVisitableMetadataStore.setState({
3929
+ metadata
3930
+ });
3931
+ }
3932
+ }
3933
+ const observer = new MutationObserver(sync);
3934
+ let hasTriggeredObserve = false;
3935
+ function observe() {
3936
+ if (hasTriggeredObserve) return;
3937
+ hasTriggeredObserve = true;
3938
+ // The module-load snapshot above can predate meta tags that appear later
3939
+ // in <head> (e.g. pco:resource, yielded right before </head>) if this
3940
+ // module's own script tag isn't deferred and runs mid-parse. Those tags
3941
+ // never mutate afterward, so the observer alone would never catch them.
3942
+ // Re-read once now, since observe() is only called once a consumer is
3943
+ // actually mounting, which is always after the document has fully parsed.
3944
+ sync();
3945
+ observer.observe(document.head, {
3946
+ attributes: true,
3947
+ childList: true,
3948
+ subtree: true
3949
+ });
3950
+ }
3951
+ function usePcoMeta() {
3952
+ observe();
3953
+ return shim.useSyncExternalStore(store.subscribe, () => store.getState());
3954
+ }
3955
+ function useRecentlyVisitableMetadata() {
3956
+ observe();
3957
+ return shim.useSyncExternalStore(recentlyVisitableMetadataStore.subscribe, () => recentlyVisitableMetadataStore.getState().metadata);
3958
+ }
3959
+
3960
+ const MAX_ENTRIES = 20;
3961
+ const FORMAT_VERSION = 1;
3962
+ const MAX_TEXT_LENGTH = 200;
3963
+ const PRODUCTS = ["accounts", "calendar", "check-ins", "giving", "groups", "home", "login", "people", "publishing", "registrations", "services"];
3964
+ function isId(value) {
3965
+ return value !== "" && Array.from(value).every(character => character >= "0" && character <= "9");
3966
+ }
3967
+ // A PCRL looks like `<product>/v2/<resource_type>/<id>`.
3968
+ function isPcrl(value) {
3969
+ const segments = value.split("/");
3970
+ return segments.length === 4 && PRODUCTS.some(product => product === segments[0]) && isId(segments[3]);
3971
+ }
3972
+ function isNonBlankString(value) {
3973
+ return typeof value === "string" && value.trim() !== "";
3974
+ }
3975
+ function isRecord(value) {
3976
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3977
+ }
3978
+ function entryKey(entry) {
3979
+ if (entry.type === "resource") {
3980
+ return `resource:${entry.pcrl}`;
3981
+ } else {
3982
+ return `page:${entry.product}:${entry.path}`;
3983
+ }
3984
+ }
3985
+ function isEntry(value) {
3986
+ if (!isRecord(value)) {
3987
+ return false;
3988
+ } else if (value.type === "resource") {
3989
+ return typeof value.pcrl === "string" && isPcrl(value.pcrl);
3990
+ } else if (value.type === "page") {
3991
+ return isNonBlankString(value.product) && isNonBlankString(value.title) && isNonBlankString(value.path) && typeof value.details === "string";
3992
+ } else {
3993
+ return false;
3994
+ }
3995
+ }
3996
+ // Cuts on code points so a surrogate pair is never split.
3997
+ function clampText(value) {
3998
+ return Array.from(value).slice(0, MAX_TEXT_LENGTH).join("");
3999
+ }
4000
+ function clampEntry(entry) {
4001
+ if (entry.type === "resource") {
4002
+ return entry;
4003
+ } else {
4004
+ return {
4005
+ ...entry,
4006
+ details: clampText(entry.details),
4007
+ title: clampText(entry.title)
4008
+ };
4009
+ }
4010
+ }
4011
+ // Recents are disposable: anything unexpected reads as an empty list (or, for
4012
+ // a single bad entry, a list without it) rather than throwing.
4013
+ function parseRecentlyVisited(value, personId) {
4014
+ let parsed = null;
4015
+ try {
4016
+ parsed = JSON.parse(value !== null && value !== void 0 ? value : "null");
4017
+ } catch {
4018
+ parsed = null;
4019
+ }
4020
+ if (isRecord(parsed) && parsed.v === FORMAT_VERSION && parsed.person === personId && Array.isArray(parsed.entries)) {
4021
+ return parsed.entries.filter(isEntry);
4022
+ } else {
4023
+ return [];
4024
+ }
4025
+ }
4026
+ function addVisit(entries, visit) {
4027
+ const key = entryKey(visit);
4028
+ return [clampEntry(visit), ...entries.filter(entry => entryKey(entry) !== key)].slice(0, MAX_ENTRIES);
4029
+ }
4030
+ function serializeRecentlyVisited(entries, personId) {
4031
+ return JSON.stringify({
4032
+ entries,
4033
+ person: personId,
4034
+ v: FORMAT_VERSION
4035
+ });
4036
+ }
4037
+
4038
+ // The product whose URL, in this environment, has the current page's host.
4039
+ // Reversing pcoUrl handles every environment @planningcenter/url knows about,
4040
+ // and guarantees the stored product rebuilds a working link.
4041
+ function currentProduct() {
4042
+ var _a;
4043
+ return (_a = PRODUCTS.find(product => new URL(pcoUrl$1.pcoUrl(product)).hostname === window.location.hostname)) !== null && _a !== void 0 ? _a : null;
4044
+ }
4045
+ function detailsFromMetadata(metadata) {
4046
+ var _a;
4047
+ try {
4048
+ const details = (_a = JSON.parse(metadata !== null && metadata !== void 0 ? metadata : "{}")) === null || _a === void 0 ? void 0 : _a.details;
4049
+ return typeof details === "string" ? details : "";
4050
+ } catch {
4051
+ return "";
4052
+ }
4053
+ }
4054
+ // pco:resource is the more specific signal, so it wins when both tags are
4055
+ // present. A resource that isn't a PCRL is ignored rather than recorded.
4056
+ function visitFromPage({
4057
+ metadata,
4058
+ resource,
4059
+ title
4060
+ }) {
4061
+ const product = currentProduct();
4062
+ if (resource && isPcrl(resource)) {
4063
+ return {
4064
+ pcrl: resource,
4065
+ type: "resource"
4066
+ };
4067
+ } else if (title && title.trim() !== "" && product) {
4068
+ return {
4069
+ details: detailsFromMetadata(metadata),
4070
+ path: window.location.pathname,
4071
+ product,
4072
+ title,
4073
+ type: "page"
4074
+ };
4075
+ } else {
4076
+ return null;
4077
+ }
4078
+ }
4079
+ // The entry Recently Visited would record for the current page, or null when
4080
+ // the page isn't eligible. Shared by the recorder and the debug panel so the
4081
+ // panel always shows what actually gets stored.
4082
+ function useCurrentVisit() {
4083
+ const {
4084
+ recently_visitable: title,
4085
+ resource
4086
+ } = usePcoMeta();
4087
+ const metadata = useRecentlyVisitableMetadata();
4088
+ return React.useMemo(() => visitFromPage({
4089
+ metadata,
4090
+ resource,
4091
+ title
4092
+ }), [metadata, resource, title]);
4093
+ }
4094
+ // Products hand the person's ID to AppsProvider, not to the topbar itself,
4095
+ // and several pass it as a number.
4096
+ function useCurrentPersonId() {
4097
+ const {
4098
+ userId
4099
+ } = useStore();
4100
+ return userId == null ? undefined : String(userId);
4101
+ }
4102
+
4103
+ // Per-origin localStorage is a stopgap: each product keeps its own list until
4104
+ // PCA-409 moves this to storage shared across product subdomains. Only this
4105
+ // file should need to change when it does.
4106
+ function storageKey({
4107
+ env,
4108
+ personId
4109
+ }) {
4110
+ return `topbar.recently_visited.${env}.${personId}`;
4111
+ }
4112
+ // localStorage doesn't notify the tab that wrote it, so readers in this tab
4113
+ // learn about new visits from this counter instead.
4114
+ const changes = createStore({
4115
+ version: 0
4116
+ });
4117
+ function readRecentlyVisited(scope) {
4118
+ try {
4119
+ return parseRecentlyVisited(localStorage.getItem(storageKey(scope)), scope.personId);
4120
+ } catch {
4121
+ return [];
4122
+ }
4123
+ }
4124
+ function recordVisit(visit, scope) {
4125
+ if (isEntry(visit)) {
4126
+ try {
4127
+ localStorage.setItem(storageKey(scope), serializeRecentlyVisited(addVisit(readRecentlyVisited(scope), visit), scope.personId));
4128
+ changes.setState({
4129
+ version: changes.getState().version + 1
4130
+ });
4131
+ } catch {
4132
+ // Storage can be full or blocked; recents are disposable.
4133
+ }
4134
+ }
4135
+ }
4136
+
4137
+ function useRecordRecentlyVisited({
4138
+ enabled,
4139
+ env
4140
+ }) {
4141
+ const personId = useCurrentPersonId();
4142
+ const visit = useCurrentVisit();
4143
+ React.useEffect(() => {
4144
+ if (enabled && personId && visit) {
4145
+ recordVisit(visit, {
4146
+ env,
4147
+ personId
4148
+ });
4149
+ }
4150
+ }, [enabled, env, personId, visit]);
4151
+ }
3412
4152
 
3413
4153
  function StyledRoot({
3414
4154
  children,
@@ -3638,6 +4378,10 @@ true;
3638
4378
  const updateStore = useUpdateStore();
3639
4379
  const useRedesignedSwitcher = useFeature(FEATURE_FLAGS.APP_SWITCHER_REDESIGN, props.features);
3640
4380
  const productSearchEnabled = useFeature(FEATURE_FLAGS.PRODUCT_SEARCH, props.features);
4381
+ useRecordRecentlyVisited({
4382
+ enabled: productSearchEnabled,
4383
+ env: props.env
4384
+ });
3641
4385
  React.useEffect(() => {
3642
4386
  const handleToolbarClick = event => {
3643
4387
  const target = event.target;
@@ -3678,7 +4422,9 @@ true;
3678
4422
  style: {
3679
4423
  margin: "auto"
3680
4424
  }
3681
- }), !notificationsEnabled && notifications(), productSearchEnabled ? (/*#__PURE__*/React.createElement(ProductSearch, null)) : search({
4425
+ }), !notificationsEnabled && notifications(), productSearchEnabled ? (/*#__PURE__*/React.createElement(ProductSearch, {
4426
+ apps: props.apps
4427
+ })) : search({
3682
4428
  hideRoutes: () => setRoutesVisible(false),
3683
4429
  showRoutes: () => setRoutesVisible(true)
3684
4430
  }), /*#__PURE__*/React.createElement(UserMenuTrigger, {
@@ -31754,6 +32500,11 @@ true;
31754
32500
  const [routesMenuVisible, setRoutesMenuVisible] = React.useState(false);
31755
32501
  const [productSwitcherVisible, setProductSwitcherVisible] = React.useState(false);
31756
32502
  const useRedesignedSwitcher = useFeature(FEATURE_FLAGS.APP_SWITCHER_REDESIGN, props.features);
32503
+ const productSearchEnabled = useFeature(FEATURE_FLAGS.PRODUCT_SEARCH, props.features);
32504
+ useRecordRecentlyVisited({
32505
+ enabled: productSearchEnabled,
32506
+ env: props.env
32507
+ });
31757
32508
  const activeRoute = props.routes.filter(({
31758
32509
  props: {
31759
32510
  active
@@ -31962,55 +32713,6 @@ true;
31962
32713
  id: "topbar"
31963
32714
  };
31964
32715
 
31965
- function parseValue(element) {
31966
- const datatype = element.getAttribute("datatype");
31967
- switch (datatype) {
31968
- case "boolean":
31969
- return element.content === "true" || element.content === "1";
31970
- default:
31971
- return element.content;
31972
- }
31973
- }
31974
- function getAllPcoMeta() {
31975
- const elements = document.head.querySelectorAll(`meta[property^="pco:"]`);
31976
- const meta = {};
31977
- elements.forEach(e => {
31978
- const property = e.getAttribute("property");
31979
- meta[property.substring("pco:".length)] = parseValue(e);
31980
- });
31981
- return meta;
31982
- }
31983
- const store = createStore(getAllPcoMeta());
31984
- const observer = new MutationObserver(() => {
31985
- const prevState = store.getState();
31986
- const nextState = getAllPcoMeta();
31987
- if (!shallowCompare(prevState, nextState)) store.setState(nextState);
31988
- });
31989
- let hasTriggeredObserve = false;
31990
- function observe() {
31991
- if (hasTriggeredObserve) return;
31992
- hasTriggeredObserve = true;
31993
- // The module-load snapshot above can predate meta tags that appear later
31994
- // in <head> (e.g. pco:resource, yielded right before </head>) if this
31995
- // module's own script tag isn't deferred and runs mid-parse. Those tags
31996
- // never mutate afterward, so the observer alone would never catch them.
31997
- // Re-read once now, since observe() is only called once a consumer is
31998
- // actually mounting, which is always after the document has fully parsed.
31999
- const freshState = getAllPcoMeta();
32000
- if (!shallowCompare(store.getState(), freshState)) {
32001
- store.setState(freshState);
32002
- }
32003
- observer.observe(document.head, {
32004
- attributes: true,
32005
- childList: true,
32006
- subtree: true
32007
- });
32008
- }
32009
- function usePcoMeta() {
32010
- observe();
32011
- return shim.useSyncExternalStore(store.subscribe, () => store.getState());
32012
- }
32013
-
32014
32716
  var styles$4 = {"debug-pco-resource":"debug-pco-resource-sLteM","debug-pco-resource-header":"debug-pco-resource-header-5fEbe","debug-pco-resource-badge":"debug-pco-resource-badge-J9v1F","debug-pco-resource-title":"debug-pco-resource-title-volWV","debug-pco-resource-field":"debug-pco-resource-field-7Gitp","debug-pco-resource-field-label":"debug-pco-resource-field-label--f1xX","debug-pco-resource-field-value":"debug-pco-resource-field-value-YoX-C","debug-pco-resource-note":"debug-pco-resource-note-N5xyf","debug-pco-resource-json":"debug-pco-resource-json-Rx-JI"};
32015
32717
 
32016
32718
  function isPresent(value) {