Skip to content
Merged
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
1 change: 1 addition & 0 deletions examples/demo/.zero-ui/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const zeroSSR = { onClick: <const V extends string[]>(key: string, vals: [...V]) => ({ 'data-ui': `cycle:${key}(${vals.join(',')})` }) as const };
38 changes: 13 additions & 25 deletions examples/demo/.zero-ui/init-zero-ui.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,26 @@
import { bodyAttributes } from './attributes';

if (typeof window !== 'undefined') {
const toDatasetKey = (dataKey: string) => dataKey.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
const toCamel = (key: string) => key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase());

const act = {
// toggle:theme-test(dark,light)
toggle: (k: string, [on = 'on']: string[]) => {
document.body.dataset[k] = document.body.dataset[k] ? '' : on;
},
// cycle:theme-test(dark,light)
cycle: (k: string, vals: string[]) => {
const cur = document.body.dataset[k] ?? vals[0];
const next = vals[(vals.indexOf(cur) + 1) % vals.length];
document.body.dataset[k] = next;
},
// set:theme-test(dark)
set: (k: string, [v = '']: string[]) => {
document.body.dataset[k] = v;
},
// attr:theme-test(data-theme)
attr: (k: string, [attr]: string[], el: HTMLElement) => {
document.body.dataset[k] = el.getAttribute(attr) ?? '';
},
const cycle = (target: HTMLElement, k: string, vals: string[]) => {
const cur = target.dataset[k] ?? vals[0]; // default = first value
const next = vals[(vals.indexOf(cur) + 1) % vals.length];
target.dataset[k] = next;
};

document.addEventListener('click', (e) => {
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-ui]');
if (!el) return;

const [, cmd, key, raw] = el.dataset.ui!.match(/^(\w+):([\w-]+)(?:\((.*?)\))?$/) || [];
if (!cmd || !(`data-${key}` in bodyAttributes)) return;
const [, key, rawVals = ''] = el.dataset.ui!.match(/^cycle:([\w-]+)(?:\((.*?)\))?$/) || [];

const dsKey = toDatasetKey(`data-${key}`);
console.log('dsKey: ', dsKey);
act[cmd as keyof typeof act]?.(dsKey, raw ? raw.split(',') : [], el);
if (!(`data-${key}` in bodyAttributes)) return; // unknown variant → bail

const vals = rawVals.split(','); // '' → [''] OK for toggle
const dsKey = toCamel(`data-${key}`);
const target = (el.closest(`[data-${key}]`) as HTMLElement) ?? document.body;

cycle(target, dsKey, vals);
});
}
5 changes: 4 additions & 1 deletion examples/demo/src/app/zero-ui-ssr/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { zeroSSR } from '../../../.zero-ui';
import { InnerDot } from './InnerDot';
import Link from 'next/link';

export const Dashboard: React.FC = () => {
const theme = 'theme-test';
const themeValues = ['dark', 'light'];
return (
<div className="theme-test-light:bg-gray-200 theme-test-light:text-gray-900 theme-test-dark:bg-gray-900 theme-test-dark:text-gray-200 flex h-screen w-screen flex-col items-center justify-start p-5">
<div className="flex flex-row items-center gap-2">
<button
type="button"
data-ui="cycle:theme-test(dark,light)"
{...zeroSSR.onClick(theme, themeValues)}
className="rounded-md bg-blue-500 px-4 py-2 text-white transition-colors hover:bg-blue-600">
Toggle Theme (Current:{' '}
{
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"reset": "git clean -fdx && pnpm install --frozen-lockfile && pnpm prepack:core && pnpm i-tarball",
"bootstrap": "pnpm install --frozen-lockfile && pnpm build && pnpm prepack:core && pnpm i-tarball",
"build": "cd packages/core && pnpm build",
"test": "cd packages/core && pnpm test:all",
"test": "cd packages/core && pnpm test:all && pnpm smoke",
"prepack:core": "pnpm -F @react-zero-ui/core pack --pack-destination ./dist",
"i-tarball": "node scripts/install-local-tarball.js",
"test:vite": "cd packages/core && pnpm test:vite",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/__tests__/e2e/next.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,16 @@ test.describe('Zero-UI Next.js Integration Tests', () => {
// Verify other states are preserved
await expect(body).toHaveAttribute('data-theme', 'dark');
await expect(body).toHaveAttribute('data-toggle-boolean', 'false');

// Click global blur toggle
await page.getByTestId('global-toggle').click();
const globalBlur = page.getByTestId('global-blur');
await expect(globalBlur).toHaveCSS('backdrop-filter', 'blur(4px)');

// Click scope blur toggle
await page.getByTestId('toggle-0').click();
const demoBlur = page.getByTestId('demo-0');
await expect(demoBlur).toHaveCSS('filter', 'blur(4px)');
});

test('Tailwind is generated correctly', async ({ page }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/* AUTO-GENERATED - DO NOT EDIT */
export declare const bodyAttributes: {
"data-blur": string;
"data-blur-global": string;
"data-child": "closed" | "open";
"data-dialog": "closed" | "open";
"data-faq": "closed" | "open";
"data-mobile": "false" | "true";
"data-number": "1" | "2";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* AUTO-GENERATED - DO NOT EDIT */
export const bodyAttributes = {
"data-blur-global": "0px",
"data-child": "closed",
"data-number": "1",
"data-theme": "light",
Expand Down
34 changes: 34 additions & 0 deletions packages/core/__tests__/fixtures/next/app/CssVarDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client';

import { useScopedUI, cssVar } from '@react-zero-ui/core';

/**
* CssVarDemo - minimal fixture for Playwright
*
* • Flips a scoped CSS variable `--blur` between "0px" ⇄ "4px"
* • Uses the updater-function pattern (`prev ⇒ next`)
* • Exposes test-ids so your spec can assert both style + text
*/
export default function CssVarDemo({ index = 0 }) {
// 👇 pass `cssVar` flag to switch makeSetter into CSS-var mode
const [blur, setBlur] = useScopedUI<'0px' | '4px'>('blur', '0px', cssVar);
// global test

return (
<div
ref={setBlur.ref} // element that owns --blur
data-testid={`demo-${index}`}
style={{ filter: 'blur(var(--blur, 0px))' }} // read the var
className="m-4 p-6 rounded bg-slate-200 space-y-3">
<button
data-testid={`toggle-${index}`}
onClick={() => setBlur((prev) => (prev === '0px' ? '4px' : '0px'))}
className="px-3 py-1 rounded bg-black text-white">
toggle blur
</button>

{/* expose current value for assertions */}
<p data-testid={`value-${index}`}>{blur}</p>
</div>
);
}
29 changes: 27 additions & 2 deletions packages/core/__tests__/fixtures/next/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';
import { useScopedUI, useUI } from '@react-zero-ui/core';
import { cssVar, useScopedUI, useUI } from '@react-zero-ui/core';
import UseEffectComponent from './UseEffectComponent';
import FAQ from './FAQ';
import { ChildComponent } from './ChildComponent';
import { ChildWithoutSetter } from './ChildWithoutSetter';
import CssVarDemo from './CssVarDemo';

export default function Page() {
const [scope, setScope] = useScopedUI<'off' | 'on'>('scope', 'off');
Expand All @@ -19,13 +20,15 @@ export default function Page() {

const [, setToggleFunction] = useUI<'white' | 'black'>('toggle-function', 'white');

const [global, setGlobal] = useUI<'0px' | '4px'>('blur-global', '0px', cssVar);

const toggleFunction = () => {
setToggleFunction((prev) => (prev === 'white' ? 'black' : 'white'));
};

return (
<div
className="p-8 theme-light:bg-white theme-dark:bg-white bg-black"
className="p-8 theme-light:bg-white theme-dark:bg-white bg-black relative"
data-testid="page-container">
<h1 className="text-2xl font-bold py-5">Global State</h1>
<hr />
Expand Down Expand Up @@ -223,6 +226,28 @@ export default function Page() {
question="Question 3"
answer="Answer 3"
/>

{Array.from({ length: 2 }).map((_, index) => (
<CssVarDemo
key={index}
index={index}
/>
))}
<div
data-testid={`global-blur-container`}
className="m-4 p-6 rounded bg-slate-200 space-y-3">
<button
data-testid={`global-toggle`}
className="bg-blue-500 text-white p-2 rounded-md"
onClick={() => setGlobal((prev) => (prev === '0px' ? '4px' : '0px'))}>
Global blur toggle
</button>
<div
data-testid={`global-blur`}
className="absolute inset-0 z-10 pointer-events-none"
style={{ backdropFilter: 'blur(var(--blur-global, 0px))' }} // read the var
/>
</div>
</div>
);
}
Empty file.
13 changes: 7 additions & 6 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
'use client';
import { useRef, type RefObject } from 'react';
import { makeSetter } from './internal.js';
import { cssVar, makeSetter } from './internal.js';

type UIAction<T extends string> = T | ((prev: T) => T);

interface ScopedSetterFn<T extends string = string> {
(action: UIAction<T>): void; // ← SINGLE source of truth
ref?: RefObject<any> | ((node: HTMLElement | null) => void);
cssVar?: typeof cssVar;
}

type GlobalSetterFn<T extends string> = (action: UIAction<T>) => void;

function useUI<T extends string>(key: string, initial: T): [T, GlobalSetterFn<T>] {
return [initial, useRef(makeSetter(key, initial, () => document.body)).current as GlobalSetterFn<T>];
function useUI<T extends string>(key: string, initial: T, flag?: typeof cssVar): [T, GlobalSetterFn<T>] {
return [initial, useRef(makeSetter(key, initial, () => document.body, flag)).current as GlobalSetterFn<T>];
}

function useScopedUI<T extends string = string>(key: string, initialValue: T): [T, ScopedSetterFn<T>] {
function useScopedUI<T extends string = string>(key: string, initialValue: T, flag?: typeof cssVar): [T, ScopedSetterFn<T>] {
// Create a ref to hold the DOM element that will receive the data-* attributes
// This allows scoping UI state to specific elements instead of always using document.body
const scopeRef = useRef<HTMLElement | null>(null);

const setterFn = useRef(makeSetter(key, initialValue, () => scopeRef.current!)).current as ScopedSetterFn<T>;
const setterFn = useRef(makeSetter(key, initialValue, () => scopeRef.current!, flag)).current as ScopedSetterFn<T>;

if (process.env.NODE_ENV !== 'production') {
// -- DEV-ONLY MULTIPLE REF GUARD (removed in production by modern bundlers) --
Expand Down Expand Up @@ -53,5 +54,5 @@ function useScopedUI<T extends string = string>(key: string, initialValue: T): [
return [initialValue, setterFn];
}

export { useUI, useScopedUI };
export { useUI, useScopedUI, cssVar };
export type { UIAction, ScopedSetterFn, GlobalSetterFn };
35 changes: 14 additions & 21 deletions packages/core/src/internal.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
// internal.ts
// src/internal.ts
import { UIAction } from './index.js';

export function makeSetter<T extends string>(key: string, initialValue: T, getTarget: () => HTMLElement) {
const camelKey = key.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
export const cssVar: unique symbol = Symbol('cssVar');

export function makeSetter<T extends string>(key: string, initialValue: T, getTarget: () => HTMLElement, flag?: typeof cssVar) {
const camelKey = key.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
const isCss = flag === cssVar;
if (process.env.NODE_ENV !== 'production') {
if (key.includes(' ') || initialValue.includes(' ')) {
throw new Error(`[Zero-UI] useUI(key, initialValue); key and initialValue must not contain spaces, got "${key}" and "${initialValue}"`);
Expand Down Expand Up @@ -43,29 +46,19 @@ export function makeSetter<T extends string>(key: string, initialValue: T, getTa
registry.set(key, initialValue);
}
}
return (valueOrFn: T | ((prev: T) => T)) => {
return (valueOrFn: UIAction<T>) => {
// SSR safety: bail out if running on server where window is undefined
if (typeof window === 'undefined') return;

const target = getTarget();
if (process.env.NODE_ENV !== 'production') {
if (target === null) {
throw new Error(
`[Zero-UI] useScopedUI(key, initialValue); targetRef is null. \n` +
`This is likely due to a missing ref attachment. \n` +
`Solution: Attach a ref to the component.\n` +
`Example: <div ref={setValue.ref} />`
);
}
}

// Write the new value to the data-* attribute
target.dataset[camelKey] =
// Check if caller passed an updater function (like React's setState(prev => prev === 'true' ? 'false' : 'true') pattern)
const prev = isCss ? ((target.style.getPropertyValue(`--${key}`) || initialValue) as T) : ((target.dataset[camelKey] || initialValue) as T);

const next =
typeof valueOrFn === 'function'
? // Call the updater function with the parsed current value - fallback to initial value if not set
valueOrFn((target.dataset[camelKey] as T) ?? initialValue)
: // Direct value assignment (no updater function)
valueOrFn;
? (valueOrFn as (p: T) => T)(prev) // ← CALL the updater
: valueOrFn;

isCss ? target.style.setProperty(`--${key}`, next) : (target.dataset[camelKey] = next);
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default createRule({
docs: { description: 'Enforce data-* attribute on element using setter.ref' },
schema: [],
messages: {
missingAttr: 'Element using "{{setter}}.ref" must include {{attr}} to avoid FOUC.',
missingAttr: 'Element using "{{setter}}.ref" should include {{attr}}=initialState to avoid FOUC.',
missingRef: 'Setter "{{setter}}" from useScopedUI("{{key}}") was never attached via .ref.',
},
},
Expand Down