Coverage for fastblocks / adapters / icons / heroicons.py: 27%
156 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-19 12:49 -0800
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-19 12:49 -0800
1"""Heroicons adapter for FastBlocks with outline/solid variants."""
3from contextlib import suppress
4from typing import Any
5from uuid import UUID
7from acb.depends import depends
9from ._base import IconsBase, IconsBaseSettings
10from ._utils import (
11 add_accessibility_attributes,
12 build_attr_string,
13 process_animations,
14 process_semantic_colors,
15 process_state_attributes,
16 process_transformations,
17)
20class HeroiconsIconsSettings(IconsBaseSettings):
21 """Settings for Heroicons adapter."""
23 # Required ACB 0.19.0+ metadata
24 MODULE_ID: UUID = UUID("01937d86-ad8f-c29a-eb1c-0a1b2c3d4e5f") # Static UUID7
25 MODULE_STATUS: str = "stable"
27 # Heroicons configuration
28 version: str = "2.0.18"
29 cdn_url: str = "https://cdn.jsdelivr.net/npm/heroicons"
30 default_variant: str = "outline" # outline, solid, mini
31 default_size: str = "24" # 20 (mini), 24 (outline/solid)
33 # Variant settings
34 enabled_variants: list[str] = ["outline", "solid", "mini"]
36 # Icon mapping for common names and aliases
37 icon_aliases: dict[str, str] = {
38 "home": "home",
39 "user": "user",
40 "settings": "cog-6-tooth",
41 "search": "magnifying-glass",
42 "menu": "bars-3",
43 "close": "x-mark",
44 "check": "check",
45 "error": "exclamation-triangle",
46 "info": "information-circle",
47 "success": "check-circle",
48 "warning": "exclamation-triangle",
49 "edit": "pencil",
50 "delete": "trash",
51 "save": "document-arrow-down",
52 "download": "arrow-down-tray",
53 "upload": "arrow-up-tray",
54 "email": "envelope",
55 "phone": "phone",
56 "location": "map-pin",
57 "calendar": "calendar-days",
58 "clock": "clock",
59 "heart": "heart",
60 "star": "star",
61 "share": "share",
62 "link": "link",
63 "copy": "document-duplicate",
64 "cut": "scissors",
65 "paste": "clipboard",
66 "undo": "arrow-uturn-left",
67 "redo": "arrow-uturn-right",
68 "refresh": "arrow-path",
69 "logout": "arrow-right-on-rectangle",
70 "login": "arrow-left-on-rectangle",
71 "plus": "plus",
72 "minus": "minus",
73 "eye": "eye",
74 "eye-off": "eye-slash",
75 "lock": "lock-closed",
76 "unlock": "lock-open",
77 }
79 # Size presets
80 size_presets: dict[str, str] = {
81 "xs": "16",
82 "sm": "20",
83 "md": "24",
84 "lg": "28",
85 "xl": "32",
86 "2xl": "40",
87 "3xl": "48",
88 }
91class HeroiconsIcons(IconsBase):
92 """Heroicons adapter with outline/solid/mini variants."""
94 # Required ACB 0.19.0+ metadata
95 MODULE_ID: UUID = UUID("01937d86-ad8f-c29a-eb1c-0a1b2c3d4e5f") # Static UUID7
96 MODULE_STATUS: str = "stable"
98 def __init__(self) -> None:
99 """Initialize Heroicons adapter."""
100 super().__init__()
101 self.settings: HeroiconsIconsSettings | None = None
103 # Register with ACB dependency system
104 with suppress(Exception):
105 depends.set(self)
107 def get_stylesheet_links(self) -> list[str]:
108 """Get Heroicons stylesheet links."""
109 if not self.settings:
110 self.settings = HeroiconsIconsSettings()
112 links = []
114 # Heroicons base CSS
115 heroicons_css = self._generate_heroicons_css()
116 links.append(f"<style>{heroicons_css}</style>")
118 return links
120 def _generate_heroicons_css(self) -> str:
121 """Generate Heroicons-specific CSS."""
122 if not self.settings:
123 self.settings = HeroiconsIconsSettings()
125 return f"""
126/* Heroicons Base Styles */
127.heroicon {{
128 display: inline-block;
129 vertical-align: -0.125em;
130 width: {self.settings.default_size}px;
131 height: {self.settings.default_size}px;
132 flex-shrink: 0;
133}}
135/* Size variants */
136.heroicon-xs {{ width: 16px; height: 16px; }}
137.heroicon-sm {{ width: 20px; height: 20px; }}
138.heroicon-md {{ width: 24px; height: 24px; }}
139.heroicon-lg {{ width: 28px; height: 28px; }}
140.heroicon-xl {{ width: 32px; height: 32px; }}
141.heroicon-2xl {{ width: 40px; height: 40px; }}
142.heroicon-3xl {{ width: 48px; height: 48px; }}
144/* Variant-specific styles */
145.heroicon-outline {{
146 stroke: currentColor;
147 fill: none;
148 stroke-width: 1.5;
149}}
151.heroicon-solid {{
152 fill: currentColor;
153}}
155.heroicon-mini {{
156 fill: currentColor;
157 width: 20px;
158 height: 20px;
159}}
161/* Rotation and transformation */
162.heroicon-rotate-90 {{ transform: rotate(90deg); }}
163.heroicon-rotate-180 {{ transform: rotate(180deg); }}
164.heroicon-rotate-270 {{ transform: rotate(270deg); }}
165.heroicon-flip-horizontal {{ transform: scaleX(-1); }}
166.heroicon-flip-vertical {{ transform: scaleY(-1); }}
168/* Animation support */
169.heroicon-spin {{
170 animation: heroicon-spin 2s linear infinite;
171}}
173.heroicon-pulse {{
174 animation: heroicon-pulse 2s ease-in-out infinite alternate;
175}}
177.heroicon-bounce {{
178 animation: heroicon-bounce 1s ease-in-out infinite;
179}}
181@keyframes heroicon-spin {{
182 0% {{ transform: rotate(0deg); }}
183 100% {{ transform: rotate(360deg); }}
184}}
186@keyframes heroicon-pulse {{
187 from {{ opacity: 1; }}
188 to {{ opacity: 0.25; }}
189}}
191@keyframes heroicon-bounce {{
192 0%, 100% {{ transform: translateY(0); }}
193 50% {{ transform: translateY(-25%); }}
194}}
196/* Color utilities */
197.heroicon-primary {{ color: var(--primary-color, #3b82f6); }}
198.heroicon-secondary {{ color: var(--secondary-color, #6b7280); }}
199.heroicon-success {{ color: var(--success-color, #10b981); }}
200.heroicon-warning {{ color: var(--warning-color, #f59e0b); }}
201.heroicon-danger {{ color: var(--danger-color, #ef4444); }}
202.heroicon-info {{ color: var(--info-color, #3b82f6); }}
203.heroicon-gray {{ color: var(--gray-color, #6b7280); }}
204.heroicon-white {{ color: white; }}
205.heroicon-black {{ color: black; }}
207/* Interactive states */
208.heroicon-interactive {{
209 cursor: pointer;
210 transition: all 0.2s ease;
211}}
213.heroicon-interactive:hover {{
214 transform: scale(1.1);
215 opacity: 0.8;
216}}
218.heroicon-interactive:active {{
219 transform: scale(0.95);
220}}
222/* States */
223.heroicon-disabled {{
224 opacity: 0.5;
225 cursor: not-allowed;
226}}
228.heroicon-loading {{
229 opacity: 0.6;
230}}
232/* Button integration */
233.btn .heroicon {{
234 margin-right: 0.5rem;
235}}
237.btn .heroicon:last-child {{
238 margin-right: 0;
239 margin-left: 0.5rem;
240}}
242.btn .heroicon:only-child {{
243 margin: 0;
244}}
246/* Badge integration */
247.badge .heroicon {{
248 width: 1em;
249 height: 1em;
250 margin-right: 0.25rem;
251}}
253/* Navigation integration */
254.nav-link .heroicon {{
255 width: 1.25rem;
256 height: 1.25rem;
257 margin-right: 0.5rem;
258}}
259"""
261 def get_icon_class(self, icon_name: str, variant: str | None = None) -> str:
262 if not self.settings:
263 self.settings = HeroiconsIconsSettings()
265 # Resolve icon aliases
266 if icon_name in self.settings.icon_aliases:
267 icon_name = self.settings.icon_aliases[icon_name]
269 # Use default variant if not specified
270 if not variant:
271 variant = self.settings.default_variant
273 # Validate variant
274 if variant not in self.settings.enabled_variants:
275 variant = self.settings.default_variant
277 return f"heroicon heroicon-{variant}"
279 def _get_icon_size(self, size: str | None, variant: str) -> str:
280 """Determine icon size based on input and variant."""
281 if size and size in self.settings.size_presets:
282 return self.settings.size_presets[size]
283 elif size and size.isdigit():
284 return size
285 else:
286 # Default size based on variant
287 return "20" if variant == "mini" else self.settings.default_size
289 def _build_icon_class(
290 self, icon_name: str, variant: str, size: str | None, attributes: dict[str, Any]
291 ) -> str:
292 """Build the complete icon class string."""
293 # Build base icon class
294 icon_class = self.get_icon_class(icon_name, variant)
296 # Add size class if using preset
297 if size and size in self.settings.size_presets:
298 icon_class += f" heroicon-{size}"
300 # Add custom classes
301 if "class" in attributes:
302 icon_class += f" {attributes.pop('class')}"
304 # Process attributes using shared utilities
305 transform_classes, attributes = process_transformations(attributes, "heroicon")
306 animation_classes, attributes = process_animations(
307 attributes, ["spin", "pulse", "bounce"], "heroicon"
308 )
309 semantic_colors = [
310 "primary",
311 "secondary",
312 "success",
313 "warning",
314 "danger",
315 "info",
316 "gray",
317 "white",
318 "black",
319 ]
320 color_class, attributes = process_semantic_colors(
321 attributes, semantic_colors, "heroicon"
322 )
323 state_classes, attributes = process_state_attributes(attributes, "heroicon")
325 # Combine all classes
326 return icon_class + (
327 transform_classes + animation_classes + color_class + state_classes
328 )
330 def _build_svg_attributes(
331 self, icon_class: str, icon_size: str, variant: str, attributes: dict[str, Any]
332 ) -> dict[str, Any]:
333 """Build SVG attributes with variant-specific settings."""
334 # Build SVG attributes
335 svg_attrs = {
336 "class": icon_class,
337 "width": icon_size,
338 "height": icon_size,
339 "viewBox": f"0 0 {icon_size} {icon_size}",
340 } | attributes
342 # Add accessibility and variant-specific attributes
343 svg_attrs = add_accessibility_attributes(svg_attrs)
344 if variant == "outline":
345 svg_attrs.setdefault("stroke-width", "1.5")
346 svg_attrs.setdefault("stroke", "currentColor")
347 svg_attrs.setdefault("fill", "none")
348 else:
349 svg_attrs.setdefault("fill", "currentColor")
351 return svg_attrs
353 def get_icon_tag(
354 self,
355 icon_name: str,
356 variant: str | None = None,
357 size: str | None = None,
358 **attributes: Any,
359 ) -> str:
360 if not self.settings:
361 self.settings = HeroiconsIconsSettings()
363 # Resolve icon aliases
364 if icon_name in self.settings.icon_aliases:
365 icon_name = self.settings.icon_aliases[icon_name]
367 # Use default variant if not specified
368 if not variant:
369 variant = self.settings.default_variant
371 # Validate variant
372 if variant not in self.settings.enabled_variants:
373 variant = self.settings.default_variant
375 # Determine size
376 icon_size = self._get_icon_size(size, variant)
378 # Build icon class
379 icon_class = self._build_icon_class(icon_name, variant, size, attributes)
381 # Build SVG attributes
382 svg_attrs = self._build_svg_attributes(
383 icon_class, icon_size, variant, attributes
384 )
386 # Generate SVG content and build tag
387 svg_content = self._get_icon_svg_content(icon_name, variant)
388 attr_string = build_attr_string(svg_attrs)
389 return f"<svg {attr_string}>{svg_content}</svg>"
391 def _get_icon_svg_content(self, icon_name: str, variant: str) -> str:
392 """Get SVG content for specific icon and variant."""
393 # This would typically come from the Heroicons icon registry
394 # For now, return placeholder content for common icons
396 # Common icon paths (simplified examples)
397 icon_paths = {
398 "home": {
399 "outline": '<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />',
400 "solid": '<path d="M11.47 3.84a.75.75 0 011.06 0l8.69 8.69a.75.75 0 101.06-1.06l-8.689-8.69a2.25 2.25 0 00-3.182 0l-8.69 8.69a.75.75 0 001.061 1.06l8.69-8.69z"/><path d="M12 5.432l8.159 8.159c.03.03.06.058.091.086v6.198c0 1.035-.84 1.875-1.875 1.875H15a.75.75 0 01-.75-.75v-4.5a.75.75 0 00-.75-.75h-3a.75.75 0 00-.75.75V21a.75.75 0 01-.75.75H5.625a1.875 1.875 0 01-1.875-1.875v-6.198a2.29 2.29 0 00.091-.086L12 5.432z"/>',
401 "mini": '<path d="M9.293 2.293a1 1 0 011.414 0l7 7A1 1 0 0117 11h-1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-3a1 1 0 00-1-1H9a1 1 0 00-1 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-6H3a1 1 0 01-.707-1.707l7-7z"/>',
402 },
403 "user": {
404 "outline": '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />',
405 "solid": '<path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd" />',
406 "mini": '<path d="M10 8a3 3 0 100-6 3 3 0 000 6zM3.465 14.493a1.23 1.23 0 00.41 1.412A9.957 9.957 0 0010 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 00-13.074.003z"/>',
407 },
408 "x-mark": {
409 "outline": '<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />',
410 "solid": '<path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 011.06 0L12 10.94l5.47-5.47a.75.75 0 111.06 1.06L13.06 12l5.47 5.47a.75.75 0 11-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 01-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 010-1.06z" clip-rule="evenodd" />',
411 "mini": '<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>',
412 },
413 "check": {
414 "outline": '<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />',
415 "solid": '<path fill-rule="evenodd" d="M19.916 4.626a.75.75 0 01.208 1.04l-9 13.5a.75.75 0 01-1.154.114l-6-6a.75.75 0 011.06-1.06l5.353 5.353 8.493-12.739a.75.75 0 011.04-.208z" clip-rule="evenodd" />',
416 "mini": '<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />',
417 },
418 }
420 # Return path for the requested icon and variant
421 if icon_name in icon_paths and variant in icon_paths[icon_name]:
422 return icon_paths[icon_name][variant]
424 # Fallback for unknown icons
425 return f"<!-- {icon_name} ({variant}) not found -->"
427 def get_icon_sprite_url(self, variant: str = "outline") -> str:
428 """Get URL for Heroicons sprite file."""
429 if not self.settings:
430 self.settings = HeroiconsIconsSettings()
432 return f"{self.settings.cdn_url}@{self.settings.version}/{variant}.svg"
434 @staticmethod
435 def get_available_icons() -> dict[str, list[str]]:
436 """Get list of available icons by category."""
437 return {
438 "general": [
439 "home",
440 "user",
441 "cog-6-tooth",
442 "magnifying-glass",
443 "bars-3",
444 "x-mark",
445 "check",
446 "plus",
447 "minus",
448 "ellipsis-horizontal",
449 ],
450 "navigation": [
451 "arrow-left",
452 "arrow-right",
453 "arrow-up",
454 "arrow-down",
455 "chevron-left",
456 "chevron-right",
457 "chevron-up",
458 "chevron-down",
459 "arrow-path",
460 "arrow-uturn-left",
461 "arrow-uturn-right",
462 ],
463 "communication": [
464 "envelope",
465 "phone",
466 "chat-bubble-left",
467 "paper-airplane",
468 "bell",
469 "speaker-wave",
470 "microphone",
471 "video-camera",
472 ],
473 "media": [
474 "play",
475 "pause",
476 "stop",
477 "backward",
478 "forward",
479 "speaker-wave",
480 "speaker-x-mark",
481 "musical-note",
482 ],
483 "file": [
484 "document",
485 "folder",
486 "arrow-down-tray",
487 "arrow-up-tray",
488 "document-arrow-down",
489 "document-text",
490 "photo",
491 "film",
492 ],
493 "editing": [
494 "pencil",
495 "trash",
496 "document-duplicate",
497 "scissors",
498 "clipboard",
499 "eye",
500 "eye-slash",
501 "lock-closed",
502 "lock-open",
503 ],
504 "status": [
505 "check-circle",
506 "x-circle",
507 "exclamation-triangle",
508 "information-circle",
509 "question-mark-circle",
510 "light-bulb",
511 ],
512 }
515# Template filter registration for FastBlocks
516def _create_hero_button(
517 text: str,
518 icon: str | None,
519 variant: str,
520 icon_position: str,
521 icons: HeroiconsIcons,
522 **attributes: Any,
523) -> str:
524 """Build button HTML with Heroicons icon."""
525 btn_class = attributes.pop("class", "btn btn-primary")
527 # Build button content
528 if icon:
529 icon_tag = icons.get_icon_tag(icon, variant, size="sm")
530 if icon_position == "left":
531 content = f"{icon_tag} {text}"
532 elif icon_position == "right":
533 content = f"{text} {icon_tag}"
534 else:
535 content = text
536 else:
537 content = text
539 # Build button attributes
540 btn_attrs = {"class": btn_class} | attributes
541 attr_string = " ".join(f'{k}="{v}"' for k, v in btn_attrs.items())
543 return f"<button {attr_string}>{content}</button>"
546def _create_hero_badge(
547 text: str,
548 icon: str | None,
549 variant: str,
550 icons: HeroiconsIcons,
551 **attributes: Any,
552) -> str:
553 """Build badge HTML with Heroicons icon."""
554 badge_class = attributes.pop("class", "badge badge-primary")
556 # Build badge content
557 if icon:
558 icon_tag = icons.get_icon_tag(icon, variant, size="xs")
559 content = f"{icon_tag} {text}"
560 else:
561 content = text
563 # Build badge attributes
564 badge_attrs = {"class": badge_class} | attributes
565 attr_string = " ".join(f'{k}="{v}"' for k, v in badge_attrs.items())
567 return f"<span {attr_string}>{content}</span>"
570def register_heroicons_filters(env: Any) -> None:
571 """Register Heroicons filters for Jinja2 templates."""
573 @env.filter("heroicon") # type: ignore[misc] # Jinja2 decorator preserves signature
574 def heroicon_filter(
575 icon_name: str,
576 variant: str = "outline",
577 size: str | None = None,
578 **attributes: Any,
579 ) -> str:
580 """Template filter for Heroicons."""
581 icons = depends.get_sync("icons")
582 if isinstance(icons, HeroiconsIcons):
583 return icons.get_icon_tag(icon_name, variant, size, **attributes)
584 return f"<!-- {icon_name} -->"
586 @env.filter("heroicon_class") # type: ignore[misc] # Jinja2 decorator preserves signature
587 def heroicon_class_filter(icon_name: str, variant: str = "outline") -> str:
588 """Template filter for Heroicons classes."""
589 icons = depends.get_sync("icons")
590 if isinstance(icons, HeroiconsIcons):
591 return icons.get_icon_class(icon_name, variant)
592 return f"heroicon-{icon_name}"
594 @env.global_("heroicons_stylesheet_links") # type: ignore[misc] # Jinja2 decorator preserves signature
595 def heroicons_stylesheet_links() -> str:
596 """Global function for Heroicons stylesheet links."""
597 icons = depends.get_sync("icons")
598 if isinstance(icons, HeroiconsIcons):
599 return "\n".join(icons.get_stylesheet_links())
600 return ""
602 @env.global_("hero_button") # type: ignore[misc] # Jinja2 decorator preserves signature
603 def hero_button(
604 text: str,
605 icon: str | None = None,
606 variant: str = "outline",
607 icon_position: str = "left",
608 **attributes: Any,
609 ) -> str:
610 """Generate button with Heroicons icon."""
611 icons = depends.get_sync("icons")
612 if isinstance(icons, HeroiconsIcons):
613 return _create_hero_button(
614 text, icon, variant, icon_position, icons, **attributes
615 )
616 return f"<button>{text}</button>"
618 @env.global_("hero_badge") # type: ignore[misc] # Jinja2 decorator preserves signature
619 def hero_badge(
620 text: str, icon: str | None = None, variant: str = "outline", **attributes: Any
621 ) -> str:
622 """Generate badge with Heroicons icon."""
623 icons = depends.get_sync("icons")
624 if isinstance(icons, HeroiconsIcons):
625 return _create_hero_badge(text, icon, variant, icons, **attributes)
626 return f"<span class='badge'>{text}</span>"
629IconsSettings = HeroiconsIconsSettings
630Icons = HeroiconsIcons
632depends.set(Icons, "heroicons")
635# ACB 0.19.0+ compatibility
636__all__ = [
637 "HeroiconsIcons",
638 "HeroiconsIconsSettings",
639 "register_heroicons_filters",
640 "Icons",
641 "IconsSettings",
642]