All files / react/src node.ts

100% Statements 103/103
100% Branches 46/46
100% Functions 9/9
100% Lines 103/103

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 1641x   1x           1x 1x 1x 1x                       1x 3x 3x 3x 3x 3x   3x 2x 2x 3x 3x   3x 3x 3x   3x 3x 3x   3x 3x   3x 2x 3x   3x 3x 3x 3x 3x 2x 2x 3x 1x 1x 1x 3x 1x 1x 3x 3x                   1x 8x   8x 5x 2x 2x   5x 1x 1x 1x 5x   2x 2x                       1x 13x   11x 11x   13x 9x 9x 2x 1x 1x 1x 1x 2x 9x   13x 13x 12x   13x 4x 4x   4x 3x 1x 1x 3x   4x 9x 9x 1x 9x 7x 7x 9x 13x 6x 6x 13x 11x                 1x 2x 2x 5x 5x 2x 2x 2x  
import { createObserver, isBrowser } from '@anchorlib/core';
import type { HTMLAttributes, InputHTMLAttributes } from 'react';
import { onCleanup } from './lifecycle.js';
import type { NodeRef } from './types.js';
 
/**
 * Mapping of React prop names to HTML attribute names.
 */
const propsMap = {
  className: 'class',
  htmlFor: 'for',
};
 
/**
 * Creates a reactive reference to an HTML element and its attributes.
 * Automatically updates the element's attributes when reactive state changes.
 *
 * @template E - The HTMLElement type
 * @template P - The HTML attributes type
 * @param factory - A function that produces attributes for the element
 * @param displayName - The display name for the reference (optional for debugging)
 * @returns A NodeRef object with reactive attribute updates
 */
export function nodeRef<E extends HTMLElement, P extends HTMLAttributes<E> = HTMLAttributes<E>>(
  factory: (node?: E) => P | void,
  displayName?: string
): NodeRef<E, P> {
  let current: E;
  let prevProps: Record<string, unknown> = {};
 
  const observer = createObserver(() => {
    observer.reset();
    update();
  });
  observer.name = `Attribute(${displayName ?? 'Anonymous'})`;
 
  const update = () => {
    const nextProps = (escapeAttributes(observer.run(() => factory(current))) ?? {}) as Record<string, unknown>;
    applyAttributes(current as HTMLElement, nextProps, prevProps);
 
    props = nextProps;
    prevProps = nextProps;
  };
 
  let props = escapeAttributes(observer.run(() => factory(current))) as Record<string, unknown>;
  prevProps = props;
 
  onCleanup(() => {
    observer.destroy();
  });
 
  return {
    get attributes() {
      return props as P;
    },
    get current() {
      return current;
    },
    set current(value: E) {
      current = value;
      update();
    },
    destroy() {
      observer.destroy();
    },
  };
}
 
/**
 * Processes attributes to make them compatible with server-side rendering.
 * Removes event handlers and converts value props to defaultValue for inputs.
 *
 * @template P - The attributes type
 * @param props - The attributes to process
 * @returns Processed attributes suitable for SSR
 */
export function escapeAttributes<P>(props: P) {
  if (isBrowser()) return props;
 
  for (const key of Object.keys(props as Record<string, unknown>)) {
    if (key.startsWith('on')) {
      delete props[key as keyof P];
    }
 
    if (key === 'value') {
      (props as InputHTMLAttributes<HTMLInputElement>).defaultValue =
        (props as InputHTMLAttributes<HTMLInputElement>).defaultValue || (props[key as keyof P] as string);
    }
  }
 
  return props;
}
 
/**
 * Applies attributes to an HTML element.
 * Handles style objects and attribute mapping.
 *
 * @template E - The HTMLElement type
 * @template P - The attributes type
 * @param element - The HTML element to apply attributes to
 * @param props - The attributes to apply
 * @param prevProps - The previous attributes for diffing (optional)
 */
export function applyAttributes<E extends HTMLElement, P>(element: E, props: P, prevProps: P = {} as P) {
  if (!(element instanceof HTMLElement)) return;
 
  const next = props as Record<string, unknown>;
  const prev = prevProps as Record<string, unknown>;
 
  for (const key of Object.keys(prev)) {
    if (key.startsWith('on')) continue;
    if (!(key in next)) {
      if (key === 'style') {
        element.removeAttribute('style');
      } else {
        element.removeAttribute(propsMap[key as keyof typeof propsMap] ?? key);
      }
    }
  }
 
  for (const [key, value] of Object.entries(next)) {
    if (key.startsWith('on')) continue;
    if (prev[key] === value) continue;
 
    if (key === 'style') {
      const nextStyle = value as Record<string, string | number>;
      const prevStyle = (prev[key] ?? {}) as Record<string, string | number>;
 
      for (const styleKey of Object.keys(prevStyle)) {
        if (!(styleKey in nextStyle)) {
          element.style.removeProperty(styleKey);
        }
      }
 
      for (const [styleKey, styleValue] of Object.entries(nextStyle)) {
        if (prevStyle[styleKey] === styleValue) continue;
        if (styleKey.startsWith('--')) {
          element.style.setProperty(styleKey, String(styleValue));
        } else {
          element.style[styleKey as never] = String(styleValue);
        }
      }
    } else {
      element.setAttribute(propsMap[key as keyof typeof propsMap] ?? key, String(value));
    }
  }
}
 
/**
 * Converts a style object to a CSS string.
 * Transforms camelCase properties to kebab-case.
 *
 * @param styles - The style object to convert
 * @returns A CSS string representation of the styles
 */
export function flattenStyles(styles: Record<string, string | number>) {
  return Object.entries(styles)
    .map(([key, value]) => {
      const kebabKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
      return `${kebabKey}: ${value};`;
    })
    .join(' ');
}