@planningcenter/topbar 12.3.0-qa-899.0 → 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.
package/dist/topbar.js CHANGED
@@ -41,7 +41,7 @@ var churchCenter__namespace = /*#__PURE__*/_interopNamespaceDefault(churchCenter
41
41
  var people__namespace = /*#__PURE__*/_interopNamespaceDefault(people);
42
42
  var toolbar__namespace = /*#__PURE__*/_interopNamespaceDefault(toolbar);
43
43
 
44
- var styles$i = {"accessibility-toolbar":"accessibility-toolbar-jgjUG"};
44
+ var styles$l = {"accessibility-toolbar":"accessibility-toolbar-jgjUG"};
45
45
 
46
46
  const AccessibilityToolbar = ({
47
47
  children
@@ -53,7 +53,7 @@ const AccessibilityToolbar = ({
53
53
  (_a = document.getElementById("main-content")) === null || _a === void 0 ? void 0 : _a.focus();
54
54
  };
55
55
  return /*#__PURE__*/React.createElement("div", {
56
- className: styles$i["accessibility-toolbar"]
56
+ className: styles$l["accessibility-toolbar"]
57
57
  }, /*#__PURE__*/React.createElement(tapestry.Link, {
58
58
  href: "#main-content",
59
59
  kind: "primary",
@@ -370,11 +370,11 @@ const X = ({
370
370
  fill: fill
371
371
  })));
372
372
 
373
- 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"};
373
+ 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"};
374
374
 
375
375
  function AnnouncementIcon() {
376
376
  return /*#__PURE__*/React.createElement("svg", {
377
- className: styles$h["announcement-icon"],
377
+ className: styles$k["announcement-icon"],
378
378
  width: "16",
379
379
  height: "16",
380
380
  viewBox: "0 0 16 16",
@@ -499,7 +499,7 @@ let StyledAnnouncement$1 = class StyledAnnouncement extends React.Component {
499
499
  } = this.props;
500
500
  const isPromo = variant === "promo";
501
501
  return /*#__PURE__*/React.createElement("div", {
502
- className: classnames(styles$h["announcement"], styles$h[`announcement--${variant !== null && variant !== void 0 ? variant : "platform"}`]),
502
+ className: classnames(styles$k["announcement"], styles$k[`announcement--${variant !== null && variant !== void 0 ? variant : "platform"}`]),
503
503
  "data-announcement-variant": variant !== null && variant !== void 0 ? variant : "platform",
504
504
  style: isPromo ? {
505
505
  backgroundImage: promoBackgroundUrl,
@@ -508,7 +508,7 @@ let StyledAnnouncement$1 = class StyledAnnouncement extends React.Component {
508
508
  backgroundSize: "cover"
509
509
  } : undefined
510
510
  }, !isPromo && /*#__PURE__*/React.createElement(AnnouncementIcon, null), /*#__PURE__*/React.createElement("div", {
511
- className: styles$h["announcement-content"],
511
+ className: styles$k["announcement-content"],
512
512
  style: style,
513
513
  ...platformProps
514
514
  }));
@@ -557,7 +557,7 @@ function StyledDismissButton$1({
557
557
  const isPromo = variant === "promo";
558
558
  return /*#__PURE__*/React.createElement("button", {
559
559
  type: "button",
560
- className: classnames(styles$h.dismiss, isPromo && styles$h["dismiss--promo"]),
560
+ className: classnames(styles$k.dismiss, isPromo && styles$k["dismiss--promo"]),
561
561
  ...props
562
562
  }, /*#__PURE__*/React.createElement(X, {
563
563
  fill: isPromo ? "currentColor" : "white",
@@ -637,9 +637,9 @@ class StyledAnnouncement extends React.Component {
637
637
  ...platformProps
638
638
  } = this.props;
639
639
  return /*#__PURE__*/React.createElement("div", {
640
- className: styles$h["announcement"]
640
+ className: styles$k["announcement"]
641
641
  }, /*#__PURE__*/React.createElement(AnnouncementIcon, null), /*#__PURE__*/React.createElement("div", {
642
- className: styles$h["announcement-content"],
642
+ className: styles$k["announcement-content"],
643
643
  style: style,
644
644
  ...platformProps
645
645
  }));
@@ -650,7 +650,7 @@ function StyledDismissButton({
650
650
  }) {
651
651
  return /*#__PURE__*/React.createElement("button", {
652
652
  type: "button",
653
- className: styles$h.dismiss,
653
+ className: styles$k.dismiss,
654
654
  ...props
655
655
  }, /*#__PURE__*/React.createElement(X, {
656
656
  fill: "white",
@@ -1772,7 +1772,7 @@ function MonoAppIcon({
1772
1772
  })) : (/*#__PURE__*/React.createElement("div", null, "appName not supported")));
1773
1773
  }
1774
1774
 
1775
- var styles$g = {"icon-slot":"icon-slot-m3ZIk","name":"name-UNqbW"};
1775
+ var styles$j = {"icon-slot":"icon-slot-m3ZIk","name":"name-UNqbW"};
1776
1776
 
1777
1777
  const AppRow = ({
1778
1778
  appName,
@@ -1781,24 +1781,24 @@ const AppRow = ({
1781
1781
  }) => {
1782
1782
  if (appName === "Accounts") {
1783
1783
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", {
1784
- className: styles$g["icon-slot"]
1784
+ className: styles$j["icon-slot"]
1785
1785
  }, /*#__PURE__*/React.createElement(MonoAppIcon, {
1786
1786
  appName: "Accounts",
1787
1787
  colors: colors,
1788
1788
  size: iconSize
1789
1789
  })), /*#__PURE__*/React.createElement("span", {
1790
- className: styles$g.name
1790
+ className: styles$j.name
1791
1791
  }, "Account settings"));
1792
1792
  }
1793
1793
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(ColorAppIcon, {
1794
1794
  appName: appName.replace(/[\s-]/, ""),
1795
1795
  size: iconSize
1796
1796
  }), /*#__PURE__*/React.createElement("span", {
1797
- className: styles$g.name
1797
+ className: styles$j.name
1798
1798
  }, appName));
1799
1799
  };
1800
1800
 
1801
- var styles$f = {"button":"button-XMbeQ","label":"label-884of","chevron":"chevron-RRg9k"};
1801
+ var styles$i = {"button":"button-XMbeQ","label":"label-884of","chevron":"chevron-RRg9k"};
1802
1802
 
1803
1803
  const AppsButton = /*#__PURE__*/React.forwardRef(({
1804
1804
  appName,
@@ -1810,10 +1810,10 @@ const AppsButton = /*#__PURE__*/React.forwardRef(({
1810
1810
  ...restProps,
1811
1811
  ref: ref,
1812
1812
  "aria-label": `App switcher: ${appName}`,
1813
- className: styles$f.button,
1813
+ className: styles$i.button,
1814
1814
  kind: "neutral-inline",
1815
1815
  label: /*#__PURE__*/React.createElement("span", {
1816
- className: styles$f.label
1816
+ className: styles$i.label
1817
1817
  }, isAccounts ? "Account settings" : appName),
1818
1818
  prefix: isAccounts ? (/*#__PURE__*/React.createElement(MonoAppIcon, {
1819
1819
  appName: "Accounts",
@@ -1824,13 +1824,13 @@ const AppsButton = /*#__PURE__*/React.forwardRef(({
1824
1824
  size: 22
1825
1825
  })),
1826
1826
  suffix: /*#__PURE__*/React.createElement(DisclosureChevronIcon, {
1827
- className: `${styles$f.chevron} symbol`
1827
+ className: `${styles$i.chevron} symbol`
1828
1828
  })
1829
1829
  });
1830
1830
  });
1831
1831
  AppsButton.displayName = "AppsButton";
1832
1832
 
1833
- 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"};
1833
+ 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"};
1834
1834
 
1835
1835
  const AppsMenu = ({
1836
1836
  appName,
@@ -1851,8 +1851,8 @@ const AppsMenu = ({
1851
1851
  }, /*#__PURE__*/React.createElement("a", {
1852
1852
  "aria-current": isCurrent ? "page" : undefined,
1853
1853
  "aria-label": name,
1854
- className: classnames(styles$e.link, {
1855
- [styles$e.current]: isCurrent
1854
+ className: classnames(styles$h.link, {
1855
+ [styles$h.current]: isCurrent
1856
1856
  }),
1857
1857
  "data-pendo": `app-switcher--change-app--${name}`,
1858
1858
  "data-turbolinks": false,
@@ -1868,22 +1868,22 @@ const AppsMenu = ({
1868
1868
  colors: colors
1869
1869
  })), /*#__PURE__*/React.createElement(tapestry.PopoverContent, {
1870
1870
  "aria-label": "Switch app",
1871
- className: styles$e["popover-content"],
1871
+ className: styles$h["popover-content"],
1872
1872
  placement: "bottom start"
1873
1873
  }, /*#__PURE__*/React.createElement("nav", {
1874
- className: styles$e.nav,
1874
+ className: styles$h.nav,
1875
1875
  "data-pendo": "app-switcher"
1876
1876
  }, /*#__PURE__*/React.createElement("ul", {
1877
- className: styles$e.list,
1877
+ className: styles$h.list,
1878
1878
  role: "list"
1879
1879
  }, homeProduct && renderProductLink(homeProduct.name), restProducts.map(({
1880
1880
  name
1881
1881
  }) => renderProductLink(name)), accountsProduct && (/*#__PURE__*/React.createElement("li", {
1882
- className: styles$e["accounts-item"]
1882
+ className: styles$h["accounts-item"]
1883
1883
  }, /*#__PURE__*/React.createElement("a", {
1884
1884
  "aria-current": appName === accountsProduct.name ? "page" : undefined,
1885
1885
  "aria-label": "Account settings",
1886
- className: classnames(styles$e.link, styles$e["accounts-link"]),
1886
+ className: classnames(styles$h.link, styles$h["accounts-link"]),
1887
1887
  "data-pendo": "app-switcher--change-app--Accounts",
1888
1888
  "data-turbolinks": false,
1889
1889
  href: pcoUrl$1(env)("accounts") + getAppsSwitchProductPath(accountsProduct.name)
@@ -2091,7 +2091,7 @@ function MonoAppName({
2091
2091
  })) : (/*#__PURE__*/React.createElement("div", null, "appName not supported"));
2092
2092
  }
2093
2093
 
2094
- var styles$d = {"unbutton":"unbutton-lEbTR"};
2094
+ var styles$g = {"unbutton":"unbutton-lEbTR"};
2095
2095
 
2096
2096
  function Unbutton({
2097
2097
  className,
@@ -2100,7 +2100,7 @@ function Unbutton({
2100
2100
  ...nativeProps
2101
2101
  }) {
2102
2102
  return /*#__PURE__*/React.createElement("button", {
2103
- className: classnames(styles$d.unbutton, className),
2103
+ className: classnames(styles$g.unbutton, className),
2104
2104
  style: {
2105
2105
  ...style
2106
2106
  },
@@ -2109,7 +2109,7 @@ function Unbutton({
2109
2109
  });
2110
2110
  }
2111
2111
 
2112
- var styles$c = {"button":"button-wsipc","button-accounts":"button-accounts-zQmBM","chevron":"chevron-obyBF","is-expanded":"is-expanded-PoCQ9"};
2112
+ var styles$f = {"button":"button-wsipc","button-accounts":"button-accounts-zQmBM","chevron":"chevron-obyBF","is-expanded":"is-expanded-PoCQ9"};
2113
2113
 
2114
2114
  const LegacyAppsButton = ({
2115
2115
  appName,
@@ -2124,8 +2124,8 @@ const LegacyAppsButton = ({
2124
2124
  "aria-haspopup": "true",
2125
2125
  "aria-label": `App switcher: ${appName}`,
2126
2126
  onClick: onClick,
2127
- className: classnames(styles$c.button, {
2128
- [styles$c["is-expanded"]]: expanded
2127
+ className: classnames(styles$f.button, {
2128
+ [styles$f["is-expanded"]]: expanded
2129
2129
  }),
2130
2130
  type: "button",
2131
2131
  style: style,
@@ -2134,7 +2134,7 @@ const LegacyAppsButton = ({
2134
2134
  appName: "Accounts",
2135
2135
  colors: colors
2136
2136
  }), /*#__PURE__*/React.createElement("div", {
2137
- className: styles$c["button-accounts"]
2137
+ className: styles$f["button-accounts"]
2138
2138
  }, "Account settings"))) : (/*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(ColorAppIcon, {
2139
2139
  appName: appName.replace(/[\s-]/, "")
2140
2140
  }), /*#__PURE__*/React.createElement(MonoAppName, {
@@ -2142,7 +2142,7 @@ const LegacyAppsButton = ({
2142
2142
  key: "mono-app-name",
2143
2143
  size: 24
2144
2144
  }))), /*#__PURE__*/React.createElement(DisclosureChevronIcon, {
2145
- className: styles$c.chevron,
2145
+ className: styles$f.chevron,
2146
2146
  key: "disclosure-chevron-icon"
2147
2147
  }));
2148
2148
  };
@@ -2275,7 +2275,7 @@ class Outsider extends React.Component {
2275
2275
  }
2276
2276
  }
2277
2277
 
2278
- var styles$b = {"popup-root":"popup-root-SeL5u","popup":"popup-nQXYW","is-visible":"is-visible-3DAUP"};
2278
+ var styles$e = {"popup-root":"popup-root-SeL5u","popup":"popup-nQXYW","is-visible":"is-visible-3DAUP"};
2279
2279
 
2280
2280
  const Popup = ({
2281
2281
  className,
@@ -2283,8 +2283,8 @@ const Popup = ({
2283
2283
  isVisible,
2284
2284
  ...nativeProps
2285
2285
  }) => (/*#__PURE__*/React.createElement(C, {
2286
- className: classnames(styles$b.popup, {
2287
- [styles$b["is-visible"]]: isVisible
2286
+ className: classnames(styles$e.popup, {
2287
+ [styles$e["is-visible"]]: isVisible
2288
2288
  }, className),
2289
2289
  ...nativeProps
2290
2290
  }));
@@ -2292,11 +2292,11 @@ const Popup = ({
2292
2292
  const PopupRoot = ({
2293
2293
  ...nativeProps
2294
2294
  }) => (/*#__PURE__*/React.createElement("div", {
2295
- className: styles$b["popup-root"],
2295
+ className: styles$e["popup-root"],
2296
2296
  ...nativeProps
2297
2297
  }));
2298
2298
 
2299
- var styles$a = {"popup":"popup-bVhSa"};
2299
+ var styles$d = {"popup":"popup-bVhSa"};
2300
2300
 
2301
2301
  const LegacyAppsMenu = ({
2302
2302
  appName,
@@ -2334,7 +2334,7 @@ const LegacyAppsMenu = ({
2334
2334
  cleanup: !visible,
2335
2335
  component: Outsider,
2336
2336
  onOutsideClick: toggle,
2337
- className: styles$a.popup,
2337
+ className: styles$d.popup,
2338
2338
  isVisible: visible
2339
2339
  }, /*#__PURE__*/React.createElement("menu", {
2340
2340
  ref: menuRef,
@@ -3244,7 +3244,97 @@ const useUpdateStore = () => React.useCallback(updates => {
3244
3244
  });
3245
3245
  }, []);
3246
3246
 
3247
+ const isMac = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform);
3248
+ const hasCommandKey = event => isMac() ? event.metaKey : event.ctrlKey;
3249
+ const commandKeyLabel = () => isMac() ? "⌘" : "Ctrl+";
3250
+
3251
+ const STORAGE_KEY = "topbar.product_search.last_product";
3252
+ function readLastProduct() {
3253
+ try {
3254
+ return localStorage.getItem(STORAGE_KEY);
3255
+ } catch {
3256
+ return null;
3257
+ }
3258
+ }
3259
+ function saveLastProduct(slug) {
3260
+ try {
3261
+ localStorage.setItem(STORAGE_KEY, slug);
3262
+ } catch {
3263
+ // Storage can be full or blocked; search still works without it.
3264
+ }
3265
+ }
3266
+
3267
+ var styles$c = {"visuallyHidden":"visuallyHidden-C28ju"};
3268
+
3269
+ var styles$b = {"tab":"tab-lQLDx"};
3270
+
3271
+ // useId isn't available on React 17, which our peer range still supports.
3272
+ let instanceCount$1 = 0;
3273
+ function useInstanceId(prefix) {
3274
+ const [id] = React.useState(() => `${prefix}-${++instanceCount$1}`);
3275
+ return id;
3276
+ }
3277
+
3278
+ function ProductTabs({
3279
+ children,
3280
+ onKeyDown,
3281
+ onSelect,
3282
+ products,
3283
+ selected
3284
+ }) {
3285
+ const id = useInstanceId("product-tabs");
3286
+ const tabId = slug => `${id}-tab-${slug}`;
3287
+ const tabRefs = React.useRef(new Map());
3288
+ const handleKeyDown = event => {
3289
+ var _a;
3290
+ const offset = {
3291
+ ArrowLeft: -1,
3292
+ ArrowRight: 1
3293
+ }[event.key];
3294
+ if (offset) {
3295
+ event.preventDefault();
3296
+ const index = products.indexOf(selected);
3297
+ const next = products[(index + offset + products.length) % products.length];
3298
+ onSelect(next.slug);
3299
+ (_a = tabRefs.current.get(next.slug)) === null || _a === void 0 ? void 0 : _a.focus();
3300
+ } else {
3301
+ onKeyDown === null || onKeyDown === void 0 ? void 0 : onKeyDown(event);
3302
+ }
3303
+ };
3304
+ return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
3305
+ "aria-label": "Product",
3306
+ onKeyDown: handleKeyDown,
3307
+ role: "tablist"
3308
+ }, products.map(product => (/*#__PURE__*/React.createElement("button", {
3309
+ "aria-controls": `${id}-panel`,
3310
+ "aria-selected": product === selected,
3311
+ className: styles$b.tab,
3312
+ id: tabId(product.slug),
3313
+ key: product.slug,
3314
+ onClick: () => onSelect(product.slug),
3315
+ ref: element => {
3316
+ if (element) {
3317
+ tabRefs.current.set(product.slug, element);
3318
+ } else {
3319
+ tabRefs.current.delete(product.slug);
3320
+ }
3321
+ },
3322
+ role: "tab",
3323
+ tabIndex: product === selected ? 0 : -1,
3324
+ type: "button"
3325
+ }, product.appName)))), /*#__PURE__*/React.createElement("div", {
3326
+ "aria-labelledby": tabId(selected.slug),
3327
+ id: `${id}-panel`,
3328
+ role: "tabpanel"
3329
+ }, children));
3330
+ }
3331
+
3332
+ var styles$a = {"result":"result-WCnxr"};
3333
+
3247
3334
  function SearchResult({
3335
+ highlighted,
3336
+ id,
3337
+ onHighlight,
3248
3338
  result
3249
3339
  }) {
3250
3340
  const {
@@ -3252,62 +3342,222 @@ function SearchResult({
3252
3342
  title,
3253
3343
  url
3254
3344
  } = result.attributes;
3345
+ const ref = React.useRef(null);
3346
+ React.useEffect(() => {
3347
+ var _a;
3348
+ if (highlighted) (_a = ref.current) === null || _a === void 0 ? void 0 : _a.scrollIntoView({
3349
+ block: "nearest"
3350
+ });
3351
+ }, [highlighted]);
3352
+ // Focus stays in the search input, which points at the highlighted result
3353
+ // with aria-activedescendant, so results are kept out of the tab order.
3354
+ // Mouse move rather than enter: arrowing scrolls results under a resting
3355
+ // pointer, and that shouldn't steal the highlight.
3255
3356
  return /*#__PURE__*/React.createElement("a", {
3256
- href: url
3357
+ "aria-selected": highlighted,
3358
+ className: styles$a.result,
3359
+ href: url,
3360
+ id: id,
3361
+ onMouseMove: onHighlight,
3362
+ ref: ref,
3363
+ role: "option",
3364
+ tabIndex: -1
3257
3365
  }, /*#__PURE__*/React.createElement("div", null, title), /*#__PURE__*/React.createElement("div", null, summarize(metadata)));
3258
3366
  }
3259
3367
  function summarize(metadata) {
3260
3368
  return Object.values(metadata !== null && metadata !== void 0 ? metadata : {}).filter(value => typeof value === "string" && value !== "").join(" · ");
3261
3369
  }
3262
3370
 
3263
- const SEARCH_DEBOUNCE_MS = 300;
3264
- function useProductSearch(term) {
3265
- const query = term.trim();
3266
- const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
3267
- const [search, setSearch] = React.useState({
3268
- status: "idle"
3371
+ const resultId = (listboxId, index) => `${listboxId}-result-${index}`;
3372
+ function SearchResults({
3373
+ search,
3374
+ term,
3375
+ ...navigation
3376
+ }) {
3377
+ var _a;
3378
+ switch (search.status) {
3379
+ case "idle":
3380
+ return null;
3381
+ case "loading":
3382
+ if ((_a = search.groups) === null || _a === void 0 ? void 0 : _a.length) {
3383
+ return /*#__PURE__*/React.createElement(ResultGroups, {
3384
+ busy: true,
3385
+ groups: search.groups,
3386
+ ...navigation
3387
+ });
3388
+ }
3389
+ return /*#__PURE__*/React.createElement("p", {
3390
+ role: "status"
3391
+ }, "Searching\u2026");
3392
+ case "error":
3393
+ return /*#__PURE__*/React.createElement("p", {
3394
+ role: "alert"
3395
+ }, "Search isn\u2019t working right now. Try again.");
3396
+ case "done":
3397
+ if (search.groups.length === 0) {
3398
+ return /*#__PURE__*/React.createElement("p", {
3399
+ role: "status"
3400
+ }, "No results for \u201C", term.trim(), "\u201D");
3401
+ }
3402
+ return /*#__PURE__*/React.createElement(ResultGroups, {
3403
+ groups: search.groups,
3404
+ ...navigation
3405
+ });
3406
+ }
3407
+ }
3408
+ function ResultGroups({
3409
+ busy = false,
3410
+ groups,
3411
+ highlightedIndex,
3412
+ id,
3413
+ label,
3414
+ onHighlight
3415
+ }) {
3416
+ let index = 0;
3417
+ return /*#__PURE__*/React.createElement("div", {
3418
+ "aria-busy": busy,
3419
+ "aria-label": label,
3420
+ id: id,
3421
+ role: "listbox"
3422
+ }, groups.map(({
3423
+ results,
3424
+ type
3425
+ }, groupIndex) => {
3426
+ const headingId = `${id}-group-${groupIndex}`;
3427
+ return /*#__PURE__*/React.createElement("div", {
3428
+ "aria-labelledby": headingId,
3429
+ key: type,
3430
+ role: "group"
3431
+ }, /*#__PURE__*/React.createElement("div", {
3432
+ id: headingId
3433
+ }, type), results.map(result => {
3434
+ const resultIndex = index++;
3435
+ return /*#__PURE__*/React.createElement(SearchResult, {
3436
+ highlighted: resultIndex === highlightedIndex,
3437
+ id: resultId(id, resultIndex),
3438
+ key: result.id,
3439
+ onHighlight: () => onHighlight(resultIndex),
3440
+ result: result
3441
+ });
3442
+ }));
3443
+ }));
3444
+ }
3445
+
3446
+ const CACHE_TTL_MS = 60000;
3447
+ const MAX_ENTRIES$1 = 50;
3448
+ const cache = new Map();
3449
+ function cachedRequest(url) {
3450
+ const cached = cache.get(url);
3451
+ if (cached && cached.expiresAt > Date.now()) {
3452
+ // Re-inserting keeps the Map in recently-used order for eviction.
3453
+ cache.delete(url);
3454
+ cache.set(url, cached);
3455
+ return cached.promise;
3456
+ } else {
3457
+ return fetchIntoCache(url);
3458
+ }
3459
+ }
3460
+ function fetchIntoCache(url) {
3461
+ const promise = apiRequest(defaultFetch, url).then(({
3462
+ json,
3463
+ ok
3464
+ }) => {
3465
+ if (!ok || json === null) throw new Error(`Request failed: ${url}`);
3466
+ return json;
3269
3467
  });
3468
+ // In-flight entries never expire, so every caller shares the one fetch.
3469
+ const entry = {
3470
+ expiresAt: Infinity,
3471
+ promise
3472
+ };
3473
+ cache.set(url, entry);
3474
+ promise.then(() => entry.expiresAt = Date.now() + CACHE_TTL_MS, () => cache.get(url) === entry && cache.delete(url));
3475
+ if (cache.size > MAX_ENTRIES$1) cache.delete(cache.keys().next().value);
3476
+ return promise;
3477
+ }
3478
+ function useCachedRequest(url) {
3479
+ const [settled, setSettled] = React.useState();
3270
3480
  React.useEffect(() => {
3271
- if (!debouncedQuery) {
3272
- setSearch({
3273
- status: "idle"
3274
- });
3481
+ if (url) {
3482
+ let current = true;
3483
+ cachedRequest(url).then(data => current && setSettled({
3484
+ data,
3485
+ url
3486
+ }), () => current && setSettled({
3487
+ failed: true,
3488
+ url
3489
+ }));
3490
+ return () => {
3491
+ current = false;
3492
+ };
3493
+ } else {
3275
3494
  return undefined;
3276
3495
  }
3277
- let current = true;
3278
- setSearch({
3496
+ }, [url]);
3497
+ if (!url) {
3498
+ return {
3499
+ status: "idle"
3500
+ };
3501
+ } else if ((settled === null || settled === void 0 ? void 0 : settled.url) !== url) {
3502
+ return {
3503
+ data: settled && "data" in settled ? settled.data : undefined,
3279
3504
  status: "loading"
3280
- });
3281
- apiRequest(defaultFetch, productSearchUrl(debouncedQuery)).then(({
3282
- json,
3283
- ok
3284
- }) => {
3285
- if (!current) return;
3286
- setSearch(ok && json ? {
3287
- groups: groupByType(json),
3288
- status: "done"
3289
- } : {
3290
- status: "error"
3291
- });
3292
- });
3293
- return () => {
3294
- current = false;
3295
3505
  };
3296
- }, [debouncedQuery]);
3297
- if (!query) return {
3298
- status: "idle"
3299
- };
3300
- if (query !== debouncedQuery) return {
3301
- status: "loading"
3302
- };
3303
- return search;
3506
+ } else if ("data" in settled) {
3507
+ return {
3508
+ data: settled.data,
3509
+ status: "done"
3510
+ };
3511
+ } else {
3512
+ return {
3513
+ status: "error"
3514
+ };
3515
+ }
3516
+ }
3517
+
3518
+ const SEARCH_DEBOUNCE_MS = 300;
3519
+ function useProductSearch(product, term) {
3520
+ const query = term.trim();
3521
+ const debouncedQuery = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
3522
+ const request = useCachedRequest(debouncedQuery ? productSearchUrl(product, debouncedQuery) : null);
3523
+ const response = "data" in request ? request.data : undefined;
3524
+ const settledProduct = useSettledProduct(product, request.status);
3525
+ // While a request loads, the previous response stays on screen, but only
3526
+ // if it was for this product: People results must never show under the
3527
+ // Services tab.
3528
+ const visibleResponse = request.status === "done" || settledProduct === product ? response : undefined;
3529
+ const groups = React.useMemo(() => visibleResponse && groupByType(visibleResponse), [visibleResponse]);
3530
+ if (!query) {
3531
+ return {
3532
+ status: "idle"
3533
+ };
3534
+ } else if (query !== debouncedQuery) {
3535
+ return {
3536
+ groups,
3537
+ status: "loading"
3538
+ };
3539
+ } else if (request.status === "done") {
3540
+ return {
3541
+ groups: groups !== null && groups !== void 0 ? groups : [],
3542
+ status: "done"
3543
+ };
3544
+ } else if (request.status === "error") {
3545
+ return {
3546
+ status: "error"
3547
+ };
3548
+ } else {
3549
+ return {
3550
+ groups,
3551
+ status: "loading"
3552
+ };
3553
+ }
3304
3554
  }
3305
- function productSearchUrl(query) {
3555
+ function productSearchUrl(product, query) {
3306
3556
  const params = new URLSearchParams({
3307
3557
  per_page: "100",
3308
3558
  "where[term]": query
3309
3559
  });
3310
- return `${window.location.origin}/~api/people/v2/product_search_results?${params}`;
3560
+ return `${window.location.origin}/~api/${product}/v2/product_search_results?${params}`;
3311
3561
  }
3312
3562
  function groupByType({
3313
3563
  data,
@@ -3332,6 +3582,14 @@ function groupByType({
3332
3582
  };
3333
3583
  return [...groups.values()].sort((a, b) => rank(a.type) - rank(b.type));
3334
3584
  }
3585
+ // The product the most recent finished request was for.
3586
+ function useSettledProduct(product, status) {
3587
+ const [settledProduct, setSettledProduct] = React.useState();
3588
+ React.useEffect(() => {
3589
+ if (status === "done") setSettledProduct(product);
3590
+ }, [product, status]);
3591
+ return settledProduct;
3592
+ }
3335
3593
  function useDebouncedValue(value, delay) {
3336
3594
  const [debounced, setDebounced] = React.useState(value);
3337
3595
  React.useEffect(() => {
@@ -3341,53 +3599,199 @@ function useDebouncedValue(value, delay) {
3341
3599
  return debounced;
3342
3600
  }
3343
3601
 
3344
- function SearchResults({
3345
- term
3602
+ const COPIED_FEEDBACK_MS = 1500;
3603
+ const verticalStep = key => ({
3604
+ ArrowDown: 1,
3605
+ ArrowUp: -1
3606
+ })[key];
3607
+ function useResultNavigation(results) {
3608
+ // Remembering which results the index belongs to puts the highlight back on
3609
+ // the first result whenever new ones arrive, without an effect to reset it.
3610
+ const [highlight, setHighlight] = React.useState({
3611
+ index: 0,
3612
+ results
3613
+ });
3614
+ const highlightedIndex = highlight.results === results ? highlight.index : 0;
3615
+ const highlighted = results[highlightedIndex];
3616
+ const [copiedUrl, setCopiedUrl] = React.useState(null);
3617
+ React.useEffect(() => {
3618
+ if (copiedUrl) {
3619
+ const timeout = setTimeout(() => setCopiedUrl(null), COPIED_FEEDBACK_MS);
3620
+ return () => clearTimeout(timeout);
3621
+ } else {
3622
+ return undefined;
3623
+ }
3624
+ }, [copiedUrl]);
3625
+ const highlightIndex = React.useCallback(index => setHighlight(current => current.results === results && current.index === index ? current : {
3626
+ index,
3627
+ results
3628
+ }), [results]);
3629
+ const handleKeyDown = event => {
3630
+ const step = verticalStep(event.key);
3631
+ if (step && results.length > 0) {
3632
+ event.preventDefault();
3633
+ highlightIndex((highlightedIndex + step + results.length) % results.length);
3634
+ } else if (event.key === "Enter" && highlighted && !event.nativeEvent.isComposing) {
3635
+ event.preventDefault();
3636
+ open(highlighted.attributes.url, {
3637
+ newTab: hasCommandKey(event)
3638
+ });
3639
+ } else if (isCopyShortcut(event) && highlighted && !hasSelectedText(event.currentTarget)) {
3640
+ event.preventDefault();
3641
+ const {
3642
+ url
3643
+ } = highlighted.attributes;
3644
+ navigator.clipboard.writeText(url).then(() => setCopiedUrl(url));
3645
+ }
3646
+ };
3647
+ return {
3648
+ copied: copiedUrl !== null && copiedUrl === (highlighted === null || highlighted === void 0 ? void 0 : highlighted.attributes.url),
3649
+ handleKeyDown,
3650
+ highlightIndex,
3651
+ highlighted,
3652
+ highlightedIndex: highlighted ? highlightedIndex : null
3653
+ };
3654
+ }
3655
+ function open(url, {
3656
+ newTab
3346
3657
  }) {
3347
- const search = useProductSearch(term);
3348
- switch (search.status) {
3349
- case "idle":
3350
- return null;
3351
- case "loading":
3352
- return /*#__PURE__*/React.createElement("p", {
3353
- role: "status"
3354
- }, "Searching\u2026");
3355
- case "error":
3356
- return /*#__PURE__*/React.createElement("p", {
3357
- role: "alert"
3358
- }, "Search isn\u2019t working right now. Try again.");
3359
- case "done":
3360
- if (search.groups.length === 0) {
3361
- return /*#__PURE__*/React.createElement("p", {
3362
- role: "status"
3363
- }, "No results for \u201C", term.trim(), "\u201D");
3658
+ if (newTab) {
3659
+ window.open(url, "_blank", "noopener");
3660
+ } else {
3661
+ window.location.assign(url);
3662
+ }
3663
+ }
3664
+ const isCopyShortcut = event => event.key.toLowerCase() === "c" && !event.altKey && !event.shiftKey && hasCommandKey(event);
3665
+ // With text selected, ⌘C should copy that text as usual, not the link.
3666
+ function hasSelectedText(element) {
3667
+ var _a, _b;
3668
+ const selectedInField = element instanceof HTMLInputElement && element.selectionStart !== element.selectionEnd;
3669
+ return selectedInField || ((_b = (_a = window.getSelection()) === null || _a === void 0 ? void 0 : _a.toString()) !== null && _b !== void 0 ? _b : "") !== "";
3670
+ }
3671
+
3672
+ const SEARCHABLE_PRODUCTS = [{
3673
+ appName: "Groups",
3674
+ slug: "groups"
3675
+ }, {
3676
+ appName: "People",
3677
+ slug: "people"
3678
+ }, {
3679
+ appName: "Services",
3680
+ slug: "services"
3681
+ }];
3682
+ const PERMISSION_CHECKS = {
3683
+ people: () => fetchMe("people", "people_permissions,site_administrator").then(({
3684
+ people_permissions,
3685
+ site_administrator
3686
+ }) => site_administrator || people_permissions !== null),
3687
+ services: () => fetchMe("services", "can_search").then(({
3688
+ can_search
3689
+ }) => can_search)
3690
+ };
3691
+ function fetchMe(product, fields) {
3692
+ const params = new URLSearchParams({
3693
+ "fields[Person]": fields
3694
+ });
3695
+ return apiRequest(defaultFetch, `${window.location.origin}/~api/${product}/v2/me?${params}`).then(({
3696
+ json,
3697
+ ok
3698
+ }) => {
3699
+ if (!ok || json === null) throw new Error("Permission check failed");
3700
+ return json.data.attributes;
3701
+ });
3702
+ }
3703
+ // Permissions rarely change mid-session, so a successful check is kept until
3704
+ // the page reloads. A failed one is dropped so a remount tries again.
3705
+ const permissionCache = new Map();
3706
+ function canSearch(slug) {
3707
+ const check = PERMISSION_CHECKS[slug];
3708
+ const cached = permissionCache.get(slug);
3709
+ if (!check) {
3710
+ return Promise.resolve(true);
3711
+ } else if (cached) {
3712
+ return cached;
3713
+ } else {
3714
+ const promise = check().catch(() => {
3715
+ permissionCache.delete(slug);
3716
+ return false;
3717
+ });
3718
+ permissionCache.set(slug, promise);
3719
+ return promise;
3720
+ }
3721
+ }
3722
+ function useSearchableProducts(apps) {
3723
+ // Keyed by name because providers hand over a fresh `apps` array on renders
3724
+ // where nothing changed.
3725
+ const accessibleKey = inSwitcherOrder(apps).map(({
3726
+ slug
3727
+ }) => slug).join(",");
3728
+ const [settled, setSettled] = React.useState();
3729
+ React.useEffect(() => {
3730
+ let current = true;
3731
+ const accessible = accessibleKey.split(",").flatMap(slug => SEARCHABLE_PRODUCTS.filter(product => product.slug === slug));
3732
+ Promise.all(accessible.map(({
3733
+ slug
3734
+ }) => canSearch(slug))).then(allowed => {
3735
+ if (current) {
3736
+ setSettled({
3737
+ accessibleKey,
3738
+ products: accessible.filter((_, index) => allowed[index])
3739
+ });
3364
3740
  }
3365
- return /*#__PURE__*/React.createElement(React.Fragment, null, search.groups.map(({
3366
- results,
3367
- type
3368
- }) => (/*#__PURE__*/React.createElement("section", {
3369
- "aria-label": type,
3370
- key: type
3371
- }, /*#__PURE__*/React.createElement("h2", null, type), /*#__PURE__*/React.createElement("ul", null, results.map(result => (/*#__PURE__*/React.createElement("li", {
3372
- key: result.id
3373
- }, /*#__PURE__*/React.createElement(SearchResult, {
3374
- result: result
3375
- })))))))));
3741
+ });
3742
+ return () => {
3743
+ current = false;
3744
+ };
3745
+ }, [accessibleKey]);
3746
+ if ((settled === null || settled === void 0 ? void 0 : settled.accessibleKey) === accessibleKey) {
3747
+ return {
3748
+ products: settled.products,
3749
+ status: "done"
3750
+ };
3751
+ } else {
3752
+ return {
3753
+ status: "loading"
3754
+ };
3376
3755
  }
3377
3756
  }
3757
+ function inSwitcherOrder(apps) {
3758
+ return groupApps(apps).restProducts.flatMap(app => SEARCHABLE_PRODUCTS.filter(product => product.appName === app.name));
3759
+ }
3378
3760
 
3379
- const isMac = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform);
3380
- const isSearchShortcut = event => event.key.toLowerCase() === "k" && !event.altKey && !event.shiftKey && (isMac() ? event.metaKey : event.ctrlKey);
3381
- function ProductSearch() {
3761
+ const isSearchShortcut = event => event.key.toLowerCase() === "k" && !event.altKey && !event.shiftKey && hasCommandKey(event);
3762
+ // Products opened their own search with `/` before this one replaced it.
3763
+ const isLegacySearchShortcut = event => event.key === "/" && !event.altKey && !event.ctrlKey && !event.metaKey && !isTypingIn(event.target);
3764
+ const isTypingKey = event => event.key.length === 1 && event.key !== " " && !event.altKey && !event.ctrlKey && !event.metaKey;
3765
+ const isTypingIn = target => target instanceof Element && target.closest('input, select, textarea, [contenteditable]:not([contenteditable="false"])') !== null;
3766
+ function ProductSearch({
3767
+ apps
3768
+ }) {
3769
+ const searchable = useSearchableProducts(apps);
3770
+ if (searchable.status === "done" && searchable.products.length > 0) {
3771
+ return /*#__PURE__*/React.createElement(SearchButtonAndDialog, {
3772
+ products: searchable.products
3773
+ });
3774
+ } else {
3775
+ return null;
3776
+ }
3777
+ }
3778
+ function SearchButtonAndDialog({
3779
+ products
3780
+ }) {
3382
3781
  const [open, setOpen] = React.useState(false);
3383
3782
  React.useEffect(() => {
3783
+ // Rich text editors like Trix bind ⌘K to "insert link"; capturing at the
3784
+ // window and stopping propagation keeps them from ever seeing it.
3384
3785
  const handleKeyDown = event => {
3385
- if (!isSearchShortcut(event)) return;
3386
- // Rich text editors like Trix bind ⌘K to "insert link"; capturing at
3387
- // the window and stopping propagation keeps them from ever seeing it.
3388
- event.preventDefault();
3389
- event.stopPropagation();
3390
- setOpen(wasOpen => !wasOpen);
3786
+ if (isSearchShortcut(event)) {
3787
+ event.preventDefault();
3788
+ event.stopPropagation();
3789
+ setOpen(wasOpen => !wasOpen);
3790
+ } else if (isLegacySearchShortcut(event)) {
3791
+ event.preventDefault();
3792
+ event.stopPropagation();
3793
+ setOpen(true);
3794
+ }
3391
3795
  };
3392
3796
  window.addEventListener("keydown", handleKeyDown, {
3393
3797
  capture: true
@@ -3397,7 +3801,7 @@ function ProductSearch() {
3397
3801
  });
3398
3802
  }, []);
3399
3803
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("button", {
3400
- "aria-keyshortcuts": isMac() ? "Meta+K" : "Control+K",
3804
+ "aria-keyshortcuts": `${isMac() ? "Meta+K" : "Control+K"} /`,
3401
3805
  "aria-label": "Search",
3402
3806
  onClick: () => setOpen(true),
3403
3807
  type: "button"
@@ -3408,21 +3812,357 @@ function ProductSearch() {
3408
3812
  dismissible: true,
3409
3813
  onClose: () => setOpen(false),
3410
3814
  open: open
3411
- }, /*#__PURE__*/React.createElement(SearchPanel, null)));
3815
+ }, /*#__PURE__*/React.createElement(SearchPanel, {
3816
+ products: products
3817
+ })));
3412
3818
  }
3413
- function SearchPanel() {
3819
+ function SearchPanel({
3820
+ products
3821
+ }) {
3822
+ var _a, _b;
3823
+ const listboxId = useInstanceId("product-search-results");
3414
3824
  const [term, setTerm] = React.useState("");
3825
+ const [selectedSlug, setSelectedSlug] = React.useState(() => initialProduct(products).slug);
3826
+ const product = (_a = products.find(({
3827
+ slug
3828
+ }) => slug === selectedSlug)) !== null && _a !== void 0 ? _a : products[0];
3829
+ const hasTerm = term.trim() !== "";
3830
+ const search = useProductSearch(product.slug, term);
3831
+ const groups = "groups" in search ? search.groups : undefined;
3832
+ const results = React.useMemo(() => {
3833
+ var _a;
3834
+ return (_a = groups === null || groups === void 0 ? void 0 : groups.flatMap(group => group.results)) !== null && _a !== void 0 ? _a : [];
3835
+ }, [groups]);
3836
+ const navigation = useResultNavigation(results);
3837
+ const {
3838
+ highlightedIndex
3839
+ } = navigation;
3840
+ const inputRef = React.useRef(null);
3841
+ const [announceHighlight, setAnnounceHighlight] = React.useState(false);
3842
+ // The tabs keep focus while arrowing through results, but only the focused
3843
+ // input can point screen readers at the highlight, so from the tabs it's
3844
+ // announced instead. Typing goes back to the input, keystroke and all.
3845
+ const handleTabsKeyDown = event => {
3846
+ var _a;
3847
+ if (isTypingKey(event)) {
3848
+ (_a = inputRef.current) === null || _a === void 0 ? void 0 : _a.focus();
3849
+ } else if (verticalStep(event.key)) {
3850
+ setAnnounceHighlight(true);
3851
+ navigation.handleKeyDown(event);
3852
+ } else {
3853
+ navigation.handleKeyDown(event);
3854
+ }
3855
+ };
3856
+ React.useEffect(() => {
3857
+ if (hasTerm) saveLastProduct(product.slug);
3858
+ }, [hasTerm, product.slug]);
3415
3859
  return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("input", {
3860
+ "aria-activedescendant": highlightedIndex === null ? undefined : resultId(listboxId, highlightedIndex),
3861
+ "aria-autocomplete": "list",
3862
+ "aria-controls": results.length > 0 ? listboxId : undefined,
3863
+ "aria-expanded": results.length > 0,
3416
3864
  "aria-label": "Search",
3417
3865
  autoFocus: true,
3418
3866
  onChange: event => setTerm(event.target.value),
3867
+ onFocus: () => setAnnounceHighlight(false),
3868
+ onKeyDown: navigation.handleKeyDown,
3419
3869
  placeholder: "Search",
3870
+ ref: inputRef,
3871
+ role: "combobox",
3420
3872
  type: "search",
3421
3873
  value: term
3422
- }), /*#__PURE__*/React.createElement(SearchResults, {
3874
+ }), /*#__PURE__*/React.createElement(ProductTabs, {
3875
+ onKeyDown: handleTabsKeyDown,
3876
+ onSelect: setSelectedSlug,
3877
+ products: products,
3878
+ selected: product
3879
+ }, /*#__PURE__*/React.createElement(SearchResults, {
3880
+ highlightedIndex: highlightedIndex,
3881
+ id: listboxId,
3882
+ label: `${product.appName} results`,
3883
+ onHighlight: navigation.highlightIndex,
3884
+ search: search,
3423
3885
  term: term
3886
+ })), /*#__PURE__*/React.createElement("div", {
3887
+ "aria-live": "polite",
3888
+ className: styles$c.visuallyHidden
3889
+ }, announceHighlight ? (_b = navigation.highlighted) === null || _b === void 0 ? void 0 : _b.attributes.title : null), /*#__PURE__*/React.createElement(KeyboardHints, {
3890
+ copied: navigation.copied
3424
3891
  }));
3425
3892
  }
3893
+ function KeyboardHints({
3894
+ copied
3895
+ }) {
3896
+ const command = commandKeyLabel();
3897
+ 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", {
3898
+ role: "status"
3899
+ }, copied ? "Copied!" : "Copy link"), " \u00B7 ", command, "K Open search");
3900
+ }
3901
+ function initialProduct(products) {
3902
+ var _a, _b, _c;
3903
+ const lastProduct = readLastProduct();
3904
+ const bySlug = slug => products.find(product => product.slug === slug);
3905
+ 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];
3906
+ }
3907
+
3908
+ function parseValue(element) {
3909
+ const datatype = element.getAttribute("datatype");
3910
+ switch (datatype) {
3911
+ case "boolean":
3912
+ return element.content === "true" || element.content === "1";
3913
+ default:
3914
+ return element.content;
3915
+ }
3916
+ }
3917
+ function getAllPcoMeta() {
3918
+ const elements = document.head.querySelectorAll(`meta[property^="pco:"]`);
3919
+ const meta = {};
3920
+ elements.forEach(e => {
3921
+ const property = e.getAttribute("property");
3922
+ meta[property.substring("pco:".length)] = parseValue(e);
3923
+ });
3924
+ return meta;
3925
+ }
3926
+ // usePcoMeta only reads each tag's content. Recently Visited also needs the
3927
+ // pco:recently_visitable tag's `metadata` attribute, which can change while
3928
+ // the content stays the same, so it is tracked in its own store.
3929
+ function getRecentlyVisitableMetadata() {
3930
+ var _a, _b;
3931
+ 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;
3932
+ }
3933
+ const store = createStore(getAllPcoMeta());
3934
+ const recentlyVisitableMetadataStore = createStore({
3935
+ metadata: getRecentlyVisitableMetadata()
3936
+ });
3937
+ function sync() {
3938
+ const nextState = getAllPcoMeta();
3939
+ if (!shallowCompare(store.getState(), nextState)) store.setState(nextState);
3940
+ const metadata = getRecentlyVisitableMetadata();
3941
+ if (recentlyVisitableMetadataStore.getState().metadata !== metadata) {
3942
+ recentlyVisitableMetadataStore.setState({
3943
+ metadata
3944
+ });
3945
+ }
3946
+ }
3947
+ const observer = new MutationObserver(sync);
3948
+ let hasTriggeredObserve = false;
3949
+ function observe() {
3950
+ if (hasTriggeredObserve) return;
3951
+ hasTriggeredObserve = true;
3952
+ // The module-load snapshot above can predate meta tags that appear later
3953
+ // in <head> (e.g. pco:resource, yielded right before </head>) if this
3954
+ // module's own script tag isn't deferred and runs mid-parse. Those tags
3955
+ // never mutate afterward, so the observer alone would never catch them.
3956
+ // Re-read once now, since observe() is only called once a consumer is
3957
+ // actually mounting, which is always after the document has fully parsed.
3958
+ sync();
3959
+ observer.observe(document.head, {
3960
+ attributes: true,
3961
+ childList: true,
3962
+ subtree: true
3963
+ });
3964
+ }
3965
+ function usePcoMeta() {
3966
+ observe();
3967
+ return shim.useSyncExternalStore(store.subscribe, () => store.getState());
3968
+ }
3969
+ function useRecentlyVisitableMetadata() {
3970
+ observe();
3971
+ return shim.useSyncExternalStore(recentlyVisitableMetadataStore.subscribe, () => recentlyVisitableMetadataStore.getState().metadata);
3972
+ }
3973
+
3974
+ const MAX_ENTRIES = 20;
3975
+ const FORMAT_VERSION = 1;
3976
+ const MAX_TEXT_LENGTH = 200;
3977
+ const PRODUCTS = ["accounts", "calendar", "check-ins", "giving", "groups", "home", "login", "people", "publishing", "registrations", "services"];
3978
+ function isId(value) {
3979
+ return value !== "" && Array.from(value).every(character => character >= "0" && character <= "9");
3980
+ }
3981
+ // A PCRL looks like `<product>/v2/<resource_type>/<id>`.
3982
+ function isPcrl(value) {
3983
+ const segments = value.split("/");
3984
+ return segments.length === 4 && PRODUCTS.some(product => product === segments[0]) && isId(segments[3]);
3985
+ }
3986
+ function isNonBlankString(value) {
3987
+ return typeof value === "string" && value.trim() !== "";
3988
+ }
3989
+ function isRecord(value) {
3990
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3991
+ }
3992
+ function entryKey(entry) {
3993
+ if (entry.type === "resource") {
3994
+ return `resource:${entry.pcrl}`;
3995
+ } else {
3996
+ return `page:${entry.product}:${entry.path}`;
3997
+ }
3998
+ }
3999
+ function isEntry(value) {
4000
+ if (!isRecord(value)) {
4001
+ return false;
4002
+ } else if (value.type === "resource") {
4003
+ return typeof value.pcrl === "string" && isPcrl(value.pcrl);
4004
+ } else if (value.type === "page") {
4005
+ return isNonBlankString(value.product) && isNonBlankString(value.title) && isNonBlankString(value.path) && typeof value.details === "string";
4006
+ } else {
4007
+ return false;
4008
+ }
4009
+ }
4010
+ // Cuts on code points so a surrogate pair is never split.
4011
+ function clampText(value) {
4012
+ return Array.from(value).slice(0, MAX_TEXT_LENGTH).join("");
4013
+ }
4014
+ function clampEntry(entry) {
4015
+ if (entry.type === "resource") {
4016
+ return entry;
4017
+ } else {
4018
+ return {
4019
+ ...entry,
4020
+ details: clampText(entry.details),
4021
+ title: clampText(entry.title)
4022
+ };
4023
+ }
4024
+ }
4025
+ // Recents are disposable: anything unexpected reads as an empty list (or, for
4026
+ // a single bad entry, a list without it) rather than throwing.
4027
+ function parseRecentlyVisited(value, personId) {
4028
+ let parsed = null;
4029
+ try {
4030
+ parsed = JSON.parse(value !== null && value !== void 0 ? value : "null");
4031
+ } catch {
4032
+ parsed = null;
4033
+ }
4034
+ if (isRecord(parsed) && parsed.v === FORMAT_VERSION && parsed.person === personId && Array.isArray(parsed.entries)) {
4035
+ return parsed.entries.filter(isEntry);
4036
+ } else {
4037
+ return [];
4038
+ }
4039
+ }
4040
+ function addVisit(entries, visit) {
4041
+ const key = entryKey(visit);
4042
+ return [clampEntry(visit), ...entries.filter(entry => entryKey(entry) !== key)].slice(0, MAX_ENTRIES);
4043
+ }
4044
+ function serializeRecentlyVisited(entries, personId) {
4045
+ return JSON.stringify({
4046
+ entries,
4047
+ person: personId,
4048
+ v: FORMAT_VERSION
4049
+ });
4050
+ }
4051
+
4052
+ // The product whose URL, in this environment, has the current page's host.
4053
+ // Reversing pcoUrl handles every environment @planningcenter/url knows about,
4054
+ // and guarantees the stored product rebuilds a working link.
4055
+ function currentProduct() {
4056
+ var _a;
4057
+ return (_a = PRODUCTS.find(product => new URL(pcoUrl$1.pcoUrl(product)).hostname === window.location.hostname)) !== null && _a !== void 0 ? _a : null;
4058
+ }
4059
+ function detailsFromMetadata(metadata) {
4060
+ var _a;
4061
+ try {
4062
+ const details = (_a = JSON.parse(metadata !== null && metadata !== void 0 ? metadata : "{}")) === null || _a === void 0 ? void 0 : _a.details;
4063
+ return typeof details === "string" ? details : "";
4064
+ } catch {
4065
+ return "";
4066
+ }
4067
+ }
4068
+ // pco:resource is the more specific signal, so it wins when both tags are
4069
+ // present. A resource that isn't a PCRL is ignored rather than recorded.
4070
+ function visitFromPage({
4071
+ metadata,
4072
+ resource,
4073
+ title
4074
+ }) {
4075
+ const product = currentProduct();
4076
+ if (resource && isPcrl(resource)) {
4077
+ return {
4078
+ pcrl: resource,
4079
+ type: "resource"
4080
+ };
4081
+ } else if (title && title.trim() !== "" && product) {
4082
+ return {
4083
+ details: detailsFromMetadata(metadata),
4084
+ path: window.location.pathname,
4085
+ product,
4086
+ title,
4087
+ type: "page"
4088
+ };
4089
+ } else {
4090
+ return null;
4091
+ }
4092
+ }
4093
+ // The entry Recently Visited would record for the current page, or null when
4094
+ // the page isn't eligible. Shared by the recorder and the debug panel so the
4095
+ // panel always shows what actually gets stored.
4096
+ function useCurrentVisit() {
4097
+ const {
4098
+ recently_visitable: title,
4099
+ resource
4100
+ } = usePcoMeta();
4101
+ const metadata = useRecentlyVisitableMetadata();
4102
+ return React.useMemo(() => visitFromPage({
4103
+ metadata,
4104
+ resource,
4105
+ title
4106
+ }), [metadata, resource, title]);
4107
+ }
4108
+ // Products hand the person's ID to AppsProvider, not to the topbar itself,
4109
+ // and several pass it as a number.
4110
+ function useCurrentPersonId() {
4111
+ const {
4112
+ userId
4113
+ } = useStore();
4114
+ return userId == null ? undefined : String(userId);
4115
+ }
4116
+
4117
+ // Per-origin localStorage is a stopgap: each product keeps its own list until
4118
+ // PCA-409 moves this to storage shared across product subdomains. Only this
4119
+ // file should need to change when it does.
4120
+ function storageKey({
4121
+ env,
4122
+ personId
4123
+ }) {
4124
+ return `topbar.recently_visited.${env}.${personId}`;
4125
+ }
4126
+ // localStorage doesn't notify the tab that wrote it, so readers in this tab
4127
+ // learn about new visits from this counter instead.
4128
+ const changes = createStore({
4129
+ version: 0
4130
+ });
4131
+ function readRecentlyVisited(scope) {
4132
+ try {
4133
+ return parseRecentlyVisited(localStorage.getItem(storageKey(scope)), scope.personId);
4134
+ } catch {
4135
+ return [];
4136
+ }
4137
+ }
4138
+ function recordVisit(visit, scope) {
4139
+ if (isEntry(visit)) {
4140
+ try {
4141
+ localStorage.setItem(storageKey(scope), serializeRecentlyVisited(addVisit(readRecentlyVisited(scope), visit), scope.personId));
4142
+ changes.setState({
4143
+ version: changes.getState().version + 1
4144
+ });
4145
+ } catch {
4146
+ // Storage can be full or blocked; recents are disposable.
4147
+ }
4148
+ }
4149
+ }
4150
+
4151
+ function useRecordRecentlyVisited({
4152
+ enabled,
4153
+ env
4154
+ }) {
4155
+ const personId = useCurrentPersonId();
4156
+ const visit = useCurrentVisit();
4157
+ React.useEffect(() => {
4158
+ if (enabled && personId && visit) {
4159
+ recordVisit(visit, {
4160
+ env,
4161
+ personId
4162
+ });
4163
+ }
4164
+ }, [enabled, env, personId, visit]);
4165
+ }
3426
4166
 
3427
4167
  function StyledRoot({
3428
4168
  children,
@@ -3652,6 +4392,10 @@ const NotSmallTopbar = ({
3652
4392
  const updateStore = useUpdateStore();
3653
4393
  const useRedesignedSwitcher = useFeature(FEATURE_FLAGS.APP_SWITCHER_REDESIGN, props.features);
3654
4394
  const productSearchEnabled = useFeature(FEATURE_FLAGS.PRODUCT_SEARCH, props.features);
4395
+ useRecordRecentlyVisited({
4396
+ enabled: productSearchEnabled,
4397
+ env: props.env
4398
+ });
3655
4399
  React.useEffect(() => {
3656
4400
  const handleToolbarClick = event => {
3657
4401
  const target = event.target;
@@ -3692,7 +4436,9 @@ const NotSmallTopbar = ({
3692
4436
  style: {
3693
4437
  margin: "auto"
3694
4438
  }
3695
- }), !notificationsEnabled && notifications(), productSearchEnabled ? (/*#__PURE__*/React.createElement(ProductSearch, null)) : search({
4439
+ }), !notificationsEnabled && notifications(), productSearchEnabled ? (/*#__PURE__*/React.createElement(ProductSearch, {
4440
+ apps: props.apps
4441
+ })) : search({
3696
4442
  hideRoutes: () => setRoutesVisible(false),
3697
4443
  showRoutes: () => setRoutesVisible(true)
3698
4444
  }), /*#__PURE__*/React.createElement(UserMenuTrigger, {
@@ -31768,6 +32514,11 @@ const SmallTopbar = ({
31768
32514
  const [routesMenuVisible, setRoutesMenuVisible] = React.useState(false);
31769
32515
  const [productSwitcherVisible, setProductSwitcherVisible] = React.useState(false);
31770
32516
  const useRedesignedSwitcher = useFeature(FEATURE_FLAGS.APP_SWITCHER_REDESIGN, props.features);
32517
+ const productSearchEnabled = useFeature(FEATURE_FLAGS.PRODUCT_SEARCH, props.features);
32518
+ useRecordRecentlyVisited({
32519
+ enabled: productSearchEnabled,
32520
+ env: props.env
32521
+ });
31771
32522
  const activeRoute = props.routes.filter(({
31772
32523
  props: {
31773
32524
  active
@@ -31976,55 +32727,6 @@ const topbarTheme = {
31976
32727
  id: "topbar"
31977
32728
  };
31978
32729
 
31979
- function parseValue(element) {
31980
- const datatype = element.getAttribute("datatype");
31981
- switch (datatype) {
31982
- case "boolean":
31983
- return element.content === "true" || element.content === "1";
31984
- default:
31985
- return element.content;
31986
- }
31987
- }
31988
- function getAllPcoMeta() {
31989
- const elements = document.head.querySelectorAll(`meta[property^="pco:"]`);
31990
- const meta = {};
31991
- elements.forEach(e => {
31992
- const property = e.getAttribute("property");
31993
- meta[property.substring("pco:".length)] = parseValue(e);
31994
- });
31995
- return meta;
31996
- }
31997
- const store = createStore(getAllPcoMeta());
31998
- const observer = new MutationObserver(() => {
31999
- const prevState = store.getState();
32000
- const nextState = getAllPcoMeta();
32001
- if (!shallowCompare(prevState, nextState)) store.setState(nextState);
32002
- });
32003
- let hasTriggeredObserve = false;
32004
- function observe() {
32005
- if (hasTriggeredObserve) return;
32006
- hasTriggeredObserve = true;
32007
- // The module-load snapshot above can predate meta tags that appear later
32008
- // in <head> (e.g. pco:resource, yielded right before </head>) if this
32009
- // module's own script tag isn't deferred and runs mid-parse. Those tags
32010
- // never mutate afterward, so the observer alone would never catch them.
32011
- // Re-read once now, since observe() is only called once a consumer is
32012
- // actually mounting, which is always after the document has fully parsed.
32013
- const freshState = getAllPcoMeta();
32014
- if (!shallowCompare(store.getState(), freshState)) {
32015
- store.setState(freshState);
32016
- }
32017
- observer.observe(document.head, {
32018
- attributes: true,
32019
- childList: true,
32020
- subtree: true
32021
- });
32022
- }
32023
- function usePcoMeta() {
32024
- observe();
32025
- return shim.useSyncExternalStore(store.subscribe, () => store.getState());
32026
- }
32027
-
32028
32730
  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"};
32029
32731
 
32030
32732
  function isPresent(value) {