@37signals/lexxy 0.9.33 → 1.0.1

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) {
@@ -2422,14 +2474,14 @@ function extendTextNodeConversion(conversionName, ...callbacks) {
2422
2474
  ...conversionOutput,
2423
2475
  forChild: (lexicalNode, parentNode) => {
2424
2476
  const originalForChild = conversionOutput?.forChild ?? (x => x);
2425
- let childNode = originalForChild(lexicalNode, parentNode);
2426
-
2477
+ const childNode = originalForChild(lexicalNode, parentNode);
2427
2478
 
2428
2479
  if ($isTextNode(childNode)) {
2429
- childNode = callbacks.reduce(
2480
+ return callbacks.reduce(
2430
2481
  (childNode, callback) => callback(childNode, element) ?? childNode,
2431
2482
  childNode
2432
- );
2483
+ )
2484
+ } else {
2433
2485
  return childNode
2434
2486
  }
2435
2487
  }
@@ -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,
@@ -6787,6 +6844,7 @@ class PastedContentFormatter {
6787
6844
  format() {
6788
6845
  this.#inlinePreservableStyles();
6789
6846
  this.#stripStyleElements();
6847
+ this.#stripInterchangeNewlines();
6790
6848
  this.#unwrapPlaceholderAnchors();
6791
6849
  this.#stripTableCellColorStyles();
6792
6850
  new OfficeFormatter(this.doc).format();
@@ -6822,6 +6880,35 @@ class PastedContentFormatter {
6822
6880
  }
6823
6881
  }
6824
6882
 
6883
+ // Chrome and Safari append <br class="Apple-interchange-newline"> when the
6884
+ // copied selection ends at a line end (Google Docs does this on every copy).
6885
+ // It marks the end of the selection, not a line the author wrote, so left in
6886
+ // place it pastes as an extra empty paragraph. Only the trailing marker goes:
6887
+ // one at the start of the fragment is a real line boundary.
6888
+ #stripInterchangeNewlines() {
6889
+ for (const br of this.doc.querySelectorAll("br.Apple-interchange-newline")) {
6890
+ if (this.#isAtEndOfBody(br)) {
6891
+ br.remove();
6892
+ }
6893
+ }
6894
+ }
6895
+
6896
+ #isAtEndOfBody(node) {
6897
+ for (let current = node; current && current !== this.doc.body; current = current.parentNode) {
6898
+ for (let sibling = current.nextSibling; sibling; sibling = sibling.nextSibling) {
6899
+ if (!this.#isIgnorableTrailingNode(sibling)) return false
6900
+ }
6901
+ }
6902
+ return true
6903
+ }
6904
+
6905
+ #isIgnorableTrailingNode(node) {
6906
+ if (node.nodeType === Node.COMMENT_NODE) {
6907
+ return true
6908
+ }
6909
+ return node.nodeType === Node.TEXT_NODE && node.textContent.trim() === ""
6910
+ }
6911
+
6825
6912
  // Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
6826
6913
  // from rendered views where mentions and interactive elements are wrapped in
6827
6914
  // <a href="#"> tags. Unwrap them so their text content pastes as plain text
@@ -7260,7 +7347,7 @@ class Contents {
7260
7347
  this.#performTextReplacement(anchorNode, lastIndex, stringToReplace, replacementNodes);
7261
7348
  }
7262
7349
 
7263
- uploadFiles(files, { selectLast } = {}) {
7350
+ uploadFiles(files, { selectLast, altText } = {}) {
7264
7351
  if (!this.editorElement) return // Disposed (e.g. on turbo:before-cache); a late drop can still land here
7265
7352
 
7266
7353
  if (!this.editorElement.supportsAttachments) {
@@ -7273,6 +7360,10 @@ class Contents {
7273
7360
  const uploader = Uploader.for(this.editorElement, validFiles);
7274
7361
  uploader.$uploadFiles();
7275
7362
 
7363
+ if (altText && files.length === 1 && uploader.nodes.length === 1) {
7364
+ uploader.nodes[0].getWritable().altText = altText;
7365
+ }
7366
+
7276
7367
  if (selectLast && uploader.nodes?.length) {
7277
7368
  const lastNode = uploader.nodes.at(-1);
7278
7369
  lastNode.selectEnd();
@@ -7985,46 +8076,51 @@ class Clipboard {
7985
8076
  }
7986
8077
 
7987
8078
  #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
- }
8079
+ if (this.editorElement.supportsAttachments) {
8080
+ const html = clipboardData.getData("text/html");
8081
+ const files = clipboardData.files;
8082
+ const copiedImage = files.length && this.#copiedImage(html);
8002
8083
 
8003
- if (files.length) {
8004
- this.#uploadFilesPreservingScroll(files);
8005
- return true
8084
+ if (copiedImage) {
8085
+ this.#uploadFilesPreservingScroll(files, copiedImage.getAttribute("alt"));
8086
+ return true
8087
+ } else if (html && !this.#isLexicalClipboardData(clipboardData)) {
8088
+ this.contents.insertHtml(html, { tag: PASTE_TAG });
8089
+ return true
8090
+ } else if (files.length) {
8091
+ this.#uploadFilesPreservingScroll(files);
8092
+ return true
8093
+ } else {
8094
+ return false
8095
+ }
8096
+ } else {
8097
+ return false
8006
8098
  }
8007
-
8008
- return false
8009
8099
  }
8010
8100
 
8011
8101
  #isLexicalClipboardData(clipboardData) {
8012
8102
  return Array.from(clipboardData.types).includes("application/x-lexical-editor")
8013
8103
  }
8014
8104
 
8015
- #isCopiedImageHTML(html) {
8016
- if (!html) return false
8017
-
8018
- const doc = parseHtml(html);
8019
- const elementChildren = Array.from(doc.body.children);
8105
+ #copiedImage(html) {
8106
+ if (html) {
8107
+ const doc = parseHtml(html);
8108
+ const elementChildren = Array.from(doc.body.children);
8020
8109
 
8021
- return elementChildren.length === 1 && elementChildren[0].tagName === "IMG"
8110
+ if (elementChildren.length === 1 && elementChildren[0].tagName === "IMG") {
8111
+ return elementChildren[0]
8112
+ } else {
8113
+ return null
8114
+ }
8115
+ } else {
8116
+ return null
8117
+ }
8022
8118
  }
8023
8119
 
8024
- #uploadFilesPreservingScroll(files) {
8120
+ #uploadFilesPreservingScroll(files, altText) {
8025
8121
  this.#preservingScrollPosition(() => {
8026
8122
  if (files.length) {
8027
- this.contents.uploadFiles(files, { selectLast: true });
8123
+ this.contents.uploadFiles(files, { selectLast: true, altText });
8028
8124
  }
8029
8125
  });
8030
8126
  }
@@ -8588,7 +8684,7 @@ class TrixContentExtension extends LexxyExtension {
8588
8684
  priority: 1
8589
8685
  }),
8590
8686
  span: (element) => onlyStyledElements(element, {
8591
- conversion: extendTextNodeConversion("mark", $applyHighlightStyle),
8687
+ conversion: extendTextNodeConversion("span", $applyHighlightStyle),
8592
8688
  priority: 1
8593
8689
  }),
8594
8690
  strong: (element) => onlyStyledElements(element, {
@@ -10324,6 +10420,7 @@ class LexicalEditorElement extends HTMLElement {
10324
10420
  #historyState = { undo: false, redo: false }
10325
10421
 
10326
10422
  #validity = new Map()
10423
+ #validationAttempted = false
10327
10424
  #validationTextArea = document.createElement("textarea")
10328
10425
  #uploadRequests
10329
10426
  #liveRegion
@@ -10408,10 +10505,13 @@ class LexicalEditorElement extends HTMLElement {
10408
10505
  }
10409
10506
 
10410
10507
  requiredChangedCallback() {
10508
+ if (this.editorContentElement) this.#synchronizeAriaAttribute("aria-required");
10411
10509
  if (this.isConnected) this.#requestValidityRefresh();
10412
10510
  }
10413
10511
 
10414
10512
  formResetCallback() {
10513
+ this.#validationAttempted = false;
10514
+ this.#synchronizeAriaAttribute("aria-invalid");
10415
10515
  this.value = this.#initialValue;
10416
10516
  this.editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
10417
10517
  }
@@ -10438,6 +10538,15 @@ class LexicalEditorElement extends HTMLElement {
10438
10538
  return this.internals.validity
10439
10539
  }
10440
10540
 
10541
+ get validationMessage() {
10542
+ return this.internals.validationMessage
10543
+ }
10544
+
10545
+ setCustomValidity(message) {
10546
+ this.#validationTextArea.setCustomValidity(message);
10547
+ this.#refreshValidity();
10548
+ }
10549
+
10441
10550
  checkValidity() {
10442
10551
  return this.internals.checkValidity()
10443
10552
  }
@@ -10675,6 +10784,7 @@ class LexicalEditorElement extends HTMLElement {
10675
10784
  this.#attachDebugHooks();
10676
10785
  this.#attachToolbar();
10677
10786
  this.#resetBeforeTurboCaches();
10787
+ this.#observeAriaAttributes();
10678
10788
 
10679
10789
  this.#setInternalFormValue(this.value, { suppressEvent: true });
10680
10790
  this.#synchronizeWithChanges();
@@ -10747,7 +10857,9 @@ class LexicalEditorElement extends HTMLElement {
10747
10857
  classList: "lexxy-editor__content",
10748
10858
  contenteditable: true,
10749
10859
  role: "textbox",
10750
- "aria-multiline": true,
10860
+ "aria-multiline": this.supportsMultiLine,
10861
+ "aria-required": this.required,
10862
+ "aria-invalid": false,
10751
10863
  "aria-label": this.#labelText,
10752
10864
  placeholder: this.getAttribute("placeholder")
10753
10865
  });
@@ -10780,6 +10892,46 @@ class LexicalEditorElement extends HTMLElement {
10780
10892
  return Array.from(this.attributes).filter(attribute => attribute.name.startsWith("aria-"))
10781
10893
  }
10782
10894
 
10895
+ #observeAriaAttributes() {
10896
+ const observer = new MutationObserver(mutations => {
10897
+ for (const { attributeName } of mutations) {
10898
+ if (attributeName.startsWith("aria-")) {
10899
+ this.#synchronizeAriaAttribute(attributeName);
10900
+ } else if (attributeName === "single-line") {
10901
+ this.#synchronizeAriaAttribute("aria-multiline");
10902
+ }
10903
+ }
10904
+ });
10905
+ observer.observe(this, { attributes: true });
10906
+
10907
+ this.#listeners.track(
10908
+ () => observer.disconnect(),
10909
+ registerEventListener(this, "invalid", () => {
10910
+ this.#validationAttempted = true;
10911
+ this.#synchronizeAriaAttribute("aria-invalid");
10912
+ })
10913
+ );
10914
+ }
10915
+
10916
+ #synchronizeAriaAttribute(name) {
10917
+ const value = this.getAttribute(name) ?? this.#defaultAriaValue(name);
10918
+ if (value === null) {
10919
+ this.editorContentElement.removeAttribute(name);
10920
+ } else if (this.editorContentElement.getAttribute(name) !== String(value)) {
10921
+ this.editorContentElement.setAttribute(name, value);
10922
+ }
10923
+ }
10924
+
10925
+ #defaultAriaValue(name) {
10926
+ switch (name) {
10927
+ case "aria-label": return this.#labelText
10928
+ case "aria-required": return this.required
10929
+ case "aria-multiline": return this.supportsMultiLine
10930
+ case "aria-invalid": return this.#validationAttempted && !this.validity.valid
10931
+ default: return null
10932
+ }
10933
+ }
10934
+
10783
10935
  #setInternalFormValue(html, { suppressEvent = false } = {}) {
10784
10936
  const changed = html !== this.#previousInternalFormValue;
10785
10937
 
@@ -10836,11 +10988,16 @@ class LexicalEditorElement extends HTMLElement {
10836
10988
  #refreshValidity() {
10837
10989
  this.#refreshInternalValidity();
10838
10990
  const { validity, message } = this.#calculateValidity();
10839
- this.internals.setValidity(validity, message, this.editorContentElement);
10991
+ this.internals.setValidity(validity, message, this.editorContentElement ?? undefined);
10992
+ if (this.editorContentElement) {
10993
+ this.#synchronizeAriaAttribute("aria-invalid");
10994
+ }
10840
10995
  }
10841
10996
 
10842
10997
  #refreshInternalValidity() {
10843
- this.#validationTextArea.required = this.required && this.isBlank;
10998
+ if (this.editorContentElement) {
10999
+ this.#validationTextArea.required = this.required && this.isBlank;
11000
+ }
10844
11001
  const flags = this.#validationTextArea.validity;
10845
11002
  const message = this.#validationTextArea.validationMessage;
10846
11003
 
@@ -10855,12 +11012,14 @@ class LexicalEditorElement extends HTMLElement {
10855
11012
  // internal TextArea's ValidityState can contain `valid: true`
10856
11013
  if (flags.valid === true) continue
10857
11014
 
11015
+ let hasError = false;
10858
11016
  for (const flag in flags) {
10859
11017
  if (flags[flag]) {
10860
11018
  validity[flag] = true;
10861
- messages.push(message);
11019
+ hasError = true;
10862
11020
  }
10863
11021
  }
11022
+ if (hasError) messages.push(message);
10864
11023
  }
10865
11024
 
10866
11025
  return { validity, message: messages.join("\n") }
@@ -11178,6 +11337,7 @@ class LexicalEditorElement extends HTMLElement {
11178
11337
 
11179
11338
  #resetValidity() {
11180
11339
  this.#validity = new Map();
11340
+ this.#validationAttempted = false;
11181
11341
  }
11182
11342
  }
11183
11343
 
@@ -12123,6 +12283,111 @@ class CodeLanguagePicker extends HTMLElement {
12123
12283
  }
12124
12284
  }
12125
12285
 
12286
+ class AlternativeTextDialog {
12287
+ #editor
12288
+ #editorElement
12289
+ #dialog
12290
+ #input
12291
+ #nodeKey
12292
+ #elementToRefocus
12293
+ #listeners = new ListenerBin()
12294
+
12295
+ constructor(editorElement) {
12296
+ this.#editor = editorElement.editor;
12297
+ this.#editorElement = editorElement;
12298
+ this.#dialog = createElement("dialog", { className: "lexxy-alternative-text-dialog", ariaLabel: "Alternative text" });
12299
+ this.#input = createElement("textarea", { rows: 1, autofocus: true, ariaLabel: "Description", placeholder: "Keep it short, but enough to convey the context…" });
12300
+
12301
+ const title = createElement("h2", { textContent: "Describe the image for people who can't see it:" });
12302
+
12303
+ const actions = createElement("div", { className: "lexxy-alternative-text-dialog__actions" });
12304
+ const cancel = createElement("button", { type: "button", textContent: "Cancel" });
12305
+ const save = createElement("button", { type: "button", className: "lexxy-alternative-text-dialog__save", textContent: "Save" });
12306
+ actions.append(cancel, save);
12307
+
12308
+ const controls = createElement("div", { className: "lexxy-alternative-text-dialog__controls" });
12309
+ controls.append(this.#input, actions);
12310
+ this.#dialog.append(title, controls);
12311
+ editorElement.appendChild(this.#dialog);
12312
+
12313
+ this.#listeners.track(
12314
+ registerEventListener(cancel, "click", () => this.#close()),
12315
+ registerEventListener(save, "click", () => this.#save()),
12316
+ registerEventListener(this.#dialog, "keydown", this.#handleKeyDown),
12317
+ registerEventListener(document, "pointerdown", this.#closeOnClickOutside)
12318
+ );
12319
+ }
12320
+
12321
+ dispose() {
12322
+ this.#dialog.remove();
12323
+ this.#listeners.dispose();
12324
+ }
12325
+
12326
+ open(nodeKey) {
12327
+ const description = this.#editor.read(() => {
12328
+ const node = $getNodeByKey(nodeKey);
12329
+ if ($isActionTextAttachmentNode(node)) {
12330
+ return node.altText
12331
+ }
12332
+ });
12333
+
12334
+ if (description !== undefined) {
12335
+ this.#nodeKey = nodeKey;
12336
+ this.#input.value = description;
12337
+ this.#anchorBelowAttachment(nodeKey);
12338
+ this.#elementToRefocus = document.activeElement;
12339
+ this.#editorElement.querySelector("lexxy-toolbar")?.closeDropdowns();
12340
+ this.#dialog.show();
12341
+ this.#input.focus();
12342
+ }
12343
+ }
12344
+
12345
+ #anchorBelowAttachment(nodeKey) {
12346
+ const figureElement = this.#editor.getElementByKey(nodeKey);
12347
+ if (figureElement) {
12348
+ const rect = figureElement.getBoundingClientRect();
12349
+ const editorRect = this.#editorElement.getBoundingClientRect();
12350
+ const bottom = rect.bottom - editorRect.top - this.#editorElement.clientTop + this.#editorElement.scrollTop;
12351
+ this.#dialog.style.setProperty("--lexxy-anchor-bottom", `${bottom}px`);
12352
+ }
12353
+ }
12354
+
12355
+ #save() {
12356
+ const description = this.#input.value.trim();
12357
+ this.#editor.update(() => {
12358
+ const node = $getNodeByKey(this.#nodeKey);
12359
+ if ($isActionTextAttachmentNode(node) && node.altText !== description) {
12360
+ node.getWritable().altText = description;
12361
+ }
12362
+ }, { tag: [ HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG ] });
12363
+ this.#close();
12364
+ }
12365
+
12366
+ #handleKeyDown = (event) => {
12367
+ event.stopPropagation();
12368
+ if (event.key === "Escape") {
12369
+ this.#close();
12370
+ } else {
12371
+ trapFocusAtTabBoundary(this.#dialog, event);
12372
+ }
12373
+ }
12374
+
12375
+ // Dismissing by clicking elsewhere must leave focus where the click landed.
12376
+ #closeOnClickOutside = (event) => {
12377
+ if (this.#dialog.open && !this.#dialog.contains(event.target)) {
12378
+ this.#close({ refocus: false });
12379
+ }
12380
+ }
12381
+
12382
+ #close({ refocus = true } = {}) {
12383
+ this.#dialog.close();
12384
+ if (refocus && this.#elementToRefocus?.isConnected) {
12385
+ this.#elementToRefocus.focus();
12386
+ }
12387
+ this.#elementToRefocus = null;
12388
+ }
12389
+ }
12390
+
12126
12391
  const DELETE_ICON = `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
12127
12392
  <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
12393
  </svg>`;
@@ -12130,6 +12395,8 @@ const DELETE_ICON = `<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"
12130
12395
  class AttachmentToolbar extends HTMLElement {
12131
12396
  #listeners = new ListenerBin()
12132
12397
  #buttons = []
12398
+ #alternativeTextButton
12399
+ #alternativeTextDialog
12133
12400
  #currentNodeKey = null
12134
12401
  #reflowObserver = new ResizeObserver(() => this.#updatePosition())
12135
12402
  #presentationObserver = new MutationObserver(() => this.#refresh())
@@ -12156,6 +12423,7 @@ class AttachmentToolbar extends HTMLElement {
12156
12423
  this.#listeners.dispose();
12157
12424
  this.#reflowObserver.disconnect();
12158
12425
  this.#presentationObserver.disconnect();
12426
+ this.#alternativeTextDialog?.dispose();
12159
12427
  }
12160
12428
 
12161
12429
  get #editor() {
@@ -12170,7 +12438,9 @@ class AttachmentToolbar extends HTMLElement {
12170
12438
  this.innerHTML = "";
12171
12439
 
12172
12440
  const container = createElement("div", { className: "lexxy-floating-controls__group" });
12441
+ container.appendChild(this.#createAlternativeTextButton());
12173
12442
  container.appendChild(this.#createRemoveButton());
12443
+ this.#alternativeTextDialog = new AlternativeTextDialog(this.#editorElement);
12174
12444
  this.appendChild(container);
12175
12445
 
12176
12446
  this.#buttons = Array.from(this.querySelectorAll("button"));
@@ -12203,6 +12473,22 @@ class AttachmentToolbar extends HTMLElement {
12203
12473
  return this.#currentNodeKey !== null
12204
12474
  }
12205
12475
 
12476
+ #createAlternativeTextButton() {
12477
+ this.#alternativeTextButton = createElement("button", {
12478
+ type: "button",
12479
+ className: "lexxy-attachment-toolbar__alternative-text",
12480
+ ariaLabel: "Alternative text",
12481
+ title: "Alternative text",
12482
+ ariaHasPopup: "dialog",
12483
+ tabIndex: -1
12484
+ });
12485
+ this.#alternativeTextButton.appendChild(createElement("span", null, "ALT"));
12486
+ this.#listeners.track(registerEventListener(this.#alternativeTextButton, "click", () => {
12487
+ this.#alternativeTextDialog.open(this.#currentNodeKey);
12488
+ }));
12489
+ return this.#alternativeTextButton
12490
+ }
12491
+
12206
12492
  #navigateOrExit = (event) => {
12207
12493
  if (event.key === "Escape") {
12208
12494
  event.preventDefault();
@@ -12275,6 +12561,7 @@ class AttachmentToolbar extends HTMLElement {
12275
12561
 
12276
12562
  this.#editor.getEditorState().read(() => {
12277
12563
  const node = $getNodeByKey(nodeKey);
12564
+ this.#alternativeTextButton.hidden = !this.#canEditAlternativeText(node);
12278
12565
  this.dataset.nodeType = node.getType();
12279
12566
  this.dataset.presentation = this.#presentationOf(node, element);
12280
12567
 
@@ -12286,6 +12573,12 @@ class AttachmentToolbar extends HTMLElement {
12286
12573
  });
12287
12574
  }
12288
12575
 
12576
+ #canEditAlternativeText(node) {
12577
+ return this.#editorElement.dataset.actionTextSupportsAlt !== "false"
12578
+ && node.getType() === "action_text_attachment"
12579
+ && node.isPreviewableAttachment
12580
+ }
12581
+
12289
12582
  #presentationOf(node, element) {
12290
12583
  if ($isImageGalleryNode(node.getParent())) {
12291
12584
  return "gallery"
@@ -12307,8 +12600,11 @@ class AttachmentToolbar extends HTMLElement {
12307
12600
  if (figureElement) {
12308
12601
  const rect = figureElement.getBoundingClientRect();
12309
12602
  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`);
12603
+ // We are positioned against the editor's padding box, but the rect measures its border box.
12604
+ const borderTop = this.#editorElement.clientTop;
12605
+ const borderLeft = this.#editorElement.clientLeft;
12606
+ this.style.setProperty("--lexxy-anchor-top", `${rect.top - editorRect.top - borderTop}px`);
12607
+ this.style.setProperty("--lexxy-anchor-left", `${rect.left - editorRect.left - borderLeft}px`);
12312
12608
  this.style.setProperty("--lexxy-anchor-width", `${rect.width}px`);
12313
12609
  this.style.setProperty("--lexxy-anchor-height", `${rect.height}px`);
12314
12610
  this.#flipWhenOverflowing(editorRect);
@@ -12348,7 +12644,7 @@ class AttachmentToolbar extends HTMLElement {
12348
12644
  #focusToolbarOnAltF10 = (event) => {
12349
12645
  if (this.#hasSelectedNode && event.altKey && event.key === "F10") {
12350
12646
  event.preventDefault();
12351
- this.#buttons[0]?.focus();
12647
+ this.#buttons.find(isActiveAndVisible)?.focus();
12352
12648
  return true
12353
12649
  } else {
12354
12650
  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.1",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",