Add language-search component for Autre Langue input + active search in lists

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.
This commit is contained in:
Pontoporeia
2026-05-10 10:59:52 +02:00
parent 96fa8ee266
commit 048a14bc2e
22 changed files with 667 additions and 237 deletions

View File

@@ -0,0 +1,242 @@
<?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 «&nbsp;<?= htmlspecialchars($lang['name']) ?>&nbsp;»" 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);