Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GenericRenderer, RenderFunction, RenderResult } from "./core";
import { BaseScheduler } from "./scheduler";
import { reflectingSymbol } from "./symbols";
import { sheets } from "./util";

const toCamelCase = (val = ""): string =>
Expand Down Expand Up @@ -143,7 +144,11 @@ function makeComponent(render: RenderFunction): Creator {
if (oldValue === newValue) {
return;
}
if ((this as any)[reflectingSymbol]) {
return;
}
let val = newValue === "" ? true : newValue;
if (val === null) val = false;
Reflect.set(this, toCamelCase(name), val);
}
}
Expand Down
1 change: 1 addition & 0 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export { useReducer } from "./use-reducer";
export { useMemo } from "./use-memo";
export { useContext } from "./use-context";
export { useProperty, lift } from "./use-property";
export { useAttribute } from "./use-attribute";
export { useRef } from "./use-ref";
export { useHost } from "./use-host";
export { hook, Hook } from "./hook";
Expand Down
3 changes: 3 additions & 0 deletions src/symbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const layoutEffectsSymbol = Symbol("haunted.layoutEffects");
type EffectsSymbols = typeof effectsSymbol | typeof layoutEffectsSymbol;
type Phase = typeof updateSymbol | typeof commitSymbol | typeof effectsSymbol;

const reflectingSymbol = Symbol("haunted.reflecting");

const contextEvent = "haunted.context";

export {
Expand All @@ -18,6 +20,7 @@ export {
commitSymbol,
effectsSymbol,
layoutEffectsSymbol,
reflectingSymbol,
contextEvent,
Phase,
EffectsSymbols,
Expand Down
134 changes: 134 additions & 0 deletions src/use-attribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { hook, Hook } from "./hook";
import { State } from "./state";
import { reflectingSymbol } from "./symbols";
import type { NewState, StateUpdater, StateTuple } from "./use-state";

type Host = Element & { [key: string]: any } & {
[reflectingSymbol]?: boolean;
};
type ChangeEvent = {
value: boolean;
path: string;
};

export interface UseAttribute {
(attribute: string): StateTuple<boolean>;
}

const toCamelCase = (val = ""): string =>
val.replace(/-+([a-z])?/g, (_, char: string) =>
char ? char.toUpperCase() : ""
);

const UPPER = /([A-Z])/gu;
const toKebabCase = (val: string): string =>
val.replace(UPPER, "-$1").toLowerCase();

/**
* Hook for bidirectional boolean property ↔ attribute sync.
*
* The `attribute` parameter is the **attribute name** (kebab-case), e.g. `'opened'`
* or `'open-on-focus'`. The hook derives the corresponding camelCase property name
* for host element access.
*
* Always reflects: setting the value to `true` adds the attribute (empty string),
* setting it to `false` removes the attribute.
*
* Dispatches a `{attribute}-changed` CustomEvent (cancelable) on every state change.
*
* @example
* ```ts
* const [opened, setOpened] = useAttribute('opened');
* ```
*/
export const useAttribute = hook(
class extends Hook<[string], StateTuple<boolean>, Host> {
property: string;
eventName: string;
attrName: string;
lastReflected: boolean | undefined;

constructor(id: number, state: State<Host>, attribute: string) {
super(id, state);

if (this.state.virtual) {
throw new Error("Can't be used with virtual components.");
}

this.updater = this.updater.bind(this);
this.property = toCamelCase(attribute);
this.attrName = toKebabCase(this.property);
this.eventName = this.attrName + "-changed";

// If the attribute is already present on the host, start as true
if (this.state.host.hasAttribute(this.attrName)) {
if (this.state.host[this.property] == null) {
this.updateProp(true);
return;
}

Check warning on line 68 in src/use-attribute.ts

View workflow job for this annotation

GitHub Actions / Test: ubuntu-latest (node@22)

This hunk is not covered
}

// Respect parent-provided value via .prop= binding
if (this.state.host[this.property] != null) return;

// Default: false (boolean semantics)
this.updateProp(false);
}

update(_attribute: string): StateTuple<boolean> {
const currentValue = !!this.state.host[this.property];
if (!Object.is(currentValue, this.lastReflected)) {
this.reflectToAttribute(currentValue);
}
return [currentValue, this.updater];
}

updater(value: NewState<boolean>): void {
const previousValue = !!this.state.host[this.property];

if (typeof value === "function") {
const updaterFn = value as (previousState: boolean) => boolean;
value = updaterFn(previousValue);
}

value = !!value; // coerce to boolean

if (Object.is(previousValue, value)) {
return;
}

Check warning on line 98 in src/use-attribute.ts

View workflow job for this annotation

GitHub Actions / Test: ubuntu-latest (node@22)

This hunk is not covered

this.updateProp(value);
}

updateProp(value: boolean): void {
const ev = this.notify(value);
if (ev.defaultPrevented) return;
this.state.host[this.property] = value;
this.reflectToAttribute(value);
}

reflectToAttribute(value: boolean): void {
const host = this.state.host;
this.lastReflected = value;
host[reflectingSymbol] = true;
try {
if (value) {
host.setAttribute(this.attrName, "");
} else {
host.removeAttribute(this.attrName);
}
} finally {
host[reflectingSymbol] = false;
}
}

notify(value: boolean) {
const ev = new CustomEvent<ChangeEvent>(this.eventName, {
detail: { value, path: this.property },
cancelable: true,
});
this.state.host.dispatchEvent(ev);
return ev;
}
}
) as UseAttribute;
Loading