Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 24 additions & 15 deletions fasthtml/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/01_components.ipynb.

# %% auto #0
__all__ = ['named', 'html_attrs', 'hx_attrs', 'hx_evts', 'js_evts', 'hx_attrs_annotations', 'hx_evt_attrs', 'js_evt_attrs',
'evt_attrs', 'attrmap_x', 'ft_html', 'ft_hx', 'File', 'show', 'fill_form', 'fill_dataclass', 'find_inputs',
'html2ft', 'sse_message', 'A', 'Abbr', 'Address', 'Area', 'Article', 'Aside', 'Audio', 'B', 'Base', 'Bdi',
'Bdo', 'Blockquote', 'Body', 'Br', 'Button', 'Canvas', 'Caption', 'Cite', 'Code', 'Col', 'Colgroup', 'Data',
'Datalist', 'Dd', 'Del', 'Details', 'Dfn', 'Dialog', 'Div', 'Dl', 'Dt', 'Em', 'Embed', 'Fencedframe',
'Fieldset', 'Figcaption', 'Figure', 'Footer', 'Form', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'Head', 'Header',
'Hgroup', 'Hr', 'I', 'Iframe', 'Img', 'Input', 'Ins', 'Kbd', 'Label', 'Legend', 'Li', 'Link', 'Main', 'Map',
'Mark', 'Menu', 'Meta', 'Meter', 'Nav', 'Noscript', 'Object', 'Ol', 'Optgroup', 'Option', 'Output', 'P',
'Picture', 'PortalExperimental', 'Pre', 'Progress', 'Q', 'Rp', 'Rt', 'Ruby', 'S', 'Samp', 'Script', 'Search',
'Section', 'Select', 'Slot', 'Small', 'Source', 'Span', 'Strong', 'Style', 'Sub', 'Summary', 'Sup', 'Table',
'Tbody', 'Td', 'Template', 'Textarea', 'Tfoot', 'Th', 'Thead', 'Time', 'Title', 'Tr', 'Track', 'U', 'Ul',
'Var', 'Video', 'Wbr']
__all__ = ['named', 'html_attrs', 'hx_attrs', 'hx_attrs_4only', 'hx_evts', 'hx_evts_4only', 'js_evts', 'hx_attrs_annotations',
'hx_evt_attrs', 'js_evt_attrs', 'evt_attrs', 'HxPartial', 'attrmap_x', 'ft_html', 'ft_hx', 'File', 'show',
'fill_form', 'fill_dataclass', 'find_inputs', 'html2ft', 'sse_message', 'A', 'Abbr', 'Address', 'Area',
'Article', 'Aside', 'Audio', 'B', 'Base', 'Bdi', 'Bdo', 'Blockquote', 'Body', 'Br', 'Button', 'Canvas',
'Caption', 'Cite', 'Code', 'Col', 'Colgroup', 'Data', 'Datalist', 'Dd', 'Del', 'Details', 'Dfn', 'Dialog',
'Div', 'Dl', 'Dt', 'Em', 'Embed', 'Fencedframe', 'Fieldset', 'Figcaption', 'Figure', 'Footer', 'Form', 'H1',
'H2', 'H3', 'H4', 'H5', 'H6', 'Head', 'Header', 'Hgroup', 'Hr', 'I', 'Iframe', 'Img', 'Input', 'Ins', 'Kbd',
'Label', 'Legend', 'Li', 'Link', 'Main', 'Map', 'Mark', 'Menu', 'Meta', 'Meter', 'Nav', 'Noscript', 'Object',
'Ol', 'Optgroup', 'Option', 'Output', 'P', 'Picture', 'PortalExperimental', 'Pre', 'Progress', 'Q', 'Rp',
'Rt', 'Ruby', 'S', 'Samp', 'Script', 'Search', 'Section', 'Select', 'Slot', 'Small', 'Source', 'Span',
'Strong', 'Style', 'Sub', 'Summary', 'Sup', 'Table', 'Tbody', 'Td', 'Template', 'Textarea', 'Tfoot', 'Th',
'Thead', 'Time', 'Title', 'Tr', 'Track', 'U', 'Ul', 'Var', 'Video', 'Wbr']

# %% ../nbs/api/01_components.ipynb #8e2d405b
from dataclasses import dataclass, asdict, is_dataclass, make_dataclass, replace, astuple, MISSING
Expand Down Expand Up @@ -46,10 +46,13 @@ def __add__(self:FT, b): return f'{self}{b}'
named = set('a button form frame iframe img input map meta object param select textarea'.split())
html_attrs = 'id cls title style accesskey contenteditable dir draggable enterkeyhint hidden inert inputmode lang popover spellcheck tabindex translate'.split()
hx_attrs = 'get post put delete patch trigger target swap swap_oob include select select_oob indicator push_url confirm disable replace_url vals disabled_elt ext headers history history_elt indicator inherit params preserve prompt replace_url request sync validate'
hx_attrs_4only = 'action method config ignore' # https://four.htmx.org/docs/get-started/migration#attributes

hx_evts = 'abort afterOnLoad afterProcessNode afterRequest afterSettle afterSwap beforeCleanupElement beforeOnLoad beforeProcessNode beforeRequest beforeSwap beforeSend beforeTransition configRequest confirm historyCacheError historyCacheMiss historyCacheMissError historyCacheMissLoad historyRestore beforeHistorySave load noSSESourceError onLoadError oobAfterSwap oobBeforeSwap oobErrorNoTarget prompt pushedIntoHistory replacedInHistory responseError sendAbort sendError sseError sseOpen swapError targetError timeout validation:validate validation:failed validation:halted xhr:abort xhr:loadend xhr:loadstart xhr:progress'
# https://four.htmx.org/docs/get-started/migration#renamed-events
hx_evts_4only = 'after:init before:cleanup before:history:update before:init before:process before:history:restore after:history:push after:history:replace error'
js_evts = "blur change contextmenu focus input invalid reset select submit keydown keypress keyup click dblclick mousedown mouseenter mouseleave mousemove mouseout mouseover mouseup wheel"
hx_attrs = [f'hx_{o}' for o in hx_attrs.split()]
hx_attrs = [f'hx_{o}' for o in hx_attrs.split() + hx_attrs_4only.split()]
hx_attrs_annotations = {
"hx_swap": Literal["innerHTML", "outerHTML", "afterbegin", "beforebegin", "beforeend", "afterend", "delete", "none"] | str,
"hx_swap_oob": Literal["true", "innerHTML", "outerHTML", "afterbegin", "beforebegin", "beforeend", "afterend", "delete", "none"] | str,
Expand All @@ -64,7 +67,7 @@ def __add__(self:FT, b): return f'{self}{b}'
hx_attrs_annotations = {k: Optional[v] for k,v in hx_attrs_annotations.items()}
hx_attrs = html_attrs + hx_attrs

hx_evt_attrs = ['hx_on__'+camel2snake(o).replace(':','_') for o in hx_evts.split()]
hx_evt_attrs = ['hx_on__'+camel2snake(o).replace(':','_') for o in hx_evts.split() + hx_evts_4only.split()]
js_evt_attrs = ['hx_on_'+o for o in js_evts.split()]
evt_attrs = js_evt_attrs+hx_evt_attrs

Expand Down Expand Up @@ -117,6 +120,10 @@ def ft_hx(tag: str, *c, target_id=None, hx_vals=None, hx_target=None, **kwargs):
'Td', 'Template', 'Textarea', 'Tfoot', 'Th', 'Thead', 'Time', 'Title', 'Tr', 'Track', 'U', 'Ul', 'Var', 'Video', 'Wbr']
for o in _all_: _g[o] = partial(ft_hx, o.lower())

# %% ../nbs/api/01_components.ipynb #dfa8f01c
HxPartial = partial(ft_hx, 'hx-partial') # For hx v4, manually mapping because Python doesn't allow hyphens


# %% ../nbs/api/01_components.ipynb #fab04fb3
def File(fname):
"Use the unescaped text in file `fname` directly"
Expand Down Expand Up @@ -244,7 +251,9 @@ def _parse(elm, lvl=0, indent=4):
return _parse(soup, 1)

# %% ../nbs/api/01_components.ipynb #c6203402
def sse_message(elm, event='message'):
def sse_message(elm, event='message', htmx4=False):
"Convert element `elm` into a format suitable for SSE streaming"
data = '\n'.join(f'data: {o}' for o in to_xml(elm).splitlines())
# In htmx v4, if only want swap -> omit event. Including it triggers custom DOM event
if htmx4 and event == 'message': return f'{data}\n\n'
return f'event: {event}\n{data}\n\n'
70 changes: 50 additions & 20 deletions fasthtml/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/api/00_core.ipynb.

# %% auto #0
__all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'htmx_exts', 'htmxsrc', 'fhjsscr', 'surrsrc', 'scopesrc', 'viewport',
'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date', 'snake2hyphens',
'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn', 'JSONResponse',
'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple', 'noop_body',
'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'FastHTML', 'HostRoute',
'nested_name', 'serve', 'Client', 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param', 'StaticNoCache',
'add_sig_param', 'into', 'MiddlewareBase', 'FtResponse', 'unqid']
__all__ = ['empty', 'htmx_hdrs', 'fh_cfg', 'htmx_resps', 'htmx_exts', 'htmxsrc', 'htmx4src', 'fhjsscr', 'surrsrc', 'scopesrc',
'viewport', 'charset', 'cors_allow', 'iframe_scr', 'all_meths', 'devtools_loc', 'parsed_date',
'snake2hyphens', 'HtmxHeaders', 'HttpHeader', 'HtmxResponseHeaders', 'form2dict', 'parse_form', 'ApiReturn',
'JSONResponse', 'flat_xt', 'Beforeware', 'EventStream', 'signal_shutdown', 'uri', 'decode_uri', 'flat_tuple',
'noop_body', 'respond', 'is_full_page', 'Redirect', 'get_key', 'qp', 'def_hdrs', 'Lifespan', 'FastHTML',
'HostRoute', 'nested_name', 'serve', 'Client', 'RouteFuncs', 'APIRouter', 'cookie', 'reg_re_param',
'StaticNoCache', 'add_sig_param', 'into', 'MiddlewareBase', 'FtResponse', 'unqid']

# %% ../nbs/api/00_core.ipynb #23503b9e
import json,uuid,inspect,types,asyncio,inspect,random,contextlib,itsdangerous
Expand Down Expand Up @@ -62,14 +62,17 @@ def snake2hyphens(s:str):
history_restore_request="HX-History-Restore-Request",
prompt="HX-Prompt",
request="HX-Request",
request_type="HX-Request-Type", # v4: partial or full update
source="HX-Source", # v4: replaces v2 HX-Trigger but with tag#id instead just id
target="HX-Target",
trigger_name="HX-Trigger-Name",
trigger="HX-Trigger")

@dataclass
class HtmxHeaders:
boosted:str|None=None; current_url:str|None=None; history_restore_request:str|None=None; prompt:str|None=None
request:str|None=None; target:str|None=None; trigger_name:str|None=None; trigger:str|None=None
request:str|None=None; request_type:str|None=None; source:str|None=None; target:str|None=None;
trigger_name:str|None=None; trigger:str|None=None
def __bool__(self): return any(hasattr(self,o) for o in htmx_hdrs)

def _get_htmx(h):
Expand Down Expand Up @@ -236,6 +239,7 @@ async def _find_p(conn, data, hdrs, arg:str, p:Parameter):
if res in (empty,None): res = conn.query_params.getlist(arg)
if res==[]: res = None
if res in (empty,None): res = data.get(arg, None)
if res in (empty, None) and conn.scope['app'].htmx4: res = data.get('values', {}).get(arg, None)
if res in (empty,None):
if p.default is empty:
if isinstance(conn, Request): raise HTTPException(400, f"Missing required field: {arg}")
Expand Down Expand Up @@ -277,7 +281,7 @@ async def _handle(f, *args, **kwargs):

# %% ../nbs/api/00_core.ipynb #ad0f0e87
async def _wrap_ws(ws, data, params):
hdrs = Headers({k.lower():v for k,v in data.pop('HEADERS', {}).items() if v is not None})
hdrs = Headers({k.lower():v for k,v in (data.pop('HEADERS', {}) or data.pop('headers', {})).items() if v is not None})
return await _find_ps(ws, data, hdrs, params)

# %% ../nbs/api/00_core.ipynb #dcc15129
Expand Down Expand Up @@ -498,11 +502,15 @@ async def _wrap_call(f, req, params):
"remove-me": "https://cdn.jsdelivr.net/npm/htmx-ext-remove-me@2.0.0/remove-me.js",
"debug": "https://unpkg.com/htmx.org@1.9.12/dist/ext/debug.js",
"ws": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.3/ws.js",
"chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js"
"ws4": "https://unpkg.com/htmx.org@4.0.0-beta4/dist/ext/hx-ws.js",
"chunked-transfer": "https://cdn.jsdelivr.net/npm/htmx-ext-transfer-encoding-chunked@0.4.0/transfer-encoding-chunked.js",
"sse4": "https://unpkg.com/htmx.org@4.0.0-beta4/dist/ext/hx-sse.js",
"live": "https://unpkg.com/htmx.org@4.0.0-beta4/dist/ext/hx-live.js"
}

# %% ../nbs/api/00_core.ipynb #60cb52ea
htmxsrc = Script(src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.js")
htmx4src = Script(src="https://unpkg.com/htmx.org@4.0.0-beta4/dist/htmx.js")
fhjsscr = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@1.0.12/fasthtml.js")
surrsrc = Script(src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js")
scopesrc = Script(src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js")
Expand Down Expand Up @@ -547,11 +555,17 @@ def _sub(m):
return p + ('?' + urlencode({k:'' if v in (False,None) else v for k,v in kw.items()},doseq=True) if kw else '')

# %% ../nbs/api/00_core.ipynb #f86690c4
def def_hdrs(htmx=True, surreal=True):
def def_hdrs(htmx=True, htmx4=False, surreal=True):
"Default headers for a FastHTML app"
hdrs = []
if surreal: hdrs = [surrsrc,scopesrc] + hdrs
if htmx: hdrs = [htmxsrc,fhjsscr] + hdrs
if htmx4:
# metaCharacter="-" makes htmx4 use dashes instead of colons
meta_cfg = Meta(name="htmx-config", content=json.dumps({"metaCharacter": "-"}))
hdrs = [meta_cfg, htmx4src,fhjsscr] + hdrs
# TODO: Check if fhjsscr works with htmx4
elif htmx:
hdrs = [htmxsrc,fhjsscr] + hdrs
return [charset, viewport] + hdrs

# %% ../nbs/api/00_core.ipynb #2c5285ae
Expand All @@ -562,11 +576,24 @@ def def_hdrs(htmx=True, surreal=True):
function sendmsg() {
window.parent.postMessage({height: document.documentElement.offsetHeight}, '*');
}

window.onload = function() {
sendmsg();
document.body.addEventListener('htmx:afterSettle', sendmsg);
document.body.addEventListener('htmx:wsAfterMessage', sendmsg);
};"""))

const ver = window.htmx?.version || '';
const isV4 = ver.startsWith('4.');
const mc = window.htmx?.config?.metaCharacter || ':';

if (isV4) {
document.body.addEventListener(`htmx${mc}after${mc}swap`, sendmsg);
document.body.addEventListener(`htmx${mc}after${mc}ws${mc}message`, sendmsg);
} else {
document.body.addEventListener('htmx:afterSettle', sendmsg);
document.body.addEventListener('htmx:wsAfterMessage', sendmsg);
}
};
"""))


# %% ../nbs/api/00_core.ipynb #17ced9a3
class _LifespanCtx:
Expand Down Expand Up @@ -599,19 +626,22 @@ def on_event(self, event_type):
class FastHTML(Starlette):
def __init__(self, debug=False, routes=None, middleware=None, title: str = "FastHTML page", exception_handlers=None,
on_startup=None, on_shutdown=None, lifespan=None, hdrs=None, ftrs=None, exts=None,
before=None, after=None, surreal=True, htmx=True, default_hdrs=True, sess_cls=SessionMiddleware,
before=None, after=None, surreal=True, htmx=True, htmx4=False, default_hdrs=True, sess_cls=SessionMiddleware,
secret_key=None, session_cookie='session_', max_age=365*24*3600, sess_path='/',
same_site='lax', sess_https_only=False, sess_domain=None, key_fname='.sesskey',
body_wrap=noop_body, htmlkw=None, nb_hdrs=False, canonical=True, **bodykw):
middleware,before,after = map(_list, (middleware,before,after))
self.title,self.canonical,self.session_cookie,self.key_fname = title,canonical,session_cookie,key_fname
self.htmx4 = htmx4
hdrs,ftrs,exts = map(listify, (hdrs,ftrs,exts))
if htmx4 and exts:
exts = ['ws4' if e in ('ws', 'ws4') else 'sse4' if e in ('sse', 'sse4') else e for e in exts]
exts = {k:htmx_exts[k] for k in exts}
htmlkw = htmlkw or {}
if default_hdrs: hdrs = def_hdrs(htmx, surreal=surreal) + hdrs
if default_hdrs: hdrs = def_hdrs(htmx, htmx4, surreal=surreal) + hdrs
hdrs += [Script(src=ext) for ext in exts.values()]
if IN_NOTEBOOK:
hdrs.append(iframe_scr)
hdrs.append(iframe_scr) # TODO: check iframe_scr work with htmx4
from IPython.display import display,HTML
if nb_hdrs: display(HTML(to_xml(tuple(hdrs))))
middleware.append(cors_allow)
Expand All @@ -621,8 +651,8 @@ def __init__(self, debug=False, routes=None, middleware=None, title: str = "Fast
self.secret_key = get_key(secret_key, key_fname)
if sess_cls:
sess = Middleware(sess_cls, secret_key=self.secret_key,session_cookie=session_cookie,
max_age=max_age, path=sess_path, same_site=same_site,
https_only=sess_https_only, domain=sess_domain)
max_age=max_age, path=sess_path, same_site=same_site,
https_only=sess_https_only, domain=sess_domain)
middleware.append(sess)
exception_handlers = ifnone(exception_handlers, {})
if 404 not in exception_handlers:
Expand Down
4 changes: 2 additions & 2 deletions fasthtml/fastapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def fast_app(
pico:Optional[bool]=None, # Include PicoCSS header?
surreal:Optional[bool]=True, # Include surreal.js/scope headers?
htmx:Optional[bool]=True, # Include HTMX header?
htmx4:Optional[bool]=False,
exts:Optional[list|str]=None, # HTMX extension names to include
canonical:bool=True, # Automatically include canonical link?
secret_key:Optional[str]=None, # Signing key for sessions
Expand All @@ -70,7 +71,7 @@ def fast_app(
app = _app_factory(hdrs=h, ftrs=ftrs, before=before, middleware=middleware, live=live, debug=debug, title=title, routes=routes, exception_handlers=exception_handlers,
on_startup=on_startup, on_shutdown=on_shutdown, lifespan=lifespan, default_hdrs=default_hdrs, secret_key=secret_key, canonical=canonical,
session_cookie=session_cookie, max_age=max_age, sess_path=sess_path, same_site=same_site, sess_https_only=sess_https_only,
sess_domain=sess_domain, key_fname=key_fname, exts=exts, surreal=surreal, htmx=htmx, htmlkw=htmlkw,
sess_domain=sess_domain, key_fname=key_fname, exts=exts, surreal=surreal, htmx=htmx, htmx4=htmx4, htmlkw=htmlkw,
reload_attempts=reload_attempts, reload_interval=reload_interval, body_wrap=body_wrap, nb_hdrs=nb_hdrs, **(bodykw or {}))
app.static_route_exts(static_path=static_path)
if not db_file: return app,app.route
Expand All @@ -86,4 +87,3 @@ def fast_app(
dbtbls = [_get_tbl(db.t, k, v) for k,v in tbls.items()]
if len(dbtbls)==1: dbtbls=dbtbls[0]
return app,app.route,*dbtbls

27 changes: 17 additions & 10 deletions fasthtml/jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,22 @@ def _repr_html_(self:FT):
return to_xml(Div(self, scr_proc, **kw))

# %% ../nbs/api/06_jupyter.ipynb #1daaa0e1
def htmx_config_port(port=8000):
display(HTML('''
def htmx_config_port(port=8000, htmx4=False):
evt = 'htmx-config-request' if htmx4 else 'htmx:configRequest'

display(HTML(f'''
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
if(event.detail.path.includes('://')) return;
htmx.config.selfRequestsOnly=false;
event.detail.path = `${location.protocol}//${location.hostname}:%s${event.detail.path}`;
});
</script>''' % port))
document.body.addEventListener('{evt}', (event) => {{
const path = {'event.detail.ctx.request.action' if htmx4 else 'event.detail.path'};
if (path.includes('://')) return;

const full = `${{location.protocol}}//${{location.hostname}}:{port}${{path}}`;

{'event.detail.ctx.request.mode = "cors";' if htmx4 else 'htmx.config.selfRequestsOnly = false;'}
{'event.detail.ctx.request.action = full;' if htmx4 else 'event.detail.path = full;'}
}});
</script>'''))


# %% ../nbs/api/06_jupyter.ipynb #79406618
class JupyUvi:
Expand All @@ -91,8 +98,8 @@ def __init__(self, app, log_level="error", host='0.0.0.0', port=8000, start=True
self._live_ver = 0
if live: self._setup_live(app)
if start: self.start()
if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port)
if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port, htmx4=app.htmx4)

def start(self):
self.server = nb_serve(self.app, log_level=self.log_level, host=self.host, port=self.port,daemon=self.daemon, **self.kwargs)

Expand Down
11 changes: 7 additions & 4 deletions fasthtml/pico.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ def Container(*args, **kwargs)->FT:
"A PicoCSS Container, implemented as a Main with class 'container'"
return Main(*args, cls="container", **kwargs)

# %% ../nbs/api/04_pico.ipynb #138dc298
def PicoBusy():
return (HtmxOn('beforeRequest', "event.detail.elt.setAttribute('aria-busy', 'true' )"),
HtmxOn('afterRequest', "event.detail.elt.setAttribute('aria-busy', 'false')"))
# %% ../nbs/api/04_pico.ipynb #b8c98614
def PicoBusy(htmx4=False, metaChar=None):
if metaChar is None: metaChar = '-' if htmx4 else ':'
evt = (f'before{metaChar}request', f'after{metaChar}request') if htmx4 else ('beforeRequest', 'afterRequest')
elt = 'event.detail.ctx.sourceElement' if htmx4 else 'event.detail.elt'
return (HtmxOn(evt[0], f"{elt}.setAttribute('aria-busy', 'true' )", htmx4=htmx4),
HtmxOn(evt[1], f"{elt}.setAttribute('aria-busy', 'false')", htmx4=htmx4))
Loading
Loading