192 lines
5.5 KiB
TypeScript
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",
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
};
|
|
}
|