@planningcenter/topbar 12.5.0-rc.1 → 12.5.0-rc.2

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
@@ -3630,7 +3630,7 @@ const store = createStore(getAllPcoMeta());
3630
3630
  const recentlyVisitableMetadataStore = createStore({
3631
3631
  metadata: getRecentlyVisitableMetadata()
3632
3632
  });
3633
- function sync() {
3633
+ function sync$1() {
3634
3634
  const nextState = getAllPcoMeta();
3635
3635
  if (!shallowCompare(store.getState(), nextState)) store.setState(nextState);
3636
3636
  const metadata = getRecentlyVisitableMetadata();
@@ -3640,7 +3640,7 @@ function sync() {
3640
3640
  });
3641
3641
  }
3642
3642
  }
3643
- const observer = new MutationObserver(sync);
3643
+ const observer = new MutationObserver(sync$1);
3644
3644
  let hasTriggeredObserve = false;
3645
3645
  function observe() {
3646
3646
  if (hasTriggeredObserve) return;
@@ -3651,7 +3651,7 @@ function observe() {
3651
3651
  // never mutate afterward, so the observer alone would never catch them.
3652
3652
  // Re-read once now, since observe() is only called once a consumer is
3653
3653
  // actually mounting, which is always after the document has fully parsed.
3654
- sync();
3654
+ sync$1();
3655
3655
  observer.observe(document.head, {
3656
3656
  attributes: true,
3657
3657
  childList: true,
@@ -3667,10 +3667,8 @@ function useRecentlyVisitableMetadata() {
3667
3667
  return shim.useSyncExternalStore(recentlyVisitableMetadataStore.subscribe, () => recentlyVisitableMetadataStore.getState().metadata);
3668
3668
  }
3669
3669
 
3670
- // Entries match the ones home-mobile stores, so one list can sync across web
3671
- // and mobile.
3672
- const ENTRY_VERSION = 3;
3673
3670
  const MAX_ENTRIES = 50;
3671
+ const MAX_ENTRY_BYTES = 1024;
3674
3672
  const MAX_TEXT_LENGTH = 200;
3675
3673
  const PRODUCTS$1 = ["accounts", "calendar", "check-ins", "giving", "groups", "home", "login", "people", "publishing", "registrations", "services"];
3676
3674
  // Login has no pages worth returning to.
@@ -3717,8 +3715,8 @@ function entryKey(entry) {
3717
3715
  return `page:${entry.app}:${entry.path.split("?")[0]}`;
3718
3716
  }
3719
3717
  }
3720
- function isEntry(value) {
3721
- if (!isRecord(value) || value.version !== ENTRY_VERSION || typeof value.visitedAt !== "string") {
3718
+ function hasEntryShape(value) {
3719
+ if (!isRecord(value) || typeof value.visitedAt !== "string") {
3722
3720
  return false;
3723
3721
  } else if (value.type === "resource") {
3724
3722
  return typeof value.pcrl === "string" && isPcrl(value.pcrl);
@@ -3728,15 +3726,38 @@ function isEntry(value) {
3728
3726
  return false;
3729
3727
  }
3730
3728
  }
3729
+ function utf8Length(value) {
3730
+ return Array.from(value).reduce((length, character) => {
3731
+ var _a;
3732
+ const codePoint = (_a = character.codePointAt(0)) !== null && _a !== void 0 ? _a : 0;
3733
+ if (codePoint < 0x80) {
3734
+ return length + 1;
3735
+ } else if (codePoint < 0x800) {
3736
+ return length + 2;
3737
+ } else if (codePoint < 0x10000) {
3738
+ return length + 3;
3739
+ } else {
3740
+ return length + 4;
3741
+ }
3742
+ }, 0);
3743
+ }
3744
+ function excessBytes(entry) {
3745
+ return utf8Length(JSON.stringify(entry)) - MAX_ENTRY_BYTES;
3746
+ }
3747
+ // user-store-sync rejects a whole write over one entry above its size limit,
3748
+ // so an entry that can't fit isn't one this list can hold.
3749
+ function isEntry(value) {
3750
+ return hasEntryShape(value) && excessBytes(value) <= 0;
3751
+ }
3731
3752
  // Cuts on code points so a surrogate pair is never split.
3732
3753
  function clampText(value) {
3733
3754
  return Array.from(value).slice(0, MAX_TEXT_LENGTH).join("");
3734
3755
  }
3735
- function clampMetadata({
3756
+ function metadataFrom({
3736
3757
  details,
3737
3758
  type
3738
3759
  }) {
3739
- return {
3760
+ const metadata = {
3740
3761
  ...(details && {
3741
3762
  details: clampText(details)
3742
3763
  }),
@@ -3744,39 +3765,124 @@ function clampMetadata({
3744
3765
  type: clampText(type)
3745
3766
  })
3746
3767
  };
3768
+ return Object.keys(metadata).length > 0 ? {
3769
+ metadata
3770
+ } : {};
3771
+ }
3772
+ function pageEntry({
3773
+ app,
3774
+ path,
3775
+ visitedAt
3776
+ }, {
3777
+ details,
3778
+ title,
3779
+ type
3780
+ }) {
3781
+ return {
3782
+ app,
3783
+ ...metadataFrom({
3784
+ details,
3785
+ type
3786
+ }),
3787
+ path,
3788
+ title: clampText(title),
3789
+ type: "page",
3790
+ visitedAt
3791
+ };
3747
3792
  }
3748
- function clampVisit(visit) {
3749
- if (visit.type === "resource") {
3750
- return visit;
3793
+ function texts(entry) {
3794
+ var _a, _b, _c, _d;
3795
+ return {
3796
+ details: (_b = (_a = entry.metadata) === null || _a === void 0 ? void 0 : _a.details) !== null && _b !== void 0 ? _b : "",
3797
+ title: entry.title,
3798
+ type: (_d = (_c = entry.metadata) === null || _c === void 0 ? void 0 : _c.type) !== null && _d !== void 0 ? _d : ""
3799
+ };
3800
+ }
3801
+ function jsonBytes(value) {
3802
+ return utf8Length(JSON.stringify(value)) - 2;
3803
+ }
3804
+ function longestText(entry) {
3805
+ return Object.entries(texts(entry)).reduce((longest, candidate) => jsonBytes(candidate[1]) > jsonBytes(longest[1]) ? candidate : longest);
3806
+ }
3807
+ // Takes whole characters off the end until at least `excess` bytes are gone,
3808
+ // so a surrogate pair is never split.
3809
+ function shortenBy(value, excess, minLength) {
3810
+ var _a;
3811
+ const characters = Array.from(value);
3812
+ let removed = 0;
3813
+ while (characters.length > minLength && removed < excess) {
3814
+ removed += jsonBytes((_a = characters.pop()) !== null && _a !== void 0 ? _a : "");
3815
+ }
3816
+ return characters.join("");
3817
+ }
3818
+ // Trims the longest text field by the overflow, repeating until the entry
3819
+ // fits or the title is down to one character.
3820
+ function trimTexts(entry) {
3821
+ const excess = excessBytes(entry);
3822
+ const [field, value] = longestText(entry);
3823
+ const shortened = shortenBy(value, excess, field === "title" ? 1 : 0);
3824
+ if (excess <= 0 || shortened === value) {
3825
+ return entry;
3751
3826
  } else {
3752
- return {
3753
- ...visit,
3754
- ...(visit.metadata && {
3755
- metadata: clampMetadata(visit.metadata)
3756
- }),
3757
- title: clampText(visit.title.trim())
3758
- };
3827
+ return trimTexts(pageEntry(entry, {
3828
+ ...texts(entry),
3829
+ [field]: shortened
3830
+ }));
3831
+ }
3832
+ }
3833
+ // The query string is the first thing to go, since it's not part of the
3834
+ // page's identity; what's left is trimmed to fit under the service's limit.
3835
+ function fitPage(entry) {
3836
+ if (excessBytes(entry) <= 0) {
3837
+ return entry;
3838
+ } else {
3839
+ return trimTexts({
3840
+ ...entry,
3841
+ path: entry.path.split("?")[0]
3842
+ });
3759
3843
  }
3760
3844
  }
3761
3845
  function toEntry(visit, visitedAt) {
3762
- return {
3763
- ...clampVisit(visit),
3764
- version: ENTRY_VERSION,
3846
+ const entry = {
3847
+ ...visit,
3765
3848
  visitedAt: visitedAt.toISOString()
3766
3849
  };
3850
+ if (entry.type === "resource") {
3851
+ return entry;
3852
+ } else {
3853
+ return fitPage(pageEntry(entry, {
3854
+ ...texts(entry),
3855
+ title: entry.title.trim()
3856
+ }));
3857
+ }
3858
+ }
3859
+ // user-store-sync rejects a whole write over one field it doesn't know, so
3860
+ // anything else an entry carries (like the version older entries stored) is
3861
+ // dropped on the way in.
3862
+ function withKnownFields(entry) {
3863
+ if (entry.type === "resource") {
3864
+ return {
3865
+ pcrl: entry.pcrl,
3866
+ type: entry.type,
3867
+ visitedAt: entry.visitedAt
3868
+ };
3869
+ } else {
3870
+ return pageEntry(entry, texts(entry));
3871
+ }
3767
3872
  }
3768
3873
  // Recents are disposable: anything unexpected reads as an empty list (or, for
3769
3874
  // a single bad entry, a list without it) rather than throwing.
3875
+ function entriesFrom(value) {
3876
+ if (Array.isArray(value)) {
3877
+ return value.filter(isEntry).map(withKnownFields);
3878
+ } else {
3879
+ return [];
3880
+ }
3881
+ }
3770
3882
  function parseRecentlyVisited(value) {
3771
- let parsed = null;
3772
3883
  try {
3773
- parsed = JSON.parse(value !== null && value !== void 0 ? value : "null");
3884
+ return entriesFrom(JSON.parse(value !== null && value !== void 0 ? value : "null"));
3774
3885
  } catch {
3775
- parsed = null;
3776
- }
3777
- if (Array.isArray(parsed)) {
3778
- return parsed.filter(isEntry);
3779
- } else {
3780
3886
  return [];
3781
3887
  }
3782
3888
  }
@@ -3784,6 +3890,38 @@ function addVisit(entries, entry) {
3784
3890
  const key = entryKey(entry);
3785
3891
  return [entry, ...entries.filter(existing => entryKey(existing) !== key)].slice(0, MAX_ENTRIES);
3786
3892
  }
3893
+ // The service may write timestamps with fewer fractional digits than
3894
+ // toISOString, so they're compared as times rather than strings.
3895
+ function visitTime(entry) {
3896
+ return isRecord(entry) && typeof entry.visitedAt === "string" ? Date.parse(entry.visitedAt) || 0 : 0;
3897
+ }
3898
+ function newestFirst(entries) {
3899
+ const seen = new Set();
3900
+ return entries.sort((a, b) => visitTime(b) - visitTime(a)).filter(entry => {
3901
+ if (isEntry(entry)) {
3902
+ const key = entryKey(entry);
3903
+ const isFirst = !seen.has(key);
3904
+ seen.add(key);
3905
+ return isFirst;
3906
+ } else {
3907
+ return true;
3908
+ }
3909
+ }).slice(0, MAX_ENTRIES);
3910
+ }
3911
+ // One list from several, newest visit first wherever it was recorded.
3912
+ function mergeEntries(...lists) {
3913
+ return newestFirst(lists.flat());
3914
+ }
3915
+ // Products pin different topbar versions, so the shared list can hold entries
3916
+ // this build doesn't recognize (a newer app or type). They're kept, as stored,
3917
+ // in what's written back, so an older topbar never erases a newer client's
3918
+ // visits.
3919
+ function mergeStored(stored, local) {
3920
+ return newestFirst([...(Array.isArray(stored) ? stored : []), ...local]);
3921
+ }
3922
+ function isSameList(a, b) {
3923
+ return a.length === b.length && a.every((entry, index) => entryKey(entry) === entryKey(b[index]) && visitTime(entry) === visitTime(b[index]));
3924
+ }
3787
3925
 
3788
3926
  // The app whose URL, in this environment, has the current page's host.
3789
3927
  // Reversing pcoUrl handles every environment @planningcenter/url knows about,
@@ -3894,9 +4032,164 @@ function useCurrentPersonId() {
3894
4032
  return userId == null ? undefined : String(userId);
3895
4033
  }
3896
4034
 
3897
- // Per-origin localStorage is a stopgap: each product keeps its own list until
3898
- // PCA-409 moves this to storage shared across product subdomains. Only this
3899
- // file should need to change when it does.
4035
+ // Tokens last 15 minutes. Refreshing a minute early keeps one from expiring
4036
+ // between reading a token and the service checking it.
4037
+ const REFRESH_MARGIN_MS = 60000;
4038
+ // A failed mint (a CSRF 422, a 5xx, a body that isn't a token) isn't retried
4039
+ // on every visit and panel open; the product is left alone for a while.
4040
+ const MINT_BACKOFF_MS = 5 * 60000;
4041
+ const MAX_ATTEMPTS = 3;
4042
+ // A product without the endpoint answers 404 for the rest of the session, so
4043
+ // it's asked once per tab rather than on every page.
4044
+ const UNAVAILABLE_UNTIL_KEY = "topbar.user_store_sync.unavailable_until";
4045
+ const END_OF_SESSION = Number.MAX_SAFE_INTEGER;
4046
+ // Per person, so switching connected people never writes one person's visits
4047
+ // with another's token.
4048
+ function tokenKey(personId) {
4049
+ return `topbar.user_store_sync.token.${personId}`;
4050
+ }
4051
+ function readSession(key) {
4052
+ try {
4053
+ return sessionStorage.getItem(key);
4054
+ } catch {
4055
+ return null;
4056
+ }
4057
+ }
4058
+ function writeSession(key, value) {
4059
+ try {
4060
+ sessionStorage.setItem(key, value);
4061
+ } catch {
4062
+ // Without sessionStorage the next request just mints again.
4063
+ }
4064
+ }
4065
+ function isUserStoreSyncUnavailable() {
4066
+ return Number(readSession(UNAVAILABLE_UNTIL_KEY)) > Date.now();
4067
+ }
4068
+ function markUnavailable(until) {
4069
+ writeSession(UNAVAILABLE_UNTIL_KEY, String(until));
4070
+ }
4071
+ function isToken(value) {
4072
+ return isRecord(value) && typeof value.token === "string" && typeof value.url === "string" && Number.isFinite(value.expiresAt) && typeof value.readOnly === "boolean";
4073
+ }
4074
+ function cachedToken(personId) {
4075
+ var _a;
4076
+ try {
4077
+ const token = JSON.parse((_a = readSession(tokenKey(personId))) !== null && _a !== void 0 ? _a : "null");
4078
+ if (isToken(token) && token.expiresAt - REFRESH_MARGIN_MS > Date.now()) {
4079
+ return token;
4080
+ } else {
4081
+ return null;
4082
+ }
4083
+ } catch {
4084
+ return null;
4085
+ }
4086
+ }
4087
+ function tokenFrom(json) {
4088
+ if (isRecord(json)) {
4089
+ const token = {
4090
+ expiresAt: typeof json.expires_at === "string" ? Date.parse(json.expires_at) : NaN,
4091
+ readOnly: json.read_only === true,
4092
+ token: json.token,
4093
+ url: json.url
4094
+ };
4095
+ return isToken(token) ? token : null;
4096
+ } else {
4097
+ return null;
4098
+ }
4099
+ }
4100
+ async function mintToken(personId) {
4101
+ const {
4102
+ json,
4103
+ ok,
4104
+ response
4105
+ } = await apiRequest(railsCsrfFetch, `${window.location.origin}/user_store_sync/token`, {
4106
+ method: "POST"
4107
+ });
4108
+ const token = ok ? tokenFrom(json) : null;
4109
+ if (token) {
4110
+ writeSession(tokenKey(personId), JSON.stringify(token));
4111
+ } else if ((response === null || response === void 0 ? void 0 : response.status) === 404) {
4112
+ markUnavailable(END_OF_SESSION);
4113
+ } else {
4114
+ markUnavailable(Date.now() + MINT_BACKOFF_MS);
4115
+ }
4116
+ return token;
4117
+ }
4118
+ async function request(personId, key, init = {}, isRetry = false) {
4119
+ var _a;
4120
+ const token = isRetry ? await mintToken(personId) : (_a = cachedToken(personId)) !== null && _a !== void 0 ? _a : await mintToken(personId);
4121
+ if (token) {
4122
+ const response = await fetch(`${token.url}/v1/data/${key}`, {
4123
+ ...init,
4124
+ headers: {
4125
+ ...init.headers,
4126
+ Authorization: `Bearer ${token.token}`
4127
+ }
4128
+ });
4129
+ if (response.status === 401 && !isRetry) {
4130
+ return request(personId, key, init, true);
4131
+ } else {
4132
+ return {
4133
+ response,
4134
+ token
4135
+ };
4136
+ }
4137
+ } else {
4138
+ return null;
4139
+ }
4140
+ }
4141
+ // The service answers version 0 for a person with nothing stored yet, and
4142
+ // takes `If-Match: "0"` for the first write. Without a version at all, the
4143
+ // write goes unconditioned.
4144
+ function ifMatch(version) {
4145
+ return typeof version === "number" ? {
4146
+ "If-Match": `"${version}"`
4147
+ } : {};
4148
+ }
4149
+ // Applies `change` to the stored value and writes the result, rereading and
4150
+ // retrying when another tab or device wrote in between. `change` returns null
4151
+ // when there's nothing to write. A read-only token (staff impersonating
4152
+ // someone) only reads, so nothing is written into the person's history.
4153
+ // Resolves to the value now stored, or null when the service couldn't be
4154
+ // reached.
4155
+ async function updateStoredValue(personId, key, change, attemptsLeft = MAX_ATTEMPTS) {
4156
+ const read = await request(personId, key);
4157
+ if (read === null || read === void 0 ? void 0 : read.response.ok) {
4158
+ const {
4159
+ value,
4160
+ version
4161
+ } = await read.response.json();
4162
+ const next = read.token.readOnly ? null : change(value);
4163
+ if (next === null) {
4164
+ return Array.isArray(value) ? value : [];
4165
+ } else {
4166
+ const write = await request(personId, key, {
4167
+ body: JSON.stringify({
4168
+ value: next
4169
+ }),
4170
+ headers: {
4171
+ "Content-Type": "application/json",
4172
+ ...ifMatch(version)
4173
+ },
4174
+ method: "PUT"
4175
+ });
4176
+ if (write === null || write === void 0 ? void 0 : write.response.ok) {
4177
+ return next;
4178
+ } else if ((write === null || write === void 0 ? void 0 : write.response.status) === 409 && attemptsLeft > 1) {
4179
+ return updateStoredValue(personId, key, change, attemptsLeft - 1);
4180
+ } else {
4181
+ return null;
4182
+ }
4183
+ }
4184
+ } else {
4185
+ return null;
4186
+ }
4187
+ }
4188
+
4189
+ // Each product keeps its own copy in localStorage, and user-store-sync joins
4190
+ // the copies up across products and devices wherever the product can mint a
4191
+ // token for it.
4192
+ const SYNC_KEY = "recently_viewed";
3900
4193
  function storageKey({
3901
4194
  env,
3902
4195
  personId
@@ -3915,17 +4208,60 @@ function readRecentlyVisited(scope) {
3915
4208
  return [];
3916
4209
  }
3917
4210
  }
4211
+ function writeRecentlyVisited(scope, entries) {
4212
+ try {
4213
+ localStorage.setItem(storageKey(scope), JSON.stringify(entries));
4214
+ changes.setState({
4215
+ version: changes.getState().version + 1
4216
+ });
4217
+ } catch {
4218
+ // Storage can be full or blocked; recents are disposable.
4219
+ }
4220
+ }
4221
+ async function syncOnce(scope) {
4222
+ try {
4223
+ const synced = await updateStoredValue(scope.personId, SYNC_KEY, stored => {
4224
+ const shared = entriesFrom(stored);
4225
+ const local = readRecentlyVisited(scope);
4226
+ return isSameList(mergeEntries(shared, local), shared) ? null : mergeStored(stored, local);
4227
+ });
4228
+ if (synced) {
4229
+ writeRecentlyVisited(scope, mergeEntries(entriesFrom(synced), readRecentlyVisited(scope)));
4230
+ }
4231
+ } catch {
4232
+ // The service is best-effort; the local copy stands on its own.
4233
+ }
4234
+ }
4235
+ const syncing = new Map();
4236
+ const syncAgain = new Set();
4237
+ // One sync per list at a time. A visit recorded while one runs schedules a
4238
+ // single follow-up, so it still reaches the service; anything else asking
4239
+ // mid-sync just waits for the one running.
4240
+ function sync(scope, followUp) {
4241
+ const key = storageKey(scope);
4242
+ const running = syncing.get(key);
4243
+ if (isUserStoreSyncUnavailable()) {
4244
+ return Promise.resolve();
4245
+ } else if (running) {
4246
+ if (followUp) syncAgain.add(key);
4247
+ return running;
4248
+ } else {
4249
+ const run = syncOnce(scope).then(() => {
4250
+ syncing.delete(key);
4251
+ return syncAgain.delete(key) ? sync(scope, false) : undefined;
4252
+ });
4253
+ syncing.set(key, run);
4254
+ return run;
4255
+ }
4256
+ }
4257
+ function syncRecentlyVisited(scope) {
4258
+ return sync(scope, false);
4259
+ }
3918
4260
  function recordVisit(visit, scope) {
3919
4261
  const entry = toEntry(visit, new Date());
3920
4262
  if (isEntry(entry)) {
3921
- try {
3922
- localStorage.setItem(storageKey(scope), JSON.stringify(addVisit(readRecentlyVisited(scope), entry)));
3923
- changes.setState({
3924
- version: changes.getState().version + 1
3925
- });
3926
- } catch {
3927
- // Storage can be full or blocked; recents are disposable.
3928
- }
4263
+ writeRecentlyVisited(scope, addVisit(readRecentlyVisited(scope), entry));
4264
+ sync(scope, true);
3929
4265
  }
3930
4266
  }
3931
4267
  function useRecentlyVisited({
@@ -3933,6 +4269,14 @@ function useRecentlyVisited({
3933
4269
  personId
3934
4270
  }) {
3935
4271
  const version = shim.useSyncExternalStore(changes.subscribe, () => changes.getState().version);
4272
+ React.useEffect(() => {
4273
+ if (personId) {
4274
+ syncRecentlyVisited({
4275
+ env,
4276
+ personId
4277
+ });
4278
+ }
4279
+ }, [env, personId]);
3936
4280
  // `version` isn't read, but listing it re-reads storage after each visit.
3937
4281
  return React.useMemo(() => personId ? readRecentlyVisited({
3938
4282
  env,
@@ -4107,28 +4451,44 @@ function useCopyLink() {
4107
4451
  };
4108
4452
  }
4109
4453
 
4454
+ function indexOf(highlight, items, list) {
4455
+ const pinnedIndex = items.findIndex(({
4456
+ key
4457
+ }) => key === highlight.key);
4458
+ if (highlight.list !== list) {
4459
+ return 0;
4460
+ } else if (pinnedIndex >= 0) {
4461
+ return pinnedIndex;
4462
+ } else {
4463
+ return highlight.index;
4464
+ }
4465
+ }
4110
4466
  // Remembering which list the index belongs to puts the highlight back on the
4111
4467
  // first item whenever a new list replaces it, without an effect to reset it.
4112
- // A list that grows or fills in under the same `list` keeps its highlight.
4468
+ // A list that grows, fills in, or reorders under the same `list` keeps its
4469
+ // highlight on the same item, found again by key.
4113
4470
  // Disabled, nothing is highlighted and the keys and pointer leave it alone.
4114
4471
  function useResultNavigation(items, list, enabled = true) {
4115
4472
  const [highlight, setHighlight] = React.useState({
4116
4473
  index: 0,
4117
4474
  list
4118
4475
  });
4119
- const highlightedIndex = highlight.list === list ? highlight.index : 0;
4476
+ const highlightedIndex = indexOf(highlight, items, list);
4120
4477
  const highlighted = enabled ? items[highlightedIndex] : undefined;
4121
4478
  const {
4122
4479
  copiedUrl,
4123
4480
  copy
4124
4481
  } = useCopyLink();
4125
4482
  const highlightIndex = React.useCallback(index => {
4483
+ var _a;
4126
4484
  if (!enabled) return;
4127
- setHighlight(current => current.list === list && current.index === index ? current : {
4485
+ const key = (_a = items[index]) === null || _a === void 0 ? void 0 : _a.key;
4486
+ setHighlight(current => current.list === list && current.index === index && current.key === key ? current : {
4128
4487
  index,
4488
+ key,
4129
4489
  list
4130
4490
  });
4131
- }, [enabled, list]);
4491
+ }, [enabled, items, list]);
4132
4492
  const handleKeyDown = event => {
4133
4493
  if (!enabled) return;
4134
4494
  const step = verticalStep(event.key);