388 lines
11 KiB
Text
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>
|
|
}
|
|
}
|