Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/strong-birds-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@xstate/lit': major
'xstate': minor
'@xstate/react': minor
'@xstate/solid': minor
'@xstate/store': minor
'@xstate/svelte': minor
'@xstate/vue': minor
---

Add Lit Controller
1 change: 1 addition & 0 deletions packages/xstate-lit/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @xstate/lit
75 changes: 75 additions & 0 deletions packages/xstate-lit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# @xstate/lit

The [@xstate/lit](https://github.com/lit/lit) package contains a [Reactive Controller](https://lit.dev/docs/composition/controllers/) for using XState with Lit.

- [Read the full documentation in the XState docs](https://stately.ai/docs/xstate-lit/).
- [Read our contribution guidelines](https://github.com/statelyai/xstate/blob/main/CONTRIBUTING.md).

## Quick Start

1. Install `xstate` and `@xstate/lit`:

```bash
npm i xstate @xstate/lit
```

**Via CDN**

```html
<script src="https://unpkg.com/@xstate/lit/dist/xstate-lit.esm.js"></script>
```

2. Import the `UseMachine` Lit controller:

**`new UseMachine(this, {machine, options?, callback?})`**

```js
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
import { createBrowserInspector } from '@statelyai/inspect';
import { createMachine } from 'xstate';
import { UseMachine } from '@xstate/lit';

const { inspect } = createBrowserInspector({
// Comment out the line below to start the inspector
autoStart: false,
});

const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: {
on: { TOGGLE: 'active' },
},
active: {
on: { TOGGLE: 'inactive' },
},
},
});

@customElement('toggle-component')
export class ToggleComponent extends LitElement {
toggleController: UseMachine<typeof toggleMachine>;

constructor() {
super();
this.toggleController = new UseMachine(this, {
machine: toggleMachine,
options: { inspect }
});
}

private get turnedOff() {
return this.toggleController.snapshot.matches('inactive');
}

render() {
return html`
<button @click=${() => this.toggleController.send({ type: 'TOGGLE' })}>
${this.turnedOff ? 'Turn on' : 'Turn off'}
</button>
`;
}
}
```
66 changes: 66 additions & 0 deletions packages/xstate-lit/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"name": "@xstate/lit",
"version": "1.0.0",
"description": "XState tools for Lit",
"keywords": [
"state",
"machine",
"statechart",
"scxml",
"state",
"graph",
"store",
"lit",
"reactive controller",
"web components"
],
"author": "David Khourshid <davidkpiano@gmail.com>",
"homepage": "https://github.com/statelyai/xstate/tree/main/packages/xstate-lit#readme",
"license": "MIT",
"main": "dist/xstate-lit.cjs.js",
"module": "dist/xstate-lit.esm.js",
"type": "module",
"preconstruct": {
"exports": true,
"___experimentalFlags_WILL_CHANGE_IN_PATCH": {
"typeModule": true,
"distInRoot": true,
"importsConditions": true
}
},
"exports": {
".": {
"types": {
"import": "./dist/xstate-lit.cjs.mjs",
"default": "./dist/xstate-lit.cjs.js"
},
"module": "./dist/xstate-lit.esm.js",
"import": "./dist/xstate-lit.cjs.mjs",
"default": "./dist/xstate-lit.cjs.js"
},
"./package.json": "./package.json"
},
"sideEffects": false,
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/statelyai/xstate.git"
},
"bugs": {
"url": "https://github.com/statelyai/xstate/issues"
},
"peerDependencies": {
"lit": "^3.3.1",
"xstate": "workspace:^"
},
"devDependencies": {
"@vitest/browser": "^3.2.2",
"lit": "^3.3.1",
"playwright": "^1.54.1",
"vitest": "^3.2.2",
"vitest-browser-lit": "^0.1.0",
"xstate": "workspace:^"
}
}
1 change: 1 addition & 0 deletions packages/xstate-lit/setup-file.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import 'vitest-browser-lit'
94 changes: 94 additions & 0 deletions packages/xstate-lit/src/UseMachine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { ReactiveController, ReactiveControllerHost } from 'lit';
import {
Actor,
AnyStateMachine,
ActorOptions,
createActor,
EventFrom,
Subscription,
SnapshotFrom,
} from 'xstate';

export class UseMachine<TMachine extends AnyStateMachine>
implements ReactiveController
{
private host: ReactiveControllerHost;
private machine: TMachine;
private options?: ActorOptions<TMachine>;
private callback?: (snapshot: SnapshotFrom<TMachine>) => void;
private actorRef = {} as Actor<TMachine>;
private subs: Subscription = { unsubscribe: () => {} };
private currentSnapshot: SnapshotFrom<TMachine>;

constructor(
host: ReactiveControllerHost,
{
machine,
options,
callback,
}: {
machine: TMachine;
options?: ActorOptions<TMachine>;
callback?: (snapshot: SnapshotFrom<TMachine>) => void;
}
) {
this.machine = machine;
this.options = options;
this.callback = callback;
this.currentSnapshot = this.snapshot;

(this.host = host).addController(this);
}

/**
* The underlying ActorRef from XState
*/
get actor() {
return this.actorRef;
}

/**
* The latest snapshot of the actor's state
*/
get snapshot() {
return this.actorRef?.getSnapshot?.();
}

/**
* Send an event to the actor service
* @param {import('xstate').EventFrom<typeof this.machine>} ev
*/
send(ev: EventFrom<TMachine>) {
this.actorRef?.send(ev);
}

unsubscribe() {
this.subs.unsubscribe();
}

protected onNext = (snapshot: SnapshotFrom<TMachine>) => {
if (this.currentSnapshot !== snapshot) {
this.currentSnapshot = snapshot;
this.callback?.(snapshot);
this.host.requestUpdate();
}
};

private startService() {
this.actorRef = createActor(this.machine, this.options);
this.subs = this.actorRef?.subscribe(this.onNext);
this.actorRef?.start();
}

private stopService() {
this.actorRef?.stop();
}

hostConnected() {
this.startService();
}

hostDisconnected() {
this.stopService();
}
}
1 change: 1 addition & 0 deletions packages/xstate-lit/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { UseMachine } from './UseMachine.ts';
76 changes: 76 additions & 0 deletions packages/xstate-lit/test/UseActor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { html, LitElement } from 'lit';
import type { AnyMachineSnapshot } from 'xstate';
import { fromPromise } from 'xstate/actors';
import { fetchMachine } from './fetchMachine.ts';
import { UseMachine } from '../src/index.ts';

const onFetch = () =>
new Promise<string>((res) => {
setTimeout(() => res('some data'), 50);
});

const fMachine = fetchMachine.provide({
actors: {
fetchData: fromPromise(onFetch)
}
});

export class UseActor extends LitElement {
fetchController: UseMachine<typeof fetchMachine> = {} as UseMachine<
typeof fetchMachine
>;
persistedState: AnyMachineSnapshot | undefined = undefined;

static properties = {
persistedState: { attribute: false }
};

override connectedCallback() {
super.connectedCallback?.();

this.fetchController = new UseMachine(this, {
machine: fMachine,
options: {
snapshot: this.persistedState
}
});
}

override render() {
return html`
<slot></slot>
<div>
${this.fetchController.snapshot.matches('idle')
? html`
<button
@click=${() => this.fetchController.send({ type: 'FETCH' })}
>
Fetch
</button>
`
: ''}
${this.fetchController.snapshot.matches('loading')
? html` <div>Loading...</div> `
: ''}
${this.fetchController.snapshot.matches('success')
? html`
<div>
Success! Data:
<div data-testid="data">
${this.fetchController.snapshot.context.data}
</div>
</div>
`
: ''}
</div>
`;
}
}

window.customElements.define('use-actor', UseActor);

declare global {
interface HTMLElementTagNameMap {
'use-actor': UseActor;
}
}
52 changes: 52 additions & 0 deletions packages/xstate-lit/test/UseActorRef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { html, LitElement } from 'lit';
import { createMachine } from 'xstate';
import { UseMachine } from '../src/index.ts';

const machine = createMachine({
initial: 'inactive',
states: {
inactive: {
on: {
TOGGLE: 'active'
}
},
active: {
on: {
TOGGLE: 'inactive'
}
}
}
});

export class UseActorRef extends LitElement {
machineController: UseMachine<typeof machine> = {} as UseMachine<
typeof machine
>;

constructor() {
super();
this.machineController = new UseMachine(this, {
machine: machine
});
}

private get turn() {
return this.machineController.snapshot.matches('inactive');
}

override render() {
return html`
<button @click=${() => this.machineController.send({ type: 'TOGGLE' })}>
${this.turn ? 'Turn on' : 'Turn off'}
</button>
`;
}
}

window.customElements.define('use-actor-ref', UseActorRef);

declare global {
interface HTMLElementTagNameMap {
'use-actor-ref': UseActorRef;
}
}
Loading
Loading