All Examples
Web Components
QR code designer wiring two real third-party web components into Foldkit with CustomElement.define. A hex color picker from vanilla-colorful emits color-changed CustomEvents that flow back as Messages, and the sl-qr-code element from Shoelace accepts typed properties. The picker and the QR never touch each other directly; they share state through the Model.
Web Components
CustomElement
Third-Party Library
/
import '@shoelace-style/shoelace/dist/components/qr-code/qr-code.js'
import { clsx } from 'clsx'
import { Match as M, Schema as S } from 'effect'
import { Command, CustomElement, Runtime, Ui } from 'foldkit'
import { Document, Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import 'vanilla-colorful/hex-color-picker.js'
// MODEL
const Model = S.Struct({
content: S.String,
fillColor: S.String,
backgroundColor: S.String,
})
type Model = typeof Model.Type
// MESSAGE
const UpdatedContent = m('UpdatedContent', { value: S.String })
const ChangedFillColor = m('ChangedFillColor', { value: S.String })
const ChangedBackgroundColor = m('ChangedBackgroundColor', { value: S.String })
const Message = S.Union([
UpdatedContent,
ChangedFillColor,
ChangedBackgroundColor,
])
type Message = typeof Message.Type
// INIT
const DEFAULT_CONTENT = 'https://foldkit.dev'
const DEFAULT_FILL_COLOR = '#1e1b4b'
const DEFAULT_BACKGROUND_COLOR = '#fef3c7'
const init: Runtime.ProgramInit<Model, Message> = () => [
{
content: DEFAULT_CONTENT,
fillColor: DEFAULT_FILL_COLOR,
backgroundColor: DEFAULT_BACKGROUND_COLOR,
},
[],
]
// UPDATE
type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]
const withUpdateReturn = M.withReturnType<UpdateReturn>()
export const update = (model: Model, message: Message): UpdateReturn =>
M.value(message).pipe(
withUpdateReturn,
M.tagsExhaustive({
UpdatedContent: ({ value }) => [evo(model, { content: () => value }), []],
ChangedFillColor: ({ value }) => [
evo(model, { fillColor: () => value }),
[],
],
ChangedBackgroundColor: ({ value }) => [
evo(model, { backgroundColor: () => value }),
[],
],
}),
)
// WEB COMPONENT
const hexColorPicker = CustomElement.define({
tag: 'hex-color-picker',
properties: {
color: S.String,
},
events: {
'color-changed': S.Struct({ value: S.String }),
},
})
const qrCode = CustomElement.define({
tag: 'sl-qr-code',
properties: {
value: S.String,
label: S.String,
size: S.Number,
fill: S.String,
background: S.String,
radius: S.Number,
},
events: {},
})
const colorPicker = hexColorPicker.withMessage<Message>()
const qr = qrCode.withMessage<Message>()
// VIEW
const h = html<Message>()
const view = (model: Model): Document => ({
title: 'Foldkit QR Designer',
body: h.div(
[
h.Class(
'min-h-screen bg-slate-50 text-slate-900 px-6 py-10 flex flex-col items-center',
),
],
[
h.div(
[h.Class('w-full max-w-3xl flex flex-col gap-8')],
[headerView(), designerView(model)],
),
],
),
})
const codeView = (text: string): Html =>
h.code([h.Class('px-1 py-0.5 rounded bg-slate-200 text-[0.8em]')], [text])
const headerView = (): Html =>
h.header(
[h.Class('flex flex-col gap-2')],
[
h.h1([h.Class('text-3xl font-bold tracking-tight')], ['QR Designer']),
h.p(
[h.Class('text-sm text-slate-600 leading-relaxed')],
[
'Two third-party web components, both bound to Foldkit through ',
codeView('CustomElement.define'),
'. ',
codeView('<hex-color-picker>'),
' from ',
codeView('vanilla-colorful'),
' emits ',
codeView('color-changed'),
' CustomEvents that flow back as Messages. ',
codeView('<sl-qr-code>'),
' from ',
codeView('@shoelace-style/shoelace'),
' accepts typed properties (',
codeView('value'),
', ',
codeView('fill'),
', ',
codeView('background'),
', ',
codeView('size'),
', ',
codeView('radius'),
') that the runtime diffs through Snabbdom’s propsModule. The pickers and the QR never touch each other directly; they share state through the Model.',
],
),
],
)
const FIELD_LABEL_CLASS =
'text-xs font-semibold uppercase tracking-wide text-slate-600'
const FIELD_CONTROL_CLASS =
'px-3 py-2 text-sm rounded-md border border-slate-300 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200'
const designerView = (model: Model): Html =>
h.div(
[
h.Class(
'grid grid-cols-1 md:grid-cols-[1fr_auto] gap-6 bg-white rounded-xl shadow-sm border border-slate-200 p-6',
),
],
[controlsView(model), previewView(model)],
)
const controlsView = (model: Model): Html =>
h.div(
[h.Class('flex flex-col gap-5')],
[
contentFieldView(model),
colorFieldView({
id: 'fill-color',
label: 'Fill color',
value: model.fillColor,
onChange: value => ChangedFillColor({ value }),
}),
colorFieldView({
id: 'background-color',
label: 'Background color',
value: model.backgroundColor,
onChange: value => ChangedBackgroundColor({ value }),
}),
],
)
const contentFieldView = (model: Model): Html =>
Ui.Input.view({
id: 'qr-content',
value: model.content,
onInput: value => UpdatedContent({ value }),
placeholder: 'https://foldkit.dev',
toView: attributes =>
h.div(
[h.Class('flex flex-col gap-1.5')],
[
h.label(
[...attributes.label, h.Class(FIELD_LABEL_CLASS)],
['Encoded value'],
),
h.input([...attributes.input, h.Class(FIELD_CONTROL_CLASS)]),
h.p(
[h.Class('text-xs text-slate-500')],
[
'Anything the QR spec accepts: a URL, plain text, a Wi-Fi config, a payment URI.',
],
),
],
),
})
const colorFieldView = (
config: Readonly<{
id: string
label: string
value: string
onChange: (value: string) => Message
}>,
): Html =>
h.div(
[h.Class('flex flex-col gap-1.5')],
[
h.label([h.For(config.id), h.Class(FIELD_LABEL_CLASS)], [config.label]),
h.div(
[h.Class('flex items-center gap-3')],
[
colorPicker([
colorPicker.Color(config.value),
colorPicker.OnColorChanged(detail => config.onChange(detail.value)),
h.Class('w-40 h-40 rounded-md shadow-inner'),
h.Id(config.id),
]),
h.div(
[h.Class('flex flex-col gap-1')],
[
h.span(
[h.Class('text-sm font-mono text-slate-800')],
[config.value.toUpperCase()],
),
swatchRow(config.value, config.onChange),
],
),
],
),
],
)
const PRESET_COLORS: ReadonlyArray<string> = [
'#1e1b4b',
'#9d174d',
'#0f766e',
'#b45309',
'#fef3c7',
'#ffffff',
]
const swatchClass = (isActive: boolean): string =>
clsx('w-5 h-5 rounded-full cursor-pointer transition', {
'border-2 border-indigo-600 shadow-sm': isActive,
'border border-slate-300 hover:border-slate-500': !isActive,
})
const swatchRow = (
active: string,
onChange: (value: string) => Message,
): Html =>
h.div(
[h.Class('flex flex-wrap gap-1.5 max-w-[10rem]')],
PRESET_COLORS.map(color =>
h.button(
[
h.Type('button'),
h.OnClick(onChange(color)),
h.Title(color),
h.AriaLabel(`Use ${color}`),
h.Class(swatchClass(color.toLowerCase() === active.toLowerCase())),
h.Style({ backgroundColor: color }),
],
[],
),
),
)
const QR_SIZE = 220
const previewView = (model: Model): Html =>
h.div(
[
h.Class(
'flex flex-col items-center gap-3 self-start p-4 rounded-md bg-slate-50 border border-slate-200',
),
],
[
qr([
qr.Value(model.content),
qr.Label(`QR code for ${model.content}`),
qr.Size(QR_SIZE),
qr.Fill(model.fillColor),
qr.Background(model.backgroundColor),
qr.Radius(0),
]),
h.p(
[h.Class('text-xs text-slate-500 max-w-[14rem] text-center')],
[
'Live ',
codeView('<sl-qr-code>'),
'. Property writes diff through Snabbdom; the canvas redraws when the Model changes.',
],
),
],
)
// RUN
const program = Runtime.makeProgram({
Model,
init,
update,
view,
container: document.getElementById('root')!,
devTools: {
Message,
},
})
Runtime.run(program)