@repobit/dex-store-elements
v2.0.24
Published
HTML elements layer for pricings
Downloads
5,240
Readme
@repobit/dex-store-elements
Reactive HTML custom elements + declarative attribute renderers for building dynamic pricing UIs on top of @repobit/dex-store.
- Custom elements:
<bd-product>,<bd-option>,<bd-context>(a Lit-based, scope-providing node tree) - Declarative renderers via
data-store-*attributes — no framework glue code - Eta templates for text and attributes with a unified DSL context
- Compute layer (min/max over the reachable option space) feeds a flat state DTO into templates and the hide DSL
- Composable event topology: node-local ignore-list filtering via
data-store-event-id+ignore-events(data-store-idremains a v1 alias)
Requirements
- Node 18+
- A browser (or jsdom for tests)
Install
npm i @repobit/dex-store-elements @repobit/dex-storeQuick start
<script type="module">
import {
registerContextNodes,
registerActionNodes,
registerRenderNodes
} from '@repobit/dex-store-elements';
import { Store } from '@repobit/dex-store';
window.addEventListener('DOMContentLoaded', () => {
registerContextNodes();
const context = document.querySelector('bd-context');
context.store = new Store({
locale : 'en-us',
provider: { name: 'vlaicu' }
});
// Optional: analytics data layer callback
// Fires once per <bd-option> that declares `data-layer-event`.
context.dataLayer = ({ option, event }) => {
window.dataLayer?.push({
event,
productId : option.getProduct().getId(),
campaign : option.getProduct().getCampaign(),
variation : option.getVariation(),
devices : option.getDevices(),
subscription: option.getSubscription(),
price : option.getDiscountedPrice({ currency: false })
});
};
// Optional: derived values/functions available in templates + hide DSL.
context.derived = async ({ option }) => ({
mails: (p) => ((option?.getDevices?.() ?? 0) / p) * 100
});
const disposeActions = registerActionNodes(context);
const disposeRenders = registerRenderNodes(context);
// Call disposers during teardown (SPA route changes, dynamic mounts).
// disposeActions();
// disposeRenders();
});
</script>
<bd-context>
<bd-product product-id="com.bitdefender.tsmd.v2">
<bd-option devices="5" subscription="12" data-layer-event="info">
<!-- Attribute renderers -->
<div data-store-render data-store-devices></div>
<div data-store-render
data-store-subscription
data-store-subscription-type="years"></div>
<div data-store-render data-store-price="discounted || full"></div>
<a data-store-render data-store-buy-link>Buy</a>
<!-- Eta template (text) -->
<p>Now at only {{= it.option.price.discounted }}!</p>
<!-- Eta template (implicit attribute) -->
<div title="Devices {{= it.option.devices }}"></div>
<!-- Hide via DSL -->
<div data-store-render
data-store-hide="!it.option.price.discounted">
Hidden when no discounted price exists
</div>
<!-- Action: emit a store event -->
<button data-store-action
data-store-set-devices="25"
data-store-event-id="devicesBtn">
25 devices
</button>
</bd-option>
</bd-product>
</bd-context>Architecture in 30 seconds
bd-contextpublishes aBdScope(store, derived, dataLayer, batch schedule) and is a hard event boundary — events from outside it cannot drive descendants inside it.bd-productconsumes a scope, publishesBdScope.product(plus a normalisedoptionfield).bd-optionconsumes a scope and publishesBdScope.optionafter resolving the option fromstore + devices + subscription + action.registerActionNodes(root)attachesBdActionElementAdapters to every element matching[data-store-action](or that declares one of thedata-store-set-*attributes). Adapters dispatchbd-action-requestevents on user interaction; the nearestbd-*ancestor catches them and drives its own scope.registerRenderNodes(root)walks every[data-store-render]element and runs the matching attribute handler whenever an observed attribute changes.- Compute (
bd-compute, opt-out viacompute-disabled) reduces the reachable option space to aBdComputeResultwith min/max formatted strings, available asit.state.*in templates.
Custom elements
Only three elements are registered. bd-root / bd-state from v1 are intentionally gone — bd-context is the scope-publishing root.
| Tag | Attributes | Notes |
| --- | --- | --- |
| <bd-context> | ignore-events, ignore-events-parent (always true), eta-disabled, compute-disabled, topology-boundary | Publishes store, derived, dataLayer, mutationBatchSchedule. |
| <bd-product product-id="…" campaign="…" bundle> | all the above + product-id, campaign, bundle | Reflects product-id, campaign, bundle. |
| <bd-option devices="5" subscription="12" data-layer-event="info"> | all the above + devices, subscription, data-layer-event | Reflects devices, subscription. Fires dataLayer once on first load. |
All three extend BdScopedElement and pick up every scope-related property (ignore-events, ignore-events-parent, eta-disabled, compute-disabled, topology-boundary).
Scope-related properties
ignore-events="a, b, c"— drop transitions whoseeventIdmatches any of the comma-separated ids. Applies to this node and every descendant (the cascade is gated here).ignore-events-parent(boolean) — drop cascades from the parent scope; only react to local DOM events.bd-contextenables this by default.eta-disabled/compute-disabled(boolean) — kill Eta rendering / compute for this subtree.topology-boundary(boolean) — onBdNodeElement, marks a topology boundary for the action registry.
Render attributes
Add data-store-render to any element you want re-rendered on scope changes. The matching handler is selected by the data attribute name.
| Attribute | Tokens / values | Source |
| --- | --- | --- |
| data-store-devices | — | current option.getDevices() |
| data-store-subscription | — | current option.getSubscription() |
| data-store-subscription-type | months | years | affects text formatting only |
| data-store-text-single | label string | singular label |
| data-store-text-many | label string | plural label |
| data-store-price | full, discounted, full-monthly, discounted-monthly (supports || fallbacks) | current option |
| data-store-discount | value, percentage, value-monthly, percentage-monthly (supports || fallbacks) | current option |
| data-store-context-price | min-full, max-full, min-discounted, max-discounted, min-full-monthly, max-full-monthly, min-discounted-monthly, max-discounted-monthly | compute result |
| data-store-context-discount | min-value, max-value, min-percentage, max-percentage, min-value-monthly, max-value-monthly, min-percentage-monthly, max-percentage-monthly | compute result |
| data-store-buy-link | (optional trial duration, e.g. "30 days") | buy URL, or trial URL when a duration is supplied |
| data-store-hide | boolean expression in the DSL | hide the element when truthy |
| data-store-hide-type | display (default) | opacity | visibility | how to hide |
data-store-trial-linkis gone — usedata-store-buy-link="30 days"to pick a trial URL by duration. Falls back to the regular buy URL when no trial is configured.
Hide DSL
Boolean expression compiled once per change, evaluated against the unified context:
<div data-store-render data-store-hide="!it.option.price.discounted">…</div>
<div data-store-render data-store-hide="it.product.campaign === 'test'">…</div>
<div data-store-render
data-store-hide="it.state.discount.percentage.min && !it.mails(10)">
…
</div>The expression runs against the same context the Eta templates see (see DSL context reference below). Numeric comparisons on prices are not portable — they're formatted strings and vary by currency. Devices and subscription remain numeric.
Eta templates
The Eta context variable is it (Eta default). The available shape is identical to the hide DSL:
it.option.*(only inside<bd-option>)it.product.*(only inside<bd-product>)it.state.*/it.ctx.*(everywhere —ctxis an alias ofstate)- any root-level keys returned by
derived(see below)
The raw Store, BdScope, transitions, and controller flags are not exposed.
Templates can appear in text content (<p>…{{= it.option.price.discounted }}…</p>) or implicitly in any attribute whose value contains {{.
Derived variables / functions
context.derived = async ({ product, option, state }) => ({
mails: (p) => ((option?.getDevices?.() ?? 0) / p) * 100,
// Nested overrides are merged into their slot:
option: { someVar: 'hello' }
});Read from templates as {{= it.mails(10) }}. Read from the hide DSL as it.mails(10). The factory may be async and is called whenever the scope inputs change.
As in v1, the returned object is deep-merged into the context root. This also allows a derived object to extend or override nested option, product, state, or ctx values.
DSL context reference
The shape exposed to both Eta templates and the hide DSL:
it = {
product: { // TemplateProductContext | undefined
id : string,
campaign: string | undefined,
name : string | undefined
},
option: { // TemplateOptionContext | undefined
price: {
full : string,
discounted : string | undefined,
fullMonthly : string,
discountedMonthly: string | undefined
},
discount: {
percentage : string,
percentageMonthly: string,
value : string,
valueMonthly : string
},
links: {
buy : string | undefined,
trial: (trialDuration: string) => string | undefined
},
devices : number,
subscription: number
},
state: { // TemplateStateContext | undefined
price: {
full: {
min: string, max: string,
monthly: { min: string, max: string }
},
discounted: {
min: string, max: string,
monthly: { min: string, max: string }
}
},
discount: {
percentage: {
min: string, max: string,
monthly: { min: string, max: string }
},
value: {
min: string, max: string,
monthly: { min: string, max: string }
}
}
},
ctx, // alias of `state`
// ...root-level values returned by `derived(...)`
}Notes
- All price / discount values in the DSL are formatted strings (currency-aware). Don't perform numeric comparisons on them.
it.ctxis an alias ofit.statefor convenience.it.option.links.trial('30 days')returns the trial URL for that duration, orundefinedif no trial is configured.derived(...)receives only{ option, product, state }; its result is deep-merged at the root.
Data layer
context.dataLayer = ({ option, event }) => {
window.dataLayer?.push({
event,
productId: option.getProduct().getId(),
devices : option.getDevices(),
// …
});
};- Fires once per
<bd-option>instance (first successful load). Re-firing requires re-mounting the node. eventis the value ofdata-layer-eventon the option element. Canonical values:all,info,comparison. Custom strings are accepted.
Actions
<button data-store-action
data-store-set-devices="25"
data-store-event-id="devicesBtn">25 devices</button>
<button data-store-action
data-store-set-type="devices"
data-store-set-delta="next"
data-store-set-min="1"
data-store-set-max="25">next</button>| Attribute | Effect |
| --- | --- |
| data-store-action | Marks the element as an action source. |
| data-store-set-devices="N" | Set devices = N. |
| data-store-set-subscription="N" | Set subscription = N. |
| data-store-set-id="…" | Switch the current product id. |
| data-store-set-campaign="…" | Switch the campaign for the current product. |
| data-store-set-bundle (boolean) | Toggle bundle inclusion. |
| data-store-set-type="devices\|subscription" | Required for delta updates. |
| data-store-set-delta="next\|prev\|<number>" | Move the option by N (or next / prev). |
| data-store-set-min, data-store-set-max | Bounds for delta updates. |
| data-store-set-use-as-value (boolean) | Treat delta as an absolute value rather than an increment. |
| data-store-event-id="…" | Tag the source element so ignore-events on bd-* nodes can drop its events. |
| data-store-id="…" | Legacy v1 alias for data-store-event-id. The canonical attribute wins when both are present. |
For <input type="number|text"> and <select>, the adapter reads the current input value and dispatches a delta update. For <input type="checkbox|radio"> it fires on click when checked === true.
Ignoring events on a node
<bd-option ignore-events="devicesBtn, subscriptionBtn">
<!-- this option ignores matching local or inherited transitions -->
</bd-option>ignore-events is node-local, as in v1; it is not copied into BdScope. Each node independently decides whether to consume a local or inherited transition. If a parent drops a local event, that event is naturally not forwarded to its descendants. data-store-event-id is canonical; legacy data-store-id is accepted as an alias.
Registration and initialization
| Export | Purpose |
| --- | --- |
| registerContextNodes() | Register <bd-context>, <bd-product>, <bd-option> custom elements. Idempotent. |
| registerActionNodes(root) | Attach BdActionElementAdapters to every action element under root. Returns () => void to disconnect. |
| registerRenderNodes(root) | Wire every [data-store-render] element under root to its attribute handler. Returns () => void to disconnect. |
| elementDefinitions | { 'bd-context': ContextNode, 'bd-product': ProductNode, 'bd-option': OptionNode } — for advanced consumers that want to register tags themselves. |
The package is side-effect free; nothing is registered until you call registerContextNodes().
Store config
trialLinks and overrides come from @repobit/dex-store and work transparently with these elements. Configure them on the Store and render via attributes:
import { Store } from '@repobit/dex-store';
const store = new Store({
locale : 'en-us',
provider: { name: 'vlaicu' },
// productAlias -> campaign -> optionVariation -> trialDays -> URL
// productAlias is the id after adaptor mapping.
// optionVariation key: '<devices>-<subscription>' (e.g. '5-12').
trialLinks: {
'com.bitdefender.tsmd.v2': {
default: {
'5-12': { '30 days': 'https://trial.example.com/default/5-12' }
}
}
},
// Per-product overrides
overrides: {
'com.bitdefender.tsmd.v2': {
default: { campaign: 'OvDefault' },
PromoX: {
campaign: 'PromoX',
options : {
'5-12' : { discountedPrice: 49.99 },
'10-12': null // delete this variation
}
}
}
}
});Render via attributes:
<a data-store-render data-store-buy-link>Buy</a>
<a data-store-render data-store-buy-link="30 days">30-day trial</a>Caveats
- Scoping:
it.option.*is only available inside<bd-option>.it.product.*is only available inside<bd-product>. Everything else is available everywhere. - Nested providers: an inner
bd-context/bd-product/bd-optionrenders its own subtree; templates inside it never see the outer scope'soption(only the inner one). - Eta templates are scoped to non-provider elements — provider elements don't render text content as templates.
- Compute (state min/max) only runs when at least one
bd-context/bd-product/bd-optionenables it (default: enabled). Disable withcompute-disabledon a provider to skip the reduction for preview widgets.
TypeScript
import type {
ContextNode,
ProductNode,
OptionNode,
BdScope,
BdScopeDeriveFn
} from '@repobit/dex-store-elements';
import {
registerContextNodes,
registerActionNodes,
registerRenderNodes
} from '@repobit/dex-store-elements';The custom element classes extend BdScopedElement and BdNodeElement (Lit LitElement subclasses). They are exported from the main entry.
License
ISC
