✨(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:
@@ -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`.
|
||||
|
||||
|
||||
10
packages/widgets/Dockerfile
Normal file
10
packages/widgets/Dockerfile
Normal 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
31
packages/widgets/build.js
Normal 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'
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
10
packages/widgets/eslint.config.mjs
Normal file
10
packages/widgets/eslint.config.mjs
Normal 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,
|
||||
);
|
||||
16
packages/widgets/index.html
Normal file
16
packages/widgets/index.html
Normal 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
2732
packages/widgets/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
packages/widgets/package.json
Normal file
19
packages/widgets/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
31
packages/widgets/src/shared/events.ts
Normal file
31
packages/widgets/src/shared/events.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
43
packages/widgets/src/shared/focus.ts
Normal file
43
packages/widgets/src/shared/focus.ts
Normal 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);
|
||||
};
|
||||
};
|
||||
70
packages/widgets/src/shared/script.ts
Normal file
70
packages/widgets/src/shared/script.ts
Normal 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);
|
||||
};
|
||||
30
packages/widgets/src/shared/shadow-dom.ts
Normal file
30
packages/widgets/src/shared/shadow-dom.ts
Normal 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
1
packages/widgets/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
204
packages/widgets/src/widgets/feedback/main.ts
Normal file
204
packages/widgets/src/widgets/feedback/main.ts
Normal 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">×</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);
|
||||
158
packages/widgets/src/widgets/feedback/styles.css
Normal file
158
packages/widgets/src/widgets/feedback/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
367
packages/widgets/src/widgets/lagaufre/main.ts
Normal file
367
packages/widgets/src/widgets/lagaufre/main.ts
Normal 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">×</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);
|
||||
271
packages/widgets/src/widgets/lagaufre/styles.css
Normal file
271
packages/widgets/src/widgets/lagaufre/styles.css
Normal 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;
|
||||
}
|
||||
1
packages/widgets/src/widgets/loader/icon.svg
Normal file
1
packages/widgets/src/widgets/loader/icon.svg
Normal 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 |
86
packages/widgets/src/widgets/loader/main.ts
Normal file
86
packages/widgets/src/widgets/loader/main.ts
Normal 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);
|
||||
105
packages/widgets/src/widgets/loader/styles.css
Normal file
105
packages/widgets/src/widgets/loader/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
25
packages/widgets/tsconfig.json
Normal file
25
packages/widgets/tsconfig.json
Normal 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"]
|
||||
}
|
||||
19
packages/widgets/vite.config.ts
Normal file
19
packages/widgets/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user