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

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.config import Settings 

8from acb.depends import depends 

9 

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) 

19 

20 

21class HeroiconsSettings(Settings): # type: ignore[misc] 

22 """Settings for Heroicons adapter.""" 

23 

24 # Required ACB 0.19.0+ metadata 

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

26 MODULE_STATUS: str = "stable" 

27 

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) 

33 

34 # Variant settings 

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

36 

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 } 

79 

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 } 

90 

91 

92class HeroiconsAdapter(IconsBase): 

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

94 

95 # Required ACB 0.19.0+ metadata 

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

97 MODULE_STATUS: str = "stable" 

98 

99 def __init__(self) -> None: 

100 """Initialize Heroicons adapter.""" 

101 super().__init__() 

102 self.settings: HeroiconsSettings | None = None 

103 

104 # Register with ACB dependency system 

105 with suppress(Exception): 

106 depends.set(self) 

107 

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

109 """Get Heroicons stylesheet links.""" 

110 if not self.settings: 

111 self.settings = HeroiconsSettings() 

112 

113 links = [] 

114 

115 # Heroicons base CSS 

116 heroicons_css = self._generate_heroicons_css() 

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

118 

119 return links 

120 

121 def _generate_heroicons_css(self) -> str: 

122 """Generate Heroicons-specific CSS.""" 

123 if not self.settings: 

124 self.settings = HeroiconsSettings() 

125 

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

135 

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

144 

145/* Variant-specific styles */ 

146.heroicon-outline {{ 

147 stroke: currentColor; 

148 fill: none; 

149 stroke-width: 1.5; 

150}} 

151 

152.heroicon-solid {{ 

153 fill: currentColor; 

154}} 

155 

156.heroicon-mini {{ 

157 fill: currentColor; 

158 width: 20px; 

159 height: 20px; 

160}} 

161 

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

168 

169/* Animation support */ 

170.heroicon-spin {{ 

171 animation: heroicon-spin 2s linear infinite; 

172}} 

173 

174.heroicon-pulse {{ 

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

176}} 

177 

178.heroicon-bounce {{ 

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

180}} 

181 

182@keyframes heroicon-spin {{ 

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

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

185}} 

186 

187@keyframes heroicon-pulse {{ 

188 from {{ opacity: 1; }} 

189 to {{ opacity: 0.25; }} 

190}} 

191 

192@keyframes heroicon-bounce {{ 

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

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

195}} 

196 

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

207 

208/* Interactive states */ 

209.heroicon-interactive {{ 

210 cursor: pointer; 

211 transition: all 0.2s ease; 

212}} 

213 

214.heroicon-interactive:hover {{ 

215 transform: scale(1.1); 

216 opacity: 0.8; 

217}} 

218 

219.heroicon-interactive:active {{ 

220 transform: scale(0.95); 

221}} 

222 

223/* States */ 

224.heroicon-disabled {{ 

225 opacity: 0.5; 

226 cursor: not-allowed; 

227}} 

228 

229.heroicon-loading {{ 

230 opacity: 0.6; 

231}} 

232 

233/* Button integration */ 

234.btn .heroicon {{ 

235 margin-right: 0.5rem; 

236}} 

237 

238.btn .heroicon:last-child {{ 

239 margin-right: 0; 

240 margin-left: 0.5rem; 

241}} 

242 

243.btn .heroicon:only-child {{ 

244 margin: 0; 

245}} 

246 

247/* Badge integration */ 

248.badge .heroicon {{ 

249 width: 1em; 

250 height: 1em; 

251 margin-right: 0.25rem; 

252}} 

253 

254/* Navigation integration */ 

255.nav-link .heroicon {{ 

256 width: 1.25rem; 

257 height: 1.25rem; 

258 margin-right: 0.5rem; 

259}} 

260""" 

261 

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

266 

267 # Resolve icon aliases 

268 if icon_name in self.settings.icon_aliases: 

269 icon_name = self.settings.icon_aliases[icon_name] 

270 

271 # Use default variant if not specified 

272 if not variant: 

273 variant = self.settings.default_variant 

274 

275 # Validate variant 

276 if variant not in self.settings.enabled_variants: 

277 variant = self.settings.default_variant 

278 

279 return f"heroicon heroicon-{variant}" 

280 

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

291 

292 # Resolve icon aliases 

293 if icon_name in self.settings.icon_aliases: 

294 icon_name = self.settings.icon_aliases[icon_name] 

295 

296 # Use default variant if not specified 

297 if not variant: 

298 variant = self.settings.default_variant 

299 

300 # Validate variant 

301 if variant not in self.settings.enabled_variants: 

302 variant = self.settings.default_variant 

303 

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 

312 

313 # Build base icon class 

314 icon_class = self.get_icon_class(icon_name, variant) 

315 

316 # Add size class if using preset 

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

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

319 

320 # Add custom classes 

321 if "class" in attributes: 

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

323 

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

344 

345 # Combine all classes 

346 icon_class += ( 

347 transform_classes + animation_classes + color_class + state_classes 

348 ) 

349 

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 

357 

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

366 

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

371 

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 

376 

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 } 

400 

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] 

404 

405 # Fallback for unknown icons 

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

407 

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

412 

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

414 

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 } 

493 

494 

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

506 

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 

518 

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

522 

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

524 

525 

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

535 

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 

542 

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

546 

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

548 

549 

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

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

552 

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

565 

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

573 

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

581 

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

597 

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

607 

608 

609# ACB 0.19.0+ compatibility 

610__all__ = ["HeroiconsAdapter", "HeroiconsSettings", "register_heroicons_filters"]