generated from boboko/starter
setting up front 1
This commit is contained in:
+10
-1
@@ -1 +1,10 @@
|
||||
import './bootstrap';
|
||||
import "./bootstrap";
|
||||
import "./utils/strip-accents";
|
||||
|
||||
import { Application } from "@hotwired/stimulus";
|
||||
import { registerControllers } from "./stimulus/index";
|
||||
|
||||
const application = Application.start();
|
||||
application.debug = false;
|
||||
|
||||
registerControllers(application);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.scrollHandler = this.onScroll.bind(this)
|
||||
window.addEventListener('scroll', this.scrollHandler, { passive: true })
|
||||
this.onScroll()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
window.removeEventListener('scroll', this.scrollHandler)
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
this.element.classList.toggle('is-visible', window.scrollY > 300)
|
||||
}
|
||||
|
||||
scrollToTop(e) {
|
||||
e.preventDefault()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['menu']
|
||||
|
||||
open() {
|
||||
this.menuTarget.classList.add('is-open')
|
||||
}
|
||||
|
||||
close() {
|
||||
this.menuTarget.classList.remove('is-open')
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.menuTarget.classList.toggle('is-open')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Register all Stimulus controllers here.
|
||||
// Example:
|
||||
// import HelloController from './controllers/hello_controller';
|
||||
// application.register('hello', HelloController);
|
||||
|
||||
import BackToTopController from './back-to-top-controller'
|
||||
import ProductFormController from './product-form-controller'
|
||||
import ProductGalleryController from './product-gallery-controller'
|
||||
import QuantityController from './quantity-controller'
|
||||
import StarRatingController from './star-rating-controller'
|
||||
import TabsController from './tabs-controller'
|
||||
|
||||
export function registerControllers(application) {
|
||||
application.register('back-to-top', BackToTopController)
|
||||
application.register('product-form', ProductFormController)
|
||||
application.register('product-gallery', ProductGalleryController)
|
||||
application.register('quantity', QuantityController)
|
||||
application.register('star-rating', StarRatingController)
|
||||
application.register('tabs', TabsController)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['price', 'image', 'swatch', 'colorName']
|
||||
static values = { variants: Array, selected: Number }
|
||||
|
||||
connect() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlId = parseInt(params.get('variant'))
|
||||
const defaultId = this.variantsValue[0]?.id
|
||||
|
||||
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
|
||||
? urlId
|
||||
: defaultId
|
||||
}
|
||||
|
||||
selectVariant(event) {
|
||||
const id = parseInt(event.currentTarget.dataset.variantId)
|
||||
this.selectedValue = id
|
||||
|
||||
const url = new URL(window.location)
|
||||
url.searchParams.set('variant', id)
|
||||
window.history.pushState({}, '', url)
|
||||
}
|
||||
|
||||
selectedValueChanged(id) {
|
||||
if (!id) return
|
||||
|
||||
const variant = this.variantsValue.find(v => v.id === id)
|
||||
if (!variant) return
|
||||
|
||||
if (this.hasPriceTarget && variant.price !== null) {
|
||||
this.priceTarget.textContent = '€' + parseFloat(variant.price).toFixed(2)
|
||||
}
|
||||
|
||||
if (this.hasImageTarget && variant.image) {
|
||||
this.imageTarget.src = variant.image
|
||||
}
|
||||
|
||||
this.swatchTargets.forEach(swatch => {
|
||||
const isSelected = parseInt(swatch.dataset.variantId) === id
|
||||
swatch.classList.toggle('is-selected', isSelected)
|
||||
swatch.setAttribute('aria-pressed', String(isSelected))
|
||||
|
||||
if (isSelected && this.hasColorNameTarget) {
|
||||
this.colorNameTarget.textContent = swatch.getAttribute('aria-label')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
const SCROLL_STEP = 1;
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["main", "thumb", "track", "lightbox", "lightboxImage", "lightboxCounter"];
|
||||
|
||||
connect() {
|
||||
this.offset = 0;
|
||||
this.lightboxIndex = 0;
|
||||
this.#syncHeight();
|
||||
|
||||
this.mainTarget.addEventListener("load", () => this.#syncHeight());
|
||||
|
||||
this._onKeydown = this.#handleKeydown.bind(this);
|
||||
document.addEventListener("keydown", this._onKeydown);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("keydown", this._onKeydown);
|
||||
}
|
||||
|
||||
select(event) {
|
||||
const btn = event.currentTarget;
|
||||
this.#setThumb(btn);
|
||||
this.lightboxIndex = this.thumbTargets.indexOf(btn);
|
||||
}
|
||||
|
||||
scrollUp() {
|
||||
this.offset = Math.max(0, this.offset - SCROLL_STEP);
|
||||
this.#applyScroll();
|
||||
}
|
||||
|
||||
scrollDown() {
|
||||
this.offset = Math.min(this.thumbTargets.length - 1, this.offset + SCROLL_STEP);
|
||||
this.#applyScroll();
|
||||
}
|
||||
|
||||
closeLightboxOnBackdrop(event) {
|
||||
// Only close if clicking directly on the backdrop (the popover div itself),
|
||||
// not on the image, arrows, close button, or counter
|
||||
if (event.target === this.lightboxTarget) {
|
||||
this.lightboxTarget.hidePopover();
|
||||
}
|
||||
}
|
||||
|
||||
noop(event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
openLightbox() {
|
||||
this.#updateLightbox(this.lightboxIndex);
|
||||
this.lightboxTarget.showPopover();
|
||||
}
|
||||
|
||||
prevImage() {
|
||||
const next = (this.lightboxIndex - 1 + this.thumbTargets.length) % this.thumbTargets.length;
|
||||
this.#updateLightbox(next);
|
||||
}
|
||||
|
||||
nextImage() {
|
||||
const next = (this.lightboxIndex + 1) % this.thumbTargets.length;
|
||||
this.#updateLightbox(next);
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────
|
||||
|
||||
#updateLightbox(index) {
|
||||
this.lightboxIndex = index;
|
||||
const thumb = this.thumbTargets[index];
|
||||
if (!thumb) return;
|
||||
|
||||
this.lightboxImageTarget.src = thumb.dataset.src;
|
||||
this.lightboxImageTarget.alt = thumb.dataset.alt;
|
||||
this.lightboxCounterTarget.textContent =
|
||||
`${index + 1} of ${this.thumbTargets.length}`;
|
||||
}
|
||||
|
||||
#setThumb(btn) {
|
||||
const src = btn.dataset.src;
|
||||
const alt = btn.dataset.alt;
|
||||
|
||||
this.mainTarget.src = src;
|
||||
this.mainTarget.alt = alt;
|
||||
|
||||
this.thumbTargets.forEach((t) => {
|
||||
const active = t === btn;
|
||||
t.classList.toggle("border-2", active);
|
||||
t.classList.toggle("border-1", !active);
|
||||
t.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
}
|
||||
|
||||
#applyScroll() {
|
||||
if (!this.thumbTargets.length) return;
|
||||
const thumbHeight = this.thumbTargets[0].offsetHeight;
|
||||
const gap = 16; // gap-4 = 16px
|
||||
this.trackTarget.scrollTop = this.offset * (thumbHeight + gap);
|
||||
}
|
||||
|
||||
#syncHeight() {
|
||||
const h = this.mainTarget.offsetHeight;
|
||||
if (h > 0) {
|
||||
this.trackTarget.style.maxHeight = h + "px";
|
||||
}
|
||||
}
|
||||
|
||||
#handleKeydown(e) {
|
||||
if (!this.hasLightboxTarget) return;
|
||||
if (!this.lightboxTarget.matches(":popover-open")) return;
|
||||
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); this.prevImage(); }
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); this.nextImage(); }
|
||||
if (e.key === "Escape") { this.lightboxTarget.hidePopover(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['input', 'decrement']
|
||||
static values = { min: { type: Number, default: 1 } }
|
||||
|
||||
connect() {
|
||||
this.#updateDecrement()
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.#setValue(this.#current + 1)
|
||||
}
|
||||
|
||||
decrement() {
|
||||
this.#setValue(this.#current - 1)
|
||||
}
|
||||
|
||||
clamp() {
|
||||
this.#setValue(this.#current)
|
||||
}
|
||||
|
||||
get #current() {
|
||||
return parseInt(this.inputTarget.value, 10) || this.minValue
|
||||
}
|
||||
|
||||
#setValue(val) {
|
||||
const clamped = Math.max(this.minValue, val)
|
||||
this.inputTarget.value = clamped
|
||||
this.#updateDecrement()
|
||||
this.inputTarget.dispatchEvent(new Event('quantity:change', { bubbles: true }))
|
||||
}
|
||||
|
||||
#updateDecrement() {
|
||||
const atMin = this.#current <= this.minValue
|
||||
this.decrementTarget.disabled = atMin
|
||||
this.decrementTarget.setAttribute('aria-disabled', atMin)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['star', 'input']
|
||||
static values = { rating: { type: Number, default: 0 } }
|
||||
|
||||
hover(event) {
|
||||
this.#fill(parseInt(event.currentTarget.dataset.value))
|
||||
}
|
||||
|
||||
leave() {
|
||||
this.#fill(this.ratingValue)
|
||||
}
|
||||
|
||||
select(event) {
|
||||
const val = parseInt(event.currentTarget.dataset.value)
|
||||
this.ratingValue = val
|
||||
this.inputTarget.value = val
|
||||
|
||||
this.starTargets.forEach(star => {
|
||||
star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val))
|
||||
})
|
||||
}
|
||||
|
||||
#fill(upTo) {
|
||||
this.starTargets.forEach(star => {
|
||||
const filled = parseInt(star.dataset.value) <= upTo
|
||||
star.querySelector('svg').setAttribute('fill', filled ? 'currentColor' : 'none')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['button', 'panel']
|
||||
|
||||
show(event) {
|
||||
this.#activate(event.currentTarget.dataset.panel)
|
||||
}
|
||||
|
||||
#activate(panelId) {
|
||||
this.buttonTargets.forEach(btn => {
|
||||
const active = btn.dataset.panel === panelId
|
||||
btn.classList.toggle('is-active', active)
|
||||
btn.setAttribute('aria-selected', String(active))
|
||||
})
|
||||
|
||||
this.panelTargets.forEach(panel => {
|
||||
panel.hidden = panel.id !== `tab-panel-${panelId}`
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Strip Greek tonos (accent marks) from text inside elements that have the
|
||||
* `uppercase` CSS class.
|
||||
*
|
||||
* In Greek typography, capital letters do not carry the tonos accent.
|
||||
* CSS `text-transform: uppercase` uppercases the glyph but leaves the tonos
|
||||
* in place, producing visually incorrect output (e.g. Ά instead of Α).
|
||||
* This utility rewrites the text nodes directly so the rendered result is clean.
|
||||
*
|
||||
* The diaeresis (ϊ, ϋ) is preserved — it is retained in Greek uppercase.
|
||||
*
|
||||
* Runs once on DOMContentLoaded and then watches for dynamically added nodes
|
||||
* via MutationObserver.
|
||||
*/
|
||||
|
||||
const ACCENT_MAP = {
|
||||
// Lowercase with tonos → without tonos
|
||||
'ά': 'α', 'έ': 'ε', 'ή': 'η', 'ί': 'ι', 'ό': 'ο', 'ύ': 'υ', 'ώ': 'ω',
|
||||
// Uppercase with tonos → without tonos (for already-uppercased text)
|
||||
'Ά': 'Α', 'Έ': 'Ε', 'Ή': 'Η', 'Ί': 'Ι', 'Ό': 'Ο', 'Ύ': 'Υ', 'Ώ': 'Ω',
|
||||
// Combined tonos + diaeresis → diaeresis only (preserve the diaeresis)
|
||||
'ΐ': 'ϊ', 'ΰ': 'ϋ',
|
||||
}
|
||||
|
||||
const ACCENT_RE = /[άέήίόύώΆΈΉΊΌΎΏΐΰ]/g
|
||||
|
||||
function stripAccents(str) {
|
||||
return str.replace(ACCENT_RE, c => ACCENT_MAP[c] ?? c)
|
||||
}
|
||||
|
||||
function processElement(el) {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||
let node
|
||||
while ((node = walker.nextNode())) {
|
||||
const val = node.nodeValue
|
||||
if (val && /[άέήίόύώΆΈΉΊΌΎΏΐΰ]/.test(val)) {
|
||||
node.nodeValue = stripAccents(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyToPage() {
|
||||
document.querySelectorAll('.uppercase').forEach(processElement)
|
||||
}
|
||||
|
||||
// ── Initial pass ──────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', applyToPage)
|
||||
|
||||
// ── Observe dynamic additions ─────────────────────────────────────────────────
|
||||
const observer = new MutationObserver(mutations => {
|
||||
for (const { addedNodes } of mutations) {
|
||||
for (const node of addedNodes) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) continue
|
||||
if (node.classList?.contains('uppercase')) processElement(node)
|
||||
node.querySelectorAll?.('.uppercase').forEach(processElement)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
Reference in New Issue
Block a user