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

1"""Heroicons adapter for FastBlocks with outline/solid variants.""" 

2 

3from contextlib import suppress 

4from typing import Any 

5from uuid import UUID 

6 

7from acb.depends import depends 

8 

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) 

18 

19 

20class HeroiconsIconsSettings(IconsBaseSettings): 

21 """Settings for Heroicons adapter.""" 

22 

23 # Required ACB 0.19.0+ metadata 

24 MODULE_ID: UUID = UUID("01937d86-ad8f-c29a-eb1c-0a1b2c3d4e5f") # Static UUID7 

25 MODULE_STATUS: str = "stable" 

26 

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) 

32 

33 # Variant settings 

34 enabled_variants: list[str] = ["outline", "solid", "mini"] 

35 

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 } 

78 

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 } 

89 

90 

91class HeroiconsIcons(IconsBase): 

92 """Heroicons adapter with outline/solid/mini variants.""" 

93 

94 # Required ACB 0.19.0+ metadata 

95 MODULE_ID: UUID = UUID("01937d86-ad8f-c29a-eb1c-0a1b2c3d4e5f") # Static UUID7 

96 MODULE_STATUS: str = "stable" 

97 

98 def __init__(self) -> None: 

99 """Initialize Heroicons adapter.""" 

100 super().__init__() 

101 self.settings: HeroiconsIconsSettings | None = None 

102 

103 # Register with ACB dependency system 

104 with suppress(Exception): 

105 depends.set(self) 

106 

107 def get_stylesheet_links(self) -> list[str]: 

108 """Get Heroicons stylesheet links.""" 

109 if not self.settings: 

110 self.settings = HeroiconsIconsSettings() 

111 

112 links = [] 

113 

114 # Heroicons base CSS 

115 heroicons_css = self._generate_heroicons_css() 

116 links.append(f"<style>{heroicons_css}</style>") 

117 

118 return links 

119 

120 def _generate_heroicons_css(self) -> str: 

121 """Generate Heroicons-specific CSS.""" 

122 if not self.settings: 

123 self.settings = HeroiconsIconsSettings() 

124 

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}} 

134 

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; }} 

143 

144/* Variant-specific styles */ 

145.heroicon-outline {{ 

146 stroke: currentColor; 

147 fill: none; 

148 stroke-width: 1.5; 

149}} 

150 

151.heroicon-solid {{ 

152 fill: currentColor; 

153}} 

154 

155.heroicon-mini {{ 

156 fill: currentColor; 

157 width: 20px; 

158 height: 20px; 

159}} 

160 

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); }} 

167 

168/* Animation support */ 

169.heroicon-spin {{ 

170 animation: heroicon-spin 2s linear infinite; 

171}} 

172 

173.heroicon-pulse {{ 

174 animation: heroicon-pulse 2s ease-in-out infinite alternate; 

175}} 

176 

177.heroicon-bounce {{ 

178 animation: heroicon-bounce 1s ease-in-out infinite; 

179}} 

180 

181@keyframes heroicon-spin {{ 

182 0% {{ transform: rotate(0deg); }} 

183 100% {{ transform: rotate(360deg); }} 

184}} 

185 

186@keyframes heroicon-pulse {{ 

187 from {{ opacity: 1; }} 

188 to {{ opacity: 0.25; }} 

189}} 

190 

191@keyframes heroicon-bounce {{ 

192 0%, 100% {{ transform: translateY(0); }} 

193 50% {{ transform: translateY(-25%); }} 

194}} 

195 

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; }} 

206 

207/* Interactive states */ 

208.heroicon-interactive {{ 

209 cursor: pointer; 

210 transition: all 0.2s ease; 

211}} 

212 

213.heroicon-interactive:hover {{ 

214 transform: scale(1.1); 

215 opacity: 0.8; 

216}} 

217 

218.heroicon-interactive:active {{ 

219 transform: scale(0.95); 

220}} 

221 

222/* States */ 

223.heroicon-disabled {{ 

224 opacity: 0.5; 

225 cursor: not-allowed; 

226}} 

227 

228.heroicon-loading {{ 

229 opacity: 0.6; 

230}} 

231 

232/* Button integration */ 

233.btn .heroicon {{ 

234 margin-right: 0.5rem; 

235}} 

236 

237.btn .heroicon:last-child {{ 

238 margin-right: 0; 

239 margin-left: 0.5rem; 

240}} 

241 

242.btn .heroicon:only-child {{ 

243 margin: 0; 

244}} 

245 

246/* Badge integration */ 

247.badge .heroicon {{ 

248 width: 1em; 

249 height: 1em; 

250 margin-right: 0.25rem; 

251}} 

252 

253/* Navigation integration */ 

254.nav-link .heroicon {{ 

255 width: 1.25rem; 

256 height: 1.25rem; 

257 margin-right: 0.5rem; 

258}} 

259""" 

260 

261 def get_icon_class(self, icon_name: str, variant: str | None = None) -> str: 

262 if not self.settings: 

263 self.settings = HeroiconsIconsSettings() 

264 

265 # Resolve icon aliases 

266 if icon_name in self.settings.icon_aliases: 

267 icon_name = self.settings.icon_aliases[icon_name] 

268 

269 # Use default variant if not specified 

270 if not variant: 

271 variant = self.settings.default_variant 

272 

273 # Validate variant 

274 if variant not in self.settings.enabled_variants: 

275 variant = self.settings.default_variant 

276 

277 return f"heroicon heroicon-{variant}" 

278 

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() 

288 

289 # Resolve icon aliases 

290 if icon_name in self.settings.icon_aliases: 

291 icon_name = self.settings.icon_aliases[icon_name] 

292 

293 # Use default variant if not specified 

294 if not variant: 

295 variant = self.settings.default_variant 

296 

297 # Validate variant 

298 if variant not in self.settings.enabled_variants: 

299 variant = self.settings.default_variant 

300 

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 

309 

310 # Build base icon class 

311 icon_class = self.get_icon_class(icon_name, variant) 

312 

313 # Add size class if using preset 

314 if size and size in self.settings.size_presets: 

315 icon_class += f" heroicon-{size}" 

316 

317 # Add custom classes 

318 if "class" in attributes: 

319 icon_class += f" {attributes.pop('class')}" 

320 

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") 

341 

342 # Combine all classes 

343 icon_class += ( 

344 transform_classes + animation_classes + color_class + state_classes 

345 ) 

346 

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 

354 

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") 

363 

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>" 

368 

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 

373 

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 } 

397 

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] 

401 

402 # Fallback for unknown icons 

403 return f"<!-- {icon_name} ({variant}) not found -->" 

404 

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() 

409 

410 return f"{self.settings.cdn_url}@{self.settings.version}/{variant}.svg" 

411 

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 } 

491 

492 

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") 

504 

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 

516 

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()) 

520 

521 return f"<button {attr_string}>{content}</button>" 

522 

523 

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") 

533 

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 

540 

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()) 

544 

545 return f"<span {attr_string}>{content}</span>" 

546 

547 

548def register_heroicons_filters(env: Any) -> None: 

549 """Register Heroicons filters for Jinja2 templates.""" 

550 

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} -->" 

563 

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}" 

571 

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 "" 

579 

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>" 

595 

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>" 

605 

606 

607IconsSettings = HeroiconsIconsSettings 

608Icons = HeroiconsIcons 

609 

610depends.set(Icons, "heroicons") 

611 

612 

613# ACB 0.19.0+ compatibility 

614__all__ = [ 

615 "HeroiconsIcons", 

616 "HeroiconsIconsSettings", 

617 "register_heroicons_filters", 

618 "Icons", 

619 "IconsSettings", 

620]