scanfile/server/web/templui/components/inputotp/input_otp.templ
2025-06-03 15:44:56 +02:00

388 lines
11 KiB
Text

// templui component inputotp - version: main installed by templui v0.71.0
package inputotp
import (
"git.jmbit.de/jmb/scanfile/server/web/templui/utils"
"strconv"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Value string
Required bool
Name string
HasError bool
}
type GroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SlotProps struct {
ID string
Class string
Attributes templ.Attributes
Index int
Type string
Placeholder string
Disabled bool
}
type SeparatorProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ InputOTP(props ...Props) {
@Script()
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID + "-container" }
}
if p.Value != "" {
data-value={ p.Value }
}
class={
utils.TwMerge(
"flex flex-row items-center gap-2 w-fit",
p.Class,
),
}
data-input-otp
{ p.Attributes... }
>
<input
type="hidden"
if p.ID != "" {
id={ p.ID }
}
if p.Name != "" {
name={ p.Name }
}
data-input-otp-value-target
required?={ p.Required }
/>
{ children... }
</div>
}
templ Group(props ...GroupProps) {
{{ var p GroupProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex gap-2",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
templ Slot(props ...SlotProps) {
{{ var p SlotProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Type == "" {
{{ p.Type = "text" }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class="relative"
{ p.Attributes... }
>
<input
type={ p.Type }
inputmode="numeric"
if p.Placeholder != "" {
placeholder={ p.Placeholder }
}
maxlength="1"
class={
utils.TwMerge(
"w-10 h-12 text-center",
"rounded-md border border-input bg-background text-sm",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"placeholder:text-muted-foreground",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
p.Class,
),
}
disabled?={ p.Disabled }
data-input-index={ strconv.Itoa(p.Index) }
data-input-otp-slot
{ p.Attributes... }
/>
</div>
}
templ Separator(props ...SeparatorProps) {
{{ var p SeparatorProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex items-center text-muted-foreground text-xl",
p.Class,
),
}
{ p.Attributes... }
>
<span>-</span>
</div>
}
var handle = templ.NewOnceHandle()
templ Script() {
@handle.Once() {
<script nonce={ templ.GetNonce(ctx) }>
if (typeof window.inputOTPState === 'undefined') {
window.inputOTPState = new WeakMap();
}
(function() { // IIFE Start
// Prevent re-running the whole setup if already done
if (window.inputOTPSystemInitialized) return;
// --- Core Component Logic ---
function initInputOTP(container) {
// Prevent re-initialization if state already exists for this container
if (window.inputOTPState.has(container)) return;
// Basic elements
const hiddenInput = container.querySelector('[data-input-otp-value-target]');
const slots = Array.from(container.querySelectorAll('[data-input-otp-slot]'))
.sort((a, b) => parseInt(a.dataset.inputIndex) - parseInt(b.dataset.inputIndex));
if (!hiddenInput || slots.length === 0) return;
// Check for autofocus attribute and focus the first slot
if (container.hasAttribute('autofocus')) {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
const firstSlot = slots[0];
if (firstSlot) {
firstSlot.focus();
firstSlot.select();
}
});
}
// Core functionality helpers bound to this instance
const updateHiddenValue = () => {
hiddenInput.value = slots.map(slot => slot.value).join('');
};
const findFirstEmptySlotIndex = () => slots.findIndex(slot => !slot.value);
const focusSlot = (index) => {
if (index >= 0 && index < slots.length) {
slots[index].focus();
// Use setTimeout to ensure select happens after focus
setTimeout(() => slots[index].select(), 0);
}
};
// Event Handlers specific to this instance
const handleInput = (e) => {
const input = e.target;
const index = parseInt(input.dataset.inputIndex);
if (input.value === ' ') { input.value = ''; return; }
if (input.value.length > 1) input.value = input.value.slice(-1);
if (input.value && index < slots.length - 1) focusSlot(index + 1);
updateHiddenValue();
};
const handleKeydown = (e) => {
const input = e.target;
const index = parseInt(input.dataset.inputIndex);
if (e.key === 'Backspace') {
const currentValue = input.value;
if (index > 0) {
e.preventDefault();
if (currentValue) {
input.value = ''; updateHiddenValue(); focusSlot(index - 1);
} else {
slots[index - 1].value = ''; updateHiddenValue(); focusSlot(index - 1);
}
}
} else if (e.key === 'ArrowLeft' && index > 0) {
e.preventDefault(); focusSlot(index - 1);
} else if (e.key === 'ArrowRight' && index < slots.length - 1) {
e.preventDefault(); focusSlot(index + 1);
}
};
const handleFocus = (e) => {
const input = e.target;
const index = parseInt(input.dataset.inputIndex);
const firstEmptyIndex = findFirstEmptySlotIndex();
if (firstEmptyIndex !== -1 && index !== firstEmptyIndex) {
focusSlot(firstEmptyIndex);
return; // Prevent default focus/select on original target
}
// Use setTimeout to ensure select() happens after potential focus redirection
setTimeout(() => input.select(), 0);
};
const handlePaste = (e) => {
e.preventDefault();
const pastedData = (e.clipboardData || window.clipboardData).getData('text');
const pastedChars = pastedData.replace(/\s/g, '').split('');
let currentSlotIndex = 0; // Start pasting from the first slot
// Try to find focused slot to start paste from, fallback to 0
const focusedSlot = slots.find(slot => slot === document.activeElement);
if (focusedSlot) currentSlotIndex = parseInt(focusedSlot.dataset.inputIndex);
for (let i = 0; i < pastedChars.length && currentSlotIndex < slots.length; i++) {
slots[currentSlotIndex].value = pastedChars[i];
currentSlotIndex++;
}
updateHiddenValue();
// Focus after paste: either next available slot or last filled slot
let focusIndex = findFirstEmptySlotIndex();
if (focusIndex === -1) focusIndex = slots.length - 1;
else if (focusIndex > 0 && focusIndex > currentSlotIndex) focusIndex = currentSlotIndex; // Focus next slot after pasted content
focusSlot(Math.min(focusIndex, slots.length - 1));
};
// Add event listeners to slots
for (const slot of slots) {
slot.addEventListener('input', handleInput);
slot.addEventListener('keydown', handleKeydown);
slot.addEventListener('focus', handleFocus);
}
// Add paste listener to the container
container.addEventListener('paste', handlePaste);
// Handle label clicks to focus first slot
const targetId = hiddenInput.id;
if (targetId) {
for (const label of document.querySelectorAll(`label[for="${targetId}"]`)) {
// Check if listener already attached to avoid duplicates
if (!label.dataset.inputOtpListener) {
const labelClickListener = (e) => {
e.preventDefault();
if (slots.length > 0) focusSlot(0);
};
label.addEventListener('click', labelClickListener);
label.dataset.inputOtpListener = 'true'; // Mark as having listener
// Store handler for potential cleanup
label._inputOtpClickListener = labelClickListener;
}
}
}
// Initial value handling
if (container.dataset.value) {
const initialValue = container.dataset.value;
for (let i = 0; i < slots.length && i < initialValue.length; i++) {
slots[i].value = initialValue[i];
}
updateHiddenValue();
}
// Store state and handlers for potential cleanup
const state = { slots, hiddenInput, handleInput, handleKeydown, handleFocus, handlePaste };
window.inputOTPState.set(container, state);
}
// --- Cleanup ---
function cleanupInputOTP(container) {
const state = window.inputOTPState.get(container);
if (!state) return;
// Remove slot listeners
for (const slot of state.slots) {
slot.removeEventListener('input', state.handleInput);
slot.removeEventListener('keydown', state.handleKeydown);
slot.removeEventListener('focus', state.handleFocus);
}
// Remove container paste listener
container.removeEventListener('paste', state.handlePaste);
// Remove label listeners
const targetId = state.hiddenInput.id;
if (targetId) {
for (const label of document.querySelectorAll(`label[for="${targetId}"]`)) {
if (label._inputOtpClickListener) {
label.removeEventListener('click', label._inputOtpClickListener);
delete label._inputOtpClickListener;
delete label.dataset.inputOtpListener;
}
}
}
window.inputOTPState.delete(container);
}
function initAllComponents(root = document) {
if (root instanceof Element && root.matches('[data-input-otp]')) {
initInputOTP(root);
}
const containers = root.querySelectorAll('[data-input-otp]');
containers.forEach(initInputOTP);
}
const handleHtmxSwap = (event) => {
const target = event.detail.target || event.detail.elt;;
if (target instanceof Element) {
requestAnimationFrame(() => initAllComponents(target));
}
};
document.addEventListener('DOMContentLoaded', () => initAllComponents());
document.body.addEventListener('htmx:beforeSwap', (event) => {
const target = event.detail.target || event.detail.elt;;
if (target instanceof Element) {
// Cleanup target itself if it's an OTP container
if (target.matches && target.matches('[data-input-otp]')) {
cleanupInputOTP(target);
}
// Cleanup descendants
if (target.querySelectorAll) {
for (const container of target.querySelectorAll('[data-input-otp]')) {
cleanupInputOTP(container);
}
}
}
});
initAllComponents();
document.body.addEventListener('htmx:afterSwap', handleHtmxSwap);
document.body.addEventListener('htmx:oobAfterSwap', handleHtmxSwap);
window.inputOTPSystemInitialized = true;
})(); // End of IIFE
</script>
}
}