426 lines
11 KiB
Text
426 lines
11 KiB
Text
// templui component rating - version: main installed by templui v0.71.0
|
|
package rating
|
|
|
|
import (
|
|
"fmt"
|
|
"git.jmbit.de/jmb/scanfile/server/web/templui/components/icon"
|
|
"git.jmbit.de/jmb/scanfile/server/web/templui/utils"
|
|
"strconv"
|
|
)
|
|
|
|
type Style string
|
|
|
|
const (
|
|
StyleStar Style = "star"
|
|
StyleHeart Style = "heart"
|
|
StyleEmoji Style = "emoji"
|
|
)
|
|
|
|
type Props struct {
|
|
ID string
|
|
Class string
|
|
Attributes templ.Attributes
|
|
Value float64
|
|
ReadOnly bool
|
|
Precision float64
|
|
Name string
|
|
OnlyInteger bool
|
|
}
|
|
|
|
type GroupProps struct {
|
|
ID string
|
|
Class string
|
|
Attributes templ.Attributes
|
|
}
|
|
|
|
type ItemProps struct {
|
|
ID string
|
|
Class string
|
|
Attributes templ.Attributes
|
|
Value int
|
|
Style Style
|
|
}
|
|
|
|
templ Rating(props ...Props) {
|
|
@Script()
|
|
{{ var p Props }}
|
|
if len(props) > 0 {
|
|
{{ p = props[0] }}
|
|
}
|
|
{{ p.setDefaults() }}
|
|
<div
|
|
if p.ID != "" {
|
|
id={ p.ID }
|
|
}
|
|
data-rating-component
|
|
data-initial-value={ fmt.Sprintf("%.2f", p.Value) }
|
|
data-precision={ fmt.Sprintf("%.2f", p.Precision) }
|
|
data-readonly={ strconv.FormatBool(p.ReadOnly) }
|
|
if p.Name != "" {
|
|
data-name={ p.Name }
|
|
}
|
|
data-onlyinteger={ strconv.FormatBool(p.OnlyInteger) }
|
|
class={
|
|
utils.TwMerge(
|
|
"flex flex-col items-start gap-1",
|
|
p.Class,
|
|
),
|
|
}
|
|
{ p.Attributes... }
|
|
>
|
|
{ children... }
|
|
if p.Name != "" {
|
|
<input
|
|
type="hidden"
|
|
name={ p.Name }
|
|
value={ fmt.Sprintf("%.2f", p.Value) }
|
|
data-rating-input
|
|
/>
|
|
}
|
|
</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 flex-row items-center gap-1", p.Class) }
|
|
{ p.Attributes... }
|
|
>
|
|
{ children... }
|
|
</div>
|
|
}
|
|
|
|
templ Item(props ...ItemProps) {
|
|
{{ var p ItemProps }}
|
|
if len(props) > 0 {
|
|
{{ p = props[0] }}
|
|
}
|
|
{{ p.setDefaults() }}
|
|
<div
|
|
if p.ID != "" {
|
|
id={ p.ID }
|
|
}
|
|
data-rating-item
|
|
data-rating-value={ strconv.Itoa(p.Value) }
|
|
class={
|
|
utils.TwMerge(
|
|
"relative",
|
|
colorClass(p.Style),
|
|
"transition-opacity",
|
|
"cursor-pointer", // Default cursor
|
|
p.Class,
|
|
),
|
|
}
|
|
{ p.Attributes... }
|
|
>
|
|
<div class="opacity-30">
|
|
@ratingIcon(p.Style, false, float64(p.Value))
|
|
</div>
|
|
<div
|
|
class="absolute inset-0 overflow-hidden w-0"
|
|
data-rating-item-foreground
|
|
>
|
|
@ratingIcon(p.Style, true, float64(p.Value))
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
func colorClass(style Style) string {
|
|
switch style {
|
|
case StyleHeart:
|
|
return "text-destructive"
|
|
case StyleEmoji:
|
|
return "text-yellow-500"
|
|
default:
|
|
return "text-yellow-400"
|
|
}
|
|
}
|
|
|
|
func ratingIcon(style Style, filled bool, value float64) templ.Component {
|
|
if style == StyleEmoji {
|
|
if filled {
|
|
switch {
|
|
case value <= 1:
|
|
return icon.Angry()
|
|
case value <= 2:
|
|
return icon.Frown()
|
|
case value <= 3:
|
|
return icon.Meh()
|
|
case value <= 4:
|
|
return icon.Smile()
|
|
default:
|
|
return icon.Laugh()
|
|
}
|
|
}
|
|
return icon.Meh()
|
|
}
|
|
iconProps := icon.Props{}
|
|
if filled {
|
|
iconProps.Fill = "currentColor"
|
|
}
|
|
switch style {
|
|
case StyleHeart:
|
|
return icon.Heart(iconProps)
|
|
default:
|
|
return icon.Star(iconProps)
|
|
}
|
|
}
|
|
|
|
func (p *ItemProps) setDefaults() {
|
|
if p.Style == "" {
|
|
p.Style = StyleStar
|
|
}
|
|
}
|
|
|
|
func (p *Props) setDefaults() {
|
|
if p.Precision <= 0 {
|
|
p.Precision = 1.0
|
|
}
|
|
}
|
|
|
|
var handle = templ.NewOnceHandle()
|
|
|
|
templ Script() {
|
|
@handle.Once() {
|
|
<script nonce={ templ.GetNonce(ctx) }>
|
|
if (typeof window.ratingState === 'undefined') {
|
|
window.ratingState = new WeakMap();
|
|
}
|
|
|
|
(function() { // IIFE
|
|
function initRating(ratingElement) {
|
|
if (!ratingElement) return;
|
|
|
|
const existingState = window.ratingState.get(ratingElement);
|
|
if (existingState) {
|
|
cleanupRating(ratingElement, existingState);
|
|
}
|
|
|
|
ratingElement.dataset.ratingInitialized = 'true';
|
|
|
|
const config = {
|
|
value: parseFloat(ratingElement.dataset.initialValue) || 0,
|
|
precision: parseFloat(ratingElement.dataset.precision) || 1,
|
|
readonly: ratingElement.dataset.readonly === 'true',
|
|
name: ratingElement.dataset.name || '',
|
|
onlyInteger: ratingElement.dataset.onlyinteger === 'true',
|
|
maxValue: 0
|
|
};
|
|
|
|
const hiddenInput = ratingElement.querySelector('[data-rating-input]');
|
|
let items = Array.from(ratingElement.querySelectorAll('[data-rating-item]'));
|
|
|
|
let currentValue = config.value;
|
|
let previewValue = 0;
|
|
|
|
const handlers = {
|
|
click: handleClick,
|
|
mouseover: handleMouseOver,
|
|
mouseleave: handleMouseLeave
|
|
};
|
|
|
|
function calculateMaxValue() {
|
|
let highestValue = 0;
|
|
for (const item of items) {
|
|
const value = parseInt(item.dataset.ratingValue, 10);
|
|
if (!isNaN(value) && value > highestValue) {
|
|
highestValue = value;
|
|
}
|
|
}
|
|
config.maxValue = Math.max(1, highestValue);
|
|
currentValue = Math.max(0, Math.min(config.maxValue, currentValue));
|
|
currentValue = Math.round(currentValue / config.precision) * config.precision;
|
|
updateHiddenInput();
|
|
}
|
|
|
|
function updateHiddenInput() {
|
|
if (hiddenInput) {
|
|
hiddenInput.value = currentValue.toFixed(2);
|
|
}
|
|
}
|
|
|
|
function updateItemStyles(displayValue) {
|
|
for (const item of items) {
|
|
const itemValue = parseInt(item.dataset.ratingValue, 10);
|
|
if (isNaN(itemValue)) continue;
|
|
|
|
const foreground = item.querySelector('[data-rating-item-foreground]');
|
|
if (!foreground) continue;
|
|
|
|
const valueToCompare = displayValue > 0 ? displayValue : currentValue;
|
|
|
|
const filled = itemValue <= Math.floor(valueToCompare);
|
|
const partial = !filled && (itemValue - 1 < valueToCompare && valueToCompare < itemValue);
|
|
const percentage = partial ? (valueToCompare - Math.floor(valueToCompare)) * 100 : 0;
|
|
|
|
foreground.style.width = filled ? '100%' : (partial ? `${percentage}%` : '0%');
|
|
}
|
|
}
|
|
|
|
function setValue(itemValue) {
|
|
if (config.readonly) return;
|
|
|
|
let newValue = itemValue;
|
|
if (config.onlyInteger) {
|
|
newValue = Math.round(newValue);
|
|
} else {
|
|
if (currentValue === newValue && newValue % 1 === 0) {
|
|
newValue = Math.max(0, newValue - config.precision);
|
|
} else {
|
|
newValue = Math.round(newValue / config.precision) * config.precision;
|
|
}
|
|
}
|
|
|
|
currentValue = Math.max(0, Math.min(config.maxValue, newValue));
|
|
previewValue = 0;
|
|
updateHiddenInput();
|
|
updateItemStyles(0);
|
|
|
|
ratingElement.dispatchEvent(new CustomEvent('rating-change', {
|
|
bubbles: true,
|
|
detail: {
|
|
name: config.name,
|
|
value: currentValue,
|
|
maxValue: config.maxValue
|
|
}
|
|
}));
|
|
|
|
if (hiddenInput) {
|
|
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
function handleMouseOver(event) {
|
|
if (config.readonly) return;
|
|
const item = event.target.closest('[data-rating-item]');
|
|
if (!item) return;
|
|
|
|
previewValue = parseInt(item.dataset.ratingValue, 10);
|
|
if (!isNaN(previewValue)) {
|
|
updateItemStyles(previewValue);
|
|
}
|
|
}
|
|
|
|
function handleMouseLeave() {
|
|
if (config.readonly) return;
|
|
previewValue = 0;
|
|
updateItemStyles(0);
|
|
}
|
|
|
|
function handleClick(event) {
|
|
if (config.readonly) return;
|
|
const item = event.target.closest('[data-rating-item]');
|
|
if (!item) return;
|
|
|
|
const itemValue = parseInt(item.dataset.ratingValue, 10);
|
|
if (!isNaN(itemValue)) {
|
|
setValue(itemValue);
|
|
}
|
|
}
|
|
|
|
calculateMaxValue();
|
|
updateItemStyles(0);
|
|
|
|
if (config.readonly) {
|
|
ratingElement.style.cursor = 'default';
|
|
for (const item of items) {
|
|
item.style.cursor = 'default';
|
|
}
|
|
} else {
|
|
ratingElement.addEventListener('click', handlers.click);
|
|
ratingElement.addEventListener('mouseover', handlers.mouseover);
|
|
ratingElement.addEventListener('mouseleave', handlers.mouseleave);
|
|
}
|
|
|
|
const observer = new MutationObserver(() => {
|
|
try {
|
|
const currentItemCount = ratingElement.querySelectorAll('[data-rating-item]').length;
|
|
if (currentItemCount !== items.length) {
|
|
items = Array.from(ratingElement.querySelectorAll('[data-rating-item]'));
|
|
calculateMaxValue();
|
|
updateItemStyles(previewValue > 0 ? previewValue : 0);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error in rating MutationObserver:', err);
|
|
}
|
|
});
|
|
|
|
observer.observe(ratingElement, { childList: true, subtree: true });
|
|
|
|
const state = {
|
|
handlers,
|
|
observer,
|
|
items
|
|
};
|
|
|
|
window.ratingState.set(ratingElement, state);
|
|
}
|
|
|
|
function cleanupRating(ratingElement, state) {
|
|
if (!ratingElement || !state) return;
|
|
|
|
if (!ratingElement.dataset.readonly === 'true') {
|
|
ratingElement.removeEventListener('click', state.handlers.click);
|
|
ratingElement.removeEventListener('mouseover', state.handlers.mouseover);
|
|
ratingElement.removeEventListener('mouseleave', state.handlers.mouseleave);
|
|
}
|
|
|
|
if (state.observer) {
|
|
state.observer.disconnect();
|
|
}
|
|
|
|
window.ratingState.delete(ratingElement);
|
|
ratingElement.removeAttribute('data-rating-initialized');
|
|
}
|
|
|
|
function initAllComponents(root = document) {
|
|
if (root instanceof Element && root.matches('[data-rating-component]')) {
|
|
initRating(root); // initRating handles already initialized check internally
|
|
}
|
|
if (root && typeof root.querySelectorAll === 'function') {
|
|
root.querySelectorAll('[data-rating-component]').forEach(initRating);
|
|
}
|
|
}
|
|
|
|
const handleHtmxSwap = (event) => {
|
|
const target = event.detail.target || event.detail.elt;
|
|
if (target instanceof Element) {
|
|
requestAnimationFrame(() => initAllComponents(target));
|
|
}
|
|
};
|
|
|
|
initAllComponents();
|
|
|
|
document.addEventListener('DOMContentLoaded', () => initAllComponents());
|
|
|
|
document.body.addEventListener('htmx:beforeCleanup', event => {
|
|
const containerToRemove = event.detail.target || event.detail.elt;; // Use elt for beforeCleanup
|
|
if (containerToRemove instanceof Element) {
|
|
// Cleanup target itself
|
|
if (containerToRemove.matches && containerToRemove.matches('[data-rating-component][data-rating-initialized]')) {
|
|
const state = window.ratingState.get(containerToRemove);
|
|
if (state) cleanupRating(containerToRemove, state);
|
|
}
|
|
// Cleanup descendants
|
|
if (containerToRemove.querySelectorAll) {
|
|
for (const ratingEl of containerToRemove.querySelectorAll('[data-rating-component][data-rating-initialized]')) {
|
|
const state = window.ratingState.get(ratingEl);
|
|
if (state) cleanupRating(ratingEl, state);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
document.body.addEventListener('htmx:afterSwap', handleHtmxSwap);
|
|
document.body.addEventListener('htmx:oobAfterSwap', handleHtmxSwap);
|
|
})(); // End of IIFE
|
|
</script>
|
|
}
|
|
}
|