{"id":21955174,"url":"https://github.com/gampleman/elm-webcomponents","last_synced_at":"2025-08-22T08:13:24.160Z","repository":{"id":264886502,"uuid":"872532396","full_name":"gampleman/elm-webcomponents","owner":"gampleman","description":"Generate Elm modules from annotated Typescript web components","archived":false,"fork":false,"pushed_at":"2025-02-04T12:14:36.000Z","size":123,"stargazers_count":6,"open_issues_count":1,"forks_count":1,"subscribers_count":2,"default_branch":"master","last_synced_at":"2025-08-16T20:56:45.211Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/gampleman.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null}},"created_at":"2024-10-14T15:43:55.000Z","updated_at":"2025-01-24T11:04:03.000Z","dependencies_parsed_at":"2024-11-26T16:48:36.297Z","dependency_job_id":"c5590811-b6c7-44d4-91da-80319559abed","html_url":"https://github.com/gampleman/elm-webcomponents","commit_stats":null,"previous_names":["gampleman/elm-webcomponents"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/gampleman/elm-webcomponents","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gampleman%2Felm-webcomponents","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gampleman%2Felm-webcomponents/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gampleman%2Felm-webcomponents/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gampleman%2Felm-webcomponents/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/gampleman","download_url":"https://codeload.github.com/gampleman/elm-webcomponents/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gampleman%2Felm-webcomponents/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":271606595,"owners_count":24788979,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-08-22T02:00:08.480Z","response_time":65,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-11-29T07:29:07.309Z","updated_at":"2025-08-22T08:13:24.139Z","avatar_url":"https://github.com/gampleman.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Elm Webcomponents\n\nWeb components have become a standard technology for extending Elm on the client side.\nHowever, there are a few pain points in implementing and maintaining proper interop, which this tool\naims to solve.\n\nWe provide a TypeScript library that allows you to describe the desired interface (mostly through types)\nthat the provided CLI tool can than generate type safe Elm bindings for. This automates some of the\ntedium involved in building custom elements.\n\nLet's look at an example of a simple `SizeObserver` web component:\n\n```typescript\nimport { CustomElement, component, optional } from \"elm-webcomponents\";\n\n/** Observes an element and triggers events whenever the contents size changes. */\n@component(\"size-observer\")\nclass SizeObserver extends CustomElement\u003c{\n  requiredEvents: {\n    sizeChange: { width: number; height: number };\n  };\n  htmlContent: \"single\";\n  viewFnName: \"container\";\n}\u003e {\n  /** Number of milliseconds to debounce.  */\n  @optional\n  accessor debounce: number = 100;\n\n  #resizeObserver: ResizeObserver;\n  #timeout: NodeJS.Timeout | null = null;\n\n  connectedCallback(): void {\n    this.#resizeObserver = new ResizeObserver(() =\u003e {\n      if (this.#timeout == null) {\n        this.#timeout = setTimeout(() =\u003e {\n          this.triggerEvent(\"sizeChange\", {\n            width: this.offsetWidth,\n            height: this.offsetHeight,\n          });\n          this.#timeout = null;\n        });\n      }\n    });\n\n    this.#resizeObserver.observe(this);\n  }\n\n  disconnectedCallback(): void {\n    this.#resizeObserver?.disconnect();\n    this.#timeout \u0026\u0026 clearTimeout(this.#timeout);\n  }\n}\n```\n\nWhen you run this tool, you will get the following file generated for you:\n\n```elm\nmodule SizeObserver exposing (view, debounce)\n\n{-| Observes an element and triggers events whenever the contents size changes.\n\n@docs container, debounce\n\n-}\n\nimport Html exposing (Html)\nimport Html.Attributes exposing (Attribute)\nimport Html.Events\nimport Json.Decode as Decode\nimport Json.Encode as Encode\n\n\n{-| Number of milliseconds to debounce.\n-}\ndebounce : Float -\u003e Attribute msg\ndebounce val =\n    Html.Attributes.property \"debounce\" (Encode.float val)\n\n\n{-| -}\ncontainer :\n    List (Attribute msg)\n    -\u003e { onSizeChange : { width : Float, height : Float } -\u003e msg }\n    -\u003e Html msg\n    -\u003e Html msg\ncontainer attrs req child =\n    Html.node \"size-observer\"\n        (Html.Events.on \"sizeChange\"\n            (Decode.map req.onSizeChange\n                (Decode.map2 (\\width height -\u003e { width = width, height = height })\n                    (Decode.field \"width\" Decode.float)\n                    (Decode.field \"height\" Decode.height)\n                )\n            )\n            :: attrs\n        )\n        [ child ]\n\n```\n\nAs you can see, the Elm module is ready to use with a nice idiomatic API, including documentation comments!\n\nLet's look at how this works:\n\n## The decorators\n\n### `@component`\n\nThe first piece of the puzzle is the `@component` decorator. During runtime, its job is to register the class it's decorating as a custom element with a particular tag name. During code generation, we grab this tag name and use for our `Html.node` call.\n\n### `@required` and `@optional`\n\nYou use the `@required` and `@optional` decorators to decorate properties of your class. They are implemented identically at runtime, the only difference is that the Elm code generation:\n\n```ts\nimport {\n  component,\n  required,\n  optional,\n  CustomElement,\n} from \"elm-webcomponents\";\n\n@component(\"my-example\")\nclass MyExample extends CustomElement\u003c{}\u003e {\n  @required\n  accessor foo: string;\n\n  @optional\n  accessor bar: bool = true;\n}\n```\n\nGenerates the following:\n\n```elm\nmodule MyExample exposing (view, bar)\n\nbar : Bool -\u003e Attribute msg\n\nview : List (Attribute msg) -\u003e { foo : String } -\u003e Html msg\n```\n\nAs you can notice it's impossible to call the `view` function without passing `foo`.\n\n### `accessor` vs `set` vs property\n\nThese decorators can be applied to auto-accessors, setters and plain properties:\n\n```ts\n@component(\"my-example\")\nclass MyExample extends CustomElement\u003c{}\u003e {\n  @required\n  accessor foo: string;\n\n  @required\n  set bar(value: string) {\n    // do something\n  }\n\n  @required\n  baz: string;\n}\n```\n\nWe recommend using the `accessor` keyword, since this opts into a nice reactive lifecycle of using the `update` function of the component.\n\nUsing the `accessor` is broadly equivalent to:\n\n```ts\n@component(\"my-example\")\nclass MyExample extends CustomElement\u003c{}\u003e {\n  #myValue: string = \"something\";\n\n  get myValue(): string {\n    return this.#myValue;\n  }\n\n  @required\n  set myValue(value: string) {\n    this.#myValue = value;\n    this.scheduleUpdate();\n  }\n}\n```\n\nThis allows you to then react (in a debounced way) to attributes being changed by the Elm runtime and re-rendering the UI all at once,\nrather than piece-meal as one property is updated at a time.\n\n\u003e [!WARNING]\n\u003e Setters and plain properties won't automatically schedule an update for you. With setters you can schedule an update manually, but with plain properties there is no way to react to the property being set.\n\n### `@lazy`\n\nFinally there is a `@lazy` decorator which generates the same signature as `@required`, but has different runtime semantics. It uses `Html.Lazy` under the hood to only set the property (and run the associated encoder) if the property changed (that is - it isn't reference equal to its previous value). This can be quite good if you have some very large/heavy properties as using this can save on re-rendering.\n\nHowever, the mechanism used to power `@lazy` itself has some overhead, so it is worth testing if the performance benefits are worth it.\n\n## Classes and type-level configuration\n\n### `CustomElement`\n\nAnother important part is extending `CustomElement`. `CustomElement` itself extends `HTMLElement`, so all the familiar APIs there are available, but it adds a couple of (optional) niceties at runtime.\n\n### Lifecycle hooks\n\n`CustomElement` defines the following hooks that you may override:\n\n- `init` is called when the element is added to the DOM. (It is basically just like `connectedCallback`, but there is no need to call `super` and it's a bit shorter/clearer). Use it to do one time setup.\n\n- `update` is called whenever any of the accessors changes (however, it will only be called once after multiple accessors change in a single task). It's useful for updating any UI the custom element is responsible for rendering.\n\n- `tearDown` is called on removal from the DOM. Again it is basically an alias for `disconnectedCallback`.\n\nThese are all completely optional, and you don't need to implement them, but you can for instance use them to make your own wrapper for integrating with say React:\n\n```typescript\nimport { createRoot, type Root, type ReactNode } from \"react-dom/client\";\n\nexport abstract class ReactCustomElement\u003cT\u003e extends CustomElement\u003cT\u003e {\n  #root : Root;\n\n  init() {\n    this.#root = createRoot(this);\n  }\n\n  update(){\n    this.#root.render(this.render());\n  }\n\n  abstract render() : ReactNode : {}\n\n  tearDown() {\n    this.#root.unmount();\n  }\n}\n```\n\nUsing it then would be very easy:\n\n```tsx\nimport React from \"react\";\nimport { ReactCustomElement } from \"./react-custom-element\";\nimport { component, required, optional } from \"elm-webcomponents\";\n\n@component(\"example-react\")\nclass MyComponent extends ReactCustomElement\u003c{}\u003e {\n  @required\n  accessor someInput: string;\n\n  @optiona\n  accessor someOtherInput: boolean;\n\n  render() {\n    return (\n      \u003cdiv className={this.someInput}\u003e\n        {this.someOtherInput ? \u003cspan\u003eHey!\u003c/span\u003e : \u003cb\u003eHello\u003c/b\u003e}\n      \u003c/div\u003e\n    );\n  }\n}\n```\n\n### Type-level configuration\n\nFor code generation, the most important feature is its type argument. The type argument contains a good amount of configuration, but the nice thing about having it as a type argument is that it will be **completely erased** during compilation and won't be shipped to the client at all.\n\nLet's look at the fields:\n\n```typescript\ntype Config = {\n  /**\n   * Used to define events that are required arguments to the view function.\n   * Should contain a string literal key with the event name and a value with the type that should be decoded. */\n  requiredEvents?: { [eventName: string]: any };\n  /**\n   * Used to define events that are generated as optional attribute helpers.\n   * Should contain a string literal key with the event name\n   * and a value with the type that should be decoded.\n   * */\n  optionalEvents?: { [eventName: string]: any };\n  /**\n   * The name of the view function. Defaults to \"view\".\n   */\n  viewFnName?: string;\n  /**\n   * Used to define the html content of the element.\n   * If set to \"none\", the element does not have any html content and will have no such argument.\n   * If set to \"single\", the element has a single child.\n   * If set to \"list\", the element has a list of children.\n   * If set to an object, the view function will take HTML content as part of its required arguments record and will render them as slotted content.\n   * Defaults to \"none\".\n   * */\n  htmlContent?:\n    | \"none\"\n    | \"single\"\n    | \"list\"\n    | {\n        [key: string]: {\n          mode?: \"single\" | \"list\";\n          tag?: string;\n        };\n      };\n\n  /**\n   * If the element has no optional attributes/events, set this to false to avoid generating a list of attributes.\n   * This will prevent adding class/id attributes to the element.\n   * */\n  extraAttributes?: boolean;\n};\n```\n\n### Event handling\n\nFor code generation the type parameter has two fields: `requiredEvents` and `optionalEvents`. Both of these are an object mapping the event name (as a string literal type) to a type containing event data.\n\nThe difference between them is only that the generated Elm code will enforce that `requiredEvents` are handled, but will generate optional helper functions for the `optionalEvents`.\n\nOne of the runtime niceties we provide is `this.triggerEvent(eventName, eventDetails)` function. It's a pretty shallow wrapper around `this.dispathEvent(new CustomEvent(eventName, eventDetails))`, but with the nice property that it enforces that the event name and event details are correctly encoded in either `requiredEvents` or `optionalEvents`.\n\n### Code generation customization\n\n`viewFnName` takes a string literal and allows you to specify what the function that makes the element on the elm side is going to be called. If not specified, it defaults to `\"view\"`.\n\n`extraAttributes` is only relevant if you don't have any optional attributes or event handlers. In such a case the generated function will still take a `List (Html.Attribute msg)` allowing you to add things like `id` or `class` or other useful attributes. If you don't need that and rather have a simpler API, then passing the literal `false` here will skip generating that argument.\n\n### Child DOM Nodes\n\nHTML elements can also accept child DOM Nodes as part of their input. The `htmlContent` attribute configures how this behaves.\n\n- `\"none\"` is the default value and will not generate an argument for HTML nodes.\n- `\"single\"` generates a final argument of the type `Html msg` which will be the only child node\n- `\"list\"` generates a final argument of the type `List (Html msg)` which will be the children. This is most like the behavior of elements in elm/html.\n- The final option is an object where each key corresponds to a **slot**. Since we can't attach attributes to existing Elm HTML, we wrap the arguments in a `div` by default, but this can be customized by using the `tag` key. The `mode` key (the values `single` and `list` behave as above) customises the type.\n\n```ts\n@component(\"example-component\")\nclass ExampleComponent extends IsolatedComponent\u003c{\n  htmlContent: {\n    content: { mode: \"single\"; tag: \"div\" };\n    someList: { mode: \"list\"; tag: \"ul\" };\n  };\n}\u003e {\n  // ...\n}\n```\n\nwould generate the following Elm:\n\n```elm\nmodule ExampleComponent exposing (view)\n\nimport Html exposing (Attribute, Html)\nimport Html.Attributes\n\nview : List (Attribute msg) -\u003e { content : Html msg, someList : List (Html msg) } -\u003e Html msg\nview attrs req =\n    Html.node \"example-component\"\n        attrs\n        [ Html.div [ Html.Attributes.attribute \"slot\" \"content\" ] [ req.content ]\n        , Html.ul [ Html.Attributes.attribute \"slot\" \"someList\" ] req.someList\n        ]\n```\n\nHowever, slots are only really useful when combined with the Shadow DOM. Using it with Shadow DOM allows you to quite freely mix\na DOM tree managed by Elm with a DOM tree managed by your custom element.\n\n## Shadow DOM and `IsolatedCustomElement`\n\nThe final piece of the puzzle this library provides is a subclass of `CustomElement` called `IsolatedCustomElement`,\nwhich works roughly the same but manages a shadow DOM root for you, passing it as an argument to `update` or it being accessible as `this.root`.\n\n### Problems of Shadow DOM and Style Piercing\n\nOne of the reasons Shadow DOM is awkward in Elm is that using the Shadow DOM opts you into style isolation, meaning that the DOM\ninside the custom element won't have access to the classes you define inside your application. This makes using design systems\nor things like Tailwind quite awkward. However, `IsolatedCustomElement` has a nice and performant solution for this problem:\n\n\u003e !NOTE\n\u003e The following works in Vite, might need some adjustment in other bundlers:\n\nIn `index.ts` instead of the following:\n\n```diff ts\n- import 'index.css';\n+ import './style';\n```\n\nadd the following file `style.ts`:\n\n```ts\nimport styles from \"./index.css?inline\";\n\nconst style = new CSSStyleSheet();\nstyle.replaceSync(styles);\ndocument.adoptedStyleSheets = [style];\nexport default style;\n```\n\nThis will cause your stylesheet to be included in your JS bundle instead of a separate CSS file, but it gives a _Constructed StyleSheet_,\nwhich allows nice CSS sharing with Shadow DOM.\n\nThen you can declare your component like so:\n\n```ts\nimport { component, IsolatedComponent } from \"elm-webcomponents\";\nimport style from \"./style\";\n\n@component(\"example-component\")\nclass ExampleComponent extends IsolatedComponent\u003c{\n  htmlContent: {\n    content: { mode: \"single\"; tag: \"div\" };\n    someList: { mode: \"list\"; tag: \"ul\" };\n  };\n}\u003e {\n  adoptedStyles = [style];\n\n  render() {\n    this.root.innerHTML = `\u003cdiv\u003e\n      \u003cp\u003eThe component can render it's own DOM\u003c/p\u003e\n      \u003cslot name=\"content\"\u003eYour elm content will be inserted inside here!\u003c/slot\u003e\n      \u003cp\u003eYou can have more than one:\u003c/p\u003e\n      \u003cslot name=\"someList\"\u003einterleaved with each other\u003c/slot\u003e\n    \u003c/div\u003e`;\n  }\n}\n```\n\nThe `adoptedStyles` property is key, as this will efficiently allow every style declared in your stylesheet to be accessed from inside the Shadow DOM.\n\n## Compatibility\n\n### TypeScript\n\nThis library relies on the current experimental TypeScript implementation of the decorator syntax, so you \\*_must not_ have `experimentalDecorators` enabled in your `tsconfing.json`. We recommend having `target: \"es2015\"` or later set as well.\n\n### Web platform\n\nMost of the web component APIs are well supported cross browser. `adoptedStyles` relies on features that are slightly less prevalent, but still about 93% of web users as of writing. Some polyfills may be available.\n\n### TypeScript -\u003e Elm\n\nThis codebase works by translating a subset of TypeScript types into Elm types/encoders/decoders.\n\nWe don't currently support any form of encoding for custom types on the Elm side, meaning type unions in TypeScript won't work.\n\nWe plan to address this in the future. Some more advanced TS type shenanigans will also not work, such as Indexed types and similar. The best supported are things where there is a clear correspondence between Elm and TypeScript types.\n\nFinally, `number` is encoded in Elm as `Float`. At the moment there is no way to encode `Int`, but we also plan to investigate ways to deal with this deficiency.\n\n### Status\n\nThis is beta software. Please report issues as you encounter them.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgampleman%2Felm-webcomponents","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fgampleman%2Felm-webcomponents","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgampleman%2Felm-webcomponents/lists"}