mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Mirrors the mots-clé tag-search system: dropdown suggestions from existing languages via HTMX, pill display with bin-icon remove buttons, 'Créer' option for new languages. Replaces the plain text input. - New partial: templates/partials/form/language-search.php - New fragment: public/partage/language-search-fragment.php - Admin wrapper: public/admin/language-search-fragment.php - Updated language-autre-fragment to return just the required asterisk indicator - Updated both controllers to handle language_autre as array (pill-based) with backward-compatible string path - Updated edit form to compute selectedOtherLanguages from DB - Registered new route in partage/index.php - Fix CSV importer: split comma-separated language column into individual entries - Add htmx active search to admin index, title line-clamp, predefined languages only in checkboxes - Admin index: filter form now uses htmx triggers (input delay:300ms on search, change on selects) to actively search without page reload - Sort links include hx-push-url for back-button support - Added loading indicator bar (.admin-search-indicator) - Title column: line-clamp at 2 lines with overflow hidden, native title attr tooltip for full text - Language checkboxes now show only 3 predefined languages (Français, Anglais, Néerlandais); all others go via the Autre langue search component - Added Database::getPredefinedLanguages() and excluded predefined from language-search-fragment suggestions - Included hidden sort/dir inputs in table-wrap so sort state preserved across filter changes - Fix language-search: block 'Créer' for predefined languages in dropdown The 'Créer' option in the language-search dropdown now also checks against the predefined set (français, anglais, néerlandais) to avoid offering creation of languages that already exist as checkboxes.
243 lines
11 KiB
PHP
243 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* Language search partial — interactive "Autre(s) langue(s)" input with HTMX-powered suggestions.
|
|
*
|
|
* Replaces the old plain text input with an interactive component:
|
|
* - Type to search among existing languages via HTMX
|
|
* - If the language doesn't exist, a "Créer" option appears
|
|
* - Selected languages are shown as pills with a round delete button (bin icon)
|
|
* - All language names are lowercased and deduplicated
|
|
*
|
|
* Variables consumed:
|
|
* string $name — base input name (hidden inputs will be name[]); default 'language_autre'
|
|
* string $label — visible label text
|
|
* string $placeholder — placeholder text for the search input
|
|
* string $hint — optional hint shown above the input
|
|
* string $hxPost — HTMX POST endpoint for language search
|
|
* array $selectedLanguages — array of ['id' => int|null, 'name' => string] for pre-filled languages
|
|
* string|null $id — override the id attribute prefix
|
|
* int $maxLanguages — maximum number of languages (default 10)
|
|
* bool $required — whether at least one "other language" is required (default false)
|
|
*/
|
|
|
|
$name = $name ?? 'language_autre';
|
|
$label = $label ?? 'Autre(s) langue(s)';
|
|
$placeholder = $placeholder ?? 'Rechercher une langue…';
|
|
$hint = $hint ?? null;
|
|
$hxPost = $hxPost ?? '/admin/language-search-fragment.php';
|
|
$selectedLanguages = $selectedLanguages ?? [];
|
|
$id = $id ?? $name;
|
|
$maxLanguages = $maxLanguages ?? 10;
|
|
$required = $required ?? false;
|
|
$langCount = count($selectedLanguages);
|
|
?>
|
|
<div id="<?= htmlspecialchars($id) ?>-search-container">
|
|
<span class="admin-row-label"><?= htmlspecialchars($label) ?><span id="language-autre-required"><?= $required ? ' <span class="asterisk">*</span>' : '' ?></span></span>
|
|
<div class="tag-search-wrapper">
|
|
<?php if ($hint): ?>
|
|
<small class="tag-search-hint"><?= htmlspecialchars($hint) ?></small>
|
|
<?php endif; ?>
|
|
|
|
<!-- Active language pills -->
|
|
<div class="tag-search-pills" id="<?= htmlspecialchars($id) ?>-pills">
|
|
<?php foreach ($selectedLanguages as $lang): ?>
|
|
<span class="tag-pill">
|
|
<input type="hidden" name="<?= htmlspecialchars($name) ?>[]" value="<?= htmlspecialchars($lang['name']) ?>">
|
|
<span class="tag-pill-name"><?= htmlspecialchars($lang['name']) ?></span>
|
|
<button type="button" class="tag-pill-remove" title="Retirer « <?= htmlspecialchars($lang['name']) ?> »" aria-label="Retirer <?= htmlspecialchars($lang['name']) ?>">
|
|
<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>
|
|
</button>
|
|
</span>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
|
|
<!-- Counter visible only when languages exist or max reached -->
|
|
<div class="tag-search-counter" id="<?= htmlspecialchars($id) ?>-counter"<?= $langCount === 0 ? ' style="display:none"' : '' ?>>
|
|
<span class="tag-search-count" id="<?= htmlspecialchars($id) ?>-count"><?= $langCount ?>/<?= (int)$maxLanguages ?></span>
|
|
<?php if ($langCount >= $maxLanguages): ?>
|
|
<span class="tag-search-max-msg">Maximum de langues atteint</span>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<!-- Search input (hidden when max languages reached) -->
|
|
<div class="tag-search-input-wrap"<?= $langCount >= $maxLanguages ? ' style="display:none"' : '' ?>>
|
|
<input type="text"
|
|
name="q"
|
|
id="<?= htmlspecialchars($id) ?>-search"
|
|
class="tag-search-input"
|
|
placeholder="<?= htmlspecialchars($placeholder) ?>"
|
|
autocomplete="off"
|
|
hx-post="<?= htmlspecialchars($hxPost) ?>"
|
|
hx-trigger="input changed delay:200ms, focus"
|
|
hx-target="#<?= htmlspecialchars($id) ?>-suggestions"
|
|
hx-swap="innerHTML"
|
|
hx-include="#<?= htmlspecialchars($id) ?>-pills">
|
|
</div>
|
|
|
|
<!-- Suggestions dropdown (positioned absolutely over content) -->
|
|
<div class="tag-search-suggestions" id="<?= htmlspecialchars($id) ?>-suggestions" role="listbox"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Inline script for the interactive behaviour (no external JS required) -->
|
|
<script>
|
|
(function() {
|
|
const container = document.getElementById(<?= json_encode($id . '-search-container') ?>);
|
|
if (!container || container._langSearchInit) return;
|
|
container._langSearchInit = true;
|
|
|
|
const pills = document.getElementById(<?= json_encode($id . '-pills') ?>);
|
|
const search = document.getElementById(<?= json_encode($id . '-search') ?>);
|
|
const dropdown = document.getElementById(<?= json_encode($id . '-suggestions') ?>);
|
|
const countEl = document.getElementById(<?= json_encode($id . '-count') ?>);
|
|
const counter = document.getElementById(<?= json_encode($id . '-counter') ?>);
|
|
const maxLanguages = <?= (int)$maxLanguages ?>;
|
|
const inputName = <?= json_encode($name) ?>;
|
|
let selectedIdx = -1;
|
|
|
|
function updateCount() {
|
|
const n = pills.querySelectorAll('.tag-pill').length;
|
|
if (countEl) countEl.textContent = n + '/' + maxLanguages;
|
|
if (counter) counter.style.display = (n > 0) ? '' : 'none';
|
|
|
|
// Show/hide search input based on max
|
|
const wrap = container.querySelector('.tag-search-input-wrap');
|
|
const maxMsg = container.querySelector('.tag-search-max-msg');
|
|
if (n >= maxLanguages) {
|
|
if (wrap) wrap.style.display = 'none';
|
|
if (maxMsg) maxMsg.style.display = '';
|
|
} else {
|
|
if (wrap) {
|
|
wrap.style.display = '';
|
|
if (search) search.style.display = '';
|
|
}
|
|
if (maxMsg) maxMsg.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Lowercase, collapse spaces, trim, ucfirst for display
|
|
function normalizeLang(name) {
|
|
return name.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
}
|
|
|
|
function ucfirst(str) {
|
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
}
|
|
|
|
// Check if language already exists in pills (case-insensitive)
|
|
function langAlreadyAdded(name) {
|
|
const norm = normalizeLang(name);
|
|
const existing = pills.querySelectorAll('.tag-pill-name');
|
|
for (const el of existing) {
|
|
if (normalizeLang(el.textContent) === norm) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Remove a pill
|
|
pills.addEventListener('click', function(e) {
|
|
const btn = e.target.closest('.tag-pill-remove');
|
|
if (!btn) return;
|
|
const pill = btn.closest('.tag-pill');
|
|
pill.remove();
|
|
updateCount();
|
|
// Re-enable search field visibility
|
|
const wrap = container.querySelector('.tag-search-input-wrap');
|
|
const searchInput = container.querySelector('.tag-search-input');
|
|
if (wrap && searchInput) {
|
|
wrap.style.display = '';
|
|
searchInput.style.display = '';
|
|
}
|
|
});
|
|
|
|
// Highlight a suggestion by index
|
|
function highlight(idx) {
|
|
const items = dropdown.querySelectorAll('.tag-search-item');
|
|
items.forEach(function(item, i) {
|
|
if (i === idx) {
|
|
item.classList.add('tag-search-item--highlight');
|
|
} else {
|
|
item.classList.remove('tag-search-item--highlight');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Select a suggestion by button element
|
|
function selectLang(btn) {
|
|
const langName = normalizeLang(btn.getAttribute('data-tag-name') || '');
|
|
if (!langName) return;
|
|
|
|
if (langAlreadyAdded(langName)) return;
|
|
if (pills.querySelectorAll('.tag-pill').length >= maxLanguages) return;
|
|
|
|
const escapedName = htmlEscape(langName);
|
|
const pill = document.createElement('span');
|
|
pill.className = 'tag-pill';
|
|
pill.innerHTML = '<input type="hidden" name="' + inputName + '[]" value="' + escapedName + '">'
|
|
+ '<span class="tag-pill-name">' + escapedName + '</span>'
|
|
+ '<button type="button" class="tag-pill-remove" title="Retirer \u00AB\u00A0' + escapedName + '\u00A0\u00BB" aria-label="Retirer ' + escapedName + '">'
|
|
+ '<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>'
|
|
+ '</button>';
|
|
pills.appendChild(pill);
|
|
updateCount();
|
|
search.value = '';
|
|
dropdown.innerHTML = '';
|
|
selectedIdx = -1;
|
|
search.focus();
|
|
}
|
|
|
|
// Click on suggestion
|
|
dropdown.addEventListener('click', function(e) {
|
|
const btn = e.target.closest('.tag-search-item');
|
|
if (!btn) return;
|
|
selectLang(btn);
|
|
});
|
|
|
|
// Keyboard navigation
|
|
search.addEventListener('keydown', function(e) {
|
|
const items = dropdown.querySelectorAll('.tag-search-item');
|
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (items.length === 0) return;
|
|
if (e.key === 'ArrowDown') {
|
|
selectedIdx = (selectedIdx + 1) % items.length;
|
|
} else {
|
|
selectedIdx = selectedIdx <= 0 ? items.length - 1 : selectedIdx - 1;
|
|
}
|
|
highlight(selectedIdx);
|
|
} else if (e.key === 'Enter') {
|
|
if (items.length > 0) {
|
|
e.preventDefault();
|
|
if (selectedIdx >= 0 && selectedIdx < items.length) {
|
|
selectLang(items[selectedIdx]);
|
|
} else {
|
|
selectLang(items[0]);
|
|
}
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
dropdown.innerHTML = '';
|
|
selectedIdx = -1;
|
|
}
|
|
});
|
|
|
|
// Hide dropdown on blur (after a tiny delay so click events fire)
|
|
search.addEventListener('blur', function() {
|
|
setTimeout(function() {
|
|
if (!dropdown.contains(document.activeElement)) {
|
|
dropdown.innerHTML = '';
|
|
selectedIdx = -1;
|
|
}
|
|
}, 150);
|
|
});
|
|
|
|
function htmlEscape(str) {
|
|
const el = document.createElement('span');
|
|
el.textContent = str;
|
|
return el.innerHTML;
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
<?php
|
|
unset($name, $label, $placeholder, $hint, $hxPost, $selectedLanguages, $id, $maxLanguages, $langCount, $required);
|