muddle/integrations/react.ts
2026-10-23 08:53:07 -04:00

192 lines
5.5 KiB
TypeScript

import type { Integration, IntegrationContext } from "../contracts/index.ts";
import { compileCss } from "../views/css/index.ts";
/** The states the utilities know. */
export const STATES = [
"open",
"closed",
"active",
"disabled",
"loading",
"checked",
"selected",
] as const;
/** The size of the grid that tracks the pointer. */
export interface TrackerOptions {
/** Columns of the grid. */
columns?: number;
/** Rows of the grid. */
rows?: number;
/** Extra grid detail, multiplied into each axis. */
detail?: number;
/** Enables the CSS grid to receive pointer events. Disabled by default. */
capture?: boolean;
/** Stable name for a tracker mounted inside one component. */
scope?: string;
}
/** The list of a range, used by the loops of the stylesheet. */
function range(count: number): string {
return Array.from({ length: count }, (_, index) => index + 1).join(" ");
}
/**
* The stylesheet of the integration.
*
* The mouse position is tracked by a grid of cells, the parent hack moves the
* custom properties of the root element: every cell knows its position, the
* `:has()` selector of the parent picks the hovered one.
*/
export function reactStylesheet(options: TrackerOptions = {}): string {
const detail = Math.max(1, options.detail ?? 1);
const columns = (options.columns ?? 10) * detail;
const rows = (options.rows ?? 10) * detail;
const states = STATES.join(" ");
return `
$columns: ${columns};
$rows: ${rows};
@each $state in ${states} {
.state-#{$state} {
--muddle-state: #{$state};
}
[data-state="#{$state}"] {
--muddle-state: #{$state};
}
}
[data-state] {
--muddle-state-opacity: 1;
--muddle-state-visibility: visible;
}
[data-state="closed"] {
--muddle-state-opacity: 0;
--muddle-state-visibility: hidden;
}
[data-state="disabled"] {
--muddle-state-opacity: 0.5;
}
[data-state="loading"] {
--muddle-state-opacity: 0.7;
}
.state-open {
--muddle-state-opacity: 1;
--muddle-state-visibility: visible;
}
.state-closed {
--muddle-state-opacity: 0;
--muddle-state-visibility: hidden;
}
.peer:checked ~ [data-state="checked"] {
display: block;
}
.peer:not(:checked) ~ [data-state="unchecked"] {
display: none;
}
`;
}
/** CSS for one explicitly mounted tracker. */
export function mouseTrackerCss(options: TrackerOptions = {}): string {
const detail = Math.max(1, options.detail ?? 1);
const columns = (options.columns ?? 10) * detail;
const rows = (options.rows ?? 10) * detail;
const scope = options.scope ?? "default";
const selector = `[data-muddle-tracker="${scope}"]`;
const rules = Array.from({ length: columns * rows }, (_, index) => {
const x = Math.floor(index / rows) + 1;
const y = (index % rows) + 1;
return `${selector}:has(.mouse-tracker__cell:nth-child(${index + 1}):hover) { --muddle-mouse-x: ${x / columns * 100}%; --muddle-mouse-y: ${y / rows * 100}%; }`;
});
return `${selector} { position: relative; --muddle-mouse-x: 50%; --muddle-mouse-y: 50%; }\n${selector} > .mouse-tracker { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(${columns}, 1fr); grid-template-rows: repeat(${rows}, 1fr); pointer-events: none; }\n${selector}[data-muddle-capture="true"] > .mouse-tracker .mouse-tracker__cell { pointer-events: auto; }\n${rules.join("\n")}`;
}
/** The compiled stylesheet of the integration. */
export function reactCss(options: TrackerOptions = {}): string {
return compileCss(reactStylesheet(options));
}
/** The markup of the grid that tracks the pointer. */
export function mouseTrackerMarkup(options: TrackerOptions = {}): string {
const detail = Math.max(1, options.detail ?? 1);
const columns = (options.columns ?? 10) * detail;
const rows = (options.rows ?? 10) * detail;
const cells = Array.from(
{ length: columns * rows },
() => '<div class="mouse-tracker__cell"></div>',
).join("");
const scope = options.scope ?? "default";
return `<div data-muddle-tracker="${scope}" data-muddle-capture="${options.capture === true}"><div class="mouse-tracker" aria-hidden="true">${cells}</div></div>`;
}
/** The markup of a checkbox, the label is the control. */
export function checkboxMarkup(options: {
name: string;
label: string;
value?: string;
checked?: boolean;
disabled?: boolean;
class?: string;
}): string {
const attributes = [
`type="checkbox"`,
`name="${options.name}"`,
options.value ? `value="${options.value}"` : "",
options.checked ? "checked" : "",
options.disabled ? "disabled" : "",
'class="checkbox__input"',
]
.filter((entry) => entry.length > 0)
.join(" ");
return `<label class="checkbox${options.class ? ` ${options.class}` : ""}"><input ${attributes}><span class="checkbox__control" aria-hidden="true"></span><span class="checkbox__label">${options.label}</span></label>`;
}
export interface ReactIntegrationOptions extends TrackerOptions {
/** Route the stylesheet is served at. */
route?: string;
}
/**
* Reactivity integration.
*/
export function reactIntegration(
options: ReactIntegrationOptions = {},
): Integration {
const route = options.route ?? "/_muddle/react.css";
return {
name: "react",
setup(context: IntegrationContext) {
context.loader("react", {
name: "react",
extensions: [".react.css"],
load(file) {
return {
data: {},
content: compileCss(`${file.source}\n${reactStylesheet(options)}`),
};
},
});
context.route("GET", route, () =>
new Response(reactCss(options), {
headers: {
"content-type": "text/css; charset=utf-8",
"cache-control": "public, max-age=3600",
},
}),
);
},
};
}