feature/site-header (#1)

Co-authored-by: Tim Rijkse <trijkse@gmail.com>
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-01-15 07:49:01 +00:00
parent 6816650ea5
commit 49d22484c0
17 changed files with 625 additions and 59 deletions

View File

@@ -0,0 +1,88 @@
/**
* Arrow Button Component
* A CTA link with a circular arrow icon
* Uses a slot for the button text content
*/
class ArrowButton extends HTMLElement {
static get observedAttributes() {
return ["href"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
get href() {
return this.getAttribute("href") || "#";
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
}
.arrow-button {
display: inline-flex;
align-items: center;
gap: 12px;
text-decoration: none;
color: inherit;
font-family: inherit;
font-size: inherit;
transition: opacity var(--transition-fast, 150ms ease);
}
.arrow-button:hover {
opacity: 0.8;
}
.arrow-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1.5px solid currentColor;
border-radius: 50%;
flex-shrink: 0;
}
.arrow-icon svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
}
.button-text {
text-decoration: underline;
text-underline-offset: 3px;
}
</style>
<a class="arrow-button" href="${this.href}">
<span class="arrow-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="m12 5 7 7-7 7"></path>
</svg>
</span>
<span class="button-text">
<slot></slot>
</span>
</a>
`;
}
}
customElements.define("arrow-button", ArrowButton);

View File

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

93
js/components/push-box.js Normal file
View File

@@ -0,0 +1,93 @@
/**
* Push Box Component
* A promotional container with logo, title, and CTA
* Uses slots for all content to allow easy customization in HTML
*/
class PushBox extends HTMLElement {
static get observedAttributes() {
return ["background-color", "text-color"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
get backgroundColor() {
return (
this.getAttribute("background-color") ||
"var(--color-push-box-bg, #EBEEF4)"
);
}
get textColor() {
return this.getAttribute("text-color") || "#951D51";
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
}
.push-box {
background-color: ${this.backgroundColor};
margin-bottom: 16px;
padding: 48px 16px;
display: flex;
flex-direction: column;
gap: 16px;
border-radius: 4px;
}
.logo-wrapper {
display: block;
margin-bottom: 32px;
}
.logo-wrapper ::slotted(img) {
width: 104px;
height: auto;
}
.title-wrapper ::slotted(*) {
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 24px;
font-weight: 700;
line-height: 34px;
color: #000000;
margin: 0;
}
.cta-wrapper {
color: #951D51;
font-family: var(--font-family-outfit, "Outfit", sans-serif);
font-size: 16px;
font-weight: 400;
}
</style>
<div class="push-box">
<div class="logo-wrapper">
<slot name="logo"></slot>
</div>
<div class="title-wrapper">
<slot name="title"></slot>
</div>
<div class="cta-wrapper">
<slot name="cta"></slot>
</div>
</div>
`;
}
}
customElements.define("push-box", PushBox);

View File

@@ -1,21 +1,119 @@
/**
* 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 {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.recognition = null;
this.isListening = false;
}
connectedCallback() {
this.render();
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() {
const input = this.shadowRoot.querySelector(".search-input");
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) => {
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() {
const iconColor = "#951D51";
this.shadowRoot.innerHTML = `
<style>
:host {
@@ -49,53 +163,115 @@ class SearchBar extends HTMLElement {
.search-form {
display: flex;
align-items: center;
position: relative;
padding: var(--spacing-sm, 0.5rem) var(--spacing-sm, 0.5rem);
height: 50px;
padding: 8px 16px;
background-color: #951D51;
}
.search-icon {
position: absolute;
left: var(--spacing-md, 1rem);
width: 20px;
height: 20px;
color: var(--color-text-light, #64748b);
pointer-events: none;
.search-container {
display: flex;
align-items: center;
width: 100%;
height: 40px;
background-color: #FFF;
border-radius: 8px;
overflow: hidden;
}
.search-input {
width: 100%;
padding: var(--spacing-sm, 0.5rem) var(--spacing-md, 1rem);
padding-left: calc(var(--spacing-md, 1rem) + 20px + var(--spacing-sm, 0.5rem));
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text, #1e293b);
background-color: var(--color-background-tertiary, #f1f5f9);
border: 1px solid transparent;
border-radius: var(--radius-lg, 0.75rem);
flex: 1;
height: 100%;
padding: 0 16px;
font-family: var(--font-family-base);
font-size: var(--font-size-base, 16px);
font-weight: var(--font-weight-light, 300);
color: #383838;
background-color: transparent;
border: none;
outline: none;
transition: all var(--transition-fast, 150ms ease);
}
.search-input::placeholder {
color: var(--color-text-light, #64748b);
font-family: var(--font-family-base);
font-size: var(--font-size-base, 16px);
font-weight: var(--font-weight-light, 300);
color: rgba(56, 56, 56, 0.5);
}
.search-input:focus {
background-color: var(--color-background, #ffffff);
border-color: var(--color-primary, #2563eb);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
.icon-buttons {
display: flex;
align-items: center;
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>
<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">
<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" />
</svg>
<input
type="search"
class="search-input"
placeholder="Search books, authors..."
aria-label="Search books and authors"
>
<div class="search-container">
<input
type="search"
class="search-input"
placeholder="Waar ben je naar op zoek?"
aria-label="Zoek boeken en auteurs"
>
<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>
`;
}

View File

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

View File

@@ -65,9 +65,9 @@ class TopBar extends HTMLElement {
}
::slotted([slot="logo"]) {
font-family: 'Outfit', system-ui, sans-serif;
font-size: 1.25rem;
font-weight: 700;
font-family: var(--font-family-base);
font-size: var(--font-size-xl, 1.25rem);
font-weight: var(--font-weight-bold, 700);
color: #ffffff;
text-decoration: none;
}