Source of file BootstrapExtension.php

Size: 21,018 Bytes - Last Modified: 2023-11-16T22:56:02+01:00

/home/websites/teicee/packagist/site/phpdoc/conf/../vendor/teicee/twig-bundle/src/Extension/BootstrapExtension.php

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
<?php
namespace TIC\TwigBundle\Extension;

use TIC\TwigBundle\Base\TICTwigExtension as BaseExtension;
use TIC\CoreBundle\Util\ConvertHelper;
use TIC\CoreBundle\Util\StringHelper;

use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;

/**
 * Filtres et fonctions twig pour générer des éléments avec Bootstrap 5.
 * https://symfony.com/doc/current/templating/twig_extension.html
 */
class BootstrapExtension extends BaseExtension
{

	public function getFilters(): array
	{
		return [
			new TwigFilter('bsState',     [$this, 'bsStateFilter'],         ['is_safe' => ['html']] ),
			new TwigFilter('bsBoolean',   [$this, 'bsBooleanFilter'],       ['is_safe' => ['html']] ),
			new TwigFilter('bsEnabled',   [$this, 'bsEnabledFilter'],       ['is_safe' => ['html']] ),
			new TwigFilter('bsToggle',    [$this, 'bsToggleFilter'],        ['is_safe' => ['html']] ),
			new TwigFilter('bsLabel',     [$this, 'bsLabelFilter'],         ['is_safe' => ['html']] ),
			new TwigFilter('bsBadge',     [$this, 'bsBadgeFilter'],         ['is_safe' => ['html']] ),
			new TwigFilter('bsCount',     [$this, 'bsCountFilter'],         ['is_safe' => ['html']] ),
			new TwigFilter('bsButton',    [$this, 'bsButtonFilter'],        ['is_safe' => ['html']] ),
			new TwigFilter('bsAction',    [$this, 'bsActionFilter'],        ['is_safe' => ['html']] ),
			new TwigFilter('bsAlert',     [$this, 'bsAlertFilter'],         ['is_safe' => ['html']] ),
#			new TwigFilter('bsThumb',     [$this, 'bsThumbFilter'],         ['is_safe' => ['html']] ),
		];
	}

	public function getFunctions(): array
	{
		return [
			new TwigFunction('card_tabs_head',  [$this, 'cardTabsHeadFunction'],  ['is_safe' => ['html']] ),
			new TwigFunction('form_tabs_head',  null,   ['node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode', 'is_safe' => ['html']] ),
			new TwigFunction('form_tabs_body',  null,   ['node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode', 'is_safe' => ['html']] ),
		];
	}

	public function __construct(TranslatorInterface $translator = null)
	{
		$this->translator = $translator;
	}


	/**
	 * Retourne le nom d'un icone correspondant à un état bootstrap (par exemple pour bsAlert).
	 */
	protected function getStateIcon(string $state): ?string
	{
		switch ($state) {
			case 'primary'   : return 'bi-chat-fill';
			case 'secondary' : return 'bi-question-circle-fill';
			case 'info'      : return 'bi-info-fill';
			case 'success'   : return 'bi-check-square-fill';
			case 'warning'   : return 'bi-exclamation-circle-fill';
			case 'danger'    : return 'bi-exclamation-triangle-fill';
			case 'dark'      : return 'bi-exclamation-square-fill';
			case 'light'     : return 'bi-chat-left-fill';
		}
		return null;
	}


	/**
	 * Détermine un contexte d'état Bootstrap d'après la valeur donnée.
	 *
	 * @param  mixed    $value      Variable à analyser (null, bool, string, numeric, DateTime)
	 * @return string               Classe Bootstrap (primary, secondary, info, success, warning, danger, light, dark)
	 */
	public function bsStateFilter($value): string
	{
		if (\is_object($value)) switch (true) {
			case ($value instanceof \DateTime):
				$value = $value->format('Ymd') - \date('Ymd');  // numeric (past/now/future)
				break;
			case \method_exists($value, '__toString'):
				$value = (string)$value;
				break;
			default:
				return 'light';
		}
		
		if (\is_string($value)) switch ($value) {
			case 'primary':
			case 'secondary':
			case 'success':
			case 'danger':
			case 'warning':
			case 'info':
			case 'light':
			case 'dark':
				return $value;
			case 'ext.boolean.true':  $value = true;  break;
			case 'ext.boolean.false': $value = false; break;
			case 'ext.boolean.null':  $value = null;  break;
			// TODO: voir pour conversion en numérique si ctype_digit() ?
		}
		
		if (\is_null($value)) return 'dark';
		if (\is_bool($value)) return ($value) ? 'success' : 'danger';
		if (\is_numeric($value)) {
			if ($value > 0) return 'success';
			if ($value < 0) return 'danger';
			return 'warning';
		}
		return 'info';
	}

	/**
	 * Affichage d'une valeur booléenne (vrai/faux) dans un label Bootstrap (avec traduction et état automatique).
	 *
	 * @param  mixed    $value      Variable à interpréter pour déterminer le booléen correspondant
	 * @param  string   $default    Libellé par défaut à afficher si aucune correspondance booléenne trouvée
	 * @return string               Code HTML représentant un label Bootstrap (gros badge carré)
	 */
	public function bsBooleanFilter($value, string $default='-'): string
	{
		$text = self::$strings['null'];
		try {
			$bool = $this->getBool($value);
			if ($bool !== null) $text = self::$strings[($bool) ? 'true' : 'false' ];
		} catch (\UnexpectedValueException $e) {
			$text = $default;
		}
		return $this->bsLabelFilter($text, true, true); // state auto et trans activé
	}

	/**
	 * Affichage d'une valeur booléenne (actif/inactif) dans un label Bootstrap (avec traduction et état automatique).
	 *
	 * @param  mixed    $value      Variable à interpréter pour déterminer le booléen correspondant
	 * @param  string   $tok_true   Libellé (token de traduction) à afficher si la variable vaut true
	 * @param  string   $tok_false  Libellé (token de traduction) à afficher si la variable vaut false
	 * @return string               Code HTML représentant un label Bootstrap (gros badge carré)
	 */
	public function bsEnabledFilter($value, string $tok_true='ext.state.enabled', string $tok_false='ext.state.disabled'): string
	{
		$state = $this->bsStateFilter($this->getBool($value));
		switch ($state) {
			case 'success' : $label = $tok_true;  break;
			case 'danger'  : $label = $tok_false; break;
			default        : $label = $value;
		}
		return $this->bsLabelFilter($label, $state, true);
	}

	/**
	 * Affichage d'une valeur booléenne (actif/inactif) dans un label Bootstrap cliquable (pour changer l'état).
	 *
	 * @param  mixed    $value      Variable à interpréter pour déterminer le booléen correspondant
	 * @param  string   $route_on   Route de l'action du lien si la variable vaut faux (pour activer)
	 * @param  string   $route_off  Route de l'action du lien si la variable vaut vrai (pour désactiver)
	 * @return string               Code HTML représentant un label Bootstrap (gros badge carré)
	 */
	public function bsToggleFilter(mixed $value, string $route_on, string $route_off = null): string
	{
		if ($route_off === null) $route_off = $route_on;
		
		$vbool = $this->getBool($value);
		$route = ($vbool) ? $route_off : $route_on;
		$title = ($vbool) ? "widget.toggle.off" : "widget.toggle.on";
		
		return sprintf('<a href="%s" title="%s">%s</a>',
			$route, $this->trans($title), $this->bsEnabledFilter($value)
		);
	}

	/**
	 * Affichage de la valeur dans un label Bootstrap (traduction activable) avec état automatique si non spécifié.
	 *
	 * @param  string   $value      Texte à afficher dans le bloc label
	 * @param  mixed    $state      Classe Bootstrap ('primary', 'info', 'success', 'warning', 'danger'...)
	 *                              ou boolean true pour détermination automatique à partir du texte (bsStateFilter)
	 *                              ou sinon (empty) utilisation de la classe par défaut ('dark')
	 * @param  bool     $trans      Recherche d'une traduction pour le token $text (si c'est une chaine)
	 * @param  array    $attrs      Liste d'attributs HTML (en clé/valeur) à ajouter
	 * @return string               Code HTML représentant un label Bootstrap (gros badge carré)
	 */
	public function bsLabelFilter($value, $state=null, bool $trans=false, array $attrs=[]): string
	{
		if ($state === true) $state = $this->bsStateFilter($value);
		
		return \sprintf('<span class="badge bg-%s"%s>%s</span>',
			empty($state) ? 'dark' : $state,
			$this->htmlAttr($attrs),
			$this->getText($value, $trans, true)
		);
	}

	/**
	 * Affichage de la valeur dans un badge Bootstrap (traduction activable).
	 *
	 * @param  mixed    $value      Valeur à afficher dans le bloc badge (calcul du nombre d'éléments pour un objet|array)
	 * @param  mixed    $state      Classe Bootstrap ('primary', 'info', 'success', 'warning', 'danger'...)
	 *                              ou boolean true pour détermination automatique à partir du texte (bsStateFilter)
	 *                              ou sinon (empty) utilisation de la classe par défaut ('dark')
	 * @param  bool     $trans      Recherche d'une traduction pour le token $value (si c'est une chaine)
	 * @param  string   $default    Valeur par défaut à traiter si $value est null (aucun badge si $default est aussi null)
	 * @return string               Code HTML représentant un badge Bootstrap (petit badge arrondi)
	 */
	public function bsBadgeFilter($value, $state=null, bool $trans=false, ?string $default=''): string
	{
		if ($value === null) { if ($default === null) return ''; $value = $default; }
		if (! \is_scalar($value)) $value = \count($value);
		
		if ($state === true) $state = $this->bsStateFilter($value);
		
		return \sprintf('<span class="badge rounded-pill bg-%s">%s</span>',
			empty($state) ? 'dark' : $state,
			$this->getText($value, $trans, true)
		);
	}

	/**
	 * Affichage d'un nombre dans un badge Bootstrap (conversion numérique automatique).
	 *
	 * @param  mixed    $value      Valeur à traiter pour obtenir une quantité (calcul du nombre d'éléments pour un objet|array)
	 * @param  mixed    $state      Classe Bootstrap ('primary', 'info', 'success', 'warning', 'danger'...)
	 *                              ou boolean true pour détermination automatique à partir du nombre (bsStateFilter)
	 *                              ou sinon (empty) utilisation de la classe par défaut ('dark')
	 * @param  string   $title      Texte facultatif pour ajouter un attribut HTML title (affichage au survol)
	 * @param  string   $default    Code HTML par défaut à retourner si le nombre est 0 (en place du badge)
	 * @return string               Code HTML représentant un badge Bootstrap (petit badge arrondi)
	 */
	public function bsCountFilter($value, $state=null, ?string $title=null, ?string $default=null)
	{
		$value = ConvertHelper::counter($value);
		if ($value === null) return $default;
		
		if ($state === true) $state = $this->bsStateFilter($value);
		
		return \sprintf('<span class="tic-counter badge rounded-pill bg-%s"%s>%s</span>',
			empty($state) ? 'dark' : $state,
			empty($title) ? '' : ' title="'.\htmlspecialchars($title).'"',
			\number_format($value, 0, ',', ' ')
		);
	}

	/**
	 * Génération d'un bouton (icone et/ou texte) avec les styles Bootstrap.
	 *
	 * @param  string   $target     URL (pour tag A) ou TYPE (pour tag BUTTON avec 'submit'|'reset'|'button') ou #ID ou .CLASS (tag button)
	 * @param  string   $icon       Nom d'un icone (FontAwesome), par ex: 'ok', 'download', 'envelope', 'ban-circle', 'remove', 'edit', 'list'
	 * @param  mixed    $label      Texte à afficher dans le bouton à côté de l'icone (utilisation de $href si true)
	 * @param  string   $context    Classe Bootstrap pour le style du bouton : 'primary', 'secondary', 'info', 'success', 'warning', 'danger', 'link'
	 *                              (par défaut 'primary' pour un bouton de type 'submit', sinon 'default')
	 * @param  string   $size       Classe Bootstrap pour indiquer la taille du bouton : 'lg', 'md' (défaut), 'sm', 'xs'
	 * @param  array    $attrs      Liste d'attributs HTML supplémentaires (ex 'class', 'title', 'confirm', 'disabled')
	 * @param  bool     $trans      Si faux, désactivation de la traduction du libellé du bouton (et éventuel message de confirmation)
	 * @return string               Code HTML représentant un bouton Bootstrap
	 *
	 * Exemples :
	 *   {{ 'submit'|bsButton('save', 'btn.save') }}
	 *   {{ path('app_item_show', {'id':item.id})|bsButton('show', 'btn.show') }}
	 *   {{ path('app_item_delete', {'id':item.id})|bsButton('remove', 'btn.delete', 'danger', '', {'confirm':true}) }}
	 */
	public function bsButtonFilter(string $target=null, string $icon=null, $label=null, string $context=null, string $size=null, array $attrs=[], bool $trans=true): string
	{
		// gestion de l'option 'confirm'
		$confirm = false;
		if (\array_key_exists('confirm', $attrs)) {
			$confirm = $attrs['confirm'];
			unset($attrs['confirm']);
			if (\is_string($confirm) && \strlen($confirm)) $attrs['data-confirm'] = ($trans) ? $this->trans($confirm) : $confirm;
		}
		
		// gestion de l'option 'modal'
		if (\array_key_exists('modal', $attrs)) {
			if (! isset($attrs['disabled']) || ! $attrs['disabled']) {
				$attrs['data-bs-toggle'] = "modal";
				$attrs['data-bs-target'] = "#" . $attrs['modal'];
			}
			unset($attrs['modal']);
		}
		
		// différents types de boutons selon $target
		if (\preg_match('/^(submit|reset|button)?$/', $target)) {
			$tag = 'button';
			if ($target !== '') {
				$attrs['type'] = $target;
				if ($label === true ) { $label = 'btn.' . $target; $trans = true; }
			}
		}
		elseif (\preg_match('/^([#\.])(.*)$/', $target, $match)) {
			$tag = 'button';
			$attrs['type'] = 'button';
			if ($match[1] == '#') $attrs['id']    = $match[2];
			else                  $attrs['class'] = $match[2] . ' ' . $attrs['class'];
			if ($label === true) { $label = 'btn.' . $match[2]; $trans = true; }
		}
		elseif (isset($attrs['disabled']) && $attrs['disabled']) {
			// si disabled : <button> forcé avec href déporté dans 'data-href'
			// sans quoi un <a> avec la classe 'disabled' de bootstrap bloque tout event avec 'pointers-event:none', y compris le changement du cursor !
			$tag = 'button';
			$attrs['type'] = 'button';
			$attrs['data-href'] = $target;
			if ($label === true) { $label = $target; $trans = false; }
		}
		elseif ($confirm) {
			// si click à protéger par une demande de confirmation, href déporté dans 'data-href'
			// l'URL sera remise uniquement après confirmation positive (évite les risques de court-circuit notamment par un middle-clic)
			$tag = 'a';
			$attrs['role'] = 'button';
			$attrs['href'] = '#need_confirmation';  // alternative: 'javascript:null'
			$attrs['data-href'] = $target;
		}
		else {
			$tag = 'a';
			$attrs['role'] = 'button';
			$attrs['href'] = $target;
			if ($label === true) { $label = $target; $trans = false; }
			if (isset($attrs['disabled'])) unset($attrs['disabled']);
		}
		
		// détermination des classes CSS
		if (! \strlen($context)) $context = ($target === 'submit') ? "primary" : "secondary";
		$class = "btn btn-" . $context;
		if (! empty($size) && ($size != 'md')) $class.= " btn-" . $size;
		if (! empty($confirm)) $class.= " btn-confirm";
		if (isset($attrs['disabled']) && $attrs['disabled']) $class.= " disabled";
		if (\array_key_exists('class', $attrs)) $class.= " " .$attrs['class'];
		
		// génération du contenu du bouton (icone & texte)
		if ($label === null) $label = '';
		if ($trans) $label = $this->trans($label);
		if ($icon) {
			$content = $this->getIcon($icon);
			if (\strlen($label)) { $content.= "&nbsp;$label"; $class.= " icon-left"; }
		} else {
			$content = $label;
		}
		
		// envoi du tag du bouton
		$attrs['class'] = $class;
		return \sprintf('<%1$s %2$s>%3$s</%1$s>', $tag, $this->htmlAttr($attrs), $content);
	}

	/**
	 * Génération d'un bouton affichant uniquement un icone (avec libellé en title) avec les styles Bootstrap.
	 *
	 * @param  string   $target     URL (pour tag A) ou TYPE (pour tag BUTTON avec 'submit'|'reset'|'button') ou #ID ou .CLASS (tag button)
	 * @param  string   $icon       Nom d'un icone (FontAwesome), par ex: 'ok', 'download', 'envelope', 'ban-circle', 'remove', 'edit', 'list'
	 * @param  string   $title      Texte à afficher uniquement au survol du bouton (utilisation de $href si true)
	 * @param  string   $context    Classe Bootstrap pour le style du bouton : 'default', 'primary' (défaut), 'info', 'success', 'warning', 'danger', 'link'
	 * @param  string   $size       Classe Bootstrap pour indiquer la taille du bouton : 'lg', 'md', 'sm' (défaut), 'xs'
	 * @param  array    $attrs      Liste d'attributs HTML supplémentaires (ex 'class', 'title', 'confirm', 'disabled')
	 * @param  bool     $trans      Si faux, désactivation de la traduction du libellé du bouton (et éventuel message de confirmation)
	 * @return string               Code HTML représentant un bouton Bootstrap
	 */
	public function bsActionFilter(string $target=null, string $icon=null, string $title=null, string $context=null, string $size=null, array $attrs=[], bool $trans=true): string
	{
		if ($title !== null) $attrs['title'] = ($trans) ? $this->trans($title) : $title;
		if (empty($context)) $context = 'primary';
		if (empty($size))    $size    = 'sm';
		return $this->bsButtonFilter($target, $icon, null, $context, $size, $attrs, $trans);
	}

	/**
	 * Affichage d'un texte dans une boite d'alerte Bootstrap.
	 *
	 * @param  string   $text       Texte à afficher dans la boite d'alerte
	 * @param  string   $state      Classe Bootstrap : primary, secondary, info (défaut), success, warning, danger, dark, light
	 * @param  mixed    $icon       Nom d'un icone (ou true/null par défaut pour automatique à partir du $state)
	 * @param  bool     $html       Désactivation de l'échappement du texte s'il doit déjà contenir du HTML
	 * @param  bool     $close      Ajout d'un bouton permettant de fermer la boite d'alerte
	 * @param  bool     $trans      Recherche d'une traduction pour le token $text (si c'est une chaine)
	 * @return string               Code HTML représentant un message d'alerte Bootstrap
	 */
	public function bsAlertFilter($text, ?string $state = null, $icon = null, bool $html = false, bool $close = false, bool $trans = false): string
	{
		$class = 'alert-' . (empty($state) ? 'info' : $state);
		$head = $body = $foot = '';
		
		if ($icon === null || $icon === true) $icon = $this->getStateIcon($state);
		if ($icon) {
			$class.= ' d-flex align-items-center';
			$head = $this->getIcon($icon) . '<div>';
			$foot = '</div>';
		}
		if ($close) {
			$class.= ' alert-dismissible fade show';
			$foot.= '<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>';
		}
		$body = ($trans) ? $this->trans($text) : $text;
		if (! $html) $body = \htmlspecialchars($body);
		return \sprintf('<div class="alert %s" role="alert">%s</div>', $class, $head, $body, $foot);
	}


	/**
	 * Formattage d'une liste de liens en barre de navigation Bootstrap (pour header de card).
	 *
	 * @param  array    $navs       Liste de références pour les différents onglets/boutons de la barre
	 * @param  string   $type       Style des liens : 'tabs' (onglets), 'pills' (boutons) ou 'vtabs' (boutons verticaux)
	 * @param  string   $label      Préfixe des tokens de traductions pour les libellés des références (défaut 'tabs.group.')
	 * @param  array    $extra      Liste de contenus HTML (indexés par les références) à ajouter après le libellé
	 * @return string               Code HTML d'une liste de liens de navigation Bootstrap (onglets)
	 */
	public function cardTabsHeadFunction(array $navs, string $type = 'tabs', ?string $label = 'tabs.group.', array $extra = []): string
	{
		if (empty($navs)) return '';
		
		if ($type == 'vtabs') {
			$container_tag = 'div';
			$container_mode = 'vertical';
			$container_class = 'nav nav-pills flex-column me-3';
		} else {
			$container_tag = 'ul';
			$container_mode = 'horizontal';
			$container_class = 'nav nav-' . $type . ' card-header-tabs';
		}
		
		$line = '<button role="tab" data-bs-toggle="'.(($type=='tabs')?'tab':'pill').'" data-bs-target="#%1$s"';
		$line.= ' class="nav-link%4$s" aria-controls="%2$s" type="button">%2$s%3$s</button>';
		if ($container_tag == 'ul') $line = '<li role="presentation" class="nav-item">' . $line . '</li>' . "\n";
		
		$html = \sprintf('<%s role="tablist" class="%s" aria-orientation="%s">', $container_tag, $container_class, $container_mode);
		$active = ' active';
		foreach ($navs as $nav) {
			if ($nav === '_') $nav = "misc";
			$ref = StringHelper::slugify($nav);
			$html.= \sprintf($line,
				\htmlspecialchars($ref . '_pane'),
				\htmlspecialchars(empty($label) ? $nav : $this->trans($label . $ref)),
				isset($extra[$ref]) ? $extra[$ref] : '',
				$active
			);
			$active = "";
		}
		$html.= '</' . $container_tag . '>';
		
		$html.= <<<END
<script>
if (typeof ticStarter == "object") ticStarter.add( function(){
	// activation de l'onglet correspondant si un hash est passé dans l'URL
	var hash = document.location.hash.replace('_tab', '_pane');
	if (hash) jQuery('.nav [data-bs-toggle][data-bs-target="' + hash + '"]').first().tab('show');
	
	// mise à jour du hash dans l'URL sur changement d'onglet (ancre altérée pour éviter le déplacement)
	var res = jQuery('.nav [data-bs-toggle][data-bs-target]').on('shown.bs.tab', function(e){
		document.location.hash = e.target.data('bs-target').replace('_pane', '_tab');
	});
});
</script>
END;
		return $html;
	}

}