@widgetic/editor 4.0.10 → 4.1.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.
Files changed (84) hide show
  1. package/README.md +242 -0
  2. package/dist/components/WidgetPreview.svelte +123 -86
  3. package/dist/components/WidgetPreview.svelte.d.ts +8 -0
  4. package/dist/components/color-select/ColorSelect.svelte +4 -4
  5. package/dist/components/editors/CompositionEditorApp.svelte +562 -0
  6. package/dist/components/editors/CompositionEditorApp.svelte.d.ts +95 -0
  7. package/dist/components/editors/EmbedEditor.svelte +33 -22
  8. package/dist/components/editors/EmbedEditor.svelte.d.ts +1 -1
  9. package/dist/components/editors/PropsEditor.svelte +267 -850
  10. package/dist/components/editors/PropsEditor.svelte.d.ts +10 -3
  11. package/dist/components/ic/AngleInputController.svelte +1 -1
  12. package/dist/components/ic/BrowserInputController.svelte +58 -31
  13. package/dist/components/ic/ColorPickerInputController.svelte +55 -21
  14. package/dist/components/ic/DateFieldInputController.svelte +34 -14
  15. package/dist/components/ic/DatePickerInputController.svelte +37 -21
  16. package/dist/components/ic/DropdownInputController.svelte +21 -9
  17. package/dist/components/ic/DropdownInputController.svelte.d.ts +1 -1
  18. package/dist/components/ic/FixedPositionInputController.svelte +12 -6
  19. package/dist/components/ic/FontInputController.svelte +100 -76
  20. package/dist/components/ic/InputControllerHeader.svelte +16 -9
  21. package/dist/components/ic/NumberControls.svelte +4 -4
  22. package/dist/components/ic/NumberInputController.svelte +6 -4
  23. package/dist/components/ic/PositionInputController.svelte +23 -15
  24. package/dist/components/ic/RangeCalendarInputController.svelte +36 -20
  25. package/dist/components/ic/RoundedCornersInputController.svelte +39 -26
  26. package/dist/components/ic/SliderInputController.svelte +7 -2
  27. package/dist/components/ic/SwitchInputController.svelte +12 -3
  28. package/dist/components/ic/TagsInputController.svelte +19 -4
  29. package/dist/components/ic/TextAreaInputController.svelte +79 -51
  30. package/dist/components/ic/TextAreaPreviewInputController.svelte +27 -72
  31. package/dist/components/ic/TextInputController.svelte +17 -1
  32. package/dist/components/ic/TimeInputController.svelte +11 -4
  33. package/dist/components/ic/VectorInputController.svelte +101 -70
  34. package/dist/components/ic/shared/ItemGrid.svelte +12 -4
  35. package/dist/components/ic/shared/RecentItemsSection.svelte +16 -11
  36. package/dist/components/ic/shared/RecentItemsSection.svelte.d.ts +1 -1
  37. package/dist/composition/compositionApi.d.ts +95 -0
  38. package/dist/composition/compositionApi.js +289 -0
  39. package/dist/composition/compositionEditorController.d.ts +263 -0
  40. package/dist/composition/compositionEditorController.js +746 -0
  41. package/dist/composition/compositionHelpers.d.ts +74 -0
  42. package/dist/composition/compositionHelpers.js +405 -0
  43. package/dist/i18n/index.d.ts +4 -0
  44. package/dist/i18n/index.js +4 -0
  45. package/dist/i18n/labels.d.ts +15 -0
  46. package/dist/i18n/labels.js +57 -0
  47. package/dist/i18n/language.d.ts +228 -0
  48. package/dist/i18n/language.js +34 -0
  49. package/dist/i18n/languageKit.d.ts +54 -0
  50. package/dist/i18n/languageKit.js +102 -0
  51. package/dist/i18n/messages.d.ts +241 -0
  52. package/dist/i18n/messages.js +253 -0
  53. package/dist/i18n/translate.d.ts +6 -0
  54. package/dist/i18n/translate.js +8 -0
  55. package/dist/index.d.ts +4 -0
  56. package/dist/index.js +8 -0
  57. package/dist/mediaLibraryContext.d.ts +10 -0
  58. package/dist/mediaLibraryContext.js +30 -0
  59. package/dist/utils/FontManager.d.ts +21 -5
  60. package/dist/utils/FontManager.js +62 -67
  61. package/dist/utils/VectorManager.d.ts +0 -1
  62. package/dist/utils/VectorManager.js +10 -12
  63. package/dist/utils/dockPlacement.d.ts +12 -0
  64. package/dist/utils/dockPlacement.js +13 -0
  65. package/dist/utils/editorStorage.d.ts +23 -0
  66. package/dist/utils/editorStorage.js +95 -0
  67. package/dist/utils/fieldDirection.d.ts +13 -0
  68. package/dist/utils/fieldDirection.js +25 -0
  69. package/dist/utils/fontUtils.js +5 -3
  70. package/dist/utils/htmlSafety.d.ts +21 -0
  71. package/dist/utils/htmlSafety.js +38 -0
  72. package/dist/utils/itemReorder.d.ts +90 -0
  73. package/dist/utils/itemReorder.js +354 -0
  74. package/dist/utils/recentColors.js +4 -2
  75. package/dist/utils/richTextPreview.d.ts +38 -0
  76. package/dist/utils/richTextPreview.js +207 -0
  77. package/dist/utils/scrollActivity.d.ts +14 -0
  78. package/dist/utils/scrollActivity.js +63 -0
  79. package/dist/utils/shadowDom.d.ts +67 -0
  80. package/dist/utils/shadowDom.js +211 -0
  81. package/dist/utils/vectorUtils.js +5 -3
  82. package/dist/utils/visibilityCondition.d.ts +40 -0
  83. package/dist/utils/visibilityCondition.js +535 -0
  84. package/package.json +7 -6
@@ -63,6 +63,9 @@
63
63
  import FontManager, { mapGoogleVariantIdsToWeightNames } from '../../utils/FontManager';
64
64
 
65
65
  import { shouldSkipFont } from '../../utils/skipFonts';
66
+ import { escapeHtml, cssQuotedString } from '../../utils/htmlSafety';
67
+ import { acquirePortalHost, releasePortalHost, listenForScroll, queryInTreeOf } from '../../utils/shadowDom';
68
+ import { readEditorStorageValue, writeEditorStorageValue } from '../../utils/editorStorage';
66
69
  import type { Writable } from 'svelte/store';
67
70
 
68
71
  import { InputControllerHeader } from './index';
@@ -71,6 +74,11 @@
71
74
  import { SearchInput } from '@widgetic/design-system/components';
72
75
  import CategoryTabs from './shared/CategoryTabs.svelte';
73
76
  import { RecentItemsSection, ItemGrid } from './shared';
77
+ import { useEditorLanguage } from '../../i18n/language';
78
+ import { categoryLabel, fontWeightLabel } from '../../i18n/labels';
79
+
80
+ // The words of the editor in the language of the page.
81
+ const { t } = useEditorLanguage();
74
82
 
75
83
 
76
84
  const FONT_FILTER_KEY = (id: string) => `font-specific-filter-state-${id}`;
@@ -125,6 +133,8 @@
125
133
 
126
134
  // Font loading optimization
127
135
  let fontListContainer: HTMLElement;
136
+ // The control's own element: it says which tree (the page or a module's shadow root) to listen for scrolling in
137
+ let controllerElement: HTMLDivElement | undefined;
128
138
  let familyTriggerEl: HTMLDivElement | undefined;
129
139
  let familyPopoverStyle = '';
130
140
  let familyPopoverSide: 'top' | 'bottom' = 'bottom';
@@ -191,6 +201,15 @@
191
201
  // Add a timeout to track long-running initial loads
192
202
  let initialLoadTimeout: ReturnType<typeof setTimeout> | null = null;
193
203
 
204
+ /**
205
+ * The sticky header of the font list. It is looked up in the tree the list lives in (inside a module that is a
206
+ * shadow root, which `document.querySelector` cannot see into), and the id is escaped so that one with dots or
207
+ * brackets in it is found too.
208
+ */
209
+ function findStickyHeader(): Element | null {
210
+ return queryInTreeOf(fontListContainer, `#${CSS.escape(`font-header-${controllerId}`)}`);
211
+ }
212
+
194
213
  // Function to update scroll state
195
214
  function updateScrollState() {
196
215
  if (fontListContainer) {
@@ -199,7 +218,7 @@
199
218
 
200
219
  // If we're transitioning from scrolled to not scrolled, remove transition
201
220
  if (wasScrolled && !isScrolled) {
202
- const stickyHeader = document.querySelector(`#font-header-${controllerId}`);
221
+ const stickyHeader = findStickyHeader();
203
222
  if (stickyHeader) {
204
223
  stickyHeader.classList.remove('transition-border');
205
224
  // Force a reflow to ensure the transition class removal takes effect
@@ -207,7 +226,7 @@
207
226
  }
208
227
  } else if (!wasScrolled && isScrolled) {
209
228
  // Add transition class when starting to scroll
210
- const stickyHeader = document.querySelector(`#font-header-${controllerId}`);
229
+ const stickyHeader = findStickyHeader();
211
230
  if (stickyHeader) {
212
231
  stickyHeader.classList.add('transition-border');
213
232
  }
@@ -252,22 +271,20 @@
252
271
 
253
272
  let fontPickerReadyAt = 0;
254
273
 
274
+ /**
275
+ * The layer the font popovers are drawn in: one of their own, above everything else, in the tree this input lives
276
+ * in. On a page that is a node at the end of the body; inside a module it is a node in the module's portal layer,
277
+ * because a popover drawn in the customer's body would lose the module's styles and leave a node behind in the
278
+ * customer's page. The input has to be on the page to tell which tree it is in, so until it is (a short moment,
279
+ * and a popover is never open by then) the popovers go where the design system's portal configuration sends them.
280
+ */
281
+ let fontPickerPortalHost: HTMLElement | undefined;
282
+
255
283
  function ensureFontPickerPortal() {
256
- if (!browser) return;
257
- const portalId = 'widgetic-font-picker-portal';
258
- if (document.getElementById(portalId)) return;
259
- const host = document.createElement('div');
260
- host.id = portalId;
261
- host.style.position = 'relative';
262
- host.style.zIndex = '100000000';
263
- document.body.appendChild(host);
284
+ if (!browser || fontPickerPortalHost) return;
285
+ fontPickerPortalHost = acquirePortalHost(familyTriggerEl, 'widgetic-font-picker-portal') ?? undefined;
264
286
  }
265
287
 
266
- // The font popover looks up this node while it is created. onMount is too
267
- // late: the child portal mounts first and throws if the host is missing,
268
- // which takes the whole details panel (including chat) down with it.
269
- ensureFontPickerPortal();
270
-
271
288
  onMount(async () => {
272
289
  try {
273
290
  if (browser) {
@@ -282,13 +299,13 @@
282
299
  }
283
300
 
284
301
  // Load saved filter
285
- const savedFilter = localStorage.getItem(FONT_FILTER_KEY(controllerId));
302
+ const savedFilter = readEditorStorageValue(FONT_FILTER_KEY(controllerId));
286
303
  if (savedFilter) {
287
304
  currentFilter = savedFilter as FilterType;
288
305
  }
289
306
 
290
307
  // Load saved weight
291
- const savedWeight = localStorage.getItem(FONT_WEIGHT_KEY(controllerId));
308
+ const savedWeight = readEditorStorageValue(FONT_WEIGHT_KEY(controllerId));
292
309
  if (savedWeight) {
293
310
  selectedWeight = savedWeight;
294
311
  }
@@ -790,7 +807,7 @@
790
807
 
791
808
  selectedWeight = `${controllerId}-${newWeight}`;
792
809
  if (browser) {
793
- localStorage.setItem(FONT_WEIGHT_KEY(controllerId), selectedWeight);
810
+ writeEditorStorageValue(FONT_WEIGHT_KEY(controllerId), selectedWeight);
794
811
  }
795
812
 
796
813
  // Load the full font when selected
@@ -852,7 +869,7 @@
852
869
  if (!searchTerm && value) {
853
870
  lastActiveFilter = currentFilter;
854
871
  currentFilter = 'All';
855
- localStorage.setItem(FONT_FILTER_KEY(controllerId), 'All');
872
+ writeEditorStorageValue(FONT_FILTER_KEY(controllerId), 'All');
856
873
  }
857
874
 
858
875
  // Apply filtering with the new search term
@@ -1064,7 +1081,7 @@
1064
1081
  currentFilter = filter;
1065
1082
 
1066
1083
  if (browser) {
1067
- localStorage.setItem(FONT_FILTER_KEY(controllerId), filter);
1084
+ writeEditorStorageValue(FONT_FILTER_KEY(controllerId), filter);
1068
1085
  }
1069
1086
 
1070
1087
  if (fontManagerInstance) {
@@ -1482,7 +1499,8 @@
1482
1499
  * @param term Search term
1483
1500
  * @returns HTML string with highlighted match
1484
1501
  *
1485
- * Note: Returns safe HTML as we're only wrapping with <strong>
1502
+ * Note: The result goes to {@html}, so every piece of the font name is escaped as it is put in (a name such as
1503
+ * `<img src=x onerror=...>` must show as text, not run in the page the editor is on).
1486
1504
  */
1487
1505
  function highlightMatch(name: string, term: string) {
1488
1506
  // Check if this font is the selected one
@@ -1491,8 +1509,8 @@
1491
1509
  if (!term) {
1492
1510
  // If no search term, just return the name with appropriate color
1493
1511
  return isSelected
1494
- ? `<span style="color: white">${name}</span>`
1495
- : `<span style="color: hsl(var(--grey7))">${name}</span>`;
1512
+ ? `<span style="color: white">${escapeHtml(name)}</span>`
1513
+ : `<span style="color: hsl(var(--grey7))">${escapeHtml(name)}</span>`;
1496
1514
  }
1497
1515
 
1498
1516
  const nameLower = name.toLowerCase();
@@ -1501,9 +1519,9 @@
1501
1519
  // If the name contains the search term, highlight that part
1502
1520
  if (nameLower.includes(termLower)) {
1503
1521
  const index = nameLower.indexOf(termLower);
1504
- const beforeMatch = name.slice(0, index);
1505
- const match = name.slice(index, index + termLower.length);
1506
- const afterMatch = name.slice(index + termLower.length);
1522
+ const beforeMatch = escapeHtml(name.slice(0, index));
1523
+ const match = escapeHtml(name.slice(index, index + termLower.length));
1524
+ const afterMatch = escapeHtml(name.slice(index + termLower.length));
1507
1525
 
1508
1526
  if (isSelected) {
1509
1527
  // For selected items: text is white, highlight is grey7
@@ -1516,8 +1534,8 @@
1516
1534
 
1517
1535
  // Default case - just return the name with appropriate color
1518
1536
  return isSelected
1519
- ? `<span style="color: white">${name}</span>`
1520
- : `<span style="color: hsl(var(--grey7))">${name}</span>`;
1537
+ ? `<span style="color: white">${escapeHtml(name)}</span>`
1538
+ : `<span style="color: hsl(var(--grey7))">${escapeHtml(name)}</span>`;
1521
1539
  }
1522
1540
 
1523
1541
  /**
@@ -1550,28 +1568,31 @@
1550
1568
  }
1551
1569
  }
1552
1570
 
1553
- // Update onMount to add scroll and touch listeners with passive option
1571
+ // Stops listening for scrolling (the page and, inside a module, the module's own panels: a scroll never leaves a
1572
+ // shadow root, so a listener on the window alone does not hear about those)
1573
+ let stopListeningForScroll: () => void = () => {};
1574
+
1575
+ // Update onMount to add the scroll listener with passive option
1554
1576
  onMount(() => {
1555
1577
  // Add scroll listener with passive option
1556
- window.addEventListener('scroll', handleGlobalScroll, {
1557
- capture: true,
1558
- passive: true
1559
- });
1578
+ stopListeningForScroll = listenForScroll(controllerElement, handleGlobalScroll, { passive: true });
1560
1579
  window.addEventListener('resize', handleFamilyPopoverViewportChange);
1561
1580
  window.visualViewport?.addEventListener('resize', handleFamilyPopoverViewportChange);
1562
1581
 
1563
- // Add touch listeners with passive option
1564
- window.addEventListener('touchstart', () => {}, { passive: true });
1565
- window.addEventListener('touchmove', () => {}, { passive: true });
1582
+ // (Two touchstart/touchmove listeners that did nothing used to be added to the window here, and could never be
1583
+ // removed: each input left two more on the customer's page every time it was shown. They did nothing, so they are
1584
+ // gone.)
1566
1585
  });
1567
1586
 
1568
1587
  // Update onDestroy to remove all listeners
1569
1588
  onDestroy(() => {
1570
- window.removeEventListener('scroll', handleGlobalScroll, true);
1589
+ stopListeningForScroll();
1571
1590
  window.removeEventListener('resize', handleFamilyPopoverViewportChange);
1572
1591
  window.visualViewport?.removeEventListener('resize', handleFamilyPopoverViewportChange);
1573
- window.removeEventListener('touchstart', () => {});
1574
- window.removeEventListener('touchmove', () => {});
1592
+
1593
+ // Give the popover layer back (the last input to leave removes it from the page).
1594
+ releasePortalHost(fontPickerPortalHost);
1595
+ fontPickerPortalHost = undefined;
1575
1596
 
1576
1597
  if (fontObserver) {
1577
1598
  fontObserver.disconnect();
@@ -1653,7 +1674,7 @@
1653
1674
  function handleWeightSelect(weight: string) {
1654
1675
  selectedWeight = `${controllerId}-${weight}`;
1655
1676
  if (browser) {
1656
- localStorage.setItem(FONT_WEIGHT_KEY(controllerId), selectedWeight);
1677
+ writeEditorStorageValue(FONT_WEIGHT_KEY(controllerId), selectedWeight);
1657
1678
  }
1658
1679
  const current = coerceFontValue(value);
1659
1680
  if (onChange) {
@@ -1679,7 +1700,8 @@
1679
1700
  }
1680
1701
 
1681
1702
  // Ensure the selected item is visible
1682
- const selectedElement = fontListContainer?.querySelector(`[data-font="${filteredFonts[activeSearchIndex].family}"]`);
1703
+ // (The family goes through CSS.escape: a name with a quote in it must not end the selector.)
1704
+ const selectedElement = fontListContainer?.querySelector(`[data-font="${CSS.escape(filteredFonts[activeSearchIndex].family)}"]`);
1683
1705
  selectedElement?.scrollIntoView({ block: 'nearest' });
1684
1706
  }
1685
1707
 
@@ -1691,14 +1713,16 @@
1691
1713
 
1692
1714
  // Functions for RecentItemsSection
1693
1715
  function renderFontItem(font: FontItem) {
1716
+ // The result is used as HTML by RecentItemsSection: the family is quoted for CSS and escaped for HTML where it is
1717
+ // a rule, and escaped for HTML where it is the text.
1694
1718
  return {
1695
1719
  html: `
1696
1720
  <div class="flex items-center flex-1 min-w-0">
1697
1721
  <span
1698
1722
  class="text-[24px] truncate"
1699
- style="font-family: '${font.family}', sans-serif; font-display: swap; color: inherit; -webkit-text-fill-color: currentColor;"
1723
+ style="font-family: ${escapeHtml(cssQuotedString(font.family))}, sans-serif; font-display: swap; color: inherit; -webkit-text-fill-color: currentColor;"
1700
1724
  >
1701
- ${font.family}
1725
+ ${escapeHtml(font.family)}
1702
1726
  </span>
1703
1727
  </div>
1704
1728
  `
@@ -1727,7 +1751,7 @@
1727
1751
  </script>
1728
1752
 
1729
1753
  <!-- Dual IC: header zone always on the left (even if label is empty); 185px inputs on the right. -->
1730
- <div class="font-ic flex flex-col gap-2 w-full min-w-0 widgetic-colors {className}" style={style}>
1754
+ <div bind:this={controllerElement} class="font-ic flex flex-col gap-2 w-full min-w-0 widgetic-colors {className}" style={style}>
1731
1755
  <div class="font-family-row flex items-center justify-between w-full min-w-0 gap-2" style="min-height: 48px;">
1732
1756
  <div class="font-family-label-ct flex-1 min-w-0">
1733
1757
  <InputControllerHeader label={label || ''} />
@@ -1768,7 +1792,7 @@
1768
1792
  toggleDropdown(controllerId);
1769
1793
  }}
1770
1794
  >
1771
- <Popover.Trigger tabindex={-1} class="w-full h-full flex items-center border-0 bg-transparent hover:bg-transparent focus:bg-transparent pl-3 pr-[16px] group"
1795
+ <Popover.Trigger tabindex={-1} class="w-full h-full flex items-center border-0 bg-transparent hover:bg-transparent focus:bg-transparent ps-3 pe-[16px] group"
1772
1796
  onmouseenter={() => {
1773
1797
  // Preload fonts when hovering over the button
1774
1798
  // This reduces the delay when actually clicking to open
@@ -1778,10 +1802,10 @@
1778
1802
  }}
1779
1803
  >
1780
1804
  <span
1781
- class="truncate flex-1 text-left subtext text-grey7"
1782
- style="font-family: '{resolvedFont?.family || 'system-ui'}', sans-serif; font-display: swap;"
1805
+ class="truncate flex-1 text-start subtext text-grey7"
1806
+ style="font-family: {cssQuotedString(resolvedFont?.family || 'system-ui')}, sans-serif; font-display: swap;"
1783
1807
  >
1784
- {resolvedFont?.family || "Select font..."}
1808
+ {resolvedFont?.family || $t('editor.font.select')}
1785
1809
  </span>
1786
1810
  <IconCaretDownFilled class="w-5 h-5 shrink-0 text-grey5 group-hover:text-grey7" />
1787
1811
  </Popover.Trigger>
@@ -1790,18 +1814,18 @@
1790
1814
  collisionPadding={12}
1791
1815
  side={familyPopoverSide}
1792
1816
  sideOffset={4}
1793
- portalProps={{ to: '#widgetic-font-picker-portal' }}
1817
+ portalProps={{ to: fontPickerPortalHost }}
1794
1818
  class="font-input-popover !p-0 widgetic-colors widgetic-radius rounded-medium modal-shadow border border-grey2"
1795
1819
  style={familyPopoverStyle}
1796
1820
  >
1797
1821
  <div class="flex flex-col h-full min-h-0 font-popover-content">
1798
1822
  <!-- Title and upload icon -->
1799
1823
  <div class="font-popover-header shrink-0 relative pt-6 pb-[11px] px-5">
1800
- <div class="h5 text-grey7 widgetic-colors">Fonts</div>
1824
+ <div class="h5 text-grey7 widgetic-colors">{$t('editor.font.title')}</div>
1801
1825
  <button
1802
1826
  type="button"
1803
1827
  tabindex="-1"
1804
- class="absolute right-6 top-[26px] bg-transparent border-0 cursor-pointer p-0 rounded-full hover:bg-grey3 transition-all duration-300"
1828
+ class="absolute end-6 top-[26px] bg-transparent border-0 cursor-pointer p-0 rounded-full hover:bg-grey3 transition-all duration-300"
1805
1829
  onclick={() => {
1806
1830
  try {
1807
1831
  openFileBrowser();
@@ -1821,7 +1845,7 @@
1821
1845
  <!-- Search input -->
1822
1846
  <div class="font-popover-search shrink-0 relative px-4">
1823
1847
  <SearchInput
1824
- placeholder="Search fonts..."
1848
+ placeholder={$t('editor.font.search')}
1825
1849
  bind:value={searchTerm}
1826
1850
  onInput={(e: Event) => {
1827
1851
  // Use debounced search only for typing
@@ -1835,7 +1859,7 @@
1835
1859
  // Restore the previous filter when clearing search
1836
1860
  currentFilter = lastActiveFilter;
1837
1861
  if (browser) {
1838
- localStorage.setItem(FONT_FILTER_KEY(controllerId), lastActiveFilter);
1862
+ writeEditorStorageValue(FONT_FILTER_KEY(controllerId), lastActiveFilter);
1839
1863
  }
1840
1864
 
1841
1865
  // ... rest of your onClear code ...
@@ -1849,6 +1873,7 @@
1849
1873
  categories={filterOptions}
1850
1874
  activeCategory={currentFilter}
1851
1875
  onCategorySelect={handleFilterChange}
1876
+ getCategoryLabel={(category) => categoryLabel(category, $t)}
1852
1877
  filterVisible={!searchTerm}
1853
1878
  />
1854
1879
  </div>
@@ -1865,14 +1890,13 @@
1865
1890
  }}
1866
1891
  id={`font-list-${controllerId}`}
1867
1892
  role="listbox"
1868
- aria-label="Font list"
1893
+ aria-label={$t('editor.font.list')}
1869
1894
  >
1870
1895
  <div class="space-y-0">
1871
1896
  <!-- Recently used section - only show when not searching -->
1872
1897
  {#if recentFonts.length > 0 && !searchTerm}
1873
1898
  <RecentItemsSection
1874
1899
  recentItems={recentFonts}
1875
- title="Recently used"
1876
1900
  onItemSelect={handleFontSelect}
1877
1901
  onItemDelete={handleFontDelete}
1878
1902
  isItemSelected={isFontSelected}
@@ -1893,9 +1917,9 @@
1893
1917
  <div id="font-header-{controllerId}" class="sticky top-0 -mt-2 bg-white z-10 pt-2 {isScrolled ? 'border-b border-grey2' : 'border-b border-transparent'} transition-border">
1894
1918
  <h3 class="subtext text-grey7 pb-2 flex items-center">
1895
1919
  {#if isInitialLoading}
1896
- <div class="w-4 h-4 border-2 border-t-sage-green border-r-sage-green border-b-grey2 border-l-grey2 rounded-full animate-spin mr-2"></div>
1920
+ <div class="w-4 h-4 border-2 border-t-sage-green border-e-sage-green border-b-grey2 border-s-grey2 rounded-full animate-spin me-2"></div>
1897
1921
  {/if}
1898
- {searchTerm ? 'Search results' : 'More'}
1922
+ {searchTerm ? $t('editor.list.searchResults') : $t('editor.list.more')}
1899
1923
  </h3>
1900
1924
  </div>
1901
1925
  {/if}
@@ -1905,13 +1929,13 @@
1905
1929
  {#if isInitialLoading}
1906
1930
  <!-- Show compact loading indicator while fonts are loading -->
1907
1931
  <div class="flex items-center">
1908
- <div class="text-grey5 subtext mr-2">Loading fonts</div>
1909
- <div class="w-4 h-4 border-2 border-t-sage-green border-r-sage-green border-b-grey2 border-l-grey2 rounded-full animate-spin"></div>
1932
+ <div class="text-grey5 subtext me-2">{$t('editor.font.loading')}</div>
1933
+ <div class="w-4 h-4 border-2 border-t-sage-green border-e-sage-green border-b-grey2 border-s-grey2 rounded-full animate-spin"></div>
1910
1934
  </div>
1911
1935
  {:else}
1912
1936
  <!-- Only show empty state when loading is complete and no fonts were found -->
1913
1937
  <EmptyState
1914
- message={searchTerm ? 'No fonts found' : 'No fonts available'}
1938
+ message={searchTerm ? $t('editor.font.noneFound') : $t('editor.font.noneAvailable')}
1915
1939
  height="auto"
1916
1940
  />
1917
1941
  {/if}
@@ -1934,10 +1958,10 @@
1934
1958
  style="color: {isFontSelected(font) ? '#ffffff' : 'hsl(var(--grey7))'};"
1935
1959
  >
1936
1960
  <div class="flex items-center flex-1 min-w-0 group">
1937
- <Check class="mr-2 h-6 w-6 flex-shrink-0 {isFontSelected(font) ? 'text-white' : 'text-sage-green opacity-0'}"/>
1961
+ <Check class="me-2 h-6 w-6 flex-shrink-0 {isFontSelected(font) ? 'text-white' : 'text-sage-green opacity-0'}"/>
1938
1962
  <span
1939
1963
  class="text-[24px] truncate {index === activeSearchIndex && searchTerm ? 'font-medium' : ''}"
1940
- style="font-family: '{font.family}', sans-serif; font-display: swap; color: inherit; -webkit-text-fill-color: currentColor;"
1964
+ style="font-family: {cssQuotedString(font.family)}, sans-serif; font-display: swap; color: inherit; -webkit-text-fill-color: currentColor;"
1941
1965
  >
1942
1966
  {@html highlightMatch(font.family, searchTerm)}
1943
1967
  </span>
@@ -1946,7 +1970,7 @@
1946
1970
  <div
1947
1971
  role="button"
1948
1972
  tabindex="-1"
1949
- class="ml-auto transition-opacity {resolvedFont?.family === font.family ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'}"
1973
+ class="ms-auto transition-opacity {resolvedFont?.family === font.family ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'}"
1950
1974
  onclick={(e) => {
1951
1975
  e.stopPropagation();
1952
1976
  e.preventDefault();
@@ -1955,11 +1979,11 @@
1955
1979
  >
1956
1980
  <img
1957
1981
  src={icTrash}
1958
- alt="Remove font"
1982
+ alt={$t('editor.font.remove')}
1959
1983
  class="w-4 h-4 text-grey5 hover:text-error"
1960
1984
  draggable="false"
1961
1985
  />
1962
- <span class="sr-only">Remove font</span>
1986
+ <span class="sr-only">{$t('editor.font.remove')}</span>
1963
1987
  </div>
1964
1988
  {/if}
1965
1989
  </div>
@@ -1978,7 +2002,7 @@
1978
2002
 
1979
2003
  <div class="font-weight-row flex items-center justify-between w-full min-w-0 gap-2" style="min-height: 48px;">
1980
2004
  <div class="font-weight-label-ct flex-1 min-w-0">
1981
- <InputControllerHeader label={label ? 'Font Weight' : ''} />
2005
+ <InputControllerHeader label={label ? $t('editor.font.weight') : ''} />
1982
2006
  </div>
1983
2007
  <div class="font-weight-input-ct h-12 shrink-0 rounded-large border border-grey2 flex items-center bg-grey1 transition-colors duration-200 hover:border-forest-green" style="width: 185px; max-width: 185px; min-width: 185px;">
1984
2008
  <Popover.Root
@@ -1990,15 +2014,15 @@
1990
2014
  }
1991
2015
  }}
1992
2016
  >
1993
- <Popover.Trigger tabindex={-1} class="w-full h-full flex items-center border-0 bg-transparent hover:bg-transparent focus:bg-transparent pl-3 pr-[16px] group">
1994
- <span class="truncate flex-1 text-left subtext text-grey7">
1995
- {displayWeight || 'Regular'}
2017
+ <Popover.Trigger tabindex={-1} class="w-full h-full flex items-center border-0 bg-transparent hover:bg-transparent focus:bg-transparent ps-3 pe-[16px] group">
2018
+ <span class="truncate flex-1 text-start subtext text-grey7">
2019
+ {fontWeightLabel(displayWeight || 'Regular', $t)}
1996
2020
  </span>
1997
2021
  <IconCaretDownFilled class="w-5 h-5 shrink-0 text-grey5 group-hover:text-grey7" />
1998
2022
  </Popover.Trigger>
1999
- <Popover.Content class="font-input-popover p-0 w-[150px]" portalProps={{ to: '#widgetic-font-picker-portal' }}>
2023
+ <Popover.Content class="font-input-popover p-0 w-[150px]" portalProps={{ to: fontPickerPortalHost }}>
2000
2024
  <Command.Root>
2001
- <Command.List>
2025
+ <Command.List aria-label={$t('editor.font.weightList')}>
2002
2026
  <Command.Group>
2003
2027
  {#each availableWeights as weight}
2004
2028
  <Command.Item
@@ -2007,9 +2031,9 @@
2007
2031
  class="px-2 py-2"
2008
2032
  >
2009
2033
  <Check
2010
- class="mr-1 h-4 w-4 {displayWeight === weight ? 'opacity-100' : 'opacity-0'}"
2034
+ class="me-1 h-4 w-4 {displayWeight === weight ? 'opacity-100' : 'opacity-0'}"
2011
2035
  />
2012
- <span class="truncate">{weight}</span>
2036
+ <span class="truncate">{fontWeightLabel(weight, $t)}</span>
2013
2037
  </Command.Item>
2014
2038
  {/each}
2015
2039
  </Command.Group>
@@ -2045,7 +2069,7 @@
2045
2069
  width: 185px !important;
2046
2070
  max-width: 185px !important;
2047
2071
  min-width: 185px !important;
2048
- margin-left: auto;
2072
+ margin-inline-start: auto;
2049
2073
  flex-shrink: 0;
2050
2074
  position: relative;
2051
2075
  }
@@ -2192,12 +2216,12 @@
2192
2216
 
2193
2217
  /* Style for checkmark icon */
2194
2218
  :global(.checkmark-icon) {
2195
- margin-left: 12px !important;
2219
+ margin-inline-start: 12px !important;
2196
2220
  }
2197
2221
 
2198
2222
  /* Style for font item padding */
2199
2223
  :global(.font-item) {
2200
- padding-left: 48px !important;
2224
+ padding-inline-start: 48px !important;
2201
2225
  }
2202
2226
 
2203
2227
  /* Style for font item height */
@@ -2,6 +2,9 @@
2
2
  // Svelte imports
3
3
  import { onDestroy, onMount } from 'svelte';
4
4
 
5
+ // Shared listener for "the user is scrolling" (one per tree, not one per header)
6
+ import { onScrollActivity } from '../../utils/scrollActivity';
7
+
5
8
  // Shadcn UI Components from design-system
6
9
  import { Tooltip, Portal } from '@widgetic/design-system/components';
7
10
  // console.log("InputControllerHeader - Tooltip:", Tooltip);
@@ -9,6 +12,10 @@ import { Tooltip, Portal } from '@widgetic/design-system/components';
9
12
  // Lucide Icons from design-system
10
13
  import { Info } from '@widgetic/design-system/icons';
11
14
 
15
+ // The way the screen is read: the tip opens at the end of the line, beside the icon
16
+ import { sideAtEnd, useTextDirection } from '@widgetic/design-system/components/textDirection';
17
+ const textDirection = useTextDirection();
18
+
12
19
  // Component props
13
20
  let className = '';
14
21
  export { className as class };
@@ -58,23 +65,23 @@ function handleMouseLeave() {
58
65
  clearTimeout(tooltipTimeout);
59
66
  }
60
67
 
68
+ let headerElement: HTMLElement | undefined;
69
+ let stopListeningForScrolling: () => void = () => {};
70
+
61
71
  onMount(() => {
62
- // Add listeners to both window and all scrollable parents
63
- window.addEventListener('scroll', handleScroll, { capture: true, passive: true });
64
- document.addEventListener('wheel', handleScroll, { capture: true, passive: true });
65
- document.addEventListener('touchmove', handleScroll, { capture: true, passive: true });
72
+ // The listeners for scrolling are shared by all the headers in the same tree (the page or the module's shadow
73
+ // root): every header used to add three of its own to the window and the document.
74
+ stopListeningForScrolling = onScrollActivity(headerElement, handleScroll);
66
75
  });
67
76
 
68
77
  onDestroy(() => {
69
- window.removeEventListener('scroll', handleScroll, { capture: true });
70
- document.removeEventListener('wheel', handleScroll, { capture: true });
71
- document.removeEventListener('touchmove', handleScroll, { capture: true });
78
+ stopListeningForScrolling();
72
79
  clearTimeout(scrollTimeout);
73
80
  clearTimeout(tooltipTimeout);
74
81
  });
75
82
  </script>
76
83
 
77
- <div class="input-controller-header widgetic-colors {className ? className + ' ' : ''}flex group" {style}>
84
+ <div bind:this={headerElement} class="input-controller-header widgetic-colors {className ? className + ' ' : ''}flex group" {style}>
78
85
  <div class="header-label-ct flex-1 min-w-0 flex items-center h-full">
79
86
  <div class="tooltip-group flex items-center max-w-full" style="gap: 8px;">
80
87
  <span class="subtext cursor-default select-none truncate !text-base" style="color: hsl(var(--grey5));">
@@ -103,7 +110,7 @@ onDestroy(() => {
103
110
  {#if showHelp && !isScrolling}
104
111
  <Portal target="editor-tooltips">
105
112
  <Tooltip.Content
106
- side="right"
113
+ side={sideAtEnd($textDirection)}
107
114
  sideOffset={5}
108
115
  align="center"
109
116
  alignOffset={-1}
@@ -232,10 +232,10 @@
232
232
  oninput={handleInput}
233
233
  onblur={handleBlur}
234
234
  readonly={isLocked}
235
- class="w-full h-full border-0 bg-transparent subtext text-grey7 placeholder:text-grey3 focus-visible:ring-0 focus-visible:ring-offset-0 pl-4 {customInputClass}"
235
+ class="w-full h-full border-0 bg-transparent subtext text-grey7 placeholder:text-grey3 focus-visible:ring-0 focus-visible:ring-offset-0 ps-4 {customInputClass}"
236
236
  />
237
237
  {#if suffix}
238
- <span class="subtext text-grey7 pointer-events-none -ml-1">
238
+ <span class="subtext text-grey7 pointer-events-none -ms-1">
239
239
  {suffix}
240
240
  </span>
241
241
  {/if}
@@ -279,10 +279,10 @@
279
279
 
280
280
  <style>
281
281
  :global([id^="number-input-"]) {
282
- text-align: left;
282
+ text-align: start;
283
283
  appearance: textfield;
284
284
  -moz-appearance: textfield;
285
- padding-right: 0 !important;
285
+ padding-inline-end: 0 !important;
286
286
  }
287
287
 
288
288
  :global([id^="number-input-"]::-webkit-outer-spin-button),
@@ -203,11 +203,13 @@
203
203
  * Blur Event Handler
204
204
  * Validates and normalizes input when focus is lost
205
205
  */
206
- function handleBlur() {
206
+ function handleBlur(event: FocusEvent) {
207
207
  // console.log('handleBlur:', { value, isEditing, min, max });
208
208
 
209
209
  isEditing = false;
210
- const input = document.getElementById('number-input') as HTMLInputElement;
210
+ // The input that lost the focus. (It used to be looked up by the id "number-input" in the whole document, which is
211
+ // another input when there are several, and nothing inside a module's shadow root.)
212
+ const input = event.currentTarget as HTMLInputElement;
211
213
 
212
214
  // Handle empty or invalid input
213
215
  if (input.value === '' || input.value === '-') {
@@ -371,7 +373,7 @@
371
373
  <InputControllerHeader {label} helpText={helpText} />
372
374
  </div>
373
375
 
374
- <div class="absolute right-0">
376
+ <div class="absolute end-0">
375
377
  <NumberControls
376
378
  {value}
377
379
  {onChange}
@@ -391,7 +393,7 @@
391
393
 
392
394
  <style>
393
395
  :global(#number-input) {
394
- text-align: left;
396
+ text-align: start;
395
397
  appearance: textfield;
396
398
  -moz-appearance: textfield;
397
399
  }