cmziuk/www/demos/filters.html
Cameron Redmore 19da15822c
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 31s
Initial commit.
2025-04-18 16:28:19 +01:00

608 lines
No EOL
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search Example</title>
<style>
html, body {
background: var(--background-1);
color: white;
}
:root {
--background-1: #202020;
--background-2: #2f2f3f;
--accent: #FF9F1C;
}
* {
box-sizing: border-box;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
color-scheme: dark light;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.search-flex {
display: flex;
justify-content: space-between;
flex-direction: row;
flex-wrap: wrap;
min-height: 750px;
border-radius: 25px;
overflow: hidden;
border: 1px solid #2f2f3f;
}
/* Sidebar should be 25% width, but break to own row at < 200px */
.sidebar {
background-color: var(--background-2);
/* 25% width */
flex: 1 0 25%;
/* Break to own row at < 200px */
min-width: 200px;
border-right: 1px solid #2f2f3f;
}
.main {
/* 75% width */
flex: 1 1 75%;
/* Fill height */
flex-grow: 1;
display: flex;
flex-direction: column;
}
.filters {
background: var(--background-2);
/* Minimum height of 100px */
flex: 0 1 100px;
border-bottom: 1px solid #2f2f3f;
display: flex;
align-items: center;
flex-direction: row;
gap: 10px;
padding: 10px;
}
.filters>* {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.25s;
padding: 10px;
background: rgba(255, 255, 255, 0.2);
height: 30px;
text-transform: uppercase;
border-radius: 25px;
}
.filters>*:hover {
background: rgba(255, 255, 255, 0.25);
}
.results {
background: #028090;
/* Fill rest of space */
flex: 0 1 75svh;
/* Scroll if needed */
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.currentFilter {
overflow: hidden;
height: auto;
flex-basis: 0;
flex-shrink: 1;
transition: 0.5s;
display: flex;
flex-direction: column;
}
.currentFilter.active {
flex-basis: 200px;
}
.filterValues {
flex: 1 1 auto;
/* Set as Flex, try to use 3 columns, but break to more rows if less than 200px per item */
display: flex;
flex-wrap: wrap;
justify-content: space-around;
overflow-y: auto;
gap: 10px;
padding: 10px;
}
.filterValues>div {
/* 33% width, but break to own row at < 200px */
flex: 1 0 calc((100% /3) - 10px);
min-width: 200px;
padding: 5px;
display: flex;
background: #02C39A;
padding: 5px;
border-radius: 5px;
justify-content: center;
align-items: center;
cursor: pointer;
transition: 0.25s;
}
.filterValues>div:hover {
background: #00ecb9;
}
.showAllButton {
bottom: 0;
flex: 0 0 25px;
text-align: center;
background: rgba(255, 255, 255, 0.25);
cursor: pointer;
transition: 0.25s;
}
.showAllButton:hover {
background: rgba(255, 255, 255, 0.5);
}
.selectedFilters {
overflow: hidden;
border-bottom: 1px solid #2f2f3f;
display: flex;
flex-direction: row;
flex-wrap: wrap;
transition: 0.5s;
flex-basis: min-content;
}
.selectedFilters .chip {
background: var(--accent);
padding: 10px;
margin: 10px;
border-radius: 25px;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
font-weight: bold;
cursor: pointer;
scale: 0;
animation: fadeIn 0.5s forwards;
}
.selectedFilters .chip.fadeOut {
animation: fadeOut 0.5s forwards;
}
@keyframes fadeIn {
from {
scale: 0;
}
to {
scale: 1;
}
}
@keyframes fadeOut {
from {
scale: 1;
}
to {
scale: 0;
}
}
.filler {
flex: 1 1 auto;
background: transparent;
text-align: center;
text-transform: uppercase;
pointer-events: none;
}
.sort {}
.pagination {
flex: 0 1 25%;
display: flex;
flex-direction: row;
gap: 10px;
justify-content: space-around;
}
.product {
background: #00A896;
padding: 10px;
margin: 10px;
border-radius: 10px;
display: flex;
flex-direction: column;
text-align: center;
}
</style>
</head>
<body>
<h1>Search Filter Example</h1>
<p>
Please note, this is still *very* early. It is a proof of concept, not attached to any real data, and is designed to allow experimentation with a *potential* filtering method.
</p>
<p>
This example is still missing dynamic filters entirely, and sort and pagination are yet to be implemented. It is also not yet wholly responsive.
Additionally, the counts on the category and brand buttons are not yet implemented, and filter chips are not sorted in any way.
</p>
<div class="container">
<!-- Layout with sidebar, static filters & sort, then main table -->
<div class="search-flex">
<div class="sidebar">
<h1 style="text-align: center; margin: auto;">Dynamic Filters Go Here</h1>
</div>
<div class="main">
<div class="filters">
<div class="category">Category (X)</div>
<div class="brand">Brand (X)</div>
<div class="filler"></div>
<div class="sort">Sort</div>
<div class="pagination">
<div>Previous</div>
<div>Next</div>
</div>
</div>
<div class="currentFilter">
<div class="filterValues">
</div>
<span class="showAllButton">Show All</span>
</div>
<div class="selectedFilters">
<!-- <div class="chip">Filter 1</div> -->
</div>
<div class="results">
</div>
</div>
</div>
</div>
<script>
const products = [];
const selectedFilters = {
category: [],
brand: []
};
const selectedDynamicFilters = [];
const filters = document.querySelector('.currentFilter');
const showAllButton = document.querySelector('.showAllButton');
showAllButton.addEventListener('click', () => {
const hasSize = filters.style.flexBasis;
if (hasSize) {
filters.style.flexBasis = '';
} else {
filters.style.flexBasis = (document.querySelector('.filterValues').scrollHeight + 25) + 'px';
}
});
const category = document.querySelector('.category');
const brand = document.querySelector('.brand');
let currentFilters = null;
const filterValues = document.querySelector('.filterValues');
const filterOptions = {
category: ['Category 1', 'Category 2', 'Category 3', 'Category 4', 'Category 5', 'Category 6', 'Category 7', 'Category 8', 'Category 9', 'Category 10', 'Category 11', 'Category 12', 'Category 13', 'Category 14', 'Category 15', 'Category 16', 'Category 17', 'Category 18', 'Category 19', 'Category 20'],
brand: ['Brand 1', 'Brand 2', 'Brand 3', 'Brand 4', 'Brand 5', 'Brand 6', 'Brand 7', 'Brand 8', 'Brand 9', 'Brand 10', 'Brand 11', 'Brand 12', 'Brand 13', 'Brand 14', 'Brand 15', 'Brand 16', 'Brand 17', 'Brand 18', 'Brand 19', 'Brand 20']
}
const dynamicFilters = {
size: ['50mm', '100mm', '150mm', '200mm'],
color: ['Red', 'Green', 'Blue', 'Yellow', 'Orange'],
use: ['Indoor', 'Outdoor', 'Industrial', 'Commercial', 'Residential']
}
for (let i = 0; i < 1000; i++) {
const newProd = {
id: i,
name: `Product ${i}`,
category: filterOptions.category[Math.floor(Math.random() * filterOptions.category.length)],
brand: filterOptions.brand[Math.floor(Math.random() * filterOptions.brand.length)],
attributes: {
},
price: (Math.floor(Math.random() * 100000) / 100).toFixed(2)
};
if (Math.random() > 0.5) {
newProd.attributes.size = dynamicFilters.size[Math.floor(Math.random() * dynamicFilters.size.length)];
}
if (Math.random() > 0.66) {
newProd.attributes.color = dynamicFilters.color[Math.floor(Math.random() * dynamicFilters.color.length)];
}
if (Math.random() > 0.75) {
newProd.attributes.use = dynamicFilters.use[Math.floor(Math.random() * dynamicFilters.use.length)];
}
products.push(newProd);
}
async function openOrCloseFilters(type) {
if (currentFilters === type) {
currentFilters = null;
filters.classList.remove('active');
filters.style.flexBasis = '';
return;
}
else if (!currentFilters) {
currentFilters = type;
filters.classList.add('active');
}
else {
currentFilters = type;
//Hide current filters, switch to new filters
filters.classList.remove('active');
filters.style.flexBasis = '0px';
await new Promise((resolve, reject) => {
setTimeout(() => {
filters.classList.add('active');
filters.style.flexBasis = '';
resolve();
}, 500);
});
}
filterValues.innerHTML = '';
filterOptions[type].forEach((option) => {
const div = document.createElement('div');
div.innerText = option;
//Calculate how many products remaining if this filter is applied
const filteredProducts = getFilteredProducts().filter((product) => {
return product[type] === option;
});
if (filteredProducts.length > 0) {
div.innerText += ` (${filteredProducts.length})`;
div.addEventListener('click', () => {
addOrRemoveFilter(type, option);
});
filterValues.appendChild(div);
}
});
}
function addOrRemoveFilter(filterType, filterValue) {
console.log('Adding or removing filter', filterType, filterValue)
console.log(selectedFilters);
if (selectedFilters[filterType].includes(filterValue)) {
removeFilter(filterType, filterValue);
}
else {
addFilter(filterType, filterValue);
}
}
function addFilter(filterType, filterValue) {
selectedFilters[filterType].push(filterValue);
const chip = document.createElement('div');
chip.classList.add('chip');
chip.setAttribute('data-filter-type', filterType);
chip.setAttribute('data-filter-value', filterValue);
chip.innerText = filterValue;
chip.addEventListener('click', () => {
chip.classList.add('fadeOut');
setTimeout(() => {
chip.remove();
}, 500);
removeFilter(filterType, filterValue);
});
document.querySelector('.selectedFilters').appendChild(chip);
updateProductsTable();
}
function removeFilter(filterType, filterValue) {
if (selectedFilters[filterType].includes(filterValue)) {
selectedFilters[filterType] = selectedFilters[filterType].filter((value) => value !== filterValue);
}
//Check for chip
const chip = document.querySelector(`.selectedFilters .chip[data-filter-type="${filterType}"][data-filter-value="${filterValue}"]`);
if (chip) {
chip.classList.add('fadeOut');
setTimeout(() => {
chip.remove();
}, 500);
}
updateProductsTable();
}
function getFilteredProducts() {
let filteredProducts = products;
for (const filterType in selectedFilters) {
if (selectedFilters.hasOwnProperty(filterType)) {
const filterValues = selectedFilters[filterType];
if (filterValues.length === 0) {
continue;
}
filteredProducts = filteredProducts.filter((product) => {
return filterValues.includes(product[filterType]);
});
}
}
//Dynamic filters
for (const filterType in selectedDynamicFilters) {
if (selectedDynamicFilters.hasOwnProperty(filterType)) {
const filterValues = selectedDynamicFilters[filterType];
if (filterValues.length === 0) {
continue;
}
filteredProducts = filteredProducts.filter((product) => {
return filterValues.includes(product.attributes[filterType]);
});
}
}
return filteredProducts;
}
function updateProductsTable() {
console.log('Updating products table');
const results = document.querySelector('.results');
results.innerHTML = '';
const filteredProducts = getFilteredProducts();
filteredProducts.forEach((product) => {
//Create divs for each product attribute
const div = document.createElement('div');
div.classList.add('product');
div.innerHTML = `
<h3>${product.name}</h3>
<p>${product.category}</p>
<p>${product.brand}</p>
<p>Size: ${product.attributes.size || 'N/A'}</p>
<p>Colour: ${product.attributes.color || 'N/A'}</p>
<p>Use: ${product.attributes.use || 'N/A'}</p>
<p>Price: $${product.price}</p>
`;
results.appendChild(div);
});
//Update filler with product count
document.querySelector('.filler').innerText = `Showing ${filteredProducts.length}/${products.length} products`;
}
category.addEventListener('click', () => {
openOrCloseFilters('category');
});
brand.addEventListener('click', () => {
openOrCloseFilters('brand');
});
updateProductsTable();
</script>
</body>
</html>