(widgets) import widgets code from Messages and setup Docker workflow (#33)

This adds Gaufre v2 with source, documentation, examples and built artefacts.
Also includes the feedback widget from Messages.
This commit is contained in:
Sylvain Zimmer
2025-11-19 15:18:21 +01:00
committed by GitHub
parent 3b2f083d3f
commit 720ee9f4f0
53 changed files with 5375 additions and 4 deletions

View File

@@ -27,7 +27,7 @@ This folder is meant to generate the `@gouvfr-lasuite/integration` npm package.
It's a vite app.
To start, `npm install` a first time and copy the example env file: `cp .env.example .env`. Make sure the API env var targets a running API. If you don't want to use the production one, you can run one locally easily: the API is exposed via the `/website` server, go check the README there.
To start, `npm install` a first time. Make sure the API env var targets a running API. If you don't want to use the production one, you can run one locally easily: the API is exposed via the `/website` server, go check the README there.
Then, run the local dev server with `npm run dev`.

View File

@@ -0,0 +1,10 @@
FROM node:22-slim AS widgets-deps
WORKDIR /home/widgets/
RUN npm install -g npm@11.3.0 && npm cache clean -f
ARG DOCKER_USER
USER ${DOCKER_USER}
ENV npm_config_cache=/tmp/npm-cache

31
packages/widgets/build.js Normal file
View File

@@ -0,0 +1,31 @@
import { build } from 'vite'
import { readdirSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const widgetsDir = join(__dirname, 'src', 'widgets')
function discoverWidgets() {
return readdirSync(widgetsDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
}
// Run an independent build for each widget
for (const widget of discoverWidgets()) {
await build({
build: {
emptyOutDir: false,
outDir: join(process.env.WIDGETS_OUTPUT_DIR || "", "dist"),
rollupOptions: {
input: join(widgetsDir, widget, 'main.ts'),
output: {
entryFileNames: widget + '.js',
format: 'iife'
}
}
},
})
}

View File

@@ -0,0 +1,10 @@
// @ts-check
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
);

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Widgets demo</title>
</head>
<body>
<h1>Widgets demo</h1>
<ul>
<li><a href="widgets-demo/feedback.html">Feedback</a></li>
<li><a href="widgets-demo/loader.html">Loader</a></li>
<li><a href="widgets-demo/lagaufre.html">La Gaufre</a></li>
</ul>
</body>
</html>

2732
packages/widgets/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
{
"name": "@gouvfr-lasuite/widgets",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 8931",
"build": "tsc && node build.js",
"preview": "vite preview --host 0.0.0.0 --port 8931",
"lint": "prettier --write --print-width=120 src && eslint src && tsc --noEmit"
},
"devDependencies": {
"typescript": "5.9.2",
"vite": "7.1.7",
"eslint": "9.36.0",
"typescript-eslint": "8.45.0",
"prettier": "3.6.2"
}
}

View File

@@ -0,0 +1,31 @@
/* eslint @typescript-eslint/no-explicit-any:0 */
const NAMESPACE = `lasuite-widget`;
export const triggerEvent = (
widgetName: string,
eventName: string,
detail?: Record<string, any>,
root?: Window | Document | HTMLElement | null,
) => {
return (root || document).dispatchEvent(
new CustomEvent(`${NAMESPACE}-${widgetName}-${eventName}`, detail ? { detail } : undefined),
);
};
export const listenEvent = (
widgetName: string,
eventName: string,
root: Window | Document | HTMLElement | null,
once: boolean,
callback: (data: any) => void,
) => {
const cb = (e: any) => callback(e.detail);
const eventFullName = widgetName ? `${NAMESPACE}-${widgetName}-${eventName}` : eventName;
(root || document).addEventListener(eventFullName, cb, once ? { once: true } : undefined);
return () =>
(root || document).removeEventListener(
eventFullName,
cb,
once ? ({ once: true } as EventListenerOptions) : undefined,
);
};

View File

@@ -0,0 +1,43 @@
const isVisible = (element: HTMLElement) => {
return element.offsetWidth > 0 && element.offsetHeight > 0;
};
export const trapFocus = (shadowRoot: ShadowRoot, container: HTMLElement, selector: string) => {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusable = Array.from(container.querySelectorAll(selector)).filter((element) =>
isVisible(element as HTMLElement),
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && shadowRoot.activeElement === first) {
e.preventDefault();
(last as HTMLElement).focus();
} else if (!e.shiftKey && shadowRoot.activeElement === last) {
e.preventDefault();
(first as HTMLElement).focus();
}
};
container.addEventListener("keydown", handleKeydown);
return () => {
container.removeEventListener("keydown", handleKeydown);
};
};
export const trapEscape = (cb: () => void) => {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.preventDefault();
cb();
};
document.addEventListener("keydown", handleKeydown);
return () => {
document.removeEventListener("keydown", handleKeydown);
};
};

View File

@@ -0,0 +1,70 @@
import { triggerEvent } from "./events";
type WidgetEvent = [string, string, Record<string, unknown> | undefined];
type EventArray = Array<WidgetEvent> & { _loaded?: Record<string, number> };
declare global {
var _lasuite_widget: EventArray;
}
// This could have been an enum but we want to support erasableSyntaxOnly TS settings
export const STATE_NOT_LOADED = 0;
export const STATE_LOADING = 1;
export const STATE_LOADED = 2;
export const getLoaded = (widgetName: string) => {
return window._lasuite_widget?._loaded?.[widgetName];
};
export const setLoaded = (widgetName: string, status: number) => {
if (!window._lasuite_widget?._loaded) return;
window._lasuite_widget._loaded[widgetName] = status;
};
// Replace the push method of the _lasuite_widget array used for communication between the widget and the page
export const installHook = (widgetName: string) => {
if (!window._lasuite_widget) {
window._lasuite_widget = [] as EventArray;
}
const W = window._lasuite_widget;
// Keep track of the loaded state of each widget
if (!W._loaded) {
W._loaded = {} as Record<string, number>;
}
if (getLoaded(widgetName) !== STATE_LOADED) {
// Replace the push method of the _lasuite_widget array used for communication between the widget and the page
W.push = ((...elts: WidgetEvent[]): number => {
for (const elt of elts) {
// If the target widget is loaded, fire the event
if (getLoaded(elt[0]) === STATE_LOADED) {
triggerEvent(elt[0], elt[1], elt[2]);
} else {
W[W.length] = elt;
}
}
return W.length;
}) as typeof Array.prototype.push;
setLoaded(widgetName, STATE_LOADED);
// Empty the existing array and re-push all events that were received before the hook was installed
for (const evt of W.splice(0, W.length)) {
W.push(evt);
}
}
// Finally, fire an event to signal that we are loaded
triggerEvent(widgetName, "loaded");
};
// Loads another widget from the same directory
export const injectScript = (url: string, type: string = "") => {
const newScript = document.createElement("script");
newScript.src = url;
newScript.type = type;
newScript.defer = true;
document.body.appendChild(newScript);
};

View File

@@ -0,0 +1,30 @@
// Shared utility for creating shadow DOM widgets
export function createShadowWidget(widgetName: string, htmlContent: string, cssContent: string): HTMLDivElement {
const id = `lasuite-widget-${widgetName}-shadow`;
// Check if widget already exists
const existingWidget = document.getElementById(id);
if (existingWidget) {
existingWidget.remove();
}
// Create container element
const container = document.createElement("div");
container.id = id;
// Create shadow root
const shadow = container.attachShadow({ mode: "open" });
// Create style element for scoped CSS
const style = document.createElement("style");
style.textContent = cssContent;
// Create content element
const content = document.createElement("div");
content.innerHTML = htmlContent;
// Append style and content to shadow DOM
shadow.appendChild(style);
shadow.appendChild(content);
return container;
}

1
packages/widgets/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,204 @@
import styles from "./styles.css?inline";
import { createShadowWidget } from "../../shared/shadow-dom";
import { installHook } from "../../shared/script";
import { listenEvent, triggerEvent } from "../../shared/events";
import { trapFocus, trapEscape } from "../../shared/focus";
const widgetName = "feedback";
type ConfigData = {
title?: string;
placeholder?: string;
emailPlaceholder?: string;
submitText?: string;
successText?: string;
successText2?: string;
closeLabel?: string;
submitUrl?: string;
};
type ConfigResponse = {
success?: boolean;
detail?: string;
captcha?: boolean;
config?: ConfigData;
};
type FeedbackWidgetArgs = {
title?: string;
placeholder?: string;
emailPlaceholder?: string;
submitText?: string;
successText?: string;
successText2?: string;
closeLabel?: string;
submitUrl?: string;
api: string;
channel: string;
email?: string;
bottomOffset?: number;
rightOffset?: number;
};
listenEvent(widgetName, "init", null, false, async (args: FeedbackWidgetArgs) => {
if (!args.api || !args.channel) {
console.error("Feedback widget requires an API URL and a channel ID");
return;
}
let configData: ConfigData | undefined;
try {
const config = await fetch(`${args.api}config/`, {
headers: {
"X-Channel-ID": args.channel,
},
});
const configResponse = (await config.json()) as ConfigResponse;
if (!configResponse.success) throw new Error(configResponse.detail || "Unknown error");
if (configResponse.captcha) throw new Error("Captcha is not supported yet");
configData = configResponse.config;
} catch (error) {
console.error("Error fetching config", error);
triggerEvent(widgetName, "closed");
return;
}
const title = args.title || configData?.title || "Feedback";
const placeholder = args.placeholder || configData?.placeholder || "Share your feedback...";
const emailPlaceholder = args.emailPlaceholder || configData?.emailPlaceholder || "Your email...";
const submitText = args.submitText || configData?.submitText || "Send Feedback";
const successText = args.successText || configData?.successText || "Thank you for your feedback!";
const successText2 = args.successText2 || configData?.successText2 || "";
const closeLabel = args.closeLabel || configData?.closeLabel || "Close the feedback widget";
/* prettier-ignore */
const htmlContent =
`<div id="wrapper">` +
`<div id="header">` +
`<h6 id="title"></h6>` +
`<button id="close">&times;</button>` +
`</div>` +
`<div id="content">` +
`<form>` +
`<textarea id="feedback-text" autocomplete="off" required></textarea>` +
`<input type="email" id="email" autocomplete="email" required>` +
`<button type="submit" id="submit"></button>` +
`</form>` +
`<div id="error" aria-live="polite" role="status"></div>` +
`<div aria-live="polite" role="status" id="success">` +
`<i aria-hidden="true">✔</i>` +
`<p id="success-text"></p>` +
`<p id="success-text2"></p>` +
`</div>` +
`</div>` +
`</div>`;
// Create shadow DOM widget
const shadowContainer = createShadowWidget(widgetName, htmlContent, styles);
const shadowRoot = shadowContainer.shadowRoot!;
const $ = shadowRoot.querySelector.bind(shadowRoot);
const wrapper = $<HTMLDivElement>("#wrapper")!;
const titleSpan = $<HTMLHeadingElement>("#title")!;
const submitBtn = $<HTMLButtonElement>("#submit")!;
const feedbackText = $<HTMLTextAreaElement>("#feedback-text")!;
const errorDiv = $<HTMLDivElement>("#error")!;
const closeBtn = $<HTMLButtonElement>("#close")!;
const emailInput = $<HTMLInputElement>("#email")!;
const form = $<HTMLFormElement>("form")!;
const successDiv = $<HTMLDivElement>("#success")!;
const successTextP = $<HTMLParagraphElement>("#success-text")!;
const successText2P = $<HTMLParagraphElement>("#success-text2")!;
wrapper.style.bottom = 20 + (args.bottomOffset || 0) + "px";
wrapper.style.right = 20 + (args.rightOffset || 0) + "px";
titleSpan.textContent = title;
feedbackText.placeholder = placeholder;
emailInput.placeholder = emailPlaceholder;
submitBtn.textContent = submitText;
closeBtn.setAttribute("aria-label", closeLabel);
if (args.email) {
emailInput.remove();
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorDiv.textContent = "";
const message = feedbackText.value.trim();
const email = args.email || emailInput.value.trim();
try {
if (!message) {
feedbackText.focus();
throw new Error("Missing value");
}
if (!email) {
emailInput.focus();
throw new Error("Missing value");
}
const ret = await fetch(configData?.submitUrl || `${args.api}deliver/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Channel-ID": args.channel,
},
body: JSON.stringify({ textBody: message, email }),
});
let retData;
try {
retData = await ret.json();
} catch {
throw new Error("Invalid response from server");
}
if (!retData.success) throw new Error(retData.detail || "Unknown error");
form.remove();
successDiv.style.display = "flex";
requestAnimationFrame(() => {
// This RAF call allows screen readers to register the aria-live area
successTextP.textContent = successText;
successText2P.textContent = successText2;
});
} catch (error) {
errorDiv.style.display = "block";
errorDiv.textContent = "⚠ " + (error instanceof Error ? error.message : "Unknown error");
}
});
let untrapFocus: (() => void) | null = null;
let untrapEscape: (() => void) | null = null;
let removeCloseListener: () => void = () => {};
const closeWidget = () => {
shadowRoot.host.remove();
if (untrapFocus) {
untrapFocus();
}
if (untrapEscape) {
untrapEscape();
}
if (removeCloseListener) {
removeCloseListener();
}
triggerEvent(widgetName, "closed");
};
closeBtn.addEventListener("click", closeWidget);
removeCloseListener = listenEvent(widgetName, "close", null, false, closeWidget);
document.body.appendChild(shadowContainer);
feedbackText.focus();
untrapFocus = trapFocus(shadowRoot, wrapper, "textarea,input,button");
untrapEscape = trapEscape(() => {
triggerEvent(widgetName, "close");
});
triggerEvent(widgetName, "opened");
});
installHook(widgetName);

View File

@@ -0,0 +1,158 @@
/* Widget styles */
#wrapper {
position: fixed;
z-index: 1000;
width: 350px;
height: 400px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
}
#header {
background: #000091;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 12px 12px 0 0;
}
h6 {
padding: 16px;
font-weight: 600;
font-size: 16px;
margin: 0;
}
#close {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 18px;
margin-right: 12px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
#content {
display: flex;
flex: 1;
flex-direction: column;
}
form {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
#email {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
font-family: inherit;
font-size: 14px;
}
#feedback-text {
flex: 1;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 12px;
font-family: inherit;
font-size: 14px;
resize: none;
}
#submit {
background: #000091;
color: white;
border: 2px solid #000091;
border-radius: 8px;
padding: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
#submit:hover,
#close:hover {
background: #1212ff;
}
#feedback-text:focus,
#email:focus,
#submit:focus {
outline: 2px solid #0a76f6;
border-color: white;
}
#close:focus {
outline: 2px solid white;
}
#status {
font-size: 14px;
font-weight: 500;
text-align: center;
}
#success {
display: none;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
#success i {
background: #27a658;
color: white;
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
margin-bottom: 20px;
font-style: normal;
}
#success p {
color: #0f4e27;
margin: 0;
font-size: 16px;
font-weight: 500;
margin-bottom: 30px;
}
#error {
color: #dc3545;
margin-bottom: 12px;
}
#error:empty {
margin: 0;
}
/* Responsive */
@media (max-width: 420px) {
#wrapper {
width: 100%;
right: 0px !important;
border-radius: 0;
}
}

View File

@@ -0,0 +1,367 @@
import styles from "./styles.css?inline";
import { createShadowWidget } from "../../shared/shadow-dom";
import { installHook } from "../../shared/script";
import { listenEvent, triggerEvent } from "../../shared/events";
import { trapFocus, trapEscape } from "../../shared/focus";
const widgetName = "lagaufre";
type Service = {
name: string;
url: string;
maturity?: string;
logo?: string;
};
type Organization = {
name: string;
type: string;
siret: string;
};
type ServicesResponse = {
organization?: Organization;
services: Service[];
error?: unknown;
};
type GaufreWidgetArgs = {
api?: string;
position?: string | (() => Record<string, number | string>);
top?: number;
bottom?: number;
left?: number;
right?: number;
data?: ServicesResponse;
fontFamily?: string;
background?: string;
headerLogo?: string;
headerUrl?: string;
open?: boolean;
label?: string;
closeLabel?: string;
headerLabel?: string;
loadingText?: string;
newWindowLabelSuffix?: string;
showFooter?: boolean;
dialogElement?: HTMLElement;
buttonElement?: HTMLElement;
};
let loaded = false;
// Initialize widget (load data and prepare shadow DOM)
listenEvent(widgetName, "init", null, false, async (args: GaufreWidgetArgs) => {
if (!args.api && !args.data) {
console.error("Missing API URL");
return;
}
if (loaded) {
triggerEvent(widgetName, "destroy");
await new Promise((resolve) => setTimeout(resolve, 10));
}
const listeners: (() => void)[] = [];
let isVisible = false;
// https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
/* prettier-ignore */
const htmlContent =
`<div id="wrapper" role="dialog" aria-modal="true" tabindex="-1">` +
((args.headerLogo && args.headerUrl) ? (
`<div id="header">` +
`<a href="${args.headerUrl}" target="_blank">` +
`<img src="${args.headerLogo}" id="header-logo">` +
`</a>` +
`<button type="button" id="close">&times;</button>` +
`</div>`
) : "") +
`<div id="content">` +
`<div id="loading">Loading...</div>` +
`<ul role="list" id="services-grid" style="display: none;"></ul>` +
`<div id="error" style="display: none;"></div>` +
`</div>` +
(args.showFooter ? `<div id="footer">` +
`<button id="ok-button">OK</button>` +
`</div>` : "") +
`</div>`;
// Create shadow DOM widget
const shadowContainer = createShadowWidget(widgetName, htmlContent, styles);
const shadowRoot = shadowContainer.shadowRoot!;
const wrapper = shadowRoot.querySelector<HTMLDivElement>("#wrapper")!;
const loadingDiv = shadowRoot.querySelector<HTMLDivElement>("#loading")!;
const servicesGrid = shadowRoot.querySelector<HTMLDivElement>("#services-grid")!;
const errorDiv = shadowRoot.querySelector<HTMLDivElement>("#error")!;
const closeBtn = shadowRoot.querySelector<HTMLButtonElement>("#close");
const okBtn = shadowRoot.querySelector<HTMLButtonElement>("#ok-button")!;
const headerLogo = shadowRoot.querySelector<HTMLImageElement>("#header-logo");
// Configure dynamic properties
const configure = (newArgs: GaufreWidgetArgs) => {
const directions = ["top", "bottom", "left", "right"] as const;
const applyPos = (obj: Record<string, number | string>) => {
directions.forEach((prop) => {
wrapper.style[prop] = typeof obj[prop] === "number" ? `${obj[prop]}px` : "unset";
});
};
if (!directions.every((d) => newArgs[d] === undefined)) {
applyPos(newArgs as Record<string, number | string>);
}
// Positioning parameters
if (newArgs.position) {
if (typeof newArgs.position === "function") {
const pos = newArgs.position();
wrapper.style.position = pos.position as string;
applyPos(pos);
} else {
wrapper.style.position = newArgs.position;
}
}
// Apply font family (inherit from parent or use provided)
if (newArgs.fontFamily) {
wrapper.style.fontFamily = newArgs.fontFamily;
}
// Apply background gradient if requested
if (newArgs.background) {
wrapper.style.background = newArgs.background;
}
// Apply texts
const label = newArgs.label || "Services";
const closeLabel = newArgs.closeLabel || "Close";
loadingDiv.textContent = newArgs.loadingText || "Loading…";
wrapper.setAttribute("aria-label", label);
if (closeBtn) {
closeBtn.setAttribute("aria-label", closeLabel);
}
if (headerLogo) {
headerLogo.alt = (newArgs.headerLabel || "About LaSuite") + (newArgs.newWindowLabelSuffix || "");
}
};
configure(args);
listeners.push(
listenEvent("", "resize", window, false, () => {
configure(args);
}),
);
// Initially hide the widget
wrapper.style.display = "none";
const showError = (message: string) => {
loadingDiv.style.display = "none";
servicesGrid.style.display = "none";
errorDiv.style.display = "block";
errorDiv.textContent = message;
};
const renderServices = (data: ServicesResponse) => {
// Clear previous content
servicesGrid.innerHTML = "";
data.services.forEach((service) => {
if (!service.logo) return;
if (service.maturity == "stable") delete service.maturity;
const serviceCard = document.createElement("li");
serviceCard.className = `service-card`;
/* prettier-ignore */
serviceCard.innerHTML =
`<a target="_blank">` +
`<img alt="" class="service-logo" onerror="this.style.display='none'">` +
`<div class="service-info">` +
`<div class="service-name"></div>` +
`</div>` +
`</a>`;
const anchor = serviceCard.querySelector<HTMLAnchorElement>("a")!;
const img = serviceCard.querySelector<HTMLImageElement>("img")!;
const serviceName = serviceCard.querySelector<HTMLDivElement>(".service-name")!;
if (service.maturity) {
const maturityBadge = document.createElement("div");
maturityBadge.className = "maturity-badge";
maturityBadge.textContent = service.maturity;
anchor.insertBefore(maturityBadge, img.nextSibling);
}
anchor.setAttribute(
"aria-label",
service.name + (service.maturity ? ` (${service.maturity})` : "") + (args.newWindowLabelSuffix || ""),
);
anchor.href = service.url;
img.src = service.logo;
serviceName.textContent = service.name;
servicesGrid.appendChild(serviceCard);
});
loadingDiv.style.display = "none";
errorDiv.style.display = "none";
servicesGrid.style.display = "grid";
};
// Load data
if (args.data) {
renderServices(args.data);
} else {
// Fetch services from API
try {
const response = await fetch(args.api!, {
method: "GET",
});
const data = (await response.json()) as ServicesResponse;
if (data.error) {
showError(`Error: ${JSON.stringify(data.error)}`);
} else if (data.services && data.services.length > 0) {
renderServices(data);
} else {
showError("No services found");
}
} catch (error) {
showError(`Failed to load services: ${error instanceof Error ? error.message : "Unknown error"}`);
}
}
const handleClickOutside = (event: MouseEvent) => {
if (args.dialogElement) {
return;
}
if (!shadowContainer.contains(event.target as Node)) {
triggerEvent(widgetName, "close");
}
};
let untrapFocus: (() => void) | null = null;
let untrapEscape: (() => void) | null = null;
// Open widget (show the prepared shadow DOM)
listeners.push(
listenEvent(widgetName, "open", null, false, () => {
wrapper.style.display = "block";
// Add click outside listener after a short delay to prevent immediate closing or double-clicks.
setTimeout(() => {
isVisible = true;
document.addEventListener("click", handleClickOutside);
wrapper.focus();
}, 200);
untrapFocus = trapFocus(shadowRoot, wrapper, "a,button");
if (!args.dialogElement) {
untrapEscape = trapEscape(() => {
triggerEvent(widgetName, "close");
});
}
if (args.buttonElement) {
args.buttonElement.setAttribute("aria-expanded", "true");
}
triggerEvent(widgetName, "opened");
}),
);
// Close widget (hide the shadow DOM)
listeners.push(
listenEvent(widgetName, "close", null, false, () => {
if (untrapFocus) {
untrapFocus();
}
if (untrapEscape) {
untrapEscape();
}
if (!isVisible) {
return; // Already closed
}
wrapper.style.display = "none";
isVisible = false;
// Return the focus to the button that opened the widget if any
if (args.buttonElement) {
args.buttonElement.focus();
args.buttonElement.setAttribute("aria-expanded", "false");
}
// Remove click outside listener
document.removeEventListener("click", handleClickOutside);
triggerEvent(widgetName, "closed");
}),
);
// Toggle widget visibility
listeners.push(
listenEvent(widgetName, "toggle", null, false, () => {
if (isVisible) {
triggerEvent(widgetName, "close");
} else {
triggerEvent(widgetName, "open");
}
}),
);
listeners.push(listenEvent(widgetName, "configure", null, false, configure));
// Close button click handlers
if (okBtn) {
okBtn.addEventListener("click", () => {
triggerEvent(widgetName, "close");
});
}
if (closeBtn) {
closeBtn.addEventListener("click", () => {
triggerEvent(widgetName, "close");
});
}
if (args.buttonElement) {
listeners.push(
listenEvent("", "click", args.buttonElement, false, () => {
triggerEvent(widgetName, "toggle");
}),
);
}
// Add to DOM but keep hidden
if (args.dialogElement) {
args.dialogElement.appendChild(shadowContainer);
} else {
wrapper.className = "wrapper-dialog";
document.body.appendChild(shadowContainer);
}
listenEvent(widgetName, "destroy", null, true, () => {
triggerEvent(widgetName, "close");
loaded = false;
shadowContainer.remove();
// Unlisten all events
listeners.forEach((listener) => listener());
});
loaded = true;
triggerEvent(widgetName, "initialized");
if (args.open) {
triggerEvent(widgetName, "open");
}
});
installHook(widgetName);

View File

@@ -0,0 +1,271 @@
/* La Gaufre Widget styles */
#wrapper {
display: flex;
flex-direction: column;
font-family: inherit;
}
.wrapper-dialog {
position: fixed;
top: 60px;
right: 60px;
z-index: 1000;
width: 340px;
max-height: 480px;
background: white;
border-radius: 0.25rem;
box-shadow: 0 0px 6px rgba(0, 0, 145, 0.1);
border: 1px solid #e5e5e5;
overflow: hidden;
}
#header {
display: flex;
justify-content: center;
align-items: center;
padding: 4px 16px;
background: transparent;
border-bottom: 1px solid #dfe2ea;
min-height: 48px;
position: relative;
}
#header-logo {
height: 25px;
width: auto;
object-fit: contain;
display: block;
margin: 0 auto;
}
#close {
position: absolute;
right: 16px;
}
#close {
background: none;
border: none;
color: #64748b;
cursor: pointer;
font-size: 36px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
}
#close:hover {
background-color: #f0f0f0;
}
#close:focus {
outline: 2px solid #0a76f6;
}
#footer {
display: flex;
padding: 16px;
background: transparent;
border-top: 1px solid #dfe2ea;
justify-content: flex-end;
}
#ok-button {
background: #3e5de7;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
min-width: 60px;
}
#ok-button:hover {
background: #1d4ed8;
}
#ok-button:focus {
outline: 2px solid #0a76f6;
}
#content {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
}
/* Loading state */
#loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #666;
font-size: 14px;
}
/* Error state */
#error {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: #dc3545;
font-size: 14px;
text-align: center;
}
/* Services grid */
#services-grid {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
justify-items: center;
list-style: none;
padding: 0;
margin: 0;
}
/* Service cards */
.service-card {
background: transparent;
text-align: center;
transition: all 0.2s ease;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
list-style: none;
padding: 0;
margin: 0;
}
.service-card:hover {
background-color: #eef1f4;
border-radius: 6px;
}
/* Service logo container */
.service-card a {
position: relative;
padding: 8px;
width: 100%;
text-decoration: none;
display: block;
}
.service-logo {
width: 42px;
height: 42px;
object-fit: contain;
}
.maturity-badge {
position: absolute;
top: 40px;
left: 50%;
transform: translateX(-50%);
background: #eef1f4;
color: #2845c1;
border-radius: 12px;
padding: 2px 4px;
font-size: 9px;
line-height: 9px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.3px;
white-space: nowrap;
}
/* Service info */
.service-info {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding-top: 4px;
}
.service-name {
font-weight: 600;
font-size: 14px;
color: #1e40af;
margin-bottom: 2px;
line-height: 1.2;
text-align: center;
}
/* Responsive design */
@media (max-width: 480px) {
#wrapper {
width: 100%;
right: 0 !important;
left: 0 !important;
border-radius: 0;
bottom: 0 !important;
top: 0 !important;
max-height: 100vh;
position: fixed !important;
}
#header {
height: 40px;
display: flex;
}
#header-logo {
height: 35px;
}
#footer {
display: flex;
}
#services-grid {
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.service-card {
width: 70px;
padding: 6px;
}
.service-logo,
.service-logo-placeholder {
width: 40px;
height: 40px;
}
.service-name {
font-size: 11px;
}
}
/* Scrollbar styling */
#content::-webkit-scrollbar {
width: 4px;
}
#content::-webkit-scrollbar-track {
background: transparent;
}
#content::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 2px;
}
#content::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}

View File

@@ -0,0 +1 @@
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" transform="matrix(-1, 0, 0, 1, 0, 0)"><g stroke-width="0"></g><g stroke-linecap="round" stroke-linejoin="round"></g><g><path d="M7 9H17M7 13H17M21 20L17.6757 18.3378C17.4237 18.2118 17.2977 18.1488 17.1656 18.1044C17.0484 18.065 16.9277 18.0365 16.8052 18.0193C16.6672 18 16.5263 18 16.2446 18H6.2C5.07989 18 4.51984 18 4.09202 17.782C3.71569 17.5903 3.40973 17.2843 3.21799 16.908C3 16.4802 3 15.9201 3 14.8V7.2C3 6.07989 3 5.51984 3.21799 5.09202C3.40973 4.71569 3.71569 4.40973 4.09202 4.21799C4.51984 4 5.0799 4 6.2 4H17.8C18.9201 4 19.4802 4 19.908 4.21799C20.2843 4.40973 20.5903 4.71569 20.782 5.09202C21 5.51984 21 6.0799 21 7.2V20Z" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>

After

Width:  |  Height:  |  Size: 832 B

View File

@@ -0,0 +1,86 @@
import styles from "./styles.css?inline";
import { createShadowWidget } from "../../shared/shadow-dom";
import icon from "./icon.svg?raw";
import { injectScript, installHook, getLoaded, setLoaded, STATE_LOADED, STATE_LOADING } from "../../shared/script";
import { triggerEvent, listenEvent } from "../../shared/events";
const widgetName = "loader";
type LoaderWidgetArgs = {
widget: string;
closeLabel: string;
label: string;
params: Record<string, unknown>;
script: string;
scriptType: string;
};
// The init event is sent from the embedding code
listenEvent(widgetName, "init", null, false, (args: LoaderWidgetArgs) => {
const targetWidget = args.widget || "feedback";
const htmlContent = `<div><button type="button">${icon}</button></div>`;
// Create shadow DOM widget
const shadowContainer = createShadowWidget(widgetName, htmlContent, styles);
const shadowRoot = shadowContainer.shadowRoot!;
const btn = shadowRoot.querySelector<HTMLButtonElement>("button")!;
const ariaOpen = () => {
btn.setAttribute("aria-label", String(args.closeLabel || "Close widget"));
btn.setAttribute("aria-expanded", "true");
// TODO: How could we set the aria-controls attribute too, given that we
// have no id for the widget? Should we ask for it via an event?
};
const ariaClose = () => {
btn.setAttribute("aria-label", String(args.label || "Load widget"));
btn.setAttribute("aria-expanded", "false");
};
ariaClose();
listenEvent(targetWidget, "closed", null, false, () => {
btn.classList.remove("opened");
ariaClose();
});
listenEvent(targetWidget, "opened", null, false, () => {
btn.classList.add("opened");
ariaOpen();
});
btn.addEventListener("click", () => {
if (btn.classList.contains("opened")) {
triggerEvent(targetWidget, "close");
return;
}
const loadTimeout = setTimeout(() => {
btn.classList.remove("loading");
}, 10000);
// Add loading state to the UI
btn.classList.add("loading");
const loadedCallback = () => {
clearTimeout(loadTimeout);
btn.classList.remove("loading");
const params = Object.assign({}, args.params);
params.bottomOffset = btn.offsetHeight + 20;
window._lasuite_widget.push([targetWidget, "init", params]);
};
if (getLoaded(targetWidget) === STATE_LOADED) {
loadedCallback();
} else {
listenEvent(targetWidget, "loaded", null, true, loadedCallback);
// If it isn't even loading, we need to inject the script
if (!getLoaded(targetWidget)) {
injectScript(args.script, args.scriptType || "");
setLoaded(targetWidget, STATE_LOADING);
}
}
});
document.body.appendChild(shadowContainer);
});
installHook(widgetName);

View File

@@ -0,0 +1,105 @@
/* Widget styles */
div {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
}
button {
width: 60px;
height: 60px;
border-radius: 50%;
background: #000091;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 145, 0.3);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
}
svg {
width: 35px;
height: 35px;
}
button:focus-visible {
outline: 3px solid #0a76f6;
outline-offset: 2px;
}
button:hover {
background: #1212ff;
transform: scale(1.1);
box-shadow: 0 6px 16px rgba(18, 18, 255, 0.4);
}
button:active {
transform: scale(0.95);
}
button.loading {
background: #2323ff;
}
button.loading::after {
content: "";
width: 25px;
height: 25px;
border: 3px solid transparent;
border-top: 3px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
button.loading svg {
display: none;
}
button.opened svg {
display: none;
}
button.opened::after {
content: "+";
font-size: 50px;
color: white;
height: 60px;
line-height: 60px;
font-family: Arial;
transform: rotate(45deg);
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@media (max-width: 420px) {
button {
width: 40px;
height: 40px;
}
svg {
width: 25px;
height: 25px;
}
div {
right: 15px;
bottom: 15px;
}
button.opened::after {
font-size: 40px;
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
proxy: {
'^/widgets/dist/(.+)\\.js$': {
target: 'http://localhost:8931',
rewrite: (path) => {
const match = path.match(/^\/widgets\/dist\/(.+)\.js$/)
if (match) {
return `/src/widgets/${match[1]}/main.ts`
}
return path
}
}
}
}
})