Skip to content

Latest commit

 

History

History
253 lines (214 loc) · 50.1 KB

NOTES.md

File metadata and controls

253 lines (214 loc) · 50.1 KB

Notes

ComboboxField + listbox

  • Screen Readers and [role="option"][inert]: It seems that if the only child in a listbox is a [role="option"][inert] HTMLElement, then Screen Readers (or at least VoiceOver) will view the listbox as empty. This is a big deal because it's important to have some kind of indicator that no options match the user's current filter. Showing a blank screen (i.e., showing no menu when no options match the filter) could work, but that may not be as clear. Between Mantine, Shadcn UI, Reach UI, React Select, and Vue Select, Mantine is the only Component Library that doesn't have a "No Options" display. If we can meet accessibility requirements and make things clearer for visual users, we should definitely do both.
  • You should prefer private static methods for event handlers whenever possible. However, if you need access to this, then just use a private arrow function. (This comment is outdated. See the EDIT note below.) Certainly, if you can find a way to avoid using this so that you can keep a method as static, then do so. However, you should do this reasonably. For example, it is unreasonable to expose a copy of a private field (get matchingOptions() { return this.#matchingOptions.slice() }) strictly for the sake of being able to use a static method for an event handler. Not only does this waste memory at runtime, but it runs the risk of introducing confusion and unforeseen bugs. Keep things simple and just use an arrow function if you need to; it shouldn't harm your end users. (Technically still true. But most of the time, if not all of the time, you shouldn't need an arrow function.)
    • As a side note, for this very reason, you should avoid using this as much as possible (within reason). It's theoretically possible that in the future, a refactor would make itself known which would enable you convert your instance arrow function back to a static method. Those opportunities will become more obvious if you, for example, reach for a combobox via event.target instead of through this.
    • EDIT: We recently learned that private members are lexically scoped to the class body, not to this. This means that a private field declared in MyElement can be accessed by a static event handler method defined in MyElement as long as the handler has a reference to a MyElement instance. This is game changing because it means we can actually use private fields in static methods, which will improve performance by killing the need for Symbol overhead and by allowing us to replace arrow functions / bind(this) (which allocate memory per class instance) with static methods (which allocate memory per class).
      • NOTE: This is something amazing that we can leverage, but we must use it with caution. A MyElement class instance can access the private fields of a different MyElement class instance through the approach mentioned above. So you'll want your event handlers to be registered in such a way that you know you're accessing only the class instance that you want/expect.
  • Currently, our combobox component displays a "No Options" message (which is an inert, fake option) if the user's filter doesn't match any of the component's options. (This is only in filter mode, obviously.) This message is modifiable, but some people may not want to display a message at all. In that case, they can simply use CSS to hide the listbox if it doesn't contain any options which aren't [data-filtered-out]; there are no code changes that need to be made on our side. But this is at least worth documenting when we write the overall documentation for this component.

DOM / JavaScript

  • Apparently, the focus event triggers after the mousedown event but before the mouseup event, which triggers immediately before the click event. We were able to use this knowledge to differentiate between :focusing that was initiated by clicking vs. tabbing in our code.
  • If a Range points to content inside a Text Node (which is itself CharacterData, which seems to have many helpful methods), then using CharacterData.insertData() will not reset the Range offsets to the beginning of the Node (unlike changing the Node.nodeValue directly with something like textNode.nodeValue = MY_STRING).
  • According to the Spec, an element's child nodes will be completely removed when the Element.textContent setter is called. If the setter is called with an empty string (or anything that is mapped to an empty string), then the element will be left empty. (This seems similar to how Element.innerHTML behaves when given an empty string.) If the setter is called with a regular string, then a new Text node is created and inserted as the the element's only child.
    • We'll need to be mindful of this in the codebase. If we expect a component to always have a text node (as is the assumption for the <combobox-field>), then we'll need to avoid using Element.textContent = "". Instead, we'll need to leverage Text.nodeValue = "" or another similarly-safe approach (like CharacterData.data = "").
    • This piece of information is also important when it comes to modifying Ranges, as deleting/replacing the reference to an element's original Text node can lead to unexpected/broken behavior when trying to work with Ranges that point to the element's contents.
  • For some odd reason, in Chrome and Firefox, the UserAgent (UA) would try to focus the listbox in the <select-enhancer> whenever Tab was pressed if the combobox was expanded. This happened both while Tabbing forwards and backwards. The behavior is most easily replicated if you force the combobox to be expanded with setAttribute and then try moving focus with Tab. (Safari did not exhibit this awful and confusing behavior.) This caused unexpected bugs in the code, so now we have to place a [tabindex="-1"] attribute on the listbox element.
  • disabled form controls that are contenteditable behave differently across different browsers. Consider <button disabled contenteditable="true">Content</button> in each of the 3 Major Browsers:
    • Chrome (137): Prevents focusing via Keyboard Navigation (Tabbing), Clicking, and HTMLElement.focus(). This is the ideal behavior.
    • Safari (18.5): Prevents focusing via Keyboard Navigation (Tabbing) and HTMLElement.focus(). However, if the element is clicked, it receives a cursor and can still be modified.
    • Firefox (140): Does not prevent ANY of the 3 aforementioned means of focusing an element. To Firefox, if the element is contenteditable, it's focusable.
    • Due to these discrepancies, if you want a [contenteditable] form control not to be interactive when disabled, then you'll have to toggle [contenteditable] between "false" and "true" yourself.
  • Apparently, when a :focused element is dynamically given the attribute [contenteditable="true"], a cursor is not put inside the element. (This is at least true for Google Chrome 138 on MacOS 15.5.) Although the practicality of such a scenario is probably small, this is still something worth noting. This means that users have no way of knowing that an element with which they're interacting has become editable without leaving the element and re-focusing it. The way that our <combobox-field> gets around this issue is by highlighting all of its own text content if it becomes editable while being in focus.
  • Apparently, elements that are [contenteditable] have all of their child DOM Nodes removed when 1) There is no more text within the element and 2) Backspace (not Delete) is pressed (Svelte REPL Example). What's even more interesting is that this behavior has some minor differences across browsers:
    • Chrome (138) + Firefox (140): If Backspace is pressed in a [contenteditable] element containing a single empty Text Node, the Node will be preserved in the DOM; it will not be deleted.
    • Safari (18.5): If Backspace is pressed in a [contenteditable] element containing a single empty Text Node, the Node will be removed from the DOM.
    • These manual checks were run on MacOS 15.5. Safari's treatment of Text Nodes might be a bug, but it also might be behavior that isn't strictly defined or enforced by the WHATWG/W3C. Thus, if you want to counteract this Safari behavior, your best bet is to use a MutationObserver that reinserts the desired DOM Node(s). As an example, the ComboboxField has to do this because it assumes that it always has a consistent, singular Text Node for displaying important information to Users (e.g, the User's filter, the combobox's current value, etc.). Having a MutationObserver like this also protects against undesirable manipulation of the <combobox-field>'s child nodes.
      • TODO: Although a MutationObserver is a suitable solution, you should consider opening a Safari bug in case this really is a bug on their part. (That said, it might be a good idea to maintain the current MutationObserver/logic even if Safari changes the Browser Behavior. This is because doing so would protect against undesirable child node manipulation from outside sources, such as rogue developers or misused JS Frameworks.)
  • From our brief experience, it seems that the properties of ElementInternals.validity cannot be spread into a new object, nor can they be structuredCloned. Additionally, ElementInternals.setValidity() will always default any ValidityState properties that you don't pass to the function to false. This makes it incredibly difficult to transition between different error states, because you have to track previously-established error messages (when transitioning, for example, from 2 errors to 1), and you have to write unnecessarily verbose code to maintain the current error state when you only want to, say, update an error message. These matters get even worse when you want one kind of error message to unconditionally take precedence over another one (e.g., customError > valueMissing). These issues should probably be reported to the WHATWG on GitHub.
  • Related to our previous note, it seems that some object properties are enumerable whereas others are not. Just like ValidityState's object properties are non-enumerable, most Event properties are also non-enumerable (except for isTrusted). So copying details like those belonging to an InputEvent will be more verbose if you are trying to copy only part of the object. If you want to copy the entire object, then Element.dispatchEvent(new event.constructor(event.type, event)) will do just fine.
  • Apparently, in Google Chrome 138 on MacOS 15.5, Chrome will remove focus from an HTMLElement if it is moved somewhere else in the DOM. (Firefox 140 and Safari 18.5 do not exhibit this behavior.) This is important to remember as we write our tests. Since this is specifically related to browsers and how they choose to trigger FocusEvents, this is something that developers will have to take responsibility for; this is not caused by a flaw in our component.
  • The order in which Web Components are registered matters. Particularly, the order matters when an HTML Page is loaded with the Web Components in the markup. In this scenario, the connectedCallback()s of these Web Components will run in the order in which the Custom Elements were defined. However, if the Web Components were not connected to the DOM on page load and were instead added after the browser's initial HTML Parsing, then the connectedCallback()s will be run in the natural order which one would expect (i.e., Parent, then all Children, in order).
  • Oddly enough, there are still more browser discrepancies. In Google Chrome 140 on MacOS 15.6.1 and Safari 18.6 on MacOS 15.6.1, no click event will be dispatched if the following actions occur in succession: 1) pressing the mouse down in a text-editable element (such as an <input> or [contenteditable="true"] element), 2) moving the mouse outside the text-editable element, 3) releasing the mouse. Indeed, this is the behavior that I think people would typically expect. However, Firefox 142 on MacOS 15.6.1 does not exhibit this behavior!!! Instead, the click event is dispatched on the element on which the mouse was originally pressed down. In fact, in Firefox, the mouseleave event doesn't fire until after the click event has fired. This might be an insanely annoying and unintuitive Browser implementation, or it's a Browser bug.
  • Apparently, elements created inside <template>s are created in an entirely different "Document context" than the Window's. Because of this, any Custom Elements registered with window.customElements.define() won't be recognized/acknowledged by elements created within the <template> (e.g., with template.innerHTML). This can be resolved by cloning the template's nodes into the Window's Document (e.g., with document.importNode(template.content, true)), then attaching the cloned nodes to the Window's DOM. We had to learn this the hard way in one of our Playwright tests. Below are some helpful resources on this topic:
  • Apparently, in some Browsers, the input.checked property will still be updated if the click event is canceled; however, the value will be reverted after the event has finished processing. This means that if you're trying to manually apply the effects of the click event for checkboxes, you'll have to be a little more clever/mindful with your approach. See: https://stackoverflow.com/a/32710212/17777687.
  • Typically, a pointerdown (or mousedown) event is eventually followed by a pointerup (or mouseup) event when the user "releases" their pointer (e.g., a mouse). We rely on this behavior because at times we dynamically attach a { once: true } pointerup event listener within a pointerdown event listener. However, we learned from Google Gemini that there are edge cases where the pointerup event won't fire at all. These scenarios are important to be aware of to avoid memory leaks. The 5 scenarios are below:
    • Browser Drag-and-Drop Activation (Easily Solved)
      • A pointercancel event will be dispatched instead. (There is no equivalent mouse* event.) However, this shouldn't be a practical problem if the element is not [draggable="true"]
    • Native Alerts/Modals (Easily Solved)
      • Calling a browser-native modal with something like alert() or prompt() will prevent the pointerup event from ever firing, and no accompanying pointercancel event will be fired either. That said, the simple way to avoid this problem is to avoid using such JS functions to begin with. Native <dialog>s are typically better than the browser-native ones anyway.
    • Context Menu Triggers (Easily Solved)
      • Right-clicking an element on the page will open a contextmenu. When this happens, you'll be able to see the pointerdown event, but the pointerup event won't be fired because attention is moved to the OS-native contextmenu. To circumvent this, restrict your event handler to listen only for pointerdown events with the specific MouseEvent.button of interest.
    • Loss of Window Focus
      • If the user navigates away from the current window while holding their pointer down, then pointerdown will fire but not pointerup. You can avoid this issue by watching for when the Window is blurrred. But setting up such an event listener is typically overkill — as long as you have a valid way to avoid memory leaks (if you're dynamically adding pointerup listeners in pointerdown listeners).
    • Event Propagation Interruption
      • Obviously, if event propagation is stopped aggressively, you might not be able to listen for pointerup at a higher level in the DOM. So don't stop the event's propagation. And encourage users of any libraries you produce to avoid such behaviors as well.
    • The above scenarios were the main concerns. There are other scenarios that are not really worth flagging because they aren't very realistic. (For example, an element obviously can't receive a pointerup event if it's removed from the DOM. But such a UX would never even make practical sense to begin with.) But the above are certainly worth being aware of.

TypeScript

  • TypeScript seems to have some very firm rules about declaring dynamic property names in classes. Basically, if you're going to go that route, then you'll need a static string or a unique Symbol (which can be "dynamic" — i.e., calculated at runtime). The latter is especially helpful for avoiding TypeScript errors with Index Signatures. (For example, you don't want your class to become Record<string, number>. Symbols seem pretty much guaranteed to help you avoid this, though constant, static strings also seem to be usable in later versions of TypeScript as well.)
    • We explored dynamic property names in the ComboboxField to see if we could have "private" instance data that could still be accessed from static event handler methods. Due to the index signature issue mentioned previously, Symbols seemed to be the best approach here. Unfortunately, those who read our source code could potentially still leverage the Symbol registry to access data that they should not touch. To get around this, a secure, random runtime string can be generated and passed to Symbol when the library is first loaded. People could technically still get around this by brute forcing the Symbol registry. But for anyone building a true, meaningful application, this would not be worth their time. (And it would certainly hurt performance for Users.) So this approach virtually holds the same security as private fields.
      • There are caveats with this Symbol approach, however. The largest concern is that — at least as of today (2025-08-30) — JSDocs don't work as cleanly on these kinds of dynamically-declared properties. Additionally, there is a (minor) cost for generating random strings (say, UUIDs) at runtime; and it's likely that more memory would have to be allocated than what is needed for regular, private fields. (That said, if you get to preserve your use of static methods with this approach, then the memory allocation might be superior in the end.) As a side note, remember that even if the two random strings you generate are exactly the same, you won't run into any issues there since Symbol() always produces a unique value. So name clashing within your class won't be a practical concern.
      • EDIT: We recently learned that private members are lexically scoped to the class body, not to this. See our notes in ComboboxField + listbox. We're only keeping the original Symbol note here because it's useful to know.
  • For some reason, importing anything from preact/compat can confuse TypeScript. For example, after importing from preact/compat, attempting to read a global React type like React.MouseEvent will actually make TS try to read from a Preact Event Type instead. Similarly, JSX will suddenly start being read as Preact JSX in React files unless /** @jsxImportSource react */ is specifically applied to a file. The preact/compat module must be doing some aggressive type augmentation to cause this to happen... I guess that's helpful if you're trying to migrate from React to Preact, but it's very annoying otherwise.
  • According to TypeScript expert Matt Pocock (who also appeals to the TypeScript Team's recommendations), TypeScript developers are discouraged from using .d.ts files to define local TS types modularly. It seems that .d.ts files are for library code (e.g., type definitions for JavaScript), and for defining types globally within your locally developed application/project; but they are not intended for defining an application's local type modules. For that, regular .ts files are recommended.
    • As a side note, it seems skipLibCheck: true will turn off type checking for all .d.ts files. This doesn't just mean library .d.ts files, but local app .d.ts files as well. Typically, you do not want this for local type modules.
Claude Code Discovery about TypeScript and unique Symbols (2026-08-25)

WARNING: ALL OF THE CONTENT WITHIN THIS DISCLOSURE WIDGET IS ENTIRELY AI-GENERATED BY CLAUDE CODE (OPUS 4.8 High), AND IT MAY NOT BE UP-TO-DATE OR ACCURATE. We went through a rather-unenjoyable debugging session, trying to figure out why this[uniqueSymbol] was producing errors in TypeScript at compile time. The short of it seems to be that accessing this[uniqueSymbol] in some cases can be dangerous (for TS, not for JS), and that it's better to perform type casting first so that TypeScript tries to read uniqueSymbol from a known type rather than from This. For example, (this as MenuElement)[uniqueSymbol] works, whereas this[uniqueSymbol] may not, even if this is used within the MenuElement class.

It's not clear why this behavior exists. It's also not clear why re-ordering certain statements works (see below). Claude's theory about how TypeScript gathers type information about this might be legitimate, but it also might be pure conjecture. We're only capturing its statements below so that you know what it was "thinking". Even then, we came up with a solution (i.e., direct type casting) that was different from what Claude suggested in the original chat. So... take things with a grain of salt.

FINALLY: Note that this is a Claude Code chat related to a past iteration of the MenuElement. We learned a way to get private field access working within static event handlers and static MutationObserver callbacks defined in the same class body (as discussed earlier in the ComboboxField + listbox section). So we no longer have a need for the less-secure, slightly-less-performant Symbol approach.


  • unique symbol computed class fields can get silently dropped from a class's type (TS 5.9.3, 2026-08-25). While building MenuElement, we hit 14 TS7053 errors ("expression of type 'unique symbol' can't be used to index type 'MenuElement'") on every access of our Symbol-keyed fields — even though ComboboxField uses the exact same pattern without complaint. Symbol-keyed fields are "late-bound" members: TypeScript only attaches them to the class type via a lazy pass that runs when the class's member table is first fully resolved. The bug: inside a single method, a method call on the bare this keyword that is checked before a this[someSymbol] access forces that member table to be resolved and cached without the late-bound Symbol members. That poisoned table is then shared class-wide, so all Symbol accesses break — including ones on other (non-this) variables in completely different methods. Minimal repro (no extends, no decorators needed):
    const s = Symbol("s");
    class Foo {
      /** @type {string} */ [s] = "";
      method() {
        this.bar();
        this[s] = "";
      } // `this.bar()` BEFORE `this[s]` => TS7053 on `this[s]`
      bar() {}
    }
    Reorder so this[s] comes first, split the two across separate methods, or drop the this. call — and the error vanishes. This is why ComboboxField was fine: it never indexes a Symbol off this; it always goes through a concretely-typed local (const combobox = /** @type {ComboboxField} */ (event.currentTarget); combobox[editingKey] = ...), which resolves the class type through the normal (late-bound-intact) path and never triggers the reentrant this-keyword resolution. Its ~90 this.method() calls are harmless because none is ever followed by a this[symbol] in the same method.
    • The fix (what MenuElement now does): the only this[symbol] accesses were the two lines in #unregisterMenuButton; casting this to the class type there fixes all 14 errors. Note the cast is required — a plain const self = this; self[symbol] does not work (it keeps the polymorphic this type and the same poisoning path); you need const self = /** @type {MenuElement} */ (this), or cast inline. General rule of thumb, consistent with our existing advice to avoid this: reach for Symbol-keyed state through a concretely-typed variable, not this.
    • This looks like a genuine TypeScript compiler bug (late-binding order-sensitivity), but no GitHub Issue has been filed yet. Full investigation/context lives in the Claude Code session menu-element-ts-symbols-1 (id 17ac0c18-db31-45ff-a556-91e8c55b5e12).

Ranges, Selection, and Reimplementing the input Event

  • Useful links/docs:
  • Firefox is the only Browser that supports multi-selection. We could skip supporting it, but we'll choose to support it to avoid breaking user expectations. Note that when Firefox encounters a multi-select for an <input> element, it will place the selection at the very end of the last selection range when text is inserted into the form control. (You should TEST that you're satisfying this behavior.)
  • Unlike Chrome and Firefox, in Safari you cannot use Selection.addRange() to set the Document's current selection to something WITHIN a Shadow DOM. However, you CAN use Selection.setBaseAndExtent() for this. Note that Chrome/Firefox support Selection.addRange() if the Range has "legal" access to content in a Shadow DOM. However, since we're trying to create something with cross-browser support, this is irrelevant. Some helpful resources:
  • Apparently, all (non-static) Ranges previously associated with a Text Node get emptied once the referenced node's nodeValue is re-set with set Node.nodeValue(). (The Ranges probably don't know what to point to anymore since the underlying value of the Node has technically changed.) This makes it imperative that you know when to leverage the a StaticRange and when to work directly with a regular, dynamic Range (or a value that it cached before being modified).
    • Alternatively, you can just leverage the CharacterData methods, like insertData() and deleteData(). These methods will preserve the Range information of the Text node that you're modifying (as much as possible) -- even if some of the Range's contents are deleted by deleteData(). Why worry about what set Node.nodeValue() will do and how you should cache your Range data when you can just use the CharacterData methods to help you avoid worrying about such things?
  • By default, browsers will collapse multiple occurrences of whitespace between character strings. So if you have a <div contenteditable="true"> and you execute div.textContent = "Hello" + " ".repeat(5) + "World", then the browser will display what looks like "Hello World" even though get div.textContent() will return a string with the appropriate number of spaces. To force the browser to display all whitespace instead of collapsing it, you need to use the white-space CSS property. (You can also try white-space-collapse, but broad and stable support for the preserve-spaces value is missing.)
    • If you're wondering how users can insert multiple spaces into [contenteditable] elements, it's because the browser inserts HTML into the [contenteditable] element instead of pure white space. In other words, the browser will insert something like Hello&nbsp; &nbsp; &nbsp;World instead of using empty strings only. However, this behavior is not always desirable. We concluded that this behavior wasn't viable for our ComboboxField since we're accepting and manipulating Plaintext only instead of HTML. (That is, we use "text/plain" in DataTransfer.getData() instead of "text/html".) So we intentionally used pure white spaces when re-implementing the input event. (Indeed, I think we were forced to.) Consequently, we recommend users update the white-space CSS property of the <combobox-field> as needed.
  • The [contenteditable="plaintextonly"] attribute setting seems to behave inconsistently between Browsers. For this reason, it's best to just stick to [contenteditable="true"] for now, even though it doesn't represent what we're truly looking for. Here are some things we found:
    • Safari + Firefox: When [contenteditable="plaintextonly"], the beforeinput event will still show text/html data in the InputEvent.dataTransfer.types even though such information is arguably irrelevant, and InputEvent.getTargetRanges() will return the correct information. Additionally, InputEvent.data will be null.
    • Chrome: When [contenteditable="plaintextonly"], the text/html data will not be exposed by the InputEvent. In fact, InputEvent.dataTransfer will be null! And the developer will have to check InputEvent.data instead. This makes some sense. However, Chrome also excludes the InputEvent.getTargetRanges() data in this scenario, making it difficult (if not impossible) to reasonably re-implement the input event's behavior fully.
    • Due to these inconsistencies (especially when it comes to dataTransfer vs data), it's best to just stick to [contenteditable="true"] since doing so will result in consistent behavior across browsers, in addition to guaranteeing that we'll always have access to the correct Target Ranges.
    • It's currently unclear whether we should file a Chromium Bug for this right now since it isn't clear which browser has the "correct" behavior. Both implementations have some downsides to them.
  • It seems that when you're inserting text into an empty element, the StaticRange returned from beforeinput points to the element itself instead of the empty Text node that the element holds (assuming element contains a Text node). When you're leveraging the StaticRanges returned from InputEvent.getTargetRanges(), you need to take this into account. If you can force circumstances that allow you to make valid assumptions, then you can work conveniently with those assumptions.
    • For example, if you can guarantee that the target element will always have a single Text node and nothing else, then you can just opearte directly on that Text node instead of referencing StaticRange.startContainer.
    • Otherwise, you'll need to bring about whatever circumstances that you require. For example, if you need the target element to have a Text node, then you can check the element returned from StaticRange.startContainer for a single Text node child. If one exists, you can operate on it. If one doesn't exist, you can insert a new Text node into the element and then operate on that text node.
  • It seems to be safe to remove \rs and \ns from an InputEvent's data before interacting with the StaticRanges from getTargetRanges(). This is because newlines are represented by newly-created HTMLElements, which themselves become contenteditable (because of the owning/wrapping contenteditable element) and have the ability to receive beforeinput events which bubble up to the owning element that listens for all beforeinput events. This paradigm makes it unnecessary for the StaticRanges to take newlines into account in their offsets, so characters like \r and \n don't influence the offsets at all. Therefore, it is perfectly safe (from what we can tell from our brief testing in Firefox) to delete all \rs and \ns from a string before interacting with the StaticRanges.

Testing

  • Playwright's approach to its ControlOrMeta key (search for ControlOrMeta or for resolveSmartModifierString) involves leveraging process.platform.
  • Apparently, MacOS doesn't allow tabbing to controls like <button>s by default. Because of this, if you run Playwright against a Safari (WebKit) browser on MacOS, all tests expecting <button>s (or other similar controls) to be focused via Tab navigation will fail. (Note: This is not an issue for all controls. For instance, textboxes work just fine.) This is why your Safari tests may work on a Linux Machine in CI, but not locally on your MacOS machine. The solution to this problem is to enable tabbing into controls on your local MacOS.
    • Enabling this in Safari > Settings > Advanced > "Press Tab to highlight each item on a webpage" will not work. It will work for your local Safari version, but not for your Playwright Tests.
    • Instead, visit System Settings > Keyboard > Keyboard Navigation. The subtext for this option says something like:

      Use keyboard navigation to move focus between controls. Press the Tab key to move focus forward and Shift Tab to move focus backward.

      • Toggle "Keyboard Navigation" on by clicking its toggle switch. Now your Playwright test(s) should be passing.
    • See Stack Overflow for additional details.
  • In @playwright/test@1.54.1, the Locator.fill() method does not work in Firefox when used on a [contenteditable] element whose text content and cursor location are modified by a beforeinput event. (See microsoft/playwright#36715 and Playwright Issue Firefox Fill.) In such a scenario, Playwright will duplicate the provided text when running against Firefox. This issue does not happen during manual testing, nor does it happen if Playwright is run against Chrome or Safari. (We discovered this bug on a MacOS machine. Not sure how other machines behave.) Moreover, this issue does not occur when using Locator.press() or Page.keyboard.press(). It's not clear why the Locator.fill() bug exists; but for now, if it becomes a problem, you can circumvent the issue by sticking to Keyboard Pressing instead of Locator Filling. Doing so is just as reliable (if not more reliable), but it's slightly less convenient.
  • Apparently, valid CSS identifiers cannot start with an unescaped number. When working with unique IDs in JavaScript, this is basically never a concern because document.getElementById(), which accepts IDs starting with unescaped numbers (since they constitute valid IDs), is always preferred over document.querySelector() for performance reasons. However, this concern does matter in Playwright, since it tends to use document.querySelector*() for its page.locator() method. Note that although document.querySelector("#123") is invalid, document.querySelector("[id='123']") is perfectly acceptable (notice the intentional use of quotation marks), so the latter is ALWAYS to be preferred over the former in Playwright Tests.
  • There are some tests which, for some reason or another, are flaky when run in a specific Playwright Test Browser, on a specific Operating System... especially in headless mode. This is annoying because it makes it difficult to discern which test failures are due to legitimate flaws in our code, and which ones are due to the idiosyncratic bugs of Operating Systems/Browsers/Playwright Test Browsers/CI. So here's what we've decided to do:
    • If a test keeps failing in CI, try it locally.
    • If the test works locally both in Headless Mode and in UI Mode, run the Playwright Tests on the Operating System that CI is using.
    • If the test is consistently failing, see if you can debug the reason for the failing test. (This is more important for consistently failing tests than it is for flaky tests. Even so, this is still a good procedure to follow for flaky tests.)
      • If you determine that the failure is due to a real bug in our code, fix it.
      • If you determine that the failure is due to a need to use Playwright differently, do so.
      • If you determine that the failure is due to idiosyncratic OS/Playwright Browser bugs, and the REAL browsers exhibit the expected/desired behavior just fine, then document this finding and skip the incorrectly-failing portions of the test.
        • Avoid skipping an entire test altogether when possible, as skipping an entire test undesirably decreases our coverage.
        • Ideally, you should open a GitHub Issue (with a minimal reproduction) if you discover legitimately buggy behavior in Playwright.
          • Or you can wait and hope that a future version of Playwright randomly fixes the problem. (lol)
      • If you determine that a test flakes due to idiosyncratic OS/Playwright Browser bugs, then annotate the Playwright Test as flaky using the format FLAKY/Operating_System/Browser/Mode (Headless or UI). Example: FLAKY/Linux/Firefox/Headless. This at least lets us know what to expect in CI, and it will help us know when we need to re-run tests rather than attempt to fix something.
        • Ideally, we don't alter the test code in a flaky test, because the test still gives us some kind of signal as to whether or not our code is working as expected. If our production code ever breaks functionality, then even these flaky tests might legitimately catch that.
        • If more than one item in a category causes flakiness, you can list each offender in a |-separated list. Example: FLAKY/Linux/Firefox/Headless|UI. If all items in a category cause flakiness, you can use ALL instead of listing out each offender individually. Example: FLAKY/Linux/Firefox/ALL. Of course, this is mainly relevant for the Mode indicator; if your tests are legitimately failing in all browsers, then your code (or your test approach) is likely the problem.

Custom Element Integration with JS Frameworks:

JS Framework Gotchas

React Ignores Custom toggle Events in Some Situations

Although the onToggle prop is generally available on all of React's HTMLElement tags, some versions of React will ignore the toggle event if it is dispatched "unnaturally". For example, you can listen for natural toggle events on the <details> element or a [popover] element just fine. But if you call div.dispatchEvent(new ToggleEvent("toggle", { ... })), an onToggle function prop on said <div> will not be called.

This is annoying because it means Custom Elements which dispatch their own toggle events will not work if onToggle is applied to them. Instead ontoggle must be applied to Custom Elements in React, which is an odd and potentially-confusing Developer Experience. ("Why do I see both onToggle and ontoggle in the TypeScript suggestions?") Literally every other noteworthy framework (including Preact) will acknowledge toggle events as normal. React deviating from this is peculiar, and I don't think this deviation always existed.

In any case, this is something to be aware of. For now, remember to add explicit ontoggle definitions to whichever Custom Elements dispatch their own toggle event. If you ever want to prove that a JS Framework does (or doesn't) support the toggle event in the "natural framework way", you can run a quick demo. Example:

// React
function App() {
  return (
    <div>
      <toggleable-element onToggle={console.log}></toggleable-element>
      <details onToggle={console.log}>
        <summary>Natively Toggleable</summary>
        This element will ALWAYS dispatch a <code>toggle</code> event when its &quot;button&quot; is clicked.
      </details>
    </div>
  );
}

export default App;
// Pure JS/TS
class ToggleableElement extends HTMLElement {
  #style = document.createElement("style");
  #button = document.createElement("button");
  #container = document.createElement("div");
  #shadowRoot = this.attachShadow({ mode: "closed" });

  constructor() {
    super();
    this.#container.id = crypto.randomUUID();
    this.#container.textContent = "Wonderful!";

    this.#button.type = "button";
    this.#button.textContent = "Toggle Me!";
    this.#button.ariaExpanded = String(false);
    this.#button.setAttribute("aria-controls", this.#container.id);

    this.#style.innerHTML = '[aria-expanded="false"] + * { display: none; }';
    this.#shadowRoot.append(this.#style, this.#button, this.#container);
  }

  connectedCallback() {
    this.#button.addEventListener("click", ToggleableElement.#handleClick, { passive: true });
  }

  disconnectedCallback() {
    this.#button.removeEventListener("click", ToggleableElement.#handleClick);
  }

  static #handleClick(event: PointerEvent) {
    // Toggle Button State
    const button = event.currentTarget as HTMLButtonElement;
    const expanded = button.ariaExpanded === String(true);
    button.ariaExpanded = String(!expanded);

    // Dispatch Toggle `event`
    const root = button.getRootNode() as ShadowRoot;
    const host = root.host as ToggleableElement;

    const oldState = expanded ? "open" : "closed";
    const newState = expanded ? "closed" : "open";
    host.dispatchEvent(new ToggleEvent("toggle", { oldState, newState }));
  }
}

if (!customElements.get("toggleable-element")) customElements.define("toggleable-element", ToggleableElement);

Node Workspaces / TypeScript Project References

  • The ordering of your package exports rules matters. Always be sure to place more specific export paths before less specific ones. For example, place "./*.js": "./*.js" and especially ".": "./index.js" before "./*": "./*.js".
  • Building TS Projects Using Project References: Typically, it should be sufficient for you to run tsc --build (or tsc -b) to build your "root" tsconfig.json file along with all of its dependencies (listed under references). However, if you're type-checking even a single *.vue file, you have to use vue-tsc, which seems to require developers to pass all relevant tsconfigs to it during a TS Project References Build. For example, you'll have to run vue-tsc --build . src instead of vue-tsc --build. Somehow, Svelte files aren't picked up by tsc (unlike Vue files), and Svelte can lint its files separately through svelte-check. This is something Vue should look into.
  • The downside of using Project References is that there is no "linting only mode". That is, you must run tsc --build in order for all the types to be checked (and seen) correctly. And that means some files (specifically those which belong to composite TSConfigs) will emit .d.ts files even if you don't want them to. However, you can always clean these emitted files up easily with tsc --build --clean or with your own node script. Why bother with the headache? Here are just a few reasons:
    1. If you can structure your code rightly (without sacrificing sanity) with Node Workspaces and Project References, you can easily build your various projects without having to write or maintain your own build scripts.
    2. Node Workspaces and Project References, when used together, help you learn how well your package exports work, how reliable your type definitions are, and how your global type augmentations behave BEFORE you publish your NPM Package.
    3. With Project References, you can pass an empty array ([]) to compilerOptions.types for a subset of your projects. In our case, it means that we can prevent the src/**/*.js files from accidentally picking up Node's setTimeout() function when we just want to use the browser's setTimout function. At the same time, we can still allow global Node.js types to exist in the rest of the project. Very helpful.
  • Getting @typescript-eslint to work with project references requires the projectService boolean option to be set. We think we've set it up correctly in this repo. We can fix it later if not, but at least lint seems to be behaving correctly over all.
  • Somewhere along the way, using NPM Workspaces and TS Project References seemed to break npm's ability to "see" the *.d.ts files during an npm publish. (Or perhaps something else broke this, such as the way we've written our scripts. However, after doing a little bit of testing, that seems much less likely.) It seems other people have run into problems like this before, and the simple solution is to use the files configuration option in the relevant package.json file instead of using a .npmignore file.