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 }, () => '
', ).join(""); const scope = options.scope ?? "default"; return `
`; } /** 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 ``; } 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", }, }), ); }, }; }