quantum-framework 0.18.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/admin.py +131 -0
- quantum/cli/check.py +223 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +414 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +548 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +565 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2619 -0
- quantum/core/config_env.py +54 -0
- quantum/core/docs_links.py +8 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +208 -0
- quantum/core/expressions.py +677 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +292 -0
- quantum/core/features/data_import/src/runtime.py +533 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +147 -0
- quantum/core/features/invocation/src/runtime.py +332 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +121 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
- quantum/core/features/ui_engine/src/parser.py +1192 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/guards.py +53 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1551 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +71 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +93 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +229 -0
- quantum/core/parsers/data/invoke_parser.py +133 -0
- quantum/core/parsers/data/query_parser.py +213 -0
- quantum/core/parsers/data/transaction_parser.py +86 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +79 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +98 -0
- quantum/core/parsers/functions/param_parser.py +73 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +164 -0
- quantum/core/xml_lines.py +81 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +25 -0
- quantum/runtime/action_handler.py +532 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +1048 -0
- quantum/runtime/component_composer.py +133 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/conditions.py +54 -0
- quantum/runtime/database_service.py +595 -0
- quantum/runtime/db_schema.py +100 -0
- quantum/runtime/dev_panel.py +178 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +283 -0
- quantum/runtime/executor_registry.py +178 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +264 -0
- quantum/runtime/executors/control_flow/__init__.py +12 -0
- quantum/runtime/executors/control_flow/if_executor.py +98 -0
- quantum/runtime/executors/control_flow/loop_executor.py +308 -0
- quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
- quantum/runtime/executors/control_flow/set_executor.py +464 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +156 -0
- quantum/runtime/executors/data/invoke_executor.py +228 -0
- quantum/runtime/executors/data/query_executor.py +290 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +546 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/local_server.py +28 -0
- quantum/runtime/logging_setup.py +141 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +218 -0
- quantum/runtime/pypy_compat.py +317 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +734 -0
- quantum/runtime/service_container.py +470 -0
- quantum/runtime/table_params.py +163 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +222 -0
- quantum/runtime/ui_console.py +405 -0
- quantum/runtime/ui_desktop.py +56 -0
- quantum/runtime/ui_form_rules.py +193 -0
- quantum/runtime/ui_html_adapter.py +2567 -0
- quantum/runtime/ui_html_templates.py +2106 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_page_renderer.py +392 -0
- quantum/runtime/ui_pager.py +73 -0
- quantum/runtime/ui_table_edit.py +139 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/ui_view.py +221 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +2131 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/services.py +109 -0
- quantum/utils/__init__.py +1 -0
- quantum_admin/__init__.py +4 -0
- quantum_admin/assets/quantum-admin-connectors.js +123 -0
- quantum_admin/assets/quantum-admin.css +2058 -0
- quantum_admin/assets/quantum-source-highlight.js +106 -0
- quantum_admin/backend/__init__.py +4 -0
- quantum_admin/backend/audit_service.py +346 -0
- quantum_admin/backend/auth_service.py +394 -0
- quantum_admin/backend/component_discovery.py +766 -0
- quantum_admin/backend/config_generator.py +586 -0
- quantum_admin/backend/connector_service.py +1016 -0
- quantum_admin/backend/crud.py +557 -0
- quantum_admin/backend/database.py +226 -0
- quantum_admin/backend/db_setup_service.py +193 -0
- quantum_admin/backend/deploy_service.py +698 -0
- quantum_admin/backend/docker_service.py +422 -0
- quantum_admin/backend/environment_service.py +418 -0
- quantum_admin/backend/git_service.py +370 -0
- quantum_admin/backend/health_service.py +390 -0
- quantum_admin/backend/job_service.py +347 -0
- quantum_admin/backend/log_service.py +331 -0
- quantum_admin/backend/main.py +14426 -0
- quantum_admin/backend/models.py +986 -0
- quantum_admin/backend/pipeline_service.py +812 -0
- quantum_admin/backend/resource_manager.py +1118 -0
- quantum_admin/backend/schemas.py +419 -0
- quantum_admin/backend/secret_manager.py +277 -0
- quantum_admin/backend/settings_service.py +488 -0
- quantum_admin/backend/template_service.py +710 -0
- quantum_admin/backend/test_execution_service.py +364 -0
- quantum_admin/backend/test_generator.py +782 -0
- quantum_admin/backend/webhook_service.py +454 -0
- quantum_admin/backend/websocket_manager.py +360 -0
- quantum_admin/components/admin/_layout/AdminShell.q +121 -0
- quantum_admin/components/admin/agents.q +107 -0
- quantum_admin/components/admin/app/[name].q +558 -0
- quantum_admin/components/admin/applications.q +160 -0
- quantum_admin/components/admin/component/[...path].q +205 -0
- quantum_admin/components/admin/components.q +59 -0
- quantum_admin/components/admin/connectors.q +283 -0
- quantum_admin/components/admin/dashboard.q +86 -0
- quantum_admin/components/admin/database.q +95 -0
- quantum_admin/components/admin/features.q +69 -0
- quantum_admin/components/admin/index.q +12 -0
- quantum_admin/components/admin/jobs.q +75 -0
- quantum_admin/components/admin/login.q +65 -0
- quantum_admin/components/admin/logout.q +11 -0
- quantum_admin/components/admin/settings.q +174 -0
- quantum_admin/components/admin/source.q +45 -0
- quantum_admin/components/admin/tests.q +73 -0
- quantum_admin/run.py +13 -0
- quantum_admin/services/__init__.py +16 -0
- quantum_admin/services/_base.py +84 -0
- quantum_admin/services/_component_test_generator.py +205 -0
- quantum_admin/services/_yaml_editor.py +82 -0
- quantum_admin/services/apps.py +330 -0
- quantum_admin/services/auth.py +61 -0
- quantum_admin/services/components.py +210 -0
- quantum_admin/services/connectors.py +164 -0
- quantum_admin/services/projects.py +189 -0
- quantum_admin/services/repository.py +223 -0
- quantum_admin/services/settings.py +150 -0
- quantum_admin/services/yaml_import.py +118 -0
- quantum_framework-0.18.0.dist-info/METADATA +281 -0
- quantum_framework-0.18.0.dist-info/RECORD +333 -0
- quantum_framework-0.18.0.dist-info/WHEEL +5 -0
- quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,2567 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UI Engine - HTML/CSS Adapter
|
|
3
|
+
|
|
4
|
+
Transforms UI AST nodes into a standalone HTML/CSS page.
|
|
5
|
+
Uses the design tokens system for normalized styling across targets.
|
|
6
|
+
|
|
7
|
+
Inside a served page, quantum/runtime/ui_page_renderer.py reuses this drawing
|
|
8
|
+
with the page's runtime (UI-1); the desktop is that page in a window (UI-4).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import calendar as _calendar
|
|
12
|
+
import re
|
|
13
|
+
from datetime import date as _date
|
|
14
|
+
from typing import Dict, List, Optional, Set
|
|
15
|
+
|
|
16
|
+
from quantum.core.ast_nodes import QuantumNode
|
|
17
|
+
from quantum.core.features.conditionals.src.ast_node import IfNode
|
|
18
|
+
from quantum.core.features.loops.src.ast_node import LoopNode
|
|
19
|
+
from quantum.core.features.state_management.src.ast_node import SetNode
|
|
20
|
+
from quantum.core.features.ui_engine.src.ast_nodes import (
|
|
21
|
+
UIWindowNode, UIHBoxNode, UIVBoxNode, UIPanelNode,
|
|
22
|
+
UITabPanelNode, UITabNode, UIGridNode, UIAccordionNode,
|
|
23
|
+
UISectionNode, UIDividedBoxNode, UIFormNode, UIFormItemNode,
|
|
24
|
+
UISpacerNode, UIScrollBoxNode,
|
|
25
|
+
UITextNode, UIButtonNode, UIInputNode, UICheckboxNode,
|
|
26
|
+
UIRadioNode, UISwitchNode, UISelectNode, UITableNode,
|
|
27
|
+
UIColumnNode, UIListNode, UIItemNode, UIImageNode,
|
|
28
|
+
UILinkNode, UIProgressNode, UITreeNode, UIMenuNode,
|
|
29
|
+
UIOptionNode, UILogNode, UIMarkdownNode, UIHeaderNode,
|
|
30
|
+
UIFooterNode, UIRuleNode, UILoadingNode, UIBadgeNode,
|
|
31
|
+
UILayoutMixin, UIValidatorNode, UIAnimateNode, UIAnimationMixin,
|
|
32
|
+
# Component Library nodes
|
|
33
|
+
UICardNode, UICardHeaderNode, UICardBodyNode, UICardFooterNode,
|
|
34
|
+
UIModalNode, UIChartNode, UIAvatarNode, UITooltipNode,
|
|
35
|
+
UIDropdownNode, UIAlertNode, UIBreadcrumbNode, UIBreadcrumbItemNode,
|
|
36
|
+
UIPaginationNode, UISkeletonNode,
|
|
37
|
+
# Toast / carousel / stepper / calendar nodes
|
|
38
|
+
UIToastNode, UIToastContainerNode,
|
|
39
|
+
UICarouselNode, UISlideNode,
|
|
40
|
+
UIStepperNode, UIStepNode,
|
|
41
|
+
UICalendarNode, UIDatePickerNode,
|
|
42
|
+
)
|
|
43
|
+
from quantum.runtime.ui_html_templates import (
|
|
44
|
+
HtmlBuilder, HTML_TEMPLATE, CSS_RESET, CSS_THEME, TAB_JS,
|
|
45
|
+
VALIDATION_JS, VALIDATION_CSS, RESPONSIVE_CSS,
|
|
46
|
+
CSS_ANIMATIONS, ANIMATION_JS,
|
|
47
|
+
TOAST_JS, CAROUSEL_JS, STEPPER_JS, CALENDAR_JS,
|
|
48
|
+
)
|
|
49
|
+
from quantum.runtime.ui_tokens import TokenConverter
|
|
50
|
+
# Optional theming imports (may not be available)
|
|
51
|
+
try:
|
|
52
|
+
from quantum.core.features.theming.src import (
|
|
53
|
+
UIThemeNode,
|
|
54
|
+
get_theme_css,
|
|
55
|
+
get_theme_switch_js,
|
|
56
|
+
THEME_PRESETS,
|
|
57
|
+
)
|
|
58
|
+
HAS_THEMING = True
|
|
59
|
+
except ImportError:
|
|
60
|
+
HAS_THEMING = False
|
|
61
|
+
UIThemeNode = None
|
|
62
|
+
get_theme_css = None
|
|
63
|
+
get_theme_switch_js = None
|
|
64
|
+
THEME_PRESETS = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ==========================================================================
|
|
68
|
+
# Calendar labels (kept in sync with CALENDAR_JS in ui_html_templates.py)
|
|
69
|
+
# ==========================================================================
|
|
70
|
+
|
|
71
|
+
MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June',
|
|
72
|
+
'July', 'August', 'September', 'October', 'November', 'December']
|
|
73
|
+
DAY_NAMES = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
|
74
|
+
|
|
75
|
+
# Default toast icons per variant (same glyphs used by TOAST_JS)
|
|
76
|
+
TOAST_ICONS = {
|
|
77
|
+
'info': 'ⓘ',
|
|
78
|
+
'success': '✓',
|
|
79
|
+
'warning': '⚠',
|
|
80
|
+
'danger': '✗',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ==========================================================================
|
|
85
|
+
# Date picker runtime (input + calendar popup)
|
|
86
|
+
# ==========================================================================
|
|
87
|
+
|
|
88
|
+
DATE_PICKER_JS = """\
|
|
89
|
+
<script>
|
|
90
|
+
// Quantum UI Date Picker (text input + q-calendar popup)
|
|
91
|
+
window.__quantumDatePicker = (function() {
|
|
92
|
+
'use strict';
|
|
93
|
+
|
|
94
|
+
var pickers = {};
|
|
95
|
+
|
|
96
|
+
function root(id) { return document.getElementById(id); }
|
|
97
|
+
function dropdown(id) { return document.getElementById(id + '-dropdown'); }
|
|
98
|
+
function inputOf(id) { return document.getElementById(id + '-input'); }
|
|
99
|
+
function valueOf(id) { return document.getElementById(id + '-value'); }
|
|
100
|
+
|
|
101
|
+
function format(iso, pattern) {
|
|
102
|
+
if (!iso) return '';
|
|
103
|
+
var parts = String(iso).split('-');
|
|
104
|
+
if (parts.length !== 3) return String(iso);
|
|
105
|
+
return (pattern || 'YYYY-MM-DD')
|
|
106
|
+
.replace('YYYY', parts[0])
|
|
107
|
+
.replace('YY', parts[0].slice(2))
|
|
108
|
+
.replace('MM', parts[1])
|
|
109
|
+
.replace('DD', parts[2]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function open(id) {
|
|
113
|
+
closeAll(id);
|
|
114
|
+
var d = dropdown(id);
|
|
115
|
+
if (d) d.classList.add('open');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function close(id) {
|
|
119
|
+
var d = dropdown(id);
|
|
120
|
+
if (d) d.classList.remove('open');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toggle(id) {
|
|
124
|
+
var d = dropdown(id);
|
|
125
|
+
if (!d) return;
|
|
126
|
+
if (d.classList.contains('open')) close(id); else open(id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function closeAll(except) {
|
|
130
|
+
Object.keys(pickers).forEach(function(pid) {
|
|
131
|
+
if (pid !== except) close(pid);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function setValue(id, iso) {
|
|
136
|
+
var opts = pickers[id] || {};
|
|
137
|
+
var input = inputOf(id);
|
|
138
|
+
var hidden = valueOf(id);
|
|
139
|
+
if (input) input.value = format(iso, opts.format);
|
|
140
|
+
if (hidden) hidden.value = iso || '';
|
|
141
|
+
close(id);
|
|
142
|
+
if (opts.onChange) opts.onChange(iso || '');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function clear(id) {
|
|
146
|
+
setValue(id, '');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getValue(id) {
|
|
150
|
+
var hidden = valueOf(id);
|
|
151
|
+
return hidden ? hidden.value : '';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function register(id, options) {
|
|
155
|
+
options = options || {};
|
|
156
|
+
pickers[id] = options;
|
|
157
|
+
if (options.disabled) return;
|
|
158
|
+
var input = inputOf(id);
|
|
159
|
+
if (input) {
|
|
160
|
+
input.addEventListener('click', function() { toggle(id); });
|
|
161
|
+
input.addEventListener('keydown', function(e) {
|
|
162
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); }
|
|
163
|
+
if (e.key === 'Escape') close(id);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
document.addEventListener('click', function(e) {
|
|
169
|
+
Object.keys(pickers).forEach(function(pid) {
|
|
170
|
+
var el = root(pid);
|
|
171
|
+
if (el && !el.contains(e.target)) close(pid);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
register: register,
|
|
177
|
+
open: open,
|
|
178
|
+
close: close,
|
|
179
|
+
toggle: toggle,
|
|
180
|
+
clear: clear,
|
|
181
|
+
setValue: setValue,
|
|
182
|
+
getValue: getValue,
|
|
183
|
+
format: format
|
|
184
|
+
};
|
|
185
|
+
})();
|
|
186
|
+
</script>
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ==========================================================================
|
|
191
|
+
# Week numbers for ui:calendar (re-applied after every calendar re-render)
|
|
192
|
+
# ==========================================================================
|
|
193
|
+
|
|
194
|
+
CALENDAR_WEEKS_JS = """\
|
|
195
|
+
<script>
|
|
196
|
+
// Quantum UI - week number column for calendars with show-week-numbers
|
|
197
|
+
window.__quantumCalendarWeeks = (function() {
|
|
198
|
+
'use strict';
|
|
199
|
+
|
|
200
|
+
function isoWeek(d) {
|
|
201
|
+
var t = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
202
|
+
t.setDate(t.getDate() + 3 - ((t.getDay() + 6) % 7));
|
|
203
|
+
var firstThursday = new Date(t.getFullYear(), 0, 4);
|
|
204
|
+
firstThursday.setDate(firstThursday.getDate() + 3 - ((firstThursday.getDay() + 6) % 7));
|
|
205
|
+
return 1 + Math.round((t - firstThursday) / 604800000);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function apply(id) {
|
|
209
|
+
var state = window.__quantumCalendar && __quantumCalendar.get(id);
|
|
210
|
+
var cal = document.getElementById(id);
|
|
211
|
+
if (!state || !cal) return;
|
|
212
|
+
var grid = cal.querySelector('.q-calendar-grid');
|
|
213
|
+
if (!grid) return;
|
|
214
|
+
var days = grid.querySelectorAll('.q-calendar-day:not(.week-number)');
|
|
215
|
+
if (!days.length) return;
|
|
216
|
+
|
|
217
|
+
var first = new Date(state.viewYear, state.viewMonth, 1);
|
|
218
|
+
var offset = (first.getDay() - (state.firstDayOfWeek || 0) + 7) % 7;
|
|
219
|
+
for (var i = 0; i < days.length; i += 7) {
|
|
220
|
+
var rowDate = new Date(state.viewYear, state.viewMonth, 1 - offset + i);
|
|
221
|
+
var cell = document.createElement('div');
|
|
222
|
+
cell.className = 'q-calendar-day week-number';
|
|
223
|
+
cell.textContent = isoWeek(rowDate);
|
|
224
|
+
grid.insertBefore(cell, days[i]);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { apply: apply };
|
|
229
|
+
})();
|
|
230
|
+
</script>
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ==========================================================================
|
|
235
|
+
# Visibility bindings for declarative toasts (data-q-show)
|
|
236
|
+
# ==========================================================================
|
|
237
|
+
|
|
238
|
+
SHOW_BINDING_JS = """\
|
|
239
|
+
<script>
|
|
240
|
+
// Quantum UI - toggles elements carrying data-q-show when their state changes
|
|
241
|
+
(function() {
|
|
242
|
+
'use strict';
|
|
243
|
+
|
|
244
|
+
function apply(name, value) {
|
|
245
|
+
var visible = !(value === undefined || value === null || value === false ||
|
|
246
|
+
value === '' || value === 0 || value === 'false');
|
|
247
|
+
document.querySelectorAll('[data-q-show="' + name + '"]').forEach(function(el) {
|
|
248
|
+
el.style.display = visible ? '' : 'none';
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
window.__quantumShowBinding = apply;
|
|
253
|
+
|
|
254
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
255
|
+
var previous = window.__quantumStateUpdate;
|
|
256
|
+
window.__quantumStateUpdate = function(name, value) {
|
|
257
|
+
if (previous) previous(name, value);
|
|
258
|
+
apply(name, value);
|
|
259
|
+
};
|
|
260
|
+
var state = window.__quantumState || {};
|
|
261
|
+
Object.keys(state).forEach(function(key) { apply(key, state[key]); });
|
|
262
|
+
});
|
|
263
|
+
})();
|
|
264
|
+
</script>
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def completed_step_indices(steps, current: int) -> List[int]:
|
|
269
|
+
"""Quais passos de um ui:stepper contam como concluidos.
|
|
270
|
+
|
|
271
|
+
Uma unica autoridade, de proposito. Esta regra vivia so aqui, e a ponte
|
|
272
|
+
do adapter desktop tinha a SUA versao — `list(range(current)) if linear
|
|
273
|
+
else []`. As duas discordavam de dois jeitos:
|
|
274
|
+
|
|
275
|
+
- `linear` nao tem nada a ver com isso. Num stepper nao-linear a ponte
|
|
276
|
+
emitia lista vazia e APAGAVA, no boot, as marcas que este arquivo tinha
|
|
277
|
+
desenhado corretamente. O usuario via o progresso sumir.
|
|
278
|
+
- `completed` e tri-state: nao setado (None), "true" e "false". A ponte
|
|
279
|
+
colapsava None e "false" no mesmo valor, entao um passo marcado
|
|
280
|
+
explicitamente como `completed="false"` antes do atual ganhava a marca
|
|
281
|
+
de volta.
|
|
282
|
+
|
|
283
|
+
A regra: o atributo explicito vence; sem atributo, um passo esta
|
|
284
|
+
concluido se esta atras do atual.
|
|
285
|
+
"""
|
|
286
|
+
indices: List[int] = []
|
|
287
|
+
for i, step in enumerate(steps):
|
|
288
|
+
explicito = getattr(step, 'completed', None)
|
|
289
|
+
if explicito is not None:
|
|
290
|
+
# Mesma lista de permissao de UIHtmlAdapter._as_bool. Uma lista de
|
|
291
|
+
# NEGACAO trataria `completed="talvez"` como concluido, o oposto
|
|
292
|
+
# do que este arquivo sempre fez.
|
|
293
|
+
if explicito is True or (
|
|
294
|
+
not isinstance(explicito, bool)
|
|
295
|
+
and str(explicito).strip().lower() in ('true', '1', 'yes', 'on')):
|
|
296
|
+
indices.append(i)
|
|
297
|
+
elif i < current:
|
|
298
|
+
indices.append(i)
|
|
299
|
+
return indices
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _is_checked(valor) -> bool:
|
|
304
|
+
"""UI-6: checked="true" (or an expression that resolves to a true value)."""
|
|
305
|
+
return str(valor).strip().lower() in ('true', '1', 'on', 'yes', 'checked') if valor is not None else False
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _option_attrs(valor: str, escolhido) -> dict:
|
|
309
|
+
attrs = {'value': valor}
|
|
310
|
+
if escolhido is not None and valor == str(escolhido).strip():
|
|
311
|
+
attrs['selected'] = True
|
|
312
|
+
return attrs
|
|
313
|
+
|
|
314
|
+
class UIHtmlAdapter:
|
|
315
|
+
"""Generates HTML/CSS from UI AST nodes."""
|
|
316
|
+
|
|
317
|
+
def __init__(self):
|
|
318
|
+
self._tab_counter = 0
|
|
319
|
+
self._has_tabs = False
|
|
320
|
+
self._has_validation = False # Track if we have forms with validation
|
|
321
|
+
self._has_animations = False # Track if we have animations
|
|
322
|
+
self._tokens = TokenConverter('html')
|
|
323
|
+
self._features_used: Set[str] = set() # Track features for compatibility
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# Form validation tracking
|
|
327
|
+
self._form_counter = 0
|
|
328
|
+
self._form_validators: Dict[str, List[UIValidatorNode]] = {} # form_id -> validators
|
|
329
|
+
|
|
330
|
+
# Animation tracking
|
|
331
|
+
self._animate_counter = 0
|
|
332
|
+
|
|
333
|
+
# Toast / carousel / stepper / calendar tracking
|
|
334
|
+
self._has_toast = False
|
|
335
|
+
self._has_carousel = False
|
|
336
|
+
self._has_stepper = False
|
|
337
|
+
self._has_calendar = False
|
|
338
|
+
self._has_date_picker = False
|
|
339
|
+
self._has_calendar_weeks = False
|
|
340
|
+
self._has_show_bindings = False
|
|
341
|
+
self._toast_counter = 0
|
|
342
|
+
self._toast_container_counter = 0
|
|
343
|
+
self._carousel_counter = 0
|
|
344
|
+
self._stepper_counter = 0
|
|
345
|
+
self._calendar_counter = 0
|
|
346
|
+
self._date_picker_counter = 0
|
|
347
|
+
|
|
348
|
+
# Inline init statements executed on DOMContentLoaded
|
|
349
|
+
self._component_inits: List[str] = []
|
|
350
|
+
# Extra CSS rules emitted for specific component instances
|
|
351
|
+
self._extra_css: List[str] = []
|
|
352
|
+
|
|
353
|
+
# Theme configuration
|
|
354
|
+
self._theme: Optional[UIThemeNode] = None
|
|
355
|
+
|
|
356
|
+
def generate(self, windows: List[QuantumNode], ui_children: List[QuantumNode],
|
|
357
|
+
title: str = "Quantum UI",
|
|
358
|
+
theme: Optional[UIThemeNode] = None) -> str:
|
|
359
|
+
"""Generate complete HTML page from UI AST.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
windows: List of UIWindowNode instances
|
|
363
|
+
ui_children: List of top-level UI nodes
|
|
364
|
+
title: Page title
|
|
365
|
+
theme: Optional UIThemeNode for theme configuration
|
|
366
|
+
"""
|
|
367
|
+
# Store theme for reference
|
|
368
|
+
self._theme = theme
|
|
369
|
+
|
|
370
|
+
body_builder = HtmlBuilder()
|
|
371
|
+
|
|
372
|
+
# Render windows
|
|
373
|
+
for window in windows:
|
|
374
|
+
self._render_node(window, body_builder)
|
|
375
|
+
|
|
376
|
+
# Render top-level children (outside windows)
|
|
377
|
+
for child in ui_children:
|
|
378
|
+
self._render_node(child, body_builder)
|
|
379
|
+
|
|
380
|
+
body_html = body_builder.build()
|
|
381
|
+
|
|
382
|
+
# Assemble CSS
|
|
383
|
+
css = CSS_RESET + '\n'
|
|
384
|
+
|
|
385
|
+
# Add theme CSS (replaces or augments CSS_THEME)
|
|
386
|
+
if HAS_THEMING and theme and get_theme_css:
|
|
387
|
+
preset = theme.preset or 'light'
|
|
388
|
+
overrides = theme.get_color_overrides() if hasattr(theme, 'get_color_overrides') else {}
|
|
389
|
+
auto_switch = getattr(theme, 'auto_switch', False)
|
|
390
|
+
theme_css = get_theme_css(preset, overrides, auto_switch)
|
|
391
|
+
css += theme_css + '\n'
|
|
392
|
+
else:
|
|
393
|
+
css += CSS_THEME + '\n'
|
|
394
|
+
|
|
395
|
+
css += RESPONSIVE_CSS + '\n'
|
|
396
|
+
if self._has_validation:
|
|
397
|
+
css += VALIDATION_CSS + '\n'
|
|
398
|
+
if self._has_animations:
|
|
399
|
+
css += CSS_ANIMATIONS + '\n'
|
|
400
|
+
if self._extra_css:
|
|
401
|
+
css += '\n'.join(self._extra_css) + '\n'
|
|
402
|
+
|
|
403
|
+
# Assemble JS
|
|
404
|
+
js = ''
|
|
405
|
+
|
|
406
|
+
# Add theme switching JS if we have a theme
|
|
407
|
+
if HAS_THEMING and theme and get_theme_switch_js:
|
|
408
|
+
js += get_theme_switch_js() + '\n'
|
|
409
|
+
|
|
410
|
+
if self._has_tabs:
|
|
411
|
+
js += TAB_JS + '\n'
|
|
412
|
+
if self._has_validation:
|
|
413
|
+
js += VALIDATION_JS + '\n'
|
|
414
|
+
# Add form-specific validators
|
|
415
|
+
js += self._generate_validators_script() + '\n'
|
|
416
|
+
|
|
417
|
+
# Add animation JS if needed
|
|
418
|
+
if self._has_animations:
|
|
419
|
+
js += ANIMATION_JS + '\n'
|
|
420
|
+
|
|
421
|
+
# Component runtimes (toast, carousel, stepper, calendar, date picker)
|
|
422
|
+
if self._has_toast:
|
|
423
|
+
js += self._emit_runtime(TOAST_JS) + '\n'
|
|
424
|
+
if self._has_carousel:
|
|
425
|
+
js += self._emit_runtime(CAROUSEL_JS) + '\n'
|
|
426
|
+
if self._has_stepper:
|
|
427
|
+
js += self._emit_runtime(STEPPER_JS) + '\n'
|
|
428
|
+
if self._has_calendar or self._has_date_picker:
|
|
429
|
+
js += self._emit_runtime(CALENDAR_JS) + '\n'
|
|
430
|
+
if self._has_calendar_weeks:
|
|
431
|
+
js += CALENDAR_WEEKS_JS + '\n'
|
|
432
|
+
if self._has_date_picker:
|
|
433
|
+
js += DATE_PICKER_JS + '\n'
|
|
434
|
+
if self._has_show_bindings:
|
|
435
|
+
js += SHOW_BINDING_JS + '\n'
|
|
436
|
+
if self._component_inits:
|
|
437
|
+
js += self._generate_component_init_script() + '\n'
|
|
438
|
+
|
|
439
|
+
return HTML_TEMPLATE.format(
|
|
440
|
+
title=title,
|
|
441
|
+
css=css,
|
|
442
|
+
body=body_html,
|
|
443
|
+
js=js,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def _generate_validators_script(self) -> str:
|
|
447
|
+
"""Generate JS to register custom validators for each form."""
|
|
448
|
+
if not self._form_validators:
|
|
449
|
+
return ''
|
|
450
|
+
|
|
451
|
+
lines = ['<script>', 'document.addEventListener("DOMContentLoaded", function() {']
|
|
452
|
+
|
|
453
|
+
for form_id, validators in self._form_validators.items():
|
|
454
|
+
for validator in validators:
|
|
455
|
+
validator_obj = self._validator_to_js_object(validator)
|
|
456
|
+
lines.append(f" __qValidation.registerValidator('{form_id}', {validator_obj});")
|
|
457
|
+
|
|
458
|
+
lines.append('});')
|
|
459
|
+
lines.append('</script>')
|
|
460
|
+
return '\n'.join(lines)
|
|
461
|
+
|
|
462
|
+
def _emit_runtime(self, source: str) -> str:
|
|
463
|
+
"""Emit a shared JS runtime from ui_html_templates.
|
|
464
|
+
|
|
465
|
+
Isto consertava o JS na SAIDA com um regex, porque TOAST_JS e
|
|
466
|
+
CALENDAR_JS viviam em strings triplas nao-raw: o Python comia as
|
|
467
|
+
barras que escapavam aspas dentro de literais JS aninhados, e a
|
|
468
|
+
pagina recebia `dismiss('' + id + '')` — erro de sintaxe que derrubava
|
|
469
|
+
o modulo inteiro no browser.
|
|
470
|
+
|
|
471
|
+
Consertar a saida deixava a causa de pe: o proximo runtime escrito do
|
|
472
|
+
mesmo jeito voltaria a quebrar, em silencio. As strings viraram raw na
|
|
473
|
+
fonte, entao aqui nao ha mais o que reparar.
|
|
474
|
+
"""
|
|
475
|
+
return source
|
|
476
|
+
|
|
477
|
+
def _generate_component_init_script(self) -> str:
|
|
478
|
+
"""Generate the DOMContentLoaded script that boots interactive components.
|
|
479
|
+
|
|
480
|
+
Every component gets its own listener: a failing user callback in one
|
|
481
|
+
component must not stop the remaining ones from being initialised.
|
|
482
|
+
"""
|
|
483
|
+
if not self._component_inits:
|
|
484
|
+
return ''
|
|
485
|
+
|
|
486
|
+
lines = ['<script>']
|
|
487
|
+
for snippet in self._component_inits:
|
|
488
|
+
lines.append('document.addEventListener("DOMContentLoaded", function() {')
|
|
489
|
+
for raw in snippet.split('\n'):
|
|
490
|
+
lines.append((' ' + raw) if raw else '')
|
|
491
|
+
lines.append('});')
|
|
492
|
+
lines.append('</script>')
|
|
493
|
+
return '\n'.join(lines)
|
|
494
|
+
|
|
495
|
+
def _validator_to_js_object(self, validator: UIValidatorNode) -> str:
|
|
496
|
+
"""Convert a UIValidatorNode to a JavaScript object literal."""
|
|
497
|
+
parts = []
|
|
498
|
+
parts.append(f"name: '{validator.name}'")
|
|
499
|
+
|
|
500
|
+
if validator.field:
|
|
501
|
+
parts.append(f"field: '{validator.field}'")
|
|
502
|
+
if validator.rule_type:
|
|
503
|
+
parts.append(f"type: '{validator.rule_type}'")
|
|
504
|
+
if validator.pattern:
|
|
505
|
+
# Escape special characters for JS string
|
|
506
|
+
escaped_pattern = validator.pattern.replace('\\', '\\\\').replace("'", "\\'")
|
|
507
|
+
parts.append(f"pattern: '{escaped_pattern}'")
|
|
508
|
+
if validator.match:
|
|
509
|
+
parts.append(f"match: '{validator.match}'")
|
|
510
|
+
if validator.min:
|
|
511
|
+
parts.append(f"min: '{validator.min}'")
|
|
512
|
+
if validator.max:
|
|
513
|
+
parts.append(f"max: '{validator.max}'")
|
|
514
|
+
if validator.minlength is not None:
|
|
515
|
+
parts.append(f"minlength: {validator.minlength}")
|
|
516
|
+
if validator.maxlength is not None:
|
|
517
|
+
parts.append(f"maxlength: {validator.maxlength}")
|
|
518
|
+
if validator.expression:
|
|
519
|
+
# Custom JS expression - wrap in function
|
|
520
|
+
parts.append(f"expression: function(value, form) {{ return {validator.expression}; }}")
|
|
521
|
+
if validator.message:
|
|
522
|
+
escaped_msg = validator.message.replace("'", "\\'")
|
|
523
|
+
parts.append(f"message: '{escaped_msg}'")
|
|
524
|
+
if validator.trigger:
|
|
525
|
+
parts.append(f"trigger: '{validator.trigger}'")
|
|
526
|
+
|
|
527
|
+
return '{' + ', '.join(parts) + '}'
|
|
528
|
+
|
|
529
|
+
# ------------------------------------------------------------------
|
|
530
|
+
# Layout style helper
|
|
531
|
+
# ------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
def _layout_style(self, node) -> str:
|
|
534
|
+
"""Build inline CSS from layout attributes."""
|
|
535
|
+
parts = []
|
|
536
|
+
if hasattr(node, 'gap') and node.gap:
|
|
537
|
+
self._features_used.add('gap')
|
|
538
|
+
parts.append(f"gap: {self._css_size(node.gap)}")
|
|
539
|
+
if hasattr(node, 'padding') and node.padding:
|
|
540
|
+
parts.append(f"padding: {self._css_size(node.padding)}")
|
|
541
|
+
if hasattr(node, 'margin') and node.margin:
|
|
542
|
+
parts.append(f"margin: {self._css_size(node.margin)}")
|
|
543
|
+
if hasattr(node, 'align') and node.align:
|
|
544
|
+
parts.append(f"align-items: {self._css_align(node.align)}")
|
|
545
|
+
if hasattr(node, 'justify') and node.justify:
|
|
546
|
+
if node.justify in ('between', 'around'):
|
|
547
|
+
self._features_used.add(f'justify_{node.justify}')
|
|
548
|
+
parts.append(f"justify-content: {self._css_justify(node.justify)}")
|
|
549
|
+
if hasattr(node, 'width') and node.width:
|
|
550
|
+
if node.width.isdigit() or node.width.endswith('px'):
|
|
551
|
+
self._features_used.add('pixel_units')
|
|
552
|
+
parts.append(f"width: {self._css_dimension(node.width)}")
|
|
553
|
+
if hasattr(node, 'height') and node.height:
|
|
554
|
+
if node.height.isdigit() or node.height.endswith('px'):
|
|
555
|
+
self._features_used.add('pixel_units')
|
|
556
|
+
parts.append(f"height: {self._css_dimension(node.height)}")
|
|
557
|
+
if hasattr(node, 'background') and node.background:
|
|
558
|
+
parts.append(f"background-color: {self._css_color(node.background)}")
|
|
559
|
+
if hasattr(node, 'color') and node.color:
|
|
560
|
+
parts.append(f"color: {self._css_color(node.color)}")
|
|
561
|
+
if hasattr(node, 'border') and node.border:
|
|
562
|
+
parts.append(f"border: {node.border}")
|
|
563
|
+
return '; '.join(parts)
|
|
564
|
+
|
|
565
|
+
def get_features_used(self) -> Set[str]:
|
|
566
|
+
"""Return set of features used during generation (for compatibility checking)."""
|
|
567
|
+
return self._features_used.copy()
|
|
568
|
+
|
|
569
|
+
def _layout_attrs(self, node) -> dict:
|
|
570
|
+
"""Build HTML attributes from layout properties."""
|
|
571
|
+
attrs = {}
|
|
572
|
+
style = self._layout_style(node)
|
|
573
|
+
if style:
|
|
574
|
+
attrs['style'] = style
|
|
575
|
+
if hasattr(node, 'ui_id') and node.ui_id:
|
|
576
|
+
attrs['id'] = node.ui_id
|
|
577
|
+
if hasattr(node, 'ui_class') and node.ui_class:
|
|
578
|
+
attrs['class'] = node.ui_class
|
|
579
|
+
# UI-2: responsive classes
|
|
580
|
+
responsivo = []
|
|
581
|
+
if getattr(node, 'stack_below', None):
|
|
582
|
+
responsivo.append(f'q-stack-{node.stack_below}')
|
|
583
|
+
if getattr(node, 'hide_below', None):
|
|
584
|
+
responsivo.append(f'q-hide-{node.hide_below}')
|
|
585
|
+
if getattr(node, 'hide_above', None):
|
|
586
|
+
responsivo.append(f'q-hide-above-{node.hide_above}')
|
|
587
|
+
if str(getattr(node, 'grow', '') or '').lower() == 'true':
|
|
588
|
+
responsivo.append('q-grow')
|
|
589
|
+
if responsivo:
|
|
590
|
+
attrs['class'] = ' '.join(filter(None, [attrs.get('class')] + responsivo))
|
|
591
|
+
if hasattr(node, 'visible') and node.visible == 'false':
|
|
592
|
+
existing = attrs.get('style', '')
|
|
593
|
+
attrs['style'] = (existing + '; ' if existing else '') + 'display: none'
|
|
594
|
+
return attrs
|
|
595
|
+
|
|
596
|
+
def _merge_attrs(self, base: dict, layout: dict) -> dict:
|
|
597
|
+
"""Merge class and style attrs."""
|
|
598
|
+
result = dict(base)
|
|
599
|
+
for k, v in layout.items():
|
|
600
|
+
if k == 'class' and k in result:
|
|
601
|
+
result[k] = result[k] + ' ' + v
|
|
602
|
+
elif k == 'style' and k in result:
|
|
603
|
+
result[k] = result[k] + '; ' + v
|
|
604
|
+
elif k == 'id' and k not in result:
|
|
605
|
+
result[k] = v
|
|
606
|
+
else:
|
|
607
|
+
result[k] = v
|
|
608
|
+
return result
|
|
609
|
+
|
|
610
|
+
# ------------------------------------------------------------------
|
|
611
|
+
# CSS value helpers (using TokenConverter for normalization)
|
|
612
|
+
# ------------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
def _css_size(self, val: str) -> str:
|
|
615
|
+
"""Convert spacing value using tokens system."""
|
|
616
|
+
return self._tokens.spacing(val)
|
|
617
|
+
|
|
618
|
+
def _css_dimension(self, val: str) -> str:
|
|
619
|
+
"""Convert size/dimension value using tokens system."""
|
|
620
|
+
return self._tokens.size(val)
|
|
621
|
+
|
|
622
|
+
def _css_align(self, val: str) -> str:
|
|
623
|
+
"""Convert align value using tokens system."""
|
|
624
|
+
return self._tokens.align(val)
|
|
625
|
+
|
|
626
|
+
def _css_justify(self, val: str) -> str:
|
|
627
|
+
"""Convert justify value using tokens system."""
|
|
628
|
+
return self._tokens.justify(val)
|
|
629
|
+
|
|
630
|
+
def _css_color(self, val: str) -> str:
|
|
631
|
+
"""Convert color value using tokens system."""
|
|
632
|
+
return self._tokens.color(val)
|
|
633
|
+
|
|
634
|
+
def _css_font_size(self, val: str) -> str:
|
|
635
|
+
"""Convert font size using tokens system."""
|
|
636
|
+
return self._tokens.font_size(val)
|
|
637
|
+
|
|
638
|
+
# ------------------------------------------------------------------
|
|
639
|
+
# Attribute / JS literal helpers
|
|
640
|
+
# ------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
def _as_int(self, value, default: int = 0) -> int:
|
|
643
|
+
"""Coerce an attribute to int (parsers may hand over raw strings)."""
|
|
644
|
+
if value is None:
|
|
645
|
+
return default
|
|
646
|
+
if isinstance(value, bool):
|
|
647
|
+
return int(value)
|
|
648
|
+
try:
|
|
649
|
+
return int(str(value).strip())
|
|
650
|
+
except (TypeError, ValueError):
|
|
651
|
+
return default
|
|
652
|
+
|
|
653
|
+
def _as_bool(self, value, default: bool = False) -> bool:
|
|
654
|
+
"""Coerce an attribute to bool (parsers may hand over raw strings)."""
|
|
655
|
+
if value is None:
|
|
656
|
+
return default
|
|
657
|
+
if isinstance(value, bool):
|
|
658
|
+
return value
|
|
659
|
+
return str(value).strip().lower() in ('true', '1', 'yes', 'on')
|
|
660
|
+
|
|
661
|
+
def _js_str(self, value) -> str:
|
|
662
|
+
"""Quote a value as a single-quoted JS string literal."""
|
|
663
|
+
text = '' if value is None else str(value)
|
|
664
|
+
text = (text.replace('\\', '\\\\')
|
|
665
|
+
.replace("'", "\\'")
|
|
666
|
+
.replace('\r', '')
|
|
667
|
+
.replace('\n', '\\n'))
|
|
668
|
+
return f"'{text}'"
|
|
669
|
+
|
|
670
|
+
def _js_bool(self, value) -> str:
|
|
671
|
+
return 'true' if value else 'false'
|
|
672
|
+
|
|
673
|
+
def _js_statement(self, handler: str, arg: str) -> str:
|
|
674
|
+
"""Turn an on-* handler attribute into a JS statement.
|
|
675
|
+
|
|
676
|
+
``on-change="doThing()"`` is used verbatim, ``on-change="doThing"``
|
|
677
|
+
is called with the component value.
|
|
678
|
+
"""
|
|
679
|
+
code = handler.strip()
|
|
680
|
+
if not code.endswith((')', '}', ';')):
|
|
681
|
+
code = f"{code}({arg})"
|
|
682
|
+
if not code.endswith(';'):
|
|
683
|
+
code += ';'
|
|
684
|
+
return code
|
|
685
|
+
|
|
686
|
+
def _js_change_handler(self, handler: Optional[str], bind: Optional[str],
|
|
687
|
+
arg: str = 'value') -> Optional[str]:
|
|
688
|
+
"""Build the JS callback used by interactive components.
|
|
689
|
+
|
|
690
|
+
Writes the new value back to the bound state variable (when ``bind``
|
|
691
|
+
is set) and then invokes the author's handler.
|
|
692
|
+
"""
|
|
693
|
+
statements: List[str] = []
|
|
694
|
+
if bind:
|
|
695
|
+
statements.append('window.__quantumState = window.__quantumState || {};')
|
|
696
|
+
statements.append(f"window.__quantumState[{self._js_str(bind)}] = {arg};")
|
|
697
|
+
statements.append(
|
|
698
|
+
f"if (window.__quantumShowBinding) window.__quantumShowBinding({self._js_str(bind)}, {arg});")
|
|
699
|
+
if handler:
|
|
700
|
+
statements.append(self._js_statement(handler, arg))
|
|
701
|
+
if not statements:
|
|
702
|
+
return None
|
|
703
|
+
return 'function(' + arg + ') { ' + ' '.join(statements) + ' }'
|
|
704
|
+
|
|
705
|
+
def _js_options(self, options: Dict[str, str]) -> str:
|
|
706
|
+
"""Render an ordered dict of already-encoded JS values as an object literal."""
|
|
707
|
+
parts = [f"{k}: {v}" for k, v in options.items() if v is not None]
|
|
708
|
+
return '{' + ', '.join(parts) + '}'
|
|
709
|
+
|
|
710
|
+
def _parse_iso_date(self, value) -> Optional[_date]:
|
|
711
|
+
"""Parse an ISO (YYYY-MM-DD) date, returning None when unusable."""
|
|
712
|
+
if not value:
|
|
713
|
+
return None
|
|
714
|
+
try:
|
|
715
|
+
return _date.fromisoformat(str(value).strip()[:10])
|
|
716
|
+
except (TypeError, ValueError):
|
|
717
|
+
return None
|
|
718
|
+
|
|
719
|
+
def _format_date(self, value: _date, pattern: str) -> str:
|
|
720
|
+
"""Format a date with the picker's display pattern (mirrors DATE_PICKER_JS)."""
|
|
721
|
+
iso = value.isoformat()
|
|
722
|
+
year, month, day = iso.split('-')
|
|
723
|
+
return (pattern or 'YYYY-MM-DD') \
|
|
724
|
+
.replace('YYYY', year) \
|
|
725
|
+
.replace('YY', year[2:]) \
|
|
726
|
+
.replace('MM', month) \
|
|
727
|
+
.replace('DD', day)
|
|
728
|
+
|
|
729
|
+
# ------------------------------------------------------------------
|
|
730
|
+
# Node rendering dispatch
|
|
731
|
+
# ------------------------------------------------------------------
|
|
732
|
+
|
|
733
|
+
def _render_node(self, node: QuantumNode, b: HtmlBuilder):
|
|
734
|
+
"""Dispatch node to its render method."""
|
|
735
|
+
# Containers
|
|
736
|
+
if isinstance(node, UIWindowNode):
|
|
737
|
+
self._render_window(node, b)
|
|
738
|
+
elif isinstance(node, UIHBoxNode):
|
|
739
|
+
self._render_hbox(node, b)
|
|
740
|
+
elif isinstance(node, UIVBoxNode):
|
|
741
|
+
self._render_vbox(node, b)
|
|
742
|
+
elif isinstance(node, UIPanelNode):
|
|
743
|
+
self._render_panel(node, b)
|
|
744
|
+
elif isinstance(node, UITabPanelNode):
|
|
745
|
+
self._render_tabpanel(node, b)
|
|
746
|
+
elif isinstance(node, UITabNode):
|
|
747
|
+
self._render_tab(node, b)
|
|
748
|
+
elif isinstance(node, UIGridNode):
|
|
749
|
+
self._render_grid(node, b)
|
|
750
|
+
elif isinstance(node, UIAccordionNode):
|
|
751
|
+
self._render_accordion(node, b)
|
|
752
|
+
elif isinstance(node, UISectionNode):
|
|
753
|
+
self._render_section(node, b)
|
|
754
|
+
elif isinstance(node, UIDividedBoxNode):
|
|
755
|
+
self._render_dividedbox(node, b)
|
|
756
|
+
elif isinstance(node, UIFormNode):
|
|
757
|
+
self._render_form(node, b)
|
|
758
|
+
elif isinstance(node, UIFormItemNode):
|
|
759
|
+
self._render_formitem(node, b)
|
|
760
|
+
elif isinstance(node, UISpacerNode):
|
|
761
|
+
self._render_spacer(node, b)
|
|
762
|
+
elif isinstance(node, UIScrollBoxNode):
|
|
763
|
+
self._render_scrollbox(node, b)
|
|
764
|
+
elif isinstance(node, UIAnimateNode):
|
|
765
|
+
self._render_animate(node, b)
|
|
766
|
+
# Widgets
|
|
767
|
+
elif isinstance(node, UITextNode):
|
|
768
|
+
self._render_text(node, b)
|
|
769
|
+
elif isinstance(node, UIButtonNode):
|
|
770
|
+
self._render_button(node, b)
|
|
771
|
+
elif isinstance(node, UIInputNode):
|
|
772
|
+
self._render_input(node, b)
|
|
773
|
+
elif isinstance(node, UICheckboxNode):
|
|
774
|
+
self._render_checkbox(node, b)
|
|
775
|
+
elif isinstance(node, UIRadioNode):
|
|
776
|
+
self._render_radio(node, b)
|
|
777
|
+
elif isinstance(node, UISwitchNode):
|
|
778
|
+
self._render_switch(node, b)
|
|
779
|
+
elif isinstance(node, UISelectNode):
|
|
780
|
+
self._render_select(node, b)
|
|
781
|
+
elif isinstance(node, UITableNode):
|
|
782
|
+
self._render_table(node, b)
|
|
783
|
+
elif isinstance(node, UIListNode):
|
|
784
|
+
self._render_list(node, b)
|
|
785
|
+
elif isinstance(node, UIItemNode):
|
|
786
|
+
self._render_item(node, b)
|
|
787
|
+
elif isinstance(node, UIImageNode):
|
|
788
|
+
self._render_image(node, b)
|
|
789
|
+
elif isinstance(node, UILinkNode):
|
|
790
|
+
self._render_link(node, b)
|
|
791
|
+
elif isinstance(node, UIProgressNode):
|
|
792
|
+
self._render_progress(node, b)
|
|
793
|
+
elif isinstance(node, UITreeNode):
|
|
794
|
+
self._render_tree(node, b)
|
|
795
|
+
elif isinstance(node, UIMenuNode):
|
|
796
|
+
self._render_menu(node, b)
|
|
797
|
+
elif isinstance(node, UIOptionNode):
|
|
798
|
+
self._render_option(node, b)
|
|
799
|
+
elif isinstance(node, UILogNode):
|
|
800
|
+
self._render_log(node, b)
|
|
801
|
+
elif isinstance(node, UIMarkdownNode):
|
|
802
|
+
self._render_markdown(node, b)
|
|
803
|
+
elif isinstance(node, UIHeaderNode):
|
|
804
|
+
self._render_header(node, b)
|
|
805
|
+
elif isinstance(node, UIFooterNode):
|
|
806
|
+
self._render_footer(node, b)
|
|
807
|
+
elif isinstance(node, UIRuleNode):
|
|
808
|
+
self._render_rule(node, b)
|
|
809
|
+
elif isinstance(node, UILoadingNode):
|
|
810
|
+
self._render_loading(node, b)
|
|
811
|
+
elif isinstance(node, UIBadgeNode):
|
|
812
|
+
self._render_badge(node, b)
|
|
813
|
+
# Component Library
|
|
814
|
+
elif isinstance(node, UICardNode):
|
|
815
|
+
self._render_card(node, b)
|
|
816
|
+
elif isinstance(node, UICardHeaderNode):
|
|
817
|
+
self._render_card_header(node, b)
|
|
818
|
+
elif isinstance(node, UICardBodyNode):
|
|
819
|
+
self._render_card_body(node, b)
|
|
820
|
+
elif isinstance(node, UICardFooterNode):
|
|
821
|
+
self._render_card_footer(node, b)
|
|
822
|
+
elif isinstance(node, UIModalNode):
|
|
823
|
+
self._render_modal(node, b)
|
|
824
|
+
elif isinstance(node, UIChartNode):
|
|
825
|
+
self._render_chart(node, b)
|
|
826
|
+
elif isinstance(node, UIAvatarNode):
|
|
827
|
+
self._render_avatar(node, b)
|
|
828
|
+
elif isinstance(node, UITooltipNode):
|
|
829
|
+
self._render_tooltip(node, b)
|
|
830
|
+
elif isinstance(node, UIDropdownNode):
|
|
831
|
+
self._render_dropdown(node, b)
|
|
832
|
+
elif isinstance(node, UIAlertNode):
|
|
833
|
+
self._render_alert(node, b)
|
|
834
|
+
elif isinstance(node, UIBreadcrumbNode):
|
|
835
|
+
self._render_breadcrumb(node, b)
|
|
836
|
+
elif isinstance(node, UIBreadcrumbItemNode):
|
|
837
|
+
self._render_breadcrumb_item(node, b)
|
|
838
|
+
elif isinstance(node, UIPaginationNode):
|
|
839
|
+
self._render_pagination(node, b)
|
|
840
|
+
elif isinstance(node, UISkeletonNode):
|
|
841
|
+
self._render_skeleton(node, b)
|
|
842
|
+
# Toast / carousel / stepper / calendar
|
|
843
|
+
elif isinstance(node, UIToastNode):
|
|
844
|
+
self._render_toast(node, b)
|
|
845
|
+
elif isinstance(node, UIToastContainerNode):
|
|
846
|
+
self._render_toast_container(node, b)
|
|
847
|
+
elif isinstance(node, UICarouselNode):
|
|
848
|
+
self._render_carousel(node, b)
|
|
849
|
+
elif isinstance(node, UISlideNode):
|
|
850
|
+
self._render_slide(node, b)
|
|
851
|
+
elif isinstance(node, UIStepperNode):
|
|
852
|
+
self._render_stepper(node, b)
|
|
853
|
+
elif isinstance(node, UIStepNode):
|
|
854
|
+
self._render_step(node, b)
|
|
855
|
+
elif isinstance(node, UICalendarNode):
|
|
856
|
+
self._render_calendar(node, b)
|
|
857
|
+
elif isinstance(node, UIDatePickerNode):
|
|
858
|
+
self._render_date_picker(node, b)
|
|
859
|
+
# Quantum nodes passthrough
|
|
860
|
+
elif isinstance(node, SetNode):
|
|
861
|
+
b.comment(f'q:set {node.name} = {node.value}')
|
|
862
|
+
elif isinstance(node, LoopNode):
|
|
863
|
+
b.comment(f'q:loop {node.var_name}')
|
|
864
|
+
elif isinstance(node, IfNode):
|
|
865
|
+
b.comment(f'q:if {node.condition}')
|
|
866
|
+
# UIColumnNode is handled inside table render
|
|
867
|
+
|
|
868
|
+
def _render_children(self, children: list, b: HtmlBuilder):
|
|
869
|
+
for child in children:
|
|
870
|
+
self._render_node(child, b)
|
|
871
|
+
|
|
872
|
+
# ------------------------------------------------------------------
|
|
873
|
+
# Container renders
|
|
874
|
+
# ------------------------------------------------------------------
|
|
875
|
+
|
|
876
|
+
def _render_window(self, node: UIWindowNode, b: HtmlBuilder):
|
|
877
|
+
attrs = self._merge_attrs({'class': 'q-window'}, self._layout_attrs(node))
|
|
878
|
+
b.open_tag('div', attrs)
|
|
879
|
+
b.indent()
|
|
880
|
+
self._render_children(node.children, b)
|
|
881
|
+
b.dedent()
|
|
882
|
+
b.close_tag('div')
|
|
883
|
+
|
|
884
|
+
def _render_hbox(self, node: UIHBoxNode, b: HtmlBuilder):
|
|
885
|
+
base_style = 'display: flex; flex-direction: row'
|
|
886
|
+
attrs = self._merge_attrs({'style': base_style}, self._layout_attrs(node))
|
|
887
|
+
b.open_tag('div', attrs)
|
|
888
|
+
b.indent()
|
|
889
|
+
self._render_children(node.children, b)
|
|
890
|
+
b.dedent()
|
|
891
|
+
b.close_tag('div')
|
|
892
|
+
|
|
893
|
+
def _render_vbox(self, node: UIVBoxNode, b: HtmlBuilder):
|
|
894
|
+
base_style = 'display: flex; flex-direction: column'
|
|
895
|
+
attrs = self._merge_attrs({'style': base_style}, self._layout_attrs(node))
|
|
896
|
+
b.open_tag('div', attrs)
|
|
897
|
+
b.indent()
|
|
898
|
+
self._render_children(node.children, b)
|
|
899
|
+
b.dedent()
|
|
900
|
+
b.close_tag('div')
|
|
901
|
+
|
|
902
|
+
def _render_panel(self, node: UIPanelNode, b: HtmlBuilder):
|
|
903
|
+
attrs = self._merge_attrs({'class': 'q-panel'}, self._layout_attrs(node))
|
|
904
|
+
b.open_tag('div', attrs)
|
|
905
|
+
b.indent()
|
|
906
|
+
if node.title:
|
|
907
|
+
b.open_tag('div', {'class': 'q-panel-title'})
|
|
908
|
+
b.indent()
|
|
909
|
+
b.text(node.title)
|
|
910
|
+
b.dedent()
|
|
911
|
+
b.close_tag('div')
|
|
912
|
+
self._render_children(node.children, b)
|
|
913
|
+
b.dedent()
|
|
914
|
+
b.close_tag('div')
|
|
915
|
+
|
|
916
|
+
def _render_tabpanel(self, node: UITabPanelNode, b: HtmlBuilder):
|
|
917
|
+
self._has_tabs = True
|
|
918
|
+
self._tab_counter += 1
|
|
919
|
+
group_id = f"tabs-{self._tab_counter}"
|
|
920
|
+
|
|
921
|
+
attrs = self._merge_attrs({'class': 'q-tabs'}, self._layout_attrs(node))
|
|
922
|
+
b.open_tag('div', attrs)
|
|
923
|
+
b.indent()
|
|
924
|
+
|
|
925
|
+
# Tab headers
|
|
926
|
+
b.open_tag('div', {'class': 'q-tab-headers'})
|
|
927
|
+
b.indent()
|
|
928
|
+
tabs = [c for c in node.children if isinstance(c, UITabNode)]
|
|
929
|
+
for i, tab in enumerate(tabs):
|
|
930
|
+
tab_id = f"{group_id}-tab-{i}"
|
|
931
|
+
cls = 'q-tab-header active' if i == 0 else 'q-tab-header'
|
|
932
|
+
b.open_tag('button', {'class': cls, 'data-tab': tab_id, 'data-tab-group': group_id})
|
|
933
|
+
b.indent()
|
|
934
|
+
b.text(tab.title)
|
|
935
|
+
b.dedent()
|
|
936
|
+
b.close_tag('button')
|
|
937
|
+
b.dedent()
|
|
938
|
+
b.close_tag('div')
|
|
939
|
+
|
|
940
|
+
# Tab contents
|
|
941
|
+
for i, tab in enumerate(tabs):
|
|
942
|
+
tab_id = f"{group_id}-tab-{i}"
|
|
943
|
+
cls = 'q-tab-content active' if i == 0 else 'q-tab-content'
|
|
944
|
+
b.open_tag('div', {'id': tab_id, 'class': cls, 'data-tab-group': group_id})
|
|
945
|
+
b.indent()
|
|
946
|
+
self._render_children(tab.children, b)
|
|
947
|
+
b.dedent()
|
|
948
|
+
b.close_tag('div')
|
|
949
|
+
|
|
950
|
+
b.dedent()
|
|
951
|
+
b.close_tag('div')
|
|
952
|
+
|
|
953
|
+
def _render_tab(self, node: UITabNode, b: HtmlBuilder):
|
|
954
|
+
# Tabs are rendered inside tabpanel, standalone tab renders as div
|
|
955
|
+
attrs = self._merge_attrs({}, self._layout_attrs(node))
|
|
956
|
+
b.open_tag('div', attrs)
|
|
957
|
+
b.indent()
|
|
958
|
+
self._render_children(node.children, b)
|
|
959
|
+
b.dedent()
|
|
960
|
+
b.close_tag('div')
|
|
961
|
+
|
|
962
|
+
def _render_grid(self, node: UIGridNode, b: HtmlBuilder):
|
|
963
|
+
cols = (node.columns or '1').strip()
|
|
964
|
+
base = {'style': 'display: grid'}
|
|
965
|
+
if ':' in cols or ' ' in cols:
|
|
966
|
+
# UI-2: "1 sm:2 lg:3" — 1 column, 2 from sm, 3 from lg (widest first wins)
|
|
967
|
+
classes = []
|
|
968
|
+
for parte in cols.split():
|
|
969
|
+
faixa, _, n = parte.rpartition(':')
|
|
970
|
+
if not n.isdigit() or int(n) not in range(1, 7) or faixa not in ('', 'sm', 'md', 'lg'):
|
|
971
|
+
raise ValueError(f'<ui:grid columns="{cols}">: each part is N or sm:N / md:N / lg:N, N from 1 to 6')
|
|
972
|
+
classes.append(f'q-{faixa}-cols-{n}' if faixa else f'q-cols-{n}')
|
|
973
|
+
base['class'] = ' '.join(classes)
|
|
974
|
+
else:
|
|
975
|
+
try:
|
|
976
|
+
n = int(cols)
|
|
977
|
+
base['style'] = f"display: grid; grid-template-columns: repeat({n}, 1fr)"
|
|
978
|
+
except ValueError:
|
|
979
|
+
base['style'] = f"display: grid; grid-template-columns: {cols}"
|
|
980
|
+
attrs = self._merge_attrs(base, self._layout_attrs(node))
|
|
981
|
+
b.open_tag('div', attrs)
|
|
982
|
+
b.indent()
|
|
983
|
+
self._render_children(node.children, b)
|
|
984
|
+
b.dedent()
|
|
985
|
+
b.close_tag('div')
|
|
986
|
+
|
|
987
|
+
def _render_accordion(self, node: UIAccordionNode, b: HtmlBuilder):
|
|
988
|
+
attrs = self._merge_attrs({}, self._layout_attrs(node))
|
|
989
|
+
b.open_tag('div', attrs)
|
|
990
|
+
b.indent()
|
|
991
|
+
self._render_children(node.children, b)
|
|
992
|
+
b.dedent()
|
|
993
|
+
b.close_tag('div')
|
|
994
|
+
|
|
995
|
+
def _render_section(self, node: UISectionNode, b: HtmlBuilder):
|
|
996
|
+
detail_attrs = {'class': 'q-section'}
|
|
997
|
+
if node.expanded:
|
|
998
|
+
detail_attrs['open'] = True
|
|
999
|
+
attrs = self._merge_attrs(detail_attrs, self._layout_attrs(node))
|
|
1000
|
+
b.open_tag('details', attrs)
|
|
1001
|
+
b.indent()
|
|
1002
|
+
b.open_tag('summary')
|
|
1003
|
+
b.indent()
|
|
1004
|
+
b.text(node.title)
|
|
1005
|
+
b.dedent()
|
|
1006
|
+
b.close_tag('summary')
|
|
1007
|
+
b.open_tag('div', {'class': 'q-section-content'})
|
|
1008
|
+
b.indent()
|
|
1009
|
+
self._render_children(node.children, b)
|
|
1010
|
+
b.dedent()
|
|
1011
|
+
b.close_tag('div')
|
|
1012
|
+
b.dedent()
|
|
1013
|
+
b.close_tag('details')
|
|
1014
|
+
|
|
1015
|
+
def _render_dividedbox(self, node: UIDividedBoxNode, b: HtmlBuilder):
|
|
1016
|
+
dir_class = 'q-dividedbox-h' if node.direction == 'horizontal' else 'q-dividedbox-v'
|
|
1017
|
+
attrs = self._merge_attrs({'class': f'q-dividedbox {dir_class}'}, self._layout_attrs(node))
|
|
1018
|
+
b.open_tag('div', attrs)
|
|
1019
|
+
b.indent()
|
|
1020
|
+
for i, child in enumerate(node.children):
|
|
1021
|
+
if i > 0:
|
|
1022
|
+
b.open_tag('div', {'class': 'q-divider'}, self_closing=False)
|
|
1023
|
+
b.close_tag('div')
|
|
1024
|
+
self._render_node(child, b)
|
|
1025
|
+
b.dedent()
|
|
1026
|
+
b.close_tag('div')
|
|
1027
|
+
|
|
1028
|
+
def _render_form(self, node: UIFormNode, b: HtmlBuilder):
|
|
1029
|
+
# Track form for validation
|
|
1030
|
+
self._form_counter += 1
|
|
1031
|
+
form_id = f"q-form-{self._form_counter}"
|
|
1032
|
+
|
|
1033
|
+
form_attrs = {'class': 'q-form', 'id': form_id}
|
|
1034
|
+
|
|
1035
|
+
# Validation mode attributes
|
|
1036
|
+
if node.validation_mode in ('client', 'both'):
|
|
1037
|
+
self._has_validation = True
|
|
1038
|
+
form_attrs['data-validation'] = node.validation_mode
|
|
1039
|
+
form_attrs['data-error-display'] = node.error_display
|
|
1040
|
+
|
|
1041
|
+
# HTML5 novalidate attribute (use our custom JS validation instead)
|
|
1042
|
+
if node.novalidate or node.validation_mode in ('client', 'both'):
|
|
1043
|
+
form_attrs['novalidate'] = True
|
|
1044
|
+
|
|
1045
|
+
if node.on_submit:
|
|
1046
|
+
# Add validation check before submit
|
|
1047
|
+
if node.validation_mode in ('client', 'both'):
|
|
1048
|
+
form_attrs['onsubmit'] = f"return __qValidation.validateForm('{form_id}') && ({node.on_submit})"
|
|
1049
|
+
else:
|
|
1050
|
+
form_attrs['onsubmit'] = node.on_submit
|
|
1051
|
+
|
|
1052
|
+
attrs = self._merge_attrs(form_attrs, self._layout_attrs(node))
|
|
1053
|
+
b.open_tag('form', attrs)
|
|
1054
|
+
b.indent()
|
|
1055
|
+
|
|
1056
|
+
# Register custom validators for this form
|
|
1057
|
+
if node.validators:
|
|
1058
|
+
self._form_validators[form_id] = node.validators
|
|
1059
|
+
|
|
1060
|
+
# Render error summary if configured
|
|
1061
|
+
if node.error_display in ('summary', 'both'):
|
|
1062
|
+
b.open_tag('div', {'class': 'q-validation-summary', 'id': f'{form_id}-summary', 'style': 'display:none'})
|
|
1063
|
+
b.close_tag('div')
|
|
1064
|
+
|
|
1065
|
+
self._render_children(node.children, b)
|
|
1066
|
+
b.dedent()
|
|
1067
|
+
b.close_tag('form')
|
|
1068
|
+
|
|
1069
|
+
def _render_formitem(self, node: UIFormItemNode, b: HtmlBuilder):
|
|
1070
|
+
attrs = self._merge_attrs({'class': 'q-formitem'}, self._layout_attrs(node))
|
|
1071
|
+
b.open_tag('div', attrs)
|
|
1072
|
+
b.indent()
|
|
1073
|
+
if node.label:
|
|
1074
|
+
b.open_tag('label', {'class': 'q-formitem-label'})
|
|
1075
|
+
b.indent()
|
|
1076
|
+
b.text(node.label)
|
|
1077
|
+
b.dedent()
|
|
1078
|
+
b.close_tag('label')
|
|
1079
|
+
self._render_children(node.children, b)
|
|
1080
|
+
b.dedent()
|
|
1081
|
+
b.close_tag('div')
|
|
1082
|
+
|
|
1083
|
+
def _render_spacer(self, node: UISpacerNode, b: HtmlBuilder):
|
|
1084
|
+
style = 'flex: 1'
|
|
1085
|
+
if node.size:
|
|
1086
|
+
style = f"flex: 0 0 {self._css_size(node.size)}"
|
|
1087
|
+
attrs = self._merge_attrs({'class': 'q-spacer', 'style': style}, self._layout_attrs(node))
|
|
1088
|
+
b.open_tag('div', attrs, self_closing=False)
|
|
1089
|
+
b.close_tag('div')
|
|
1090
|
+
|
|
1091
|
+
def _render_scrollbox(self, node: UIScrollBoxNode, b: HtmlBuilder):
|
|
1092
|
+
attrs = self._merge_attrs({'class': 'q-scrollbox'}, self._layout_attrs(node))
|
|
1093
|
+
b.open_tag('div', attrs)
|
|
1094
|
+
b.indent()
|
|
1095
|
+
self._render_children(node.children, b)
|
|
1096
|
+
b.dedent()
|
|
1097
|
+
b.close_tag('div')
|
|
1098
|
+
|
|
1099
|
+
# ------------------------------------------------------------------
|
|
1100
|
+
# Widget renders
|
|
1101
|
+
# ------------------------------------------------------------------
|
|
1102
|
+
|
|
1103
|
+
def _render_text(self, node: UITextNode, b: HtmlBuilder):
|
|
1104
|
+
style_parts = []
|
|
1105
|
+
if node.size:
|
|
1106
|
+
self._features_used.add('font_size')
|
|
1107
|
+
style_parts.append(f"font-size: {self._css_font_size(node.size)}")
|
|
1108
|
+
if node.weight:
|
|
1109
|
+
style_parts.append(f"font-weight: {node.weight}")
|
|
1110
|
+
base_attrs = {}
|
|
1111
|
+
if style_parts:
|
|
1112
|
+
base_attrs['style'] = '; '.join(style_parts)
|
|
1113
|
+
attrs = self._merge_attrs(base_attrs, self._layout_attrs(node))
|
|
1114
|
+
b.open_tag('span', attrs)
|
|
1115
|
+
b.indent()
|
|
1116
|
+
content = node.content
|
|
1117
|
+
b.text(content)
|
|
1118
|
+
b.dedent()
|
|
1119
|
+
b.close_tag('span')
|
|
1120
|
+
|
|
1121
|
+
def _render_button(self, node: UIButtonNode, b: HtmlBuilder):
|
|
1122
|
+
cls = 'q-btn'
|
|
1123
|
+
if node.variant:
|
|
1124
|
+
cls += f' q-btn-{node.variant}'
|
|
1125
|
+
btn_attrs = {'class': cls}
|
|
1126
|
+
if node.disabled:
|
|
1127
|
+
btn_attrs['disabled'] = True
|
|
1128
|
+
if node.on_click:
|
|
1129
|
+
btn_attrs['onclick'] = node.on_click
|
|
1130
|
+
attrs = self._merge_attrs(btn_attrs, self._layout_attrs(node))
|
|
1131
|
+
b.open_tag('button', attrs)
|
|
1132
|
+
b.indent()
|
|
1133
|
+
# Render content with binding support
|
|
1134
|
+
content = node.content
|
|
1135
|
+
b.text(content)
|
|
1136
|
+
b.dedent()
|
|
1137
|
+
b.close_tag('button')
|
|
1138
|
+
|
|
1139
|
+
def _render_input(self, node: UIInputNode, b: HtmlBuilder):
|
|
1140
|
+
input_attrs = {'class': 'q-input', 'type': node.input_type}
|
|
1141
|
+
if getattr(node, 'value', None) not in (None, ''):
|
|
1142
|
+
input_attrs['value'] = node.value
|
|
1143
|
+
if node.placeholder:
|
|
1144
|
+
input_attrs['placeholder'] = node.placeholder
|
|
1145
|
+
if node.bind:
|
|
1146
|
+
input_attrs['name'] = node.bind
|
|
1147
|
+
|
|
1148
|
+
# HTML5 validation attributes
|
|
1149
|
+
if node.required:
|
|
1150
|
+
input_attrs['required'] = True
|
|
1151
|
+
if node.min is not None:
|
|
1152
|
+
input_attrs['min'] = node.min
|
|
1153
|
+
if node.max is not None:
|
|
1154
|
+
input_attrs['max'] = node.max
|
|
1155
|
+
if node.minlength is not None:
|
|
1156
|
+
input_attrs['minlength'] = str(node.minlength)
|
|
1157
|
+
if node.maxlength is not None:
|
|
1158
|
+
input_attrs['maxlength'] = str(node.maxlength)
|
|
1159
|
+
if node.pattern:
|
|
1160
|
+
input_attrs['pattern'] = node.pattern
|
|
1161
|
+
|
|
1162
|
+
# Custom error message for JS validation
|
|
1163
|
+
if node.error_message:
|
|
1164
|
+
input_attrs['data-error-message'] = node.error_message
|
|
1165
|
+
|
|
1166
|
+
# Custom validators (comma-separated list)
|
|
1167
|
+
if node.validators:
|
|
1168
|
+
input_attrs['data-validators'] = ','.join(node.validators)
|
|
1169
|
+
|
|
1170
|
+
# Check if input has any validation
|
|
1171
|
+
has_validation = (node.error_message or node.required or node.validators or
|
|
1172
|
+
node.pattern or node.min or node.max or
|
|
1173
|
+
node.minlength or node.maxlength)
|
|
1174
|
+
|
|
1175
|
+
if has_validation:
|
|
1176
|
+
self._has_validation = True
|
|
1177
|
+
input_id = getattr(node, 'ui_id', None) or node.bind or f"input-{id(node)}"
|
|
1178
|
+
input_attrs['id'] = input_id
|
|
1179
|
+
attrs = self._merge_attrs(input_attrs, self._layout_attrs(node))
|
|
1180
|
+
|
|
1181
|
+
# Create wrapper for input + error message
|
|
1182
|
+
b.open_tag('div', {'class': 'q-input-wrapper'})
|
|
1183
|
+
b.indent()
|
|
1184
|
+
b.open_tag('input', attrs, self_closing=True)
|
|
1185
|
+
b.open_tag('div', {'class': 'q-error-message', 'id': f'{input_id}-error', 'style': 'display:none'})
|
|
1186
|
+
if node.error_message:
|
|
1187
|
+
b.text(node.error_message)
|
|
1188
|
+
b.close_tag('div')
|
|
1189
|
+
b.dedent()
|
|
1190
|
+
b.close_tag('div')
|
|
1191
|
+
else:
|
|
1192
|
+
attrs = self._merge_attrs(input_attrs, self._layout_attrs(node))
|
|
1193
|
+
b.open_tag('input', attrs, self_closing=True)
|
|
1194
|
+
|
|
1195
|
+
def _render_checkbox(self, node: UICheckboxNode, b: HtmlBuilder):
|
|
1196
|
+
attrs = self._merge_attrs({'class': 'q-checkbox'}, self._layout_attrs(node))
|
|
1197
|
+
b.open_tag('label', attrs)
|
|
1198
|
+
b.indent()
|
|
1199
|
+
cb_attrs = {'type': 'checkbox'}
|
|
1200
|
+
if node.bind:
|
|
1201
|
+
cb_attrs['name'] = node.bind
|
|
1202
|
+
if _is_checked(getattr(node, 'checked', None)):
|
|
1203
|
+
cb_attrs['checked'] = True
|
|
1204
|
+
b.open_tag('input', cb_attrs, self_closing=True)
|
|
1205
|
+
if node.label:
|
|
1206
|
+
b.text(node.label)
|
|
1207
|
+
b.dedent()
|
|
1208
|
+
b.close_tag('label')
|
|
1209
|
+
|
|
1210
|
+
def _render_radio(self, node: UIRadioNode, b: HtmlBuilder):
|
|
1211
|
+
attrs = self._merge_attrs({}, self._layout_attrs(node))
|
|
1212
|
+
b.open_tag('div', attrs)
|
|
1213
|
+
b.indent()
|
|
1214
|
+
if node.options:
|
|
1215
|
+
for opt in node.options.split(','):
|
|
1216
|
+
opt = opt.strip()
|
|
1217
|
+
b.open_tag('label', {'class': 'q-checkbox'})
|
|
1218
|
+
b.indent()
|
|
1219
|
+
radio_attrs = {'type': 'radio', 'value': opt}
|
|
1220
|
+
if node.bind:
|
|
1221
|
+
radio_attrs['name'] = node.bind
|
|
1222
|
+
if getattr(node, 'value', None) is not None and opt == str(node.value).strip():
|
|
1223
|
+
radio_attrs['checked'] = True
|
|
1224
|
+
b.open_tag('input', radio_attrs, self_closing=True)
|
|
1225
|
+
b.text(opt)
|
|
1226
|
+
b.dedent()
|
|
1227
|
+
b.close_tag('label')
|
|
1228
|
+
b.dedent()
|
|
1229
|
+
b.close_tag('div')
|
|
1230
|
+
|
|
1231
|
+
def _render_switch(self, node: UISwitchNode, b: HtmlBuilder):
|
|
1232
|
+
attrs = self._merge_attrs({'class': 'q-switch'}, self._layout_attrs(node))
|
|
1233
|
+
b.open_tag('label', attrs)
|
|
1234
|
+
b.indent()
|
|
1235
|
+
cb_attrs = {'type': 'checkbox'}
|
|
1236
|
+
if node.bind:
|
|
1237
|
+
cb_attrs['name'] = node.bind
|
|
1238
|
+
if _is_checked(getattr(node, 'checked', None)):
|
|
1239
|
+
cb_attrs['checked'] = True
|
|
1240
|
+
b.open_tag('input', cb_attrs, self_closing=True)
|
|
1241
|
+
b.open_tag('span', {'class': 'q-switch-track'})
|
|
1242
|
+
b.indent()
|
|
1243
|
+
b.open_tag('span', {'class': 'q-switch-thumb'}, self_closing=False)
|
|
1244
|
+
b.close_tag('span')
|
|
1245
|
+
b.dedent()
|
|
1246
|
+
b.close_tag('span')
|
|
1247
|
+
if node.label:
|
|
1248
|
+
b.text(node.label)
|
|
1249
|
+
b.dedent()
|
|
1250
|
+
b.close_tag('label')
|
|
1251
|
+
|
|
1252
|
+
def _render_select(self, node: UISelectNode, b: HtmlBuilder):
|
|
1253
|
+
select_attrs = {'class': 'q-select'}
|
|
1254
|
+
if node.bind:
|
|
1255
|
+
select_attrs['name'] = node.bind
|
|
1256
|
+
attrs = self._merge_attrs(select_attrs, self._layout_attrs(node))
|
|
1257
|
+
b.open_tag('select', attrs)
|
|
1258
|
+
b.indent()
|
|
1259
|
+
# Inline options
|
|
1260
|
+
if node.options:
|
|
1261
|
+
for opt in node.options.split(','):
|
|
1262
|
+
opt = opt.strip()
|
|
1263
|
+
b.open_tag('option', _option_attrs(opt, getattr(node, 'value', None)))
|
|
1264
|
+
b.indent()
|
|
1265
|
+
b.text(opt)
|
|
1266
|
+
b.dedent()
|
|
1267
|
+
b.close_tag('option')
|
|
1268
|
+
# Child option nodes
|
|
1269
|
+
for child in getattr(node, 'children', []):
|
|
1270
|
+
if isinstance(child, UIOptionNode):
|
|
1271
|
+
b.open_tag('option', _option_attrs(child.value or '', getattr(node, 'value', None)))
|
|
1272
|
+
b.indent()
|
|
1273
|
+
b.text(child.label or child.value or '')
|
|
1274
|
+
b.dedent()
|
|
1275
|
+
b.close_tag('option')
|
|
1276
|
+
b.dedent()
|
|
1277
|
+
b.close_tag('select')
|
|
1278
|
+
|
|
1279
|
+
def _render_table(self, node: UITableNode, b: HtmlBuilder):
|
|
1280
|
+
attrs = self._merge_attrs({'class': 'q-table'}, self._layout_attrs(node))
|
|
1281
|
+
b.open_tag('table', attrs)
|
|
1282
|
+
b.indent()
|
|
1283
|
+
columns = [c for c in node.children if isinstance(c, UIColumnNode)]
|
|
1284
|
+
if columns:
|
|
1285
|
+
b.open_tag('thead')
|
|
1286
|
+
b.indent()
|
|
1287
|
+
b.open_tag('tr')
|
|
1288
|
+
b.indent()
|
|
1289
|
+
for col in columns:
|
|
1290
|
+
th_attrs = {}
|
|
1291
|
+
if col.align:
|
|
1292
|
+
th_attrs['style'] = f"text-align: {col.align}"
|
|
1293
|
+
if col.column_width:
|
|
1294
|
+
existing = th_attrs.get('style', '')
|
|
1295
|
+
th_attrs['style'] = (existing + '; ' if existing else '') + f"width: {self._css_dimension(col.column_width)}"
|
|
1296
|
+
b.open_tag('th', th_attrs if th_attrs else None)
|
|
1297
|
+
b.indent()
|
|
1298
|
+
b.text(col.label or col.key or '')
|
|
1299
|
+
b.dedent()
|
|
1300
|
+
b.close_tag('th')
|
|
1301
|
+
b.dedent()
|
|
1302
|
+
b.close_tag('tr')
|
|
1303
|
+
b.dedent()
|
|
1304
|
+
b.close_tag('thead')
|
|
1305
|
+
b.open_tag('tbody')
|
|
1306
|
+
b.indent()
|
|
1307
|
+
if node.source:
|
|
1308
|
+
b.comment(f'Data source: {node.source}')
|
|
1309
|
+
b.dedent()
|
|
1310
|
+
b.close_tag('tbody')
|
|
1311
|
+
b.dedent()
|
|
1312
|
+
b.close_tag('table')
|
|
1313
|
+
|
|
1314
|
+
def _render_list(self, node: UIListNode, b: HtmlBuilder):
|
|
1315
|
+
attrs = self._merge_attrs({}, self._layout_attrs(node))
|
|
1316
|
+
b.open_tag('ul', attrs)
|
|
1317
|
+
b.indent()
|
|
1318
|
+
if node.source:
|
|
1319
|
+
b.comment(f'Data source: {node.source}')
|
|
1320
|
+
self._render_children(node.children, b)
|
|
1321
|
+
b.dedent()
|
|
1322
|
+
b.close_tag('ul')
|
|
1323
|
+
|
|
1324
|
+
def _render_item(self, node: UIItemNode, b: HtmlBuilder):
|
|
1325
|
+
attrs = self._merge_attrs({}, self._layout_attrs(node))
|
|
1326
|
+
b.open_tag('li', attrs)
|
|
1327
|
+
b.indent()
|
|
1328
|
+
self._render_children(node.children, b)
|
|
1329
|
+
b.dedent()
|
|
1330
|
+
b.close_tag('li')
|
|
1331
|
+
|
|
1332
|
+
def _render_image(self, node: UIImageNode, b: HtmlBuilder):
|
|
1333
|
+
self._features_used.add('image')
|
|
1334
|
+
img_attrs = {}
|
|
1335
|
+
if node.src:
|
|
1336
|
+
img_attrs['src'] = node.src
|
|
1337
|
+
if node.alt:
|
|
1338
|
+
img_attrs['alt'] = node.alt
|
|
1339
|
+
attrs = self._merge_attrs(img_attrs, self._layout_attrs(node))
|
|
1340
|
+
b.open_tag('img', attrs, self_closing=True)
|
|
1341
|
+
|
|
1342
|
+
def _render_link(self, node: UILinkNode, b: HtmlBuilder):
|
|
1343
|
+
if node.external:
|
|
1344
|
+
self._features_used.add('link_external')
|
|
1345
|
+
a_attrs = {}
|
|
1346
|
+
if node.to:
|
|
1347
|
+
a_attrs['href'] = node.to
|
|
1348
|
+
if node.external:
|
|
1349
|
+
a_attrs['target'] = '_blank'
|
|
1350
|
+
a_attrs['rel'] = 'noopener noreferrer'
|
|
1351
|
+
attrs = self._merge_attrs(a_attrs, self._layout_attrs(node))
|
|
1352
|
+
b.open_tag('a', attrs)
|
|
1353
|
+
b.indent()
|
|
1354
|
+
b.text(node.content)
|
|
1355
|
+
b.dedent()
|
|
1356
|
+
b.close_tag('a')
|
|
1357
|
+
|
|
1358
|
+
def _render_progress(self, node: UIProgressNode, b: HtmlBuilder):
|
|
1359
|
+
prog_attrs = {}
|
|
1360
|
+
if node.value:
|
|
1361
|
+
prog_attrs['value'] = node.value
|
|
1362
|
+
if node.max:
|
|
1363
|
+
prog_attrs['max'] = node.max
|
|
1364
|
+
attrs = self._merge_attrs(prog_attrs, self._layout_attrs(node))
|
|
1365
|
+
b.open_tag('progress', attrs)
|
|
1366
|
+
b.close_tag('progress')
|
|
1367
|
+
|
|
1368
|
+
def _render_tree(self, node: UITreeNode, b: HtmlBuilder):
|
|
1369
|
+
attrs = self._merge_attrs({}, self._layout_attrs(node))
|
|
1370
|
+
b.open_tag('div', attrs)
|
|
1371
|
+
b.indent()
|
|
1372
|
+
if node.source:
|
|
1373
|
+
b.comment(f'Tree source: {node.source}')
|
|
1374
|
+
b.dedent()
|
|
1375
|
+
b.close_tag('div')
|
|
1376
|
+
|
|
1377
|
+
def _render_menu(self, node: UIMenuNode, b: HtmlBuilder):
|
|
1378
|
+
attrs = self._merge_attrs({'class': 'q-menu'}, self._layout_attrs(node))
|
|
1379
|
+
b.open_tag('nav', attrs)
|
|
1380
|
+
b.indent()
|
|
1381
|
+
self._render_children(node.children, b)
|
|
1382
|
+
b.dedent()
|
|
1383
|
+
b.close_tag('nav')
|
|
1384
|
+
|
|
1385
|
+
def _render_option(self, node: UIOptionNode, b: HtmlBuilder):
|
|
1386
|
+
opt_attrs = {'class': 'q-menu-item'}
|
|
1387
|
+
if node.on_click:
|
|
1388
|
+
opt_attrs['onclick'] = node.on_click
|
|
1389
|
+
b.open_tag('span', opt_attrs)
|
|
1390
|
+
b.indent()
|
|
1391
|
+
b.text(node.label or node.value or '')
|
|
1392
|
+
b.dedent()
|
|
1393
|
+
b.close_tag('span')
|
|
1394
|
+
|
|
1395
|
+
def _render_log(self, node: UILogNode, b: HtmlBuilder):
|
|
1396
|
+
log_attrs = {'class': 'q-log'}
|
|
1397
|
+
attrs = self._merge_attrs(log_attrs, self._layout_attrs(node))
|
|
1398
|
+
b.open_tag('div', attrs)
|
|
1399
|
+
b.close_tag('div')
|
|
1400
|
+
|
|
1401
|
+
def _render_markdown(self, node: UIMarkdownNode, b: HtmlBuilder):
|
|
1402
|
+
attrs = self._merge_attrs({}, self._layout_attrs(node))
|
|
1403
|
+
b.open_tag('div', attrs)
|
|
1404
|
+
b.indent()
|
|
1405
|
+
b.text(node.content)
|
|
1406
|
+
b.dedent()
|
|
1407
|
+
b.close_tag('div')
|
|
1408
|
+
|
|
1409
|
+
def _render_header(self, node: UIHeaderNode, b: HtmlBuilder):
|
|
1410
|
+
attrs = self._merge_attrs({'class': 'q-header'}, self._layout_attrs(node))
|
|
1411
|
+
b.open_tag('header', attrs)
|
|
1412
|
+
b.indent()
|
|
1413
|
+
if node.title:
|
|
1414
|
+
b.text(node.title)
|
|
1415
|
+
elif node.content:
|
|
1416
|
+
b.text(node.content)
|
|
1417
|
+
self._render_children(node.children, b)
|
|
1418
|
+
b.dedent()
|
|
1419
|
+
b.close_tag('header')
|
|
1420
|
+
|
|
1421
|
+
def _render_footer(self, node: UIFooterNode, b: HtmlBuilder):
|
|
1422
|
+
attrs = self._merge_attrs({'class': 'q-footer'}, self._layout_attrs(node))
|
|
1423
|
+
b.open_tag('footer', attrs)
|
|
1424
|
+
b.indent()
|
|
1425
|
+
if node.content:
|
|
1426
|
+
b.text(node.content)
|
|
1427
|
+
self._render_children(node.children, b)
|
|
1428
|
+
b.dedent()
|
|
1429
|
+
b.close_tag('footer')
|
|
1430
|
+
|
|
1431
|
+
def _render_rule(self, node: UIRuleNode, b: HtmlBuilder):
|
|
1432
|
+
b.open_tag('hr', None, self_closing=True)
|
|
1433
|
+
|
|
1434
|
+
def _render_loading(self, node: UILoadingNode, b: HtmlBuilder):
|
|
1435
|
+
attrs = self._merge_attrs({'class': 'q-loading'}, self._layout_attrs(node))
|
|
1436
|
+
b.open_tag('div', attrs)
|
|
1437
|
+
b.indent()
|
|
1438
|
+
b.open_tag('div', {'class': 'q-spinner'}, self_closing=False)
|
|
1439
|
+
b.close_tag('div')
|
|
1440
|
+
if node.text:
|
|
1441
|
+
b.text(node.text)
|
|
1442
|
+
b.dedent()
|
|
1443
|
+
b.close_tag('div')
|
|
1444
|
+
|
|
1445
|
+
def _render_badge(self, node: UIBadgeNode, b: HtmlBuilder):
|
|
1446
|
+
cls = 'q-badge'
|
|
1447
|
+
if node.variant:
|
|
1448
|
+
cls += f' q-badge-{node.variant}'
|
|
1449
|
+
badge_attrs = {'class': cls}
|
|
1450
|
+
if node.badge_color:
|
|
1451
|
+
badge_attrs['style'] = f"background-color: {self._css_color(node.badge_color)}"
|
|
1452
|
+
attrs = self._merge_attrs(badge_attrs, self._layout_attrs(node))
|
|
1453
|
+
b.open_tag('span', attrs)
|
|
1454
|
+
b.indent()
|
|
1455
|
+
b.text(node.content)
|
|
1456
|
+
b.dedent()
|
|
1457
|
+
b.close_tag('span')
|
|
1458
|
+
|
|
1459
|
+
# ------------------------------------------------------------------
|
|
1460
|
+
# Animation render
|
|
1461
|
+
# ------------------------------------------------------------------
|
|
1462
|
+
|
|
1463
|
+
def _render_animate(self, node: UIAnimateNode, b: HtmlBuilder):
|
|
1464
|
+
"""Render a ui:animate wrapper with animation effects.
|
|
1465
|
+
|
|
1466
|
+
The ui:animate node wraps child elements and applies CSS animations
|
|
1467
|
+
based on the animation type, trigger, duration, and other properties.
|
|
1468
|
+
|
|
1469
|
+
Attributes supported:
|
|
1470
|
+
- type/anim_type: fade, slide, scale, rotate, bounce, pulse, shake, etc.
|
|
1471
|
+
- animate: Animation preset name (alternative to type)
|
|
1472
|
+
- duration: Animation duration (e.g., '300', '300ms', '0.3s')
|
|
1473
|
+
- delay: Animation delay
|
|
1474
|
+
- easing: Easing function (ease, ease-in, ease-out, linear, spring, bounce)
|
|
1475
|
+
- repeat: Repeat count (1, 2, 'infinite')
|
|
1476
|
+
- trigger: Animation trigger (on-load, on-hover, on-click, on-visible)
|
|
1477
|
+
- direction: Animation direction (normal, reverse, alternate)
|
|
1478
|
+
"""
|
|
1479
|
+
self._has_animations = True
|
|
1480
|
+
self._animate_counter += 1
|
|
1481
|
+
|
|
1482
|
+
# Build CSS classes
|
|
1483
|
+
classes = ['q-animate']
|
|
1484
|
+
|
|
1485
|
+
# Animation type/preset
|
|
1486
|
+
anim_type = node.anim_type or node.animate
|
|
1487
|
+
if anim_type:
|
|
1488
|
+
# Normalize animation type name
|
|
1489
|
+
anim_class = f"q-anim-{anim_type.replace('_', '-')}"
|
|
1490
|
+
classes.append(anim_class)
|
|
1491
|
+
|
|
1492
|
+
# Trigger class
|
|
1493
|
+
trigger = node.anim_trigger or 'on-load'
|
|
1494
|
+
if trigger != 'on-load':
|
|
1495
|
+
classes.append(f"q-trigger-{trigger}")
|
|
1496
|
+
|
|
1497
|
+
# Easing class
|
|
1498
|
+
if node.anim_easing:
|
|
1499
|
+
easing = node.anim_easing.replace('_', '-')
|
|
1500
|
+
classes.append(f"q-easing-{easing}")
|
|
1501
|
+
|
|
1502
|
+
# Build inline styles for animation variables
|
|
1503
|
+
style_parts = []
|
|
1504
|
+
|
|
1505
|
+
# Duration
|
|
1506
|
+
if node.anim_duration:
|
|
1507
|
+
duration = node.anim_duration
|
|
1508
|
+
# Add 'ms' if no unit specified
|
|
1509
|
+
if duration.isdigit():
|
|
1510
|
+
duration = f"{duration}ms"
|
|
1511
|
+
style_parts.append(f"--q-anim-duration: {duration}")
|
|
1512
|
+
|
|
1513
|
+
# Delay
|
|
1514
|
+
if node.anim_delay:
|
|
1515
|
+
delay = node.anim_delay
|
|
1516
|
+
if delay.isdigit():
|
|
1517
|
+
delay = f"{delay}ms"
|
|
1518
|
+
style_parts.append(f"--q-anim-delay: {delay}")
|
|
1519
|
+
|
|
1520
|
+
# Repeat
|
|
1521
|
+
if node.anim_repeat:
|
|
1522
|
+
style_parts.append(f"--q-anim-repeat: {node.anim_repeat}")
|
|
1523
|
+
|
|
1524
|
+
# Direction
|
|
1525
|
+
if node.anim_direction:
|
|
1526
|
+
style_parts.append(f"--q-anim-direction: {node.anim_direction}")
|
|
1527
|
+
|
|
1528
|
+
# Build attributes
|
|
1529
|
+
anim_attrs = {'class': ' '.join(classes)}
|
|
1530
|
+
|
|
1531
|
+
if style_parts:
|
|
1532
|
+
anim_attrs['style'] = '; '.join(style_parts)
|
|
1533
|
+
|
|
1534
|
+
# Add data attribute for once-only visibility trigger
|
|
1535
|
+
if trigger == 'on-visible':
|
|
1536
|
+
anim_attrs['data-anim-once'] = 'true'
|
|
1537
|
+
|
|
1538
|
+
# Generate unique ID if needed for JS targeting
|
|
1539
|
+
anim_id = f"q-anim-{self._animate_counter}"
|
|
1540
|
+
anim_attrs['id'] = anim_id
|
|
1541
|
+
|
|
1542
|
+
# Merge with layout attributes
|
|
1543
|
+
attrs = self._merge_attrs(anim_attrs, self._layout_attrs(node))
|
|
1544
|
+
|
|
1545
|
+
# Render the wrapper div
|
|
1546
|
+
b.open_tag('div', attrs)
|
|
1547
|
+
b.indent()
|
|
1548
|
+
|
|
1549
|
+
# Render children
|
|
1550
|
+
self._render_children(node.children, b)
|
|
1551
|
+
|
|
1552
|
+
b.dedent()
|
|
1553
|
+
b.close_tag('div')
|
|
1554
|
+
|
|
1555
|
+
def _animation_attrs(self, node) -> dict:
|
|
1556
|
+
"""Build animation-related HTML attributes from animation properties.
|
|
1557
|
+
|
|
1558
|
+
This method can be used to add animation attributes to any UI node
|
|
1559
|
+
that supports inline animations via the 'animate' or 'transition' attributes.
|
|
1560
|
+
"""
|
|
1561
|
+
attrs = {}
|
|
1562
|
+
|
|
1563
|
+
# Check if node has animation mixin
|
|
1564
|
+
if not hasattr(node, 'animate'):
|
|
1565
|
+
return attrs
|
|
1566
|
+
|
|
1567
|
+
# Handle inline animate attribute
|
|
1568
|
+
if node.animate:
|
|
1569
|
+
self._has_animations = True
|
|
1570
|
+
classes = ['q-animate', f"q-anim-{node.animate.replace('_', '-')}"]
|
|
1571
|
+
attrs['class'] = ' '.join(classes)
|
|
1572
|
+
|
|
1573
|
+
# Handle inline transition attribute (e.g., "scale:0.95:100ms")
|
|
1574
|
+
if hasattr(node, 'transition') and node.transition:
|
|
1575
|
+
self._has_animations = True
|
|
1576
|
+
attrs['data-transition'] = node.transition
|
|
1577
|
+
|
|
1578
|
+
return attrs
|
|
1579
|
+
|
|
1580
|
+
# ------------------------------------------------------------------
|
|
1581
|
+
# Component Library renders
|
|
1582
|
+
# ------------------------------------------------------------------
|
|
1583
|
+
|
|
1584
|
+
def _render_card(self, node: UICardNode, b: HtmlBuilder):
|
|
1585
|
+
cls = 'q-card'
|
|
1586
|
+
if node.variant:
|
|
1587
|
+
cls += f' q-card-{node.variant}'
|
|
1588
|
+
attrs = self._merge_attrs({'class': cls}, self._layout_attrs(node))
|
|
1589
|
+
b.open_tag('div', attrs)
|
|
1590
|
+
b.indent()
|
|
1591
|
+
if node.image:
|
|
1592
|
+
b.open_tag('div', {'class': 'q-card-image'})
|
|
1593
|
+
b.open_tag('img', {'src': node.image, 'alt': node.title or ''}, self_closing=True)
|
|
1594
|
+
b.close_tag('div')
|
|
1595
|
+
# UI-7: the title shows even next to a ui:card-header (it used to vanish).
|
|
1596
|
+
if node.title:
|
|
1597
|
+
b.open_tag('div', {'class': 'q-card-header'})
|
|
1598
|
+
b.open_tag('div', {'class': 'q-card-title'})
|
|
1599
|
+
b.text(node.title)
|
|
1600
|
+
b.close_tag('div')
|
|
1601
|
+
if node.subtitle:
|
|
1602
|
+
b.open_tag('div', {'class': 'q-card-subtitle'})
|
|
1603
|
+
b.text(node.subtitle)
|
|
1604
|
+
b.close_tag('div')
|
|
1605
|
+
b.close_tag('div')
|
|
1606
|
+
self._render_children(node.children, b)
|
|
1607
|
+
b.dedent()
|
|
1608
|
+
b.close_tag('div')
|
|
1609
|
+
|
|
1610
|
+
def _render_card_header(self, node: UICardHeaderNode, b: HtmlBuilder):
|
|
1611
|
+
attrs = self._merge_attrs({'class': 'q-card-header'}, self._layout_attrs(node))
|
|
1612
|
+
b.open_tag('div', attrs)
|
|
1613
|
+
b.indent()
|
|
1614
|
+
self._render_children(node.children, b)
|
|
1615
|
+
b.dedent()
|
|
1616
|
+
b.close_tag('div')
|
|
1617
|
+
|
|
1618
|
+
def _render_card_body(self, node: UICardBodyNode, b: HtmlBuilder):
|
|
1619
|
+
attrs = self._merge_attrs({'class': 'q-card-body'}, self._layout_attrs(node))
|
|
1620
|
+
b.open_tag('div', attrs)
|
|
1621
|
+
b.indent()
|
|
1622
|
+
self._render_children(node.children, b)
|
|
1623
|
+
b.dedent()
|
|
1624
|
+
b.close_tag('div')
|
|
1625
|
+
|
|
1626
|
+
def _render_card_footer(self, node: UICardFooterNode, b: HtmlBuilder):
|
|
1627
|
+
attrs = self._merge_attrs({'class': 'q-card-footer'}, self._layout_attrs(node))
|
|
1628
|
+
b.open_tag('div', attrs)
|
|
1629
|
+
b.indent()
|
|
1630
|
+
self._render_children(node.children, b)
|
|
1631
|
+
b.dedent()
|
|
1632
|
+
b.close_tag('div')
|
|
1633
|
+
|
|
1634
|
+
def _render_modal(self, node: UIModalNode, b: HtmlBuilder):
|
|
1635
|
+
self._features_used.add('modal')
|
|
1636
|
+
modal_id = node.modal_id or f'modal-{id(node)}'
|
|
1637
|
+
overlay_class = 'q-modal-overlay' + (' q-modal-open' if node.open else '')
|
|
1638
|
+
b.open_tag('div', {'class': overlay_class, 'id': modal_id})
|
|
1639
|
+
b.indent()
|
|
1640
|
+
dialog_class = 'q-modal' + (f' q-modal-{node.size}' if node.size else '')
|
|
1641
|
+
attrs = self._merge_attrs({'class': dialog_class, 'role': 'dialog'}, self._layout_attrs(node))
|
|
1642
|
+
b.open_tag('div', attrs)
|
|
1643
|
+
b.indent()
|
|
1644
|
+
if node.title or node.closable:
|
|
1645
|
+
b.open_tag('div', {'class': 'q-modal-header'})
|
|
1646
|
+
if node.title:
|
|
1647
|
+
b.open_tag('h3', {'class': 'q-modal-title'})
|
|
1648
|
+
b.text(node.title)
|
|
1649
|
+
b.close_tag('h3')
|
|
1650
|
+
if node.closable:
|
|
1651
|
+
b.open_tag('button', {'class': 'q-modal-close', 'onclick': f"document.getElementById('{modal_id}').classList.remove('q-modal-open')"})
|
|
1652
|
+
b.text('x')
|
|
1653
|
+
b.close_tag('button')
|
|
1654
|
+
b.close_tag('div')
|
|
1655
|
+
b.open_tag('div', {'class': 'q-modal-body'})
|
|
1656
|
+
self._render_children(node.children, b)
|
|
1657
|
+
b.close_tag('div')
|
|
1658
|
+
b.dedent()
|
|
1659
|
+
b.close_tag('div')
|
|
1660
|
+
b.dedent()
|
|
1661
|
+
b.close_tag('div')
|
|
1662
|
+
|
|
1663
|
+
def _render_chart(self, node: UIChartNode, b: HtmlBuilder):
|
|
1664
|
+
self._features_used.add('chart')
|
|
1665
|
+
attrs = self._merge_attrs({'class': f'q-chart q-chart-{node.chart_type}'}, self._layout_attrs(node))
|
|
1666
|
+
b.open_tag('div', attrs)
|
|
1667
|
+
b.indent()
|
|
1668
|
+
if node.title:
|
|
1669
|
+
b.open_tag('div', {'class': 'q-chart-title'})
|
|
1670
|
+
b.text(node.title)
|
|
1671
|
+
b.close_tag('div')
|
|
1672
|
+
b.open_tag('div', {'class': 'q-chart-container'})
|
|
1673
|
+
if node.labels and node.values:
|
|
1674
|
+
labels = [l.strip() for l in node.labels.split(',')]
|
|
1675
|
+
values = [v.strip() for v in node.values.split(',')]
|
|
1676
|
+
colors = [c.strip() for c in node.colors.split(',')] if node.colors else []
|
|
1677
|
+
default_colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6']
|
|
1678
|
+
max_val = max(float(v) for v in values) if values else 100
|
|
1679
|
+
for i, (label, value) in enumerate(zip(labels, values)):
|
|
1680
|
+
color = colors[i] if i < len(colors) else default_colors[i % len(default_colors)]
|
|
1681
|
+
percent = (float(value) / max_val) * 100 if max_val > 0 else 0
|
|
1682
|
+
if node.chart_type == 'bar':
|
|
1683
|
+
b.open_tag('div', {'class': 'q-chart-bar-group'})
|
|
1684
|
+
b.open_tag('div', {'class': 'q-chart-label'})
|
|
1685
|
+
b.text(label)
|
|
1686
|
+
b.close_tag('div')
|
|
1687
|
+
b.open_tag('div', {'class': 'q-chart-bar', 'style': f'width:{percent}%;background:{color}'})
|
|
1688
|
+
b.open_tag('span', {'class': 'q-chart-value'})
|
|
1689
|
+
b.text(value)
|
|
1690
|
+
b.close_tag('span')
|
|
1691
|
+
b.close_tag('div')
|
|
1692
|
+
b.close_tag('div')
|
|
1693
|
+
b.close_tag('div')
|
|
1694
|
+
b.dedent()
|
|
1695
|
+
b.close_tag('div')
|
|
1696
|
+
|
|
1697
|
+
def _render_avatar(self, node: UIAvatarNode, b: HtmlBuilder):
|
|
1698
|
+
cls = 'q-avatar' + (f' q-avatar-{node.size}' if node.size else '')
|
|
1699
|
+
if node.shape == 'square':
|
|
1700
|
+
cls += ' q-avatar-square'
|
|
1701
|
+
attrs = self._merge_attrs({'class': cls}, self._layout_attrs(node))
|
|
1702
|
+
b.open_tag('div', attrs)
|
|
1703
|
+
if node.src:
|
|
1704
|
+
b.open_tag('img', {'src': node.src, 'alt': node.name or ''}, self_closing=True)
|
|
1705
|
+
elif node.name:
|
|
1706
|
+
initials = ''.join(p[0].upper() for p in node.name.split()[:2])
|
|
1707
|
+
b.open_tag('span', {'class': 'q-avatar-initials'})
|
|
1708
|
+
b.text(initials or '?')
|
|
1709
|
+
b.close_tag('span')
|
|
1710
|
+
if node.status:
|
|
1711
|
+
b.open_tag('span', {'class': f'q-avatar-status q-avatar-status-{node.status}'})
|
|
1712
|
+
b.close_tag('span')
|
|
1713
|
+
b.close_tag('div')
|
|
1714
|
+
|
|
1715
|
+
def _render_tooltip(self, node: UITooltipNode, b: HtmlBuilder):
|
|
1716
|
+
self._features_used.add('tooltip')
|
|
1717
|
+
attrs = self._merge_attrs({'class': f'q-tooltip-wrapper q-tooltip-{node.position}'}, self._layout_attrs(node))
|
|
1718
|
+
b.open_tag('div', attrs)
|
|
1719
|
+
self._render_children(node.children, b)
|
|
1720
|
+
b.open_tag('span', {'class': 'q-tooltip-text', 'role': 'tooltip'})
|
|
1721
|
+
b.text(node.content)
|
|
1722
|
+
b.close_tag('span')
|
|
1723
|
+
b.close_tag('div')
|
|
1724
|
+
|
|
1725
|
+
def _render_dropdown(self, node: UIDropdownNode, b: HtmlBuilder):
|
|
1726
|
+
self._features_used.add('dropdown')
|
|
1727
|
+
cls = f'q-dropdown q-dropdown-{node.dropdown_align}'
|
|
1728
|
+
if node.trigger == 'hover':
|
|
1729
|
+
cls += ' q-dropdown-hover'
|
|
1730
|
+
attrs = self._merge_attrs({'class': cls}, self._layout_attrs(node))
|
|
1731
|
+
b.open_tag('div', attrs)
|
|
1732
|
+
b.open_tag('button', {'class': 'q-dropdown-trigger'})
|
|
1733
|
+
b.text(node.label or 'Menu')
|
|
1734
|
+
b.open_tag('span', {'class': 'q-dropdown-arrow'})
|
|
1735
|
+
b.text(' v')
|
|
1736
|
+
b.close_tag('span')
|
|
1737
|
+
b.close_tag('button')
|
|
1738
|
+
b.open_tag('div', {'class': 'q-dropdown-menu'})
|
|
1739
|
+
self._render_children(node.children, b)
|
|
1740
|
+
b.close_tag('div')
|
|
1741
|
+
b.close_tag('div')
|
|
1742
|
+
|
|
1743
|
+
def _render_alert(self, node: UIAlertNode, b: HtmlBuilder):
|
|
1744
|
+
cls = f'q-alert q-alert-{node.variant}'
|
|
1745
|
+
if node.dismissible:
|
|
1746
|
+
cls += ' q-alert-dismissible'
|
|
1747
|
+
alert_id = f'alert-{id(node)}'
|
|
1748
|
+
attrs = self._merge_attrs({'class': cls, 'role': 'alert', 'id': alert_id}, self._layout_attrs(node))
|
|
1749
|
+
b.open_tag('div', attrs)
|
|
1750
|
+
if node.icon:
|
|
1751
|
+
b.open_tag('span', {'class': 'q-alert-icon'})
|
|
1752
|
+
b.text(node.icon)
|
|
1753
|
+
b.close_tag('span')
|
|
1754
|
+
b.open_tag('div', {'class': 'q-alert-content'})
|
|
1755
|
+
if node.title:
|
|
1756
|
+
b.open_tag('div', {'class': 'q-alert-title'})
|
|
1757
|
+
b.text(node.title)
|
|
1758
|
+
b.close_tag('div')
|
|
1759
|
+
if node.content:
|
|
1760
|
+
b.open_tag('div', {'class': 'q-alert-message'})
|
|
1761
|
+
b.text(node.content)
|
|
1762
|
+
b.close_tag('div')
|
|
1763
|
+
self._render_children(node.children, b)
|
|
1764
|
+
b.close_tag('div')
|
|
1765
|
+
if node.dismissible:
|
|
1766
|
+
b.open_tag('button', {'class': 'q-alert-dismiss', 'onclick': f"document.getElementById('{alert_id}').style.display='none'"})
|
|
1767
|
+
b.text('x')
|
|
1768
|
+
b.close_tag('button')
|
|
1769
|
+
b.close_tag('div')
|
|
1770
|
+
|
|
1771
|
+
def _render_breadcrumb(self, node: UIBreadcrumbNode, b: HtmlBuilder):
|
|
1772
|
+
attrs = self._merge_attrs({'class': 'q-breadcrumb'}, self._layout_attrs(node))
|
|
1773
|
+
b.open_tag('nav', attrs)
|
|
1774
|
+
b.open_tag('ol', {'class': 'q-breadcrumb-list'})
|
|
1775
|
+
for i, child in enumerate(node.children):
|
|
1776
|
+
if isinstance(child, UIBreadcrumbItemNode):
|
|
1777
|
+
is_last = i == len(node.children) - 1
|
|
1778
|
+
self._render_breadcrumb_item_internal(child, b, node.separator, is_last)
|
|
1779
|
+
b.close_tag('ol')
|
|
1780
|
+
b.close_tag('nav')
|
|
1781
|
+
|
|
1782
|
+
def _render_breadcrumb_item(self, node: UIBreadcrumbItemNode, b: HtmlBuilder):
|
|
1783
|
+
self._render_breadcrumb_item_internal(node, b, '/', False)
|
|
1784
|
+
|
|
1785
|
+
def _render_breadcrumb_item_internal(self, node: UIBreadcrumbItemNode, b: HtmlBuilder, sep: str, is_last: bool):
|
|
1786
|
+
cls = 'q-breadcrumb-item' + (' q-breadcrumb-current' if is_last else '')
|
|
1787
|
+
b.open_tag('li', {'class': cls})
|
|
1788
|
+
if node.to and not is_last:
|
|
1789
|
+
b.open_tag('a', {'href': node.to, 'class': 'q-breadcrumb-link'})
|
|
1790
|
+
b.text(node.label)
|
|
1791
|
+
b.close_tag('a')
|
|
1792
|
+
else:
|
|
1793
|
+
b.open_tag('span', {'class': 'q-breadcrumb-text'})
|
|
1794
|
+
b.text(node.label)
|
|
1795
|
+
b.close_tag('span')
|
|
1796
|
+
if not is_last:
|
|
1797
|
+
b.open_tag('span', {'class': 'q-breadcrumb-separator'})
|
|
1798
|
+
b.text(sep)
|
|
1799
|
+
b.close_tag('span')
|
|
1800
|
+
b.close_tag('li')
|
|
1801
|
+
|
|
1802
|
+
def _render_pagination(self, node: UIPaginationNode, b: HtmlBuilder):
|
|
1803
|
+
self._features_used.add('pagination')
|
|
1804
|
+
attrs = self._merge_attrs({'class': 'q-pagination'}, self._layout_attrs(node))
|
|
1805
|
+
b.open_tag('nav', attrs)
|
|
1806
|
+
if node.show_total:
|
|
1807
|
+
b.open_tag('span', {'class': 'q-pagination-total'})
|
|
1808
|
+
b.text(f'Total: {node.total or 0} items')
|
|
1809
|
+
b.close_tag('span')
|
|
1810
|
+
b.open_tag('div', {'class': 'q-pagination-controls'})
|
|
1811
|
+
b.open_tag('button', {'class': 'q-pagination-prev'})
|
|
1812
|
+
b.text('Prev')
|
|
1813
|
+
b.close_tag('button')
|
|
1814
|
+
b.open_tag('span', {'class': 'q-pagination-pages'})
|
|
1815
|
+
b.text(f'Page {node.current}')
|
|
1816
|
+
b.close_tag('span')
|
|
1817
|
+
b.open_tag('button', {'class': 'q-pagination-next'})
|
|
1818
|
+
b.text('Next')
|
|
1819
|
+
b.close_tag('button')
|
|
1820
|
+
b.close_tag('div')
|
|
1821
|
+
if node.show_jump:
|
|
1822
|
+
b.open_tag('div', {'class': 'q-pagination-jump'})
|
|
1823
|
+
b.text('Go to ')
|
|
1824
|
+
b.open_tag('input', {'type': 'number', 'min': '1', 'class': 'q-pagination-input'}, self_closing=True)
|
|
1825
|
+
b.close_tag('div')
|
|
1826
|
+
b.close_tag('nav')
|
|
1827
|
+
|
|
1828
|
+
def _render_skeleton(self, node: UISkeletonNode, b: HtmlBuilder):
|
|
1829
|
+
cls = f'q-skeleton q-skeleton-{node.variant}'
|
|
1830
|
+
if node.animated:
|
|
1831
|
+
cls += ' q-skeleton-animated'
|
|
1832
|
+
attrs = self._merge_attrs({'class': cls}, self._layout_attrs(node))
|
|
1833
|
+
if node.variant == 'text':
|
|
1834
|
+
for _ in range(node.lines):
|
|
1835
|
+
b.open_tag('div', attrs)
|
|
1836
|
+
b.close_tag('div')
|
|
1837
|
+
elif node.variant == 'card':
|
|
1838
|
+
b.open_tag('div', {'class': 'q-skeleton-card'})
|
|
1839
|
+
b.open_tag('div', {'class': 'q-skeleton q-skeleton-rect q-skeleton-animated', 'style': 'height:120px'})
|
|
1840
|
+
b.close_tag('div')
|
|
1841
|
+
b.open_tag('div', {'class': 'q-skeleton q-skeleton-text q-skeleton-animated', 'style': 'width:60%'})
|
|
1842
|
+
b.close_tag('div')
|
|
1843
|
+
b.open_tag('div', {'class': 'q-skeleton q-skeleton-text q-skeleton-animated', 'style': 'width:80%'})
|
|
1844
|
+
b.close_tag('div')
|
|
1845
|
+
b.close_tag('div')
|
|
1846
|
+
else:
|
|
1847
|
+
b.open_tag('div', attrs)
|
|
1848
|
+
b.close_tag('div')
|
|
1849
|
+
|
|
1850
|
+
# ------------------------------------------------------------------
|
|
1851
|
+
# Toast renders
|
|
1852
|
+
# ------------------------------------------------------------------
|
|
1853
|
+
|
|
1854
|
+
def _render_toast(self, node: UIToastNode, b: HtmlBuilder):
|
|
1855
|
+
"""Render ui:toast as a positioned, dismissible notification."""
|
|
1856
|
+
self._features_used.add('toast')
|
|
1857
|
+
self._has_toast = True
|
|
1858
|
+
self._toast_counter += 1
|
|
1859
|
+
|
|
1860
|
+
toast_id = node.ui_id or f'q-toast-{self._toast_counter}'
|
|
1861
|
+
variant = node.variant or 'info'
|
|
1862
|
+
position = node.position or 'top-right'
|
|
1863
|
+
duration = self._as_int(node.duration, 3000)
|
|
1864
|
+
dismissible = self._as_bool(node.dismissible, True)
|
|
1865
|
+
|
|
1866
|
+
# Fixed wrapper so a declared toast lands on the requested corner
|
|
1867
|
+
b.open_tag('div', {'class': f'q-toast-container q-toast-{position}'})
|
|
1868
|
+
b.indent()
|
|
1869
|
+
|
|
1870
|
+
toast_attrs = {'class': f'q-toast q-toast-{variant}', 'id': toast_id, 'role': 'status'}
|
|
1871
|
+
if node.show:
|
|
1872
|
+
# Hidden until the bound variable becomes truthy (SHOW_BINDING_JS)
|
|
1873
|
+
self._has_show_bindings = True
|
|
1874
|
+
toast_attrs['data-q-show'] = node.show
|
|
1875
|
+
toast_attrs['style'] = 'display: none'
|
|
1876
|
+
attrs = self._merge_attrs(toast_attrs, self._layout_attrs(node))
|
|
1877
|
+
b.open_tag('div', attrs)
|
|
1878
|
+
b.indent()
|
|
1879
|
+
|
|
1880
|
+
icon = node.icon or TOAST_ICONS.get(variant, TOAST_ICONS['info'])
|
|
1881
|
+
b.open_tag('span', {'class': 'q-toast-icon', 'aria-hidden': 'true'})
|
|
1882
|
+
b.indent()
|
|
1883
|
+
b.text(icon)
|
|
1884
|
+
b.dedent()
|
|
1885
|
+
b.close_tag('span')
|
|
1886
|
+
|
|
1887
|
+
b.open_tag('div', {'class': 'q-toast-content'})
|
|
1888
|
+
b.indent()
|
|
1889
|
+
if node.title:
|
|
1890
|
+
b.open_tag('div', {'class': 'q-toast-title'})
|
|
1891
|
+
b.indent()
|
|
1892
|
+
b.text(node.title)
|
|
1893
|
+
b.dedent()
|
|
1894
|
+
b.close_tag('div')
|
|
1895
|
+
b.open_tag('div', {'class': 'q-toast-message'})
|
|
1896
|
+
b.indent()
|
|
1897
|
+
b.text(node.message)
|
|
1898
|
+
b.dedent()
|
|
1899
|
+
b.close_tag('div')
|
|
1900
|
+
b.dedent()
|
|
1901
|
+
b.close_tag('div')
|
|
1902
|
+
|
|
1903
|
+
if dismissible:
|
|
1904
|
+
actions = []
|
|
1905
|
+
if node.on_close:
|
|
1906
|
+
actions.append(self._js_statement(node.on_close, self._js_str(toast_id)))
|
|
1907
|
+
actions.append(f"__quantumToast.dismiss({self._js_str(toast_id)});")
|
|
1908
|
+
b.open_tag('button', {'class': 'q-toast-close', 'type': 'button',
|
|
1909
|
+
'aria-label': 'Close', 'onclick': ' '.join(actions)})
|
|
1910
|
+
b.indent()
|
|
1911
|
+
b.text('×')
|
|
1912
|
+
b.dedent()
|
|
1913
|
+
b.close_tag('button')
|
|
1914
|
+
|
|
1915
|
+
b.dedent()
|
|
1916
|
+
b.close_tag('div')
|
|
1917
|
+
b.dedent()
|
|
1918
|
+
b.close_tag('div')
|
|
1919
|
+
|
|
1920
|
+
# Auto-dismiss (skipped while a show-binding controls visibility)
|
|
1921
|
+
if duration > 0 and not node.show:
|
|
1922
|
+
self._component_inits.append(
|
|
1923
|
+
f"setTimeout(function() {{ if (window.__quantumToast) "
|
|
1924
|
+
f"__quantumToast.dismiss({self._js_str(toast_id)}); }}, {duration});"
|
|
1925
|
+
)
|
|
1926
|
+
|
|
1927
|
+
def _render_toast_container(self, node: UIToastContainerNode, b: HtmlBuilder):
|
|
1928
|
+
"""Render ui:toast-container and make it the sink for runtime toasts."""
|
|
1929
|
+
self._features_used.add('toast')
|
|
1930
|
+
self._has_toast = True
|
|
1931
|
+
self._toast_container_counter += 1
|
|
1932
|
+
|
|
1933
|
+
container_id = node.ui_id or f'q-toast-container-{self._toast_container_counter}'
|
|
1934
|
+
position = node.position or 'top-right'
|
|
1935
|
+
max_toasts = self._as_int(node.max_toasts, 5)
|
|
1936
|
+
|
|
1937
|
+
base = {'class': f'q-toast-container q-toast-{position}', 'id': container_id,
|
|
1938
|
+
'data-max-toasts': str(max_toasts), 'aria-live': 'polite', 'role': 'region'}
|
|
1939
|
+
attrs = self._merge_attrs(base, self._layout_attrs(node))
|
|
1940
|
+
b.open_tag('div', attrs)
|
|
1941
|
+
b.close_tag('div')
|
|
1942
|
+
|
|
1943
|
+
cid = self._js_str(container_id)
|
|
1944
|
+
pos = self._js_str(position)
|
|
1945
|
+
self._component_inits.append(f"""(function() {{
|
|
1946
|
+
var container = document.getElementById({cid});
|
|
1947
|
+
if (!container || !window.__quantumToast) return;
|
|
1948
|
+
var max = {max_toasts};
|
|
1949
|
+
var showToast = __quantumToast.show;
|
|
1950
|
+
__quantumToast.show = function(options) {{
|
|
1951
|
+
options = options || {{}};
|
|
1952
|
+
if (!options.position) options.position = {pos};
|
|
1953
|
+
var id = showToast(options);
|
|
1954
|
+
if (options.position === {pos}) {{
|
|
1955
|
+
var toast = document.getElementById(id);
|
|
1956
|
+
if (toast) container.appendChild(toast);
|
|
1957
|
+
// Toasts already fading out do not count towards the limit
|
|
1958
|
+
var items = container.querySelectorAll('.q-toast:not(.q-toast-out)');
|
|
1959
|
+
for (var i = 0; i < items.length - max; i++) {{
|
|
1960
|
+
__quantumToast.dismiss(items[i].id);
|
|
1961
|
+
}}
|
|
1962
|
+
}}
|
|
1963
|
+
return id;
|
|
1964
|
+
}};
|
|
1965
|
+
['info', 'success', 'warning', 'danger'].forEach(function(variant) {{
|
|
1966
|
+
__quantumToast[variant] = function(message, options) {{
|
|
1967
|
+
options = options || {{}};
|
|
1968
|
+
options.message = message;
|
|
1969
|
+
options.variant = variant;
|
|
1970
|
+
return __quantumToast.show(options);
|
|
1971
|
+
}};
|
|
1972
|
+
}});
|
|
1973
|
+
__quantumToast.error = __quantumToast.danger;
|
|
1974
|
+
}})();""")
|
|
1975
|
+
|
|
1976
|
+
# ------------------------------------------------------------------
|
|
1977
|
+
# Carousel renders
|
|
1978
|
+
# ------------------------------------------------------------------
|
|
1979
|
+
|
|
1980
|
+
def _render_carousel(self, node: UICarouselNode, b: HtmlBuilder):
|
|
1981
|
+
"""Render ui:carousel with track, arrows and indicators."""
|
|
1982
|
+
self._features_used.add('carousel')
|
|
1983
|
+
self._has_carousel = True
|
|
1984
|
+
self._carousel_counter += 1
|
|
1985
|
+
|
|
1986
|
+
carousel_id = node.ui_id or f'q-carousel-{self._carousel_counter}'
|
|
1987
|
+
animation = node.animation or 'slide'
|
|
1988
|
+
show_arrows = self._as_bool(node.show_arrows, True)
|
|
1989
|
+
show_indicators = self._as_bool(node.show_indicators, True)
|
|
1990
|
+
auto_play = self._as_bool(node.auto_play, False)
|
|
1991
|
+
loop = self._as_bool(node.loop, True)
|
|
1992
|
+
interval = self._as_int(node.interval, 5000)
|
|
1993
|
+
|
|
1994
|
+
# Slides: explicit ui:slide children, otherwise one slide per child
|
|
1995
|
+
slide_nodes = [c for c in node.children if isinstance(c, UISlideNode)]
|
|
1996
|
+
if slide_nodes:
|
|
1997
|
+
slides = [(s, s.children) for s in slide_nodes]
|
|
1998
|
+
else:
|
|
1999
|
+
slides = [(None, [c]) for c in node.children]
|
|
2000
|
+
|
|
2001
|
+
total = len(slides)
|
|
2002
|
+
current = self._as_int(node.current, 0)
|
|
2003
|
+
current = max(0, min(current, total - 1)) if total else 0
|
|
2004
|
+
|
|
2005
|
+
cls = 'q-carousel'
|
|
2006
|
+
if animation == 'fade':
|
|
2007
|
+
cls += ' q-carousel-fade'
|
|
2008
|
+
attrs = self._merge_attrs({'class': cls, 'id': carousel_id}, self._layout_attrs(node))
|
|
2009
|
+
b.open_tag('div', attrs)
|
|
2010
|
+
b.indent()
|
|
2011
|
+
|
|
2012
|
+
b.open_tag('div', {'class': 'q-carousel-track'})
|
|
2013
|
+
b.indent()
|
|
2014
|
+
for i, (slide_node, children) in enumerate(slides):
|
|
2015
|
+
slide_cls = 'q-carousel-slide'
|
|
2016
|
+
if i == current:
|
|
2017
|
+
slide_cls += ' active'
|
|
2018
|
+
slide_attrs = {'class': slide_cls, 'data-slide': str(i)}
|
|
2019
|
+
if slide_node is not None:
|
|
2020
|
+
slide_attrs = self._merge_attrs(slide_attrs, self._layout_attrs(slide_node))
|
|
2021
|
+
b.open_tag('div', slide_attrs)
|
|
2022
|
+
b.indent()
|
|
2023
|
+
self._render_children(children, b)
|
|
2024
|
+
b.dedent()
|
|
2025
|
+
b.close_tag('div')
|
|
2026
|
+
b.dedent()
|
|
2027
|
+
b.close_tag('div')
|
|
2028
|
+
|
|
2029
|
+
if show_arrows and total > 1:
|
|
2030
|
+
b.open_tag('button', {'class': 'q-carousel-arrow q-carousel-prev', 'type': 'button',
|
|
2031
|
+
'aria-label': 'Previous slide'})
|
|
2032
|
+
b.indent()
|
|
2033
|
+
b.text('❮')
|
|
2034
|
+
b.dedent()
|
|
2035
|
+
b.close_tag('button')
|
|
2036
|
+
b.open_tag('button', {'class': 'q-carousel-arrow q-carousel-next', 'type': 'button',
|
|
2037
|
+
'aria-label': 'Next slide'})
|
|
2038
|
+
b.indent()
|
|
2039
|
+
b.text('❯')
|
|
2040
|
+
b.dedent()
|
|
2041
|
+
b.close_tag('button')
|
|
2042
|
+
|
|
2043
|
+
if show_indicators and total > 1:
|
|
2044
|
+
b.open_tag('div', {'class': 'q-carousel-indicators'})
|
|
2045
|
+
b.indent()
|
|
2046
|
+
for i in range(total):
|
|
2047
|
+
ind_cls = 'q-carousel-indicator' + (' active' if i == current else '')
|
|
2048
|
+
b.open_tag('button', {'class': ind_cls, 'type': 'button',
|
|
2049
|
+
'aria-label': f'Go to slide {i + 1}'})
|
|
2050
|
+
b.close_tag('button')
|
|
2051
|
+
b.dedent()
|
|
2052
|
+
b.close_tag('div')
|
|
2053
|
+
|
|
2054
|
+
b.dedent()
|
|
2055
|
+
b.close_tag('div')
|
|
2056
|
+
|
|
2057
|
+
options = {
|
|
2058
|
+
'current': str(current),
|
|
2059
|
+
'autoPlay': self._js_bool(auto_play),
|
|
2060
|
+
'interval': str(interval),
|
|
2061
|
+
'loop': self._js_bool(loop),
|
|
2062
|
+
'animation': self._js_str(animation),
|
|
2063
|
+
}
|
|
2064
|
+
on_change = self._js_change_handler(node.on_change, node.bind, 'index')
|
|
2065
|
+
if on_change:
|
|
2066
|
+
options['onChange'] = on_change
|
|
2067
|
+
|
|
2068
|
+
cid = self._js_str(carousel_id)
|
|
2069
|
+
self._component_inits.append(f"""(function() {{
|
|
2070
|
+
if (!window.__quantumCarousel) return;
|
|
2071
|
+
var api = __quantumCarousel.init({cid}, {self._js_options(options)});
|
|
2072
|
+
var state = __quantumCarousel.get({cid});
|
|
2073
|
+
if (api && state) {{
|
|
2074
|
+
for (var key in api) {{ state[key] = api[key]; }}
|
|
2075
|
+
}}
|
|
2076
|
+
}})();""")
|
|
2077
|
+
|
|
2078
|
+
def _render_slide(self, node: UISlideNode, b: HtmlBuilder):
|
|
2079
|
+
"""Render a standalone ui:slide (outside a carousel)."""
|
|
2080
|
+
attrs = self._merge_attrs({'class': 'q-carousel-slide'}, self._layout_attrs(node))
|
|
2081
|
+
b.open_tag('div', attrs)
|
|
2082
|
+
b.indent()
|
|
2083
|
+
self._render_children(node.children, b)
|
|
2084
|
+
b.dedent()
|
|
2085
|
+
b.close_tag('div')
|
|
2086
|
+
|
|
2087
|
+
# ------------------------------------------------------------------
|
|
2088
|
+
# Stepper renders
|
|
2089
|
+
# ------------------------------------------------------------------
|
|
2090
|
+
|
|
2091
|
+
def _render_stepper(self, node: UIStepperNode, b: HtmlBuilder):
|
|
2092
|
+
"""Render ui:stepper with step indicators and per-step content."""
|
|
2093
|
+
self._features_used.add('stepper')
|
|
2094
|
+
self._has_stepper = True
|
|
2095
|
+
self._stepper_counter += 1
|
|
2096
|
+
|
|
2097
|
+
stepper_id = node.ui_id or f'q-stepper-{self._stepper_counter}'
|
|
2098
|
+
orientation = node.orientation or 'horizontal'
|
|
2099
|
+
show_labels = self._as_bool(node.show_labels, True)
|
|
2100
|
+
clickable = self._as_bool(node.clickable, False)
|
|
2101
|
+
linear = self._as_bool(node.linear, True)
|
|
2102
|
+
|
|
2103
|
+
steps = [c for c in node.children if isinstance(c, UIStepNode)]
|
|
2104
|
+
total = len(steps)
|
|
2105
|
+
current = self._as_int(node.current, 0)
|
|
2106
|
+
current = max(0, min(current, total - 1)) if total else 0
|
|
2107
|
+
|
|
2108
|
+
completed = completed_step_indices(steps, current)
|
|
2109
|
+
|
|
2110
|
+
attrs = self._merge_attrs(
|
|
2111
|
+
{'class': f'q-stepper q-stepper-{orientation}', 'id': stepper_id},
|
|
2112
|
+
self._layout_attrs(node))
|
|
2113
|
+
b.open_tag('div', attrs)
|
|
2114
|
+
b.indent()
|
|
2115
|
+
|
|
2116
|
+
# Step indicators
|
|
2117
|
+
b.open_tag('div', {'class': 'q-stepper-header'})
|
|
2118
|
+
b.indent()
|
|
2119
|
+
for i, step in enumerate(steps):
|
|
2120
|
+
item_cls = 'q-step-item'
|
|
2121
|
+
if i == current:
|
|
2122
|
+
item_cls += ' active'
|
|
2123
|
+
elif i in completed:
|
|
2124
|
+
item_cls += ' completed'
|
|
2125
|
+
if step.error:
|
|
2126
|
+
item_cls += ' error'
|
|
2127
|
+
b.open_tag('div', {'class': item_cls, 'data-step': str(i)})
|
|
2128
|
+
b.indent()
|
|
2129
|
+
|
|
2130
|
+
b.open_tag('div', {'class': 'q-step-indicator'})
|
|
2131
|
+
b.indent()
|
|
2132
|
+
b.text(step.icon if step.icon else str(i + 1))
|
|
2133
|
+
b.dedent()
|
|
2134
|
+
b.close_tag('div')
|
|
2135
|
+
|
|
2136
|
+
if show_labels and (step.title or step.description or step.error):
|
|
2137
|
+
b.open_tag('div', {'class': 'q-step-labels'})
|
|
2138
|
+
b.indent()
|
|
2139
|
+
if step.title:
|
|
2140
|
+
b.open_tag('div', {'class': 'q-step-title'})
|
|
2141
|
+
b.indent()
|
|
2142
|
+
b.text(step.title)
|
|
2143
|
+
b.dedent()
|
|
2144
|
+
b.close_tag('div')
|
|
2145
|
+
if step.description:
|
|
2146
|
+
b.open_tag('div', {'class': 'q-step-description'})
|
|
2147
|
+
b.indent()
|
|
2148
|
+
b.text(step.description)
|
|
2149
|
+
b.dedent()
|
|
2150
|
+
b.close_tag('div')
|
|
2151
|
+
if step.error:
|
|
2152
|
+
b.open_tag('div', {'class': 'q-step-description q-step-error'})
|
|
2153
|
+
b.indent()
|
|
2154
|
+
b.text(step.error)
|
|
2155
|
+
b.dedent()
|
|
2156
|
+
b.close_tag('div')
|
|
2157
|
+
if self._as_bool(step.optional, False):
|
|
2158
|
+
b.open_tag('div', {'class': 'q-step-optional'})
|
|
2159
|
+
b.indent()
|
|
2160
|
+
b.text('Optional')
|
|
2161
|
+
b.dedent()
|
|
2162
|
+
b.close_tag('div')
|
|
2163
|
+
b.dedent()
|
|
2164
|
+
b.close_tag('div')
|
|
2165
|
+
|
|
2166
|
+
if i < total - 1:
|
|
2167
|
+
b.open_tag('div', {'class': 'q-step-connector'})
|
|
2168
|
+
b.close_tag('div')
|
|
2169
|
+
|
|
2170
|
+
b.dedent()
|
|
2171
|
+
b.close_tag('div')
|
|
2172
|
+
b.dedent()
|
|
2173
|
+
b.close_tag('div')
|
|
2174
|
+
|
|
2175
|
+
# Step contents (only the current one is visible)
|
|
2176
|
+
b.open_tag('div', {'class': 'q-stepper-content'})
|
|
2177
|
+
b.indent()
|
|
2178
|
+
for i, step in enumerate(steps):
|
|
2179
|
+
content_cls = 'q-step-content' + (' active' if i == current else '')
|
|
2180
|
+
content_attrs = self._merge_attrs({'class': content_cls, 'data-step': str(i)},
|
|
2181
|
+
self._layout_attrs(step))
|
|
2182
|
+
b.open_tag('div', content_attrs)
|
|
2183
|
+
b.indent()
|
|
2184
|
+
self._render_children(step.children, b)
|
|
2185
|
+
b.dedent()
|
|
2186
|
+
b.close_tag('div')
|
|
2187
|
+
b.dedent()
|
|
2188
|
+
b.close_tag('div')
|
|
2189
|
+
|
|
2190
|
+
b.dedent()
|
|
2191
|
+
b.close_tag('div')
|
|
2192
|
+
|
|
2193
|
+
options = {
|
|
2194
|
+
'current': str(current),
|
|
2195
|
+
'linear': self._js_bool(linear),
|
|
2196
|
+
'clickable': self._js_bool(clickable),
|
|
2197
|
+
}
|
|
2198
|
+
on_change = self._js_change_handler(node.on_change, node.bind, 'index')
|
|
2199
|
+
if on_change:
|
|
2200
|
+
options['onChange'] = on_change
|
|
2201
|
+
if node.on_complete:
|
|
2202
|
+
complete_body = self._js_statement(node.on_complete, '')
|
|
2203
|
+
options['onComplete'] = 'function() { ' + complete_body + ' }'
|
|
2204
|
+
|
|
2205
|
+
sid = self._js_str(stepper_id)
|
|
2206
|
+
completed_js = '[' + ', '.join(str(i) for i in completed) + ']'
|
|
2207
|
+
self._component_inits.append(f"""(function() {{
|
|
2208
|
+
if (!window.__quantumStepper) return;
|
|
2209
|
+
var api = __quantumStepper.init({sid}, {self._js_options(options)});
|
|
2210
|
+
var state = __quantumStepper.get({sid});
|
|
2211
|
+
if (!api || !state) return;
|
|
2212
|
+
for (var key in api) {{ state[key] = api[key]; }}
|
|
2213
|
+
{completed_js}.forEach(function(index) {{ api.complete(index); }});
|
|
2214
|
+
}})();""")
|
|
2215
|
+
|
|
2216
|
+
def _render_step(self, node: UIStepNode, b: HtmlBuilder):
|
|
2217
|
+
"""Render a standalone ui:step (outside a stepper)."""
|
|
2218
|
+
attrs = self._merge_attrs({'class': 'q-step-item'}, self._layout_attrs(node))
|
|
2219
|
+
b.open_tag('div', attrs)
|
|
2220
|
+
b.indent()
|
|
2221
|
+
if node.title or node.description:
|
|
2222
|
+
b.open_tag('div', {'class': 'q-step-labels'})
|
|
2223
|
+
b.indent()
|
|
2224
|
+
if node.title:
|
|
2225
|
+
b.open_tag('div', {'class': 'q-step-title'})
|
|
2226
|
+
b.indent()
|
|
2227
|
+
b.text(node.title)
|
|
2228
|
+
b.dedent()
|
|
2229
|
+
b.close_tag('div')
|
|
2230
|
+
if node.description:
|
|
2231
|
+
b.open_tag('div', {'class': 'q-step-description'})
|
|
2232
|
+
b.indent()
|
|
2233
|
+
b.text(node.description)
|
|
2234
|
+
b.dedent()
|
|
2235
|
+
b.close_tag('div')
|
|
2236
|
+
b.dedent()
|
|
2237
|
+
b.close_tag('div')
|
|
2238
|
+
b.open_tag('div', {'class': 'q-step-content active'})
|
|
2239
|
+
b.indent()
|
|
2240
|
+
self._render_children(node.children, b)
|
|
2241
|
+
b.dedent()
|
|
2242
|
+
b.close_tag('div')
|
|
2243
|
+
b.dedent()
|
|
2244
|
+
b.close_tag('div')
|
|
2245
|
+
|
|
2246
|
+
# ------------------------------------------------------------------
|
|
2247
|
+
# Calendar / date picker renders
|
|
2248
|
+
# ------------------------------------------------------------------
|
|
2249
|
+
|
|
2250
|
+
def _calendar_settings(self, node) -> Dict:
|
|
2251
|
+
"""Collect the parsed calendar configuration shared by both renders."""
|
|
2252
|
+
mode = getattr(node, 'mode', 'single') or 'single'
|
|
2253
|
+
raw = [v.strip() for v in str(getattr(node, 'value', '') or '').split(',') if v.strip()]
|
|
2254
|
+
dates = [d for d in (self._parse_iso_date(v) for v in raw) if d]
|
|
2255
|
+
disabled_dates = {d for d in
|
|
2256
|
+
(self._parse_iso_date(v) for v in
|
|
2257
|
+
str(getattr(node, 'disabled_dates', '') or '').split(','))
|
|
2258
|
+
if d}
|
|
2259
|
+
disabled_days = {self._as_int(v, -1) for v in
|
|
2260
|
+
str(getattr(node, 'disabled_days', '') or '').split(',') if v.strip()}
|
|
2261
|
+
return {
|
|
2262
|
+
'mode': mode,
|
|
2263
|
+
'selected': dates[0] if dates else None,
|
|
2264
|
+
'range_start': dates[0] if mode == 'range' and dates else None,
|
|
2265
|
+
'range_end': dates[1] if mode == 'range' and len(dates) > 1 else None,
|
|
2266
|
+
'multiple': dates if mode == 'multiple' else [],
|
|
2267
|
+
'min_date': self._parse_iso_date(getattr(node, 'min_date', None)),
|
|
2268
|
+
'max_date': self._parse_iso_date(getattr(node, 'max_date', None)),
|
|
2269
|
+
'disabled_dates': disabled_dates,
|
|
2270
|
+
'disabled_days': disabled_days,
|
|
2271
|
+
'first_day': self._as_int(getattr(node, 'first_day_of_week', 0), 0) % 7,
|
|
2272
|
+
'show_weeks': self._as_bool(getattr(node, 'show_week_numbers', False), False),
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
def _calendar_day_disabled(self, day: _date, settings: Dict) -> bool:
|
|
2276
|
+
"""Whether a day cannot be selected (min/max, weekday or explicit list)."""
|
|
2277
|
+
if settings['min_date'] and day < settings['min_date']:
|
|
2278
|
+
return True
|
|
2279
|
+
if settings['max_date'] and day > settings['max_date']:
|
|
2280
|
+
return True
|
|
2281
|
+
# JS weekday numbering: 0 = Sunday
|
|
2282
|
+
if ((day.weekday() + 1) % 7) in settings['disabled_days']:
|
|
2283
|
+
return True
|
|
2284
|
+
return day in settings['disabled_dates']
|
|
2285
|
+
|
|
2286
|
+
def _calendar_selection_classes(self, day: _date, settings: Dict) -> List[str]:
|
|
2287
|
+
"""Selection related CSS classes for a single day cell."""
|
|
2288
|
+
mode = settings['mode']
|
|
2289
|
+
classes: List[str] = []
|
|
2290
|
+
if mode == 'range':
|
|
2291
|
+
if settings['range_start'] == day:
|
|
2292
|
+
classes.extend(['range-start', 'selected'])
|
|
2293
|
+
if settings['range_end'] == day:
|
|
2294
|
+
classes.extend(['range-end', 'selected'])
|
|
2295
|
+
if (settings['range_start'] and settings['range_end']
|
|
2296
|
+
and settings['range_start'] < day < settings['range_end']):
|
|
2297
|
+
classes.append('in-range')
|
|
2298
|
+
elif mode == 'multiple':
|
|
2299
|
+
if day in settings['multiple']:
|
|
2300
|
+
classes.append('selected')
|
|
2301
|
+
elif settings['selected'] == day:
|
|
2302
|
+
classes.append('selected')
|
|
2303
|
+
return classes
|
|
2304
|
+
|
|
2305
|
+
# The month navigation buttons carry .q-calendar-nav themselves (that is
|
|
2306
|
+
# what CALENDAR_JS renders), while CSS_THEME styles `.q-calendar-nav button`.
|
|
2307
|
+
CALENDAR_NAV_CSS = (
|
|
2308
|
+
".q-calendar-header .q-calendar-nav { display: inline-flex; align-items: center;"
|
|
2309
|
+
" justify-content: center; width: 28px; height: 28px; background: none;"
|
|
2310
|
+
" border: 1px solid var(--q-border); border-radius: var(--q-radius); cursor: pointer; }\n"
|
|
2311
|
+
".q-calendar-header .q-calendar-nav:hover { background: var(--q-light); }"
|
|
2312
|
+
)
|
|
2313
|
+
|
|
2314
|
+
def _render_calendar_markup(self, b: HtmlBuilder, target_id: str, settings: Dict):
|
|
2315
|
+
"""Render the month header and day grid of a calendar.
|
|
2316
|
+
|
|
2317
|
+
The markup mirrors what CALENDAR_JS re-renders on interaction, so the
|
|
2318
|
+
page shows a usable month grid before (and without) JavaScript.
|
|
2319
|
+
"""
|
|
2320
|
+
if self.CALENDAR_NAV_CSS not in self._extra_css:
|
|
2321
|
+
self._extra_css.append(self.CALENDAR_NAV_CSS)
|
|
2322
|
+
today = _date.today()
|
|
2323
|
+
year, month = today.year, today.month
|
|
2324
|
+
target = self._js_str(target_id)
|
|
2325
|
+
|
|
2326
|
+
# Same element shape CALENDAR_JS re-renders, so markup stays stable
|
|
2327
|
+
# once the runtime takes over.
|
|
2328
|
+
b.open_tag('div', {'class': 'q-calendar-header'})
|
|
2329
|
+
b.indent()
|
|
2330
|
+
b.open_tag('button', {'class': 'q-calendar-nav', 'type': 'button',
|
|
2331
|
+
'aria-label': 'Previous month',
|
|
2332
|
+
'onclick': f"__quantumCalendar.prevMonth({target})"})
|
|
2333
|
+
b.indent()
|
|
2334
|
+
b.text('‹')
|
|
2335
|
+
b.dedent()
|
|
2336
|
+
b.close_tag('button')
|
|
2337
|
+
b.open_tag('span', {'class': 'q-calendar-title'})
|
|
2338
|
+
b.indent()
|
|
2339
|
+
b.text(f'{MONTH_NAMES[month - 1]} {year}')
|
|
2340
|
+
b.dedent()
|
|
2341
|
+
b.close_tag('span')
|
|
2342
|
+
b.open_tag('button', {'class': 'q-calendar-nav', 'type': 'button',
|
|
2343
|
+
'aria-label': 'Next month',
|
|
2344
|
+
'onclick': f"__quantumCalendar.nextMonth({target})"})
|
|
2345
|
+
b.indent()
|
|
2346
|
+
b.text('›')
|
|
2347
|
+
b.dedent()
|
|
2348
|
+
b.close_tag('button')
|
|
2349
|
+
b.dedent()
|
|
2350
|
+
b.close_tag('div')
|
|
2351
|
+
|
|
2352
|
+
first_day = settings['first_day']
|
|
2353
|
+
b.open_tag('div', {'class': 'q-calendar-grid'})
|
|
2354
|
+
b.indent()
|
|
2355
|
+
|
|
2356
|
+
if settings['show_weeks']:
|
|
2357
|
+
b.open_tag('div', {'class': 'q-calendar-weekday'})
|
|
2358
|
+
b.close_tag('div')
|
|
2359
|
+
for name in DAY_NAMES[first_day:] + DAY_NAMES[:first_day]:
|
|
2360
|
+
b.open_tag('div', {'class': 'q-calendar-weekday'})
|
|
2361
|
+
b.indent()
|
|
2362
|
+
b.text(name)
|
|
2363
|
+
b.dedent()
|
|
2364
|
+
b.close_tag('div')
|
|
2365
|
+
|
|
2366
|
+
# calendar.Calendar uses 0 = Monday, the node uses 0 = Sunday
|
|
2367
|
+
weeks = _calendar.Calendar((first_day + 6) % 7).monthdatescalendar(year, month)
|
|
2368
|
+
for week in weeks:
|
|
2369
|
+
if settings['show_weeks']:
|
|
2370
|
+
b.open_tag('div', {'class': 'q-calendar-day week-number'})
|
|
2371
|
+
b.indent()
|
|
2372
|
+
b.text(str(week[0].isocalendar()[1]))
|
|
2373
|
+
b.dedent()
|
|
2374
|
+
b.close_tag('div')
|
|
2375
|
+
for day in week:
|
|
2376
|
+
classes = ['q-calendar-day']
|
|
2377
|
+
onclick = None
|
|
2378
|
+
if day.month != month:
|
|
2379
|
+
classes.append('other-month')
|
|
2380
|
+
else:
|
|
2381
|
+
if day == today:
|
|
2382
|
+
classes.append('today')
|
|
2383
|
+
if self._calendar_day_disabled(day, settings):
|
|
2384
|
+
classes.append('disabled')
|
|
2385
|
+
else:
|
|
2386
|
+
onclick = f"__quantumCalendar.selectDate({target}, {day.day})"
|
|
2387
|
+
classes.extend(self._calendar_selection_classes(day, settings))
|
|
2388
|
+
day_attrs = {'class': ' '.join(classes)}
|
|
2389
|
+
if onclick:
|
|
2390
|
+
day_attrs['onclick'] = onclick
|
|
2391
|
+
day_attrs['role'] = 'button'
|
|
2392
|
+
b.open_tag('div', day_attrs)
|
|
2393
|
+
b.indent()
|
|
2394
|
+
b.text(str(day.day))
|
|
2395
|
+
b.dedent()
|
|
2396
|
+
b.close_tag('div')
|
|
2397
|
+
|
|
2398
|
+
b.dedent()
|
|
2399
|
+
b.close_tag('div')
|
|
2400
|
+
|
|
2401
|
+
def _calendar_init_options(self, node, settings: Dict,
|
|
2402
|
+
on_change: Optional[str]) -> Dict[str, str]:
|
|
2403
|
+
"""Build the option literal handed to __quantumCalendar.init."""
|
|
2404
|
+
options = {
|
|
2405
|
+
'mode': self._js_str(settings['mode']),
|
|
2406
|
+
'firstDayOfWeek': str(settings['first_day']),
|
|
2407
|
+
'showWeekNumbers': self._js_bool(settings['show_weeks']),
|
|
2408
|
+
}
|
|
2409
|
+
if getattr(node, 'value', None):
|
|
2410
|
+
options['value'] = self._js_str(node.value)
|
|
2411
|
+
if getattr(node, 'min_date', None):
|
|
2412
|
+
options['minDate'] = self._js_str(node.min_date)
|
|
2413
|
+
if getattr(node, 'max_date', None):
|
|
2414
|
+
options['maxDate'] = self._js_str(node.max_date)
|
|
2415
|
+
if getattr(node, 'disabled_dates', None):
|
|
2416
|
+
options['disabledDates'] = self._js_str(node.disabled_dates)
|
|
2417
|
+
if getattr(node, 'disabled_days', None):
|
|
2418
|
+
options['disabledDays'] = self._js_str(node.disabled_days)
|
|
2419
|
+
if on_change:
|
|
2420
|
+
options['onChange'] = on_change
|
|
2421
|
+
return options
|
|
2422
|
+
|
|
2423
|
+
def _calendar_init_script(self, calendar_id: str, options: Dict[str, str],
|
|
2424
|
+
show_weeks: bool) -> str:
|
|
2425
|
+
"""Boot a calendar and expose its API through __quantumCalendar.get()."""
|
|
2426
|
+
cid = self._js_str(calendar_id)
|
|
2427
|
+
weeks_block = ''
|
|
2428
|
+
if show_weeks:
|
|
2429
|
+
weeks_block = f"""
|
|
2430
|
+
['selectDate', 'prevMonth', 'nextMonth'].forEach(function(name) {{
|
|
2431
|
+
var fn = api[name];
|
|
2432
|
+
state[name] = function() {{
|
|
2433
|
+
var result = fn.apply(null, arguments);
|
|
2434
|
+
__quantumCalendarWeeks.apply({cid});
|
|
2435
|
+
return result;
|
|
2436
|
+
}};
|
|
2437
|
+
}});
|
|
2438
|
+
__quantumCalendarWeeks.apply({cid});"""
|
|
2439
|
+
return f"""(function() {{
|
|
2440
|
+
if (!window.__quantumCalendar) return;
|
|
2441
|
+
var api = __quantumCalendar.init({cid}, {self._js_options(options)});
|
|
2442
|
+
var state = __quantumCalendar.get({cid});
|
|
2443
|
+
if (!api || !state) return;
|
|
2444
|
+
for (var key in api) {{ state[key] = api[key]; }}{weeks_block}
|
|
2445
|
+
}})();"""
|
|
2446
|
+
|
|
2447
|
+
def _render_calendar(self, node: UICalendarNode, b: HtmlBuilder):
|
|
2448
|
+
"""Render ui:calendar as an interactive month grid."""
|
|
2449
|
+
self._features_used.add('calendar')
|
|
2450
|
+
self._has_calendar = True
|
|
2451
|
+
self._calendar_counter += 1
|
|
2452
|
+
|
|
2453
|
+
calendar_id = node.ui_id or f'q-calendar-{self._calendar_counter}'
|
|
2454
|
+
settings = self._calendar_settings(node)
|
|
2455
|
+
|
|
2456
|
+
attrs = self._merge_attrs(
|
|
2457
|
+
{'class': 'q-calendar', 'id': calendar_id, 'data-mode': settings['mode']},
|
|
2458
|
+
self._layout_attrs(node))
|
|
2459
|
+
b.open_tag('div', attrs)
|
|
2460
|
+
b.indent()
|
|
2461
|
+
self._render_calendar_markup(b, calendar_id, settings)
|
|
2462
|
+
b.dedent()
|
|
2463
|
+
b.close_tag('div')
|
|
2464
|
+
|
|
2465
|
+
if settings['show_weeks']:
|
|
2466
|
+
self._has_calendar_weeks = True
|
|
2467
|
+
# One extra column for the week numbers
|
|
2468
|
+
self._extra_css.append(
|
|
2469
|
+
f"#{calendar_id} .q-calendar-grid {{ grid-template-columns: repeat(8, 1fr); }}")
|
|
2470
|
+
|
|
2471
|
+
on_change = self._js_change_handler(node.on_change, node.bind, 'value')
|
|
2472
|
+
options = self._calendar_init_options(node, settings, on_change)
|
|
2473
|
+
self._component_inits.append(
|
|
2474
|
+
self._calendar_init_script(calendar_id, options, settings['show_weeks']))
|
|
2475
|
+
|
|
2476
|
+
def _render_date_picker(self, node: UIDatePickerNode, b: HtmlBuilder):
|
|
2477
|
+
"""Render ui:date-picker as an input backed by a calendar popup."""
|
|
2478
|
+
self._features_used.add('date_picker')
|
|
2479
|
+
self._has_date_picker = True
|
|
2480
|
+
self._date_picker_counter += 1
|
|
2481
|
+
|
|
2482
|
+
picker_id = node.ui_id or f'q-date-picker-{self._date_picker_counter}'
|
|
2483
|
+
pattern = node.format or 'YYYY-MM-DD'
|
|
2484
|
+
selected = self._parse_iso_date(node.value)
|
|
2485
|
+
disabled = self._as_bool(node.disabled, False)
|
|
2486
|
+
clearable = self._as_bool(node.clearable, True)
|
|
2487
|
+
required = self._as_bool(node.required, False)
|
|
2488
|
+
|
|
2489
|
+
settings = self._calendar_settings(node)
|
|
2490
|
+
settings['mode'] = 'single'
|
|
2491
|
+
|
|
2492
|
+
attrs = self._merge_attrs({'class': 'q-date-picker', 'id': picker_id},
|
|
2493
|
+
self._layout_attrs(node))
|
|
2494
|
+
b.open_tag('div', attrs)
|
|
2495
|
+
b.indent()
|
|
2496
|
+
|
|
2497
|
+
input_attrs = {
|
|
2498
|
+
'type': 'text',
|
|
2499
|
+
'class': 'q-date-picker-input',
|
|
2500
|
+
'id': f'{picker_id}-input',
|
|
2501
|
+
'value': self._format_date(selected, pattern) if selected else '',
|
|
2502
|
+
'placeholder': node.placeholder or 'Select date',
|
|
2503
|
+
'data-format': pattern,
|
|
2504
|
+
'autocomplete': 'off',
|
|
2505
|
+
'aria-haspopup': 'dialog',
|
|
2506
|
+
'readonly': True,
|
|
2507
|
+
}
|
|
2508
|
+
if required:
|
|
2509
|
+
input_attrs['required'] = True
|
|
2510
|
+
if disabled:
|
|
2511
|
+
input_attrs['disabled'] = True
|
|
2512
|
+
b.open_tag('input', input_attrs, self_closing=True)
|
|
2513
|
+
|
|
2514
|
+
# Canonical ISO value, so forms submit a machine readable date
|
|
2515
|
+
hidden_attrs = {'type': 'hidden', 'id': f'{picker_id}-value',
|
|
2516
|
+
'value': selected.isoformat() if selected else ''}
|
|
2517
|
+
if node.bind:
|
|
2518
|
+
hidden_attrs['name'] = node.bind
|
|
2519
|
+
b.open_tag('input', hidden_attrs, self_closing=True)
|
|
2520
|
+
|
|
2521
|
+
if clearable and not disabled:
|
|
2522
|
+
b.open_tag('button', {
|
|
2523
|
+
'class': 'q-date-picker-clear', 'type': 'button', 'aria-label': 'Clear date',
|
|
2524
|
+
'onclick': f"__quantumDatePicker.clear({self._js_str(picker_id)})"})
|
|
2525
|
+
b.indent()
|
|
2526
|
+
b.text('×')
|
|
2527
|
+
b.dedent()
|
|
2528
|
+
b.close_tag('button')
|
|
2529
|
+
|
|
2530
|
+
b.open_tag('span', {'class': 'q-date-picker-icon', 'aria-hidden': 'true'})
|
|
2531
|
+
b.indent()
|
|
2532
|
+
b.text('📅')
|
|
2533
|
+
b.dedent()
|
|
2534
|
+
b.close_tag('span')
|
|
2535
|
+
|
|
2536
|
+
calendar_id = f'{picker_id}-cal'
|
|
2537
|
+
b.open_tag('div', {'class': 'q-date-picker-dropdown', 'id': f'{picker_id}-dropdown',
|
|
2538
|
+
'role': 'dialog'})
|
|
2539
|
+
b.indent()
|
|
2540
|
+
b.open_tag('div', {'class': 'q-calendar', 'id': calendar_id, 'data-mode': 'single'})
|
|
2541
|
+
b.indent()
|
|
2542
|
+
self._render_calendar_markup(b, calendar_id, settings)
|
|
2543
|
+
b.dedent()
|
|
2544
|
+
b.close_tag('div')
|
|
2545
|
+
b.dedent()
|
|
2546
|
+
b.close_tag('div')
|
|
2547
|
+
|
|
2548
|
+
b.dedent()
|
|
2549
|
+
b.close_tag('div')
|
|
2550
|
+
|
|
2551
|
+
pid = self._js_str(picker_id)
|
|
2552
|
+
picker_change = (f"function(value) {{ __quantumDatePicker.setValue({pid}, value); }}")
|
|
2553
|
+
options = self._calendar_init_options(node, settings, picker_change)
|
|
2554
|
+
options['mode'] = self._js_str('single')
|
|
2555
|
+
|
|
2556
|
+
register_options = {
|
|
2557
|
+
'format': self._js_str(pattern),
|
|
2558
|
+
'disabled': self._js_bool(disabled),
|
|
2559
|
+
}
|
|
2560
|
+
on_change = self._js_change_handler(node.on_change, node.bind, 'value')
|
|
2561
|
+
if on_change:
|
|
2562
|
+
register_options['onChange'] = on_change
|
|
2563
|
+
|
|
2564
|
+
init = self._calendar_init_script(calendar_id, options, settings['show_weeks'])
|
|
2565
|
+
init += (f"\nif (window.__quantumDatePicker) __quantumDatePicker.register({pid}, "
|
|
2566
|
+
f"{self._js_options(register_options)});")
|
|
2567
|
+
self._component_inits.append(init)
|