All ExamplesView source on GitHub
Query Sync
Filterable dinosaur table where every control syncs to URL query parameters. Schema transforms enforce valid states — invalid params gracefully fall back.
Routing
Query Params
/
import { clsx } from 'clsx'
import {
Array,
Effect,
Match as M,
Option,
Order,
Predicate,
Schema as S,
String,
Types,
pipe,
} from 'effect'
import { Route, Runtime, Ui } from 'foldkit'
import { Command } from 'foldkit/command'
import { Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { load, pushUrl, replaceUrl } from 'foldkit/navigation'
import { r } from 'foldkit/route'
import { ts } from 'foldkit/schema'
import { evo } from 'foldkit/struct'
import { AnchorConfig } from 'foldkit/ui/listbox'
import { Url, toString as urlToString } from 'foldkit/url'
import { type Dinosaur, dinosaurs } from './data'
const Diet = S.Literal('Carnivore', 'Herbivore', 'Omnivore')
const Period = S.Literal('Triassic', 'Jurassic', 'Cretaceous')
const SortColumn = S.Literal('Name', 'Period', 'Diet', 'Length', 'Weight')
type SortColumn = typeof SortColumn.Type
const Unsorted = ts('Unsorted')
const Ascending = ts('Ascending', { column: SortColumn })
const Descending = ts('Descending', { column: SortColumn })
const Sorting = S.Union(Unsorted, Ascending, Descending)
type Sorting = typeof Sorting.Type
const dietFilterItems: ReadonlyArray<string> = ['', ...Diet.literals]
const periodFilterItems: ReadonlyArray<string> = ['', ...Period.literals]
// ROUTE
const SORT_PARAM_SEPARATOR = ':'
const optionFromValidParam = <A extends string>(schema: S.Schema<A, A>) => {
const decode = S.decodeUnknownOption(schema)
return S.transform(S.UndefinedOr(S.String), S.OptionFromSelf(schema), {
strict: true,
decode: value => decode(value),
encode: option => Option.getOrUndefined(option),
})
}
const SortDirection = S.Literal('Ascending', 'Descending')
const sortingFromParam = (() => {
const decodeColumn = S.decodeUnknownOption(SortColumn)
const decodeDirection = S.decodeUnknownOption(SortDirection)
return S.transform(S.UndefinedOr(S.String), Sorting, {
strict: true,
decode: value => {
if (Predicate.isUndefined(value)) {
return Unsorted()
}
const parts = String.split(value, SORT_PARAM_SEPARATOR)
return pipe(
Option.all({
column: pipe(Array.get(parts, 0), Option.flatMap(decodeColumn)),
direction: pipe(Array.get(parts, 1), Option.flatMap(decodeDirection)),
}),
Option.map(({ column, direction }) =>
M.value(direction).pipe(
M.when('Ascending', () => Ascending({ column })),
M.when('Descending', () => Descending({ column })),
M.exhaustive,
),
),
Option.getOrElse(() => Unsorted()),
)
},
encode: sorting =>
M.value(sorting).pipe(
M.withReturnType<string | undefined>(),
M.tagsExhaustive({
Unsorted: () => undefined,
Ascending: ({ column }) =>
`${column}${SORT_PARAM_SEPARATOR}Ascending`,
Descending: ({ column }) =>
`${column}${SORT_PARAM_SEPARATOR}Descending`,
}),
),
})
})()
const BrowseRoute = r('Browse', {
search: S.Option(S.String),
sorting: Sorting,
diet: S.Option(Diet),
period: S.Option(Period),
})
const NotFoundRoute = r('NotFound', { path: S.String })
const AppRoute = S.Union(BrowseRoute, NotFoundRoute)
type AppRoute = typeof AppRoute.Type
const browseRouter = pipe(
Route.root,
Route.query(
S.Struct({
search: S.OptionFromUndefinedOr(S.String),
sorting: sortingFromParam,
diet: optionFromValidParam(Diet),
period: optionFromValidParam(Period),
}),
),
Route.mapTo(BrowseRoute),
)
const routeParser = Route.oneOf(browseRouter)
const urlToAppRoute = Route.parseUrlWithFallback(routeParser, NotFoundRoute)
// MODEL
const Model = S.Struct({
route: AppRoute,
dietListbox: Ui.Listbox.Model,
periodListbox: Ui.Listbox.Model,
})
type Model = typeof Model.Type
// MESSAGE
const NoOp = m('NoOp')
const ClickedLink = m('ClickedLink', { request: Runtime.UrlRequest })
const ChangedUrl = m('ChangedUrl', { url: Url })
const ChangedSearchInput = m('ChangedSearchInput', { value: S.String })
const ClickedColumnHeader = m('ClickedColumnHeader', { column: SortColumn })
const GotDietListboxMessage = m('GotDietListboxMessage', {
message: Ui.Listbox.Message,
})
const GotPeriodListboxMessage = m('GotPeriodListboxMessage', {
message: Ui.Listbox.Message,
})
const Message = S.Union(
NoOp,
ClickedLink,
ChangedUrl,
ChangedSearchInput,
ClickedColumnHeader,
GotDietListboxMessage,
GotPeriodListboxMessage,
)
type Message = typeof Message.Type
// INIT
type BrowseFields = Omit<typeof BrowseRoute.Type, '_tag'>
const emptyBrowseFields: BrowseFields = {
search: Option.none(),
sorting: Unsorted(),
diet: Option.none(),
period: Option.none(),
}
const routeToBrowseFields = (route: AppRoute): BrowseFields =>
M.value(route).pipe(
M.tag('Browse', route => route),
M.orElse(() => emptyBrowseFields),
)
const init: Runtime.ApplicationInit<Model, Message> = (url: Url) => {
const route = urlToAppRoute(url)
const fields = routeToBrowseFields(route)
return [
{
route,
dietListbox: Ui.Listbox.init({
id: 'diet-filter',
selectedItem: Option.getOrElse(fields.diet, () => ''),
}),
periodListbox: Ui.Listbox.init({
id: 'period-filter',
selectedItem: Option.getOrElse(fields.period, () => ''),
}),
},
[],
]
}
// UPDATE
const columnSortDirection = (
sorting: Sorting,
column: SortColumn,
): Types.Tags<Sorting> => {
const isColumnSorted =
sorting._tag !== 'Unsorted' && sorting.column === column
if (isColumnSorted) {
return sorting._tag
} else {
return 'Unsorted'
}
}
const nextSorting = (sorting: Sorting, column: SortColumn): Sorting =>
pipe(
columnSortDirection(sorting, column),
M.value,
M.when('Unsorted', () => Ascending({ column })),
M.when('Ascending', () => Descending({ column })),
M.when('Descending', () => Unsorted()),
M.exhaustive,
)
const selectionToParam = <A extends string>(
maybeSelectedItem: Option.Option<string>,
schema: S.Schema<A, A>,
): Option.Option<A> => {
const decode = S.decodeUnknownOption(schema)
return pipe(
maybeSelectedItem,
Option.filter(String.isNonEmpty),
Option.flatMap(value => decode(value)),
)
}
const replaceFilters = (fields: BrowseFields): Command<typeof NoOp> =>
replaceUrl(browseRouter(fields)).pipe(Effect.as(NoOp()))
type UpdateReturn = [Model, ReadonlyArray<Command<Message>>]
const withUpdateReturn = M.withReturnType<UpdateReturn>()
const update = (model: Model, message: Message): UpdateReturn =>
M.value(message).pipe(
withUpdateReturn,
M.tagsExhaustive({
NoOp: () => [model, []],
ClickedLink: ({ request }) =>
M.value(request).pipe(
withUpdateReturn,
M.tagsExhaustive({
Internal: ({ url }) => [
model,
[pushUrl(urlToString(url)).pipe(Effect.as(NoOp()))],
],
External: ({ href }) => [
model,
[load(href).pipe(Effect.as(NoOp()))],
],
}),
),
ChangedUrl: ({ url }) => {
const nextRoute = urlToAppRoute(url)
const fields = routeToBrowseFields(nextRoute)
return [
evo(model, {
route: () => nextRoute,
dietListbox: () =>
evo(model.dietListbox, {
maybeSelectedItem: () =>
Option.orElse(fields.diet, () => Option.some('')),
}),
periodListbox: () =>
evo(model.periodListbox, {
maybeSelectedItem: () =>
Option.orElse(fields.period, () => Option.some('')),
}),
}),
[],
]
},
ChangedSearchInput: ({ value }) => {
const fields = routeToBrowseFields(model.route)
return [
model,
[
replaceFilters({
...fields,
search: Option.liftPredicate(value, String.isNonEmpty),
}),
],
]
},
ClickedColumnHeader: ({ column }) => {
const fields = routeToBrowseFields(model.route)
return [
model,
[
replaceFilters({
...fields,
sorting: nextSorting(fields.sorting, column),
}),
],
]
},
GotDietListboxMessage: ({ message }) => {
const [nextDietListbox, listboxCommands] = Ui.Listbox.update(
model.dietListbox,
message,
)
const commands = listboxCommands.map(
Effect.map(message => GotDietListboxMessage({ message })),
)
return M.value(message).pipe(
withUpdateReturn,
M.tag('SelectedItem', () => {
const fields = routeToBrowseFields(model.route)
return [
evo(model, { dietListbox: () => nextDietListbox }),
[
...commands,
replaceFilters({
...fields,
diet: selectionToParam(
nextDietListbox.maybeSelectedItem,
Diet,
),
}),
],
]
}),
M.orElse(() => [
evo(model, { dietListbox: () => nextDietListbox }),
commands,
]),
)
},
GotPeriodListboxMessage: ({ message }) => {
const [nextPeriodListbox, listboxCommands] = Ui.Listbox.update(
model.periodListbox,
message,
)
const commands = listboxCommands.map(
Effect.map(message => GotPeriodListboxMessage({ message })),
)
return M.value(message).pipe(
withUpdateReturn,
M.tag('SelectedItem', () => {
const fields = routeToBrowseFields(model.route)
return [
evo(model, { periodListbox: () => nextPeriodListbox }),
[
...commands,
replaceFilters({
...fields,
period: selectionToParam(
nextPeriodListbox.maybeSelectedItem,
Period,
),
}),
],
]
}),
M.orElse(() => [
evo(model, { periodListbox: () => nextPeriodListbox }),
commands,
]),
)
},
}),
)
// VIEW
const {
a,
button,
div,
h1,
header,
input,
keyed,
main,
p,
path,
span,
svg,
table,
tbody,
td,
th,
thead,
tr,
AriaHidden,
AriaLabel,
AriaSort,
Class,
D,
Fill,
Href,
OnClick,
OnInput,
Placeholder,
Stroke,
StrokeLinecap,
StrokeLinejoin,
StrokeWidth,
Type,
Value,
ViewBox,
Xmlns,
} = html<Message>()
const columnOrders: Record<SortColumn, Order.Order<Dinosaur>> = {
Name: Order.mapInput(Order.string, ({ name }: Dinosaur) => name),
Period: Order.mapInput(Order.string, ({ period }: Dinosaur) => period),
Diet: Order.mapInput(Order.string, ({ diet }: Dinosaur) => diet),
Length: Order.mapInput(
Order.number,
({ lengthMeters }: Dinosaur) => lengthMeters,
),
Weight: Order.mapInput(Order.number, ({ weightKg }: Dinosaur) => weightKg),
}
const filterWhenSome =
<A, B>(
maybeValue: Option.Option<A>,
predicate: (value: A, item: B) => boolean,
) =>
(items: ReadonlyArray<B>): ReadonlyArray<B> =>
Option.match(maybeValue, {
onNone: () => items,
onSome: value => Array.filter(items, item => predicate(value, item)),
})
const sortBySorting =
<A>(sorting: Sorting, orders: Record<SortColumn, Order.Order<A>>) =>
(items: ReadonlyArray<A>): ReadonlyArray<A> =>
M.value(sorting).pipe(
M.tag('Unsorted', () => items),
M.tag('Ascending', ({ column }) => Array.sort(items, orders[column])),
M.tag('Descending', ({ column }) =>
Array.sort(items, Order.reverse(orders[column])),
),
M.exhaustive,
)
const filterAndSort = (fields: BrowseFields): ReadonlyArray<Dinosaur> =>
pipe(
dinosaurs,
filterWhenSome(fields.search, (query, dinosaur) =>
dinosaur.name.toLowerCase().includes(query.toLowerCase()),
),
filterWhenSome(
fields.diet,
(dietValue, dinosaur) => dinosaur.diet === dietValue,
),
filterWhenSome(
fields.period,
(periodValue, dinosaur) => dinosaur.period === periodValue,
),
sortBySorting(fields.sorting, columnOrders),
)
const sortIndicator = (column: SortColumn, sorting: Sorting): string =>
M.value(columnSortDirection(sorting, column)).pipe(
M.when('Unsorted', () => ''),
M.when('Ascending', () => '\u2191'),
M.when('Descending', () => '\u2193'),
M.exhaustive,
)
const BADGE_BASE = 'px-2 py-0.5 rounded-full text-xs font-medium'
const periodBadgeClass = (period: string): string =>
clsx(BADGE_BASE, {
'bg-amber-100 text-amber-800': period === 'Triassic',
'bg-sky-100 text-sky-800': period === 'Jurassic',
'bg-purple-100 text-purple-800': period === 'Cretaceous',
})
const dietBadgeClass = (diet: string): string =>
clsx(BADGE_BASE, {
'bg-red-100 text-red-800': diet === 'Carnivore',
'bg-green-100 text-green-800': diet === 'Herbivore',
'bg-orange-100 text-orange-800': diet === 'Omnivore',
})
const SORT_INDICATOR_WIDTH = 'w-4'
const headerButtonClass =
'w-full px-4 py-3 text-sm font-semibold text-gray-700 cursor-pointer select-none hover:bg-gray-100 focus-visible:bg-emerald-100 focus-visible:text-emerald-900 focus-visible:outline-none transition'
const bodyCellClass = 'px-4 py-3 text-sm text-gray-700'
const sortAriaLabel = (column: SortColumn, sorting: Sorting): string =>
M.value(columnSortDirection(sorting, column)).pipe(
M.when('Unsorted', () => `Sort by ${column}`),
M.when('Ascending', () => `Sort by ${column}, currently ascending`),
M.when('Descending', () => `Sort by ${column}, currently descending`),
M.exhaustive,
)
const ariaSortValue = (column: SortColumn, sorting: Sorting): string =>
M.value(columnSortDirection(sorting, column)).pipe(
M.when('Unsorted', () => 'none'),
M.when('Ascending', () => 'ascending'),
M.when('Descending', () => 'descending'),
M.exhaustive,
)
const sortableColumnHeader = (
column: SortColumn,
displayLabel: string,
fields: BrowseFields,
isRightAligned: boolean,
): Html => {
const indicator = span(
[Class(clsx(SORT_INDICATOR_WIDTH, 'inline-block text-center'))],
[sortIndicator(column, fields.sorting)],
)
const label = span([], [displayLabel])
const alignment = isRightAligned ? 'text-right' : 'text-left'
return th(
[AriaSort(ariaSortValue(column, fields.sorting))],
[
button(
[
Type('button'),
OnClick(ClickedColumnHeader({ column })),
AriaLabel(sortAriaLabel(column, fields.sorting)),
Class(clsx(headerButtonClass, alignment)),
],
isRightAligned ? [indicator, label] : [label, indicator],
),
],
)
}
const LISTBOX_ANCHOR: AnchorConfig = {
placement: 'bottom-start',
gap: 4,
padding: 8,
}
const listboxButtonClassName =
'inline-flex items-center justify-between gap-2 min-w-40 px-4 py-2 text-sm border border-gray-300 rounded-lg bg-white cursor-pointer select-none hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:border-emerald-500'
const listboxItemsClassName =
'absolute mt-1 min-w-40 rounded-lg border border-gray-200 bg-white shadow-lg overflow-hidden z-10 outline-none'
const listboxItemClassName =
'group px-3 py-2 text-sm text-gray-700 cursor-pointer data-[active]:bg-emerald-50 data-[active]:text-emerald-900'
const listboxBackdropClassName = 'fixed inset-0 z-0'
const listboxWrapperClassName = 'relative inline-block'
const filterItemConfig = (label: string): Ui.Listbox.ItemConfig => ({
className: listboxItemClassName,
content: div(
[Class('flex items-center gap-2')],
[
span(
[
Class(
'w-4 text-center text-emerald-600 invisible group-data-[selected]:visible',
),
],
['\u2713'],
),
span([], [label]),
],
),
})
const chevronDown = (className: string): Html =>
svg(
[
AriaHidden(true),
Class(className),
Xmlns('http://www.w3.org/2000/svg'),
Fill('none'),
ViewBox('0 0 24 24'),
StrokeWidth('1.5'),
Stroke('currentColor'),
],
[
path(
[
StrokeLinecap('round'),
StrokeLinejoin('round'),
D('M19.5 8.25l-7.5 7.5-7.5-7.5'),
],
[],
),
],
)
const filterButtonContent = (label: string): Html =>
div(
[Class('flex w-full items-center justify-between gap-4')],
[span([], [label]), chevronDown('w-4 h-4 text-gray-400')],
)
const filterButtonLabel = (
maybeSelectedItem: Option.Option<string>,
fallback: string,
): string =>
pipe(
maybeSelectedItem,
Option.filter(String.isNonEmpty),
Option.getOrElse(() => fallback),
)
const dietLabel = (item: string): string =>
String.isEmpty(item) ? 'All Diets' : item
const periodLabel = (item: string): string =>
String.isEmpty(item) ? 'All Periods' : item
const browseView = (model: Model, route: typeof BrowseRoute.Type): Html => {
const fields = routeToBrowseFields(route)
const results = filterAndSort(fields)
return div(
[Class('max-w-6xl mx-auto px-4')],
[
h1(
[Class('text-3xl font-bold text-gray-800 mb-2')],
['Dinosaur Explorer'],
),
p(
[Class('text-gray-500 mb-6')],
[
'Filter, sort, and search \u2014 every control syncs to the URL. Try changing the filters, then copy the URL or hit the back button.',
],
),
div(
[Class('flex flex-wrap items-start gap-3 mb-6')],
[
input([
Value(Option.getOrElse(fields.search, () => '')),
Placeholder('Search by name\u2026'),
OnInput(value => ChangedSearchInput({ value })),
Class(
'flex-1 min-w-48 px-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500',
),
]),
Ui.Listbox.view({
model: model.dietListbox,
toMessage: message => GotDietListboxMessage({ message }),
anchor: LISTBOX_ANCHOR,
items: dietFilterItems,
itemToConfig: item => filterItemConfig(dietLabel(item)),
itemToSearchText: dietLabel,
buttonContent: filterButtonContent(
filterButtonLabel(
model.dietListbox.maybeSelectedItem,
'All Diets',
),
),
buttonClassName: listboxButtonClassName,
itemsClassName: listboxItemsClassName,
backdropClassName: listboxBackdropClassName,
className: listboxWrapperClassName,
}),
Ui.Listbox.view({
model: model.periodListbox,
toMessage: message => GotPeriodListboxMessage({ message }),
anchor: LISTBOX_ANCHOR,
items: periodFilterItems,
itemToConfig: item => filterItemConfig(periodLabel(item)),
itemToSearchText: periodLabel,
buttonContent: filterButtonContent(
filterButtonLabel(
model.periodListbox.maybeSelectedItem,
'All Periods',
),
),
buttonClassName: listboxButtonClassName,
itemsClassName: listboxItemsClassName,
backdropClassName: listboxBackdropClassName,
className: listboxWrapperClassName,
}),
],
),
p(
[Class('text-sm text-gray-500 mb-3')],
[
`Showing ${Array.length(results)} of ${Array.length(dinosaurs)} dinosaurs`,
],
),
Array.match(results, {
onEmpty: () =>
div(
[Class('text-center py-12 text-gray-400')],
[
p([Class('text-lg')], ['No dinosaurs match your filters.']),
p(
[Class('text-sm mt-2')],
['Try broadening your search or removing filters.'],
),
],
),
onNonEmpty: rows =>
div(
[Class('overflow-x-auto rounded-lg border border-gray-200')],
[
table(
[Class('w-full')],
[
thead(
[Class('bg-gray-50 border-b border-gray-200')],
[
tr(
[],
[
sortableColumnHeader('Name', 'Name', fields, false),
sortableColumnHeader(
'Period',
'Period',
fields,
false,
),
sortableColumnHeader('Diet', 'Diet', fields, false),
sortableColumnHeader(
'Length',
'Length (m)',
fields,
true,
),
sortableColumnHeader(
'Weight',
'Weight (kg)',
fields,
true,
),
],
),
],
),
tbody(
[],
Array.map(rows, dinosaur =>
tr(
[
Class(
'border-b border-gray-100 hover:bg-gray-50 transition',
),
],
[
td(
[
Class(
clsx(
bodyCellClass,
'font-medium text-gray-900',
),
),
],
[dinosaur.name],
),
td(
[Class(bodyCellClass)],
[
span(
[Class(periodBadgeClass(dinosaur.period))],
[dinosaur.period],
),
],
),
td(
[Class(bodyCellClass)],
[
span(
[Class(dietBadgeClass(dinosaur.diet))],
[dinosaur.diet],
),
],
),
td(
[
Class(
clsx(bodyCellClass, 'text-right tabular-nums'),
),
],
[dinosaur.lengthMeters.toString()],
),
td(
[
Class(
clsx(bodyCellClass, 'text-right tabular-nums'),
),
],
[dinosaur.weightKg.toLocaleString()],
),
],
),
),
),
],
),
],
),
}),
p(
[Class('text-xs text-gray-400 mt-6 text-center')],
[
'All filter and sort state lives in the URL. Share it or bookmark it.',
],
),
],
)
}
const notFoundView = (path: string): Html =>
div(
[Class('max-w-4xl mx-auto px-4 text-center')],
[
h1(
[Class('text-4xl font-bold text-red-600 mb-6')],
['404 \u2014 Page Not Found'],
),
p(
[Class('text-lg text-gray-600 mb-4')],
[`The path "${path}" was not found.`],
),
a(
[
Href(browseRouter(emptyBrowseFields)),
Class('text-emerald-600 hover:underline'),
],
['\u2190 Back to Dinosaur Explorer'],
),
],
)
const view = (model: Model): Html => {
const routeContent = M.value(model.route).pipe(
M.tagsExhaustive({
Browse: route => browseView(model, route),
NotFound: ({ path }) => notFoundView(path),
}),
)
return div(
[Class('min-h-screen bg-gray-50')],
[
header(
[Class('bg-emerald-600 text-white px-6 py-4 mb-8 shadow-sm')],
[
div(
[Class('max-w-6xl mx-auto flex items-center gap-3')],
[
span([Class('text-lg font-semibold')], ['foldkit']),
span([Class('text-emerald-200 text-sm')], ['query-sync example']),
],
),
],
),
main(
[Class('pb-12')],
[keyed('div')(model.route._tag, [], [routeContent])],
),
],
)
}
// RUN
const app = Runtime.makeApplication({
Model,
init,
update,
view,
container: document.getElementById('root')!,
browser: {
onUrlRequest: request => ClickedLink({ request }),
onUrlChange: url => ChangedUrl({ url }),
},
})
Runtime.run(app)