feature/site-header #1

Merged
rubberducky merged 4 commits from feature/site-header into main 2026-01-15 07:49:02 +00:00
11 changed files with 394 additions and 46 deletions
Showing only changes of commit f7be64dbba - Show all commits

View File

@@ -277,6 +277,7 @@ top-bar .logo {
top-bar .actions { top-bar .actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-md);
} }
/* ========================================================================== /* ==========================================================================

View File

@@ -11,7 +11,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<link rel="stylesheet" href="css/styles.css" /> <link rel="stylesheet" href="css/styles.css" />

View File

@@ -59,11 +59,13 @@ class HorizontalScrollNav extends HTMLElement {
.nav-container { .nav-container {
display: flex; display: flex;
gap: var(--spacing-sm, 0.5rem); align-items: center;
height: 80px;
gap: 24px;
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; scrollbar-width: none;
-ms-overflow-style: none; -ms-overflow-style: none;
padding: var(--spacing-xs, 0.25rem) var(--spacing-sm, 0.5rem); padding: 0 16px;
} }
.nav-container::-webkit-scrollbar { .nav-container::-webkit-scrollbar {
@@ -72,29 +74,37 @@ class HorizontalScrollNav extends HTMLElement {
.nav-pill { .nav-pill {
flex-shrink: 0; flex-shrink: 0;
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem); height: 32px;
font-size: var(--font-size-sm, 0.875rem); padding: 0;
font-size: 16px;
font-weight: var(--font-weight-medium, 500); font-weight: var(--font-weight-medium, 500);
color: var(--color-text-light, #64748b); line-height: 1.5;
background-color: var(--color-background-tertiary, #f1f5f9); color: #000000;
background-color: transparent;
border: none; border: none;
border-radius: var(--radius-full, 9999px); border-radius: 0;
cursor: pointer; cursor: pointer;
transition: all var(--transition-fast, 150ms ease); transition: all var(--transition-fast, 150ms ease);
white-space: nowrap; white-space: nowrap;
text-decoration: underline;
display: flex;
align-items: center;
} }
.nav-pill:hover { .nav-pill:hover {
background-color: var(--color-border, #e2e8f0); color: #333333;
} }
.nav-pill.active { .nav-pill.active {
color: var(--color-text-inverse, #ffffff); color: #ffffff;
background-color: var(--color-primary, #2563eb); background-color: #951D51;
text-decoration: none;
border-radius: 9999px;
padding: 0 16px;
} }
.nav-pill.active:hover { .nav-pill.active:hover {
background-color: var(--color-primary-dark, #1d4ed8); background-color: #7a1843;
} }
</style> </style>
<nav class="nav-container" role="navigation" aria-label="Book categories"> <nav class="nav-container" role="navigation" aria-label="Book categories">

View File

@@ -1,21 +1,119 @@
/** /**
* Search Bar Component * Search Bar Component
* Search input with icon * Search input with mic and search icons on the right
* Includes speech recognition functionality
*/ */
import { micIcon } from "../icons/mic.js";
import { searchIcon } from "../icons/search.js";
class SearchBar extends HTMLElement { class SearchBar extends HTMLElement {
constructor() { constructor() {
super(); super();
this.attachShadow({ mode: "open" }); this.attachShadow({ mode: "open" });
this.recognition = null;
this.isListening = false;
} }
connectedCallback() { connectedCallback() {
this.render(); this.render();
this.addEventListeners(); this.addEventListeners();
this.initSpeechRecognition();
}
disconnectedCallback() {
if (this.recognition) {
this.recognition.stop();
}
}
initSpeechRecognition() {
// Check for browser support
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.warn("Speech recognition is not supported in this browser");
const micButton = this.shadowRoot.querySelector(".mic-button");
if (micButton) {
micButton.style.display = "none";
}
return;
}
this.recognition = new SpeechRecognition();
this.recognition.continuous = false;
this.recognition.interimResults = true;
this.recognition.lang = "nl-NL";
this.recognition.onstart = () => {
this.isListening = true;
this.updateMicButtonState();
};
this.recognition.onend = () => {
this.isListening = false;
this.updateMicButtonState();
};
this.recognition.onresult = (event) => {
const input = this.shadowRoot.querySelector(".search-input");
const transcript = Array.from(event.results)
.map((result) => result[0].transcript)
.join("");
input.value = transcript;
// Dispatch input event for live updates
this.dispatchEvent(
new CustomEvent("search-input", {
detail: { query: transcript },
bubbles: true,
composed: true,
})
);
// If final result, also dispatch search event
if (event.results[0].isFinal) {
this.dispatchEvent(
new CustomEvent("search", {
detail: { query: transcript },
bubbles: true,
composed: true,
})
);
}
};
this.recognition.onerror = (event) => {
console.error("Speech recognition error:", event.error);
this.isListening = false;
this.updateMicButtonState();
};
}
updateMicButtonState() {
const micButton = this.shadowRoot.querySelector(".mic-button");
if (micButton) {
micButton.classList.toggle("listening", this.isListening);
micButton.setAttribute("aria-pressed", this.isListening.toString());
}
}
toggleSpeechRecognition() {
if (!this.recognition) return;
if (this.isListening) {
this.recognition.stop();
} else {
this.recognition.start();
}
} }
addEventListeners() { addEventListeners() {
const input = this.shadowRoot.querySelector(".search-input"); const input = this.shadowRoot.querySelector(".search-input");
const form = this.shadowRoot.querySelector(".search-form"); const form = this.shadowRoot.querySelector(".search-form");
const micButton = this.shadowRoot.querySelector(".mic-button");
const searchButton = this.shadowRoot.querySelector(".search-button");
form.addEventListener("submit", (e) => { form.addEventListener("submit", (e) => {
e.preventDefault(); e.preventDefault();
@@ -37,9 +135,25 @@ class SearchBar extends HTMLElement {
}) })
); );
}); });
micButton.addEventListener("click", () => {
this.toggleSpeechRecognition();
});
searchButton.addEventListener("click", () => {
this.dispatchEvent(
new CustomEvent("search", {
detail: { query: input.value },
bubbles: true,
composed: true,
})
);
});
} }
render() { render() {
const iconColor = "#951D51";
this.shadowRoot.innerHTML = ` this.shadowRoot.innerHTML = `
<style> <style>
:host { :host {
@@ -49,53 +163,115 @@ class SearchBar extends HTMLElement {
.search-form { .search-form {
display: flex; display: flex;
align-items: center; align-items: center;
position: relative; height: 50px;
padding: var(--spacing-sm, 0.5rem) var(--spacing-sm, 0.5rem); padding: 8px 16px;
background-color: #951D51; background-color: #951D51;
} }
.search-icon { .search-container {
position: absolute; display: flex;
left: var(--spacing-md, 1rem); align-items: center;
width: 20px; width: 100%;
height: 20px; height: 40px;
color: var(--color-text-light, #64748b); background-color: #FADCE7;
pointer-events: none; border-radius: 8px;
overflow: hidden;
} }
.search-input { .search-input {
width: 100%; flex: 1;
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem); height: 100%;
padding-left: calc(var(--spacing-md, 1rem) + 20px + var(--spacing-sm, 0.5rem)); padding: 0 16px;
font-size: var(--font-size-sm, 0.875rem); font-family: 'Outfit', system-ui, sans-serif;
color: var(--color-text, #1e293b); font-size: 16px;
background-color: var(--color-background-tertiary, #f1f5f9); font-weight: 300;
border: 1px solid transparent; color: #383838;
border-radius: var(--radius-lg, 0.75rem); background-color: transparent;
border: none;
outline: none; outline: none;
transition: all var(--transition-fast, 150ms ease);
} }
.search-input::placeholder { .search-input::placeholder {
color: var(--color-text-light, #64748b); font-family: 'Outfit', system-ui, sans-serif;
font-size: 16px;
font-weight: 300;
color: rgba(56, 56, 56, 0.5);
} }
.search-input:focus { .icon-buttons {
background-color: var(--color-background, #ffffff); display: flex;
border-color: var(--color-primary, #2563eb); align-items: center;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); gap: 4px;
padding-right: 8px;
}
.icon-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
cursor: pointer;
padding: 0;
border-radius: 4px;
transition: background-color 150ms ease;
}
.icon-button:hover {
background-color: rgba(149, 29, 81, 0.1);
}
.icon-button:active {
background-color: rgba(149, 29, 81, 0.2);
}
.icon-button.listening {
background-color: rgba(149, 29, 81, 0.2);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.icon-button svg {
width: 20px;
height: 20px;
} }
</style> </style>
<form class="search-form" role="search"> <form class="search-form" role="search">
<svg class="search-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <div class="search-container">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /> <input
</svg> type="search"
<input class="search-input"
type="search" placeholder="Waar ben je naar op zoek?"
class="search-input" aria-label="Zoek boeken en auteurs"
placeholder="Search books, authors..." >
aria-label="Search books and authors" <div class="icon-buttons">
> <button
type="button"
class="icon-button mic-button"
aria-label="Zoeken met spraak"
aria-pressed="false"
>
${micIcon({ size: 20, color: iconColor })}
</button>
<button
type="submit"
class="icon-button search-button"
aria-label="Zoeken"
>
${searchIcon({ size: 20, color: iconColor })}
</button>
</div>
</div>
</form> </form>
`; `;
} }

View File

@@ -26,7 +26,6 @@ class SiteHeader extends HTMLElement {
.header { .header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-sm, 0.5rem);
border-bottom: 1px solid var(--color-border, #e2e8f0); border-bottom: 1px solid var(--color-border, #e2e8f0);
} }
</style> </style>

9
js/icons/index.js Normal file
View File

@@ -0,0 +1,9 @@
/**
* Lucide Icons Index
* Re-exports all icons for easy importing
*/
export { micIcon } from "./mic.js";
export { searchIcon } from "./search.js";
export { menuIcon } from "./menu.js";
export { userIcon } from "./user.js";
export { shoppingBagIcon } from "./shopping-bag.js";

31
js/icons/menu.js Normal file
View File

@@ -0,0 +1,31 @@
/**
* Menu Icon (Lucide)
* @param {Object} props - Icon properties
* @param {number} props.size - Icon size (default: 24)
* @param {string} props.color - Icon color (default: currentColor)
* @param {number} props.strokeWidth - Stroke width (default: 2)
* @returns {string} SVG string
*/
export function menuIcon({
size = 24,
color = "currentColor",
strokeWidth = 2,
} = {}) {
return `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${size}"
height="${size}"
viewBox="0 0 24 24"
fill="none"
stroke="${color}"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="4" x2="20" y1="12" y2="12"/>
<line x1="4" x2="20" y1="6" y2="6"/>
<line x1="4" x2="20" y1="18" y2="18"/>
</svg>
`;
}

31
js/icons/mic.js Normal file
View File

@@ -0,0 +1,31 @@
/**
* Mic Icon (Lucide)
* @param {Object} props - Icon properties
* @param {number} props.size - Icon size (default: 24)
* @param {string} props.color - Icon color (default: currentColor)
* @param {number} props.strokeWidth - Stroke width (default: 2)
* @returns {string} SVG string
*/
export function micIcon({
size = 24,
color = "currentColor",
strokeWidth = 2,
} = {}) {
return `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${size}"
height="${size}"
viewBox="0 0 24 24"
fill="none"
stroke="${color}"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" x2="12" y1="19" y2="22"/>
</svg>
`;
}

30
js/icons/search.js Normal file
View File

@@ -0,0 +1,30 @@
/**
* Search Icon (Lucide)
* @param {Object} props - Icon properties
* @param {number} props.size - Icon size (default: 24)
* @param {string} props.color - Icon color (default: currentColor)
* @param {number} props.strokeWidth - Stroke width (default: 2)
* @returns {string} SVG string
*/
export function searchIcon({
size = 24,
color = "currentColor",
strokeWidth = 2,
} = {}) {
return `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${size}"
height="${size}"
viewBox="0 0 24 24"
fill="none"
stroke="${color}"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
`;
}

31
js/icons/shopping-bag.js Normal file
View File

@@ -0,0 +1,31 @@
/**
* Shopping Bag Icon (Lucide)
* @param {Object} props - Icon properties
* @param {number} props.size - Icon size (default: 24)
* @param {string} props.color - Icon color (default: currentColor)
* @param {number} props.strokeWidth - Stroke width (default: 2)
* @returns {string} SVG string
*/
export function shoppingBagIcon({
size = 24,
color = "currentColor",
strokeWidth = 2,
} = {}) {
return `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${size}"
height="${size}"
viewBox="0 0 24 24"
fill="none"
stroke="${color}"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
<path d="M3 6h18"/>
<path d="M16 10a4 4 0 0 1-8 0"/>
</svg>
`;
}

30
js/icons/user.js Normal file
View File

@@ -0,0 +1,30 @@
/**
* User Icon (Lucide)
* @param {Object} props - Icon properties
* @param {number} props.size - Icon size (default: 24)
* @param {string} props.color - Icon color (default: currentColor)
* @param {number} props.strokeWidth - Stroke width (default: 2)
* @returns {string} SVG string
*/
export function userIcon({
size = 24,
color = "currentColor",
strokeWidth = 2,
} = {}) {
return `
<svg
xmlns="http://www.w3.org/2000/svg"
width="${size}"
height="${size}"
viewBox="0 0 24 24"
fill="none"
stroke="${color}"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="8" r="5"/>
<path d="M20 21a8 8 0 0 0-16 0"/>
</svg>
`;
}