@juspay/svelte-ui-components 4.54.1 → 4.55.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.
@@ -65,6 +65,7 @@
65
65
  statusText,
66
66
  statusTestId,
67
67
  slashCommands = null,
68
+ inlineTriggers = null,
68
69
  slashMenuAriaLabel = 'slash commands',
69
70
  slashMenuHint = '↑↓ move · tab complete · ↵ select · esc dismiss',
70
71
  slashMenuClasses,
@@ -96,6 +97,7 @@
96
97
 
97
98
  let fileInput: HTMLInputElement | null = $state(null);
98
99
  let inputElement: HTMLTextAreaElement | null = $state(null);
100
+ let menuRaised = $state(false);
99
101
 
100
102
  export const focus = (options?: FocusOptions): void => {
101
103
  inputElement?.focus(options);
@@ -133,6 +135,10 @@
133
135
  let slashDismissed = $state(false);
134
136
  let slashSelected = $state(0);
135
137
  let slashList: HTMLUListElement | null = $state(null);
138
+ let highlightLayer: HTMLDivElement | null = $state(null);
139
+
140
+ // Where the caret sits, so an inline trigger can open the menu on the word it is in.
141
+ let caret = $state(0);
136
142
 
137
143
  // A command opens on the first character of its name: "/model" on "/",
138
144
  // "@support" on "@". Letters, digits and whitespace never trigger, so a
@@ -143,12 +149,31 @@
143
149
  return /^[^\p{L}\p{N}\s]$/u.test(first) ? first : null;
144
150
  }
145
151
 
152
+ // The word ending at the caret when it starts with an inline trigger: "@ge" in
153
+ // "ask @ge|". Null unless the caret sits at the end of that word.
154
+ const inlineWord = $derived.by((): { start: number; text: string } | null => {
155
+ if (inlineTriggers === null || slashCommands === null) {
156
+ return null;
157
+ }
158
+ const text = /(?:^|\s)(\S*)$/.exec(value.slice(0, caret))?.[1] ?? '';
159
+ const trigger = slashTrigger(text);
160
+ if (trigger === null || !inlineTriggers.includes(trigger) || /^\S/.test(value.slice(caret))) {
161
+ return null;
162
+ }
163
+ return { start: caret - text.length, text };
164
+ });
165
+
146
166
  const slashMatches = $derived.by((): ChatComposerSlashCommand[] => {
147
- const trigger = slashTrigger(value);
148
- if (slashCommands === null || slashDismissed || trigger === null || /\s/.test(value)) {
167
+ const typed = inlineWord?.text ?? value;
168
+ const trigger = slashTrigger(typed);
169
+ if (slashCommands === null || slashDismissed || trigger === null || /\s/.test(typed)) {
149
170
  return [];
150
171
  }
151
- const query = value.toLowerCase();
172
+ // An inline trigger opens only on the word at the caret.
173
+ if (inlineWord === null && inlineTriggers?.includes(trigger)) {
174
+ return [];
175
+ }
176
+ const query = typed.toLowerCase();
152
177
  return slashCommands.filter(
153
178
  (command) =>
154
179
  slashTrigger(command.name) === trigger &&
@@ -163,7 +188,8 @@
163
188
  return null;
164
189
  }
165
190
  const parsed = /^(\S+)\s(\S*)$/.exec(value);
166
- if (parsed === null || slashTrigger(parsed[1] ?? '') === null) {
191
+ const trigger = parsed === null ? null : slashTrigger(parsed[1] ?? '');
192
+ if (parsed === null || trigger === null || inlineTriggers?.includes(trigger)) {
167
193
  return null;
168
194
  }
169
195
  const name = (parsed[1] ?? '').toLowerCase();
@@ -229,6 +255,15 @@
229
255
  if (typeof command !== 'object') {
230
256
  return;
231
257
  }
258
+ // Inline: replace only the word at the caret and keep the rest of the text.
259
+ const word = inlineWord;
260
+ if (word !== null) {
261
+ const rest = value.slice(caret).replace(/^\s/, '');
262
+ value = `${value.slice(0, word.start)}${command.name} ${rest}`;
263
+ placeCaret(word.start + command.name.length + 1);
264
+ slashSelected = 0;
265
+ return;
266
+ }
232
267
  // A command that takes arguments gets a trailing space and waits for
233
268
  // them; with enumerable choices the menu stays open for stage two.
234
269
  value = `${command.name} `;
@@ -236,6 +271,111 @@
236
271
  slashSelected = 0;
237
272
  }
238
273
 
274
+ function placeCaret(position: number): void {
275
+ caret = position;
276
+ void tick().then(() => {
277
+ inputElement?.focus();
278
+ inputElement?.setSelectionRange(position, position);
279
+ });
280
+ }
281
+
282
+ function trackCaret(event: Event & { currentTarget: HTMLTextAreaElement }): void {
283
+ caret = event.currentTarget.selectionStart ?? value.length;
284
+ }
285
+
286
+ // Command names with an inline trigger, drawn in the mention colour wherever they sit.
287
+ const mentionNames = $derived.by((): Set<string> => {
288
+ if (inlineTriggers === null || slashCommands === null) {
289
+ return new Set();
290
+ }
291
+ const triggers = inlineTriggers;
292
+ return new Set(
293
+ slashCommands
294
+ .map((command) => command.name.toLowerCase())
295
+ .filter((name) => {
296
+ const trigger = slashTrigger(name);
297
+ return trigger !== null && triggers.includes(trigger);
298
+ })
299
+ );
300
+ });
301
+
302
+ function syncHighlightScroll(): void {
303
+ if (highlightLayer !== null && inputElement !== null) {
304
+ highlightLayer.scrollTop = inputElement.scrollTop;
305
+ }
306
+ }
307
+
308
+ // Painted from script, not the template: the layer must hold the text exactly as typed,
309
+ // and template whitespace would be collapsed. The trailing space keeps a final newline
310
+ // its own line, as the textarea shows it.
311
+ const paintMentions: Action<HTMLDivElement, { text: string; names: Set<string> }> = (
312
+ node,
313
+ initial
314
+ ) => {
315
+ function paint({ text, names }: { text: string; names: Set<string> }): void {
316
+ node.replaceChildren(
317
+ ...text.split(/(\s+)/).map((part) => {
318
+ if (!names.has(part.toLowerCase())) {
319
+ return document.createTextNode(part);
320
+ }
321
+ const mention = document.createElement('span');
322
+ mention.className = 'mention';
323
+ mention.textContent = part;
324
+ return mention;
325
+ }),
326
+ document.createTextNode(' ')
327
+ );
328
+ syncHighlightScroll();
329
+ }
330
+ paint(initial);
331
+ return { update: paint };
332
+ };
333
+
334
+ // The menu opens in the browser's top layer where it can, so no ancestor's overflow or
335
+ // mask clips it, and is then placed against the composer exactly where it sat before.
336
+ // Without popover support it stays absolutely positioned inside the composer.
337
+ const raiseMenu: Action<HTMLDivElement> = (node) => {
338
+ // The menu is the composer's own child, so its parent is the box it sits above.
339
+ const anchor = node.parentElement;
340
+ if (anchor === null || typeof node.showPopover !== 'function') {
341
+ return {};
342
+ }
343
+ node.setAttribute('popover', 'manual');
344
+ try {
345
+ node.showPopover();
346
+ } catch {
347
+ node.removeAttribute('popover');
348
+ return {};
349
+ }
350
+ menuRaised = true;
351
+ function place(): void {
352
+ if (anchor === null) {
353
+ return;
354
+ }
355
+ const rect = anchor.getBoundingClientRect();
356
+ const outset =
357
+ parseFloat(
358
+ getComputedStyle(anchor).getPropertyValue('--chat-composer-slash-menu-outset')
359
+ ) || 0;
360
+ node.style.left = `${rect.left + anchor.clientLeft - outset}px`;
361
+ node.style.width = `${anchor.clientWidth + outset * 2}px`;
362
+ node.style.bottom = `${document.documentElement.clientHeight - (rect.top + anchor.clientTop) + outset}px`;
363
+ }
364
+ place();
365
+ const resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(place) : null;
366
+ resizeObserver?.observe(anchor);
367
+ window.addEventListener('resize', place);
368
+ window.addEventListener('scroll', place, true);
369
+ return {
370
+ destroy() {
371
+ resizeObserver?.disconnect();
372
+ window.removeEventListener('resize', place);
373
+ window.removeEventListener('scroll', place, true);
374
+ menuRaised = false;
375
+ }
376
+ };
377
+ };
378
+
239
379
  // Keeps the highlighted row in view while the list scrolls.
240
380
  function revealSlash(): void {
241
381
  void tick().then(() => {
@@ -265,8 +405,8 @@
265
405
  if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
266
406
  event.preventDefault();
267
407
  // Enter on a complete match submits it; otherwise it completes and
268
- // the next Enter submits.
269
- if (value.trim() === slashCompletion(slashActive)) {
408
+ // the next Enter submits. An inline word always completes.
409
+ if (inlineWord === null && value.trim() === slashCompletion(slashActive)) {
270
410
  submit();
271
411
  } else {
272
412
  pickSlash(slashActive);
@@ -342,6 +482,7 @@
342
482
  // A fresh edit un-dismisses the slash menu and resets its highlight.
343
483
  slashDismissed = false;
344
484
  slashSelected = 0;
485
+ trackCaret(event);
345
486
  oninput?.(event.currentTarget.value, event);
346
487
  }
347
488
 
@@ -409,8 +550,25 @@
409
550
  {/snippet}
410
551
 
411
552
  {#snippet textInput()}
553
+ {#if inlineTriggers !== null}
554
+ <div class="input-field">
555
+ <div
556
+ class="input-highlights"
557
+ aria-hidden="true"
558
+ bind:this={highlightLayer}
559
+ use:paintMentions={{ text: value, names: mentionNames }}
560
+ ></div>
561
+ {@render textArea()}
562
+ </div>
563
+ {:else}
564
+ {@render textArea()}
565
+ {/if}
566
+ {/snippet}
567
+
568
+ {#snippet textArea()}
412
569
  <textarea
413
570
  class="input"
571
+ class:inline-mentions={inlineTriggers !== null}
414
572
  bind:this={inputElement}
415
573
  data-pw={inputTestId ?? null}
416
574
  bind:value
@@ -427,6 +585,9 @@
427
585
  oninput={handleInput}
428
586
  onfocus={(event) => oninputfocus?.(event)}
429
587
  onkeydown={handleKeydown}
588
+ onkeyup={trackCaret}
589
+ onclick={trackCaret}
590
+ onscroll={syncHighlightScroll}
430
591
  onpaste={(event) => onpaste?.(event)}
431
592
  use:autoGrow={value}
432
593
  ></textarea>
@@ -518,10 +679,12 @@
518
679
  {#if slashOpen}
519
680
  <div
520
681
  class="slash-menu {slashMenuClasses ?? ''}"
682
+ class:top-layer={menuRaised}
521
683
  role="listbox"
522
684
  id={slashListboxId}
523
685
  aria-label={slashMenuAriaLabel}
524
686
  use:slashDismissal
687
+ use:raiseMenu
525
688
  >
526
689
  <!-- role="presentation": a listbox's accessible children must be its own role=option
527
690
  rows. Left as a plain <ul>, this list's implicit role=list would sit between the
@@ -541,6 +704,7 @@
541
704
  >
542
705
  <Button
543
706
  type="button"
707
+ variant="ghost"
544
708
  classes={[slashItemClasses, index === slashActive ? slashSelectedItemClasses : '']
545
709
  .filter((value) => typeof value === 'string' && value.length > 0)
546
710
  .join(' ')}
@@ -716,6 +880,58 @@
716
880
  overflow-y: auto;
717
881
  }
718
882
 
883
+ /* Inline mentions: an identical text layer sits behind a transparent textarea and
884
+ colours the mentions, so the caret, selection and editing stay the textarea's own. */
885
+ .input-field {
886
+ position: relative;
887
+ flex: 1;
888
+ min-width: 0;
889
+ display: flex;
890
+ }
891
+
892
+ .input-field .input {
893
+ position: relative;
894
+ width: 100%;
895
+ scrollbar-gutter: stable;
896
+ }
897
+
898
+ .input.inline-mentions {
899
+ color: transparent;
900
+ caret-color: var(--chat-composer-color, #18181b);
901
+ }
902
+
903
+ .input.inline-mentions::selection {
904
+ color: var(--chat-composer-color, #18181b);
905
+ }
906
+
907
+ .input-highlights {
908
+ position: absolute;
909
+ inset: 0;
910
+ box-sizing: border-box;
911
+ overflow: hidden;
912
+ scrollbar-gutter: stable;
913
+ pointer-events: none;
914
+ white-space: pre-wrap;
915
+ overflow-wrap: break-word;
916
+ letter-spacing: normal;
917
+ word-spacing: normal;
918
+ font-family: var(--chat-composer-font-family, inherit);
919
+ font-size: var(--chat-composer-font-size, 0.9375rem);
920
+ font-weight: var(--chat-composer-font-weight, 400);
921
+ line-height: var(--chat-composer-line-height, 1.5);
922
+ color: var(--chat-composer-color, #18181b);
923
+ padding: var(--chat-composer-input-padding, 6px 4px);
924
+ }
925
+
926
+ /* The mention must lay out exactly like the textarea's text, so it inherits every font
927
+ property: a consumer's global span styles would otherwise shift it off the caret. */
928
+ .input-highlights :global(.mention) {
929
+ font: inherit;
930
+ letter-spacing: inherit;
931
+ word-spacing: inherit;
932
+ color: var(--chat-composer-mention-color, var(--chat-composer-slash-name-color, #2563eb));
933
+ }
934
+
719
935
  .input::placeholder {
720
936
  color: var(--chat-composer-placeholder-color, #52525b);
721
937
  }
@@ -792,10 +1008,24 @@
792
1008
  margin-bottom: var(--chat-composer-slash-menu-gap, 8px);
793
1009
  overflow: hidden;
794
1010
  z-index: var(--chat-composer-slash-menu-z-index, 30);
795
- background: var(--chat-composer-slash-menu-background, #ffffff);
796
- border: var(--chat-composer-slash-menu-border, 1px solid #e4e4e7);
1011
+ background: var(--chat-composer-slash-menu-background, var(--menu-background-color, #ffffff));
1012
+ border: var(--chat-composer-slash-menu-border, var(--menu-border, 1px solid #e0e0e0));
797
1013
  border-radius: var(--chat-composer-slash-menu-border-radius, 12px);
798
- box-shadow: var(--chat-composer-slash-menu-box-shadow, 0 8px 24px rgb(0 0 0 / 12%));
1014
+ box-shadow: var(
1015
+ --chat-composer-slash-menu-box-shadow,
1016
+ var(--menu-box-shadow, 0px 4px 16px rgba(0, 0, 0, 0.12))
1017
+ );
1018
+ }
1019
+
1020
+ /* In the top layer the menu is placed by script against the composer, so the popover's
1021
+ own defaults (inset, auto margin, padding, overflow) are cleared. */
1022
+ .chat-composer > .slash-menu.top-layer {
1023
+ position: fixed;
1024
+ inset: auto;
1025
+ box-sizing: border-box;
1026
+ margin: 0 0 var(--chat-composer-slash-menu-gap, 8px);
1027
+ padding: 0;
1028
+ color: inherit;
799
1029
  }
800
1030
 
801
1031
  .slash-menu > .slash-list {
@@ -808,17 +1038,40 @@
808
1038
  gap: var(--chat-composer-slash-list-gap, 2px);
809
1039
  }
810
1040
 
1041
+ /* Rows are menu items, not buttons: the row sets its Button's tokens itself so a
1042
+ consumer's app-wide button fill never reaches it, and the highlighted row is
1043
+ tinted by the <li> beneath a transparent Button. */
811
1044
  .slash-menu .slash-list li {
812
1045
  min-width: 0;
1046
+ border-radius: var(--chat-composer-slash-item-border-radius, 8px);
1047
+ --button-color: var(--chat-composer-slash-item-background, transparent);
1048
+ --button-background: none;
1049
+ --button-hover-color: var(--chat-composer-slash-item-hover-background, transparent);
1050
+ --button-text-color: var(--chat-composer-slash-item-color, var(--menu-item-color, #333333));
1051
+ --button-hover-text-color: var(
1052
+ --chat-composer-slash-item-color,
1053
+ var(--menu-item-color, #333333)
1054
+ );
1055
+ --button-border: none;
1056
+ --button-box-shadow: none;
1057
+ --button-border-radius: var(--chat-composer-slash-item-border-radius, 8px);
1058
+ --button-width: 100%;
1059
+ --button-height: auto;
1060
+ --button-justify-content: flex-start;
1061
+ --button-padding: var(--chat-composer-slash-item-padding, 8px 12px);
1062
+ --button-content-gap: var(--chat-composer-slash-item-gap, 8px);
813
1063
  }
814
1064
 
815
1065
  .slash-menu > .slash-foot {
816
1066
  padding: var(--chat-composer-slash-foot-padding, 4px 12px);
817
- border-top: var(--chat-composer-slash-foot-border, 1px solid #e4e4e7);
1067
+ border-top: var(
1068
+ --chat-composer-slash-foot-border,
1069
+ 1px solid var(--menu-separator-color, #e0e0e0)
1070
+ );
818
1071
  font-family: var(--chat-composer-slash-foot-font-family, inherit);
819
1072
  font-variant-ligatures: var(--chat-composer-slash-foot-font-variant-ligatures, inherit);
820
1073
  font-size: var(--chat-composer-slash-foot-font-size, 12px);
821
- color: var(--chat-composer-slash-foot-color, #71717a);
1074
+ color: var(--chat-composer-slash-foot-color, var(--menu-item-color, #333333));
822
1075
  }
823
1076
 
824
1077
  /* The default row content (a consumer's slashItem snippet replaces it). */
@@ -837,6 +1090,7 @@
837
1090
  .slash-menu .slash-desc {
838
1091
  flex: 1;
839
1092
  min-width: 0;
1093
+ text-align: start;
840
1094
  overflow: hidden;
841
1095
  text-overflow: ellipsis;
842
1096
  white-space: nowrap;
@@ -849,7 +1103,10 @@
849
1103
  Scoped so a consumer's own class stays authoritative the moment it's
850
1104
  supplied: this rule simply never applies then. */
851
1105
  .slash-menu .slash-item-selected-default {
852
- background: var(--chat-composer-slash-item-selected-background, #f4f4f5);
1106
+ background: var(
1107
+ --chat-composer-slash-item-selected-background,
1108
+ var(--menu-item-hover-background-color, #f5f5f5)
1109
+ );
853
1110
  }
854
1111
 
855
1112
  .slash-menu .slash-badge {
@@ -111,6 +111,14 @@ export type OptionalChatComposerProperties = {
111
111
  * stage listing them. `null` (the default) turns the whole feature off.
112
112
  */
113
113
  slashCommands?: ChatComposerSlashCommand[] | null;
114
+ /**
115
+ * Triggers that open the command menu on the word at the caret, anywhere in the text,
116
+ * instead of only when the whole input is one command: `['@']` makes "@geo" mid-sentence
117
+ * list the `@` commands, and picking one replaces just that word. Each command name with
118
+ * one of these triggers is drawn in `--chat-composer-mention-color` wherever it appears in
119
+ * the input. `null` (the default) keeps every trigger start-of-input only.
120
+ */
121
+ inlineTriggers?: string[] | null;
114
122
  /** Accessible name of the slash-command listbox. Defaults to 'slash commands'. */
115
123
  slashMenuAriaLabel?: string;
116
124
  /** Key-hint line under the slash-command list. */
@@ -18,6 +18,7 @@
18
18
  import { truncateToWidth } from '../_chart/labels';
19
19
  import {
20
20
  DEFAULT_CHART_CORNER_RADIUS,
21
+ CHART_LABEL_PLATE_PADDING,
21
22
  DEFAULT_CHART_MAX_HEIGHT,
22
23
  type TooltipAnchor
23
24
  } from '../_chart/types';
@@ -369,8 +370,10 @@
369
370
  measureText(truncateColumnLabel(label), colLabelFont).width,
370
371
  fontMetrics?.columnWidths[columnIndex] ?? 0
371
372
  );
372
- const canvasLeft = -(marginX + firstColumnLabelGutter) + 2;
373
- const canvasRight = plotWidth + lastColumnLabelGutter + marginX - 2;
373
+ // Inset by the plate padding: a header clamped any closer paints its plate
374
+ // outside the SVG, where it is clipped.
375
+ const canvasLeft = -(marginX + firstColumnLabelGutter) + CHART_LABEL_PLATE_PADDING;
376
+ const canvasRight = plotWidth + lastColumnLabelGutter + marginX - CHART_LABEL_PLATE_PADDING;
374
377
  const min = canvasLeft + textWidth / 2;
375
378
  const max = canvasRight - textWidth / 2;
376
379
  // Text wider than the whole canvas keeps its centre; the colWidth-based
@@ -1,10 +1,11 @@
1
1
  <script lang="ts">
2
2
  import { onMount, type Snippet } from 'svelte';
3
+ import { CHART_LABEL_PLATE_PADDING } from './types';
3
4
 
4
5
  let { children }: { children: Snippet } = $props();
5
6
  let group: SVGGElement | null = $state(null);
6
7
  let bounds: { x: number; y: number; width: number; height: number } | null = $state(null);
7
- const PADDING = 3;
8
+ const PADDING = CHART_LABEL_PLATE_PADDING;
8
9
 
9
10
  onMount(() => {
10
11
  const text = group?.querySelector('text');
@@ -18,6 +18,12 @@ export declare const DEFAULT_CHART_CORNER_RADIUS = 4;
18
18
  * an explicit `maxHeight` to override it.
19
19
  */
20
20
  export declare const DEFAULT_CHART_MAX_HEIGHT = 420;
21
+ /**
22
+ * Padding (px) a label plate draws around its text's bounding box. A label that
23
+ * is clamped to a canvas edge must stay at least this far inside it, otherwise
24
+ * its plate paints outside the SVG and is clipped there.
25
+ */
26
+ export declare const CHART_LABEL_PLATE_PADDING = 3;
21
27
  export type Margin = {
22
28
  top: number;
23
29
  right: number;
@@ -17,3 +17,9 @@ export const DEFAULT_CHART_CORNER_RADIUS = 4;
17
17
  * an explicit `maxHeight` to override it.
18
18
  */
19
19
  export const DEFAULT_CHART_MAX_HEIGHT = 420;
20
+ /**
21
+ * Padding (px) a label plate draws around its text's bounding box. A label that
22
+ * is clamped to a canvas edge must stay at least this far inside it, otherwise
23
+ * its plate paints outside the SVG and is clipped there.
24
+ */
25
+ export const CHART_LABEL_PLATE_PADDING = 3;
package/dist/utils.js CHANGED
@@ -386,8 +386,14 @@ function scrollLocksFor(container) {
386
386
  locks = new WeakMap();
387
387
  Object.defineProperty(owner, scrollLocksKey, { value: locks });
388
388
  }
389
- // The same real owner can be adopted into another document while held.
390
- Object.defineProperty(activeOwner, activeScrollLocksKey, { value: locks, configurable: true });
389
+ // The same real owner can be adopted into another document while held. A sealed or
390
+ // non-extensible owner cannot carry the pointer, so it keeps only the document registry.
391
+ try {
392
+ Object.defineProperty(activeOwner, activeScrollLocksKey, { value: locks, configurable: true });
393
+ }
394
+ catch {
395
+ // Locking still works; adoption of such an owner while held is not tracked.
396
+ }
391
397
  return locks;
392
398
  }
393
399
  const bodyScrollReleases = [];
@@ -415,9 +421,15 @@ export function acquireScrollLock(container = typeof document === 'undefined' ?
415
421
  ]);
416
422
  }
417
423
  }
418
- lock = { count: 0, overflow };
424
+ lock = {
425
+ count: 0,
426
+ overflow,
427
+ originalCssText: container.style.cssText,
428
+ lockedCssText: ''
429
+ };
419
430
  scrollLocks.set(container, lock);
420
431
  container.style.setProperty('overflow', 'hidden', 'important');
432
+ lock.lockedCssText = container.style.cssText;
421
433
  }
422
434
  lock.count += 1;
423
435
  const heldLock = lock;
@@ -429,11 +441,18 @@ export function acquireScrollLock(container = typeof document === 'undefined' ?
429
441
  released = true;
430
442
  heldLock.count -= 1;
431
443
  if (heldLock.count === 0) {
432
- container.style.removeProperty('overflow');
433
- container.style.removeProperty('overflow-x');
434
- container.style.removeProperty('overflow-y');
435
- for (const [name, value, priority] of heldLock.overflow) {
436
- container.style.setProperty(name, value, priority);
444
+ if (container.style.cssText === heldLock.lockedCssText) {
445
+ // Nothing else wrote to the inline style while held: put it back verbatim, which keeps the
446
+ // declaration order that removing and re-adding the overflow longhands would change.
447
+ container.style.cssText = heldLock.originalCssText;
448
+ }
449
+ else {
450
+ container.style.removeProperty('overflow');
451
+ container.style.removeProperty('overflow-x');
452
+ container.style.removeProperty('overflow-y');
453
+ for (const [name, value, priority] of heldLock.overflow) {
454
+ container.style.setProperty(name, value, priority);
455
+ }
437
456
  }
438
457
  scrollLocks.delete(container);
439
458
  Reflect.deleteProperty(container, activeScrollLocksKey);