From 999897df12f0e56ea7b61019fc86913fc4ee5703 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 1 Aug 2024 03:36:57 -0600 Subject: [PATCH] The all-new, pyscript.web (ignore the branch name :) ) (#2129) * Minor cleanups: move all Element classes to bottom of module. * Commenting. * Commenting. * Commenting. * Group dunder methods. * Don't cache the element's parent. * Remove style type check until we decide whether or not to add for classes too. * Add ability to register/unregister element classes. * Implement __iter__ for container elements. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Minor renaming to make it clear when we have an Element instance vs an actual DOM element. * remove duplication: added Element.get_tag_name * Commenting. * Allow Element.append to 1) use *args, 2) accept iterables * Remove iterable check - inteferes with js proxies. * Don't use *args, so it quacks more like a list ;) * Element.append take 2 :) * Remove unused code. * Move to web.py with a page object! * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Added 'page.title' too :) * Add __getitem__ as a shortcut for page.find * Add Element.__getitem__ to be consistent * Make __getitem__ consistent for Page, Element and ElementCollection. * Docstringing. * Docstringing. * Docstringing/commenting. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix select.add (revert InnerHTML->html) * Commenting. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Hand-edit some of the AI :) * Rename ElementCollection.children -> ElementCollection.elements * Remove unnecessary guard. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../src/stdlib/pyscript/event_handling.py | 2 +- .../pyscript/{web/elements.py => web.py} | 782 ++++++++++-------- .../src/stdlib/pyscript/web/__init__.py | 22 - .../test/pyscript_dom/tests/test_dom.py | 108 ++- pyscript.core/tests/integration/test_pyweb.py | 30 +- pyscript.core/types/stdlib/pyscript.d.ts | 5 +- 6 files changed, 496 insertions(+), 453 deletions(-) rename pyscript.core/src/stdlib/pyscript/{web/elements.py => web.py} (72%) delete mode 100644 pyscript.core/src/stdlib/pyscript/web/__init__.py diff --git a/pyscript.core/src/stdlib/pyscript/event_handling.py b/pyscript.core/src/stdlib/pyscript/event_handling.py index e3d388d9..be133e4e 100644 --- a/pyscript.core/src/stdlib/pyscript/event_handling.py +++ b/pyscript.core/src/stdlib/pyscript/event_handling.py @@ -20,7 +20,7 @@ def when(event_type=None, selector=None): def decorator(func): - from pyscript.web.elements import Element, ElementCollection + from pyscript.web import Element, ElementCollection if isinstance(selector, str): elements = document.querySelectorAll(selector) diff --git a/pyscript.core/src/stdlib/pyscript/web/elements.py b/pyscript.core/src/stdlib/pyscript/web.py similarity index 72% rename from pyscript.core/src/stdlib/pyscript/web/elements.py rename to pyscript.core/src/stdlib/pyscript/web.py index d27f6448..ff37a89b 100644 --- a/pyscript.core/src/stdlib/pyscript/web/elements.py +++ b/pyscript.core/src/stdlib/pyscript/web.py @@ -1,36 +1,60 @@ -try: - from typing import Any +"""Lightweight interface to the DOM and HTML elements.""" -except ImportError: - Any = "Any" - -try: - import warnings - -except ImportError: - # TODO: For now it probably means we are in MicroPython. We should figure - # out the "right" way to handle this. For now we just ignore the warning - # and logging to console - class warnings: - @staticmethod - def warn(*args, **kwargs): - print("WARNING: ", *args, **kwargs) +# `when` is not used in this module. It is imported here save the user an additional +# import (i.e. they can get what they need from `pyscript.web`). +from pyscript import document, when # NOQA -from pyscript import document +def wrap_dom_element(dom_element): + """Wrap an existing DOM element in an instance of a subclass of `Element`. + + This is just a convenience function to avoid having to import the `Element` class + and use its class method. + """ + + return Element.wrap_dom_element(dom_element) class Element: + # A lookup table to get an `Element` subclass by tag name. Used when wrapping an + # existing DOM element. + element_classes_by_tag_name = {} + @classmethod - def from_dom_element(cls, dom_element): - """Create an instance of a subclass of `Element` for a DOM element.""" + def get_tag_name(cls): + """Return the HTML tag name for the class. - element_cls = ELEMENT_CLASSES_BY_TAG_NAME.get(dom_element.tagName.lower()) + For classes that have a trailing underscore (because they clash with a Python + keyword or built-in), we remove it to get the tag name. e.g. for the `input_` + class, the tag name is `input`. - # For any unknown elements (custom tags etc.) create an instance of this - # class ('Element'). - if not element_cls: - element_cls = cls + """ + return cls.__name__.replace("_", "") + + @classmethod + def register_element_classes(cls, element_classes): + """Register an iterable of element classes.""" + for element_class in element_classes: + tag_name = element_class.get_tag_name() + cls.element_classes_by_tag_name[tag_name] = element_class + + @classmethod + def unregister_element_classes(cls, element_classes): + """Unregister an iterable of element classes.""" + for element_class in element_classes: + tag_name = element_class.get_tag_name() + cls.element_classes_by_tag_name.pop(tag_name, None) + + @classmethod + def wrap_dom_element(cls, dom_element): + """Wrap an existing DOM element in an instance of a subclass of `Element`. + + We look up the `Element` subclass by the DOM element's tag name. For any unknown + elements (custom tags etc.) use *this* class (`Element`). + """ + element_cls = cls.element_classes_by_tag_name.get( + dom_element.tagName.lower(), cls + ) return element_cls(dom_element=dom_element) @@ -41,16 +65,33 @@ class Element: Otherwise, we are being called to *wrap* an existing DOM element. """ self._dom_element = dom_element or document.createElement( - type(self).__name__.replace("_", "") + type(self).get_tag_name() ) - self._parent = None + # A set-like interface to the element's `classList`. self._classes = Classes(self) + + # A dict-like interface to the element's `style` attribute. self._style = Style(self) # Set any specified classes, styles, and DOM properties. self.update(classes=classes, style=style, **kwargs) + def __eq__(self, obj): + """Check for equality by comparing the underlying DOM element.""" + return isinstance(obj, Element) and obj._dom_element == self._dom_element + + def __getitem__(self, key): + """Get an item within the element's children. + + If `key` is an integer or a slice we use it to index/slice the element's + children. Otherwise, we use `key` as a query selector. + """ + if isinstance(key, int) or isinstance(key, slice): + return self.children[key] + + return self.find(key) + def __getattr__(self, name): # This allows us to get attributes on the underlying DOM element that clash # with Python keywords or built-ins (e.g. the output element has an @@ -80,119 +121,102 @@ class Element: setattr(self._dom_element, name, value) + @property + def children(self): + """Return the element's children as an `ElementCollection`.""" + return ElementCollection.wrap_dom_elements(self._dom_element.children) + + @property + def classes(self): + """Return the element's `classList` as a `Classes` instance.""" + return self._classes + + @property + def parent(self): + """Return the element's `parent `Element`.""" + if self._dom_element.parentElement is None: + return None + + return Element.wrap_dom_element(self._dom_element.parentElement) + + @property + def style(self): + """Return the element's `style` attribute as a `Style` instance.""" + return self._style + + def append(self, *items): + """Append the specified items to the element.""" + for item in items: + if isinstance(item, Element): + self._dom_element.appendChild(item._dom_element) + + elif isinstance(item, ElementCollection): + for element in item: + self._dom_element.appendChild(element._dom_element) + + # We check for list/tuple here and NOT for any iterable as it will match + # a JS Nodelist which is handled explicitly below. + # NodeList. + elif isinstance(item, list) or isinstance(item, tuple): + for child in item: + self.append(child) + + else: + # In this case we know it's not an Element or an ElementCollection, so + # we guess that it's either a DOM element or NodeList returned via the + # ffi. + try: + # First, we try to see if it's an element by accessing the 'tagName' + # attribute. + item.tagName + self._dom_element.appendChild(item) + + except AttributeError: + try: + # Ok, it's not an element, so let's see if it's a NodeList by + # accessing the 'length' attribute. + item.length + for element_ in item: + self._dom_element.appendChild(element_) + + except AttributeError: + # Nope! This is not an element or a NodeList. + raise TypeError( + f'Element "{item}" is a proxy object, "' + f"but not a valid element or a NodeList." + ) + + def clone(self, clone_id=None): + """Make a clone of the element (clones the underlying DOM object too).""" + clone = Element.wrap_dom_element(self._dom_element.cloneNode(True)) + clone.id = clone_id + return clone + + def find(self, selector): + """Find all elements that match the specified selector. + + Return the results as a (possibly empty) `ElementCollection`. + """ + return ElementCollection.wrap_dom_elements( + self._dom_element.querySelectorAll(selector) + ) + + def show_me(self): + """Convenience method for 'element.scrollIntoView()'.""" + self._dom_element.scrollIntoView() + def update(self, classes=None, style=None, **kwargs): """Update the element with the specified classes, styles, and DOM properties.""" if classes: self.classes.add(classes) - if isinstance(style, dict): + if style: self.style.set(**style) - elif style is not None: - raise ValueError( - f"Style should be a dictionary, received {style} " - f"(type {type(style)}) instead." - ) - - self._set_dom_properties(**kwargs) - - def _set_dom_properties(self, **kwargs): - """Set the specified DOM properties. - - Args: - **kwargs: The properties to set - """ for name, value in kwargs.items(): setattr(self, name, value) - def __eq__(self, obj): - """Check for equality by comparing the underlying DOM element.""" - return isinstance(obj, Element) and obj._dom_element == self._dom_element - - @property - def children(self): - return ElementCollection( - [Element.from_dom_element(el) for el in self._dom_element.children] - ) - - @property - def classes(self): - return self._classes - - @property - def parent(self): - if self._parent: - return self._parent - - if self._dom_element.parentElement: - self._parent = Element.from_dom_element(self._dom_element.parentElement) - - return self._parent - - @property - def style(self): - return self._style - - def append(self, child): - if isinstance(child, Element): - self._dom_element.appendChild(child._dom_element) - - elif isinstance(child, ElementCollection): - for el in child: - self._dom_element.appendChild(el._dom_element) - - else: - # In this case we know it's not an Element or an ElementCollection, so we - # guess that it's either a DOM element or NodeList returned via the ffi. - try: - # First, we try to see if it's an element by accessing the 'tagName' - # attribute. - child.tagName - self._dom_element.appendChild(child) - - except AttributeError: - try: - # Ok, it's not an element, so let's see if it's a NodeList by - # accessing the 'length' attribute. - child.length - for element_ in child: - self._dom_element.appendChild(element_) - - except AttributeError: - # Nope! This is not an element or a NodeList. - raise TypeError( - f'Element "{child}" is a proxy object, "' - f"but not a valid element or a NodeList." - ) - - def clone(self, clone_id=None): - """Make a clone of the element (clones the underlying DOM object too).""" - clone = Element.from_dom_element(self._dom_element.cloneNode(True)) - clone.id = clone_id - return clone - - def find(self, selector): - """Return an ElementCollection representing all the child elements that - match the specified selector. - - Args: - selector (str): A string containing a selector expression - - Returns: - ElementCollection: A collection of elements matching the selector - """ - return ElementCollection( - [ - Element.from_dom_element(dom_element) - for dom_element in self._dom_element.querySelectorAll(selector) - ] - ) - - def show_me(self): - """Scroll the element into view.""" - self._dom_element.scrollIntoView() - class Classes: """A set-like interface to an element's `classList`.""" @@ -233,6 +257,7 @@ class Classes: return " ".join(self._class_list) def add(self, *class_names): + """Add one or more classes to the element.""" for class_name in class_names: if isinstance(class_name, list): for item in class_name: @@ -242,9 +267,11 @@ class Classes: self._class_list.add(class_name) def contains(self, class_name): + """Check if the element has the specified class.""" return class_name in self def remove(self, *class_names): + """Remove one or more classes from the element.""" for class_name in class_names: if isinstance(class_name, list): for item in class_name: @@ -254,10 +281,12 @@ class Classes: self._class_list.remove(class_name) def replace(self, old_class, new_class): + """Replace one of the element's classes with another.""" self.remove(old_class) self.add(new_class) def toggle(self, *class_names): + """Toggle one or more of the element's classes.""" for class_name in class_names: if class_name in self: self.remove(class_name) @@ -274,6 +303,7 @@ class HasOptions: @property def options(self): + """Return the element's options as an `Options""" if not hasattr(self, "_options"): self._options = Options(self) @@ -281,63 +311,17 @@ class HasOptions: class Options: - """This class represents the