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