Coverage for fastblocks / adapters / icons / heroicons.py: 26%
149 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 03:30 -0800
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 03:30 -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_tag(
280 self,
281 icon_name: str,
282 variant: str | None = None,
283 size: str | None = None,
284 **attributes: Any,
285 ) -> str:
286 if not self.settings:
287 self.settings = HeroiconsIconsSettings()
289 # Resolve icon aliases
290 if icon_name in self.settings.icon_aliases:
291 icon_name = self.settings.icon_aliases[icon_name]
293 # Use default variant if not specified
294 if not variant:
295 variant = self.settings.default_variant
297 # Validate variant
298 if variant not in self.settings.enabled_variants:
299 variant = self.settings.default_variant
301 # Determine size
302 if size and size in self.settings.size_presets:
303 icon_size = self.settings.size_presets[size]
304 elif size and size.isdigit():
305 icon_size = size
306 else:
307 # Default size based on variant
308 icon_size = "20" if variant == "mini" else self.settings.default_size
310 # Build base icon class
311 icon_class = self.get_icon_class(icon_name, variant)
313 # Add size class if using preset
314 if size and size in self.settings.size_presets:
315 icon_class += f" heroicon-{size}"
317 # Add custom classes
318 if "class" in attributes:
319 icon_class += f" {attributes.pop('class')}"
321 # Process attributes using shared utilities
322 transform_classes, attributes = process_transformations(attributes, "heroicon")
323 animation_classes, attributes = process_animations(
324 attributes, ["spin", "pulse", "bounce"], "heroicon"
325 )
326 semantic_colors = [
327 "primary",
328 "secondary",
329 "success",
330 "warning",
331 "danger",
332 "info",
333 "gray",
334 "white",
335 "black",
336 ]
337 color_class, attributes = process_semantic_colors(
338 attributes, semantic_colors, "heroicon"
339 )
340 state_classes, attributes = process_state_attributes(attributes, "heroicon")
342 # Combine all classes
343 icon_class += (
344 transform_classes + animation_classes + color_class + state_classes
345 )
347 # Build SVG attributes
348 svg_attrs = {
349 "class": icon_class,
350 "width": icon_size,
351 "height": icon_size,
352 "viewBox": f"0 0 {icon_size} {icon_size}",
353 } | attributes
355 # Add accessibility and variant-specific attributes
356 svg_attrs = add_accessibility_attributes(svg_attrs)
357 if variant == "outline":
358 svg_attrs.setdefault("stroke-width", "1.5")
359 svg_attrs.setdefault("stroke", "currentColor")
360 svg_attrs.setdefault("fill", "none")
361 else:
362 svg_attrs.setdefault("fill", "currentColor")
364 # Generate SVG content and build tag
365 svg_content = self._get_icon_svg_content(icon_name, variant)
366 attr_string = build_attr_string(svg_attrs)
367 return f"<svg {attr_string}>{svg_content}</svg>"
369 def _get_icon_svg_content(self, icon_name: str, variant: str) -> str:
370 """Get SVG content for specific icon and variant."""
371 # This would typically come from the Heroicons icon registry
372 # For now, return placeholder content for common icons
374 # Common icon paths (simplified examples)
375 icon_paths = {
376 "home": {
377 "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" />',
378 "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"/>',
379 "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"/>',
380 },
381 "user": {
382 "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" />',
383 "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" />',
384 "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"/>',
385 },
386 "x-mark": {
387 "outline": '<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />',
388 "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" />',
389 "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"/>',
390 },
391 "check": {
392 "outline": '<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />',
393 "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" />',
394 "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" />',
395 },
396 }
398 # Return path for the requested icon and variant
399 if icon_name in icon_paths and variant in icon_paths[icon_name]:
400 return icon_paths[icon_name][variant]
402 # Fallback for unknown icons
403 return f"<!-- {icon_name} ({variant}) not found -->"
405 def get_icon_sprite_url(self, variant: str = "outline") -> str:
406 """Get URL for Heroicons sprite file."""
407 if not self.settings:
408 self.settings = HeroiconsIconsSettings()
410 return f"{self.settings.cdn_url}@{self.settings.version}/{variant}.svg"
412 @staticmethod
413 def get_available_icons() -> dict[str, list[str]]:
414 """Get list of available icons by category."""
415 return {
416 "general": [
417 "home",
418 "user",
419 "cog-6-tooth",
420 "magnifying-glass",
421 "bars-3",
422 "x-mark",
423 "check",
424 "plus",
425 "minus",
426 "ellipsis-horizontal",
427 ],
428 "navigation": [
429 "arrow-left",
430 "arrow-right",
431 "arrow-up",
432 "arrow-down",
433 "chevron-left",
434 "chevron-right",
435 "chevron-up",
436 "chevron-down",
437 "arrow-path",
438 "arrow-uturn-left",
439 "arrow-uturn-right",
440 ],
441 "communication": [
442 "envelope",
443 "phone",
444 "chat-bubble-left",
445 "paper-airplane",
446 "bell",
447 "speaker-wave",
448 "microphone",
449 "video-camera",
450 ],
451 "media": [
452 "play",
453 "pause",
454 "stop",
455 "backward",
456 "forward",
457 "speaker-wave",
458 "speaker-x-mark",
459 "musical-note",
460 ],
461 "file": [
462 "document",
463 "folder",
464 "arrow-down-tray",
465 "arrow-up-tray",
466 "document-arrow-down",
467 "document-text",
468 "photo",
469 "film",
470 ],
471 "editing": [
472 "pencil",
473 "trash",
474 "document-duplicate",
475 "scissors",
476 "clipboard",
477 "eye",
478 "eye-slash",
479 "lock-closed",
480 "lock-open",
481 ],
482 "status": [
483 "check-circle",
484 "x-circle",
485 "exclamation-triangle",
486 "information-circle",
487 "question-mark-circle",
488 "light-bulb",
489 ],
490 }
493# Template filter registration for FastBlocks
494def _create_hero_button(
495 text: str,
496 icon: str | None,
497 variant: str,
498 icon_position: str,
499 icons: HeroiconsIcons,
500 **attributes: Any,
501) -> str:
502 """Build button HTML with Heroicons icon."""
503 btn_class = attributes.pop("class", "btn btn-primary")
505 # Build button content
506 if icon:
507 icon_tag = icons.get_icon_tag(icon, variant, size="sm")
508 if icon_position == "left":
509 content = f"{icon_tag} {text}"
510 elif icon_position == "right":
511 content = f"{text} {icon_tag}"
512 else:
513 content = text
514 else:
515 content = text
517 # Build button attributes
518 btn_attrs = {"class": btn_class} | attributes
519 attr_string = " ".join(f'{k}="{v}"' for k, v in btn_attrs.items())
521 return f"<button {attr_string}>{content}</button>"
524def _create_hero_badge(
525 text: str,
526 icon: str | None,
527 variant: str,
528 icons: HeroiconsIcons,
529 **attributes: Any,
530) -> str:
531 """Build badge HTML with Heroicons icon."""
532 badge_class = attributes.pop("class", "badge badge-primary")
534 # Build badge content
535 if icon:
536 icon_tag = icons.get_icon_tag(icon, variant, size="xs")
537 content = f"{icon_tag} {text}"
538 else:
539 content = text
541 # Build badge attributes
542 badge_attrs = {"class": badge_class} | attributes
543 attr_string = " ".join(f'{k}="{v}"' for k, v in badge_attrs.items())
545 return f"<span {attr_string}>{content}</span>"
548def register_heroicons_filters(env: Any) -> None:
549 """Register Heroicons filters for Jinja2 templates."""
551 @env.filter("heroicon") # type: ignore[misc] # Jinja2 decorator preserves signature
552 def heroicon_filter(
553 icon_name: str,
554 variant: str = "outline",
555 size: str | None = None,
556 **attributes: Any,
557 ) -> str:
558 """Template filter for Heroicons."""
559 icons = depends.get_sync("icons")
560 if isinstance(icons, HeroiconsIcons):
561 return icons.get_icon_tag(icon_name, variant, size, **attributes)
562 return f"<!-- {icon_name} -->"
564 @env.filter("heroicon_class") # type: ignore[misc] # Jinja2 decorator preserves signature
565 def heroicon_class_filter(icon_name: str, variant: str = "outline") -> str:
566 """Template filter for Heroicons classes."""
567 icons = depends.get_sync("icons")
568 if isinstance(icons, HeroiconsIcons):
569 return icons.get_icon_class(icon_name, variant)
570 return f"heroicon-{icon_name}"
572 @env.global_("heroicons_stylesheet_links") # type: ignore[misc] # Jinja2 decorator preserves signature
573 def heroicons_stylesheet_links() -> str:
574 """Global function for Heroicons stylesheet links."""
575 icons = depends.get_sync("icons")
576 if isinstance(icons, HeroiconsIcons):
577 return "\n".join(icons.get_stylesheet_links())
578 return ""
580 @env.global_("hero_button") # type: ignore[misc] # Jinja2 decorator preserves signature
581 def hero_button(
582 text: str,
583 icon: str | None = None,
584 variant: str = "outline",
585 icon_position: str = "left",
586 **attributes: Any,
587 ) -> str:
588 """Generate button with Heroicons icon."""
589 icons = depends.get_sync("icons")
590 if isinstance(icons, HeroiconsIcons):
591 return _create_hero_button(
592 text, icon, variant, icon_position, icons, **attributes
593 )
594 return f"<button>{text}</button>"
596 @env.global_("hero_badge") # type: ignore[misc] # Jinja2 decorator preserves signature
597 def hero_badge(
598 text: str, icon: str | None = None, variant: str = "outline", **attributes: Any
599 ) -> str:
600 """Generate badge with Heroicons icon."""
601 icons = depends.get_sync("icons")
602 if isinstance(icons, HeroiconsIcons):
603 return _create_hero_badge(text, icon, variant, icons, **attributes)
604 return f"<span class='badge'>{text}</span>"
607IconsSettings = HeroiconsIconsSettings
608Icons = HeroiconsIcons
610depends.set(Icons, "heroicons")
613# ACB 0.19.0+ compatibility
614__all__ = [
615 "HeroiconsIcons",
616 "HeroiconsIconsSettings",
617 "register_heroicons_filters",
618 "Icons",
619 "IconsSettings",
620]