Skip to content

Commit

Permalink
feat(jsx): class name normalization helper
Browse files Browse the repository at this point in the history
  • Loading branch information
kasperskei committed Oct 24, 2024
1 parent d87c180 commit 2e89b99
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 9 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/jsx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
"scripts": {
"prepublishOnly": "npm run build && npm run test",
"build": "microbundle -f esm,cjs && cp src/jsx.d.ts build/ && cp -r build/ jsx-runtime/build && cp -r build/ jsx-dev-runtime/build",
"test": "tsc && wtr src/index.test.tsx",
"test:watch": "wtr src/index.test.tsx --watch"
"test": "tsc && wtr src/*.test.{ts,tsx}",
"test:watch": "wtr src/*.test.{ts,tsx} --watch"
},
"dependencies": {
"@reatom/core": ">=3.6.0",
Expand Down
61 changes: 60 additions & 1 deletion packages/jsx/src/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ it('custom component', setup((ctx, h, hf, mount, parent) => {
const Component = (props: JSX.HTMLAttributes) => <div {...props} />

assert.instance(<Component />, window.HTMLElement)
assert.is(((<Component draggable />) as HTMLElement).draggable, true)
assert.is(((<Component draggable="true" />) as HTMLElement).draggable, true)
assert.equal(((<Component>123</Component>) as HTMLElement).innerText, '123')
}))

Expand Down Expand Up @@ -418,6 +418,65 @@ it('css property and class attribute', setup(async (ctx, h, hf, mount, parent) =
assert.is(ref1.dataset.reatom, ref2.dataset.reatom)
}))

it('css custom property', setup(async (ctx, h, hf, mount, parent) => {
const colorAtom = atom('red' as string | undefined)

const component = (
<div
css:first-property={colorAtom}
css:secondProperty={colorAtom}
></div>
)

mount(parent, component)
await sleep()

assert.is(component.style.getPropertyValue('--first-property'), 'red')
assert.is(component.style.getPropertyValue('--secondProperty'), 'red')

colorAtom(ctx, 'green')

assert.is(component.style.getPropertyValue('--first-property'), 'green')
assert.is(component.style.getPropertyValue('--secondProperty'), 'green')

colorAtom(ctx, undefined)

assert.is(component.style.getPropertyValue('--first-property'), '')
assert.is(component.style.getPropertyValue('--secondProperty'), '')
}))

it('class and className attribute', setup(async (ctx, h, hf, mount, parent) => {
const classAtom = atom('' as string | undefined)

const ref1 = (<div class={classAtom}></div>)
const ref2 = (<div className={classAtom}></div>)

const component = (
<div>
{ref1}
{ref2}
</div>
)

mount(parent, component)
await sleep()

assert.ok(ref1.hasAttribute('class'))
assert.ok(ref2.hasAttribute('class'))

classAtom(ctx, 'cls')
assert.is(ref1.className, 'cls')
assert.is(ref2.className, 'cls')
assert.ok(ref1.hasAttribute('class'))
assert.ok(ref2.hasAttribute('class'))

classAtom(ctx, undefined)
assert.is(ref1.className, '')
assert.is(ref2.className, '')
assert.ok(!ref1.hasAttribute('class'))
assert.ok(!ref2.hasAttribute('class'))
}))

it('ref mount and unmount callbacks order', setup(async (ctx, h, hf, mount, parent) => {
const order: number[] = []

Expand Down
16 changes: 13 additions & 3 deletions packages/jsx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type FC<Props = {}> = (props: Props & { children?: JSXElement }) => JSXEl

export type { JSXElement, JSX }

export { type ClassNameValue, cn } from './utils'

type DomApis = Pick<
typeof window,
'document' | 'Node' | 'Text' | 'Element' | 'MutationObserver' | 'HTMLElement' | 'DocumentFragment'
Expand Down Expand Up @@ -110,7 +112,7 @@ export const reatomJsx = (ctx: Ctx, DOM: DomApis = globalThis.window) => {
styleId = styles[val] = random().toString()
stylesheet.innerText += '[data-reatom="' + styleId + '"]{' + val + '}\n'
}
/** @see https://www.measurethat.net/Benchmarks/Show/11819/0/dataset-vs-setattribute */
/** @see https://measurethat.net/Benchmarks/Show/11819 */
element.setAttribute('data-reatom', styleId)
} else if (key === 'style' && typeof val === 'object') {
for (const key in val) {
Expand All @@ -124,9 +126,17 @@ export const reatomJsx = (ctx: Ctx, DOM: DomApis = globalThis.window) => {
if (key.startsWith('attr:')) {
key = key.slice(5)
}
if (key === 'className') key = 'class'
if (val == null || val === false) element.removeAttribute(key)
else if (val === true) element.setAttribute(key, '')
else element.setAttribute(key, String(val))
else {
val = val === true ? '' : String(val)
/**
* @see https://measurethat.net/Benchmarks/Show/54
* @see https://measurethat.net/Benchmarks/Show/31249
*/
if (key === 'class' && element instanceof HTMLElement) element.className = val
else element.setAttribute(key, val)
}
}
}

Expand Down
6 changes: 5 additions & 1 deletion packages/jsx/src/jsx.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,10 +670,12 @@ export namespace JSX {
// [key: ClassKeys]: boolean;
accessKey?: string
class?: string | undefined
/** Alias for `class`. */
className?: string | undefined
contenteditable?: boolean | 'plaintext-only' | 'inherit'
contextmenu?: string
dir?: HTMLDir
draggable?: boolean | 'false' | 'true'
draggable?: 'false' | 'true'
hidden?: boolean | 'hidden' | 'until-found'
id?: string
inert?: boolean
Expand Down Expand Up @@ -1179,6 +1181,8 @@ export namespace JSX {
}
interface StylableSVGAttributes extends CssAttributes {
class?: string | undefined
/** Alias for `class`. */
className?: string | undefined
style?: CSSProperties | string
}
interface TransformableSVGAttributes {
Expand Down
86 changes: 86 additions & 0 deletions packages/jsx/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { atom } from '@reatom/core'
import { createTestCtx } from '@reatom/testing'
import * as assert from 'uvu/assert'
import { cn } from './utils'

describe('parseClasses', () => {
const ctx = createTestCtx()

it('handles falsy correctly', () => {
assert.is(ctx.get(cn(false)), '')
assert.is(ctx.get(cn(true)), '')
assert.is(ctx.get(cn(null)), '')
assert.is(ctx.get(cn(undefined)), '')
assert.is(ctx.get(cn({})), '')
assert.is(ctx.get(cn([])), '')
assert.is(ctx.get(cn(atom(undefined))), '')
assert.is(ctx.get(cn(() => undefined)), '')
})

it('handles falsy object correctly', () => {
assert.is(ctx.get(cn({
a: '',
b: 0,
c: NaN,
d: false,
e: null,
f: undefined,
g: atom(undefined),
})), '')
})

it('handles falsy array correctly', () => {
assert.is(ctx.get(cn([
'',
null,
undefined,
{},
[],
atom(undefined),
() => undefined,
])), '')
})

it('handles object correctly', () => {
assert.is(ctx.get(cn({
a: 'a',
b: 1,
c: true,
d: {},
e: [],
f: atom(true),
g: () => undefined,
})), 'a b c d e f g')
})

it('handles deep array correctly', () => {
assert.is(ctx.get(cn(['a', ['b', ['c']]])), 'a b c')
})

it('handles deep atom correctly', () => {
assert.is(ctx.get(cn(atom(() => atom(() => atom('a'))))), 'a')
})

it('handles deep getter correctly', () => {
assert.is(ctx.get(cn(() => () => () => 'a')), 'a')
})

it('handles complex correctly', () => {
const isBAtom = atom(true)
const stringAtom = atom('d')
const classNameAtom = cn(() => atom(() => [
'a',
{b: isBAtom},
['c'],
stringAtom,
() => 'e',
]))

assert.is(ctx.get(classNameAtom), 'a b c d e')

isBAtom(ctx, false)
stringAtom(ctx, 'dd')

assert.is(ctx.get(classNameAtom), 'a c dd e')
})
})
38 changes: 38 additions & 0 deletions packages/jsx/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { atom, Atom, AtomMaybe, CtxSpy, isAtom } from '@reatom/core'
import { isObject} from '@reatom/utils'

export type ClassNameValue = AtomMaybe<
| string
| number
| boolean
| null
| undefined
| Array<ClassNameValue>
| Atom<ClassNameValue>
| Record<string, AtomMaybe<string | number | boolean | null | undefined | object>>
| ((ctx: CtxSpy) => ClassNameValue)
>

export const cn = (value: ClassNameValue): Atom<string> => atom((ctx) => parseClasses(ctx, value))

const parseClasses = (ctx: CtxSpy, value: ClassNameValue): string => {
let className = ''
while (isAtom(value)) value = ctx.spy(value)
if (typeof value === 'string') {
className = value
} else if (typeof value === 'function') {
className = parseClasses(ctx, value(ctx))
} else if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const parsed = parseClasses(ctx, value[i])
if (parsed !== '') className += className === '' ? parsed : ' ' + parsed
}
} else if (isObject(value)) {
for (const name in value) {
let val = value[name]
while (isAtom(val)) val = ctx.spy(val)
if (val) className += className === '' ? name : ' ' + name
}
}
return className
}

0 comments on commit 2e89b99

Please sign in to comment.