Coverage for fastblocks/adapters/icons/heroicons.py: 0%
146 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 03:37 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 03:37 -0700
1"""Heroicons adapter for FastBlocks with outline/solid variants."""
3from contextlib import suppress
4from typing import Any
5from uuid import UUID
7from acb.config import Settings
8from acb.depends import depends
10from ._base import IconsBase
11from ._utils import (
12 add_accessibility_attributes,
13 build_attr_string,
14 process_animations,
15 process_semantic_colors,
16 process_state_attributes,
17 process_transformations,
18)
21class HeroiconsSettings(Settings): # type: ignore[misc]
22 """Settings for Heroicons adapter."""
24 # Required ACB 0.19.0+ metadata
25 MODULE_ID: UUID = UUID("01937d86-ad8f-c29a-eb1c-0a1b2c3d4e5f") # Static UUID7
26 MODULE_STATUS: str = "stable"
28 # Heroicons configuration
29 version: str = "2.0.18"
30 cdn_url: str = "https://cdn.jsdelivr.net/npm/heroicons"
31 default_variant: str = "outline" # outline, solid, mini
32 default_size: str = "24" # 20 (mini), 24 (outline/solid)
34 # Variant settings
35 enabled_variants: list[str] = ["outline", "solid", "mini"]
37 # Icon mapping for common names and aliases
38 icon_aliases: dict[str, str] = {
39 "home": "home",
40 "user": "user",
41 "settings": "cog-6-tooth",
42 "search": "magnifying-glass",
43 "menu": "bars-3",
44 "close": "x-mark",
45 "check": "check",
46 "error": "exclamation-triangle",
47 "info": "information-circle",
48 "success": "check-circle",
49 "warning": "exclamation-triangle",
50 "edit": "pencil",
51 "delete": "trash",
52 "save": "document-arrow-down",
53 "download": "arrow-down-tray",
54 "upload": "arrow-up-tray",
55 "email": "envelope",
56 "phone": "phone",
57 "location": "map-pin",
58 "calendar": "calendar-days",
59 "clock": "clock",
60 "heart": "heart",
61 "star": "star",
62 "share": "share",
63 "link": "link",
64 "copy": "document-duplicate",
65 "cut": "scissors",
66 "paste": "clipboard",
67 "undo": "arrow-uturn-left",
68 "redo": "arrow-uturn-right",
69 "refresh": "arrow-path",
70 "logout": "arrow-right-on-rectangle",
71 "login": "arrow-left-on-rectangle",
72 "plus": "plus",
73 "minus": "minus",
74 "eye": "eye",
75 "eye-off": "eye-slash",
76 "lock": "lock-closed",
77 "unlock": "lock-open",
78 }
80 # Size presets
81 size_presets: dict[str, str] = {
82 "xs": "16",
83 "sm": "20",
84 "md": "24",
85 "lg": "28",
86 "xl": "32",
87 "2xl": "40",
88 "3xl": "48",
89 }
92class HeroiconsAdapter(IconsBase):
93 """Heroicons adapter with outline/solid/mini variants."""
95 # Required ACB 0.19.0+ metadata
96 MODULE_ID: UUID = UUID("01937d86-ad8f-c29a-eb1c-0a1b2c3d4e5f") # Static UUID7
97 MODULE_STATUS: str = "stable"
99 def __init__(self) -> None:
100 """Initialize Heroicons adapter."""
101 super().__init__()
102 self.settings: HeroiconsSettings | None = None
104 # Register with ACB dependency system
105 with suppress(Exception):
106 depends.set(self)
108 def get_stylesheet_links(self) -> list[str]:
109 """Get Heroicons stylesheet links."""
110 if not self.settings:
111 self.settings = HeroiconsSettings()
113 links = []
115 # Heroicons base CSS
116 heroicons_css = self._generate_heroicons_css()
117 links.append(f"<style>{heroicons_css}</style>")
119 return links
121 def _generate_heroicons_css(self) -> str:
122 """Generate Heroicons-specific CSS."""
123 if not self.settings:
124 self.settings = HeroiconsSettings()
126 return f"""
127/* Heroicons Base Styles */
128.heroicon {{
129 display: inline-block;
130 vertical-align: -0.125em;
131 width: {self.settings.default_size}px;
132 height: {self.settings.default_size}px;
133 flex-shrink: 0;
134}}
136/* Size variants */
137.heroicon-xs {{ width: 16px; height: 16px; }}
138.heroicon-sm {{ width: 20px; height: 20px; }}
139.heroicon-md {{ width: 24px; height: 24px; }}
140.heroicon-lg {{ width: 28px; height: 28px; }}
141.heroicon-xl {{ width: 32px; height: 32px; }}
142.heroicon-2xl {{ width: 40px; height: 40px; }}
143.heroicon-3xl {{ width: 48px; height: 48px; }}
145/* Variant-specific styles */
146.heroicon-outline {{
147 stroke: currentColor;
148 fill: none;
149 stroke-width: 1.5;
150}}
152.heroicon-solid {{
153 fill: currentColor;
154}}
156.heroicon-mini {{
157 fill: currentColor;
158 width: 20px;
159 height: 20px;
160}}
162/* Rotation and transformation */
163.heroicon-rotate-90 {{ transform: rotate(90deg); }}
164.heroicon-rotate-180 {{ transform: rotate(180deg); }}
165.heroicon-rotate-270 {{ transform: rotate(270deg); }}
166.heroicon-flip-horizontal {{ transform: scaleX(-1); }}
167.heroicon-flip-vertical {{ transform: scaleY(-1); }}
169/* Animation support */
170.heroicon-spin {{
171 animation: heroicon-spin 2s linear infinite;
172}}
174.heroicon-pulse {{
175 animation: heroicon-pulse 2s ease-in-out infinite alternate;
176}}
178.heroicon-bounce {{
179 animation: heroicon-bounce 1s ease-in-out infinite;
180}}
182@keyframes heroicon-spin {{
183 0% {{ transform: rotate(0deg); }}
184 100% {{ transform: rotate(360deg); }}
185}}
187@keyframes heroicon-pulse {{
188 from {{ opacity: 1; }}
189 to {{ opacity: 0.25; }}
190}}
192@keyframes heroicon-bounce {{
193 0%, 100% {{ transform: translateY(0); }}
194 50% {{ transform: translateY(-25%); }}
195}}
197/* Color utilities */
198.heroicon-primary {{ color: var(--primary-color, #3b82f6); }}
199.heroicon-secondary {{ color: var(--secondary-color, #6b7280); }}
200.heroicon-success {{ color: var(--success-color, #10b981); }}
201.heroicon-warning {{ color: var(--warning-color, #f59e0b); }}
202.heroicon-danger {{ color: var(--danger-color, #ef4444); }}
203.heroicon-info {{ color: var(--info-color, #3b82f6); }}
204.heroicon-gray {{ color: var(--gray-color, #6b7280); }}
205.heroicon-white {{ color: white; }}
206.heroicon-black {{ color: black; }}
208/* Interactive states */
209.heroicon-interactive {{
210 cursor: pointer;
211 transition: all 0.2s ease;
212}}
214.heroicon-interactive:hover {{
215 transform: scale(1.1);
216 opacity: 0.8;
217}}
219.heroicon-interactive:active {{
220 transform: scale(0.95);
221}}
223/* States */
224.heroicon-disabled {{
225 opacity: 0.5;
226 cursor: not-allowed;
227}}
229.heroicon-loading {{
230 opacity: 0.6;
231}}
233/* Button integration */
234.btn .heroicon {{
235 margin-right: 0.5rem;
236}}
238.btn .heroicon:last-child {{
239 margin-right: 0;
240 margin-left: 0.5rem;
241}}
243.btn .heroicon:only-child {{
244 margin: 0;
245}}
247/* Badge integration */
248.badge .heroicon {{
249 width: 1em;
250 height: 1em;
251 margin-right: 0.25rem;
252}}
254/* Navigation integration */
255.nav-link .heroicon {{
256 width: 1.25rem;
257 height: 1.25rem;
258 margin-right: 0.5rem;
259}}
260"""
262 def get_icon_class(self, icon_name: str, variant: str | None = None) -> str:
263 """Get Heroicons icon class with variant support."""
264 if not self.settings:
265 self.settings = HeroiconsSettings()
267 # Resolve icon aliases
268 if icon_name in self.settings.icon_aliases:
269 icon_name = self.settings.icon_aliases[icon_name]
271 # Use default variant if not specified
272 if not variant:
273 variant = self.settings.default_variant
275 # Validate variant
276 if variant not in self.settings.enabled_variants:
277 variant = self.settings.default_variant
279 return f"heroicon heroicon-{variant}"
281 def get_icon_tag( # type: ignore[override] # Intentional API extension with variant/size
282 self,
283 icon_name: str,
284 variant: str | None = None,
285 size: str | None = None,
286 **attributes: Any,
287 ) -> str:
288 """Generate Heroicons SVG tag with full customization."""
289 if not self.settings:
290 self.settings = HeroiconsSettings()
292 # Resolve icon aliases
293 if icon_name in self.settings.icon_aliases:
294 icon_name = self.settings.icon_aliases[icon_name]
296 # Use default variant if not specified
297 if not variant:
298 variant = self.settings.default_variant
300 # Validate variant
301 if variant not in self.settings.enabled_variants:
302 variant = self.settings.default_variant
304 # Determine size
305 if size and size in self.settings.size_presets:
306 icon_size = self.settings.size_presets[size]
307 elif size and size.isdigit():
308 icon_size = size
309 else:
310 # Default size based on variant
311 icon_size = "20" if variant == "mini" else self.settings.default_size
313 # Build base icon class
314 icon_class = self.get_icon_class(icon_name, variant)
316 # Add size class if using preset
317 if size and size in self.settings.size_presets:
318 icon_class += f" heroicon-{size}"
320 # Add custom classes
321 if "class" in attributes:
322 icon_class += f" {attributes.pop('class')}"
324 # Process attributes using shared utilities
325 transform_classes, attributes = process_transformations(attributes, "heroicon")
326 animation_classes, attributes = process_animations(
327 attributes, ["spin", "pulse", "bounce"], "heroicon"
328 )
329 semantic_colors = [
330 "primary",
331 "secondary",
332 "success",
333 "warning",
334 "danger",
335 "info",
336 "gray",
337 "white",
338 "black",
339 ]
340 color_class, attributes = process_semantic_colors(
341 attributes, semantic_colors, "heroicon"
342 )
343 state_classes, attributes = process_state_attributes(attributes, "heroicon")
345 # Combine all classes
346 icon_class += (
347 transform_classes + animation_classes + color_class + state_classes
348 )
350 # Build SVG attributes
351 svg_attrs = {
352 "class": icon_class,
353 "width": icon_size,
354 "height": icon_size,
355 "viewBox": f"0 0 {icon_size} {icon_size}",
356 } | attributes
358 # Add accessibility and variant-specific attributes
359 svg_attrs = add_accessibility_attributes(svg_attrs)
360 if variant == "outline":
361 svg_attrs.setdefault("stroke-width", "1.5")
362 svg_attrs.setdefault("stroke", "currentColor")
363 svg_attrs.setdefault("fill", "none")
364 else:
365 svg_attrs.setdefault("fill", "currentColor")
367 # Generate SVG content and build tag
368 svg_content = self._get_icon_svg_content(icon_name, variant)
369 attr_string = build_attr_string(svg_attrs)
370 return f"<svg {attr_string}>{svg_content}</svg>"
372 def _get_icon_svg_content(self, icon_name: str, variant: str) -> str:
373 """Get SVG content for specific icon and variant."""
374 # This would typically come from the Heroicons icon registry
375 # For now, return placeholder content for common icons
377 # Common icon paths (simplified examples)
378 icon_paths = {
379 "home": {
380 "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" />',
381 "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"/>',
382 "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"/>',
383 },
384 "user": {
385 "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" />',
386 "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" />',
387 "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"/>',
388 },
389 "x-mark": {
390 "outline": '<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />',
391 "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" />',
392 "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"/>',
393 },
394 "check": {
395 "outline": '<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />',
396 "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" />',
397 "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" />',
398 },
399 }
401 # Return path for the requested icon and variant
402 if icon_name in icon_paths and variant in icon_paths[icon_name]:
403 return icon_paths[icon_name][variant]
405 # Fallback for unknown icons
406 return f"<!-- {icon_name} ({variant}) not found -->"
408 def get_icon_sprite_url(self, variant: str = "outline") -> str:
409 """Get URL for Heroicons sprite file."""
410 if not self.settings:
411 self.settings = HeroiconsSettings()
413 return f"{self.settings.cdn_url}@{self.settings.version}/{variant}.svg"
415 def get_available_icons(self) -> dict[str, list[str]]:
416 """Get list of available icons by category."""
417 return {
418 "general": [
419 "home",
420 "user",
421 "cog-6-tooth",
422 "magnifying-glass",
423 "bars-3",
424 "x-mark",
425 "check",
426 "plus",
427 "minus",
428 "ellipsis-horizontal",
429 ],
430 "navigation": [
431 "arrow-left",
432 "arrow-right",
433 "arrow-up",
434 "arrow-down",
435 "chevron-left",
436 "chevron-right",
437 "chevron-up",
438 "chevron-down",
439 "arrow-path",
440 "arrow-uturn-left",
441 "arrow-uturn-right",
442 ],
443 "communication": [
444 "envelope",
445 "phone",
446 "chat-bubble-left",
447 "paper-airplane",
448 "bell",
449 "speaker-wave",
450 "microphone",
451 "video-camera",
452 ],
453 "media": [
454 "play",
455 "pause",
456 "stop",
457 "backward",
458 "forward",
459 "speaker-wave",
460 "speaker-x-mark",
461 "musical-note",
462 ],
463 "file": [
464 "document",
465 "folder",
466 "arrow-down-tray",
467 "arrow-up-tray",
468 "document-arrow-down",
469 "document-text",
470 "photo",
471 "film",
472 ],
473 "editing": [
474 "pencil",
475 "trash",
476 "document-duplicate",
477 "scissors",
478 "clipboard",
479 "eye",
480 "eye-slash",
481 "lock-closed",
482 "lock-open",
483 ],
484 "status": [
485 "check-circle",
486 "x-circle",
487 "exclamation-triangle",
488 "information-circle",
489 "question-mark-circle",
490 "light-bulb",
491 ],
492 }
495# Template filter registration for FastBlocks
496def _create_hero_button(
497 text: str,
498 icon: str | None,
499 variant: str,
500 icon_position: str,
501 icons: HeroiconsAdapter,
502 **attributes: Any,
503) -> str:
504 """Build button HTML with Heroicons icon."""
505 btn_class = attributes.pop("class", "btn btn-primary")
507 # Build button content
508 if icon:
509 icon_tag = icons.get_icon_tag(icon, variant, size="sm")
510 if icon_position == "left":
511 content = f"{icon_tag} {text}"
512 elif icon_position == "right":
513 content = f"{text} {icon_tag}"
514 else:
515 content = text
516 else:
517 content = text
519 # Build button attributes
520 btn_attrs = {"class": btn_class} | attributes
521 attr_string = " ".join(f'{k}="{v}"' for k, v in btn_attrs.items())
523 return f"<button {attr_string}>{content}</button>"
526def _create_hero_badge(
527 text: str,
528 icon: str | None,
529 variant: str,
530 icons: HeroiconsAdapter,
531 **attributes: Any,
532) -> str:
533 """Build badge HTML with Heroicons icon."""
534 badge_class = attributes.pop("class", "badge badge-primary")
536 # Build badge content
537 if icon:
538 icon_tag = icons.get_icon_tag(icon, variant, size="xs")
539 content = f"{icon_tag} {text}"
540 else:
541 content = text
543 # Build badge attributes
544 badge_attrs = {"class": badge_class} | attributes
545 attr_string = " ".join(f'{k}="{v}"' for k, v in badge_attrs.items())
547 return f"<span {attr_string}>{content}</span>"
550def register_heroicons_filters(env: Any) -> None:
551 """Register Heroicons filters for Jinja2 templates."""
553 @env.filter("heroicon") # type: ignore[misc] # Jinja2 decorator preserves signature
554 def heroicon_filter(
555 icon_name: str,
556 variant: str = "outline",
557 size: str | None = None,
558 **attributes: Any,
559 ) -> str:
560 """Template filter for Heroicons."""
561 icons = depends.get("icons")
562 if isinstance(icons, HeroiconsAdapter):
563 return icons.get_icon_tag(icon_name, variant, size, **attributes)
564 return f"<!-- {icon_name} -->"
566 @env.filter("heroicon_class") # type: ignore[misc] # Jinja2 decorator preserves signature
567 def heroicon_class_filter(icon_name: str, variant: str = "outline") -> str:
568 """Template filter for Heroicons classes."""
569 icons = depends.get("icons")
570 if isinstance(icons, HeroiconsAdapter):
571 return icons.get_icon_class(icon_name, variant)
572 return f"heroicon-{icon_name}"
574 @env.global_("heroicons_stylesheet_links") # type: ignore[misc] # Jinja2 decorator preserves signature
575 def heroicons_stylesheet_links() -> str:
576 """Global function for Heroicons stylesheet links."""
577 icons = depends.get("icons")
578 if isinstance(icons, HeroiconsAdapter):
579 return "\n".join(icons.get_stylesheet_links())
580 return ""
582 @env.global_("hero_button") # type: ignore[misc] # Jinja2 decorator preserves signature
583 def hero_button(
584 text: str,
585 icon: str | None = None,
586 variant: str = "outline",
587 icon_position: str = "left",
588 **attributes: Any,
589 ) -> str:
590 """Generate button with Heroicons icon."""
591 icons = depends.get("icons")
592 if isinstance(icons, HeroiconsAdapter):
593 return _create_hero_button(
594 text, icon, variant, icon_position, icons, **attributes
595 )
596 return f"<button>{text}</button>"
598 @env.global_("hero_badge") # type: ignore[misc] # Jinja2 decorator preserves signature
599 def hero_badge(
600 text: str, icon: str | None = None, variant: str = "outline", **attributes: Any
601 ) -> str:
602 """Generate badge with Heroicons icon."""
603 icons = depends.get("icons")
604 if isinstance(icons, HeroiconsAdapter):
605 return _create_hero_badge(text, icon, variant, icons, **attributes)
606 return f"<span class='badge'>{text}</span>"
609# ACB 0.19.0+ compatibility
610__all__ = ["HeroiconsAdapter", "HeroiconsSettings", "register_heroicons_filters"]