Finalized all component docs

This commit is contained in:
Ronan Berder
2025-06-14 16:48:01 +08:00
parent 7e3773276c
commit 386d139fe2
30 changed files with 1215 additions and 691 deletions
+21 -2
View File
@@ -161,7 +161,6 @@
h2 {
@apply col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight;
}
section {
@apply text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed;
@@ -989,7 +988,27 @@
@apply pointer-events-auto w-full mt-4 animate-[toast-up_0.3s_ease-in-out] grid grid-rows-[1fr] transition-[grid-template-rows,opacity,margin] duration-300 ease-in-out;
.toast-content {
@apply text-popover-foreground text-[13px] bg-popover border shadow-lg rounded-lg overflow-hidden;
@apply text-popover-foreground text-[13px] bg-popover border shadow-lg rounded-lg overflow-hidden flex gap-2.5 p-3 items-center;
svg {
@apply size-4 shrink-0;
}
section {
h2 {
@apply font-medium tracking-tight;
}
p {
@apply text-muted-foreground;
}
}
footer {
@apply ml-auto flex flex-col gap-2;
[data-toast-action],
[data-toast-cancel] {
@apply h-6 px-2.5 text-xs;
}
}
}
&[aria-hidden='true'] {
@apply grid-rows-[0fr] opacity-0 m-0 border-0 p-0 overflow-hidden;
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "./basecoat.css";
-3
View File
@@ -24,7 +24,6 @@
};
const handleKeyDown = (e) => {
// If the menu isn't open, we only care about ArrowUp/Down/Enter/Space to open it.
if (!popover.matches(':popover-open')) {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
e.preventDefault();
@@ -33,7 +32,6 @@
return;
}
// If the menu IS open, we handle all navigation.
let nextIndex = activeIndex;
if (menuItems.length === 0) return;
@@ -76,7 +74,6 @@
trigger.setAttribute('aria-expanded', e.newState === 'open');
if (e.newState === 'open') {
menuItems = Array.from(menu.querySelectorAll('[role^="menuitem"]:not([disabled])'));
// Ensure all menu items have IDs for aria-activedescendant
menuItems.forEach((item, index) => {
if (!item.id) item.id = `${menu.id}-item-${index}`;
});
+2
View File
@@ -138,6 +138,8 @@
});
popover.addEventListener('toggle', (e) => {
trigger.setAttribute('aria-expanded', e.newState === 'open');
if (e.newState === 'open') {
if (filter) filter.focus();
+1 -1
View File
@@ -71,7 +71,7 @@
const isMobile = window.innerWidth < breakpoint;
if (isMobile && (target.closest('[data-close-mobile-sidebar]') || (target.closest('a, button') && !target.closest('[data-keep-mobile-sidebar-open]')))) {
if (isMobile && (target.closest('a, button') && !target.closest('[data-keep-mobile-sidebar-open]'))) {
if (document.activeElement) document.activeElement.blur();
setState(false);
return;
+189 -68
View File
@@ -1,75 +1,196 @@
window.basecoat = window.basecoat || {};
window.basecoat.registerToast = function(Alpine) {
if (Alpine.components && Alpine.components.toast) return;
(() => {
let toaster;
const toasts = new WeakMap();
let isPaused = false;
const ICONS = {
success: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
error: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>',
info: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
warning: '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>'
};
Alpine.store('toaster', { isPaused: false });
Alpine.data('toast', (config={}) => ({
config: config,
open: false,
timeoutDuration: null,
timeoutId: null,
function initToaster(toasterElement) {
if (toasterElement.dataset.toasterInitialized) return;
toaster = toasterElement;
init() {
if (config.duration !== -1) {
this.timeoutDuration = config.duration || (config.category === 'error' ? 5000 : 3000);
this.timeoutId = setTimeout(() => { this.close() }, this.timeoutDuration);
toaster.addEventListener('mouseenter', pauseAllTimeouts);
toaster.addEventListener('mouseleave', resumeAllTimeouts);
toaster.addEventListener('click', (e) => {
const actionLink = e.target.closest('.toast footer a');
const actionButton = e.target.closest('.toast footer button');
if (actionLink || actionButton) {
closeToast(e.target.closest('.toast'));
}
this.open = true;
this.$watch('$store.toaster.isPaused', (isPaused) => {
if (!this.open) return;
if (isPaused) {
this.pauseTimeout();
});
toaster.querySelectorAll('.toast:not([data-toast-initialized])').forEach(initToast);
toaster.dataset.toasterInitialized = 'true';
}
function initToast(element) {
if (element.dataset.toastInitialized) return;
const duration = parseInt(element.dataset.duration);
const timeoutDuration = duration !== -1
? duration || (element.dataset.category === 'error' ? 5000 : 3000)
: -1;
const state = {
remainingTime: timeoutDuration,
timeoutId: null,
startTime: null,
};
if (timeoutDuration !== -1) {
if (isPaused) {
state.timeoutId = null;
} else {
state.startTime = Date.now();
state.timeoutId = setTimeout(() => closeToast(element), timeoutDuration);
}
}
toasts.set(element, state);
element.dataset.toastInitialized = 'true';
}
function pauseAllTimeouts() {
if (isPaused) return;
isPaused = true;
toaster.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(element => {
if (!toasts.has(element)) return;
const state = toasts.get(element);
if (state.timeoutId) {
clearTimeout(state.timeoutId);
state.timeoutId = null;
state.remainingTime -= Date.now() - state.startTime;
}
});
}
function resumeAllTimeouts() {
if (!isPaused) return;
isPaused = false;
toaster.querySelectorAll('.toast:not([aria-hidden="true"])').forEach(element => {
if (!toasts.has(element)) return;
const state = toasts.get(element);
if (state.remainingTime !== -1 && !state.timeoutId) {
if (state.remainingTime > 0) {
state.startTime = Date.now();
state.timeoutId = setTimeout(() => closeToast(element), state.remainingTime);
} else {
this.resumeTimeout();
closeToast(element);
}
}
});
}
function closeToast(element) {
if (!toasts.has(element)) return;
const state = toasts.get(element);
clearTimeout(state.timeoutId);
toasts.delete(element);
if (document.activeElement) document.activeElement.blur();
element.setAttribute('aria-hidden', 'true');
element.addEventListener('transitionend', () => element.remove(), { once: true });
}
function executeAction(button, toast) {
const actionString = button.dataset.toastAction;
if (!actionString) return;
try {
const func = new Function('close', actionString);
func(() => closeToast(toast));
} catch (e) {
console.error('Error executing toast action:', e);
}
}
function createToast(config) {
const {
category = 'info',
title,
description,
action,
cancel,
duration,
icon,
} = config;
const iconHtml = icon || (category && ICONS[category]) || '';
const titleHtml = title ? `<h2>${title}</h2>` : '';
const descriptionHtml = description ? `<p>${description}</p>` : '';
const actionHtml = action?.href
? `<a href="${action.href}" class="btn" data-toast-action>${action.label}</a>`
: action?.onclick
? `<button type="button" class="btn" data-toast-action onclick="${action.onclick}">${action.label}</button>`
: '';
const cancelHtml = cancel
? `<button type="button" class="btn-outline h-6 text-xs px-2.5 rounded-sm" data-toast-cancel onclick="${cancel?.onclick}">${cancel.label}</button>`
: '';
const footerHtml = actionHtml || cancelHtml ? `<footer>${actionHtml}${cancelHtml}</footer>` : '';
const html = `
<div
class="toast"
role="${category === 'error' ? 'alert' : 'status'}"
aria-atomic="true"
${category ? `data-category="${category}"` : ''}
${duration !== undefined ? `data-duration="${duration}"` : ''}
>
<div class="toast-content">
${iconHtml}
<section>
${titleHtml}
${descriptionHtml}
</section>
${footerHtml}
</div>
</div>
</div>
`;
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content.firstChild;
}
const initialToaster = document.getElementById('toaster');
if (initialToaster) initToaster(initialToaster);
window.addEventListener('basecoat:toast', (e) => {
if (!toaster) {
console.error('Cannot create toast: toaster container not found on page.');
return;
}
const config = e.detail?.config || {};
const toastElement = createToast(config);
toaster.appendChild(toastElement);
});
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches('#toaster')) {
initToaster(node);
}
if (toaster && node.matches('.toast:not([data-toast-initialized])')) {
initToast(node);
}
});
},
pauseTimeout() {
clearTimeout(this.timeoutId);
this.timeoutId = null;
},
resumeTimeout(index) {
if (this.open && this.timeoutId === null) {
this.timeoutId = setTimeout(() => { this.close() }, this.timeoutDuration);
}
},
close() {
this.pauseTimeout();
this.open = false;
this.$el.blur();
},
executeAction(actionString) {
if (actionString) {
Alpine.evaluate(this.$el, actionString);
}
},
$toastBindings: {
['@mouseenter']() { this.$store.toaster.isPaused = true },
['@mouseleave']() { this.$store.toaster.isPaused = false },
['@keydown.escape.prevent']() { this.close() },
[':aria-hidden']() { return !this.open }
},
}));
Alpine.magic('toast', (el) => (config, toasterId='toaster') => {
const toaster = document.getElementById(toasterId);
const template = document.getElementById('toast-template');
if (!toaster) {
console.error(`Toaster container with id #${toasterId} not found.`);
return;
}
if (!template) {
console.error('Toast template with id #toast-template not found.');
return;
}
const clone = template.content.firstElementChild.cloneNode(true);
clone.setAttribute('x-data', `toast(${JSON.stringify(config)})`);
clone.removeAttribute('id');
toaster.appendChild(clone);
});
});
};
observer.observe(document.body, { childList: true, subtree: true });
})();
+3 -3
View File
@@ -35,7 +35,7 @@
{% if trigger %}
<button
type="button"
onClick="document.getElementById('{{ id }}').showModal()"
onclick="document.getElementById('{{ id }}').showModal()"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
@@ -48,12 +48,12 @@
class="dialog"
aria-labelledby="{{ id }}-title"
{% if description %}aria-describedby="{{ id }}-description"{% endif %}
{% if close_on_overlay_click %}onClick="this.close()"{% endif %}
{% if close_on_overlay_click %}onclick="this.close()"{% endif %}
{% for key, value in dialog_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
<article {% if close_on_overlay_click %}onClick="event.stopPropagation()"{% endif %}>
<article {% if close_on_overlay_click %}onclick="event.stopPropagation()"{% endif %}>
{% if title or description %}
<header
{% for key, value in header_attrs %}
+1
View File
@@ -25,6 +25,7 @@
popovertarget="{{ id }}"
aria-haspopup="menu"
aria-controls="{{ id }}-menu"
aria-expanded="false"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
+4 -2
View File
@@ -18,6 +18,8 @@
type="button"
popovertarget="{{ id }}"
id="{{ id }}-trigger"
onclick="this.setAttribute('aria-expanded', this.getAttribute('aria-expanded') === 'false' ? 'true' : 'false')"
aria-expanded="false"
{% for key, value in trigger_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
@@ -26,12 +28,12 @@
</button>
<div
popover
class="popover {{ popover_attrs.class }}"
id="{{ id }}"
class="popover {{ popover_attrs.class }}"
{% for key, value in popover_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{{ caller() if caller }}
</div>
{% endmacro %}
{% endmacro %}
+44 -133
View File
@@ -10,16 +10,14 @@
{% macro toaster(
id="toaster",
toasts=[],
main_attrs=None,
is_fragment=false
attrs=None
) %}
<div
id="{{ id }}"
class="toaster"
{% for key, value in main_attrs %}
{% for key, value in attrs %}
{{ key }}="{{ value }}"
{% endfor %}
{% if is_fragment %}hx-swap-oob="beforeend"{% endif %}
>
{% for item in toasts %}
{{ toast(
@@ -31,79 +29,6 @@
) }}
{% endfor %}
</div>
{% if not is_fragment %}
<template id="toast-template">
<div
class="toast"
role="status"
aria-atomic="true"
x-bind="$toastBindings"
>
<div class="toast-content">
<div class="flex items-center justify-between gap-x-3 p-4 [&>svg]:size-4 [&>svg]:shrink-0 [&>[role=img]]:size-4 [&>[role=img]]:shrink-0 [&>[role=img]>svg]:size-4">
<template x-if="config.icon">
<span aria-hidden="true" role="img" x-html="config.icon"></span>
</template>
<template x-if="!config.icon && config.category === 'success'">
{{ toast_icons.success | safe }}
</template>
<template x-if="!config.icon && config.category === 'error'">
{{ toast_icons.error | safe }}
</template>
<template x-if="!config.icon && config.category === 'info'">
{{ toast_icons.info | safe }}
</template>
<template x-if="!config.icon && config.category === 'warning'">
{{ toast_icons.warning | safe }}
</template>
<section class="flex-1 flex flex-col gap-0.5 items-start">
<template x-if="config.title">
<h2 class="font-medium" x-text="config.title"></h2>
</template>
<template x-if="config.description">
<p class="text-muted-foreground" x-text="config.description"></p>
</template>
</section>
<template x-if="config.action || config.cancel">
<footer class="flex flex-col gap-1 self-start">
<template x-if="config.action?.click">
<button
type="button"
class="btn h-6 text-xs px-2.5 rounded-sm"
@click="executeAction(config.action.click)"
x-text="config.action.label"
></button>
</template>
<template x-if="config.action?.url">
<a
:href="config.action.url"
class="btn h-6 text-xs px-2.5 rounded-sm"
x-text="config.action.label"
></a>
</template>
<template x-if="config.cancel?.click">
<button
type="button"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
@click="executeAction(config.cancel.click)"
x-text="config.cancel.label"
></button>
</template>
<template x-if="config.cancel?.url">
<a
:href="config.cancel.url"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
x-text="config.cancel.label"
></a>
</template>
</footer>
</template>
</div>
</div>
</div>
</template>
{% endif %}
{% endmacro %}
{#
@@ -134,65 +59,51 @@
aria-atomic="true"
aria-hidden="false"
{% if category %}data-category="{{ category }}"{% endif %}
x-data="toast({
category: '{{ category }}',
duration: {{ duration or 'null' }}
})"
x-bind="$toastBindings"
>
<div class="toast-content">
<div class="flex items-center justify-between gap-x-3 p-4 [&>svg]:size-4 [&>svg]:shrink-0 [&>[role=img]]:size-4 [&>[role=img]]:shrink-0 [&>[role=img]>svg]:size-4">
{% if category in ["error", "success", "info", "warning"] %}
{{ toast_icons[category] | safe }}
{% if category in ["error", "success", "info", "warning"] %}
{% if category == "success" %}
{% lucide "circle-check" %}
{% elif category == "error" %}
{% lucide "circle-x" %}
{% elif category == "info" %}
{% lucide "info" %}
{% elif category == "warning" %}
{% lucide "triangle-alert" %}
{% endif %}
<section class="flex-1 flex flex-col gap-0.5 items-start">
{% if title %}
<h2 class="font-medium">{{ title }}</h2>
{% endif %}
{% if description %}
<p class="text-muted-foreground">{{ description }}</p>
{% endif %}
</section>
{% if action or cancel %}
<footer class="flex flex-col gap-1 self-start">
{% if action %}
{% if action.click %}
<button
type="button"
class="btn h-6 text-xs px-2.5 rounded-sm"
@click="{{ action.click }}"
>{{ action.label }}</button>
{% elif action.url %}
<a
href="{{ action.url }}"
class="btn h-6 text-xs px-2.5 rounded-sm"
>{{ action.label }}</a>
{% endif %}
{% endif %}
<section>
{% if title %}<h2>{{ title }}</h2>{% endif %}
{% if description %}<p>{{ description }}</p>{% endif %}
</section>
{% if action or cancel %}
<footer>
{% if action %}
{% if action.href %}
<a
href="{{ action.href }}"
class="btn-sm"
data-toast-action
>{{ action.label }}</a>
{% else %}
<button
type="button"
class="btn"
data-toast-action
{% if action.onclick %}onclick="{{ action.onclick }}"{% endif %}
>{{ action.label }}</button>
{% endif %}
{% if cancel %}
{% if cancel.click %}
<button
type="button"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
@click="{{ cancel.click }}"
>{{ cancel.label }}</button>
{% elif cancel.url %}
<a
href="{{ cancel.url }}"
class="btn-outline h-6 text-xs px-2.5 rounded-sm"
>{{ toast.cancel.label }}</a>
{% endif %}
{% endif %}
</footer>
{% endif %}
</div>
{% endif %}
{% if cancel %}
<button
type="button"
class="btn-sm-outline"
data-toast-cancel
{% if cancel.onclick %}onclick="{{ cancel.onclick }}"{% endif %}
>{{ cancel.label }}</button>
{% endif %}
</footer>
{% endif %}
</div>
</div>
{% endmacro %}
{% set toast_icons = {
'success': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-check-icon lucide-circle-check"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
'error': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>',
'info': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info-icon lucide-info"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
'warning': '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>'
} %}
{% endmacro %}