@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
@@ -1,4 +1,5 @@
1
1
  import type { ContentSchemaView, DesignSchemaView } from '../../schemaViews.js';
2
+ import type { MediaLibraryProvider } from '@widgetic/file-browser';
2
3
  /**
3
4
  * STYLES from Widgetic Design System
4
5
  * Note: props-editor.css global rules (body, *) moved to app.css (standalone only).
@@ -28,8 +29,8 @@ declare const PropsEditor: $$__sveltets_2_IsomorphicComponent<{
28
29
  designSchema?: DesignSchemaView | null;
29
30
  contentSchema?: ContentSchemaView | undefined;
30
31
  itemTemplate?: any;
31
- designTabTitle?: string;
32
- contentTabTitle?: string;
32
+ designTabTitle?: string | undefined;
33
+ contentTabTitle?: string | undefined;
33
34
  defaultTab?: "design" | "content";
34
35
  defaultOpen?: boolean;
35
36
  showDragHandle?: boolean;
@@ -40,11 +41,17 @@ declare const PropsEditor: $$__sveltets_2_IsomorphicComponent<{
40
41
  /** Optional content below the shared toolbar, before property fields. */ headerContent?: any;
41
42
  headerClass?: string;
42
43
  dragHandleClass?: string;
43
- title?: string;
44
+ title?: string | undefined;
44
45
  /** Hide the default missing-schema message for header-only editors. */ showSchemaError?: boolean;
45
46
  /** Optional layout wrapper moved by this editor's built-in drag and position API. */ positionTarget?: any;
46
47
  initialDesignValues?: Record<string, any>;
47
48
  initialContentValues?: Record<string, any>;
49
+ /**
50
+ * Where the file controls (images, video, audio, documents) get their files from. Without it they show the
51
+ * Widgetic account's uploads, as they always have (configured with `setFileBrowserUploadConfig`). With a
52
+ * `MediaLibraryProvider` they list, upload, rename and delete through its functions instead, so a partner's own
53
+ * storage (a CMS, an S3 bucket, a DAM) can be used with no Widgetic storage at all.
54
+ */ mediaLibrary?: MediaLibraryProvider | undefined;
48
55
  updateDesignValue?: (propertyId: string, value: any) => void;
49
56
  setPosition?: (left: number, top: number) => void;
50
57
  getPosition?: () => {
@@ -234,7 +234,7 @@
234
234
  <InputControllerHeader {label} {helpText} />
235
235
  </div>
236
236
 
237
- <div class="absolute right-0 h-[48px] flex items-center">
237
+ <div class="absolute end-0 h-[48px] flex items-center">
238
238
  <div class="flex items-center justify-end gap-2">
239
239
  <!-- Circle Indicator -->
240
240
  <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -35,12 +35,17 @@
35
35
  import { Portal, Tooltip } from '@widgetic/design-system/components';
36
36
  import { ThumbHashImage } from "@widgetic/design-system/components";
37
37
  import { FileBrowser, setFileBrowserUploadConfig, persistentSelectStore } from '@widgetic/file-browser';
38
+ import { useEditorMediaLibrary } from '../../mediaLibraryContext';
39
+ import { useEditorLanguage } from '../../i18n/language';
40
+ import type { EditorMessageKey } from '../../i18n/messages';
41
+ import type { Translate } from '@widgetic/design-system/i18n';
38
42
  import X from '../icons/x.svelte';
39
43
  // console.log("BrowserInputController - Tooltip:", Tooltip);
40
44
 
41
45
 
42
46
  // utils imports
43
47
  import MediaManager from '../../utils/MediaManager';
48
+ import { queryInTreeOf } from '../../utils/shadowDom';
44
49
 
45
50
  // Static asset imports (bundled via Vite, works when imported as library)
46
51
  import icAudio from '../../assets/icons/ic-browser/audio.svg?url';
@@ -65,10 +70,20 @@
65
70
  (portalTarget instanceof HTMLElement &&
66
71
  portalTarget.id === 'props-editor-file-browser-portal');
67
72
  export let config: BrowserInputControllerProps['config'] = {};
73
+
74
+ // The media library the editor above was given (a partner's own storage, for instance), read through a getter
75
+ // because it reaches this component through context.
76
+ const getEditorMediaLibrary = useEditorMediaLibrary();
77
+
78
+ // The words of the editor in the language of the page, and the share of them that belongs to the File Browser inside it.
79
+ const { t, fileBrowser: fileBrowserLanguage } = useEditorLanguage();
68
80
 
69
81
  // Extract allowed file types from config or default to all files
70
82
  const fileTypes = config.fileTypes || ['*'];
71
83
 
84
+ // The control's own element, to find the editor's panel from (see getPortalHost)
85
+ let controllerElement: HTMLDivElement | undefined;
86
+
72
87
  // Media element references for audio/video playback
73
88
  let audioElement: HTMLAudioElement | null = null;
74
89
  let videoElement: HTMLVideoElement | null = null;
@@ -166,6 +181,9 @@
166
181
  /** Only embedded iframe mode (URL params) sets upload config here; standalone uses parent +page sync. */
167
182
  function syncFileBrowserUploadConfigIfEmbedded() {
168
183
  if (typeof window === 'undefined') return;
184
+ // With a media library the File Browser takes its files from it and never reads the upload configuration, and the
185
+ // address of the page this editor sits in (which can be a partner's) is none of the editor's business.
186
+ if (getEditorMediaLibrary()) return;
169
187
  const params = new URLSearchParams(window.location.search);
170
188
  const apiUrl = params.get('apiUrl');
171
189
  const token = params.get('token');
@@ -187,7 +205,9 @@
187
205
  function getPortalHost(): HTMLElement | null {
188
206
  if (portalTarget instanceof HTMLElement) return portalTarget;
189
207
  if (typeof document === 'undefined') return null;
190
- return document.getElementById('props-editor-file-browser-portal');
208
+ // Looked for in the tree this control is in: inside a module that is a shadow root, which `document` cannot see into
209
+ const portalHost = queryInTreeOf(controllerElement, '#props-editor-file-browser-portal');
210
+ return portalHost instanceof HTMLElement ? portalHost : null;
191
211
  }
192
212
 
193
213
  function setPortalHostInteractive(active: boolean) {
@@ -377,15 +397,15 @@
377
397
  $: if (videoElement) setupMediaElement(videoElement);
378
398
 
379
399
  // Add placeholder text logic
380
- function getAddToEditorLabelSingular(fileTypes: string[]): string {
381
- if (!fileTypes || fileTypes.includes('*')) return 'Use file';
400
+ function getAddToEditorLabelSingular(fileTypes: string[], translate: Translate<EditorMessageKey>): string {
401
+ if (!fileTypes || fileTypes.includes('*')) return translate('editor.file.useFile');
382
402
  const type = fileTypes[0].split('/')[0];
383
403
  switch (type) {
384
- case 'image': return 'Use image';
385
- case 'video': return 'Use video';
386
- case 'audio': return 'Use audio';
387
- case 'document': return 'Use document';
388
- default: return 'Use file';
404
+ case 'image': return translate('editor.file.useImage');
405
+ case 'video': return translate('editor.file.useVideo');
406
+ case 'audio': return translate('editor.file.useAudio');
407
+ case 'document': return translate('editor.file.useDocument');
408
+ default: return translate('editor.file.useFile');
389
409
  }
390
410
  }
391
411
 
@@ -405,15 +425,15 @@
405
425
  }
406
426
  }
407
427
 
408
- function getPlaceholderText(fileTypes: string[]): string {
409
- if (!fileTypes || fileTypes.includes('*')) return 'Add file...';
428
+ function getPlaceholderText(fileTypes: string[], translate: Translate<EditorMessageKey>): string {
429
+ if (!fileTypes || fileTypes.includes('*')) return translate('editor.file.addFile');
410
430
  const type = fileTypes[0].split('/')[0];
411
431
  switch (type) {
412
- case 'image': return 'Add image...';
413
- case 'video': return 'Add video...';
414
- case 'audio': return 'Add audio...';
415
- case 'document': return 'Add docs...';
416
- default: return 'Add file...';
432
+ case 'image': return translate('editor.file.addImage');
433
+ case 'video': return translate('editor.file.addVideo');
434
+ case 'audio': return translate('editor.file.addAudio');
435
+ case 'document': return translate('editor.file.addDocument');
436
+ default: return translate('editor.file.addFile');
417
437
  }
418
438
  }
419
439
 
@@ -429,7 +449,7 @@
429
449
 
430
450
  $: fileBrowserFilter = getInitialFileBrowserFilter(fileTypes);
431
451
  $: fileBrowserRestrictUpload = fileBrowserFilter !== 'all';
432
- $: fileBrowserAddToEditorLabelSingular = getAddToEditorLabelSingular(fileTypes);
452
+ $: fileBrowserAddToEditorLabelSingular = getAddToEditorLabelSingular(fileTypes, $t);
433
453
 
434
454
  function handleContentRemoval(e: MouseEvent) {
435
455
  e.stopPropagation();
@@ -461,13 +481,13 @@
461
481
  </script>
462
482
 
463
483
  <!-- Main container with updated styling -->
464
- <div class="flex flex-col gap-2 widgetic-colors">
484
+ <div bind:this={controllerElement} class="flex flex-col gap-2 widgetic-colors">
465
485
  <div class="flex items-center w-full relative">
466
486
  <div class="flex-1 min-w-0">
467
487
  <InputControllerHeader label={header?.label || label} helpText={header?.helpText} />
468
488
  </div>
469
489
 
470
- <div class="absolute right-0">
490
+ <div class="absolute end-0">
471
491
  <!-- svelte-ignore a11y_click_events_have_key_events -->
472
492
  <!-- svelte-ignore a11y_no_static_element_interactions -->
473
493
  <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -482,12 +502,12 @@
482
502
  <!-- File name or placeholder -->
483
503
  <div class="browser-input-controller-label flex-1 min-w-0 px-4 flex items-center h-full">
484
504
  <span class="subtext {value ? 'text-grey7' : 'text-grey3'} truncate inline-block">
485
- {value ? getFileName(value) : getPlaceholderText(fileTypes)}
505
+ {value ? getFileName(value) : getPlaceholderText(fileTypes, $t)}
486
506
  </span>
487
507
  </div>
488
508
 
489
509
  <!-- Preview area -->
490
- <div class="flex-none h-full flex items-center pr-[16px]">
510
+ <div class="flex-none h-full flex items-center pe-[16px]">
491
511
  {#if isChecking}
492
512
  <!-- Loading state -->
493
513
  <div class="w-8 h-7 bg-gray-100 rounded-lg"></div>
@@ -564,7 +584,7 @@
564
584
  >
565
585
  <img
566
586
  src={isPlaying ? icPlayerPause : icPlayerPlay}
567
- alt={isPlaying ? "Pause" : "Play"}
587
+ alt={isPlaying ? $t('editor.file.pause') : $t('editor.file.play')}
568
588
  class="w-5 h-5 {isPlaying ? 'opacity-0 hover:opacity-100' : 'opacity-100'} transition-opacity"
569
589
  />
570
590
  </div>
@@ -600,7 +620,7 @@
600
620
  >
601
621
  <img
602
622
  src={isPlaying ? icPlayerPause : icPlayerPlay}
603
- alt={isPlaying ? "Pause" : "Play"}
623
+ alt={isPlaying ? $t('editor.file.pause') : $t('editor.file.play')}
604
624
  class="w-5 h-5"
605
625
  />
606
626
  </div>
@@ -629,14 +649,14 @@
629
649
  <!-- Error state -->
630
650
  <img
631
651
  src={icError}
632
- alt="File not found"
652
+ alt={$t('editor.file.notFound')}
633
653
  class="w-8 h-7 object-contain"
634
654
  />
635
655
  {:else}
636
656
  <!-- Empty state -->
637
657
  <img
638
658
  src={icFolder}
639
- alt="Empty folder"
659
+ alt={$t('editor.file.emptyFolder')}
640
660
  class="w-8 h-7 object-contain"
641
661
  />
642
662
  {/if}
@@ -645,9 +665,9 @@
645
665
  {#if value}
646
666
  <button
647
667
  type="button"
648
- class="browser-ic-clear-btn absolute top-[-4px] right-[-4px] w-4 h-4 rounded-full hover:bg-grey2 transition-colors flex items-center justify-center"
668
+ class="browser-ic-clear-btn absolute top-[-4px] end-[-4px] w-4 h-4 rounded-full hover:bg-grey2 transition-colors flex items-center justify-center"
649
669
  on:click={handleContentRemoval}
650
- aria-label="Clear file"
670
+ aria-label={$t('editor.file.clear')}
651
671
  >
652
672
  <div class="w-4 h-4 flex items-center justify-center">
653
673
  <X class="w-4! h-4!" />
@@ -685,31 +705,38 @@
685
705
  class:rounded-large={!fillEditorPanel}
686
706
  class:shadow-2xl={!fillEditorPanel}
687
707
  >
688
- <div class="browser-input-file-browser-header relative flex h-[48px] shrink-0 items-center border-b border-grey2 pl-4 pr-8">
689
- <span class="body-medium text-grey7">Select file</span>
708
+ <div class="browser-input-file-browser-header relative flex h-[48px] shrink-0 items-center border-b border-grey2 ps-4 pe-8">
709
+ <span class="body-medium text-grey7">{$t('editor.file.dialogTitle')}</span>
690
710
  <button
691
711
  type="button"
692
712
  class="browser-input-file-browser-close inline-flex h-6 w-6 items-center justify-center p-0"
693
- style="position:absolute;top:4px;right:4px;"
713
+ style="position:absolute;top:4px;inset-inline-end:4px;"
694
714
  on:click={closeFileBrowser}
695
- aria-label="Close file browser"
715
+ aria-label={$t('editor.file.closeDialog')}
696
716
  >
697
717
  <X class="h-6! w-6!" />
698
718
  </button>
699
719
  </div>
700
720
 
701
721
  <div class="browser-input-file-browser-body flex min-h-0 flex-1 flex-col overflow-hidden">
722
+ <!-- With a media library the File Browser keeps nothing about this visit (the filter it was left on, the
723
+ folder it was left in) in the page's localStorage: that storage belongs to the page this editor sits in,
724
+ which can be a partner's. Without one (the Widgetic site) it remembers them, as it always did. -->
702
725
  <FileBrowser
703
726
  class="h-full min-h-0 flex-1"
704
727
  instanceId={`props-editor-browser-input-${fileBrowserFilter}`}
705
728
  initialFilter={fileBrowserFilter}
706
729
  restrictUpload={fileBrowserRestrictUpload}
707
730
  useDemoContent={false}
731
+ mediaLibrary={getEditorMediaLibrary()}
732
+ persistHistory={!getEditorMediaLibrary()}
708
733
  allowSelecting={true}
709
734
  showAddToEditor={true}
710
735
  maxSelections={1}
711
736
  activeFileId={getCurrentFileId()}
712
737
  addToEditorLabel={fileBrowserAddToEditorLabelSingular}
738
+ locale={$fileBrowserLanguage.locale ?? undefined}
739
+ messages={$fileBrowserLanguage.messages ?? null}
713
740
  on:select={handleFileBrowserSelect}
714
741
  />
715
742
  </div>
@@ -722,7 +749,7 @@
722
749
  .browser-input-file-browser-close {
723
750
  position: absolute;
724
751
  top: 4px;
725
- right: 4px;
752
+ inset-inline-end: 4px;
726
753
  }
727
754
 
728
755
  .browser-input-file-browser-dialog-animate {
@@ -69,6 +69,15 @@
69
69
  import { colorNames } from '../../utils/colorNames';
70
70
  import { getColorSuggestions, getNextSuggestion, type AutocompleteResult } from '../../utils/colorAutocomplete';
71
71
  import { recentColorsUtil } from '../../utils/recentColors';
72
+ import { listenForScroll, lockPointerStyle } from '../../utils/shadowDom';
73
+ import { useEditorLanguage } from '../../i18n/language';
74
+ import { useTextDirection } from '@widgetic/design-system/components/textDirection';
75
+
76
+ // The words of the editor in the language of the page.
77
+ const { t } = useEditorLanguage();
78
+
79
+ // The way the screen is read: the pop-up opens from the end of the field, which is its left edge on a right to left screen.
80
+ const textDirection = useTextDirection();
72
81
 
73
82
 
74
83
 
@@ -544,8 +553,10 @@
544
553
  * @param event - Scroll event
545
554
  */
546
555
  function handleScroll(event: Event) {
547
- // Don't close if scrolling happens within allowed elements
548
- if ((event.target as HTMLElement).closest('[data-allow-scroll="true"]')) {
556
+ // Don't close if scrolling happens within allowed elements. The page itself scrolling has the document as its
557
+ // target (it has no closest()), so only an element can be one of the allowed ones.
558
+ const scrollTarget = event.target;
559
+ if (scrollTarget instanceof Element && scrollTarget.closest('[data-allow-scroll="true"]')) {
549
560
  return;
550
561
  }
551
562
 
@@ -557,6 +568,9 @@
557
568
  }
558
569
  }
559
570
 
571
+ // Puts the pointer back after an opacity drag, however the drag ends (the picker can be removed in the middle of one).
572
+ let releaseOpacityPointerStyle: (() => void) | null = null;
573
+
560
574
  /**
561
575
  * Manages opacity slider interactions
562
576
  * - Handles mouse drag events
@@ -567,7 +581,12 @@
567
581
  */
568
582
  function handleOpacityMouseDown(event: MouseEvent) {
569
583
  isDragging = true;
570
- document.body.style.cursor = 'grabbing';
584
+ // The cursor is shown on the host of the shadow root the picker is in, and on <body> only on a normal page: the
585
+ // <body> of a customer's page is not the editor's to change.
586
+ releaseOpacityPointerStyle?.();
587
+ releaseOpacityPointerStyle = lockPointerStyle(event.currentTarget ?? event.target, 'grabbing', {
588
+ blockTextSelection: false
589
+ });
571
590
  updateOpacity(event);
572
591
 
573
592
  const handleMouseMove = (e: MouseEvent) => {
@@ -578,7 +597,8 @@
578
597
 
579
598
  const handleMouseUp = () => {
580
599
  isDragging = false;
581
- document.body.style.cursor = '';
600
+ releaseOpacityPointerStyle?.();
601
+ releaseOpacityPointerStyle = null;
582
602
  window.removeEventListener('mousemove', handleMouseMove);
583
603
  window.removeEventListener('mouseup', handleMouseUp);
584
604
  };
@@ -947,6 +967,10 @@
947
967
  }
948
968
  }
949
969
 
970
+ // Stops listening for scrolling (the page and, inside a module, the module's own panels: a scroll never leaves a
971
+ // shadow root, so a listener on the window alone does not hear about those)
972
+ let stopListeningForScroll: () => void = () => {};
973
+
950
974
  // Sets up event listeners when the component mounts
951
975
  onMount(() => {
952
976
  initializeColor();
@@ -954,17 +978,19 @@
954
978
  window.addEventListener('mousemove', handleGlobalMouseMove);
955
979
  window.addEventListener('keydown', handlePopupKeyDown);
956
980
  window.addEventListener('keyup', handlePopupKeyUp);
957
- window.addEventListener('scroll', handleScroll, true);
981
+ stopListeningForScroll = listenForScroll(inputRef, handleScroll);
958
982
  window.addEventListener('widgetic:close-color-pickers', onCloseOtherPickers as EventListener);
959
983
  });
960
984
 
961
985
  // Cleans up event listeners when the component is destroyed
962
986
  onDestroy(() => {
987
+ releaseOpacityPointerStyle?.();
988
+ releaseOpacityPointerStyle = null;
963
989
  window.removeEventListener('mouseup', handleGlobalMouseUp);
964
990
  window.removeEventListener('mousemove', handleGlobalMouseMove);
965
991
  window.removeEventListener('keydown', handlePopupKeyDown);
966
992
  window.removeEventListener('keyup', handlePopupKeyUp);
967
- window.removeEventListener('scroll', handleScroll, true);
993
+ stopListeningForScroll();
968
994
  window.removeEventListener('widgetic:close-color-pickers', onCloseOtherPickers as EventListener);
969
995
  });
970
996
 
@@ -1172,11 +1198,16 @@
1172
1198
  const top = openAbove
1173
1199
  ? Math.round(rect.top - popoverHeight - 4)
1174
1200
  : Math.round(rect.bottom + 4);
1175
- // Align popup's right edge with the right edge of the full input container
1201
+ // Align the popup's end edge with the end edge of the full input container: the right edge on a left to right screen,
1202
+ // the left edge on a right to left one (the popup itself is a drawing, drawn left to right, and opens toward the inside)
1176
1203
  const inputContainer = triggerRef.closest('.color-input-main-container') as HTMLElement | null;
1177
- const rightRef = inputContainer ? inputContainer.getBoundingClientRect().right : rect.right;
1178
- const right = Math.round(window.innerWidth - rightRef);
1179
- popoverStyle = `top: ${top}px; right: ${right}px;`;
1204
+ const containerRect = inputContainer ? inputContainer.getBoundingClientRect() : rect;
1205
+ if ($textDirection === 'rtl') {
1206
+ popoverStyle = `top: ${top}px; left: ${Math.round(containerRect.left)}px;`; // rtl-ok: the end edge of a right to left field is its left edge
1207
+ } else {
1208
+ const right = Math.round(window.innerWidth - containerRect.right);
1209
+ popoverStyle = `top: ${top}px; right: ${right}px;`; // rtl-ok: the end edge of a left to right field is its right edge
1210
+ }
1180
1211
  }
1181
1212
 
1182
1213
  // Position is calculated in handleToggle after tick()
@@ -1225,17 +1256,19 @@
1225
1256
  </div>
1226
1257
 
1227
1258
  <!-- Input Controller Container -->
1228
- <div class="input-controller-ct absolute right-0">
1259
+ <div class="input-controller-ct absolute end-0">
1229
1260
  <div class="color-input-main-container rounded-large flex items-center transition-colors duration-200"
1230
1261
  style="height: 48px; width: 185px; background: hsl(var(--grey1)); {showRing ? 'border: 1px solid hsl(var(--grey2));' : ''}">
1231
- <div class="flex items-center w-full justify-between" style="padding-right: 12px;">
1262
+ <div class="flex items-center w-full justify-between" style="padding-inline-end: 12px;">
1232
1263
 
1233
1264
  <!-- Hex Input Container -->
1234
1265
  <div class="hex-input-container flex-1" bind:this={inputRef}>
1266
+ <!-- A colour code (#FF00AA) is read left to right on every screen: in a right to left line its "#" would end up at the wrong end -->
1235
1267
  <Input
1236
1268
  bind:this={inputComponent}
1237
1269
  type="text"
1238
- class="h-9 border-0 bg-transparent subtext text-grey7 placeholder:text-grey3 focus-visible:ring-0 focus-visible:ring-offset-0 pl-4"
1270
+ dir="ltr"
1271
+ class="h-9 border-0 bg-transparent subtext text-grey7 placeholder:text-grey3 focus-visible:ring-0 focus-visible:ring-offset-0 ps-4"
1239
1272
  value={hexInput}
1240
1273
  onfocus={(e) => {
1241
1274
  dispatch('focus', e);
@@ -1299,7 +1332,7 @@
1299
1332
  z-index: 99999;
1300
1333
  max-height: 12rem;
1301
1334
  top: {inputRect.bottom + 4}px;
1302
- left: {inputRect.left}px;
1335
+ left: {inputRect.left}px; /* rtl-ok: the list is as wide as the field it opens from, so its left edge is the field's */
1303
1336
  width: {inputRect.width}px;
1304
1337
  "
1305
1338
  data-allow-scroll="true"
@@ -1348,7 +1381,8 @@
1348
1381
 
1349
1382
  <!-- svelte-ignore a11y_no_static_element_interactions -->
1350
1383
  <!-- svelte-ignore a11y_click_events_have_key_events -->
1351
- <div class="color-picker-popover-content w-auto pt-2 pb-2 pl-2 pr-0 widgetic-colors widgetic-radius modal-shadow"
1384
+ <div class="color-picker-popover-content w-auto pt-2 pb-2 ps-2 pe-0 widgetic-colors widgetic-radius modal-shadow"
1385
+ dir="ltr"
1352
1386
  style="{popoverStyle} border: 1px solid hsl(var(--grey2)); z-index: 2;"
1353
1387
  onmousedown={(e) => e.stopPropagation()}
1354
1388
  onclick={(e) => e.stopPropagation()}
@@ -1358,7 +1392,7 @@
1358
1392
 
1359
1393
  <!-- Recent Colors Container -->
1360
1394
  {#if saveRecentColors && (isShiftPressed || isSpacePressed)}
1361
- <div class="recent-colors-container" style="width: {pickerSize + sliderWidth + 14}px; margin-left: 10px; padding-top: 11px; padding-bottom: 8px;">
1395
+ <div class="recent-colors-container" style="width: {pickerSize + sliderWidth + 14}px; margin-inline-start: 10px; padding-top: 11px; padding-bottom: 8px;">
1362
1396
  {#if recentColors.length > 0}
1363
1397
  <div class="recent-colors-grid" style="display: grid; grid-template-columns: repeat(5, 1fr); width: 100%; gap: {swatchGap}px;">
1364
1398
  {#each recentColors.slice(0, 5) as {color, alpha}, i}
@@ -1392,7 +1426,7 @@
1392
1426
  </div>
1393
1427
  {:else}
1394
1428
  <div class="text-sm text-gray-500 text-center py-2">
1395
- No recent colors
1429
+ {$t('editor.color.noRecent')}
1396
1430
  </div>
1397
1431
  {/if}
1398
1432
  </div>
@@ -1403,7 +1437,7 @@
1403
1437
  <div
1404
1438
  class="color-picker-wrapper relative flex flex-col"
1405
1439
  role="application"
1406
- aria-label="Color picker"
1440
+ aria-label={$t('editor.color.picker')}
1407
1441
  onmousedown={handleDragStart}
1408
1442
  style="padding: 0;"
1409
1443
  >
@@ -1428,7 +1462,7 @@
1428
1462
  <div
1429
1463
  bind:this={opacitySliderRef}
1430
1464
  class="relative cursor-pointer opacity-slider"
1431
- style="width: {pickerSize}px; height: {sliderWidth}px; margin-left: 10px;"
1465
+ style="width: {pickerSize}px; height: {sliderWidth}px; margin-inline-start: 10px;"
1432
1466
  onmousedown={handleOpacityMouseDown}
1433
1467
  >
1434
1468
  <div class="absolute inset-0 overflow-hidden">
@@ -1449,13 +1483,13 @@
1449
1483
  </div>
1450
1484
  </div>
1451
1485
  <!-- Opacity number input — square, matching swatch size -->
1452
- <div class="opacity-number-ct flex items-center justify-center" style="width: {swatchSize + 6}px; min-width: {swatchSize + 6}px; height: {swatchSize}px; margin-left: 8px;">
1486
+ <div class="opacity-number-ct flex items-center justify-center" style="width: {swatchSize + 6}px; min-width: {swatchSize + 6}px; height: {swatchSize}px; margin-inline-start: 8px;">
1453
1487
  <Input
1454
1488
  type="number"
1455
1489
  min="0"
1456
1490
  max="100"
1457
1491
  class="w-full h-full text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none ring-0 focus:ring-0 shadow-none widgetic-colors subtext"
1458
- style="border: 1px solid hsl(var(--forest-green)); color: hsl(var(--grey7)); padding-left: 8px; padding-right: 0;"
1492
+ style="border: 1px solid hsl(var(--forest-green)); color: hsl(var(--grey7)); padding-inline-start: 8px; padding-inline-end: 0;"
1459
1493
  value={opacityPercentage}
1460
1494
  oninput={(e) => {
1461
1495
  e.stopPropagation();
@@ -29,6 +29,16 @@
29
29
  <script lang="ts">
30
30
  import { onMount, tick } from 'svelte';
31
31
  import InputControllerHeader from './InputControllerHeader.svelte';
32
+ import { activeElementIn } from '../../utils/shadowDom';
33
+ import { useEditorLanguage } from '../../i18n/language';
34
+ import type { EditorMessageKey } from '../../i18n/messages';
35
+ import { useTextDirection } from '@widgetic/design-system/components/textDirection';
36
+
37
+ // The words of the editor in the language of the page.
38
+ const { t } = useEditorLanguage();
39
+
40
+ // The way the screen is read: the row of fields turns around with it, and so do the keys that move from one field to the next.
41
+ const textDirection = useTextDirection();
32
42
 
33
43
  // Constants
34
44
  const MIN_DAY = 1;
@@ -92,27 +102,30 @@
92
102
  }
93
103
  });
94
104
 
95
- // Get field configuration
105
+ // Get field configuration. The hint in a box and its name for a screen reader are messages (`hintKey`, `labelKey`).
96
106
  function getFieldConfig(field: 'day' | 'month' | 'year') {
97
- const configs = {
107
+ const configs: Record<'day' | 'month' | 'year', { min: number; max: number; hintKey: EditorMessageKey; labelKey: EditorMessageKey; width: string; maxLength: number }> = {
98
108
  day: {
99
109
  min: MIN_DAY,
100
110
  max: MAX_DAY,
101
- placeholder: 'DD',
111
+ hintKey: 'editor.dateField.dayHint',
112
+ labelKey: 'editor.dateField.dayLabel',
102
113
  width: '40px',
103
114
  maxLength: 2
104
115
  },
105
116
  month: {
106
117
  min: MIN_MONTH,
107
118
  max: MAX_MONTH,
108
- placeholder: 'MM',
119
+ hintKey: 'editor.dateField.monthHint',
120
+ labelKey: 'editor.dateField.monthLabel',
109
121
  width: '40px',
110
122
  maxLength: 2
111
123
  },
112
124
  year: {
113
125
  min: MIN_YEAR,
114
126
  max: MAX_YEAR,
115
- placeholder: 'YYYY',
127
+ hintKey: 'editor.dateField.yearHint',
128
+ labelKey: 'editor.dateField.yearLabel',
116
129
  width: '60px',
117
130
  maxLength: 4
118
131
  }
@@ -311,9 +324,12 @@
311
324
 
312
325
  // Reset focused field after a short delay to allow for focus changes
313
326
  setTimeout(() => {
314
- if (!document.activeElement ||
315
- !(document.activeElement instanceof HTMLInputElement) ||
316
- !Object.values(inputRefs).includes(document.activeElement)) {
327
+ // The focused element of the tree this control is in: inside a module `document.activeElement` is the module's
328
+ // host element, which is never one of the fields
329
+ const focusedElement = activeElementIn(input);
330
+ if (!focusedElement ||
331
+ !(focusedElement instanceof HTMLInputElement) ||
332
+ !Object.values(inputRefs).includes(focusedElement)) {
317
333
  focusedField = null;
318
334
  }
319
335
  }, 100);
@@ -333,11 +349,14 @@
333
349
  return;
334
350
  }
335
351
 
336
- // Handle arrow navigation
352
+ // Handle arrow navigation. The digits of a field run left to right on every screen, so the key that leaves a field at the
353
+ // right end of its digits is ArrowRight on every screen, and what lies on that side is the next field on a left to right
354
+ // screen and the one before it on a right to left one (where the row of fields starts on the right).
337
355
  if (e.key === 'ArrowRight') {
338
356
  if (input.selectionStart === input.value.length) {
339
357
  e.preventDefault();
340
- focusNextField(field);
358
+ if ($textDirection === 'rtl') focusPreviousField(field);
359
+ else focusNextField(field);
341
360
  }
342
361
  return;
343
362
  }
@@ -345,7 +364,8 @@
345
364
  if (e.key === 'ArrowLeft') {
346
365
  if (input.selectionStart === 0) {
347
366
  e.preventDefault();
348
- focusPreviousField(field);
367
+ if ($textDirection === 'rtl') focusNextField(field);
368
+ else focusPreviousField(field);
349
369
  }
350
370
  return;
351
371
  }
@@ -399,7 +419,7 @@
399
419
  <InputControllerHeader {label} {helpText} />
400
420
  </div>
401
421
 
402
- <div class="absolute right-0">
422
+ <div class="absolute end-0">
403
423
  <div class="h-[48px] w-[185px] rounded-large border border-grey2 bg-grey1 flex items-center transition-colors duration-200 hover:border-forest-green">
404
424
  <div class="flex items-center justify-between px-4 w-full">
405
425
  {#each fieldOrder as field}
@@ -412,14 +432,14 @@
412
432
  pattern="[0-9]*"
413
433
  bind:this={inputRefs[field]}
414
434
  value={field === 'day' ? day : field === 'month' ? month : year}
415
- placeholder={config.placeholder}
435
+ placeholder={$t(config.hintKey)}
416
436
  on:input={field === 'day' ? handleDayInput : field === 'month' ? handleMonthInput : handleYearInput}
417
437
  on:change={handleChange}
418
438
  on:focus={() => handleFocus(field)}
419
439
  on:blur={() => handleBlur(field)}
420
440
  on:keydown={(e) => handleKeyDown(field, e)}
421
441
  class={getFieldClasses(field).input}
422
- aria-label={`${field} input`}
442
+ aria-label={$t(config.labelKey)}
423
443
  maxlength={config.maxLength}
424
444
  />
425
445
  </div>