Skip to main content
All Examples

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,
  Schema as S,
  SchemaTransformation,
  String,
  Types,
  pipe,
} from 'effect'
import { Command, Route, Runtime, Ui } from 'foldkit'
import { Document, Html, childAttributes, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { UrlRequest, 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.Literals(['Carnivore', 'Herbivore', 'Omnivore'])
const Period = S.Literals(['Triassic', 'Jurassic', 'Cretaceous'])
const SortColumn = S.Literals(['Name', 'Period', 'Diet', 'Length', 'Weight'])
type SortColumn = typeof SortColumn.Type

export const Unsorted = ts('Unsorted')
export const Ascending = ts('Ascending', { column: SortColumn })
export 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.Codec<A, A>) => {
  const decode = S.decodeUnknownOption(schema)

  return S.OptionFromOptional(S.String).pipe(
    S.decodeTo(
      S.Option(schema),
      SchemaTransformation.transform({
        decode: (maybeRaw: Option.Option<string>): Option.Option<A> =>
          Option.flatMap(maybeRaw, decode),
        encode: (maybeValue: Option.Option<A>): Option.Option<string> =>
          maybeValue,
      }),
    ),
  )
}

const SortDirection = S.Literals(['Ascending', 'Descending'])

const sortingFromParam = (() => {
  const decodeColumn = S.decodeUnknownOption(SortColumn)
  const decodeDirection = S.decodeUnknownOption(SortDirection)

  return S.OptionFromOptional(S.String).pipe(
    S.decodeTo(
      Sorting,
      SchemaTransformation.transform({
        decode: (maybeRaw: Option.Option<string>): Sorting =>
          Option.match(maybeRaw, {
            onNone: () => Unsorted(),
            onSome: value => {
              const parts = String.split(value, SORT_PARAM_SEPARATOR)

              return pipe(
                Option.all({
                  column: pipe(
                    parts,
                    Array.get(0),
                    Option.flatMap(decodeColumn),
                  ),
                  direction: pipe(
                    parts,
                    Array.get(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): Option.Option<string> =>
          M.value(sorting).pipe(
            M.withReturnType<Option.Option<string>>(),
            M.tagsExhaustive({
              Unsorted: () => Option.none(),
              Ascending: ({ column }) =>
                Option.some(`${column}${SORT_PARAM_SEPARATOR}Ascending`),
              Descending: ({ column }) =>
                Option.some(`${column}${SORT_PARAM_SEPARATOR}Descending`),
            }),
          ),
      }),
    ),
  )
})()

export const BrowseRoute = r('Browse', {
  search: S.Option(S.String),
  sorting: Sorting,
  diet: S.Option(Diet),
  period: S.Option(Period),
})

export 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.OptionFromOptional(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

export const Model = S.Struct({
  route: AppRoute,
  dietListbox: Ui.Listbox.Model,
  periodListbox: Ui.Listbox.Model,
})
export type Model = typeof Model.Type

// MESSAGE

export const CompletedNavigateInternal = m('CompletedNavigateInternal')
export const CompletedLoadExternal = m('CompletedLoadExternal')
export const CompletedReplaceUrl = m('CompletedReplaceUrl')
export const ClickedLink = m('ClickedLink', { request: UrlRequest })
export const ChangedUrl = m('ChangedUrl', { url: Url })
export const ChangedSearchInput = m('ChangedSearchInput', { value: S.String })
export const ClickedColumnHeader = m('ClickedColumnHeader', {
  column: SortColumn,
})
export const GotDietListboxMessage = m('GotDietListboxMessage', {
  message: Ui.Listbox.Message,
})
export const GotPeriodListboxMessage = m('GotPeriodListboxMessage', {
  message: Ui.Listbox.Message,
})

export const Message = S.Union([
  CompletedNavigateInternal,
  CompletedLoadExternal,
  CompletedReplaceUrl,
  ClickedLink,
  ChangedUrl,
  ChangedSearchInput,
  ClickedColumnHeader,
  GotDietListboxMessage,
  GotPeriodListboxMessage,
])
export 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),
  )

export const init: Runtime.RoutingApplicationInit<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.Codec<A, A>,
): Option.Option<A> => {
  const decode = S.decodeUnknownOption(schema)

  return pipe(
    maybeSelectedItem,
    Option.filter(String.isNonEmpty),
    Option.flatMap(value => decode(value)),
  )
}

export const ReplaceFilters = Command.define(
  'ReplaceFilters',
  {
    search: S.Option(S.String),
    sorting: Sorting,
    diet: S.Option(Diet),
    period: S.Option(Period),
  },
  CompletedReplaceUrl,
)(fields =>
  replaceUrl(browseRouter(fields)).pipe(Effect.as(CompletedReplaceUrl())),
)

const NavigateInternal = Command.define(
  'NavigateInternal',
  { url: S.String },
  CompletedNavigateInternal,
)(({ url }) => pushUrl(url).pipe(Effect.as(CompletedNavigateInternal())))

const LoadExternal = Command.define(
  'LoadExternal',
  { href: S.String },
  CompletedLoadExternal,
)(({ href }) => load(href).pipe(Effect.as(CompletedLoadExternal())))

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({
      CompletedNavigateInternal: () => [model, []],
      CompletedLoadExternal: () => [model, []],
      CompletedReplaceUrl: () => [model, []],

      ClickedLink: ({ request }) =>
        M.value(request).pipe(
          withUpdateReturn,
          M.tagsExhaustive({
            Internal: ({ url }) => [
              model,
              [NavigateInternal({ url: urlToString(url) })],
            ],
            External: ({ href }) => [model, [LoadExternal({ href })]],
          }),
        ),

      ChangedUrl: ({ url }) => {
        const nextRoute = urlToAppRoute(url)
        const fields = routeToBrowseFields(nextRoute)

        return [
          evo(model, {
            route: () => nextRoute,
            dietListbox: DietListbox.reflectSelectedItem(
              Option.orElse(fields.diet, () => Option.some('')),
            ),
            periodListbox: PeriodListbox.reflectSelectedItem(
              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, maybeOutMessage] =
          DietListbox.update(model.dietListbox, message)
        const mappedCommands = Command.mapMessages(listboxCommands, message =>
          GotDietListboxMessage({ message }),
        )

        return Option.match(maybeOutMessage, {
          onNone: (): UpdateReturn => [
            evo(model, { dietListbox: () => nextDietListbox }),
            mappedCommands,
          ],
          onSome: M.type<Ui.Listbox.OutMessage>().pipe(
            M.withReturnType<UpdateReturn>(),
            M.tagsExhaustive({
              Selected: () => {
                const fields = routeToBrowseFields(model.route)
                return [
                  evo(model, { dietListbox: () => nextDietListbox }),
                  [
                    ...mappedCommands,
                    ReplaceFilters({
                      ...fields,
                      diet: selectionToParam(
                        nextDietListbox.maybeSelectedItem,
                        Diet,
                      ),
                    }),
                  ],
                ]
              },
            }),
          ),
        })
      },

      GotPeriodListboxMessage: ({ message }) => {
        const [nextPeriodListbox, listboxCommands, maybeOutMessage] =
          PeriodListbox.update(model.periodListbox, message)
        const mappedCommands = Command.mapMessages(listboxCommands, message =>
          GotPeriodListboxMessage({ message }),
        )

        return Option.match(maybeOutMessage, {
          onNone: (): UpdateReturn => [
            evo(model, { periodListbox: () => nextPeriodListbox }),
            mappedCommands,
          ],
          onSome: M.type<Ui.Listbox.OutMessage>().pipe(
            M.withReturnType<UpdateReturn>(),
            M.tagsExhaustive({
              Selected: () => {
                const fields = routeToBrowseFields(model.route)
                return [
                  evo(model, { periodListbox: () => nextPeriodListbox }),
                  [
                    ...mappedCommands,
                    ReplaceFilters({
                      ...fields,
                      period: selectionToParam(
                        nextPeriodListbox.maybeSelectedItem,
                        Period,
                      ),
                    }),
                  ],
                ]
              },
            }),
          ),
        })
      },
    }),
  )

// VIEW

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.flip(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', () => '↑'),
    M.when('Descending', () => '↓'),
    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 dinosaurRowView = (dinosaur: Dinosaur): Html => {
  const h = html<Message>()
  return h.keyed('tr')(
    dinosaur.name,
    [h.Class('border-b border-gray-100 hover:bg-gray-50 transition')],
    [
      h.td(
        [h.Class(clsx(bodyCellClass, 'font-medium text-gray-900'))],
        [dinosaur.name],
      ),
      h.td(
        [h.Class(bodyCellClass)],
        [
          h.span(
            [h.Class(periodBadgeClass(dinosaur.period))],
            [dinosaur.period],
          ),
        ],
      ),
      h.td(
        [h.Class(bodyCellClass)],
        [h.span([h.Class(dietBadgeClass(dinosaur.diet))], [dinosaur.diet])],
      ),
      h.td(
        [h.Class(clsx(bodyCellClass, 'text-right tabular-nums'))],
        [dinosaur.lengthMeters.toString()],
      ),
      h.td(
        [h.Class(clsx(bodyCellClass, 'text-right tabular-nums'))],
        [dinosaur.weightKg.toLocaleString()],
      ),
    ],
  )
}

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 h = html<Message>()

  const indicator = h.span(
    [h.Class(clsx(SORT_INDICATOR_WIDTH, 'inline-block text-center'))],
    [sortIndicator(column, fields.sorting)],
  )
  const label = h.span([], [displayLabel])
  const alignment = isRightAligned ? 'text-right' : 'text-left'

  return h.th(
    [h.AriaSort(ariaSortValue(column, fields.sorting))],
    [
      h.button(
        [
          h.Type('button'),
          h.OnClick(ClickedColumnHeader({ column })),
          h.AriaLabel(sortAriaLabel(column, fields.sorting)),
          h.Class(clsx(headerButtonClass, alignment)),
        ],
        isRightAligned ? [indicator, label] : [label, indicator],
      ),
    ],
  )
}

const LISTBOX_ANCHOR: AnchorConfig = {
  placement: 'bottom-start',
  gap: 4,
  padding: 8,
}

const DietListbox = Ui.Listbox.create<string>()
const PeriodListbox = Ui.Listbox.create<string>()

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 => {
  const h = html<Message>()

  return {
    className: listboxItemClassName,
    content: h.div(
      [h.Class('flex items-center gap-2')],
      [
        h.span(
          [
            h.Class(
              'w-4 text-center text-emerald-600 invisible group-data-[selected]:visible',
            ),
          ],
          ['✓'],
        ),
        h.span([], [label]),
      ],
    ),
  }
}

const chevronDown = (className: string): Html => {
  const h = html<Message>()

  return h.svg(
    [
      h.AriaHidden(true),
      h.Class(className),
      h.Xmlns('http://www.w3.org/2000/svg'),
      h.Fill('none'),
      h.ViewBox('0 0 24 24'),
      h.StrokeWidth('1.5'),
      h.Stroke('currentColor'),
    ],
    [
      h.path(
        [
          h.StrokeLinecap('round'),
          h.StrokeLinejoin('round'),
          h.D('M19.5 8.25l-7.5 7.5-7.5-7.5'),
        ],
        [],
      ),
    ],
  )
}

const filterButtonContent = (label: string): Html => {
  const h = html<Message>()

  return h.div(
    [h.Class('flex w-full items-center justify-between gap-4')],
    [h.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 h = html<Message>()

  const fields = routeToBrowseFields(route)
  const results = filterAndSort(fields)

  return h.div(
    [h.Class('max-w-6xl mx-auto px-4')],
    [
      h.h1(
        [h.Class('text-3xl font-bold text-gray-800 mb-2')],
        ['Dinosaur Explorer'],
      ),
      h.p(
        [h.Class('text-gray-500 mb-6')],
        [
          'Filter, sort, and search. Every control syncs to the URL. Try changing the filters, then copy the URL or hit the back button.',
        ],
      ),

      h.div(
        [h.Class('flex flex-wrap items-start gap-3 mb-6')],
        [
          h.input([
            h.Value(Option.getOrElse(fields.search, () => '')),
            h.Placeholder('Search by name…'),
            h.OnInput(value => ChangedSearchInput({ value })),
            h.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',
            ),
          ]),
          h.submodel({
            slotId: model.dietListbox.id,
            model: model.dietListbox,
            view: DietListbox.view,
            viewInputs: {
              anchor: LISTBOX_ANCHOR,
              items: dietFilterItems,
              itemToConfig: item => filterItemConfig(dietLabel(item)),
              itemToSearchText: dietLabel,
              buttonContent: filterButtonContent(
                filterButtonLabel(
                  model.dietListbox.maybeSelectedItem,
                  'All Diets',
                ),
              ),
              buttonAttributes: childAttributes([
                h.Class(listboxButtonClassName),
              ]),
              itemsAttributes: childAttributes([
                h.Class(listboxItemsClassName),
              ]),
              backdropAttributes: childAttributes([
                h.Class(listboxBackdropClassName),
              ]),
              attributes: childAttributes([h.Class(listboxWrapperClassName)]),
            },
            toParentMessage: message => GotDietListboxMessage({ message }),
          }),
          h.submodel({
            slotId: model.periodListbox.id,
            model: model.periodListbox,
            view: PeriodListbox.view,
            viewInputs: {
              anchor: LISTBOX_ANCHOR,
              items: periodFilterItems,
              itemToConfig: item => filterItemConfig(periodLabel(item)),
              itemToSearchText: periodLabel,
              buttonContent: filterButtonContent(
                filterButtonLabel(
                  model.periodListbox.maybeSelectedItem,
                  'All Periods',
                ),
              ),
              buttonAttributes: childAttributes([
                h.Class(listboxButtonClassName),
              ]),
              itemsAttributes: childAttributes([
                h.Class(listboxItemsClassName),
              ]),
              backdropAttributes: childAttributes([
                h.Class(listboxBackdropClassName),
              ]),
              attributes: childAttributes([h.Class(listboxWrapperClassName)]),
            },
            toParentMessage: message => GotPeriodListboxMessage({ message }),
          }),
        ],
      ),

      h.p(
        [h.Class('text-sm text-gray-500 mb-3')],
        [
          `Showing ${Array.length(results)} of ${Array.length(dinosaurs)} dinosaurs`,
        ],
      ),

      Array.match(results, {
        onEmpty: () =>
          h.div(
            [h.Class('text-center py-12 text-gray-400')],
            [
              h.p([h.Class('text-lg')], ['No dinosaurs match your filters.']),
              h.p(
                [h.Class('text-sm mt-2')],
                ['Try broadening your search or removing filters.'],
              ),
            ],
          ),
        onNonEmpty: rows =>
          h.div(
            [h.Class('overflow-x-auto rounded-lg border border-gray-200')],
            [
              h.table(
                [h.Class('w-full')],
                [
                  h.thead(
                    [h.Class('bg-gray-50 border-b border-gray-200')],
                    [
                      h.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,
                          ),
                        ],
                      ),
                    ],
                  ),
                  h.tbody([], rows.map(dinosaurRowView)),
                ],
              ),
            ],
          ),
      }),

      h.p(
        [h.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 => {
  const h = html<Message>()

  return h.div(
    [h.Class('max-w-4xl mx-auto px-4 text-center')],
    [
      h.h1(
        [h.Class('text-4xl font-bold text-red-600 mb-6')],
        ['404 — Page Not Found'],
      ),
      h.p(
        [h.Class('text-lg text-gray-600 mb-4')],
        [`The path "${path}" was not found.`],
      ),
      h.a(
        [
          h.Href(browseRouter(emptyBrowseFields)),
          h.Class('text-emerald-600 hover:underline'),
        ],
        ['← Back to Dinosaur Explorer'],
      ),
    ],
  )
}

const routeTitle = (route: Model['route']): string =>
  M.value(route).pipe(
    M.tag('Browse', () => 'Dinosaur Explorer'),
    M.orElse(() => 'Not Found | Dinosaur Explorer'),
  )

export const view = (model: Model): Document => {
  const h = html<Message>()

  const routeContent = M.value(model.route).pipe(
    M.tagsExhaustive({
      Browse: route => browseView(model, route),
      NotFound: ({ path }) => notFoundView(path),
    }),
  )

  const body = h.div(
    [h.Class('min-h-screen bg-gray-50')],
    [
      h.header(
        [h.Class('bg-emerald-600 text-white px-6 py-4 mb-8 shadow-sm')],
        [
          h.div(
            [h.Class('max-w-6xl mx-auto flex items-center gap-3')],
            [
              h.span([h.Class('text-lg font-semibold')], ['foldkit']),
              h.span(
                [h.Class('text-emerald-200 text-sm')],
                ['query-sync example'],
              ),
            ],
          ),
        ],
      ),
      h.main(
        [h.Class('pb-12')],
        [h.keyed('div')(model.route._tag, [], [routeContent])],
      ),
    ],
  )

  return { title: routeTitle(model.route), body }
}