@37signals/lexxy 0.9.33 → 1.0.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.
package/README.md CHANGED
@@ -8,6 +8,7 @@ A modern rich text editor for Rails.
8
8
 
9
9
  - Built on top of [Lexical](https://lexical.dev), the powerful text editor framework from Meta.
10
10
  - Good HTML semantics. Paragraphs are real `<p>` tags, as they should be.
11
+ - Designed with [accessibility](https://lexxy.dev/docs/accessibility.html) in mind.
11
12
  - Markdown support: shortcuts, auto-formatting on paste.
12
13
  - Real-time code syntax highlighting.
13
14
  - Create links by pasting URLs on selected text.
package/dist/lexxy.esm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { highlightCode, highlightElement } from './lexxy_helpers.esm.js';
2
2
  import createDOMPurify from 'dompurify';
3
3
  import { getStyleObjectFromCSS, getCSSFromStyleObject, $getSelectionStyleValueForProperty, $ensureForwardRangeSelection, $isAtNodeEnd, $patchStyleText, $setBlocksType, $forEachSelectedTextNode } from '@lexical/selection';
4
- import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, $isTextNode, DecoratorNode, $createTextNode, defineExtension, $createNodeSelection, $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $isDecoratorNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $normalizeSelection__EXPERIMENTAL, $isNodeSelection, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, ElementNode, $splitNode, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, INSERT_PARAGRAPH_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_CRITICAL, $addUpdateTag, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $getChildCaretAtIndex, $createLineBreakNode, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, DRAGSTART_COMMAND, DROP_COMMAND, KEY_DOWN_COMMAND, flipDirection, mergeRegister as mergeRegister$1, $createRangeSelectionFromDom, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, KEY_SPACE_COMMAND, INPUT_COMMAND, KEY_BACKSPACE_COMMAND } from 'lexical';
4
+ import { SKIP_DOM_SELECTION_TAG, CAN_UNDO_COMMAND, COMMAND_PRIORITY_LOW, CAN_REDO_COMMAND, $getSelection, $isRangeSelection, $isTextNode, DecoratorNode, $createTextNode, defineExtension, $createNodeSelection, $getRoot, $caretFromPoint, $setSelectionFromCaretRange, $getCaretRange, $normalizeCaret, $getChildCaret, $getCaretInDirection, $isParagraphNode, $isLineBreakNode, $createParagraphNode, $isElementNode, $isRootOrShadowRoot, $isRootNode, $isDecoratorNode, $getSiblingCaret, $rewindSiblingCaret, $splitAtPointCaretNext, $normalizeSelection__EXPERIMENTAL, $isNodeSelection, $isChildCaret, $isTextPointCaret, $isExtendableTextPointCaret, $isSiblingCaret, $getCommonAncestor, $findMatchingParent, TextNode, createCommand, COMMAND_PRIORITY_EDITOR, $getEditor, $getNodeByKey, HISTORY_MERGE_TAG, SKIP_SCROLL_INTO_VIEW_TAG, $cloneWithProperties, $getNearestRootOrShadowRoot, $createRangeSelection, $setSelection, ElementNode, $splitNode, createState, COMMAND_PRIORITY_NORMAL, $getState, $setState, $hasUpdateTag, PASTE_TAG, FORMAT_TEXT_COMMAND, UNDO_COMMAND, REDO_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_TAB_COMMAND, INSERT_LINE_BREAK_COMMAND, COMMAND_PRIORITY_HIGH, INSERT_PARAGRAPH_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ARROW_DOWN_COMMAND, DELETE_CHARACTER_COMMAND, SELECTION_CHANGE_COMMAND, COMMAND_PRIORITY_CRITICAL, $addUpdateTag, CLICK_COMMAND, isDOMNode, $getNearestNodeFromDOMNode, $getChildCaretAtIndex, $createLineBreakNode, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, PASTE_COMMAND, $onUpdate, ParagraphNode, RootNode, DRAGSTART_COMMAND, DROP_COMMAND, KEY_DOWN_COMMAND, flipDirection, mergeRegister as mergeRegister$1, $createRangeSelectionFromDom, CLEAR_HISTORY_COMMAND, KEY_ENTER_COMMAND, KEY_SPACE_COMMAND, INPUT_COMMAND, HISTORY_PUSH_TAG, KEY_BACKSPACE_COMMAND } from 'lexical';
5
5
  import * as lexical from 'lexical';
6
6
  export { lexical as Lexical };
7
7
  import { LinkNode, $createAutoLinkNode, $toggleLink, $createLinkNode, $isLinkNode, AutoLinkNode } from '@lexical/link';
@@ -589,6 +589,35 @@ function isKeyboardActivation(event) {
589
589
  return event instanceof PointerEvent && event.pointerId === -1
590
590
  }
591
591
 
592
+ function trapFocusAtTabBoundary(container, event) {
593
+ if (event.key !== "Tab") return
594
+
595
+ const tabStops = Array.from(container.querySelectorAll("a[href], area[href], button, input:not([type='hidden']), select, textarea, iframe, object, embed, [contenteditable]:not([contenteditable='false']), [tabindex], summary, audio[controls], video[controls]"))
596
+ .filter(element => element.tabIndex >= 0 && !element.matches(":disabled") && !element.closest("[inert]") && isActiveAndVisible(element))
597
+ .sort((first, second) => {
598
+ if (first.tabIndex > 0 && second.tabIndex > 0) {
599
+ return first.tabIndex - second.tabIndex
600
+ } else if (first.tabIndex > 0) {
601
+ return -1
602
+ } else if (second.tabIndex > 0) {
603
+ return 1
604
+ } else {
605
+ return 0
606
+ }
607
+ });
608
+
609
+ const firstTabStop = tabStops[0];
610
+ const lastTabStop = tabStops.at(-1);
611
+
612
+ if (event.shiftKey && document.activeElement === firstTabStop) {
613
+ event.preventDefault();
614
+ lastTabStop.focus();
615
+ } else if (!event.shiftKey && document.activeElement === lastTabStop) {
616
+ event.preventDefault();
617
+ firstTabStop.focus();
618
+ }
619
+ }
620
+
592
621
  function handleRollingTabIndex(elements, event, { orientation = "horizontal", wrap = false } = {}) {
593
622
  const previousActiveElement = document.activeElement;
594
623
 
@@ -1443,10 +1472,12 @@ class ToolbarDropdown extends HTMLElement {
1443
1472
  }
1444
1473
 
1445
1474
  #handleKeyDown = (event) => {
1446
- if (event.key === "Escape") {
1475
+ if (event.key === "Escape" && this.isOpen) {
1447
1476
  event.stopPropagation();
1448
1477
  this.close({ focusEditor: !this.#shouldReturnFocusToTrigger });
1449
1478
  if (this.#shouldReturnFocusToTrigger) this.trigger?.focus();
1479
+ } else if (this.isOpen && this.panel.role === "dialog") {
1480
+ trapFocusAtTabBoundary(this.panel, event);
1450
1481
  } else if (this.#isNavigatingMenu(event)) {
1451
1482
  event.stopPropagation();
1452
1483
  handleRollingTabIndex(this.#buttons, event, { orientation: "both", wrap: true });
@@ -1461,7 +1492,7 @@ class ToolbarDropdown extends HTMLElement {
1461
1492
  // Ask for the ring explicitly: opening the menu with the mouse otherwise leaves the
1462
1493
  // first item focused without a focus ring, since focus() inherits the mouse modality.
1463
1494
  this.#interactiveElements[0]?.focus({ focusVisible: true });
1464
- await this.#resetTabIndexValues();
1495
+ if (this.panel.role === "menu") await this.#resetTabIndexValues();
1465
1496
  }
1466
1497
 
1467
1498
  async #resetTabIndexValues() {
@@ -2233,12 +2264,33 @@ class CustomActionTextAttachmentNode extends DecoratorNode {
2233
2264
  const trimmedLabel = this.label.trim();
2234
2265
  const matches = [ ...figure.querySelectorAll("span") ].filter((span) => span.textContent.trim() === trimmedLabel);
2235
2266
  const deepest = matches.filter((span) => !matches.some((other) => other !== span && span.contains(other)));
2267
+ const labelImage = figure.querySelector("[data-lexxy-label-image]");
2268
+
2236
2269
  for (const span of deepest) {
2237
2270
  if (span.getAttribute("aria-hidden") !== "true") {
2238
- span.setAttribute("data-lexxy-label-mirror", "");
2271
+ if (span.contains(labelImage)) {
2272
+ this.#wrapLabelTextNodesIn(span);
2273
+ } else {
2274
+ span.setAttribute("data-lexxy-label-mirror", "");
2275
+ }
2239
2276
  }
2240
2277
  }
2241
2278
  }
2279
+
2280
+ #wrapLabelTextNodesIn(element) {
2281
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
2282
+ const textNodes = [];
2283
+
2284
+ while (walker.nextNode()) {
2285
+ if (walker.currentNode.textContent.trim()) textNodes.push(walker.currentNode);
2286
+ }
2287
+
2288
+ for (const textNode of textNodes) {
2289
+ const mirror = createElement("span", { "data-lexxy-label-mirror": "" });
2290
+ textNode.replaceWith(mirror);
2291
+ mirror.append(textNode);
2292
+ }
2293
+ }
2242
2294
  }
2243
2295
 
2244
2296
  function $isCustomActionTextAttachmentNode(node) {
@@ -2975,7 +3027,7 @@ class ActionTextAttachmentNode extends DecoratorNode {
2975
3027
  node: new ActionTextAttachmentNode({
2976
3028
  src: img.getAttribute("src"),
2977
3029
  fileName: fileName,
2978
- caption: img.getAttribute("alt") || "",
3030
+ altText: img.getAttribute("alt"),
2979
3031
  contentType: "image/*",
2980
3032
  width: img.getAttribute("width"),
2981
3033
  height: img.getAttribute("height")
@@ -3300,8 +3352,9 @@ class ActionTextAttachmentNode extends DecoratorNode {
3300
3352
  }
3301
3353
 
3302
3354
  #swapToPreviewDOM(figure, previewSrc) {
3355
+ const altText = this.editor.read(() => this.getLatest().altText);
3303
3356
  this.#swapFigureContent(figure, "attachment--file", "attachment--preview", () => {
3304
- const img = createElement("img", { src: previewSrc, draggable: false, alt: this.altText });
3357
+ const img = createElement("img", { src: previewSrc, draggable: false, alt: altText });
3305
3358
  img.onerror = () => this.#swapPreviewToFileDOM(img);
3306
3359
  const container = createElement("div", { className: "attachment__container" });
3307
3360
  container.appendChild(img);
@@ -6184,6 +6237,11 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
6184
6237
  updateDOM(prevNode, dom) {
6185
6238
  if (this.uploadError !== prevNode.uploadError) return true
6186
6239
 
6240
+ const image = dom.querySelector("img");
6241
+ if (image && prevNode.altText !== this.altText) {
6242
+ image.alt = this.altText;
6243
+ }
6244
+
6187
6245
  if (prevNode.progress !== this.progress) {
6188
6246
  const progress = dom.querySelector("progress");
6189
6247
  progress.value = this.progress ?? 0;
@@ -6217,7 +6275,7 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
6217
6275
  }
6218
6276
 
6219
6277
  #createDOMForImage() {
6220
- return createElement("img")
6278
+ return createElement("img", { alt: this.altText })
6221
6279
  }
6222
6280
 
6223
6281
  #createDOMForFile() {
@@ -6382,7 +6440,6 @@ class AttachmentNodeConversion {
6382
6440
  const { blob } = this;
6383
6441
  return {
6384
6442
  sgid: blob.attachable_sgid,
6385
- altText: blob.filename,
6386
6443
  contentType: blob.content_type,
6387
6444
  fileName: blob.filename,
6388
6445
  fileSize: blob.byte_size,
@@ -7260,7 +7317,7 @@ class Contents {
7260
7317
  this.#performTextReplacement(anchorNode, lastIndex, stringToReplace, replacementNodes);
7261
7318
  }
7262
7319
 
7263
- uploadFiles(files, { selectLast } = {}) {
7320
+ uploadFiles(files, { selectLast, altText } = {}) {
7264
7321
  if (!this.editorElement) return // Disposed (e.g. on turbo:before-cache); a late drop can still land here
7265
7322
 
7266
7323
  if (!this.editorElement.supportsAttachments) {
@@ -7273,6 +7330,10 @@ class Contents {
7273
7330
  const uploader = Uploader.for(this.editorElement, validFiles);
7274
7331
  uploader.$uploadFiles();
7275
7332
 
7333
+ if (altText && files.length === 1 && uploader.nodes.length === 1) {
7334
+ uploader.nodes[0].getWritable().altText = altText;
7335
+ }
7336
+
7276
7337
  if (selectLast && uploader.nodes?.length) {
7277
7338
  const lastNode = uploader.nodes.at(-1);
7278
7339
  lastNode.selectEnd();
@@ -7985,46 +8046,51 @@ class Clipboard {
7985
8046
  }
7986
8047
 
7987
8048
  #handlePastedFiles(clipboardData) {
7988
- if (!this.editorElement.supportsAttachments) return false
7989
-
7990
- const html = clipboardData.getData("text/html");
7991
- const files = clipboardData.files;
7992
-
7993
- if (files.length && this.#isCopiedImageHTML(html)) {
7994
- this.#uploadFilesPreservingScroll(files);
7995
- return true
7996
- }
7997
-
7998
- if (html && !this.#isLexicalClipboardData(clipboardData)) {
7999
- this.contents.insertHtml(html, { tag: PASTE_TAG });
8000
- return true
8001
- }
8049
+ if (this.editorElement.supportsAttachments) {
8050
+ const html = clipboardData.getData("text/html");
8051
+ const files = clipboardData.files;
8052
+ const copiedImage = files.length && this.#copiedImage(html);
8002
8053
 
8003
- if (files.length) {
8004
- this.#uploadFilesPreservingScroll(files);
8005
- return true
8054
+ if (copiedImage) {
8055
+ this.#uploadFilesPreservingScroll(files, copiedImage.getAttribute("alt"));
8056
+ return true
8057
+ } else if (html && !this.#isLexicalClipboardData(clipboardData)) {
8058
+ this.contents.insertHtml(html, { tag: PASTE_TAG });
8059
+ return true
8060
+ } else if (files.length) {
8061
+ this.#uploadFilesPreservingScroll(files);
8062
+ return true
8063
+ } else {
8064
+ return false
8065
+ }
8066
+ } else {
8067
+ return false
8006
8068
  }
8007
-
8008
- return false
8009
8069
  }
8010
8070
 
8011
8071
  #isLexicalClipboardData(clipboardData) {
8012
8072
  return Array.from(clipboardData.types).includes("application/x-lexical-editor")
8013
8073
  }
8014
8074
 
8015
- #isCopiedImageHTML(html) {
8016
- if (!html) return false
8017
-
8018
- const doc = parseHtml(html);
8019
- const elementChildren = Array.from(doc.body.children);
8075
+ #copiedImage(html) {
8076
+ if (html) {
8077
+ const doc = parseHtml(html);
8078
+ const elementChildren = Array.from(doc.body.children);
8020
8079
 
8021
- return elementChildren.length === 1 && elementChildren[0].tagName === "IMG"
8080
+ if (elementChildren.length === 1 && elementChildren[0].tagName === "IMG") {
8081
+ return elementChildren[0]
8082
+ } else {
8083
+ return null
8084
+ }
8085
+ } else {
8086
+ return null
8087
+ }
8022
8088
  }
8023
8089
 
8024
- #uploadFilesPreservingScroll(files) {
8090
+ #uploadFilesPreservingScroll(files, altText) {
8025
8091
  this.#preservingScrollPosition(() => {
8026
8092
  if (files.length) {
8027
- this.contents.uploadFiles(files, { selectLast: true });
8093
+ this.contents.uploadFiles(files, { selectLast: true, altText });
8028
8094
  }
8029
8095
  });
8030
8096
  }
@@ -10324,6 +10390,7 @@ class LexicalEditorElement extends HTMLElement {
10324
10390
  #historyState = { undo: false, redo: false }
10325
10391
 
10326
10392
  #validity = new Map()
10393
+ #validationAttempted = false
10327
10394
  #validationTextArea = document.createElement("textarea")
10328
10395
  #uploadRequests
10329
10396
  #liveRegion
@@ -10408,10 +10475,13 @@ class LexicalEditorElement extends HTMLElement {
10408
10475
  }
10409
10476
 
10410
10477
  requiredChangedCallback() {
10478
+ if (this.editorContentElement) this.#synchronizeAriaAttribute("aria-required");
10411
10479
  if (this.isConnected) this.#requestValidityRefresh();
10412
10480
  }
10413
10481
 
10414
10482
  formResetCallback() {
10483
+ this.#validationAttempted = false;
10484
+ this.#synchronizeAriaAttribute("aria-invalid");
10415
10485
  this.value = this.#initialValue;
10416
10486
  this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
10417
10487
  }
@@ -10438,6 +10508,15 @@ class LexicalEditorElement extends HTMLElement {
10438
10508
  return this.internals.validity
10439
10509
  }
10440
10510
 
10511
+ get validationMessage() {
10512
+ return this.internals.validationMessage
10513
+ }
10514
+
10515
+ setCustomValidity(message) {
10516
+ this.#validationTextArea.setCustomValidity(message);
10517
+ this.#refreshValidity();
10518
+ }
10519
+
10441
10520
  checkValidity() {
10442
10521
  return this.internals.checkValidity()
10443
10522
  }
@@ -10675,6 +10754,7 @@ class LexicalEditorElement extends HTMLElement {
10675
10754
  this.#attachDebugHooks();
10676
10755
  this.#attachToolbar();
10677
10756
  this.#resetBeforeTurboCaches();
10757
+ this.#observeAriaAttributes();
10678
10758
 
10679
10759
  this.#setInternalFormValue(this.value, { suppressEvent: true });
10680
10760
  this.#synchronizeWithChanges();
@@ -10747,7 +10827,9 @@ class LexicalEditorElement extends HTMLElement {
10747
10827
  classList: "lexxy-editor__content",
10748
10828
  contenteditable: true,
10749
10829
  role: "textbox",
10750
- "aria-multiline": true,
10830
+ "aria-multiline": this.supportsMultiLine,
10831
+ "aria-required": this.required,
10832
+ "aria-invalid": false,
10751
10833
  "aria-label": this.#labelText,
10752
10834
  placeholder: this.getAttribute("placeholder")
10753
10835
  });
@@ -10780,6 +10862,46 @@ class LexicalEditorElement extends HTMLElement {
10780
10862
  return Array.from(this.attributes).filter(attribute => attribute.name.startsWith("aria-"))
10781
10863
  }
10782
10864
 
10865
+ #observeAriaAttributes() {
10866
+ const observer = new MutationObserver(mutations => {
10867
+ for (const { attributeName } of mutations) {
10868
+ if (attributeName.startsWith("aria-")) {
10869
+ this.#synchronizeAriaAttribute(attributeName);
10870
+ } else if (attributeName === "single-line") {
10871
+ this.#synchronizeAriaAttribute("aria-multiline");
10872
+ }
10873
+ }
10874
+ });
10875
+ observer.observe(this, { attributes: true });
10876
+
10877
+ this.#listeners.track(
10878
+ () => observer.disconnect(),
10879
+ registerEventListener(this, "invalid", () => {
10880
+ this.#validationAttempted = true;
10881
+ this.#synchronizeAriaAttribute("aria-invalid");
10882
+ })
10883
+ );
10884
+ }
10885
+
10886
+ #synchronizeAriaAttribute(name) {
10887
+ const value = this.getAttribute(name) ?? this.#defaultAriaValue(name);
10888
+ if (value === null) {
10889
+ this.editorContentElement.removeAttribute(name);
10890
+ } else if (this.editorContentElement.getAttribute(name) !== String(value)) {
10891
+ this.editorContentElement.setAttribute(name, value);
10892
+ }
10893
+ }
10894
+
10895
+ #defaultAriaValue(name) {
10896
+ switch (name) {
10897
+ case "aria-label": return this.#labelText
10898
+ case "aria-required": return this.required
10899
+ case "aria-multiline": return this.supportsMultiLine
10900
+ case "aria-invalid": return this.#validationAttempted && !this.validity.valid
10901
+ default: return null
10902
+ }
10903
+ }
10904
+
10783
10905
  #setInternalFormValue(html, { suppressEvent = false } = {}) {
10784
10906
  const changed = html !== this.#previousInternalFormValue;
10785
10907
 
@@ -10836,11 +10958,16 @@ class LexicalEditorElement extends HTMLElement {
10836
10958
  #refreshValidity() {
10837
10959
  this.#refreshInternalValidity();
10838
10960
  const { validity, message } = this.#calculateValidity();
10839
- this.internals.setValidity(validity, message, this.editorContentElement);
10961
+ this.internals.setValidity(validity, message, this.editorContentElement ?? undefined);
10962
+ if (this.editorContentElement) {
10963
+ this.#synchronizeAriaAttribute("aria-invalid");
10964
+ }
10840
10965
  }
10841
10966
 
10842
10967
  #refreshInternalValidity() {
10843
- this.#validationTextArea.required = this.required && this.isBlank;
10968
+ if (this.editorContentElement) {
10969
+ this.#validationTextArea.required = this.required && this.isBlank;
10970
+ }
10844
10971
  const flags = this.#validationTextArea.validity;
10845
10972
  const message = this.#validationTextArea.validationMessage;
10846
10973
 
@@ -10855,12 +10982,14 @@ class LexicalEditorElement extends HTMLElement {
10855
10982
  // internal TextArea's ValidityState can contain `valid: true`
10856
10983
  if (flags.valid === true) continue
10857
10984
 
10985
+ let hasError = false;
10858
10986
  for (const flag in flags) {
10859
10987
  if (flags[flag]) {
10860
10988
  validity[flag] = true;
10861
- messages.push(message);
10989
+ hasError = true;
10862
10990
  }
10863
10991
  }
10992
+ if (hasError) messages.push(message);
10864
10993
  }
10865
10994
 
10866
10995
  return { validity, message: messages.join("\n") }
@@ -11178,6 +11307,7 @@ class LexicalEditorElement extends HTMLElement {
11178
11307
 
11179
11308
  #resetValidity() {
11180
11309
  this.#validity = new Map();
11310
+ this.#validationAttempted = false;
11181
11311
  }
11182
11312
  }
11183
11313
 
@@ -12123,6 +12253,111 @@ class CodeLanguagePicker extends HTMLElement {
12123
12253
  }
12124
12254
  }
12125
12255
 
12256
+ class AlternativeTextDialog {
12257
+ #editor
12258
+ #editorElement
12259
+ #dialog
12260
+ #input
12261
+ #nodeKey
12262
+ #elementToRefocus
12263
+ #listeners = new ListenerBin()
12264
+
12265
+ constructor(editorElement) {
12266
+ this.#editor = editorElement.editor;
12267
+ this.#editorElement = editorElement;
12268
+ this.#dialog = createElement("dialog", { className: "lexxy-alternative-text-dialog", ariaLabel: "Alternative text" });
12269
+ this.#input = createElement("textarea", { rows: 1, autofocus: true, ariaLabel: "Description", placeholder: "Keep it short, but enough to convey the context…" });
12270
+
12271
+ const title = createElement("h2", { textContent: "Describe the image for people who can't see it:" });
12272
+
12273
+ const actions = createElement("div", { className: "lexxy-alternative-text-dialog__actions" });
12274
+ const cancel = createElement("button", { type: "button", textContent: "Cancel" });
12275
+ const save = createElement("button", { type: "button", className: "lexxy-alternative-text-dialog__save", textContent: "Save" });
12276
+ actions.append(cancel, save);
12277
+
12278
+ const controls = createElement("div", { className: "lexxy-alternative-text-dialog__controls" });
12279
+ controls.append(this.#input, actions);
12280
+ this.#dialog.append(title, controls);
12281
+ editorElement.appendChild(this.#dialog);
12282
+
12283
+ this.#listeners.track(
12284
+ registerEventListener(cancel, "click", () => this.#close()),
12285
+ registerEventListener(save, "click", () => this.#save()),
12286
+ registerEventListener(this.#dialog, "keydown", this.#handleKeyDown),
12287
+ registerEventListener(document, "pointerdown", this.#closeOnClickOutside)
12288
+ );
12289
+ }
12290
+
12291
+ dispose() {
12292
+ this.#dialog.remove();
12293
+ this.#listeners.dispose();
12294
+ }
12295
+
12296
+ open(nodeKey) {
12297
+ const description = this.#editor.read(() => {
12298
+ const node = $getNodeByKey(nodeKey);
12299
+ if ($isActionTextAttachmentNode(node)) {
12300
+ return node.altText
12301
+ }
12302
+ });
12303
+
12304
+ if (description !== undefined) {
12305
+ this.#nodeKey = nodeKey;
12306
+ this.#input.value = description;
12307
+ this.#anchorBelowAttachment(nodeKey);
12308
+ this.#elementToRefocus = document.activeElement;
12309
+ this.#editorElement.querySelector("lexxy-toolbar")?.closeDropdowns();
12310
+ this.#dialog.show();
12311
+ this.#input.focus();
12312
+ }
12313
+ }
12314
+
12315
+ #anchorBelowAttachment(nodeKey) {
12316
+ const figureElement = this.#editor.getElementByKey(nodeKey);
12317
+ if (figureElement) {
12318
+ const rect = figureElement.getBoundingClientRect();
12319
+ const editorRect = this.#editorElement.getBoundingClientRect();
12320
+ const bottom = rect.bottom - editorRect.top - this.#editorElement.clientTop + this.#editorElement.scrollTop;
12321
+ this.#dialog.style.setProperty("--lexxy-anchor-bottom", `${bottom}px`);
12322
+ }
12323
+ }
12324
+
12325
+ #save() {
12326
+ const description = this.#input.value.trim();
12327
+ this.#editor.update(() => {
12328
+ const node = $getNodeByKey(this.#nodeKey);
12329
+ if ($isActionTextAttachmentNode(node) && node.altText !== description) {
12330
+ node.getWritable().altText = description;
12331
+ }
12332
+ }, { tag: [ HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG ] });
12333
+ this.#close();
12334
+ }
12335
+
12336
+ #handleKeyDown = (event) => {
12337
+ event.stopPropagation();
12338
+ if (event.key === "Escape") {
12339
+ this.#close();
12340
+ } else {
12341
+ trapFocusAtTabBoundary(this.#dialog, event);
12342
+ }
12343
+ }
12344
+
12345
+ // Dismissing by clicking elsewhere must leave focus where the click landed.
12346
+ #closeOnClickOutside = (event) => {
12347
+ if (this.#dialog.open && !this.#dialog.contains(event.target)) {
12348
+ this.#close({ refocus: false });
12349
+ }
12350
+ }
12351
+
12352
+ #close({ refocus = true } = {}) {
12353
+ this.#dialog.close();
12354
+ if (refocus && this.#elementToRefocus?.isConnected) {
12355
+ this.#elementToRefocus.focus();
12356
+ }
12357
+ this.#elementToRefocus = null;
12358
+ }
12359
+ }
12360
+
12126
12361
  const DELETE_ICON = `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
12127
12362
  <path d="M11.2041 1.01074C12.2128 1.113 13 1.96435 13 3V4H15L15.1025 4.00488C15.6067 4.05621 16 4.48232 16 5C16 5.55228 15.5523 6 15 6H14.8457L14.1416 15.1533C14.0614 16.1953 13.1925 17 12.1475 17H5.85254L5.6582 16.9902C4.76514 16.9041 4.03607 16.2296 3.88184 15.3457L3.8584 15.1533L3.1543 6H3C2.44772 6 2 5.55228 2 5C2 4.44772 2.44772 4 3 4H5V3C5 1.89543 5.89543 1 7 1H11L11.2041 1.01074ZM5.85254 15H12.1475L12.8398 6H5.16016L5.85254 15ZM7 4H11V3H7V4Z"/>
12128
12363
  </svg>`;
@@ -12130,6 +12365,8 @@ const DELETE_ICON = `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"
12130
12365
  class AttachmentToolbar extends HTMLElement {
12131
12366
  #listeners = new ListenerBin()
12132
12367
  #buttons = []
12368
+ #alternativeTextButton
12369
+ #alternativeTextDialog
12133
12370
  #currentNodeKey = null
12134
12371
  #reflowObserver = new ResizeObserver(() => this.#updatePosition())
12135
12372
  #presentationObserver = new MutationObserver(() => this.#refresh())
@@ -12156,6 +12393,7 @@ class AttachmentToolbar extends HTMLElement {
12156
12393
  this.#listeners.dispose();
12157
12394
  this.#reflowObserver.disconnect();
12158
12395
  this.#presentationObserver.disconnect();
12396
+ this.#alternativeTextDialog?.dispose();
12159
12397
  }
12160
12398
 
12161
12399
  get #editor() {
@@ -12170,7 +12408,9 @@ class AttachmentToolbar extends HTMLElement {
12170
12408
  this.innerHTML = "";
12171
12409
 
12172
12410
  const container = createElement("div", { className: "lexxy-floating-controls__group" });
12411
+ container.appendChild(this.#createAlternativeTextButton());
12173
12412
  container.appendChild(this.#createRemoveButton());
12413
+ this.#alternativeTextDialog = new AlternativeTextDialog(this.#editorElement);
12174
12414
  this.appendChild(container);
12175
12415
 
12176
12416
  this.#buttons = Array.from(this.querySelectorAll("button"));
@@ -12203,6 +12443,22 @@ class AttachmentToolbar extends HTMLElement {
12203
12443
  return this.#currentNodeKey !== null
12204
12444
  }
12205
12445
 
12446
+ #createAlternativeTextButton() {
12447
+ this.#alternativeTextButton = createElement("button", {
12448
+ type: "button",
12449
+ className: "lexxy-attachment-toolbar__alternative-text",
12450
+ ariaLabel: "Alternative text",
12451
+ title: "Alternative text",
12452
+ ariaHasPopup: "dialog",
12453
+ tabIndex: -1
12454
+ });
12455
+ this.#alternativeTextButton.appendChild(createElement("span", null, "ALT"));
12456
+ this.#listeners.track(registerEventListener(this.#alternativeTextButton, "click", () => {
12457
+ this.#alternativeTextDialog.open(this.#currentNodeKey);
12458
+ }));
12459
+ return this.#alternativeTextButton
12460
+ }
12461
+
12206
12462
  #navigateOrExit = (event) => {
12207
12463
  if (event.key === "Escape") {
12208
12464
  event.preventDefault();
@@ -12275,6 +12531,7 @@ class AttachmentToolbar extends HTMLElement {
12275
12531
 
12276
12532
  this.#editor.getEditorState().read(() => {
12277
12533
  const node = $getNodeByKey(nodeKey);
12534
+ this.#alternativeTextButton.hidden = !this.#canEditAlternativeText(node);
12278
12535
  this.dataset.nodeType = node.getType();
12279
12536
  this.dataset.presentation = this.#presentationOf(node, element);
12280
12537
 
@@ -12286,6 +12543,12 @@ class AttachmentToolbar extends HTMLElement {
12286
12543
  });
12287
12544
  }
12288
12545
 
12546
+ #canEditAlternativeText(node) {
12547
+ return this.#editorElement.dataset.actionTextSupportsAlt !== "false"
12548
+ && node.getType() === "action_text_attachment"
12549
+ && node.isPreviewableAttachment
12550
+ }
12551
+
12289
12552
  #presentationOf(node, element) {
12290
12553
  if ($isImageGalleryNode(node.getParent())) {
12291
12554
  return "gallery"
@@ -12307,8 +12570,11 @@ class AttachmentToolbar extends HTMLElement {
12307
12570
  if (figureElement) {
12308
12571
  const rect = figureElement.getBoundingClientRect();
12309
12572
  const editorRect = this.#editorElement.getBoundingClientRect();
12310
- this.style.setProperty("--lexxy-anchor-top", `${rect.top - editorRect.top}px`);
12311
- this.style.setProperty("--lexxy-anchor-left", `${rect.left - editorRect.left}px`);
12573
+ // We are positioned against the editor's padding box, but the rect measures its border box.
12574
+ const borderTop = this.#editorElement.clientTop;
12575
+ const borderLeft = this.#editorElement.clientLeft;
12576
+ this.style.setProperty("--lexxy-anchor-top", `${rect.top - editorRect.top - borderTop}px`);
12577
+ this.style.setProperty("--lexxy-anchor-left", `${rect.left - editorRect.left - borderLeft}px`);
12312
12578
  this.style.setProperty("--lexxy-anchor-width", `${rect.width}px`);
12313
12579
  this.style.setProperty("--lexxy-anchor-height", `${rect.height}px`);
12314
12580
  this.#flipWhenOverflowing(editorRect);
@@ -12348,7 +12614,7 @@ class AttachmentToolbar extends HTMLElement {
12348
12614
  #focusToolbarOnAltF10 = (event) => {
12349
12615
  if (this.#hasSelectedNode && event.altKey && event.key === "F10") {
12350
12616
  event.preventDefault();
12351
- this.#buttons[0]?.focus();
12617
+ this.#buttons.find(isActiveAndVisible)?.focus();
12352
12618
  return true
12353
12619
  } else {
12354
12620
  return false
@@ -855,7 +855,7 @@
855
855
 
856
856
  .lexxy-table-control__more-menu-trigger {
857
857
  aspect-ratio: auto;
858
- box-shadow: 0 0 0 1px inset rgba(128, 128, 128, 0.3);
858
+ box-shadow: 0 0 0 1px inset var(--lexxy-color-floating-controls-separator);
859
859
  font-weight: normal;
860
860
  padding: 0 1.25ch;
861
861
  }
@@ -981,7 +981,7 @@
981
981
  /* The anchor is measured in px from the editor's top-left, so these stay physical. */
982
982
  left: calc(var(--lexxy-anchor-left) + var(--lexxy-anchor-width));
983
983
  top: var(--lexxy-anchor-top);
984
- transform: translate(-100%, 0);
984
+ transform: translate(calc(-100% - 1.5ch), 1.5ch);
985
985
 
986
986
  &[hidden] {
987
987
  display: none;
@@ -1006,6 +1006,39 @@
1006
1006
  }
1007
1007
  }
1008
1008
 
1009
+ .lexxy-floating-controls__group {
1010
+ display: flex;
1011
+ }
1012
+
1013
+ button[hidden] {
1014
+ display: none;
1015
+ }
1016
+
1017
+ .lexxy-attachment-toolbar__alternative-text {
1018
+ aspect-ratio: auto;
1019
+ margin-inline-end: calc(var(--lexxy-toolbar-spacing) + 1px);
1020
+ padding-inline: 1ch;
1021
+ position: relative;
1022
+
1023
+ span {
1024
+ display: inline-block;
1025
+ font-size: 0.9em;
1026
+ }
1027
+
1028
+ &:after {
1029
+ background-color: var(--lexxy-color-floating-controls-separator);
1030
+ block-size: 1.125em;
1031
+ content: "";
1032
+ display: block;
1033
+ inline-size: 1px;
1034
+ inset-block: 0;
1035
+ inset-inline-end: calc(-1 * var(--lexxy-toolbar-spacing));
1036
+ margin: auto;
1037
+ pointer-events: none;
1038
+ position: absolute;
1039
+ }
1040
+ }
1041
+
1009
1042
  .lexxy-attachment-toolbar__remove:hover,
1010
1043
  .lexxy-attachment-toolbar__remove:focus-visible {
1011
1044
  background-color: var(--lexxy-color-red);
@@ -1181,3 +1214,105 @@ action-text-attachment[content-type^="application/vnd.actiontext"] {
1181
1214
  display: none;
1182
1215
  }
1183
1216
  }
1217
+
1218
+ .lexxy-alternative-text-dialog {
1219
+ background: var(--lexxy-color-canvas);
1220
+ border: 2px solid var(--lexxy-color-selected-hover);
1221
+ border-radius: calc(var(--lexxy-radius) + var(--lexxy-toolbar-gap));
1222
+ box-shadow: var(--lexxy-shadow);
1223
+ box-sizing: border-box;
1224
+ color: var(--lexxy-color-ink);
1225
+ font: inherit;
1226
+ inline-size: 60%;
1227
+ inset-block: calc(var(--lexxy-anchor-bottom) + var(--lexxy-toolbar-spacing)) auto;
1228
+ inset-inline: 0;
1229
+ margin-block: 0;
1230
+ margin-inline: auto;
1231
+ min-inline-size: min(20rem, 100%);
1232
+ overflow: auto;
1233
+ padding: var(--lexxy-toolbar-spacing);
1234
+ z-index: 3;
1235
+
1236
+ h2 {
1237
+ font-size: inherit;
1238
+ font-weight: bold;
1239
+ line-height: 1.5;
1240
+ margin: 0;
1241
+ padding-block-start: calc(0.25 * var(--lexxy-text-small));
1242
+ padding-inline: calc(0.5 * var(--lexxy-text-small));
1243
+ text-align: center;
1244
+ }
1245
+
1246
+ .lexxy-alternative-text-dialog__controls {
1247
+ align-items: stretch;
1248
+ display: flex;
1249
+ flex-direction: column;
1250
+ gap: 0.5em;
1251
+ margin-block-start: 0.5em;
1252
+ }
1253
+
1254
+ textarea {
1255
+ background: var(--lexxy-color-canvas);
1256
+ border: 1px solid var(--lexxy-color-ink-lighter);
1257
+ border-radius: var(--lexxy-radius);
1258
+ box-sizing: border-box;
1259
+ color: inherit;
1260
+ display: block;
1261
+ font: inherit;
1262
+ font-size: var(--lexxy-text-small);
1263
+ font-weight: normal;
1264
+ min-block-size: calc(2lh + 1em + 2px);
1265
+ min-inline-size: 0;
1266
+ padding: 0.5em;
1267
+
1268
+ @supports (field-sizing: content) {
1269
+ field-sizing: content;
1270
+ }
1271
+
1272
+ @supports (resize: none) {
1273
+ resize: none;
1274
+ }
1275
+ }
1276
+
1277
+ button {
1278
+ background-color: var(--lexxy-color-ink-lightest);
1279
+ block-size: var(--lexxy-toolbar-button-size);
1280
+ border: 0;
1281
+ border-radius: var(--lexxy-radius);
1282
+ color: currentColor;
1283
+ cursor: pointer;
1284
+ display: grid;
1285
+ font-size: var(--lexxy-text-small);
1286
+ inline-size: 100%;
1287
+ line-height: inherit;
1288
+ padding-inline: 2ch;
1289
+ place-items: center;
1290
+
1291
+ &:hover {
1292
+ opacity: 0.8;
1293
+ }
1294
+ }
1295
+
1296
+ .lexxy-alternative-text-dialog__save {
1297
+ background-color: var(--lexxy-color-accent-dark);
1298
+ color: var(--lexxy-color-ink-inverted);
1299
+
1300
+ &:hover {
1301
+ background-color: var(--lexxy-color-accent-medium);
1302
+ }
1303
+ }
1304
+
1305
+ textarea:focus-visible {
1306
+ outline: var(--lexxy-focus-ring-size) solid var(--lexxy-focus-ring-color);
1307
+ outline-offset: var(--lexxy-focus-ring-offset);
1308
+ }
1309
+
1310
+ .lexxy-alternative-text-dialog__actions {
1311
+ display: flex;
1312
+ gap: 0.5em;
1313
+
1314
+ button {
1315
+ flex: 1;
1316
+ }
1317
+ }
1318
+ }
@@ -35,6 +35,7 @@
35
35
  --lexxy-color-selected-dark: var(--lexxy-color-blue);
36
36
  --lexxy-color-selected-50: color-mix(in oklch, var(--lexxy-color-selected-dark) 50%, transparent);
37
37
  --lexxy-color-code-bg: var(--lexxy-color-ink-lightest);
38
+ --lexxy-color-floating-controls-separator: rgba(128, 128, 128, 0.3);
38
39
 
39
40
  /* Text color highlights */
40
41
  --highlight-1: rgb(136, 118, 38);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.33",
3
+ "version": "1.0.0",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",