Establish NODE.DC design system baseline
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { computeFloatingPosition, type FloatingPlacement } from "@nodedc/ui-core";
|
||||
|
||||
export interface FloatingLayerOptions {
|
||||
trigger: HTMLElement;
|
||||
surface: HTMLElement;
|
||||
placement?: FloatingPlacement;
|
||||
offset?: number;
|
||||
minWidth?: number;
|
||||
matchTriggerWidth?: boolean;
|
||||
closeOnSelect?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface FloatingLayerController {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
reposition: () => void;
|
||||
isOpen: () => boolean;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createFloatingLayer({
|
||||
trigger,
|
||||
surface,
|
||||
placement = "bottom-start",
|
||||
offset = 8,
|
||||
minWidth = 180,
|
||||
matchTriggerWidth = true,
|
||||
closeOnSelect = false,
|
||||
onOpenChange,
|
||||
}: FloatingLayerOptions): FloatingLayerController {
|
||||
let openState = false;
|
||||
const originalParent = surface.parentNode;
|
||||
const originalNextSibling = surface.nextSibling;
|
||||
|
||||
surface.hidden = true;
|
||||
surface.classList.add("nodedc-dropdown-surface");
|
||||
document.body.append(surface);
|
||||
|
||||
const reposition = () => {
|
||||
if (!openState) return;
|
||||
const anchor = trigger.getBoundingClientRect();
|
||||
const measured = surface.getBoundingClientRect();
|
||||
const surfaceWidth = Math.max(minWidth, matchTriggerWidth ? anchor.width : 0, measured.width);
|
||||
const surfaceHeight = Math.max(1, surface.scrollHeight);
|
||||
const position = computeFloatingPosition({ anchor, surfaceWidth, surfaceHeight, placement, offset });
|
||||
Object.assign(surface.style, {
|
||||
position: "fixed",
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
width: `${surfaceWidth}px`,
|
||||
maxHeight: `${position.maxHeight}px`,
|
||||
visibility: "visible",
|
||||
});
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (openState || trigger.hasAttribute("disabled")) return;
|
||||
openState = true;
|
||||
surface.hidden = false;
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
window.dispatchEvent(new CustomEvent("nodedc-floating-open", { detail: { surface } }));
|
||||
requestAnimationFrame(reposition);
|
||||
onOpenChange?.(true);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (!openState) return;
|
||||
openState = false;
|
||||
surface.hidden = true;
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
onOpenChange?.(false);
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
if (openState) close();
|
||||
else open();
|
||||
};
|
||||
|
||||
const handleTriggerClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
};
|
||||
const handleDocumentPointerDown = (event: PointerEvent) => {
|
||||
if (!(event.target instanceof Node)) return;
|
||||
if (!trigger.contains(event.target) && !surface.contains(event.target)) close();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && openState) {
|
||||
close();
|
||||
trigger.focus();
|
||||
}
|
||||
};
|
||||
const handleOtherOpen = (event: Event) => {
|
||||
const otherSurface = (event as CustomEvent<{ surface?: HTMLElement }>).detail.surface;
|
||||
if (otherSurface && otherSurface !== surface) close();
|
||||
};
|
||||
const handleSurfaceClick = (event: MouseEvent) => {
|
||||
if (closeOnSelect && event.target instanceof HTMLElement && event.target.closest("[data-nodedc-option]")) close();
|
||||
};
|
||||
|
||||
trigger.addEventListener("click", handleTriggerClick);
|
||||
surface.addEventListener("click", handleSurfaceClick);
|
||||
document.addEventListener("pointerdown", handleDocumentPointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("nodedc-floating-open", handleOtherOpen as EventListener);
|
||||
window.addEventListener("resize", reposition);
|
||||
window.addEventListener("scroll", reposition, true);
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
reposition,
|
||||
isOpen: () => openState,
|
||||
destroy: () => {
|
||||
close();
|
||||
trigger.removeEventListener("click", handleTriggerClick);
|
||||
surface.removeEventListener("click", handleSurfaceClick);
|
||||
document.removeEventListener("pointerdown", handleDocumentPointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("nodedc-floating-open", handleOtherOpen as EventListener);
|
||||
window.removeEventListener("resize", reposition);
|
||||
window.removeEventListener("scroll", reposition, true);
|
||||
if (originalParent) originalParent.insertBefore(surface, originalNextSibling);
|
||||
surface.removeAttribute("style");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { applyNodedcTheme, clearNodedcAccent, createAccentVariables, readableTextRgb } from "@nodedc/ui-core";
|
||||
export * from "./floating";
|
||||
export * from "./modal";
|
||||
export * from "./select";
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
const focusableSelector = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
|
||||
export interface ModalControllerOptions {
|
||||
overlay: HTMLElement;
|
||||
dialog: HTMLElement;
|
||||
closeControls?: Iterable<HTMLElement>;
|
||||
closeOnBackdrop?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
lockBodyScroll?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ModalController {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
isOpen: () => boolean;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createModalController({
|
||||
overlay,
|
||||
dialog,
|
||||
closeControls = [],
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
lockBodyScroll = true,
|
||||
onOpenChange,
|
||||
}: ModalControllerOptions): ModalController {
|
||||
let openState = false;
|
||||
let previousFocus: HTMLElement | null = null;
|
||||
let previousOverflow = "";
|
||||
const controls = Array.from(closeControls);
|
||||
|
||||
overlay.classList.add("nodedc-overlay", "nodedc-ui-root");
|
||||
dialog.classList.add("nodedc-window");
|
||||
dialog.setAttribute("role", "dialog");
|
||||
dialog.setAttribute("aria-modal", "true");
|
||||
dialog.tabIndex = -1;
|
||||
overlay.hidden = true;
|
||||
|
||||
const focusFirst = () => {
|
||||
const first = dialog.querySelector<HTMLElement>(focusableSelector);
|
||||
(first ?? dialog).focus();
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (openState) return;
|
||||
openState = true;
|
||||
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
previousOverflow = document.body.style.overflow;
|
||||
if (lockBodyScroll) document.body.style.overflow = "hidden";
|
||||
overlay.hidden = false;
|
||||
requestAnimationFrame(focusFirst);
|
||||
onOpenChange?.(true);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (!openState) return;
|
||||
openState = false;
|
||||
overlay.hidden = true;
|
||||
if (lockBodyScroll) document.body.style.overflow = previousOverflow;
|
||||
previousFocus?.focus();
|
||||
onOpenChange?.(false);
|
||||
};
|
||||
|
||||
const handleOverlayPointerDown = (event: PointerEvent) => {
|
||||
if (closeOnBackdrop && event.target === overlay) close();
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!openState) return;
|
||||
if (event.key === "Escape" && closeOnEscape) {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector));
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
overlay.addEventListener("pointerdown", handleOverlayPointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
controls.forEach((control) => control.addEventListener("click", close));
|
||||
|
||||
return {
|
||||
open,
|
||||
close,
|
||||
isOpen: () => openState,
|
||||
destroy: () => {
|
||||
close();
|
||||
overlay.removeEventListener("pointerdown", handleOverlayPointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
controls.forEach((control) => control.removeEventListener("click", close));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createFloatingLayer, type FloatingLayerController, type FloatingLayerOptions } from "./floating";
|
||||
|
||||
export interface SelectControllerOptions<T extends string> extends Omit<FloatingLayerOptions, "closeOnSelect"> {
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
valueTarget?: HTMLElement;
|
||||
}
|
||||
|
||||
export interface SelectController<T extends string> extends FloatingLayerController {
|
||||
getValue: () => T;
|
||||
setValue: (value: T, emit?: boolean) => void;
|
||||
}
|
||||
|
||||
export function createSelectController<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
valueTarget,
|
||||
surface,
|
||||
trigger,
|
||||
...floatingOptions
|
||||
}: SelectControllerOptions<T>): SelectController<T> {
|
||||
let currentValue = value;
|
||||
const floating = createFloatingLayer({
|
||||
trigger,
|
||||
surface,
|
||||
closeOnSelect: true,
|
||||
...floatingOptions,
|
||||
});
|
||||
|
||||
const options = () => Array.from(surface.querySelectorAll<HTMLElement>("[data-nodedc-option][data-nodedc-value]"));
|
||||
|
||||
const sync = () => {
|
||||
options().forEach((option) => {
|
||||
const selected = option.dataset.nodedcValue === currentValue;
|
||||
option.dataset.selected = selected ? "true" : "false";
|
||||
option.setAttribute("aria-selected", String(selected));
|
||||
if (selected && valueTarget) valueTarget.textContent = option.dataset.nodedcLabel ?? option.textContent ?? currentValue;
|
||||
});
|
||||
};
|
||||
|
||||
const setValue = (nextValue: T, emit = true) => {
|
||||
currentValue = nextValue;
|
||||
sync();
|
||||
if (emit) onChange(nextValue);
|
||||
};
|
||||
|
||||
const handleOptionClick = (event: MouseEvent) => {
|
||||
if (!(event.target instanceof HTMLElement)) return;
|
||||
const option = event.target.closest<HTMLElement>("[data-nodedc-option][data-nodedc-value]");
|
||||
if (!option || option.hasAttribute("disabled")) return;
|
||||
setValue(option.dataset.nodedcValue as T);
|
||||
};
|
||||
|
||||
surface.addEventListener("click", handleOptionClick);
|
||||
sync();
|
||||
|
||||
return {
|
||||
...floating,
|
||||
getValue: () => currentValue,
|
||||
setValue,
|
||||
destroy: () => {
|
||||
surface.removeEventListener("click", handleOptionClick);
|
||||
floating.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user