// Design system officiel du Web Vasco.
//
// Les écrans consomment ces primitives au lieu de reconstruire leurs boutons,
// lignes de réglages, champs et feuilles. Les tokens historiques restent la
// source des couleurs : ce module leur ajoute les contrats de composition,
// de typographie et de mouvement qui manquaient à l'application connectée.
if (!window.VascoDesignContract) {
throw new Error('Le contrat visuel Vasco doit être chargé avant le UI Kit.');
}
const VASCO_UI_TOKENS = window.VascoDesignContract;
function VascoCloseIcon({ size = 18 }) {
return ;
}
function VascoChevronIcon({ direction = 'right', size = 16 }) {
const path = direction === 'left' ? 'M15 6l-6 6 6 6' : 'M9 6l6 6-6 6';
return ;
}
// Babel Standalone transforme chaque fichier JSX dans la portée globale. Ses
// variables d'aide (`_excluded`, `_excluded2`, …) peuvent donc être remplacées
// par un fichier chargé plus tard. Une décomposition avec `...buttonProps`
// finirait alors par transmettre des propriétés React internes au DOM. Cette
// allowlist garde les attributs HTML/accessibilité utiles sans dépendre de ces
// helpers globaux.
//
// Aucune primitive du socle n'utilise plus `...rest` : le tri se fait par ce
// qu'un élément natif accepte, jamais par ce qu'on pense avoir retiré. Une
// primitive dont les données portent un nom d'attribut HTML (`value`,
// `options`, `name`) déclare ce qu'elle consomme dans `ownProps` : cette part
// reste à elle et ne traverse pas vers le DOM.
function vascoSafeButtonProps(props, ownProps = null) {
const safe = {};
const htmlAttributes = new Set(['id', 'name', 'value', 'form', 'tabIndex', 'autoFocus', 'role']);
const domEvents = new Set([
'onBlur', 'onFocus', 'onKeyDown', 'onKeyUp', 'onMouseEnter', 'onMouseLeave',
'onPointerDown', 'onPointerUp',
]);
Object.entries(props || {}).forEach(([key, value]) => {
if (ownProps && ownProps.has(key)) return;
if (key.startsWith('aria-') || key.startsWith('data-')
|| htmlAttributes.has(key) || domEvents.has(key)) safe[key] = value;
});
return safe;
}
function VascoButton(props) {
const { children, onClick, variant = 'secondary', size = 'large', disabled = false,
busy = false, busyLabel = null, icon = null, grow = false, type = 'button', ariaLabel = null,
buttonRef = null, style = {} } = props;
const buttonProps = vascoSafeButtonProps(props);
const solid = variant === 'solid';
const accent = variant === 'accent';
const accentSoft = variant === 'accent-soft';
const successSoft = variant === 'success-soft';
const neutralSoft = variant === 'neutral-soft';
const danger = variant === 'danger';
const ghost = variant === 'ghost';
const height = {
small: VASCO_UI_TOKENS.control.small,
medium: VASCO_UI_TOKENS.control.medium,
large: VASCO_UI_TOKENS.control.large,
}[size] || VASCO_UI_TOKENS.control.large;
const inactive = disabled || busy;
return ;
}
function VascoIconButton({ label, onClick, children, variant = 'secondary', disabled = false,
size = VASCO_UI_TOKENS.control.icon, buttonRef = null, pressed = null,
expanded = null, title = null, style = {} }) {
const ghost = variant === 'ghost';
return ;
}
// Action textuelle discrète : relancer, modifier, effacer, afficher la suite.
// Elle évite que chaque écran réinvente le faux lien posé sous une carte.
function VascoInlineAction(props) {
const { children, onClick, disabled = false, busy = false, danger = false,
icon = null, ariaLabel = null, title = null, type = 'button', style = {} } = props;
const buttonProps = vascoSafeButtonProps(props);
const inactive = disabled || busy;
return ;
}
// Choix compact pour les filtres et variantes exclusives. `aria-pressed` porte
// l'état : la couleur n'est jamais la seule information disponible.
function VascoChoiceChip(props) {
const { children, selected = false, onClick, disabled = false,
ariaLabel = null, title = null, style = {} } = props;
const buttonProps = vascoSafeButtonProps(props);
return ;
}
// État global d'une fonctionnalité : information, simulation, indisponibilité
// ou erreur. Le texte reste la preuve principale ; la couleur et le symbole ne
// font que faciliter le balayage visuel.
function VascoStatusNotice({ title, message = null, tone = 'info', compact = false, style = {} }) {
const appearances = {
info: { color: VASCO_UI_TOKENS.color.accent, background: VASCO_UI_TOKENS.color.accentTint, symbol: 'i' },
success: { color: VASCO_UI_TOKENS.color.success, background: VASCO_UI_TOKENS.color.surfaceAlt, symbol: '✓' },
warning: { color: VASCO_UI_TOKENS.color.warning, background: VASCO_UI_TOKENS.color.surfaceAlt, symbol: '!' },
danger: { color: VASCO_UI_TOKENS.color.danger, background: VASCO_UI_TOKENS.color.surfaceAlt, symbol: '!' },
};
const appearance = appearances[tone] || appearances.info;
return
{appearance.symbol}
{title}
{message && {message}}
;
}
// Ligne ou carte de choix composée. Les contenus métier restent libres mais la
// surface, l'état sélectionné et la cible tactile restent identiques.
function VascoInteractiveRow(props) {
const { title = null, subtitle = null, children = null, leading = null,
trailing = null, onClick, selected = false, disabled = false, ariaLabel = null,
buttonRef = null, style = {} } = props;
const buttonProps = vascoSafeButtonProps(props);
return ;
}
function VascoSettingRow({ icon, label, sub, right, onClick, danger = false, reason = null,
role, checked }) {
const actionable = Boolean(onClick) && !reason;
const body = <>
{label}
{sub &&
{sub}
}
{reason &&
{reason}
}
{right || (actionable ?
: null)}
>;
const shared = {
width: '100%', display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 16px', background: 'none', border: 'none',
fontFamily: VASCO_UI_TOKENS.type.body, textAlign: 'left',
};
if (!actionable) return {body}
;
return ;
}
function VascoSettingGroup({ title, children, style = {} }) {
return
{title}
{React.Children.map(children, (child, index) =>
{index > 0 && }
{child}
)}
;
}
function VascoToggle({ on, disabled = false }) {
return ;
}
function VascoReplaceCloseAction(node, onClose, requestClose) {
return React.Children.map(node, (child) => {
if (!React.isValidElement(child)) return child;
const nextProps = {};
if (typeof onClose === 'function' && child.props.onClick === onClose) {
nextProps.onClick = requestClose;
}
if (typeof onClose === 'function' && child.props.onClose === onClose) {
nextProps.onClose = requestClose;
}
if (child.props.children !== undefined) {
nextProps.children = VascoReplaceCloseAction(child.props.children, onClose, requestClose);
}
return Object.keys(nextProps).length ? React.cloneElement(child, nextProps) : child;
});
}
const VASCO_OVERLAY_SCROLL_LOCK = {
count: 0,
bodyOverflow: '',
bodyOverscroll: '',
bodyPaddingRight: '',
rootOverflow: '',
rootOverscroll: '',
};
function VascoUseOverlayScrollLock(active) {
React.useEffect(() => {
if (!active) return undefined;
const body = document.body;
const root = document.documentElement;
if (VASCO_OVERLAY_SCROLL_LOCK.count === 0) {
VASCO_OVERLAY_SCROLL_LOCK.bodyOverflow = body.style.overflow;
VASCO_OVERLAY_SCROLL_LOCK.bodyOverscroll = body.style.overscrollBehavior;
VASCO_OVERLAY_SCROLL_LOCK.bodyPaddingRight = body.style.paddingRight;
VASCO_OVERLAY_SCROLL_LOCK.rootOverflow = root.style.overflow;
VASCO_OVERLAY_SCROLL_LOCK.rootOverscroll = root.style.overscrollBehavior;
const scrollbarWidth = Math.max(0, window.innerWidth - root.clientWidth);
body.style.overflow = 'hidden';
body.style.overscrollBehavior = 'none';
root.style.overflow = 'hidden';
root.style.overscrollBehavior = 'none';
if (scrollbarWidth > 0) body.style.paddingRight = `${scrollbarWidth}px`;
}
VASCO_OVERLAY_SCROLL_LOCK.count += 1;
return () => {
VASCO_OVERLAY_SCROLL_LOCK.count = Math.max(0, VASCO_OVERLAY_SCROLL_LOCK.count - 1);
if (VASCO_OVERLAY_SCROLL_LOCK.count !== 0) return;
body.style.overflow = VASCO_OVERLAY_SCROLL_LOCK.bodyOverflow;
body.style.overscrollBehavior = VASCO_OVERLAY_SCROLL_LOCK.bodyOverscroll;
body.style.paddingRight = VASCO_OVERLAY_SCROLL_LOCK.bodyPaddingRight;
root.style.overflow = VASCO_OVERLAY_SCROLL_LOCK.rootOverflow;
root.style.overscrollBehavior = VASCO_OVERLAY_SCROLL_LOCK.rootOverscroll;
};
}, [active]);
}
// Une feuille couvre l'écran qui l'a ouverte, et la feuille basse rejoint même
// une autre racine du document (`#vasco-stage`). Les panneaux qui se ferment
// sur un appui extérieur écoutent, eux, `document` : sans retenue, le premier
// toucher dans la feuille leur paraîtrait venir du dehors, et le panneau
// emporterait la feuille en se fermant. L'overlay retient donc les appuis nés
// chez lui – panneau et fond compris – avant qu'ils n'atteignent `document`.
// Les fermetures extérieures propres au socle restent possibles : elles
// écoutent `document` en phase de capture, qui précède cette retenue.
const vascoHoldOverlayPointer = (event) => event.nativeEvent.stopPropagation();
const VASCO_OVERLAY_POINTER_GUARD = Object.freeze({
onMouseDown: vascoHoldOverlayPointer,
onPointerDown: vascoHoldOverlayPointer,
onTouchStart: vascoHoldOverlayPointer,
});
// Les nouveaux consommateurs gardent la feuille montée et pilotent `open`.
// L'absence de `open` conserve les anciens AcSheet : leur fermeture extérieure,
// leur croix et leur bouton Annuler sont retardés jusqu'à la fin du mouvement.
function VascoFormSheet({ open = undefined, title, subtitle, onClose, children, footer,
busy = false, maxHeight = '82%', zIndex = VASCO_UI_TOKENS.layer.overlay,
showClose = true }) {
const controlled = typeof open === 'boolean';
const [legacyOpen, setLegacyOpen] = React.useState(true);
const effectiveOpen = controlled ? open : legacyOpen;
const [mounted, setMounted] = React.useState(Boolean(effectiveOpen));
const [visible, setVisible] = React.useState(false);
const legacyCloseTimer = React.useRef(null);
const requestClose = () => {
if (busy) return;
if (controlled) {
onClose();
return;
}
if (!legacyOpen || legacyCloseTimer.current !== null) return;
setLegacyOpen(false);
legacyCloseTimer.current = window.setTimeout(() => {
legacyCloseTimer.current = null;
onClose();
}, VASCO_UI_TOKENS.motion.formExit);
};
React.useEffect(() => {
let mountFrame = null;
let revealFrame = null;
let hideTimer = null;
if (effectiveOpen) {
setMounted(true);
mountFrame = window.requestAnimationFrame(() => {
revealFrame = window.requestAnimationFrame(() => setVisible(true));
});
} else {
setVisible(false);
hideTimer = window.setTimeout(
() => setMounted(false),
VASCO_UI_TOKENS.motion.formExit,
);
}
return () => {
if (mountFrame !== null) window.cancelAnimationFrame(mountFrame);
if (revealFrame !== null) window.cancelAnimationFrame(revealFrame);
if (hideTimer !== null) window.clearTimeout(hideTimer);
};
}, [effectiveOpen]);
React.useEffect(() => {
if (!effectiveOpen || busy) return undefined;
const closeOnEscape = (event) => {
if (event.key === 'Escape') requestClose();
};
document.addEventListener('keydown', closeOnEscape);
return () => document.removeEventListener('keydown', closeOnEscape);
}, [effectiveOpen, busy, onClose]);
React.useEffect(() => () => {
if (legacyCloseTimer.current !== null) window.clearTimeout(legacyCloseTimer.current);
}, []);
if (!mounted) return null;
const duration = visible
? VASCO_UI_TOKENS.motion.formEnter
: VASCO_UI_TOKENS.motion.formExit;
const renderedFooter = VascoReplaceCloseAction(footer, onClose, requestClose);
return
event.stopPropagation()} style={{
position: 'relative', flex: '1 1 auto', minWidth: 0, minHeight: 0,
transform: visible ? 'translateY(0) scale(1)' : 'translateY(8px) scale(.975)',
opacity: visible ? 1 : 0,
transition: `opacity ${duration}ms ${VASCO_UI_TOKENS.motion.form}, transform ${duration}ms ${VASCO_UI_TOKENS.motion.form}`,
background: VASCO_UI_TOKENS.color.surface,
borderRadius: VASCO_UI_TOKENS.radius.sheet, padding: '20px 20px 18px',
boxShadow: VASCO_UI_TOKENS.shadow.large, maxHeight: '100%',
display: 'flex', flexDirection: 'column',
}}>
{title}
{subtitle &&
{subtitle}
}
{showClose &&
}
{children}
{renderedFooter &&
{renderedFooter}
}
;
}
// Feuille mobile ancrée en bas. Les nouveaux écrans pilotent `open`. L'absence
// de cette propriété garde temporairement compatibles les anciennes feuilles
// conditionnelles tout en leur donnant une vraie animation de sortie.
function VascoBottomSheet({ open = undefined, title, subtitle, onClose, children, footer,
busy = false, dismissOnBackdrop = true, height = 'auto', maxHeight = '82%', headerAside = null,
showClose = true, contentRef = null, contentStyle = {} }) {
const controlled = typeof open === 'boolean';
const [legacyOpen, setLegacyOpen] = React.useState(true);
const effectiveOpen = controlled ? open : legacyOpen;
const [mounted, setMounted] = React.useState(Boolean(effectiveOpen));
const [visible, setVisible] = React.useState(false);
const legacyCloseTimer = React.useRef(null);
const panelRef = React.useRef(null);
VascoUseOverlayScrollLock(effectiveOpen);
const requestClose = () => {
if (busy) return;
if (controlled) {
onClose();
return;
}
if (!legacyOpen || legacyCloseTimer.current !== null) return;
setLegacyOpen(false);
legacyCloseTimer.current = window.setTimeout(() => {
legacyCloseTimer.current = null;
onClose();
}, VASCO_UI_TOKENS.motion.sheetExit);
};
React.useEffect(() => {
let mountFrame = null;
let revealFrame = null;
let hideTimer = null;
if (effectiveOpen) {
setMounted(true);
// Deux images distinctes garantissent que la position basse est peinte
// avant le début de la transition, même lorsque React groupe ses mises à jour.
mountFrame = window.requestAnimationFrame(() => {
revealFrame = window.requestAnimationFrame(() => {
setVisible(true);
panelRef.current?.focus({ preventScroll: true });
});
});
} else {
setVisible(false);
hideTimer = window.setTimeout(
() => setMounted(false),
VASCO_UI_TOKENS.motion.sheetExit,
);
}
return () => {
if (mountFrame !== null) window.cancelAnimationFrame(mountFrame);
if (revealFrame !== null) window.cancelAnimationFrame(revealFrame);
if (hideTimer !== null) window.clearTimeout(hideTimer);
};
}, [effectiveOpen]);
React.useEffect(() => {
if (!effectiveOpen || busy) return undefined;
const closeOnEscape = (event) => {
if (event.key === 'Escape' && dismissOnBackdrop) requestClose();
};
document.addEventListener('keydown', closeOnEscape);
return () => document.removeEventListener('keydown', closeOnEscape);
}, [effectiveOpen, busy, onClose]);
React.useEffect(() => () => {
if (legacyCloseTimer.current !== null) window.clearTimeout(legacyCloseTimer.current);
}, []);
if (!mounted) return null;
const renderedFooter = VascoReplaceCloseAction(footer, onClose, requestClose);
const overlayRoot = document.getElementById('vasco-stage') || document.body;
const sheet =
{title}
{subtitle &&
{subtitle}
}
{headerAside}
{showClose &&
}
{children}
{renderedFooter &&
{renderedFooter}
}
;
return ReactDOM.createPortal(sheet, overlayRoot);
}
// Écran de détail plein format pour les parcours longs (documents, historique,
// assistants). Il centralise le rôle modal, Échap et le mouvement ; son contenu
// peut garder un en-tête métier pendant la migration.
function VascoDetailSheet({ open = undefined, title = null, subtitle = null, onClose,
children, footer = null, busy = false, background = VASCO_UI_TOKENS.color.canvas,
contentStyle = {}, zIndex = VASCO_UI_TOKENS.layer.overlay, onEscape = null,
ariaLabel = null, motion = 'lift' }) {
const controlled = typeof open === 'boolean';
const [legacyOpen, setLegacyOpen] = React.useState(true);
const effectiveOpen = controlled ? open : legacyOpen;
const [mounted, setMounted] = React.useState(Boolean(effectiveOpen));
const [visible, setVisible] = React.useState(false);
const legacyCloseTimer = React.useRef(null);
const slidesFromLeft = motion === 'slideFromLeft';
const enterDuration = slidesFromLeft
? VASCO_UI_TOKENS.motion.sheetEnter
: VASCO_UI_TOKENS.motion.formEnter;
const exitDuration = slidesFromLeft
? VASCO_UI_TOKENS.motion.sheetExit
: VASCO_UI_TOKENS.motion.formExit;
const easing = slidesFromLeft
? VASCO_UI_TOKENS.motion.sheet
: VASCO_UI_TOKENS.motion.form;
const requestClose = () => {
if (busy) return;
if (controlled) {
onClose();
return;
}
if (!legacyOpen || legacyCloseTimer.current !== null) return;
setLegacyOpen(false);
legacyCloseTimer.current = window.setTimeout(() => {
legacyCloseTimer.current = null;
onClose();
}, exitDuration);
};
React.useEffect(() => {
let mountFrame = null;
let revealFrame = null;
let hideTimer = null;
if (effectiveOpen) {
setMounted(true);
mountFrame = window.requestAnimationFrame(() => {
revealFrame = window.requestAnimationFrame(() => setVisible(true));
});
} else {
setVisible(false);
hideTimer = window.setTimeout(() => setMounted(false), exitDuration);
}
return () => {
if (mountFrame !== null) window.cancelAnimationFrame(mountFrame);
if (revealFrame !== null) window.cancelAnimationFrame(revealFrame);
if (hideTimer !== null) window.clearTimeout(hideTimer);
};
}, [effectiveOpen, exitDuration]);
React.useEffect(() => {
if (!effectiveOpen || busy) return undefined;
const closeOnEscape = (event) => {
if (event.key === 'Escape') {
if (onEscape) onEscape(requestClose);
else requestClose();
}
};
document.addEventListener('keydown', closeOnEscape);
return () => document.removeEventListener('keydown', closeOnEscape);
}, [effectiveOpen, busy, onClose, onEscape]);
React.useEffect(() => () => {
if (legacyCloseTimer.current !== null) window.clearTimeout(legacyCloseTimer.current);
}, []);
if (!mounted) return null;
const duration = visible ? enterDuration : exitDuration;
const hiddenTransform = slidesFromLeft ? 'translateX(-100%)' : 'translateY(8px)';
const renderedChildren = VascoReplaceCloseAction(children, onClose, requestClose);
const renderedFooter = VascoReplaceCloseAction(footer, onClose, requestClose);
return
{title && {subtitle} : null} />}
{renderedChildren}
{renderedFooter &&
{renderedFooter}
}
;
}
function VascoConfirmDialog({ open = undefined, title, message, confirmLabel = 'Confirmer',
cancelLabel = 'Annuler', danger = false, busy = false, onClose, onConfirm, children = null }) {
return
{cancelLabel}
{confirmLabel}
>}>
{message && {message}
}
{children}
;
}
// La saisie libre du socle : une ligne, plusieurs lignes, ou un nombre. Jusqu'ici
// chaque écran redessinait son `` ou son `