"""
Extensions module for components.
**Usage example:**
```py
from duck.html.components.button import Button
from duck.html.components.extensions import Extension
class MyExtension(Extension):
def my_new_method(self):
# This method will now be available on component `MyButton`
# Do something
pass
def existing_method(self):
super().existing_method()
# When overriding an existing method, don't forget to call `super()`
# Do something, e.g. access component keyword arguments through `self.kwargs`
def apply_extension(self):
super().apply_extension()
# Modify something or do something
self.style["background-color"] = "red"
class MyButton(MyExtension, Button):
pass
btn = MyButton()
btn.style["background-color"] == "red" # Outputs: True
```
"""
# NOTE: In the future we need to use a max of 2 extensions just to avoid Method Resolution Order (MRO) overhead.
from typing import Optional, Any, Union
[docs]
class ExtensionError(Exception):
"""
Raised when there is an error related to a component extension.
"""
[docs]
class KwargError(ExtensionError):
"""
Raised when there is no required keyword argument in component `kwargs`.
"""
[docs]
class RequestNotFoundError(ExtensionError):
"""
Raised when there is no required 'request' in component `kwargs` or `kwargs['context']` (if component used in a template).
"""
[docs]
class Extension:
"""
Base class for all component extensions.
Extensions allow reusable behaviors to be added to components via mixins.
Override methods like `on_create` or define new ones for extended logic.
"""
[docs]
def on_create(self):
super().on_create()
self.apply_extension() # This applies all extensions in according to MRO
if not getattr(self, "_base_extension_applied", False):
raise ExtensionError("Seems like extension method `apply_extension` has been overridden but 'super().apply_extension()' has not been called.")
[docs]
def apply_extension(self):
"""
Overrride this method to apply the desired extensions.
Notes:
- Don't forget to call `super().apply_extensions()` inside the method.
- Methods or property definations are applied to the component automatically.
"""
self._base_extension_applied = True
[docs]
class BasicExtension(Extension):
"""
Basic extension for HTML components, providing common properties like `text`, `id`, `bg_color`, and `color`.
"""
[docs]
def apply_extension(self):
"""
Apply the extension. Applies initial values from `kwargs`
for basic properties such as `id`, `klass`, `text`, `bg_color`, and `color`.
"""
super().apply_extension()
# Apply the current extension
keys = {"id", "klass", "text", "bg_color", "color"}
for key in keys:
value = self.kwargs.get(key)
if value is not None:
setattr(self, key, value)
@property
def id(self) -> Optional[str]:
"""
Returns the ID of the component.
Returns:
Optional[str]: The ID if set, otherwise None.
"""
return self.props.get("id")
@id.setter
def id(self, id_: str):
"""
Sets the ID of the component.
Args:
id_ (str): The ID to assign to the component.
"""
self.props["id"] = id_
@property
def klass(self) -> Optional[str]:
"""
Returns the `class` of the component.
Returns:
Optional[str]: The class' if set, otherwise None.
"""
return self.props.get("class")
@klass.setter
def klass(self, class_: str):
"""
Sets the `class` of the component.
Args:
class_ (str): The `class` to assign to the component.
"""
self.props["class"] = class_
@property
def text(self) -> str:
"""
Returns the inner html of the component.
Notes:
This escapes HTML if found in text. You can disable this by setting `escape_on_text=False` on component.
Returns:
str: The inner content of the component.
Raises:
ExtensionError: If the component does not support `inner_html`.
"""
from duck.html.components import InnerComponent
if not isinstance(self, InnerComponent):
raise ExtensionError(f"Property `text` can only be used on inner components with `inner_html`, not {type(self)}")
return self.inner_html
@text.setter
def text(self, text: Union[str, int, float]):
"""
Sets the text of the component.
Args:
text (Union[str, int, float]): The new inner content.
Notes:
This escapes HTML if found in text. You can disable this by setting `self.escape_on_text=False` on component.
Raises:
ExtensionError: If the component does not support `inner_html` or if input is not a string, LiveResult or Lazy object.
"""
from duck.html import escape
from duck.html.components import InnerComponent
if not isinstance(self, InnerComponent):
raise ExtensionError(f"Property `text` can only be used on inner components with `inner_html`, not {type(self)}")
if not isinstance(text, (str, int, float)):
raise ExtensionError(f"Text must be a valid string, integer or float, not {type(text)}")
if not self.escape_on_text:
text = str(text) if not isinstance(text, str) else text # Convert data to right format
text = escape(text)
# Set escaped text.
self.inner_html = text
@property
def bg_color(self) -> Optional[str]:
"""
Returns the background color of the component.
Returns:
Optional[str]: The background color if set, otherwise None.
"""
return self.style.get("background-color")
@bg_color.setter
def bg_color(self, color: str):
"""
Sets the background color of the component.
Args:
color (str): A valid CSS color string.
"""
self.style["background-color"] = color
@property
def color(self) -> Optional[str]:
"""
Returns the foreground (text) color of the component.
Returns:
Optional[str]: The text color if set, otherwise None.
"""
return self.style.get("color")
@color.setter
def color(self, color: str):
"""
Sets the foreground (text) color of the component.
Args:
color (str): A valid CSS color string.
"""
self.style["color"] = color
[docs]
def get_kwarg_or_raise(self, kwarg: str) -> Any:
"""
Retrieves an argument from component `kwargs` or raise an exception.
Raises:
KwargError: If a keyword argument is not provided to the component.
"""
if kwarg not in self.kwargs:
raise KwargError(f"Keyword argument `{kwarg}` is required, could not be found in `self.kwargs`.")
return self.kwargs.get(kwarg)
[docs]
def get_request_or_raise(self) -> "HttpRequest":
"""
Retrieves a request object from component `kwargs` or raise an exception.
Raises:
RequestNotFoundError: If the request is not in kwargs or kwargs['context'] (if used in templates).
"""
from duck.http.request import HttpRequest
request: HttpRequest = getattr(self, "request", None) or self.kwargs.get('request')
if not request:
# Ξaybe this component is used in a template.
context = self.kwargs.get("context", {})
request = context.get("request")
if not request:
raise RequestNotFoundError("Request not found in `kwargs` or kwargs['context'] (if component used in a template).")
# Finally, return request.
return request
[docs]
class StyleCompatibilityExtension(Extension):
"""
Extension for improving CSS style compatibility between browsers.
Automatically adds and (optionally) deletes vendor-prefixed versions
of certain CSS properties when setting or deleting styles.
"""
def __init__(self, *args, **kw) -> None:
# Controls whether prefixed properties are deleted along with the main key
self.delete_compatibility_keys_on_delete = True
# Mapping of base CSS properties to their vendor-prefixed equivalents
self.compatibility_keys = {
# Layout and visual effects
"backdrop-filter": [
"-webkit-backdrop-filter",
"-ms-backdrop-filter",
],
"box-shadow": [
"-webkit-box-shadow",
"-moz-box-shadow",
],
"box-sizing": [
"-webkit-box-sizing",
"-moz-box-sizing",
],
"appearance": [
"-webkit-appearance",
"-moz-appearance",
],
"filter": [
"-webkit-filter",
],
"opacity": [
"-webkit-opacity",
"-moz-opacity",
],
# Transformations and animations
"transform": [
"-webkit-transform",
"-moz-transform",
"-ms-transform",
"-o-transform",
],
"transform-origin": [
"-webkit-transform-origin",
"-moz-transform-origin",
"-ms-transform-origin",
"-o-transform-origin",
],
"transition": [
"-webkit-transition",
"-moz-transition",
"-o-transition",
],
"animation": [
"-webkit-animation",
"-moz-animation",
"-o-animation",
],
"animation-delay": [
"-webkit-animation-delay",
"-moz-animation-delay",
"-o-animation-delay",
],
"animation-duration": [
"-webkit-animation-duration",
"-moz-animation-duration",
"-o-animation-duration",
],
# User interaction
"user-select": [
"-webkit-user-select",
"-moz-user-select",
"-ms-user-select",
],
"touch-action": [
"-ms-touch-action",
],
"cursor": [
"-webkit-cursor",
],
# Gradients and backgrounds
"background-clip": [
"-webkit-background-clip",
"-moz-background-clip",
],
"background-origin": [
"-webkit-background-origin",
"-moz-background-origin",
],
"background-size": [
"-webkit-background-size",
"-moz-background-size",
"-o-background-size",
],
# Flexbox
"display": [
"-webkit-box", # old flexbox syntax
"-moz-box",
"-ms-flexbox",
"-webkit-flex",
],
"align-items": [
"-webkit-align-items",
"-ms-flex-align",
],
"justify-content": [
"-webkit-justify-content",
"-ms-flex-pack",
],
"flex": [
"-webkit-flex",
"-ms-flex",
],
"flex-direction": [
"-webkit-flex-direction",
"-ms-flex-direction",
],
# Sticky and clipping
"clip-path": [
"-webkit-clip-path",
],
"position": [
"-webkit-sticky", # sticky support
],
}
# Super init
super().__init__(*args, **kw)
[docs]
def apply_extension(self):
super().apply_extension()
def on_style_setitem(key, val):
"""
Called on setting of style key to apply compatibility keys.
"""
# Add vendor-prefixed versions if applicable
for compat_key in self.compatibility_keys.get(key, []):
self.style.__setitem__(compat_key, val, call_on_set_item_handler=False)
super_on_set_item(key, val)
def on_style_delitem(key):
"""
Called on deletion of a style key.
"""
# Optionally delete vendor-prefixed versions
if self.delete_compatibility_keys_on_delete:
for compat_key in self.compatibility_keys.get(key, []):
if compat_key in self.style:
self.style.__delitem__(compat_key, call_on_delete_item_handler=False)
super_on_delete_item(key)
# Replace the styleβs magic methods with our enhanced versions
super_on_set_item = self.style._on_set_item
super_on_delete_item = self.style._on_delete_item
self.style._on_set_item = on_style_setitem
self.style._on_delete_item = on_style_delitem