1: <?php
2: namespace TIC\TwigBundle\Extension;
3:
4: use TIC\TwigBundle\Base\TICTwigExtension as BaseExtension;
5: use TIC\CoreBundle\Util\ConvertHelper;
6: use TIC\CoreBundle\Util\StringHelper;
7:
8: use Symfony\Contracts\Translation\TranslatorInterface;
9: use Twig\TwigFilter;
10: use Twig\TwigFunction;
11:
12: /**
13: * Filtres et fonctions twig pour générer des éléments avec Bootstrap 5.
14: * https://symfony.com/doc/current/templating/twig_extension.html
15: */
16: class BootstrapExtension extends BaseExtension
17: {
18:
19: public function getFilters(): array
20: {
21: return [
22: new TwigFilter('bsState', [$this, 'bsStateFilter'], ['is_safe' => ['html']] ),
23: new TwigFilter('bsBoolean', [$this, 'bsBooleanFilter'], ['is_safe' => ['html']] ),
24: new TwigFilter('bsEnabled', [$this, 'bsEnabledFilter'], ['is_safe' => ['html']] ),
25: new TwigFilter('bsToggle', [$this, 'bsToggleFilter'], ['is_safe' => ['html']] ),
26: new TwigFilter('bsLabel', [$this, 'bsLabelFilter'], ['is_safe' => ['html']] ),
27: new TwigFilter('bsBadge', [$this, 'bsBadgeFilter'], ['is_safe' => ['html']] ),
28: new TwigFilter('bsCount', [$this, 'bsCountFilter'], ['is_safe' => ['html']] ),
29: new TwigFilter('bsButton', [$this, 'bsButtonFilter'], ['is_safe' => ['html']] ),
30: new TwigFilter('bsAction', [$this, 'bsActionFilter'], ['is_safe' => ['html']] ),
31: new TwigFilter('bsAlert', [$this, 'bsAlertFilter'], ['is_safe' => ['html']] ),
32: # new TwigFilter('bsThumb', [$this, 'bsThumbFilter'], ['is_safe' => ['html']] ),
33: ];
34: }
35:
36: public function getFunctions(): array
37: {
38: return [
39: new TwigFunction('card_tabs_head', [$this, 'cardTabsHeadFunction'], ['is_safe' => ['html']] ),
40: new TwigFunction('form_tabs_head', null, ['node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode', 'is_safe' => ['html']] ),
41: new TwigFunction('form_tabs_body', null, ['node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode', 'is_safe' => ['html']] ),
42: ];
43: }
44:
45: public function __construct(TranslatorInterface $translator = null)
46: {
47: $this->translator = $translator;
48: }
49:
50:
51: /**
52: * Retourne le nom d'un icone correspondant à un état bootstrap (par exemple pour bsAlert).
53: */
54: protected function getStateIcon(string $state): ?string
55: {
56: switch ($state) {
57: case 'primary' : return 'bi-chat-fill';
58: case 'secondary' : return 'bi-question-circle-fill';
59: case 'info' : return 'bi-info-fill';
60: case 'success' : return 'bi-check-square-fill';
61: case 'warning' : return 'bi-exclamation-circle-fill';
62: case 'danger' : return 'bi-exclamation-triangle-fill';
63: case 'dark' : return 'bi-exclamation-square-fill';
64: case 'light' : return 'bi-chat-left-fill';
65: }
66: return null;
67: }
68:
69:
70: /**
71: * Détermine un contexte d'état Bootstrap d'après la valeur donnée.
72: *
73: * @param mixed $value Variable à analyser (null, bool, string, numeric, DateTime)
74: * @return string Classe Bootstrap (primary, secondary, info, success, warning, danger, light, dark)
75: */
76: public function bsStateFilter($value): string
77: {
78: if (\is_object($value)) switch (true) {
79: case ($value instanceof \DateTime):
80: $value = $value->format('Ymd') - \date('Ymd'); // numeric (past/now/future)
81: break;
82: case \method_exists($value, '__toString'):
83: $value = (string)$value;
84: break;
85: default:
86: return 'light';
87: }
88:
89: if (\is_string($value)) switch ($value) {
90: case 'primary':
91: case 'secondary':
92: case 'success':
93: case 'danger':
94: case 'warning':
95: case 'info':
96: case 'light':
97: case 'dark':
98: return $value;
99: case 'ext.boolean.true': $value = true; break;
100: case 'ext.boolean.false': $value = false; break;
101: case 'ext.boolean.null': $value = null; break;
102: // TODO: voir pour conversion en numérique si ctype_digit() ?
103: }
104:
105: if (\is_null($value)) return 'dark';
106: if (\is_bool($value)) return ($value) ? 'success' : 'danger';
107: if (\is_numeric($value)) {
108: if ($value > 0) return 'success';
109: if ($value < 0) return 'danger';
110: return 'warning';
111: }
112: return 'info';
113: }
114:
115: /**
116: * Affichage d'une valeur booléenne (vrai/faux) dans un label Bootstrap (avec traduction et état automatique).
117: *
118: * @param mixed $value Variable à interpréter pour déterminer le booléen correspondant
119: * @param string $default Libellé par défaut à afficher si aucune correspondance booléenne trouvée
120: * @return string Code HTML représentant un label Bootstrap (gros badge carré)
121: */
122: public function bsBooleanFilter($value, string $default='-'): string
123: {
124: $text = self::$strings['null'];
125: try {
126: $bool = $this->getBool($value);
127: if ($bool !== null) $text = self::$strings[($bool) ? 'true' : 'false' ];
128: } catch (\UnexpectedValueException $e) {
129: $text = $default;
130: }
131: return $this->bsLabelFilter($text, true, true); // state auto et trans activé
132: }
133:
134: /**
135: * Affichage d'une valeur booléenne (actif/inactif) dans un label Bootstrap (avec traduction et état automatique).
136: *
137: * @param mixed $value Variable à interpréter pour déterminer le booléen correspondant
138: * @param string $tok_true Libellé (token de traduction) à afficher si la variable vaut true
139: * @param string $tok_false Libellé (token de traduction) à afficher si la variable vaut false
140: * @return string Code HTML représentant un label Bootstrap (gros badge carré)
141: */
142: public function bsEnabledFilter($value, string $tok_true='ext.state.enabled', string $tok_false='ext.state.disabled'): string
143: {
144: $state = $this->bsStateFilter($this->getBool($value));
145: switch ($state) {
146: case 'success' : $label = $tok_true; break;
147: case 'danger' : $label = $tok_false; break;
148: default : $label = $value;
149: }
150: return $this->bsLabelFilter($label, $state, true);
151: }
152:
153: /**
154: * Affichage d'une valeur booléenne (actif/inactif) dans un label Bootstrap cliquable (pour changer l'état).
155: *
156: * @param mixed $value Variable à interpréter pour déterminer le booléen correspondant
157: * @param string $route_on Route de l'action du lien si la variable vaut faux (pour activer)
158: * @param string $route_off Route de l'action du lien si la variable vaut vrai (pour désactiver)
159: * @return string Code HTML représentant un label Bootstrap (gros badge carré)
160: */
161: public function bsToggleFilter(mixed $value, string $route_on, string $route_off = null): string
162: {
163: if ($route_off === null) $route_off = $route_on;
164:
165: $vbool = $this->getBool($value);
166: $route = ($vbool) ? $route_off : $route_on;
167: $title = ($vbool) ? "widget.toggle.off" : "widget.toggle.on";
168:
169: return sprintf('<a href="%s" title="%s">%s</a>',
170: $route, $this->trans($title), $this->bsEnabledFilter($value)
171: );
172: }
173:
174: /**
175: * Affichage de la valeur dans un label Bootstrap (traduction activable) avec état automatique si non spécifié.
176: *
177: * @param string $value Texte à afficher dans le bloc label
178: * @param mixed $state Classe Bootstrap ('primary', 'info', 'success', 'warning', 'danger'...)
179: * ou boolean true pour détermination automatique à partir du texte (bsStateFilter)
180: * ou sinon (empty) utilisation de la classe par défaut ('dark')
181: * @param bool $trans Recherche d'une traduction pour le token $text (si c'est une chaine)
182: * @param array $attrs Liste d'attributs HTML (en clé/valeur) à ajouter
183: * @return string Code HTML représentant un label Bootstrap (gros badge carré)
184: */
185: public function bsLabelFilter($value, $state=null, bool $trans=false, array $attrs=[]): string
186: {
187: if ($state === true) $state = $this->bsStateFilter($value);
188:
189: return \sprintf('<span class="badge bg-%s"%s>%s</span>',
190: empty($state) ? 'dark' : $state,
191: $this->htmlAttr($attrs),
192: $this->getText($value, $trans, true)
193: );
194: }
195:
196: /**
197: * Affichage de la valeur dans un badge Bootstrap (traduction activable).
198: *
199: * @param mixed $value Valeur à afficher dans le bloc badge (calcul du nombre d'éléments pour un objet|array)
200: * @param mixed $state Classe Bootstrap ('primary', 'info', 'success', 'warning', 'danger'...)
201: * ou boolean true pour détermination automatique à partir du texte (bsStateFilter)
202: * ou sinon (empty) utilisation de la classe par défaut ('dark')
203: * @param bool $trans Recherche d'une traduction pour le token $value (si c'est une chaine)
204: * @param string $default Valeur par défaut à traiter si $value est null (aucun badge si $default est aussi null)
205: * @return string Code HTML représentant un badge Bootstrap (petit badge arrondi)
206: */
207: public function bsBadgeFilter($value, $state=null, bool $trans=false, ?string $default=''): string
208: {
209: if ($value === null) { if ($default === null) return ''; $value = $default; }
210: if (! \is_scalar($value)) $value = \count($value);
211:
212: if ($state === true) $state = $this->bsStateFilter($value);
213:
214: return \sprintf('<span class="badge rounded-pill bg-%s">%s</span>',
215: empty($state) ? 'dark' : $state,
216: $this->getText($value, $trans, true)
217: );
218: }
219:
220: /**
221: * Affichage d'un nombre dans un badge Bootstrap (conversion numérique automatique).
222: *
223: * @param mixed $value Valeur à traiter pour obtenir une quantité (calcul du nombre d'éléments pour un objet|array)
224: * @param mixed $state Classe Bootstrap ('primary', 'info', 'success', 'warning', 'danger'...)
225: * ou boolean true pour détermination automatique à partir du nombre (bsStateFilter)
226: * ou sinon (empty) utilisation de la classe par défaut ('dark')
227: * @param string $title Texte facultatif pour ajouter un attribut HTML title (affichage au survol)
228: * @param string $default Code HTML par défaut à retourner si le nombre est 0 (en place du badge)
229: * @return string Code HTML représentant un badge Bootstrap (petit badge arrondi)
230: */
231: public function bsCountFilter($value, $state=null, ?string $title=null, ?string $default=null)
232: {
233: $value = ConvertHelper::counter($value);
234: if ($value === null) return $default;
235:
236: if ($state === true) $state = $this->bsStateFilter($value);
237:
238: return \sprintf('<span class="tic-counter badge rounded-pill bg-%s"%s>%s</span>',
239: empty($state) ? 'dark' : $state,
240: empty($title) ? '' : ' title="'.\htmlspecialchars($title).'"',
241: \number_format($value, 0, ',', ' ')
242: );
243: }
244:
245: /**
246: * Génération d'un bouton (icone et/ou texte) avec les styles Bootstrap.
247: *
248: * @param string $target URL (pour tag A) ou TYPE (pour tag BUTTON avec 'submit'|'reset'|'button') ou #ID ou .CLASS (tag button)
249: * @param string $icon Nom d'un icone (FontAwesome), par ex: 'ok', 'download', 'envelope', 'ban-circle', 'remove', 'edit', 'list'
250: * @param mixed $label Texte à afficher dans le bouton à côté de l'icone (utilisation de $href si true)
251: * @param string $context Classe Bootstrap pour le style du bouton : 'primary', 'secondary', 'info', 'success', 'warning', 'danger', 'link'
252: * (par défaut 'primary' pour un bouton de type 'submit', sinon 'default')
253: * @param string $size Classe Bootstrap pour indiquer la taille du bouton : 'lg', 'md' (défaut), 'sm', 'xs'
254: * @param array $attrs Liste d'attributs HTML supplémentaires (ex 'class', 'title', 'confirm', 'disabled')
255: * @param bool $trans Si faux, désactivation de la traduction du libellé du bouton (et éventuel message de confirmation)
256: * @return string Code HTML représentant un bouton Bootstrap
257: *
258: * Exemples :
259: * {{ 'submit'|bsButton('save', 'btn.save') }}
260: * {{ path('app_item_show', {'id':item.id})|bsButton('show', 'btn.show') }}
261: * {{ path('app_item_delete', {'id':item.id})|bsButton('remove', 'btn.delete', 'danger', '', {'confirm':true}) }}
262: */
263: public function bsButtonFilter(string $target=null, string $icon=null, $label=null, string $context=null, string $size=null, array $attrs=[], bool $trans=true): string
264: {
265: // gestion de l'option 'confirm'
266: $confirm = false;
267: if (\array_key_exists('confirm', $attrs)) {
268: $confirm = $attrs['confirm'];
269: unset($attrs['confirm']);
270: if (\is_string($confirm) && \strlen($confirm)) $attrs['data-confirm'] = ($trans) ? $this->trans($confirm) : $confirm;
271: }
272:
273: // gestion de l'option 'modal'
274: if (\array_key_exists('modal', $attrs)) {
275: if (! isset($attrs['disabled']) || ! $attrs['disabled']) {
276: $attrs['data-bs-toggle'] = "modal";
277: $attrs['data-bs-target'] = "#" . $attrs['modal'];
278: }
279: unset($attrs['modal']);
280: }
281:
282: // différents types de boutons selon $target
283: if (\preg_match('/^(submit|reset|button)?$/', $target)) {
284: $tag = 'button';
285: if ($target !== '') {
286: $attrs['type'] = $target;
287: if ($label === true ) { $label = 'btn.' . $target; $trans = true; }
288: }
289: }
290: elseif (\preg_match('/^([#\.])(.*)$/', $target, $match)) {
291: $tag = 'button';
292: $attrs['type'] = 'button';
293: if ($match[1] == '#') $attrs['id'] = $match[2];
294: else $attrs['class'] = $match[2] . ' ' . $attrs['class'];
295: if ($label === true) { $label = 'btn.' . $match[2]; $trans = true; }
296: }
297: elseif (isset($attrs['disabled']) && $attrs['disabled']) {
298: // si disabled : <button> forcé avec href déporté dans 'data-href'
299: // sans quoi un <a> avec la classe 'disabled' de bootstrap bloque tout event avec 'pointers-event:none', y compris le changement du cursor !
300: $tag = 'button';
301: $attrs['type'] = 'button';
302: $attrs['data-href'] = $target;
303: if ($label === true) { $label = $target; $trans = false; }
304: }
305: elseif ($confirm) {
306: // si click à protéger par une demande de confirmation, href déporté dans 'data-href'
307: // l'URL sera remise uniquement après confirmation positive (évite les risques de court-circuit notamment par un middle-clic)
308: $tag = 'a';
309: $attrs['role'] = 'button';
310: $attrs['href'] = '#need_confirmation'; // alternative: 'javascript:null'
311: $attrs['data-href'] = $target;
312: }
313: else {
314: $tag = 'a';
315: $attrs['role'] = 'button';
316: $attrs['href'] = $target;
317: if ($label === true) { $label = $target; $trans = false; }
318: if (isset($attrs['disabled'])) unset($attrs['disabled']);
319: }
320:
321: // détermination des classes CSS
322: if (! \strlen($context)) $context = ($target === 'submit') ? "primary" : "secondary";
323: $class = "btn btn-" . $context;
324: if (! empty($size) && ($size != 'md')) $class.= " btn-" . $size;
325: if (! empty($confirm)) $class.= " btn-confirm";
326: if (isset($attrs['disabled']) && $attrs['disabled']) $class.= " disabled";
327: if (\array_key_exists('class', $attrs)) $class.= " " .$attrs['class'];
328:
329: // génération du contenu du bouton (icone & texte)
330: if ($label === null) $label = '';
331: if ($trans) $label = $this->trans($label);
332: if ($icon) {
333: $content = $this->getIcon($icon);
334: if (\strlen($label)) { $content.= "&nbsp;$label"; $class.= " icon-left"; }
335: } else {
336: $content = $label;
337: }
338:
339: // envoi du tag du bouton
340: $attrs['class'] = $class;
341: return \sprintf('<%1$s %2$s>%3$s</%1$s>', $tag, $this->htmlAttr($attrs), $content);
342: }
343:
344: /**
345: * Génération d'un bouton affichant uniquement un icone (avec libellé en title) avec les styles Bootstrap.
346: *
347: * @param string $target URL (pour tag A) ou TYPE (pour tag BUTTON avec 'submit'|'reset'|'button') ou #ID ou .CLASS (tag button)
348: * @param string $icon Nom d'un icone (FontAwesome), par ex: 'ok', 'download', 'envelope', 'ban-circle', 'remove', 'edit', 'list'
349: * @param string $title Texte à afficher uniquement au survol du bouton (utilisation de $href si true)
350: * @param string $context Classe Bootstrap pour le style du bouton : 'default', 'primary' (défaut), 'info', 'success', 'warning', 'danger', 'link'
351: * @param string $size Classe Bootstrap pour indiquer la taille du bouton : 'lg', 'md', 'sm' (défaut), 'xs'
352: * @param array $attrs Liste d'attributs HTML supplémentaires (ex 'class', 'title', 'confirm', 'disabled')
353: * @param bool $trans Si faux, désactivation de la traduction du libellé du bouton (et éventuel message de confirmation)
354: * @return string Code HTML représentant un bouton Bootstrap
355: */
356: public function bsActionFilter(string $target=null, string $icon=null, string $title=null, string $context=null, string $size=null, array $attrs=[], bool $trans=true): string
357: {
358: if ($title !== null) $attrs['title'] = ($trans) ? $this->trans($title) : $title;
359: if (empty($context)) $context = 'primary';
360: if (empty($size)) $size = 'sm';
361: return $this->bsButtonFilter($target, $icon, null, $context, $size, $attrs, $trans);
362: }
363:
364: /**
365: * Affichage d'un texte dans une boite d'alerte Bootstrap.
366: *
367: * @param string $text Texte à afficher dans la boite d'alerte
368: * @param string $state Classe Bootstrap : primary, secondary, info (défaut), success, warning, danger, dark, light
369: * @param mixed $icon Nom d'un icone (ou true/null par défaut pour automatique à partir du $state)
370: * @param bool $html Désactivation de l'échappement du texte s'il doit déjà contenir du HTML
371: * @param bool $close Ajout d'un bouton permettant de fermer la boite d'alerte
372: * @param bool $trans Recherche d'une traduction pour le token $text (si c'est une chaine)
373: * @return string Code HTML représentant un message d'alerte Bootstrap
374: */
375: public function bsAlertFilter($text, ?string $state = null, $icon = null, bool $html = false, bool $close = false, bool $trans = false): string
376: {
377: $class = 'alert-' . (empty($state) ? 'info' : $state);
378: $head = $body = $foot = '';
379:
380: if ($icon === null || $icon === true) $icon = $this->getStateIcon($state);
381: if ($icon) {
382: $class.= ' d-flex align-items-center';
383: $head = $this->getIcon($icon) . '<div>';
384: $foot = '</div>';
385: }
386: if ($close) {
387: $class.= ' alert-dismissible fade show';
388: $foot.= '<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>';
389: }
390: $body = ($trans) ? $this->trans($text) : $text;
391: if (! $html) $body = \htmlspecialchars($body);
392: return \sprintf('<div class="alert %s" role="alert">%s</div>', $class, $head, $body, $foot);
393: }
394:
395:
396: /**
397: * Formattage d'une liste de liens en barre de navigation Bootstrap (pour header de card).
398: *
399: * @param array $navs Liste de références pour les différents onglets/boutons de la barre
400: * @param string $type Style des liens : 'tabs' (onglets), 'pills' (boutons) ou 'vtabs' (boutons verticaux)
401: * @param string $label Préfixe des tokens de traductions pour les libellés des références (défaut 'tabs.group.')
402: * @param array $extra Liste de contenus HTML (indexés par les références) à ajouter après le libellé
403: * @return string Code HTML d'une liste de liens de navigation Bootstrap (onglets)
404: */
405: public function cardTabsHeadFunction(array $navs, string $type = 'tabs', ?string $label = 'tabs.group.', array $extra = []): string
406: {
407: if (empty($navs)) return '';
408:
409: if ($type == 'vtabs') {
410: $container_tag = 'div';
411: $container_mode = 'vertical';
412: $container_class = 'nav nav-pills flex-column me-3';
413: } else {
414: $container_tag = 'ul';
415: $container_mode = 'horizontal';
416: $container_class = 'nav nav-' . $type . ' card-header-tabs';
417: }
418:
419: $line = '<button role="tab" data-bs-toggle="'.(($type=='tabs')?'tab':'pill').'" data-bs-target="#%1$s"';
420: $line.= ' class="nav-link%4$s" aria-controls="%2$s" type="button">%2$s%3$s</button>';
421: if ($container_tag == 'ul') $line = '<li role="presentation" class="nav-item">' . $line . '</li>' . "\n";
422:
423: $html = \sprintf('<%s role="tablist" class="%s" aria-orientation="%s">', $container_tag, $container_class, $container_mode);
424: $active = ' active';
425: foreach ($navs as $nav) {
426: if ($nav === '_') $nav = "misc";
427: $ref = StringHelper::slugify($nav);
428: $html.= \sprintf($line,
429: \htmlspecialchars($ref . '_pane'),
430: \htmlspecialchars(empty($label) ? $nav : $this->trans($label . $ref)),
431: isset($extra[$ref]) ? $extra[$ref] : '',
432: $active
433: );
434: $active = "";
435: }
436: $html.= '</' . $container_tag . '>';
437:
438: $html.= <<<END
439: <script>
440: if (typeof ticStarter == "object") ticStarter.add( function(){
441: // activation de l'onglet correspondant si un hash est passé dans l'URL
442: var hash = document.location.hash.replace('_tab', '_pane');
443: if (hash) jQuery('.nav [data-bs-toggle][data-bs-target="' + hash + '"]').first().tab('show');
444:
445: // mise à jour du hash dans l'URL sur changement d'onglet (ancre altérée pour éviter le déplacement)
446: var res = jQuery('.nav [data-bs-toggle][data-bs-target]').on('shown.bs.tab', function(e){
447: document.location.hash = e.target.data('bs-target').replace('_pane', '_tab');
448: });
449: });
450: </script>
451: END;
452: return $html;
453: }
454:
455: }
456: