move pydom and elements from pyweb to pyscript.web (#2092)

* change pydom example to use new pyscript.web namespace

* change tests to use new pyscript.web namespace

* create new pyscript.web package and move pydom to pyscript.web.dom

* add __init__ to pyscript.web and expose the dom instance instead of the pyscript.web.dom module

* move elements from pyweb.ui to pyscript.web and temp fix pydom import

* moved of elements file completed

* moved media from pyweb to pyscript.web

* RIP pyweb

* move JSProperty from pyscript.web.dom to pyscript.web.elements

* move element classes from pyscript.web.dom to pyscript.web.elements

* first round of fixes while running tests

* fix test typo

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* restore right type type returned for Element.parent. ALL TESTS PASS LOCALLY NOW

* lint

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* clean up dom.py from dead commented code and osbolete comments

* bugfix: dom shouldn't return None when it can't find any element for a specific selector so it now returns an empty collection

* additional cleanup in tests

* lint

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Fabio Pliger
2024-06-06 09:42:14 -04:00
committed by GitHub
parent b41cfb7b60
commit f1a787e031
13 changed files with 644 additions and 663 deletions

View File

@@ -19,16 +19,17 @@ def when(event_type=None, selector=None):
""" """
def decorator(func): def decorator(func):
from pyscript.web.elements import Element, ElementCollection
if isinstance(selector, str): if isinstance(selector, str):
elements = document.querySelectorAll(selector) elements = document.querySelectorAll(selector)
else: else:
# TODO: This is a hack that will be removed when pyscript becomes a package # TODO: This is a hack that will be removed when pyscript becomes a package
# and we can better manage the imports without circular dependencies # and we can better manage the imports without circular dependencies
from pyweb import pydom if isinstance(selector, Element):
if isinstance(selector, pydom.Element):
elements = [selector._js] elements = [selector._js]
elif isinstance(selector, pydom.ElementCollection): elif isinstance(selector, ElementCollection):
elements = [el._js for el in selector] elements = [el._js for el in selector]
else: else:
raise ValueError( raise ValueError(

View File

@@ -0,0 +1,5 @@
from . import elements
# Ugly trick to hide the dom module in the web package since we want the module
# to allow querying right away.
from .dom import dom

View File

@@ -0,0 +1,21 @@
from pyscript import document
from pyscript.web.elements import Element, ElementCollection
class PyDom:
# Add objects we want to expose to the DOM namespace since this class instance is being
# remapped as "the module" itself
ElementCollection = ElementCollection
def __init__(self):
self._js = document
self.body = Element(document.body)
self.head = Element(document.head)
def __getitem__(self, key):
elements = self._js.querySelectorAll(key)
return ElementCollection([Element(el) for el in elements])
dom = PyDom()

View File

@@ -1,8 +1,40 @@
import inspect import inspect
import sys import sys
from pyscript import document, when, window try:
from pyweb import JSProperty, pydom from typing import Any
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)
try:
from functools import cached_property
except ImportError:
# TODO: same comment about micropython as above
cached_property = property
try:
from pyodide.ffi import JsProxy
except ImportError:
# TODO: same comment about micropython as above
def JsProxy(obj):
return obj
from pyscript import document, window
# from pyscript.web import dom as pydom
#: A flag to show if MicroPython is the current Python interpreter. #: A flag to show if MicroPython is the current Python interpreter.
is_micropython = "MicroPython" in sys.version is_micropython = "MicroPython" in sys.version
@@ -17,7 +49,428 @@ def getmembers_static(cls):
return inspect.getmembers_static(cls) return inspect.getmembers_static(cls)
class ElementBase(pydom.Element): class JSProperty:
"""JS property descriptor that directly maps to the property with the same
name in the underlying JS component."""
def __init__(self, name: str, allow_nones: bool = False):
self.name = name
self.allow_nones = allow_nones
def __get__(self, obj, objtype=None):
return getattr(obj._js, self.name)
def __set__(self, obj, value):
if not self.allow_nones and value is None:
return
setattr(obj._js, self.name, value)
# ------ TODO: REMOVE!!!! pydom elements
class BaseElement:
def __init__(self, js_element):
self._js = js_element
self._parent = None
self.style = StyleProxy(self)
self._proxies = {}
def __eq__(self, obj):
"""Check if the element is the same as the other element by comparing
the underlying JS element"""
return isinstance(obj, BaseElement) and obj._js == self._js
@property
def parent(self):
if self._parent:
return self._parent
if self._js.parentElement:
# TODO: This should actually return the correct class (== to tagName)
self._parent = Element(self._js.parentElement)
return self._parent
# @property
# def __class(self):
# return self.__class__ if self.__class__ != PyDom else Element
def create(self, type_, is_child=True, classes=None, html=None, label=None):
js_el = document.createElement(type_)
element = self.__class(js_el)
if classes:
for class_ in classes:
element.add_class(class_)
if html is not None:
element.html = html
if label is not None:
element.label = label
if is_child:
self.append(element)
return element
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
"""
elements = self._js.querySelectorAll(selector)
if not elements:
return None
return ElementCollection([Element(el) for el in elements])
class Element(BaseElement):
@property
def children(self):
return [self.__class__(el) for el in self._js.children]
def append(self, child):
# TODO: this is Pyodide specific for now!!!!!!
# if we get passed a JSProxy Element directly we just map it to the
# higher level Python element
if inspect.isclass(JsProxy) and isinstance(child, JsProxy):
return self.append(Element(child))
elif isinstance(child, Element):
self._js.appendChild(child._js)
return child
elif isinstance(child, ElementCollection):
for el in child:
self.append(el)
# -------- Pythonic Interface to Element -------- #
@property
def html(self):
return self._js.innerHTML
@html.setter
def html(self, value):
self._js.innerHTML = value
@property
def text(self):
return self._js.textContent
@text.setter
def text(self, value):
self._js.textContent = value
@property
def content(self):
# TODO: This breaks with with standard template elements. Define how to best
# handle this specifica use case. Just not support for now?
if self._js.tagName == "TEMPLATE":
warnings.warn(
"Content attribute not supported for template elements.", stacklevel=2
)
return None
return self._js.innerHTML
@content.setter
def content(self, value):
# TODO: (same comment as above)
if self._js.tagName == "TEMPLATE":
warnings.warn(
"Content attribute not supported for template elements.", stacklevel=2
)
return
display(value, target=self.id)
@property
def id(self):
return self._js.id
@id.setter
def id(self, value):
self._js.id = value
@property
def options(self):
if "options" in self._proxies:
return self._proxies["options"]
if not self._js.tagName.lower() in {"select", "datalist", "optgroup"}:
raise AttributeError(
f"Element {self._js.tagName} has no options attribute."
)
self._proxies["options"] = OptionsProxy(self)
return self._proxies["options"]
@property
def value(self):
return self._js.value
@value.setter
def value(self, value):
# in order to avoid confusion to the user, we don't allow setting the
# value of elements that don't have a value attribute
if not hasattr(self._js, "value"):
raise AttributeError(
f"Element {self._js.tagName} has no value attribute. If you want to "
"force a value attribute, set it directly using the `_js.value = <value>` "
"javascript API attribute instead."
)
self._js.value = value
@property
def selected(self):
return self._js.selected
@selected.setter
def selected(self, value):
# in order to avoid confusion to the user, we don't allow setting the
# value of elements that don't have a value attribute
if not hasattr(self._js, "selected"):
raise AttributeError(
f"Element {self._js.tagName} has no value attribute. If you want to "
"force a value attribute, set it directly using the `_js.value = <value>` "
"javascript API attribute instead."
)
self._js.selected = value
def clone(self, new_id=None):
clone = Element(self._js.cloneNode(True))
clone.id = new_id
return clone
def remove_class(self, classname):
classList = self._js.classList
if isinstance(classname, list):
classList.remove(*classname)
else:
classList.remove(classname)
return self
def add_class(self, classname):
classList = self._js.classList
if isinstance(classname, list):
classList.add(*classname)
else:
self._js.classList.add(classname)
return self
@property
def classes(self):
classes = self._js.classList.values()
return [x for x in classes]
def show_me(self):
self._js.scrollIntoView()
def snap(
self,
to: BaseElement | str = None,
width: int | None = None,
height: int | None = None,
):
"""
Captures a snapshot of a video element. (Only available for video elements)
Inputs:
* to: element where to save the snapshot of the video frame to
* width: width of the image
* height: height of the image
Output:
(Element) canvas element where the video frame snapshot was drawn into
"""
if self._js.tagName != "VIDEO":
raise AttributeError("Snap method is only available for video Elements")
if to is None:
canvas = self.create("canvas")
if width is None:
width = self._js.width
if height is None:
height = self._js.height
canvas._js.width = width
canvas._js.height = height
elif isinstance(to, Element):
if to._js.tagName != "CANVAS":
raise TypeError("Element to snap to must a canvas.")
canvas = to
elif getattr(to, "tagName", "") == "CANVAS":
canvas = Element(to)
elif isinstance(to, str):
# TODO (fpliger): This needs a better fix but doing a local import here for a quick fix
from pyscript.web import dom
canvas = dom[to][0]
if canvas._js.tagName != "CANVAS":
raise TypeError("Element to snap to must a be canvas.")
canvas.draw(self, width, height)
return canvas
def download(self, filename: str = "snapped.png") -> None:
"""Download the current element (only available for canvas elements) with the filename
provided in input.
Inputs:
* filename (str): name of the file being downloaded
Output:
None
"""
if self._js.tagName != "CANVAS":
raise AttributeError(
"The download method is only available for canvas Elements"
)
link = self.create("a")
link._js.download = filename
link._js.href = self._js.toDataURL()
link._js.click()
def draw(self, what, width, height):
"""Draw `what` on the current element (only available for canvas elements).
Inputs:
* what (canvas image source): An element to draw into the context. The specification permits any canvas
image source, specifically, an HTMLImageElement, an SVGImageElement, an HTMLVideoElement,
an HTMLCanvasElement, an ImageBitmap, an OffscreenCanvas, or a VideoFrame.
"""
if self._js.tagName != "CANVAS":
raise AttributeError(
"The draw method is only available for canvas Elements"
)
if isinstance(what, Element):
what = what._js
# https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
self._js.getContext("2d").drawImage(what, 0, 0, width, height)
class OptionsProxy:
"""This class represents the options of a select element. It
allows to access to add and remove options by using the `add` and `remove` methods.
"""
def __init__(self, element: Element) -> None:
self._element = element
if self._element._js.tagName.lower() != "select":
raise AttributeError(
f"Element {self._element._js.tagName} has no options attribute."
)
def add(
self,
value: Any = None,
html: str = None,
text: str = None,
before: Element | int = None,
**kws,
) -> None:
"""Add a new option to the select element"""
# create the option element and set the attributes
option = document.createElement("option")
if value is not None:
kws["value"] = value
if html is not None:
option.innerHTML = html
if text is not None:
kws["text"] = text
for key, value in kws.items():
option.setAttribute(key, value)
if before:
if isinstance(before, Element):
before = before._js
self._element._js.add(option, before)
def remove(self, item: int) -> None:
"""Remove the option at the specified index"""
self._element._js.remove(item)
def clear(self) -> None:
"""Remove all the options"""
for i in range(len(self)):
self.remove(0)
@property
def options(self):
"""Return the list of options"""
return [Element(opt) for opt in self._element._js.options]
@property
def selected(self):
"""Return the selected option"""
return self.options[self._element._js.selectedIndex]
def __iter__(self):
yield from self.options
def __len__(self):
return len(self.options)
def __repr__(self):
return f"{self.__class__.__name__} (length: {len(self)}) {self.options}"
def __getitem__(self, key):
return self.options[key]
class StyleProxy: # (dict):
def __init__(self, element: Element) -> None:
self._element = element
@cached_property
def _style(self):
return self._element._js.style
def __getitem__(self, key):
return self._style.getPropertyValue(key)
def __setitem__(self, key, value):
self._style.setProperty(key, value)
def remove(self, key):
self._style.removeProperty(key)
def set(self, **kws):
for k, v in kws.items():
self._element._js.style.setProperty(k, v)
# CSS Properties
# Reference: https://github.com/microsoft/TypeScript/blob/main/src/lib/dom.generated.d.ts#L3799C1-L5005C2
# Following prperties automatically generated from the above reference using
# tools/codegen_css_proxy.py
@property
def visible(self):
return self._element._js.style.visibility
@visible.setter
def visible(self, value):
self._element._js.style.visibility = value
# --------- END OF PYDOM STUFF ------
class ElementBase(Element):
tag = "div" tag = "div"
# GLOBAL ATTRIBUTES # GLOBAL ATTRIBUTES
@@ -83,7 +536,7 @@ class TextElementBase(ElementBase):
super().__init__(style=style, **kwargs) super().__init__(style=style, **kwargs)
# If it's an element, append the element # If it's an element, append the element
if isinstance(content, pydom.Element): if isinstance(content, Element):
self.append(content) self.append(content)
# If it's a list of elements # If it's a list of elements
elif isinstance(content, list): elif isinstance(content, list):
@@ -945,3 +1398,87 @@ class grid(TextElementBase):
# TODO: This should be a property # TODO: This should be a property
if not gap is None: if not gap is None:
self.style["gap"] = gap self.style["gap"] = gap
class StyleCollection:
def __init__(self, collection: "ElementCollection") -> None:
self._collection = collection
def __get__(self, obj, objtype=None):
return obj._get_attribute("style")
def __getitem__(self, key):
return self._collection._get_attribute("style")[key]
def __setitem__(self, key, value):
for element in self._collection._elements:
element.style[key] = value
def remove(self, key):
for element in self._collection._elements:
element.style.remove(key)
class ElementCollection:
def __init__(self, elements: [Element]) -> None:
self._elements = elements
self.style = StyleCollection(self)
def __getitem__(self, key):
# If it's an integer we use it to access the elements in the collection
if isinstance(key, int):
return self._elements[key]
# If it's a slice we use it to support slice operations over the elements
# in the collection
elif isinstance(key, slice):
return ElementCollection(self._elements[key])
# If it's anything else (basically a string) we use it as a selector
# TODO: Write tests!
elements = self._element.querySelectorAll(key)
return ElementCollection([Element(el) for el in elements])
def __len__(self):
return len(self._elements)
def __eq__(self, obj):
"""Check if the element is the same as the other element by comparing
the underlying JS element"""
return isinstance(obj, ElementCollection) and obj._elements == self._elements
def _get_attribute(self, attr, index=None):
if index is None:
return [getattr(el, attr) for el in self._elements]
# As JQuery, when getting an attr, only return it for the first element
return getattr(self._elements[index], attr)
def _set_attribute(self, attr, value):
for el in self._elements:
setattr(el, attr, value)
@property
def html(self):
return self._get_attribute("html")
@html.setter
def html(self, value):
self._set_attribute("html", value)
@property
def value(self):
return self._get_attribute("value")
@value.setter
def value(self, value):
self._set_attribute("value", value)
@property
def children(self):
return self._elements
def __iter__(self):
yield from self._elements
def __repr__(self):
return f"{self.__class__.__name__} (length: {len(self._elements)}) {self._elements}"

View File

@@ -1,2 +0,0 @@
from .pydom import JSProperty
from .pydom import dom as pydom

View File

@@ -1,569 +0,0 @@
import inspect
try:
from typing import Any
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)
try:
from functools import cached_property
except ImportError:
# TODO: same comment about micropython as above
cached_property = property
try:
from pyodide.ffi import JsProxy
except ImportError:
# TODO: same comment about micropython as above
def JsProxy(obj):
return obj
from pyscript import display, document, window
alert = window.alert
class JSProperty:
"""JS property descriptor that directly maps to the property with the same
name in the underlying JS component."""
def __init__(self, name: str, allow_nones: bool = False):
self.name = name
self.allow_nones = allow_nones
def __get__(self, obj, objtype=None):
return getattr(obj._js, self.name)
def __set__(self, obj, value):
if not self.allow_nones and value is None:
return
setattr(obj._js, self.name, value)
class BaseElement:
def __init__(self, js_element):
self._js = js_element
self._parent = None
self.style = StyleProxy(self)
self._proxies = {}
def __eq__(self, obj):
"""Check if the element is the same as the other element by comparing
the underlying JS element"""
return isinstance(obj, BaseElement) and obj._js == self._js
@property
def parent(self):
if self._parent:
return self._parent
if self._js.parentElement:
self._parent = self.__class__(self._js.parentElement)
return self._parent
@property
def __class(self):
return self.__class__ if self.__class__ != PyDom else Element
def create(self, type_, is_child=True, classes=None, html=None, label=None):
js_el = document.createElement(type_)
element = self.__class(js_el)
if classes:
for class_ in classes:
element.add_class(class_)
if html is not None:
element.html = html
if label is not None:
element.label = label
if is_child:
self.append(element)
return element
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
"""
elements = self._js.querySelectorAll(selector)
if not elements:
return None
return ElementCollection([Element(el) for el in elements])
class Element(BaseElement):
@property
def children(self):
return [self.__class__(el) for el in self._js.children]
def append(self, child):
# TODO: this is Pyodide specific for now!!!!!!
# if we get passed a JSProxy Element directly we just map it to the
# higher level Python element
if inspect.isclass(JsProxy) and isinstance(child, JsProxy):
return self.append(Element(child))
elif isinstance(child, Element):
self._js.appendChild(child._js)
return child
elif isinstance(child, ElementCollection):
for el in child:
self.append(el)
# -------- Pythonic Interface to Element -------- #
@property
def html(self):
return self._js.innerHTML
@html.setter
def html(self, value):
self._js.innerHTML = value
@property
def text(self):
return self._js.textContent
@text.setter
def text(self, value):
self._js.textContent = value
@property
def content(self):
# TODO: This breaks with with standard template elements. Define how to best
# handle this specifica use case. Just not support for now?
if self._js.tagName == "TEMPLATE":
warnings.warn(
"Content attribute not supported for template elements.", stacklevel=2
)
return None
return self._js.innerHTML
@content.setter
def content(self, value):
# TODO: (same comment as above)
if self._js.tagName == "TEMPLATE":
warnings.warn(
"Content attribute not supported for template elements.", stacklevel=2
)
return
display(value, target=self.id)
@property
def id(self):
return self._js.id
@id.setter
def id(self, value):
self._js.id = value
@property
def options(self):
if "options" in self._proxies:
return self._proxies["options"]
if not self._js.tagName.lower() in {"select", "datalist", "optgroup"}:
raise AttributeError(
f"Element {self._js.tagName} has no options attribute."
)
self._proxies["options"] = OptionsProxy(self)
return self._proxies["options"]
@property
def value(self):
return self._js.value
@value.setter
def value(self, value):
# in order to avoid confusion to the user, we don't allow setting the
# value of elements that don't have a value attribute
if not hasattr(self._js, "value"):
raise AttributeError(
f"Element {self._js.tagName} has no value attribute. If you want to "
"force a value attribute, set it directly using the `_js.value = <value>` "
"javascript API attribute instead."
)
self._js.value = value
@property
def selected(self):
return self._js.selected
@selected.setter
def selected(self, value):
# in order to avoid confusion to the user, we don't allow setting the
# value of elements that don't have a value attribute
if not hasattr(self._js, "selected"):
raise AttributeError(
f"Element {self._js.tagName} has no value attribute. If you want to "
"force a value attribute, set it directly using the `_js.value = <value>` "
"javascript API attribute instead."
)
self._js.selected = value
def clone(self, new_id=None):
clone = Element(self._js.cloneNode(True))
clone.id = new_id
return clone
def remove_class(self, classname):
classList = self._js.classList
if isinstance(classname, list):
classList.remove(*classname)
else:
classList.remove(classname)
return self
def add_class(self, classname):
classList = self._js.classList
if isinstance(classname, list):
classList.add(*classname)
else:
self._js.classList.add(classname)
return self
@property
def classes(self):
classes = self._js.classList.values()
return [x for x in classes]
def show_me(self):
self._js.scrollIntoView()
def snap(
self,
to: BaseElement | str = None,
width: int | None = None,
height: int | None = None,
):
"""
Captures a snapshot of a video element. (Only available for video elements)
Inputs:
* to: element where to save the snapshot of the video frame to
* width: width of the image
* height: height of the image
Output:
(Element) canvas element where the video frame snapshot was drawn into
"""
if self._js.tagName != "VIDEO":
raise AttributeError("Snap method is only available for video Elements")
if to is None:
canvas = self.create("canvas")
if width is None:
width = self._js.width
if height is None:
height = self._js.height
canvas._js.width = width
canvas._js.height = height
elif isinstance(to, Element):
if to._js.tagName != "CANVAS":
raise TypeError("Element to snap to must a canvas.")
canvas = to
elif getattr(to, "tagName", "") == "CANVAS":
canvas = Element(to)
elif isinstance(to, str):
canvas = pydom[to][0]
if canvas._js.tagName != "CANVAS":
raise TypeError("Element to snap to must a be canvas.")
canvas.draw(self, width, height)
return canvas
def download(self, filename: str = "snapped.png") -> None:
"""Download the current element (only available for canvas elements) with the filename
provided in input.
Inputs:
* filename (str): name of the file being downloaded
Output:
None
"""
if self._js.tagName != "CANVAS":
raise AttributeError(
"The download method is only available for canvas Elements"
)
link = self.create("a")
link._js.download = filename
link._js.href = self._js.toDataURL()
link._js.click()
def draw(self, what, width, height):
"""Draw `what` on the current element (only available for canvas elements).
Inputs:
* what (canvas image source): An element to draw into the context. The specification permits any canvas
image source, specifically, an HTMLImageElement, an SVGImageElement, an HTMLVideoElement,
an HTMLCanvasElement, an ImageBitmap, an OffscreenCanvas, or a VideoFrame.
"""
if self._js.tagName != "CANVAS":
raise AttributeError(
"The draw method is only available for canvas Elements"
)
if isinstance(what, Element):
what = what._js
# https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
self._js.getContext("2d").drawImage(what, 0, 0, width, height)
class OptionsProxy:
"""This class represents the options of a select element. It
allows to access to add and remove options by using the `add` and `remove` methods.
"""
def __init__(self, element: Element) -> None:
self._element = element
if self._element._js.tagName.lower() != "select":
raise AttributeError(
f"Element {self._element._js.tagName} has no options attribute."
)
def add(
self,
value: Any = None,
html: str = None,
text: str = None,
before: Element | int = None,
**kws,
) -> None:
"""Add a new option to the select element"""
# create the option element and set the attributes
option = document.createElement("option")
if value is not None:
kws["value"] = value
if html is not None:
option.innerHTML = html
if text is not None:
kws["text"] = text
for key, value in kws.items():
option.setAttribute(key, value)
if before:
if isinstance(before, Element):
before = before._js
self._element._js.add(option, before)
def remove(self, item: int) -> None:
"""Remove the option at the specified index"""
self._element._js.remove(item)
def clear(self) -> None:
"""Remove all the options"""
for i in range(len(self)):
self.remove(0)
@property
def options(self):
"""Return the list of options"""
return [Element(opt) for opt in self._element._js.options]
@property
def selected(self):
"""Return the selected option"""
return self.options[self._element._js.selectedIndex]
def __iter__(self):
yield from self.options
def __len__(self):
return len(self.options)
def __repr__(self):
return f"{self.__class__.__name__} (length: {len(self)}) {self.options}"
def __getitem__(self, key):
return self.options[key]
class StyleProxy: # (dict):
def __init__(self, element: Element) -> None:
self._element = element
@cached_property
def _style(self):
return self._element._js.style
def __getitem__(self, key):
return self._style.getPropertyValue(key)
def __setitem__(self, key, value):
self._style.setProperty(key, value)
def remove(self, key):
self._style.removeProperty(key)
def set(self, **kws):
for k, v in kws.items():
self._element._js.style.setProperty(k, v)
# CSS Properties
# Reference: https://github.com/microsoft/TypeScript/blob/main/src/lib/dom.generated.d.ts#L3799C1-L5005C2
# Following prperties automatically generated from the above reference using
# tools/codegen_css_proxy.py
@property
def visible(self):
return self._element._js.style.visibility
@visible.setter
def visible(self, value):
self._element._js.style.visibility = value
class StyleCollection:
def __init__(self, collection: "ElementCollection") -> None:
self._collection = collection
def __get__(self, obj, objtype=None):
return obj._get_attribute("style")
def __getitem__(self, key):
return self._collection._get_attribute("style")[key]
def __setitem__(self, key, value):
for element in self._collection._elements:
element.style[key] = value
def remove(self, key):
for element in self._collection._elements:
element.style.remove(key)
class ElementCollection:
def __init__(self, elements: [Element]) -> None:
self._elements = elements
self.style = StyleCollection(self)
def __getitem__(self, key):
# If it's an integer we use it to access the elements in the collection
if isinstance(key, int):
return self._elements[key]
# If it's a slice we use it to support slice operations over the elements
# in the collection
elif isinstance(key, slice):
return ElementCollection(self._elements[key])
# If it's anything else (basically a string) we use it as a selector
# TODO: Write tests!
elements = self._element.querySelectorAll(key)
return ElementCollection([Element(el) for el in elements])
def __len__(self):
return len(self._elements)
def __eq__(self, obj):
"""Check if the element is the same as the other element by comparing
the underlying JS element"""
return isinstance(obj, ElementCollection) and obj._elements == self._elements
def _get_attribute(self, attr, index=None):
if index is None:
return [getattr(el, attr) for el in self._elements]
# As JQuery, when getting an attr, only return it for the first element
return getattr(self._elements[index], attr)
def _set_attribute(self, attr, value):
for el in self._elements:
setattr(el, attr, value)
@property
def html(self):
return self._get_attribute("html")
@html.setter
def html(self, value):
self._set_attribute("html", value)
@property
def value(self):
return self._get_attribute("value")
@value.setter
def value(self, value):
self._set_attribute("value", value)
@property
def children(self):
return self._elements
def __iter__(self):
yield from self._elements
def __repr__(self):
return f"{self.__class__.__name__} (length: {len(self._elements)}) {self._elements}"
class DomScope:
def __getattr__(self, __name: str):
element = document[f"#{__name}"]
if element:
return element[0]
class PyDom(BaseElement):
# Add objects we want to expose to the DOM namespace since this class instance is being
# remapped as "the module" itself
BaseElement = BaseElement
Element = Element
ElementCollection = ElementCollection
def __init__(self):
# PyDom is a special case of BaseElement where we don't want to create a new JS element
# and it really doesn't have a need for styleproxy or parent to to call to __init__
# (which actually fails in MP for some reason)
self._js = document
self._parent = None
self._proxies = {}
self.ids = DomScope()
self.body = Element(document.body)
self.head = Element(document.head)
def create(self, type_, classes=None, html=None):
return super().create(type_, is_child=False, classes=classes, html=html)
def __getitem__(self, key):
elements = self._js.querySelectorAll(key)
if not elements:
return None
return ElementCollection([Element(el) for el in elements])
dom = PyDom()

View File

@@ -1 +0,0 @@
from . import elements

View File

@@ -10,6 +10,8 @@
<body> <body>
<script type="mpy" src="pydom.py"></script> <script type="mpy" src="pydom.py"></script>
<div id="system-info"></div>
<button id="just-a-button">Click For Time</button> <button id="just-a-button">Click For Time</button>
<button id="color-button">Click For Color</button> <button id="color-button">Click For Color</button>
<button id="color-reset-button">Reset Color</button> <button id="color-reset-button">Reset Color</button>

View File

@@ -4,7 +4,7 @@ import time
from datetime import datetime as dt from datetime import datetime as dt
from pyscript import display, when from pyscript import display, when
from pyweb import pydom from pyscript.web import dom
display(sys.version, target="system-info") display(sys.version, target="system-info")
@@ -19,18 +19,15 @@ def on_click():
tstr = "{:02d}/{:02d}/{:04d} {:02d}:{:02d}:{:02d}" tstr = "{:02d}/{:02d}/{:04d} {:02d}:{:02d}:{:02d}"
timenow = tstr.format(tnow[2], tnow[1], tnow[0], *tnow[2:]) timenow = tstr.format(tnow[2], tnow[1], tnow[0], *tnow[2:])
display(f"Hello from PyScript, time is: {timenow}", append=False, target="result") display(f"Hello from PyScript, time is: {timenow}", append=False, target="#result")
@when("click", "#color-button") @when("click", "#color-button")
def on_color_click(event): def on_color_click(event):
btn = pydom["#result"] btn = dom["#result"]
btn.style["background-color"] = f"#{random.randrange(0x1000000):06x}" btn.style["background-color"] = f"#{random.randrange(0x1000000):06x}"
@when("click", "#color-reset-button") @when("click", "#color-reset-button")
def reset_color(*args, **kwargs): def reset_color(*args, **kwargs):
pydom["#result"].style["background-color"] = "white" dom["#result"].style["background-color"] = "white"
# btn_reset = pydom["#color-reset-button"][0].when('click', reset_color)

View File

@@ -2,22 +2,13 @@ from unittest import mock
import pytest import pytest
from pyscript import document, when from pyscript import document, when
from pyweb import pydom from pyscript.web import dom
from pyscript.web import elements as el
class TestDocument: class TestDocument:
def test__element(self): def test__element(self):
assert pydom._js == document assert dom._js == document
def test_no_parent(self):
assert pydom.parent is None
def test_create_element(self):
new_el = pydom.create("div")
assert isinstance(new_el, pydom.BaseElement)
assert new_el._js.tagName == "DIV"
# EXPECT the new element to be associated with the document
assert new_el.parent == None
def test_getitem_by_id(): def test_getitem_by_id():
@@ -26,14 +17,14 @@ def test_getitem_by_id():
txt = "You found test_id_selector" txt = "You found test_id_selector"
selector = f"#{id_}" selector = f"#{id_}"
# EXPECT the element to be found by id # EXPECT the element to be found by id
result = pydom[selector] result = dom[selector]
div = result[0] div = result[0]
# EXPECT the element text value to match what we expect and what # EXPECT the element text value to match what we expect and what
# the JS document.querySelector API would return # the JS document.querySelector API would return
assert document.querySelector(selector).innerHTML == div.html == txt assert document.querySelector(selector).innerHTML == div.html == txt
# EXPECT the results to be of the right types # EXPECT the results to be of the right types
assert isinstance(div, pydom.BaseElement) assert isinstance(div, el.BaseElement)
assert isinstance(result, pydom.ElementCollection) assert isinstance(result, dom.ElementCollection)
def test_getitem_by_class(): def test_getitem_by_class():
@@ -43,7 +34,7 @@ def test_getitem_by_class():
"test_selector_w_children_child_1", "test_selector_w_children_child_1",
] ]
expected_class = "a-test-class" expected_class = "a-test-class"
result = pydom[f".{expected_class}"] result = dom[f".{expected_class}"]
div = result[0] div = result[0]
# EXPECT to find exact number of elements with the class in the page (== 3) # EXPECT to find exact number of elements with the class in the page (== 3)
@@ -54,7 +45,7 @@ def test_getitem_by_class():
def test_read_n_write_collection_elements(): def test_read_n_write_collection_elements():
elements = pydom[".multi-elems"] elements = dom[".multi-elems"]
for element in elements: for element in elements:
assert element.html == f"Content {element.id.replace('#', '')}" assert element.html == f"Content {element.id.replace('#', '')}"
@@ -69,15 +60,15 @@ class TestElement:
def test_query(self): def test_query(self):
# GIVEN an existing element on the page, with at least 1 child element # GIVEN an existing element on the page, with at least 1 child element
id_ = "test_selector_w_children" id_ = "test_selector_w_children"
parent_div = pydom[f"#{id_}"][0] parent_div = dom[f"#{id_}"][0]
# EXPECT it to be able to query for the first child element # EXPECT it to be able to query for the first child element
div = parent_div.find("div")[0] div = parent_div.find("div")[0]
# EXPECT the new element to be associated with the parent # EXPECT the new element to be associated with the parent
assert div.parent == parent_div assert div.parent == parent_div
# EXPECT the new element to be a BaseElement # EXPECT the new element to be a el.BaseElement
assert isinstance(div, pydom.BaseElement) assert isinstance(div, el.BaseElement)
# EXPECT the div attributes to be == to how they are configured in the page # EXPECT the div attributes to be == to how they are configured in the page
assert div.html == "Child 1" assert div.html == "Child 1"
assert div.id == "test_selector_w_children_child_1" assert div.id == "test_selector_w_children_child_1"
@@ -86,8 +77,8 @@ class TestElement:
# GIVEN 2 different Elements pointing to the same underlying element # GIVEN 2 different Elements pointing to the same underlying element
id_ = "test_id_selector" id_ = "test_id_selector"
selector = f"#{id_}" selector = f"#{id_}"
div = pydom[selector][0] div = dom[selector][0]
div2 = pydom[selector][0] div2 = dom[selector][0]
# EXPECT them to be equal # EXPECT them to be equal
assert div == div2 assert div == div2
@@ -102,27 +93,27 @@ class TestElement:
def test_append_element(self): def test_append_element(self):
id_ = "element-append-tests" id_ = "element-append-tests"
div = pydom[f"#{id_}"][0] div = dom[f"#{id_}"][0]
len_children_before = len(div.children) len_children_before = len(div.children)
new_el = div.create("p") new_el = el.p("new element")
div.append(new_el) div.append(new_el)
assert len(div.children) == len_children_before + 1 assert len(div.children) == len_children_before + 1
assert div.children[-1] == new_el assert div.children[-1] == new_el
def test_append_js_element(self): def test_append_js_element(self):
id_ = "element-append-tests" id_ = "element-append-tests"
div = pydom[f"#{id_}"][0] div = dom[f"#{id_}"][0]
len_children_before = len(div.children) len_children_before = len(div.children)
new_el = div.create("p") new_el = el.p("new element")
div.append(new_el._js) div.append(new_el._js)
assert len(div.children) == len_children_before + 1 assert len(div.children) == len_children_before + 1
assert div.children[-1] == new_el assert div.children[-1] == new_el
def test_append_collection(self): def test_append_collection(self):
id_ = "element-append-tests" id_ = "element-append-tests"
div = pydom[f"#{id_}"][0] div = dom[f"#{id_}"][0]
len_children_before = len(div.children) len_children_before = len(div.children)
collection = pydom[".collection"] collection = dom[".collection"]
div.append(collection) div.append(collection)
assert len(div.children) == len_children_before + len(collection) assert len(div.children) == len_children_before + len(collection)
@@ -132,16 +123,16 @@ class TestElement:
def test_read_classes(self): def test_read_classes(self):
id_ = "test_class_selector" id_ = "test_class_selector"
expected_class = "a-test-class" expected_class = "a-test-class"
div = pydom[f"#{id_}"][0] div = dom[f"#{id_}"][0]
assert div.classes == [expected_class] assert div.classes == [expected_class]
def test_add_remove_class(self): def test_add_remove_class(self):
id_ = "div-no-classes" id_ = "div-no-classes"
classname = "tester-class" classname = "tester-class"
div = pydom[f"#{id_}"][0] div = dom[f"#{id_}"][0]
assert not div.classes assert not div.classes
div.add_class(classname) div.add_class(classname)
same_div = pydom[f"#{id_}"][0] same_div = dom[f"#{id_}"][0]
assert div.classes == [classname] == same_div.classes assert div.classes == [classname] == same_div.classes
div.remove_class(classname) div.remove_class(classname)
assert div.classes == [] == same_div.classes assert div.classes == [] == same_div.classes
@@ -149,7 +140,7 @@ class TestElement:
def test_when_decorator(self): def test_when_decorator(self):
called = False called = False
just_a_button = pydom["#a-test-button"][0] just_a_button = dom["#a-test-button"][0]
@when("click", just_a_button) @when("click", just_a_button)
def on_click(event): def on_click(event):
@@ -157,7 +148,7 @@ class TestElement:
called = True called = True
# Now let's simulate a click on the button (using the low level JS API) # Now let's simulate a click on the button (using the low level JS API)
# so we don't risk pydom getting in the way # so we don't risk dom getting in the way
assert not called assert not called
just_a_button._js.click() just_a_button._js.click()
@@ -165,7 +156,7 @@ class TestElement:
def test_html_attribute(self): def test_html_attribute(self):
# GIVEN an existing element on the page with a known empty text content # GIVEN an existing element on the page with a known empty text content
div = pydom["#element_attribute_tests"][0] div = dom["#element_attribute_tests"][0]
# WHEN we set the html attribute # WHEN we set the html attribute
div.html = "<b>New Content</b>" div.html = "<b>New Content</b>"
@@ -177,7 +168,7 @@ class TestElement:
def test_text_attribute(self): def test_text_attribute(self):
# GIVEN an existing element on the page with a known empty text content # GIVEN an existing element on the page with a known empty text content
div = pydom["#element_attribute_tests"][0] div = dom["#element_attribute_tests"][0]
# WHEN we set the html attribute # WHEN we set the html attribute
div.text = "<b>New Content</b>" div.text = "<b>New Content</b>"
@@ -190,12 +181,12 @@ class TestElement:
class TestCollection: class TestCollection:
def test_iter_eq_children(self): def test_iter_eq_children(self):
elements = pydom[".multi-elems"] elements = dom[".multi-elems"]
assert [el for el in elements] == [el for el in elements.children] assert [el for el in elements] == [el for el in elements.children]
assert len(elements) == 3 assert len(elements) == 3
def test_slices(self): def test_slices(self):
elements = pydom[".multi-elems"] elements = dom[".multi-elems"]
assert elements[0] assert elements[0]
_slice = elements[:2] _slice = elements[:2]
assert len(_slice) == 2 assert len(_slice) == 2
@@ -205,26 +196,26 @@ class TestCollection:
def test_style_rule(self): def test_style_rule(self):
selector = ".multi-elems" selector = ".multi-elems"
elements = pydom[selector] elements = dom[selector]
for el in elements: for el in elements:
assert el.style["background-color"] != "red" assert el.style["background-color"] != "red"
elements.style["background-color"] = "red" elements.style["background-color"] = "red"
for i, el in enumerate(pydom[selector]): for i, el in enumerate(dom[selector]):
assert elements[i].style["background-color"] == "red" assert elements[i].style["background-color"] == "red"
assert el.style["background-color"] == "red" assert el.style["background-color"] == "red"
elements.style.remove("background-color") elements.style.remove("background-color")
for i, el in enumerate(pydom[selector]): for i, el in enumerate(dom[selector]):
assert el.style["background-color"] != "red" assert el.style["background-color"] != "red"
assert elements[i].style["background-color"] != "red" assert elements[i].style["background-color"] != "red"
def test_when_decorator(self): def test_when_decorator(self):
called = False called = False
buttons_collection = pydom["button"] buttons_collection = dom["button"]
@when("click", buttons_collection) @when("click", buttons_collection)
def on_click(event): def on_click(event):
@@ -232,7 +223,7 @@ class TestCollection:
called = True called = True
# Now let's simulate a click on the button (using the low level JS API) # Now let's simulate a click on the button (using the low level JS API)
# so we don't risk pydom getting in the way # so we don't risk dom getting in the way
assert not called assert not called
for button in buttons_collection: for button in buttons_collection:
button._js.click() button._js.click()
@@ -242,32 +233,32 @@ class TestCollection:
class TestCreation: class TestCreation:
def test_create_document_element(self): def test_create_document_element(self):
new_el = pydom.create("div") # TODO: This test should probably be removed since it's testing the elements module
new_el = el.div("new element")
new_el.id = "new_el_id" new_el.id = "new_el_id"
assert isinstance(new_el, pydom.BaseElement) assert isinstance(new_el, el.BaseElement)
assert new_el._js.tagName == "DIV" assert new_el._js.tagName == "DIV"
# EXPECT the new element to be associated with the document # EXPECT the new element to be associated with the document
assert new_el.parent == None assert new_el.parent == None
pydom.body.append(new_el) dom.body.append(new_el)
assert pydom["#new_el_id"][0].parent == pydom.body assert dom["#new_el_id"][0].parent == dom.body
def test_create_element_child(self): def test_create_element_child(self):
selector = "#element-creation-test" selector = "#element-creation-test"
parent_div = pydom[selector][0] parent_div = dom[selector][0]
# Creating an element from another element automatically creates that element # Creating an element from another element automatically creates that element
# as a child of the original element # as a child of the original element
new_el = parent_div.create( new_el = el.p("a div", classes=["code-description"], html="Ciao PyScripters!")
"p", classes=["code-description"], html="Ciao PyScripters!" parent_div.append(new_el)
)
assert isinstance(new_el, pydom.BaseElement) assert isinstance(new_el, el.BaseElement)
assert new_el._js.tagName == "P" assert new_el._js.tagName == "P"
# EXPECT the new element to be associated with the document # EXPECT the new element to be associated with the document
assert new_el.parent == parent_div assert new_el.parent == parent_div
assert dom[selector][0].children[0] == new_el
assert pydom[selector][0].children[0] == new_el
class TestInput: class TestInput:
@@ -281,7 +272,7 @@ class TestInput:
def test_value(self): def test_value(self):
for id_ in self.input_ids: for id_ in self.input_ids:
expected_type = id_.split("_")[-1] expected_type = id_.split("_")[-1]
result = pydom[f"#{id_}"] result = dom[f"#{id_}"]
input_el = result[0] input_el = result[0]
assert input_el._js.type == expected_type assert input_el._js.type == expected_type
assert input_el.value == f"Content {id_}" == input_el._js.value assert input_el.value == f"Content {id_}" == input_el._js.value
@@ -299,7 +290,7 @@ class TestInput:
def test_set_value_collection(self): def test_set_value_collection(self):
for id_ in self.input_ids: for id_ in self.input_ids:
input_el = pydom[f"#{id_}"] input_el = dom[f"#{id_}"]
assert input_el.value[0] == f"Content {id_}" == input_el[0].value assert input_el.value[0] == f"Content {id_}" == input_el[0].value
@@ -308,35 +299,35 @@ class TestInput:
assert input_el.value[0] == new_value == input_el[0].value assert input_el.value[0] == new_value == input_el[0].value
def test_element_without_value(self): def test_element_without_value(self):
result = pydom[f"#tests-terminal"][0] result = dom[f"#tests-terminal"][0]
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
result.value = "some value" result.value = "some value"
def test_element_without_collection(self): def test_element_without_collection(self):
result = pydom[f"#tests-terminal"] result = dom[f"#tests-terminal"]
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
result.value = "some value" result.value = "some value"
def test_element_without_collection(self): def test_element_without_collection(self):
result = pydom[f"#tests-terminal"] result = dom[f"#tests-terminal"]
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
result.value = "some value" result.value = "some value"
class TestSelect: class TestSelect:
def test_select_options_iter(self): def test_select_options_iter(self):
select = pydom[f"#test_select_element_w_options"][0] select = dom[f"#test_select_element_w_options"][0]
for i, option in enumerate(select.options, 1): for i, option in enumerate(select.options, 1):
assert option.value == f"{i}" assert option.value == f"{i}"
assert option.html == f"Option {i}" assert option.html == f"Option {i}"
def test_select_options_len(self): def test_select_options_len(self):
select = pydom[f"#test_select_element_w_options"][0] select = dom[f"#test_select_element_w_options"][0]
assert len(select.options) == 2 assert len(select.options) == 2
def test_select_options_clear(self): def test_select_options_clear(self):
select = pydom[f"#test_select_element_to_clear"][0] select = dom[f"#test_select_element_to_clear"][0]
assert len(select.options) == 3 assert len(select.options) == 3
select.options.clear() select.options.clear()
@@ -345,7 +336,7 @@ class TestSelect:
def test_select_element_add(self): def test_select_element_add(self):
# GIVEN the existing select element with no options # GIVEN the existing select element with no options
select = pydom[f"#test_select_element"][0] select = dom[f"#test_select_element"][0]
# EXPECT the select element to have no options # EXPECT the select element to have no options
assert len(select.options) == 0 assert len(select.options) == 0
@@ -426,7 +417,7 @@ class TestSelect:
def test_select_options_remove(self): def test_select_options_remove(self):
# GIVEN the existing select element with 3 options # GIVEN the existing select element with 3 options
select = pydom[f"#test_select_element_to_remove"][0] select = dom[f"#test_select_element_to_remove"][0]
# EXPECT the select element to have 3 options # EXPECT the select element to have 3 options
assert len(select.options) == 4 assert len(select.options) == 4
@@ -448,7 +439,7 @@ class TestSelect:
def test_select_get_selected_option(self): def test_select_get_selected_option(self):
# GIVEN the existing select element with one selected option # GIVEN the existing select element with one selected option
select = pydom[f"#test_select_element_w_options"][0] select = dom[f"#test_select_element_w_options"][0]
# WHEN we get the selected option # WHEN we get the selected option
selected_option = select.options.selected selected_option = select.options.selected

View File

@@ -101,10 +101,11 @@ class TestElements(PyScriptTest):
code_ = f""" code_ = f"""
from pyscript import when from pyscript import when
<script type="{interpreter}"> <script type="{interpreter}">
from pyweb import pydom from pyscript.web import dom
from pyweb.ui.elements import {el_type} from pyscript.web.elements import {el_type}
el = {el_type}({attributes}) el = {el_type}({attributes})
pydom.body.append(el) dom.body.append(el)
</script> </script>
""" """
self.pyscript_run(code_) self.pyscript_run(code_)

View File

@@ -7,16 +7,14 @@ declare namespace _default {
"ffi.py": string; "ffi.py": string;
"magic_js.py": string; "magic_js.py": string;
"util.py": string; "util.py": string;
web: {
"__init__.py": string;
"dom.py": string;
"elements.py": string;
"media.py": string;
};
"websocket.py": string; "websocket.py": string;
}; };
let pyweb: { let pyweb: {};
"__init__.py": string;
"media.py": string;
"pydom.py": string;
ui: {
"__init__.py": string;
"elements.py": string;
};
};
} }
export default _default; export default _default;