foldkit-sidenav
v0.1.0
Published
Grouped side navigation for Foldkit: task groups that expand in place, an icon rail with an overlay, and a full mode for phone drawers.
Maintainers
Readme
foldkit-sidenav
Grouped side navigation for Foldkit, as a Submodel.
- Task groups that expand and collapse in place; a group may also be a single link (a leaf group).
- Three looks from one markup: a full sidebar, an icon rail that opens the full sidebar as an overlay, and a full mode for use inside a phone drawer.
- Entries are real
<a href>links, so navigation stays with the host's router. aria-expandedon group buttons,aria-current="page"on the current entry, every target at least 44 × 44 px.- The layout (full or rail) comes from a
matchMediaSubscription, soupdatestays pure.
No domain types: the host passes groups, labels, links and icons. License: MIT.
Install
pnpm add foldkit-sidenavPeer dependencies: effect (4.x), foldkit (0.158.x). Import the default look once:
import "foldkit-sidenav/styles.css"Usage
A complete host, compiled against the published package:
import { Option, Schema } from "effect"
import { Subscription, Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { defineMessageUnion } from "foldkit/message"
import { evo } from "foldkit/struct"
import * as Sidenav from "foldkit-sidenav"
// MODEL
const Model = Schema.Struct({ sidenav: Sidenav.Model })
type Model = typeof Model.Type
export const init = (): Model => ({ sidenav: Sidenav.init({ id: "main-nav" }) })
// MESSAGE
const Message = defineMessageUnion({
GotSidenavMessage: { message: Sidenav.Message },
ChangedRoute: { groupId: Schema.String }
})
type Message = typeof Message.Type
// UPDATE
const foldSidenav = Update.foldChild({
update: Sidenav.update,
read: (model: Model) => Option.some(model.sidenav),
write: (model: Model, sidenav: Sidenav.Model) => evo(model, { sidenav: () => sidenav }),
toParentMessage: (message: Sidenav.Message) => Message.GotSidenavMessage({ message })
})
export const update = (model: Model, message: Message): Update.Return<Model, Message> =>
Message.match<Update.Return<Model, Message>>(message, {
GotSidenavMessage: ({ message }) => foldSidenav(model, message),
// On entering a route, open the group that holds it:
ChangedRoute: ({ groupId }) => ({ model: evo(model, { sidenav: (nav) => Sidenav.reveal(nav, groupId) }) })
})
// SUBSCRIPTIONS
export const subscriptions = Subscription.lift(Sidenav.subscriptions)<Model, Message>({
toChildModel: (model) => model.sidenav,
toParentMessage: (message) => Message.GotSidenavMessage({ message })
})
// VIEW
const icon = (groupId: string, h: HtmlBuilder<Message>): Html => h.span([h.AriaHidden(true), h.DataAttribute("icon", groupId)], [])
export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
h.submodel({
slotId: "sidenav",
model: model.sidenav,
view: Sidenav.view,
toParentMessage: (message) => Message.GotSidenavMessage({ message }),
viewInputs: {
ariaLabel: "Main",
mode: "responsive", // "full" inside a drawer
currentItemId: Option.some("suppliers"),
groups: [
{
id: "stock",
label: "Stock",
items: [
{ id: "stock-levels", label: "Levels", href: "/stock" },
{ id: "suppliers", label: "Suppliers", href: "/suppliers", badge: "2", badgeLabel: "2 unpaid invoices" }
]
},
{ id: "reports", label: "Reports", href: "/reports", items: [] } // a leaf group: one link
],
groupIcon: (groupId) => icon(groupId, h),
header: () => h.div([], ["Acme"]),
footer: () => h.div([], ["Alice"])
}
})Markup the host owns (groupIcon, header, footer) is passed as top-level slot callbacks, so its
handlers dispatch through the host's boundary.
API
| Export | |
|---|---|
| init({ id, expandedGroupIds?, layout?, fullLayoutMinWidthPx? }) | layout is what to show until the Subscription reports (default "full"); fullLayoutMinWidthPx defaults to 1024. |
| update(model, message) | Returns { model, commands? }. The only Commands move focus when the rail overlay opens and closes (FocusNavigation); there are no OutMessages. |
| reveal(model, groupId) | Expands a group, leaving the others as they are. Call it on route entry. |
| collapse, isExpanded, groupPanelId, groupButtonId | Small helpers. |
| FocusNavigation | The focus Command, for hosts' Story tests: resolve it with Message.CompletedFocus(). |
| subscriptions | layout (the media query) and documentEscape (only while the overlay is open). |
| layoutChanges(minWidthPx) | The layout Stream on its own. |
| view | defineView with the ViewInputs below. |
Messages
| Message | Effect |
|---|---|
| PressedGroup{groupId} | A group button in responsive mode: in the rail it opens the overlay with the group expanded; otherwise it toggles the group. |
| ToggledGroup{groupId} | Expands or collapses the group. Sent by full mode. |
| OpenedRailOverlay{groupId} / ClosedRailOverlay | Open or close the overlay. Opening moves focus to the group's first entry; Escape and a tap on the backdrop close it and return focus to the rail button that opened it. |
| ChoseItem{itemId} | A link was followed; closes the overlay. The browser/router handles the navigation. |
| ChangedLayout{layout} | From the Subscription. Widening to full closes the overlay. |
| CompletedFocus | A focus move ran (or found no target). |
ViewInputs
| Field | |
|---|---|
| ariaLabel | Name of the <nav> landmark. |
| groups | { id, label, href?, items: { id, label, href, badge?, badgeLabel? }[] }[]. A group with href is a leaf link; its id is compared with currentItemId. badgeLabel is how a screen reader says the badge ("2 unpaid invoices"); it defaults to the badge. |
| currentItemId | Option<string>: the entry (or leaf group) of the current page. |
| mode | "responsive" or "full". |
| groupIcon(groupId) | The icon markup. |
| header(), footer() | Optional host markup above and below the groups. |
The host decides where the navigation shows: for example, hide it below 640 px and put a full-mode
navigation into a drawer there.
Styling
styles.css gives neutral defaults. Theme it with custom properties on any ancestor:
| Variable | Default | Used for |
|---|---|---|
| --fk-sidenav-bg | #ffffff | panel background |
| --fk-sidenav-fg | #1f2937 | group labels |
| --fk-sidenav-muted | #4b5563 | entry labels |
| --fk-sidenav-line | #e5e7eb | borders |
| --fk-sidenav-hover | #f3f4f6 | hovered row |
| --fk-sidenav-current-bg / --fk-sidenav-current-fg | #eef2ff / #1e3a8a | current entry; the rail icon of the current group |
| --fk-sidenav-badge-bg / --fk-sidenav-badge-fg | #1f2937 / #ffffff | badges, and the rail's badge dot |
| --fk-sidenav-focus | #2563eb | focus ring |
| --fk-sidenav-backdrop | rgba(0,0,0,.25) | behind the rail overlay |
| --fk-sidenav-overlay-shadow | 0 10px 30px rgba(0,0,0,.18) | the overlay panel |
| --fk-sidenav-width / --fk-sidenav-rail-width | 240px / 64px | widths |
| --fk-sidenav-radius | 8px | row corners |
Hooks for your own CSS:
- On
.fk-sidenav(the<nav>):data-mode(responsive|full),data-layout(full|rail),data-overlay-open(true|false). Use them to adapt the header and footer to the rail. - On group buttons and leaf links:
data-current(the group holds the current page) and, on buttons,data-badge(an entry in the group has a badge). - Classes:
fk-sidenav__panel,__header,__footer,__groups,__group,__group-button,__group-link,__items,__item,__icon,__label,__chevron,__badge,__sr-only,__backdrop.
Accessibility
- In the rail, labels are visually hidden but stay in the accessibility tree, so every icon button keeps
its name; a
titleshows the name to mouse users. aria-expandedon group buttons;aria-controlsnames the item list while it exists.- The current entry (or leaf group) carries
aria-current="page". - A badge is drawn
aria-hiddenand read after a comma from a visually hidden span (fk-sidenav__sr-only), so it is never glued to the label. - Opening the rail overlay moves focus into it; Escape or a tap outside returns focus to the rail button. Following an entry leaves focus to the host's router and its new page.
