@pyreon/attrs
v0.43.1
Published
Attrs HOC chaining for Pyreon components
Downloads
3,610
Readme
@pyreon/attrs
Chainable HOC factory for default props, base swaps, composition, and statics.
@pyreon/attrs wraps a Pyreon component in an immutable, chainable builder that accumulates default props (.attrs()), reconfigures the base component (.config()), composes additional HOCs (.compose()), and attaches static metadata (.statics()). Every chain method returns a new component — the original is never mutated — and TypeScript generics accumulate so prop types stay correct after each .attrs<P>({...}) call. It's the foundation @pyreon/rocketstyle builds on; you'll also use it directly when you want default-prop composition without the dimension-styling layer.
Install
bun add @pyreon/attrs @pyreon/core @pyreon/ui-coreQuick start
import attrs from '@pyreon/attrs'
import { Element } from '@pyreon/elements'
const Button = attrs({ name: 'Button', component: Element })
.attrs({ tag: 'button', alignX: 'center', alignY: 'center' })
.attrs<{ primary?: boolean }>(({ primary }) => ({
backgroundColor: primary ? 'blue' : 'gray',
}))
// Renders Element with the accumulated defaults
<Button label="Click me" />
// Explicit props override .attrs() defaults (including `priority: true` ones)
<Button tag="a" href="/x" label="Link button" />API
attrs({ name, component })
Factory entry. Returns a Pyreon ComponentFn enhanced with chainable methods. Both name (used as displayName and a dev data-attrs attribute) and component (the base) are required — dev mode throws on missing values.
.attrs(props | callback, options?)
Add default props. Call multiple times — defaults stack left-to-right in the chain.
// Object form
Button.attrs({ tag: 'button' })
// Callback form — receives the current resolved props
Button.attrs<{ label: string }>((props) => ({
'aria-label': props.label,
}))
// Priority — lowest-precedence DEFAULTS (normal attrs and explicit props both override)
Button.attrs({ tag: 'button' }, { priority: true })
// Filter — strip these prop names before forwarding to the base
Button.attrs({}, { filter: ['internalFlag', 'variant'] })Merge order at render time:
priorityAttrs → attrs → explicit props → filterAttrs strips → base componentMerging is last-wins: explicit props override normal attrs, which override priorityAttrs. Priority attrs are the lowest-precedence defaults (used by rocketstyle to seed structural props like tag that later .attrs() calls and explicit props can still override).
.config({ name?, component?, DEBUG? })
Swap the underlying component, rename, or toggle dev debugging. Returns a new instance.
const Anchor = Button.config({ component: 'a', name: 'Anchor' })Gotcha: swapping component PRESERVES the attrs / priorityAttrs / filterAttrs / compose chains — they are re-applied against the new base (the chain-reset-on-swap behaviour is @pyreon/rocketstyle-only). If the accumulated attrs were tailored to the previous component's prop shape, filter or re-chain them explicitly so invalid attrs don't leak to the new base.
.compose({ hocName: hocFn })
Attach named HOCs to the chain. Applied in registration order — outermost wraps first. Pass null to remove a previously composed HOC.
const Enhanced = Button.compose({
withTheme: (Component) => (props) => Component({ ...props, themed: true }),
withTracking: trackingHoc,
})
const NoTracking = Enhanced.compose({ withTracking: null }).statics({ key: value })
Attach arbitrary metadata on .meta. Used by @pyreon/document-primitives (_documentType) and other systems that need post-construction component introspection.
const Btn = attrs({ name: 'Btn', component: Element }).statics({
category: 'action',
sizes: ['sm', 'md', 'lg'],
})
Btn.meta.category // 'action'.getDefaultAttrs()
Resolve the accumulated default props (calls every .attrs() callback with {}).
Button.getDefaultAttrs() // { tag: 'button', alignX: 'center', alignY: 'center' }isAttrsComponent(value)
Runtime guard — returns true for components produced by attrs().
import { isAttrsComponent } from '@pyreon/attrs'
isAttrsComponent(Button) // true
isAttrsComponent('div') // falseTypeScript
Each .attrs<P>() generic accumulates into the component's prop type. Three type-only properties expose the accumulated shapes:
type AllProps = typeof Button.$$types // origin + extended
type OriginProps = typeof Button.$$originTypes // base component's props
type ExtendedProps = typeof Button.$$extendedTypes // everything added through .attrs<P>()Use ExtractProps<typeof Button> from @pyreon/core to recover the union when forwarding through another HOC.
Gotchas
.config({ component })preserves the prop chains. The accumulatedattrs/priorityAttrs/filterAttrs/composechains are re-applied to the new base (the reset-on-swap behaviour is@pyreon/rocketstyle-only) — filter out props the new base doesn't understand.- Defaults are merged, not deep-merged. Object-valued props (e.g.
style={{ color: 'red' }}) get replaced, not combined. - The dev
data-attrsattribute is added in dev builds to aid debugging. Tree-shaken in production (gated onprocess.env.NODE_ENV !== 'production'). hoistNonReactStaticscopies non-React statics from the base onto the wrapper, soMyComponent.someStaticMethodsurvives the HOC chain.- Generic accumulation has a depth limit — TypeScript's recursive conditional-type inference caps at ~24-50 levels depending on the host environment. If you stack
.attrs<P>()calls past that, narrow generics or split the component.
Documentation
Full docs: pyreon.dev/docs/attrs (or docs/src/content/docs/attrs.md in this repo).
License
MIT
