Rating
Interactive rating component for capturing user feedback and displaying scores.
TailwindCSS
Alpine.js
package showcase
import "github.com/axzilla/templui/components"
templ RatingDefault() {
@components.Rating(components.RatingProps{
Value: 3.5,
ReadOnly: false,
Precision: 0.5,
}) {
@components.RatingGroup() {
for i := 1; i <= 5; i++ {
@components.RatingItem(components.RatingItemProps{
Value: i,
Style: components.RatingStyleStar,
})
}
}
}
}
package components
import (
"context"
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type RatingStyle string
const (
RatingStyleStar RatingStyle = "star"
RatingStyleHeart RatingStyle = "heart"
RatingStyleEmoji RatingStyle = "emoji"
)
type RatingProps struct {
ID string
Class string
Attributes templ.Attributes
Value float64
ReadOnly bool
Precision float64
Name string
OnlyInteger bool
}
type RatingGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type RatingItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value int
Style RatingStyle
}
templ Rating(props ...RatingProps) {
{{ var p RatingProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ ctx = context.WithValue(ctx, "readOnly", p.ReadOnly) }}
<div
if p.ID != "" {
id={ p.ID }
}
x-data="rating"
data-value={ strconv.FormatFloat(p.Value, 'f', -1, 64) }
data-precision={ strconv.FormatFloat(p.Precision, 'f', -1, 64) }
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... }
<input
type="hidden"
if p.Name != "" {
name={ p.Name }
}
x-bind:value="value"
disabled?={ p.Name == "" }
x-ref="input"
/>
</div>
}
templ RatingGroup(props ...RatingGroupProps) {
{{ var p RatingGroupProps }}
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 RatingItem(props ...RatingItemProps) {
{{ var p RatingItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ readOnly, _ := ctx.Value("readOnly").(bool) }}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"relative",
getColorClass(p.Style),
"transition-opacity",
"cursor-pointer",
utils.If(readOnly, "cursor-default"),
p.Class,
),
}
data-rating-value={ strconv.Itoa(p.Value) }
if !readOnly {
@click="setValue"
@mouseover="hover"
@mouseleave="resetPreview"
}
{ p.Attributes... }
>
<div class="opacity-30">
@getRatingIcon(p.Style, false, float64(p.Value))
</div>
<div
class="absolute inset-0 overflow-hidden"
x-bind:style="getItemStyle"
data-index={ strconv.Itoa(p.Value) }
>
@getRatingIcon(p.Style, true, float64(p.Value))
</div>
</div>
}
func getColorClass(style RatingStyle) string {
switch style {
case RatingStyleHeart:
return "text-destructive"
case RatingStyleEmoji:
return "text-yellow-500"
default:
return "text-yellow-400"
}
}
func getRatingIcon(style RatingStyle, filled bool, value float64) templ.Component {
if style == RatingStyleEmoji {
if filled {
switch {
case value <= 1:
return icons.Angry()
case value <= 2:
return icons.Frown()
case value <= 3:
return icons.Meh()
case value <= 4:
return icons.Smile()
default:
return icons.Laugh()
}
}
return icons.Meh()
}
if filled {
switch style {
case RatingStyleHeart:
return icons.Heart(icons.IconProps{Fill: "currentColor"})
default:
return icons.Star(icons.IconProps{Fill: "currentColor"})
}
} else {
switch style {
case RatingStyleHeart:
return icons.Heart()
default:
return icons.Star()
}
}
}
func (p *RatingItemProps) setDefaults() {
if p.Style == "" {
p.Style = RatingStyleStar
}
}
func (p *RatingProps) setDefaults() {
if p.Precision <= 0 {
p.Precision = 1.0
}
}
templ RatingScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('rating', () => ({
value: 0,
maxValue: 5, // Default value, will be dynamically updated
precision: 1,
readonly: false,
name: '',
onlyInteger: false,
previewValue: 0,
init() {
this.value = parseFloat(this.$el.dataset.value) || 0;
this.precision = parseFloat(this.$el.dataset.precision) || 1;
this.readonly = this.$el.dataset.readonly === 'true';
this.name = this.$el.dataset.name || '';
this.onlyInteger = this.$el.dataset.onlyinteger === 'true';
// Dynamically calculate maxValue based on the items
this.calculateMaxValue();
// Ensure value is within valid range
this.value = Math.min(this.maxValue, this.value);
this.value = Math.round(this.value / this.precision) * this.precision;
// Initialize the form element for proper form integration
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Check if we're in a form context
this.form = this.$el.closest('form');
if (this.form && this.name) {
// Add validation support
this.form.addEventListener('submit', () => {
this.validate();
});
}
// Setup mutation observer to react to DOM changes
this.observeDOMChanges();
},
calculateMaxValue() {
// Find all rating items and determine the highest value
const items = this.$el.querySelectorAll('[data-rating-value]');
let highestValue = 0;
items.forEach(item => {
const value = parseInt(item.dataset.ratingValue, 10);
if (value > highestValue) {
highestValue = value;
}
});
// Set minimum maxValue of 1
this.maxValue = Math.max(1, highestValue);
},
observeDOMChanges() {
// Use MutationObserver to react to DOM changes
const observer = new MutationObserver(() => {
this.calculateMaxValue();
});
// Watch for changes in the child node list
observer.observe(this.$el, { childList: true, subtree: true });
},
validate() {
// Basic validation - can be extended as needed
const isValid = this.value > 0;
// Trigger custom event for form validation
this.$dispatch('rating-validate', {
name: this.name,
value: this.value,
valid: isValid
});
return isValid;
},
setValue() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
const newValue = parseInt(item.dataset.ratingValue);
if (this.onlyInteger) {
this.value = Math.round(newValue);
} else {
this.value = Math.round(newValue / this.precision) * this.precision;
}
this.value = Math.max(0, Math.min(this.maxValue, this.value));
// Update the hidden input value
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Trigger change events for form integration
this.$dispatch('rating-change', {
name: this.name,
value: this.value,
maxValue: this.maxValue
});
// Trigger input event for better form integration
if (this.$refs.input) {
this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
this.$refs.input.dispatchEvent(new Event('change', { bubbles: true }));
}
},
getFormattedValue() {
return Math.round(this.value * 100) / 100;
},
getItemStyle() {
const index = parseInt(this.$el.dataset.index || '0');
const filled = index <= Math.floor(this.value);
const partial = !filled && (index - 1 < this.value && this.value < index);
const percentage = partial ? (this.value - Math.floor(this.value)) * 100 : 0;
return {
width: filled ? '100%' : (partial ? percentage + '%' : '0%')
};
},
hover() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
this.previewValue = parseInt(item.dataset.ratingValue);
},
resetPreview() {
if (this.readonly) return;
this.previewValue = 0;
}
}));
});
</script>
}
}
Usage
1. Add the script to your page/layout:
// Option A: All components (recommended)
@utils.ComponentScripts()
// Option B: Just Rating
@components.RatingScript()
2. Use the component:
@components.Rating(components.RatingProps{...})
Examples
With Label
package showcase
import "github.com/axzilla/templui/components"
templ RatingWithLabel() {
<div class="items-center">
<div class="flex flex-col gap-2">
@components.Label() {
Fruit
}
@components.Rating(components.RatingProps{
Value: 2,
}) {
@components.RatingGroup() {
for i := 1; i <= 5; i++ {
@components.RatingItem(components.RatingItemProps{
Value: i,
Style: components.RatingStyleStar,
})
}
}
}
</div>
</div>
}
package components
import (
"context"
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type RatingStyle string
const (
RatingStyleStar RatingStyle = "star"
RatingStyleHeart RatingStyle = "heart"
RatingStyleEmoji RatingStyle = "emoji"
)
type RatingProps struct {
ID string
Class string
Attributes templ.Attributes
Value float64
ReadOnly bool
Precision float64
Name string
OnlyInteger bool
}
type RatingGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type RatingItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value int
Style RatingStyle
}
templ Rating(props ...RatingProps) {
{{ var p RatingProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ ctx = context.WithValue(ctx, "readOnly", p.ReadOnly) }}
<div
if p.ID != "" {
id={ p.ID }
}
x-data="rating"
data-value={ strconv.FormatFloat(p.Value, 'f', -1, 64) }
data-precision={ strconv.FormatFloat(p.Precision, 'f', -1, 64) }
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... }
<input
type="hidden"
if p.Name != "" {
name={ p.Name }
}
x-bind:value="value"
disabled?={ p.Name == "" }
x-ref="input"
/>
</div>
}
templ RatingGroup(props ...RatingGroupProps) {
{{ var p RatingGroupProps }}
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 RatingItem(props ...RatingItemProps) {
{{ var p RatingItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ readOnly, _ := ctx.Value("readOnly").(bool) }}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"relative",
getColorClass(p.Style),
"transition-opacity",
"cursor-pointer",
utils.If(readOnly, "cursor-default"),
p.Class,
),
}
data-rating-value={ strconv.Itoa(p.Value) }
if !readOnly {
@click="setValue"
@mouseover="hover"
@mouseleave="resetPreview"
}
{ p.Attributes... }
>
<div class="opacity-30">
@getRatingIcon(p.Style, false, float64(p.Value))
</div>
<div
class="absolute inset-0 overflow-hidden"
x-bind:style="getItemStyle"
data-index={ strconv.Itoa(p.Value) }
>
@getRatingIcon(p.Style, true, float64(p.Value))
</div>
</div>
}
func getColorClass(style RatingStyle) string {
switch style {
case RatingStyleHeart:
return "text-destructive"
case RatingStyleEmoji:
return "text-yellow-500"
default:
return "text-yellow-400"
}
}
func getRatingIcon(style RatingStyle, filled bool, value float64) templ.Component {
if style == RatingStyleEmoji {
if filled {
switch {
case value <= 1:
return icons.Angry()
case value <= 2:
return icons.Frown()
case value <= 3:
return icons.Meh()
case value <= 4:
return icons.Smile()
default:
return icons.Laugh()
}
}
return icons.Meh()
}
if filled {
switch style {
case RatingStyleHeart:
return icons.Heart(icons.IconProps{Fill: "currentColor"})
default:
return icons.Star(icons.IconProps{Fill: "currentColor"})
}
} else {
switch style {
case RatingStyleHeart:
return icons.Heart()
default:
return icons.Star()
}
}
}
func (p *RatingItemProps) setDefaults() {
if p.Style == "" {
p.Style = RatingStyleStar
}
}
func (p *RatingProps) setDefaults() {
if p.Precision <= 0 {
p.Precision = 1.0
}
}
templ RatingScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('rating', () => ({
value: 0,
maxValue: 5, // Default value, will be dynamically updated
precision: 1,
readonly: false,
name: '',
onlyInteger: false,
previewValue: 0,
init() {
this.value = parseFloat(this.$el.dataset.value) || 0;
this.precision = parseFloat(this.$el.dataset.precision) || 1;
this.readonly = this.$el.dataset.readonly === 'true';
this.name = this.$el.dataset.name || '';
this.onlyInteger = this.$el.dataset.onlyinteger === 'true';
// Dynamically calculate maxValue based on the items
this.calculateMaxValue();
// Ensure value is within valid range
this.value = Math.min(this.maxValue, this.value);
this.value = Math.round(this.value / this.precision) * this.precision;
// Initialize the form element for proper form integration
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Check if we're in a form context
this.form = this.$el.closest('form');
if (this.form && this.name) {
// Add validation support
this.form.addEventListener('submit', () => {
this.validate();
});
}
// Setup mutation observer to react to DOM changes
this.observeDOMChanges();
},
calculateMaxValue() {
// Find all rating items and determine the highest value
const items = this.$el.querySelectorAll('[data-rating-value]');
let highestValue = 0;
items.forEach(item => {
const value = parseInt(item.dataset.ratingValue, 10);
if (value > highestValue) {
highestValue = value;
}
});
// Set minimum maxValue of 1
this.maxValue = Math.max(1, highestValue);
},
observeDOMChanges() {
// Use MutationObserver to react to DOM changes
const observer = new MutationObserver(() => {
this.calculateMaxValue();
});
// Watch for changes in the child node list
observer.observe(this.$el, { childList: true, subtree: true });
},
validate() {
// Basic validation - can be extended as needed
const isValid = this.value > 0;
// Trigger custom event for form validation
this.$dispatch('rating-validate', {
name: this.name,
value: this.value,
valid: isValid
});
return isValid;
},
setValue() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
const newValue = parseInt(item.dataset.ratingValue);
if (this.onlyInteger) {
this.value = Math.round(newValue);
} else {
this.value = Math.round(newValue / this.precision) * this.precision;
}
this.value = Math.max(0, Math.min(this.maxValue, this.value));
// Update the hidden input value
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Trigger change events for form integration
this.$dispatch('rating-change', {
name: this.name,
value: this.value,
maxValue: this.maxValue
});
// Trigger input event for better form integration
if (this.$refs.input) {
this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
this.$refs.input.dispatchEvent(new Event('change', { bubbles: true }));
}
},
getFormattedValue() {
return Math.round(this.value * 100) / 100;
},
getItemStyle() {
const index = parseInt(this.$el.dataset.index || '0');
const filled = index <= Math.floor(this.value);
const partial = !filled && (index - 1 < this.value && this.value < index);
const percentage = partial ? (this.value - Math.floor(this.value)) * 100 : 0;
return {
width: filled ? '100%' : (partial ? percentage + '%' : '0%')
};
},
hover() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
this.previewValue = parseInt(item.dataset.ratingValue);
},
resetPreview() {
if (this.readonly) return;
this.previewValue = 0;
}
}));
});
</script>
}
}
Styles
package showcase
import "github.com/axzilla/templui/components"
templ RatingStyles() {
<div class="flex flex-col gap-4">
@components.Rating(components.RatingProps{
Value: 2,
}) {
@components.RatingGroup() {
for i := 1; i <= 5; i++ {
@components.RatingItem(components.RatingItemProps{
Value: i,
Style: components.RatingStyleStar,
})
}
}
}
@components.Rating(components.RatingProps{
Value: 3,
}) {
@components.RatingGroup() {
for i := 1; i <= 5; i++ {
@components.RatingItem(components.RatingItemProps{
Value: i,
Style: components.RatingStyleHeart,
})
}
}
}
@components.Rating(components.RatingProps{
Value: 4,
}) {
@components.RatingGroup() {
for i := 1; i <= 5; i++ {
@components.RatingItem(components.RatingItemProps{
Value: i,
Style: components.RatingStyleEmoji,
})
}
}
}
</div>
}
package components
import (
"context"
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type RatingStyle string
const (
RatingStyleStar RatingStyle = "star"
RatingStyleHeart RatingStyle = "heart"
RatingStyleEmoji RatingStyle = "emoji"
)
type RatingProps struct {
ID string
Class string
Attributes templ.Attributes
Value float64
ReadOnly bool
Precision float64
Name string
OnlyInteger bool
}
type RatingGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type RatingItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value int
Style RatingStyle
}
templ Rating(props ...RatingProps) {
{{ var p RatingProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ ctx = context.WithValue(ctx, "readOnly", p.ReadOnly) }}
<div
if p.ID != "" {
id={ p.ID }
}
x-data="rating"
data-value={ strconv.FormatFloat(p.Value, 'f', -1, 64) }
data-precision={ strconv.FormatFloat(p.Precision, 'f', -1, 64) }
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... }
<input
type="hidden"
if p.Name != "" {
name={ p.Name }
}
x-bind:value="value"
disabled?={ p.Name == "" }
x-ref="input"
/>
</div>
}
templ RatingGroup(props ...RatingGroupProps) {
{{ var p RatingGroupProps }}
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 RatingItem(props ...RatingItemProps) {
{{ var p RatingItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ readOnly, _ := ctx.Value("readOnly").(bool) }}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"relative",
getColorClass(p.Style),
"transition-opacity",
"cursor-pointer",
utils.If(readOnly, "cursor-default"),
p.Class,
),
}
data-rating-value={ strconv.Itoa(p.Value) }
if !readOnly {
@click="setValue"
@mouseover="hover"
@mouseleave="resetPreview"
}
{ p.Attributes... }
>
<div class="opacity-30">
@getRatingIcon(p.Style, false, float64(p.Value))
</div>
<div
class="absolute inset-0 overflow-hidden"
x-bind:style="getItemStyle"
data-index={ strconv.Itoa(p.Value) }
>
@getRatingIcon(p.Style, true, float64(p.Value))
</div>
</div>
}
func getColorClass(style RatingStyle) string {
switch style {
case RatingStyleHeart:
return "text-destructive"
case RatingStyleEmoji:
return "text-yellow-500"
default:
return "text-yellow-400"
}
}
func getRatingIcon(style RatingStyle, filled bool, value float64) templ.Component {
if style == RatingStyleEmoji {
if filled {
switch {
case value <= 1:
return icons.Angry()
case value <= 2:
return icons.Frown()
case value <= 3:
return icons.Meh()
case value <= 4:
return icons.Smile()
default:
return icons.Laugh()
}
}
return icons.Meh()
}
if filled {
switch style {
case RatingStyleHeart:
return icons.Heart(icons.IconProps{Fill: "currentColor"})
default:
return icons.Star(icons.IconProps{Fill: "currentColor"})
}
} else {
switch style {
case RatingStyleHeart:
return icons.Heart()
default:
return icons.Star()
}
}
}
func (p *RatingItemProps) setDefaults() {
if p.Style == "" {
p.Style = RatingStyleStar
}
}
func (p *RatingProps) setDefaults() {
if p.Precision <= 0 {
p.Precision = 1.0
}
}
templ RatingScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('rating', () => ({
value: 0,
maxValue: 5, // Default value, will be dynamically updated
precision: 1,
readonly: false,
name: '',
onlyInteger: false,
previewValue: 0,
init() {
this.value = parseFloat(this.$el.dataset.value) || 0;
this.precision = parseFloat(this.$el.dataset.precision) || 1;
this.readonly = this.$el.dataset.readonly === 'true';
this.name = this.$el.dataset.name || '';
this.onlyInteger = this.$el.dataset.onlyinteger === 'true';
// Dynamically calculate maxValue based on the items
this.calculateMaxValue();
// Ensure value is within valid range
this.value = Math.min(this.maxValue, this.value);
this.value = Math.round(this.value / this.precision) * this.precision;
// Initialize the form element for proper form integration
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Check if we're in a form context
this.form = this.$el.closest('form');
if (this.form && this.name) {
// Add validation support
this.form.addEventListener('submit', () => {
this.validate();
});
}
// Setup mutation observer to react to DOM changes
this.observeDOMChanges();
},
calculateMaxValue() {
// Find all rating items and determine the highest value
const items = this.$el.querySelectorAll('[data-rating-value]');
let highestValue = 0;
items.forEach(item => {
const value = parseInt(item.dataset.ratingValue, 10);
if (value > highestValue) {
highestValue = value;
}
});
// Set minimum maxValue of 1
this.maxValue = Math.max(1, highestValue);
},
observeDOMChanges() {
// Use MutationObserver to react to DOM changes
const observer = new MutationObserver(() => {
this.calculateMaxValue();
});
// Watch for changes in the child node list
observer.observe(this.$el, { childList: true, subtree: true });
},
validate() {
// Basic validation - can be extended as needed
const isValid = this.value > 0;
// Trigger custom event for form validation
this.$dispatch('rating-validate', {
name: this.name,
value: this.value,
valid: isValid
});
return isValid;
},
setValue() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
const newValue = parseInt(item.dataset.ratingValue);
if (this.onlyInteger) {
this.value = Math.round(newValue);
} else {
this.value = Math.round(newValue / this.precision) * this.precision;
}
this.value = Math.max(0, Math.min(this.maxValue, this.value));
// Update the hidden input value
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Trigger change events for form integration
this.$dispatch('rating-change', {
name: this.name,
value: this.value,
maxValue: this.maxValue
});
// Trigger input event for better form integration
if (this.$refs.input) {
this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
this.$refs.input.dispatchEvent(new Event('change', { bubbles: true }));
}
},
getFormattedValue() {
return Math.round(this.value * 100) / 100;
},
getItemStyle() {
const index = parseInt(this.$el.dataset.index || '0');
const filled = index <= Math.floor(this.value);
const partial = !filled && (index - 1 < this.value && this.value < index);
const percentage = partial ? (this.value - Math.floor(this.value)) * 100 : 0;
return {
width: filled ? '100%' : (partial ? percentage + '%' : '0%')
};
},
hover() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
this.previewValue = parseInt(item.dataset.ratingValue);
},
resetPreview() {
if (this.readonly) return;
this.previewValue = 0;
}
}));
});
</script>
}
}
Precision (Read-Only)
package showcase
import "github.com/axzilla/templui/components"
templ RatingPrecision() {
@components.Rating(components.RatingProps{
Value: 1.3,
ReadOnly: true,
Precision: 0.5,
}) {
@components.RatingGroup() {
for i := 1; i <= 5; i++ {
@components.RatingItem(components.RatingItemProps{
Value: i,
Style: components.RatingStyleStar,
})
}
}
}
}
package components
import (
"context"
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type RatingStyle string
const (
RatingStyleStar RatingStyle = "star"
RatingStyleHeart RatingStyle = "heart"
RatingStyleEmoji RatingStyle = "emoji"
)
type RatingProps struct {
ID string
Class string
Attributes templ.Attributes
Value float64
ReadOnly bool
Precision float64
Name string
OnlyInteger bool
}
type RatingGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type RatingItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value int
Style RatingStyle
}
templ Rating(props ...RatingProps) {
{{ var p RatingProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ ctx = context.WithValue(ctx, "readOnly", p.ReadOnly) }}
<div
if p.ID != "" {
id={ p.ID }
}
x-data="rating"
data-value={ strconv.FormatFloat(p.Value, 'f', -1, 64) }
data-precision={ strconv.FormatFloat(p.Precision, 'f', -1, 64) }
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... }
<input
type="hidden"
if p.Name != "" {
name={ p.Name }
}
x-bind:value="value"
disabled?={ p.Name == "" }
x-ref="input"
/>
</div>
}
templ RatingGroup(props ...RatingGroupProps) {
{{ var p RatingGroupProps }}
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 RatingItem(props ...RatingItemProps) {
{{ var p RatingItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ readOnly, _ := ctx.Value("readOnly").(bool) }}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"relative",
getColorClass(p.Style),
"transition-opacity",
"cursor-pointer",
utils.If(readOnly, "cursor-default"),
p.Class,
),
}
data-rating-value={ strconv.Itoa(p.Value) }
if !readOnly {
@click="setValue"
@mouseover="hover"
@mouseleave="resetPreview"
}
{ p.Attributes... }
>
<div class="opacity-30">
@getRatingIcon(p.Style, false, float64(p.Value))
</div>
<div
class="absolute inset-0 overflow-hidden"
x-bind:style="getItemStyle"
data-index={ strconv.Itoa(p.Value) }
>
@getRatingIcon(p.Style, true, float64(p.Value))
</div>
</div>
}
func getColorClass(style RatingStyle) string {
switch style {
case RatingStyleHeart:
return "text-destructive"
case RatingStyleEmoji:
return "text-yellow-500"
default:
return "text-yellow-400"
}
}
func getRatingIcon(style RatingStyle, filled bool, value float64) templ.Component {
if style == RatingStyleEmoji {
if filled {
switch {
case value <= 1:
return icons.Angry()
case value <= 2:
return icons.Frown()
case value <= 3:
return icons.Meh()
case value <= 4:
return icons.Smile()
default:
return icons.Laugh()
}
}
return icons.Meh()
}
if filled {
switch style {
case RatingStyleHeart:
return icons.Heart(icons.IconProps{Fill: "currentColor"})
default:
return icons.Star(icons.IconProps{Fill: "currentColor"})
}
} else {
switch style {
case RatingStyleHeart:
return icons.Heart()
default:
return icons.Star()
}
}
}
func (p *RatingItemProps) setDefaults() {
if p.Style == "" {
p.Style = RatingStyleStar
}
}
func (p *RatingProps) setDefaults() {
if p.Precision <= 0 {
p.Precision = 1.0
}
}
templ RatingScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('rating', () => ({
value: 0,
maxValue: 5, // Default value, will be dynamically updated
precision: 1,
readonly: false,
name: '',
onlyInteger: false,
previewValue: 0,
init() {
this.value = parseFloat(this.$el.dataset.value) || 0;
this.precision = parseFloat(this.$el.dataset.precision) || 1;
this.readonly = this.$el.dataset.readonly === 'true';
this.name = this.$el.dataset.name || '';
this.onlyInteger = this.$el.dataset.onlyinteger === 'true';
// Dynamically calculate maxValue based on the items
this.calculateMaxValue();
// Ensure value is within valid range
this.value = Math.min(this.maxValue, this.value);
this.value = Math.round(this.value / this.precision) * this.precision;
// Initialize the form element for proper form integration
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Check if we're in a form context
this.form = this.$el.closest('form');
if (this.form && this.name) {
// Add validation support
this.form.addEventListener('submit', () => {
this.validate();
});
}
// Setup mutation observer to react to DOM changes
this.observeDOMChanges();
},
calculateMaxValue() {
// Find all rating items and determine the highest value
const items = this.$el.querySelectorAll('[data-rating-value]');
let highestValue = 0;
items.forEach(item => {
const value = parseInt(item.dataset.ratingValue, 10);
if (value > highestValue) {
highestValue = value;
}
});
// Set minimum maxValue of 1
this.maxValue = Math.max(1, highestValue);
},
observeDOMChanges() {
// Use MutationObserver to react to DOM changes
const observer = new MutationObserver(() => {
this.calculateMaxValue();
});
// Watch for changes in the child node list
observer.observe(this.$el, { childList: true, subtree: true });
},
validate() {
// Basic validation - can be extended as needed
const isValid = this.value > 0;
// Trigger custom event for form validation
this.$dispatch('rating-validate', {
name: this.name,
value: this.value,
valid: isValid
});
return isValid;
},
setValue() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
const newValue = parseInt(item.dataset.ratingValue);
if (this.onlyInteger) {
this.value = Math.round(newValue);
} else {
this.value = Math.round(newValue / this.precision) * this.precision;
}
this.value = Math.max(0, Math.min(this.maxValue, this.value));
// Update the hidden input value
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Trigger change events for form integration
this.$dispatch('rating-change', {
name: this.name,
value: this.value,
maxValue: this.maxValue
});
// Trigger input event for better form integration
if (this.$refs.input) {
this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
this.$refs.input.dispatchEvent(new Event('change', { bubbles: true }));
}
},
getFormattedValue() {
return Math.round(this.value * 100) / 100;
},
getItemStyle() {
const index = parseInt(this.$el.dataset.index || '0');
const filled = index <= Math.floor(this.value);
const partial = !filled && (index - 1 < this.value && this.value < index);
const percentage = partial ? (this.value - Math.floor(this.value)) * 100 : 0;
return {
width: filled ? '100%' : (partial ? percentage + '%' : '0%')
};
},
hover() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
this.previewValue = parseInt(item.dataset.ratingValue);
},
resetPreview() {
if (this.readonly) return;
this.previewValue = 0;
}
}));
});
</script>
}
}
Max Values
package showcase
import "github.com/axzilla/templui/components"
templ RatingMaxValues() {
@components.Rating(components.RatingProps{
Value: 7,
}) {
@components.RatingGroup() {
for i := 1; i <= 10; i++ {
@components.RatingItem(components.RatingItemProps{
Value: i,
Style: components.RatingStyleStar,
Class: "scale-75", // Smaller stars for better display
})
}
}
}
}
package components
import (
"context"
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type RatingStyle string
const (
RatingStyleStar RatingStyle = "star"
RatingStyleHeart RatingStyle = "heart"
RatingStyleEmoji RatingStyle = "emoji"
)
type RatingProps struct {
ID string
Class string
Attributes templ.Attributes
Value float64
ReadOnly bool
Precision float64
Name string
OnlyInteger bool
}
type RatingGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type RatingItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value int
Style RatingStyle
}
templ Rating(props ...RatingProps) {
{{ var p RatingProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ ctx = context.WithValue(ctx, "readOnly", p.ReadOnly) }}
<div
if p.ID != "" {
id={ p.ID }
}
x-data="rating"
data-value={ strconv.FormatFloat(p.Value, 'f', -1, 64) }
data-precision={ strconv.FormatFloat(p.Precision, 'f', -1, 64) }
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... }
<input
type="hidden"
if p.Name != "" {
name={ p.Name }
}
x-bind:value="value"
disabled?={ p.Name == "" }
x-ref="input"
/>
</div>
}
templ RatingGroup(props ...RatingGroupProps) {
{{ var p RatingGroupProps }}
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 RatingItem(props ...RatingItemProps) {
{{ var p RatingItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ readOnly, _ := ctx.Value("readOnly").(bool) }}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"relative",
getColorClass(p.Style),
"transition-opacity",
"cursor-pointer",
utils.If(readOnly, "cursor-default"),
p.Class,
),
}
data-rating-value={ strconv.Itoa(p.Value) }
if !readOnly {
@click="setValue"
@mouseover="hover"
@mouseleave="resetPreview"
}
{ p.Attributes... }
>
<div class="opacity-30">
@getRatingIcon(p.Style, false, float64(p.Value))
</div>
<div
class="absolute inset-0 overflow-hidden"
x-bind:style="getItemStyle"
data-index={ strconv.Itoa(p.Value) }
>
@getRatingIcon(p.Style, true, float64(p.Value))
</div>
</div>
}
func getColorClass(style RatingStyle) string {
switch style {
case RatingStyleHeart:
return "text-destructive"
case RatingStyleEmoji:
return "text-yellow-500"
default:
return "text-yellow-400"
}
}
func getRatingIcon(style RatingStyle, filled bool, value float64) templ.Component {
if style == RatingStyleEmoji {
if filled {
switch {
case value <= 1:
return icons.Angry()
case value <= 2:
return icons.Frown()
case value <= 3:
return icons.Meh()
case value <= 4:
return icons.Smile()
default:
return icons.Laugh()
}
}
return icons.Meh()
}
if filled {
switch style {
case RatingStyleHeart:
return icons.Heart(icons.IconProps{Fill: "currentColor"})
default:
return icons.Star(icons.IconProps{Fill: "currentColor"})
}
} else {
switch style {
case RatingStyleHeart:
return icons.Heart()
default:
return icons.Star()
}
}
}
func (p *RatingItemProps) setDefaults() {
if p.Style == "" {
p.Style = RatingStyleStar
}
}
func (p *RatingProps) setDefaults() {
if p.Precision <= 0 {
p.Precision = 1.0
}
}
templ RatingScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('rating', () => ({
value: 0,
maxValue: 5, // Default value, will be dynamically updated
precision: 1,
readonly: false,
name: '',
onlyInteger: false,
previewValue: 0,
init() {
this.value = parseFloat(this.$el.dataset.value) || 0;
this.precision = parseFloat(this.$el.dataset.precision) || 1;
this.readonly = this.$el.dataset.readonly === 'true';
this.name = this.$el.dataset.name || '';
this.onlyInteger = this.$el.dataset.onlyinteger === 'true';
// Dynamically calculate maxValue based on the items
this.calculateMaxValue();
// Ensure value is within valid range
this.value = Math.min(this.maxValue, this.value);
this.value = Math.round(this.value / this.precision) * this.precision;
// Initialize the form element for proper form integration
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Check if we're in a form context
this.form = this.$el.closest('form');
if (this.form && this.name) {
// Add validation support
this.form.addEventListener('submit', () => {
this.validate();
});
}
// Setup mutation observer to react to DOM changes
this.observeDOMChanges();
},
calculateMaxValue() {
// Find all rating items and determine the highest value
const items = this.$el.querySelectorAll('[data-rating-value]');
let highestValue = 0;
items.forEach(item => {
const value = parseInt(item.dataset.ratingValue, 10);
if (value > highestValue) {
highestValue = value;
}
});
// Set minimum maxValue of 1
this.maxValue = Math.max(1, highestValue);
},
observeDOMChanges() {
// Use MutationObserver to react to DOM changes
const observer = new MutationObserver(() => {
this.calculateMaxValue();
});
// Watch for changes in the child node list
observer.observe(this.$el, { childList: true, subtree: true });
},
validate() {
// Basic validation - can be extended as needed
const isValid = this.value > 0;
// Trigger custom event for form validation
this.$dispatch('rating-validate', {
name: this.name,
value: this.value,
valid: isValid
});
return isValid;
},
setValue() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
const newValue = parseInt(item.dataset.ratingValue);
if (this.onlyInteger) {
this.value = Math.round(newValue);
} else {
this.value = Math.round(newValue / this.precision) * this.precision;
}
this.value = Math.max(0, Math.min(this.maxValue, this.value));
// Update the hidden input value
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Trigger change events for form integration
this.$dispatch('rating-change', {
name: this.name,
value: this.value,
maxValue: this.maxValue
});
// Trigger input event for better form integration
if (this.$refs.input) {
this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
this.$refs.input.dispatchEvent(new Event('change', { bubbles: true }));
}
},
getFormattedValue() {
return Math.round(this.value * 100) / 100;
},
getItemStyle() {
const index = parseInt(this.$el.dataset.index || '0');
const filled = index <= Math.floor(this.value);
const partial = !filled && (index - 1 < this.value && this.value < index);
const percentage = partial ? (this.value - Math.floor(this.value)) * 100 : 0;
return {
width: filled ? '100%' : (partial ? percentage + '%' : '0%')
};
},
hover() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
this.previewValue = parseInt(item.dataset.ratingValue);
},
resetPreview() {
if (this.readonly) return;
this.previewValue = 0;
}
}));
});
</script>
}
}
Form
package showcase
import "github.com/axzilla/templui/components"
templ RatingForm() {
<form class="max-w-sm mx-auto">
@components.FormItem() {
@components.Label(components.LabelProps{
For: "product_quality",
}) {
Product Quality
}
@components.Rating(components.RatingProps{
Value: 3,
ReadOnly: false,
Precision: 1.0,
Name: "product_quality",
}) {
@components.RatingGroup() {
for i := 1; i <= 5; i++ {
@components.RatingItem(components.RatingItemProps{
Value: i,
Style: components.RatingStyleStar,
})
}
}
}
@components.FormDescription() {
Rate the quality of the product you received.
}
@components.FormMessage(components.FormMessageProps{
Variant: components.FormMessageVariantError,
}) {
Please rate the product quality.
}
}
</form>
}
package components
import (
"context"
"github.com/axzilla/templui/icons"
"github.com/axzilla/templui/utils"
"strconv"
)
type RatingStyle string
const (
RatingStyleStar RatingStyle = "star"
RatingStyleHeart RatingStyle = "heart"
RatingStyleEmoji RatingStyle = "emoji"
)
type RatingProps struct {
ID string
Class string
Attributes templ.Attributes
Value float64
ReadOnly bool
Precision float64
Name string
OnlyInteger bool
}
type RatingGroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type RatingItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value int
Style RatingStyle
}
templ Rating(props ...RatingProps) {
{{ var p RatingProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ ctx = context.WithValue(ctx, "readOnly", p.ReadOnly) }}
<div
if p.ID != "" {
id={ p.ID }
}
x-data="rating"
data-value={ strconv.FormatFloat(p.Value, 'f', -1, 64) }
data-precision={ strconv.FormatFloat(p.Precision, 'f', -1, 64) }
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... }
<input
type="hidden"
if p.Name != "" {
name={ p.Name }
}
x-bind:value="value"
disabled?={ p.Name == "" }
x-ref="input"
/>
</div>
}
templ RatingGroup(props ...RatingGroupProps) {
{{ var p RatingGroupProps }}
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 RatingItem(props ...RatingItemProps) {
{{ var p RatingItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
{{ readOnly, _ := ctx.Value("readOnly").(bool) }}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"relative",
getColorClass(p.Style),
"transition-opacity",
"cursor-pointer",
utils.If(readOnly, "cursor-default"),
p.Class,
),
}
data-rating-value={ strconv.Itoa(p.Value) }
if !readOnly {
@click="setValue"
@mouseover="hover"
@mouseleave="resetPreview"
}
{ p.Attributes... }
>
<div class="opacity-30">
@getRatingIcon(p.Style, false, float64(p.Value))
</div>
<div
class="absolute inset-0 overflow-hidden"
x-bind:style="getItemStyle"
data-index={ strconv.Itoa(p.Value) }
>
@getRatingIcon(p.Style, true, float64(p.Value))
</div>
</div>
}
func getColorClass(style RatingStyle) string {
switch style {
case RatingStyleHeart:
return "text-destructive"
case RatingStyleEmoji:
return "text-yellow-500"
default:
return "text-yellow-400"
}
}
func getRatingIcon(style RatingStyle, filled bool, value float64) templ.Component {
if style == RatingStyleEmoji {
if filled {
switch {
case value <= 1:
return icons.Angry()
case value <= 2:
return icons.Frown()
case value <= 3:
return icons.Meh()
case value <= 4:
return icons.Smile()
default:
return icons.Laugh()
}
}
return icons.Meh()
}
if filled {
switch style {
case RatingStyleHeart:
return icons.Heart(icons.IconProps{Fill: "currentColor"})
default:
return icons.Star(icons.IconProps{Fill: "currentColor"})
}
} else {
switch style {
case RatingStyleHeart:
return icons.Heart()
default:
return icons.Star()
}
}
}
func (p *RatingItemProps) setDefaults() {
if p.Style == "" {
p.Style = RatingStyleStar
}
}
func (p *RatingProps) setDefaults() {
if p.Precision <= 0 {
p.Precision = 1.0
}
}
templ RatingScript() {
{{ handle := templ.NewOnceHandle() }}
@handle.Once() {
<script defer nonce={ templ.GetNonce(ctx) }>
document.addEventListener('alpine:init', () => {
Alpine.data('rating', () => ({
value: 0,
maxValue: 5, // Default value, will be dynamically updated
precision: 1,
readonly: false,
name: '',
onlyInteger: false,
previewValue: 0,
init() {
this.value = parseFloat(this.$el.dataset.value) || 0;
this.precision = parseFloat(this.$el.dataset.precision) || 1;
this.readonly = this.$el.dataset.readonly === 'true';
this.name = this.$el.dataset.name || '';
this.onlyInteger = this.$el.dataset.onlyinteger === 'true';
// Dynamically calculate maxValue based on the items
this.calculateMaxValue();
// Ensure value is within valid range
this.value = Math.min(this.maxValue, this.value);
this.value = Math.round(this.value / this.precision) * this.precision;
// Initialize the form element for proper form integration
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Check if we're in a form context
this.form = this.$el.closest('form');
if (this.form && this.name) {
// Add validation support
this.form.addEventListener('submit', () => {
this.validate();
});
}
// Setup mutation observer to react to DOM changes
this.observeDOMChanges();
},
calculateMaxValue() {
// Find all rating items and determine the highest value
const items = this.$el.querySelectorAll('[data-rating-value]');
let highestValue = 0;
items.forEach(item => {
const value = parseInt(item.dataset.ratingValue, 10);
if (value > highestValue) {
highestValue = value;
}
});
// Set minimum maxValue of 1
this.maxValue = Math.max(1, highestValue);
},
observeDOMChanges() {
// Use MutationObserver to react to DOM changes
const observer = new MutationObserver(() => {
this.calculateMaxValue();
});
// Watch for changes in the child node list
observer.observe(this.$el, { childList: true, subtree: true });
},
validate() {
// Basic validation - can be extended as needed
const isValid = this.value > 0;
// Trigger custom event for form validation
this.$dispatch('rating-validate', {
name: this.name,
value: this.value,
valid: isValid
});
return isValid;
},
setValue() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
const newValue = parseInt(item.dataset.ratingValue);
if (this.onlyInteger) {
this.value = Math.round(newValue);
} else {
this.value = Math.round(newValue / this.precision) * this.precision;
}
this.value = Math.max(0, Math.min(this.maxValue, this.value));
// Update the hidden input value
if (this.$refs.input) {
this.$refs.input.value = this.value;
}
// Trigger change events for form integration
this.$dispatch('rating-change', {
name: this.name,
value: this.value,
maxValue: this.maxValue
});
// Trigger input event for better form integration
if (this.$refs.input) {
this.$refs.input.dispatchEvent(new Event('input', { bubbles: true }));
this.$refs.input.dispatchEvent(new Event('change', { bubbles: true }));
}
},
getFormattedValue() {
return Math.round(this.value * 100) / 100;
},
getItemStyle() {
const index = parseInt(this.$el.dataset.index || '0');
const filled = index <= Math.floor(this.value);
const partial = !filled && (index - 1 < this.value && this.value < index);
const percentage = partial ? (this.value - Math.floor(this.value)) * 100 : 0;
return {
width: filled ? '100%' : (partial ? percentage + '%' : '0%')
};
},
hover() {
if (this.readonly) return;
const item = this.$event.target.closest('[data-rating-value]');
if (!item) return;
this.previewValue = parseInt(item.dataset.ratingValue);
},
resetPreview() {
if (this.readonly) return;
this.previewValue = 0;
}
}));
});
</script>
}
}