From 0072b69fae7893f58bfb486f31029b8264d6c943 Mon Sep 17 00:00:00 2001 From: oscar marina Date: Sun, 27 Jul 2025 21:17:47 +0200 Subject: [PATCH] [@xstate/lit] Add Lit Controller - templates --- templates/lit-ts/.gitignore | 25 +++ templates/lit-ts/README.md | 9 + templates/lit-ts/demo/counter.html | 33 ++++ templates/lit-ts/demo/entry.js | 2 + templates/lit-ts/demo/index.html | 33 ++++ templates/lit-ts/index.html | 10 ++ templates/lit-ts/package.json | 27 +++ templates/lit-ts/pnpm-lock.yaml | 5 + templates/lit-ts/src/CounterElement.ts | 98 +++++++++++ templates/lit-ts/src/FeedbackElement.ts | 154 ++++++++++++++++++ templates/lit-ts/src/counterMachine.ts | 74 +++++++++ .../lit-ts/src/define/counter-element.ts | 3 + .../lit-ts/src/define/feedback-element.ts | 3 + templates/lit-ts/src/feedbackMachine.ts | 69 ++++++++ templates/lit-ts/src/index.ts | 1 + .../src/styles/counter-element-styles.css.ts | 107 ++++++++++++ .../src/styles/feedback-element-styles.css.ts | 93 +++++++++++ templates/lit-ts/tsconfig.json | 23 +++ templates/lit-ts/vite.config.js | 44 +++++ templates/lit-ts/vite.lib.config.js | 26 +++ 20 files changed, 839 insertions(+) create mode 100644 templates/lit-ts/.gitignore create mode 100644 templates/lit-ts/README.md create mode 100644 templates/lit-ts/demo/counter.html create mode 100644 templates/lit-ts/demo/entry.js create mode 100644 templates/lit-ts/demo/index.html create mode 100644 templates/lit-ts/index.html create mode 100644 templates/lit-ts/package.json create mode 100644 templates/lit-ts/pnpm-lock.yaml create mode 100644 templates/lit-ts/src/CounterElement.ts create mode 100644 templates/lit-ts/src/FeedbackElement.ts create mode 100644 templates/lit-ts/src/counterMachine.ts create mode 100644 templates/lit-ts/src/define/counter-element.ts create mode 100644 templates/lit-ts/src/define/feedback-element.ts create mode 100644 templates/lit-ts/src/feedbackMachine.ts create mode 100644 templates/lit-ts/src/index.ts create mode 100644 templates/lit-ts/src/styles/counter-element-styles.css.ts create mode 100644 templates/lit-ts/src/styles/feedback-element-styles.css.ts create mode 100644 templates/lit-ts/tsconfig.json create mode 100644 templates/lit-ts/vite.config.js create mode 100644 templates/lit-ts/vite.lib.config.js diff --git a/templates/lit-ts/.gitignore b/templates/lit-ts/.gitignore new file mode 100644 index 0000000000..24238166b8 --- /dev/null +++ b/templates/lit-ts/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +lib +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/templates/lit-ts/README.md b/templates/lit-ts/README.md new file mode 100644 index 0000000000..09e219e50e --- /dev/null +++ b/templates/lit-ts/README.md @@ -0,0 +1,9 @@ +# XState Lit TypeScript template + +A starting point template for using XState with [Lit](https://lit.dev) and TypeScript. Create a feedback form using a simple state machine. + +Using [Vite](https://vitejs.dev/) as a build tool and to run the local development server. + +## [➡️ Open in CodeSandbox](https://codesandbox.io/p/sandbox/github/statelyai/xstate/tree/main/templates/lit-ts?file=%2Fsrc%2FfeedbackMachine.ts) + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/statelyai/xstate/tree/main/templates/lit-ts?file=%2Fsrc%2FfeedbackMachine.ts) diff --git a/templates/lit-ts/demo/counter.html b/templates/lit-ts/demo/counter.html new file mode 100644 index 0000000000..eb3445c726 --- /dev/null +++ b/templates/lit-ts/demo/counter.html @@ -0,0 +1,33 @@ + + + + Demo - feedback-element + + + + + + + + + View feedback demo + + + + diff --git a/templates/lit-ts/demo/entry.js b/templates/lit-ts/demo/entry.js new file mode 100644 index 0000000000..6c66ccd7ef --- /dev/null +++ b/templates/lit-ts/demo/entry.js @@ -0,0 +1,2 @@ +import '../src/define/feedback-element.ts'; +import '../src/define/counter-element.ts'; diff --git a/templates/lit-ts/demo/index.html b/templates/lit-ts/demo/index.html new file mode 100644 index 0000000000..ca00552128 --- /dev/null +++ b/templates/lit-ts/demo/index.html @@ -0,0 +1,33 @@ + + + + Demo - feedback-element + + + + + + + + + View counter demo + + + + diff --git a/templates/lit-ts/index.html b/templates/lit-ts/index.html new file mode 100644 index 0000000000..7b58c3181e --- /dev/null +++ b/templates/lit-ts/index.html @@ -0,0 +1,10 @@ + + + + + + Redirect feedback-element url=/demo/index.html" + + + + diff --git a/templates/lit-ts/package.json b/templates/lit-ts/package.json new file mode 100644 index 0000000000..cf71b1b8e0 --- /dev/null +++ b/templates/lit-ts/package.json @@ -0,0 +1,27 @@ +{ + "name": "lit-ts", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && npm run build:lib", + "build:lib": "vite build --config vite.lib.config.js && tsc", + "preview": "vite preview" + }, + "devDependencies": { + "@custom-elements-manifest/analyzer": "^0.10.4", + "@web/rollup-plugin-html": "^2.3.0", + "rollup-plugin-copy": "^3.5.0", + "rollup-plugin-node-externals": "^8.0.1", + "tinyglobby": "^0.2.14", + "tslib": "^2.8.1", + "typescript": "^5.8.3", + "vite": "^7.0.6" + }, + "dependencies": { + "@statelyai/inspect": "^0.4.0", + "lit": "^3.3.1", + "xstate": "^5.20.1" + } +} diff --git a/templates/lit-ts/pnpm-lock.yaml b/templates/lit-ts/pnpm-lock.yaml new file mode 100644 index 0000000000..6a932beee4 --- /dev/null +++ b/templates/lit-ts/pnpm-lock.yaml @@ -0,0 +1,5 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false diff --git a/templates/lit-ts/src/CounterElement.ts b/templates/lit-ts/src/CounterElement.ts new file mode 100644 index 0000000000..c374079eff --- /dev/null +++ b/templates/lit-ts/src/CounterElement.ts @@ -0,0 +1,98 @@ +import { html, LitElement } from 'lit'; +import { state } from 'lit/decorators.js'; +import { type InspectionEvent, type SnapshotFrom } from 'xstate'; +import { counterMachine } from './counterMachine.js'; +import { UseMachine } from '@xstate/lit'; +import { styles } from './styles/counter-element-styles.css.js'; + +export class CounterElement extends LitElement { + static override styles = [styles]; + + #inspectEventsHandler: (inspEvent: InspectionEvent) => void = + this.#inspectEvents.bind(this); + + #callbackHandler: (snapshot: SnapshotFrom) => void = + this.#callbackCounterController.bind(this); + + counterController: UseMachine = new UseMachine(this, { + machine: counterMachine, + options: { + inspect: this.#inspectEventsHandler + }, + callback: this.#callbackHandler + }); + + @state() + xstate: typeof this.counterController.snapshot = + this.counterController.snapshot; + + override updated(props: Map) { + super.updated && super.updated(props); + if (props.has('xstate')) { + const { context, value } = this.xstate; + const detail = { ...(context || {}), value }; + const counterEvent = new CustomEvent('counterchange', { + bubbles: true, + detail + }); + this.dispatchEvent(counterEvent); + } + } + + #callbackCounterController(snapshot: typeof this.counterController.snapshot) { + this.xstate = snapshot; + } + + #inspectEvents(inspEvent: InspectionEvent) { + if ( + inspEvent.type === '@xstate.snapshot' && + inspEvent.event.type === 'xstate.stop' + ) { + this.xstate = {} as unknown as typeof this.counterController.snapshot; + } + } + + get #disabled() { + return this.counterController.snapshot.matches('disabled'); + } + + #send(event: any) { + this.counterController.send(event); + } + + override render() { + return html` +
+ + + + +

${this.counterController.snapshot.context.counter}

+
+
+ + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'counter-element': CounterElement; + } +} diff --git a/templates/lit-ts/src/FeedbackElement.ts b/templates/lit-ts/src/FeedbackElement.ts new file mode 100644 index 0000000000..de9192f338 --- /dev/null +++ b/templates/lit-ts/src/FeedbackElement.ts @@ -0,0 +1,154 @@ +import { html, LitElement } from 'lit'; +import { createBrowserInspector } from '@statelyai/inspect'; +import { feedbackMachine } from './feedbackMachine.js'; +import { UseMachine } from '@xstate/lit'; +import { styles } from './styles/feedback-element-styles.css.js'; + +const { inspect } = createBrowserInspector({ + // Comment out the line below to start the inspector + autoStart: false +}); + +export class FeedbackElement extends LitElement { + static override styles = [styles]; + + feedbackController: UseMachine; + + constructor() { + super(); + this.feedbackController = new UseMachine(this, { + machine: feedbackMachine, + options: { inspect } + }); + } + + #getMatches(match: 'prompt' | 'thanks' | 'form' | 'closed') { + return this.feedbackController.snapshot.matches(match); + } + + #send(ev: any) { + this.feedbackController.send(ev); + } + + override render() { + return html` + ${this.#getMatches('closed') ? this._closedTpl : this._feedbackTpl} + `; + } + + get _feedbackTpl() { + return html` + + `; + } + + get _slotTpl() { + return html`
`; + } + + get _closeFeedbackTpl() { + return html` +
+ +
+ `; + } + + get _promptTpl() { + return html` +
+

How was your experience?

+ + + + +
+ `; + } + + get _thanksTpl() { + return html` +
+

Thanks for your feedback.

+ + ${this.feedbackController.snapshot.context.feedback + ? html`

"${this.feedbackController.snapshot.context.feedback}"

` + : ''} +
+ `; + } + + get _formTpl() { + return html` +
{ + ev.preventDefault(); + this.#send({ type: 'submit' }); + }} + > +

What can we do better?

+ + + + + + +
+ `; + } + + get _closedTpl() { + return html` +
+ Feedback form closed. + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'feedback-element': FeedbackElement; + } +} diff --git a/templates/lit-ts/src/counterMachine.ts b/templates/lit-ts/src/counterMachine.ts new file mode 100644 index 0000000000..bb4d0cf5cc --- /dev/null +++ b/templates/lit-ts/src/counterMachine.ts @@ -0,0 +1,74 @@ +import { setup, assign } from 'xstate'; + +/* + * This state machine represents a simple counter that can be incremented, decremented, and toggled on and off. + * The counter starts in the "enabled" state, where it can be incremented or decremented. + * If the counter reaches its maximum value, it cannot be incremented further. Similarly, if the counter reaches its minimum value, it cannot be decremented further. The counter can also be toggled to the "disabled" state, where it cannot be incremented or decremented. + * Toggling it again will bring it back to the "enabled" state. + */ + +export const counterMachine = setup({ + types: { + context: {} as { counter: number; event: unknown }, + events: {} as + | { + type: 'INC'; + } + | { + type: 'DEC'; + } + | { + type: 'TOGGLE'; + } + }, + actions: { + increment: assign({ + counter: ({ context }) => context.counter + 1, + event: ({ event }) => event + }), + decrement: assign({ + counter: ({ context }) => context.counter - 1, + event: ({ event }) => event + }) + }, + guards: { + isNotMax: ({ context }) => context.counter < 10, + isNotMin: ({ context }) => context.counter > 0 + } +}).createMachine({ + id: 'counter', + context: { counter: 0, event: undefined }, + initial: 'enabled', + states: { + enabled: { + on: { + INC: { + actions: { + type: 'increment' + }, + guard: { + type: 'isNotMax' + } + }, + DEC: { + actions: { + type: 'decrement' + }, + guard: { + type: 'isNotMin' + } + }, + TOGGLE: { + target: 'disabled' + } + } + }, + disabled: { + on: { + TOGGLE: { + target: 'enabled' + } + } + } + } +}); diff --git a/templates/lit-ts/src/define/counter-element.ts b/templates/lit-ts/src/define/counter-element.ts new file mode 100644 index 0000000000..ca8585caac --- /dev/null +++ b/templates/lit-ts/src/define/counter-element.ts @@ -0,0 +1,3 @@ +import {CounterElement} from '../CounterElement.js'; + +window.customElements.define('counter-element', CounterElement); diff --git a/templates/lit-ts/src/define/feedback-element.ts b/templates/lit-ts/src/define/feedback-element.ts new file mode 100644 index 0000000000..156f556dcc --- /dev/null +++ b/templates/lit-ts/src/define/feedback-element.ts @@ -0,0 +1,3 @@ +import {FeedbackElement} from '../FeedbackElement.js'; + +window.customElements.define('feedback-element', FeedbackElement); diff --git a/templates/lit-ts/src/feedbackMachine.ts b/templates/lit-ts/src/feedbackMachine.ts new file mode 100644 index 0000000000..5252fb9a1f --- /dev/null +++ b/templates/lit-ts/src/feedbackMachine.ts @@ -0,0 +1,69 @@ +import { assign, setup } from 'xstate'; + +export const feedbackMachine = setup({ + types: { + context: {} as { feedback: string }, + events: {} as + | { + type: 'feedback.good'; + } + | { + type: 'feedback.bad'; + } + | { + type: 'feedback.update'; + value: string; + } + | { type: 'submit' } + | { + type: 'close'; + } + | { type: 'back' } + | { type: 'restart' } + }, + guards: { + feedbackValid: ({ context }) => context.feedback.length > 0 + } +}).createMachine({ + id: 'feedback', + initial: 'prompt', + context: { + feedback: '' + }, + states: { + prompt: { + on: { + 'feedback.good': 'thanks', + 'feedback.bad': 'form' + } + }, + form: { + on: { + 'feedback.update': { + actions: assign({ + feedback: ({ event }) => event.value + }) + }, + back: { target: 'prompt' }, + submit: { + guard: 'feedbackValid', + target: 'thanks' + } + } + }, + thanks: {}, + closed: { + on: { + restart: { + target: 'prompt', + actions: assign({ + feedback: '' + }) + } + } + } + }, + on: { + close: '.closed' + } +}); diff --git a/templates/lit-ts/src/index.ts b/templates/lit-ts/src/index.ts new file mode 100644 index 0000000000..48cabffed6 --- /dev/null +++ b/templates/lit-ts/src/index.ts @@ -0,0 +1 @@ +export {FeedbackElement} from './FeedbackElement.js'; diff --git a/templates/lit-ts/src/styles/counter-element-styles.css.ts b/templates/lit-ts/src/styles/counter-element-styles.css.ts new file mode 100644 index 0000000000..215dd0f6f7 --- /dev/null +++ b/templates/lit-ts/src/styles/counter-element-styles.css.ts @@ -0,0 +1,107 @@ +import { css } from 'lit'; + +export const styles = css` + :host { + --color-primary: #056dff; + --_mark-color: rgb(197, 197, 197); + display: block; + box-sizing: border-box; + } + + :host([hidden]), + [hidden] { + display: none !important; + } + + *, + *::before, + *::after { + box-sizing: inherit; + } + + ::slotted(*) { + display: block; + color: var(--color-primary); + white-space: nowrap; + text-indent: -1.5rem; + text-decoration: none; + margin-top: 0.5rem; + } + + [aria-disabled='true'] { + opacity: 0.5; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + pointer-events: none; + cursor: not-allowed; + } + + p { + font-size: 1.5rem; + min-width: 4.25rem; + text-align: center; + margin: auto; + padding: 0.8333em; + border-radius: 1rem; + border: 0.0625rem solid var(--_mark-color); + } + + button { + appearance: none; + color: white; + border: none; + padding: 1rem 1.5rem; + border-radius: 0.25rem; + font: inherit; + cursor: pointer; + display: inline-block; + background-color: var(--color-primary); + } + + button + button { + margin-top: 1rem; + } + + div { + display: flex; + align-items: center; + max-width: 25rem; + padding: 1em 2em; + margin: auto; + background-color: rgb(238, 238, 238); + padding: 2rem; + background: white; + border-radius: 0.25rem; + box-shadow: 0 0.5rem 1rem #0001; + border: 0.0625rem solid var(--_mark-color); + border-bottom: none; + } + div + div { + position: relative; + border-top: 0.0625rem dashed var(--_mark-color); + border-bottom: 0.0625rem solid var(--_mark-color); + } + + div + div button { + margin: 0 auto; + min-width: 10.625rem; + } + + div + div span { + position: absolute; + display: block; + bottom: -1.5rem; + margin: 0; + } + + span { + display: flex; + flex-direction: column; + margin-right: 2rem; + } + + ::slotted(*) { + white-space: nowrap; + } +`; diff --git a/templates/lit-ts/src/styles/feedback-element-styles.css.ts b/templates/lit-ts/src/styles/feedback-element-styles.css.ts new file mode 100644 index 0000000000..658e3919ac --- /dev/null +++ b/templates/lit-ts/src/styles/feedback-element-styles.css.ts @@ -0,0 +1,93 @@ +import { css } from 'lit'; + +export const styles = css` + :host { + --color-primary: #056dff; + display: block; + box-sizing: border-box; + display: block; + margin: auto; + } + + :host([hidden]), + [hidden] { + display: none !important; + } + + *, + *::before, + *::after { + box-sizing: inherit; + } + + ::slotted(*) { + display: block; + color: var(--color-primary); + text-indent: 1rem; + white-space: nowrap; + text-decoration: none; + margin-top: 0.5rem; + } + + em { + display: block; + margin-bottom: 1rem; + text-align: center; + } + + .step { + padding: 2rem; + background: white; + border-radius: 1rem; + box-shadow: 0 0.5rem 1rem #0001; + width: 75vw; + max-width: 40rem; + } + + .feedback { + position: relative; + } + + .close-feedback { + position: absolute; + top: 0; + right: 0; + } + + .close-button { + appearance: none; + background: transparent; + font: inherit; + cursor: pointer; + border: none; + padding: 1rem; + } + + .button { + appearance: none; + color: white; + border: none; + padding: 1rem 1.5rem; + border-radius: 0.25rem; + font: inherit; + font-weight: bold; + cursor: pointer; + display: inline-block; + margin-right: 1rem; + background-color: var(--color-primary); + } + + .button:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + textarea { + display: block; + border: 2px solid #eaeaea; + border-radius: 0.25rem; + margin-bottom: 1rem; + width: 100%; + padding: 0.5rem; + } +`; diff --git a/templates/lit-ts/tsconfig.json b/templates/lit-ts/tsconfig.json new file mode 100644 index 0000000000..eb303653de --- /dev/null +++ b/templates/lit-ts/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "declaration": true, + "declarationDir": "./lib/", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": ["ESNext", "DOM"], + "moduleResolution": "Node", + "strict": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/templates/lit-ts/vite.config.js b/templates/lit-ts/vite.config.js new file mode 100644 index 0000000000..77163d2d43 --- /dev/null +++ b/templates/lit-ts/vite.config.js @@ -0,0 +1,44 @@ +import {defineConfig} from 'vite'; +import {globSync} from 'tinyglobby'; +import copy from 'rollup-plugin-copy'; + +const OUT_DIR = 'dist'; +const ENTRIES_DIR = 'demo'; +const ENTRIES_GLOB = [`${ENTRIES_DIR}/**/*.js`]; + +const copyConfig = { + targets: [ + { + src: [`${ENTRIES_DIR}/**/*.*`, `!${ENTRIES_GLOB}`], + dest: OUT_DIR, + }, + ], + hook: 'writeBundle', +}; + +// https://github.com/vitejs/vite/discussions/1736#discussioncomment-5126923 +const entries = Object.fromEntries( + globSync(ENTRIES_GLOB).map((file) => { + const [key] = file.match(new RegExp(`(?<=${ENTRIES_DIR}/).*`)) || []; + return [key?.replace(/\.[^.]*$/, ''), file]; + }) +); + +export default defineConfig({ + plugins: [ + copy(copyConfig), + ], + + build: { + outDir: OUT_DIR, + rollupOptions: { + preserveEntrySignatures: 'exports-only', + input: entries, + output: { + dir: OUT_DIR, + entryFileNames: '[name].js', + format: 'es', + }, + }, + }, +}); diff --git a/templates/lit-ts/vite.lib.config.js b/templates/lit-ts/vite.lib.config.js new file mode 100644 index 0000000000..f7a4059efa --- /dev/null +++ b/templates/lit-ts/vite.lib.config.js @@ -0,0 +1,26 @@ +import {defineConfig} from 'vite'; +import {nodeExternals} from 'rollup-plugin-node-externals'; +import {globSync} from 'tinyglobby'; + +const ENTRIES_DIR = 'src'; +const ENTRIES_GLOB = [`${ENTRIES_DIR}/**/*.ts`]; + +// https://github.com/vitejs/vite/discussions/1736#discussioncomment-5126923 +const entries = Object.fromEntries( + globSync(ENTRIES_GLOB).map((file) => { + const [key] = file.match(new RegExp(`(?<=${ENTRIES_DIR}/).*`)) || []; + return [key?.replace(/\.[^.]*$/, ''), file]; + }) +); + +export default defineConfig({ + plugins: [nodeExternals()], + build: { + outDir: 'lib', + lib: { + entry: entries, + formats: ['es'], + }, + minify: false, + }, +});