Style @foldkit/ui with Pleat
Foldkit UI handles behavior and accessibility, and describes each part’s state with data attributes. Pleat’s When conditions select those attributes, so a part’s states live in its recipe and the view only spreads attributes.
The pattern
- One recipe per part. A Menu has a trigger, a panel, and items; each gets its own style, and parts that look alike share one.
- State through conditions, not the view. A highlighted item is
When.highlighted, an open trigger isWhen.open. The view never picks a class from the Model to show a state. - Relations for children. A switch thumb or a chevron has no state of its own.
When.within(marker, When.checked)moves it when the part it sits in changes. - Transitions as states. The open look is the base, the closed look sits under
When.closed, and thetransitionunderWhen.transitioning. - Tokens and variants. Every color, space, and radius is a theme token, so the dark theme works without a line of its own. Variants are recipe props with a Schema, like
toneorappearance.
The demos below share these parts. Switch to the dark theme at the top of the page to see the same styles under it.
import { Color, Recipe, Style, When } from '@pleat/core'
import { focusRing, tokens } from '../../design.ts'
const { color, font, radius, shadow, space, text } = tokens
// BUTTONS
export const uiButton = Recipe.make({
name: 'UiButton',
base: Style.make({
display: 'inline-flex',
alignItems: 'center',
gap: space[2],
border: '1px solid transparent',
borderRadius: radius.md,
fontFamily: font.sans,
fontWeight: 600,
lineHeight: 1,
cursor: 'pointer',
}).pipe(
Style.merge(focusRing),
Style.when(When.motionSafe, {
transition: 'background-color 140ms, border-color 140ms',
}),
Style.when(When.active, { translate: '0 1px' }),
// Foldkit UI sets data-disabled and aria-disabled. Disabled outranks
// hover and press, so these win whatever else holds.
Style.when(When.disabled, {
opacity: 0.5,
cursor: 'not-allowed',
translate: 'none',
}),
),
variants: {
tone: {
Primary: Style.make({
backgroundColor: color.accent,
color: color.onAccent,
}).pipe(
Style.when(When.hover, {
backgroundColor: Color.darken(color.accent, 0.06),
}),
),
Neutral: Style.make({
backgroundColor: color.surface,
borderColor: color.line,
color: color.ink,
}).pipe(
Style.when(When.hover, { borderColor: color.muted }),
Style.when(When.open, { borderColor: color.accent }),
),
Quiet: Style.make({
backgroundColor: 'transparent',
color: color.ink,
}).pipe(
Style.when(When.hover, { backgroundColor: color.sunken }),
),
},
size: {
Small: {
paddingBlock: space[2],
paddingInline: space[3],
fontSize: text.sm,
},
Medium: {
paddingBlock: space[3],
paddingInline: space[4],
fontSize: text.md,
},
},
},
defaults: { tone: 'Neutral', size: 'Small' },
})
// TRIGGERS
export const trigger = When.marker('ui-trigger')
export const triggerButton = uiButton({ tone: 'Neutral' }).pipe(
Style.merge(Style.mark(trigger)),
Style.merge(
Style.make({
justifyContent: 'space-between',
minWidth: '13rem',
fontWeight: 500,
}),
),
)
/** Points down, and up while the trigger it sits in is open. */
export const chevron = Style.make({
width: 7,
height: 7,
marginTop: -3,
borderRight: `1.5px solid ${color.muted}`,
borderBottom: `1.5px solid ${color.muted}`,
rotate: '45deg',
flexShrink: 0,
}).pipe(
Style.when(When.motionSafe, {
transition: 'rotate 160ms, margin 160ms',
}),
Style.when(When.within(trigger, When.open), {
rotate: '225deg',
marginTop: 3,
}),
)
// FLOATING PANELS
/** Menu, Listbox, and Combobox panels. Foldkit UI positions them inline, so this
* leaves position alone and styles the surface and its transition. */
export const panel = Style.make({
minWidth: 'var(--button-width, 13rem)',
padding: space[1],
backgroundColor: color.surface,
border: `1px solid ${color.line}`,
borderRadius: radius.md,
boxShadow: shadow.raised,
outline: 'none',
zIndex: 30,
transformOrigin: 'top',
}).pipe(
// Foldkit UI writes the side it placed the panel on to data-placement.
Style.when(When.data('placement', 'top'), {
transformOrigin: 'bottom',
}),
// The panel's open look is its base. data-closed holds at the start of
// entering and the end of leaving, and data-transition holds throughout.
Style.when(When.all(When.motionSafe, When.transitioning), {
transition: 'opacity 150ms ease, scale 150ms ease',
}),
Style.when(When.closed, { opacity: 0, scale: '0.96' }),
)
// OPTIONS
export const option = When.marker('ui-option')
export const optionItem = Recipe.make({
name: 'Option',
base: Style.make({
display: 'flex',
alignItems: 'center',
gap: space[2],
paddingBlock: space[2],
paddingInline: space[3],
borderRadius: 6,
fontSize: text.sm,
color: color.ink,
cursor: 'default',
userSelect: 'none',
}).pipe(
Style.merge(Style.mark(option)),
// data-active: the item the pointer or the arrow keys are on.
Style.when(When.highlighted, { backgroundColor: color.sunken }),
Style.when(When.disabled, { opacity: 0.45 }),
),
variants: {
tone: {
Default: Style.empty,
Danger: Style.make({ color: color.accent }).pipe(
Style.when(When.highlighted, {
backgroundColor: color.accentSoft,
}),
),
},
},
defaults: { tone: 'Default' },
})
/** A check mark that shows when the option it sits in is selected. */
export const optionCheck = Style.make({
width: 5,
height: 9,
marginInlineStart: 'auto',
marginTop: -3,
borderRight: `2px solid ${color.accent}`,
borderBottom: `2px solid ${color.accent}`,
rotate: '45deg',
opacity: 0,
}).pipe(
Style.when(When.within(option, When.selected), { opacity: 1 }),
)
export const separator = Style.make({
height: 1,
marginBlock: space[1],
marginInline: space[2],
backgroundColor: color.line,
})
// FIELDS
export const field = Style.make({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: space[2],
})
export const fieldLabel = Style.make({
fontSize: text.sm,
fontWeight: 600,
color: color.ink,
})
export const hint = Style.make({
fontSize: text.xs,
color: color.muted,
})
export const cluster = Style.make({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: space[3],
})
export const column = Style.make({
display: 'flex',
flexDirection: 'column',
gap: space[4],
width: '100%',
maxWidth: '26rem',
})
/** Content that fills a trigger or an option, with its ends pushed apart. */
export const spread = Style.make({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: space[3],
width: '100%',
})What Foldkit UI sets
Each condition below was checked against the attributes @foldkit/ui 0.167 actually renders, and a browser test drives a Menu by keyboard to confirm the styles apply.
| Condition | Selects | Foldkit UI sets it on |
|---|---|---|
| When.open | [data-open], [aria-expanded="true"] | Menu, Listbox, and Combobox wrapper and trigger; Disclosure button and panel; Dialog; Tooltip trigger and panel; Popover button |
| When.highlighted | [data-active] | The Menu, Listbox, or Combobox item under the pointer or the arrow keys |
| When.selected | [data-selected], [aria-selected="true"] | Tabs tab and panel; Listbox and Combobox option |
| When.checked | :checked, [data-checked], [aria-checked="true"] | Switch; Checkbox; RadioGroup option |
| When.indeterminate | :indeterminate, [data-indeterminate], [aria-checked="mixed"] | A mixed Checkbox |
| When.disabled | :disabled, [data-disabled], [aria-disabled="true"] | Every component that can be disabled |
| When.invalid | [data-invalid], [aria-invalid="true"] | Select, Listbox, Combobox, Input |
| When.readonly | [data-readonly], [aria-readonly="true"] | Switch, Checkbox, Listbox, Combobox, RadioGroup |
| When.closed | [data-closed] | Animated panels at the start of entering and the end of leaving |
| When.transitioning | [data-transition] | Animated panels while they enter or leave |
| When.data("placement", side) | [data-placement="top"] | Anchored panels, by the side they open on |
- Dialog calls
show(), notshowModal(), soWhen.backdropnever applies. Style itsbackdroppart instead. - In a RadioGroup,
data-activemarks the option holding the tab stop even when the group has no focus. UseWhen.focusVisiblethere. - Inline styles beat classes. Foldkit UI positions floating panels inline, gives an open Menu or Listbox trigger
position: relativeand a z-index, and animates a Disclosure panel’s height inline.
Button
Button gives a button its type, tab stop, and disabled attributes. When.disabled outranks hover and press, so a disabled button stays still.
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Button } from '@foldkit/ui'
import { css } from '@pleat/foldkit'
import { Message, type Model } from '../../demo/foldkitUi.ts'
import { cluster, uiButton } from './kit.ts'
export const buttonDemo = (
model: Model,
h: HtmlBuilder<Message>,
): Html =>
h.div(
[...css(cluster)],
[
Button.view(
{
onClick: Message.ClickedSave(),
toView: ({ button }) =>
h.button(
[
...button,
...css(
uiButton({ tone: 'Primary', size: 'Medium' }),
),
],
[`Save (${model.saveCount})`],
),
},
h,
),
Button.view(
{
onClick: Message.ClickedSave(),
toView: ({ button }) =>
h.button(
[...button, ...css(uiButton({ size: 'Medium' }))],
['Save a copy'],
),
},
h,
),
// isDisabled sets data-disabled and aria-disabled, and drops onClick.
Button.view(
{
isDisabled: true,
toView: ({ button }) =>
h.button(
[
...button,
...css(
uiButton({ tone: 'Primary', size: 'Medium' }),
),
],
['Publish'],
),
},
h,
),
],
)Switch
The track reads When.checked. The thumb sits inside a marked track and moves under When.within(track, When.checked). size is a variant.
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Switch } from '@foldkit/ui'
import { Recipe, Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'
import { Message, type Model } from '../../demo/foldkitUi.ts'
import { focusRing, tokens } from '../../design.ts'
import { column, hint } from './kit.ts'
const { color, space, text } = tokens
// PARTS
const track = When.marker('ui-switch')
const switchTrack = Recipe.make({
name: 'Switch',
base: Style.make({
display: 'inline-flex',
flexShrink: 0,
padding: 2,
border: 'none',
borderRadius: 999,
backgroundColor: color.line,
cursor: 'pointer',
}).pipe(
Style.merge(Style.mark(track)),
Style.merge(focusRing),
Style.when(When.motionSafe, {
transition: 'background-color 160ms',
}),
Style.when(When.checked, { backgroundColor: color.accent }),
Style.when(When.disabled, {
opacity: 0.5,
cursor: 'not-allowed',
}),
),
variants: {
size: {
Small: { width: 32, height: 18 },
Medium: { width: 44, height: 24 },
},
},
defaults: { size: 'Medium' },
})
// The thumb has no state of its own: it moves when the track it sits in is checked.
const switchThumb = Style.make({
height: '100%',
aspectRatio: '1',
borderRadius: 999,
backgroundColor: color.surface,
boxShadow: `0 1px 2px ${color.muted}`,
}).pipe(
Style.when(When.motionSafe, { transition: 'translate 160ms' }),
Style.when(When.within(track, When.checked), {
translate: '100%',
}),
)
const switchRow = Style.make({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: space[4],
})
const switchLabel = Style.make({
fontSize: text.sm,
fontWeight: 600,
cursor: 'pointer',
})
// VIEW
type SwitchConfig = Readonly<{
id: string
label: string
description: string
isChecked: boolean
onToggle: (isChecked: boolean) => Message
isDisabled?: boolean
size?: 'Small' | 'Medium'
}>
const labeledSwitch = (
config: SwitchConfig,
h: HtmlBuilder<Message>,
): Html =>
Switch.view(
{
id: config.id,
isChecked: config.isChecked,
onToggle: config.onToggle,
isDisabled: config.isDisabled ?? false,
hasDescription: true,
toView: attributes =>
h.div(
[...css(switchRow)],
[
h.div(
[],
[
h.div(
[...attributes.label, ...css(switchLabel)],
[config.label],
),
h.div(
[...attributes.description, ...css(hint)],
[config.description],
),
],
),
h.button(
[
...attributes.button,
...css(
switchTrack({ size: config.size ?? 'Medium' }),
),
],
[h.span([...css(switchThumb)])],
),
],
),
},
h,
)
export const switchDemo = (
model: Model,
h: HtmlBuilder<Message>,
): Html =>
h.div(
[...css(column)],
[
labeledSwitch(
{
id: 'ui-switch-digest',
label: 'Weekly digest',
description: 'A summary of new patterns every Monday.',
isChecked: model.isDigestOn,
onToggle: isChecked =>
Message.ToggledDigest({ isChecked }),
},
h,
),
labeledSwitch(
{
id: 'ui-switch-mentions',
label: 'Mentions',
description: 'When someone tags you in a comment.',
isChecked: model.isMentionsOn,
onToggle: isChecked =>
Message.ToggledMentions({ isChecked }),
size: 'Small',
},
h,
),
labeledSwitch(
{
id: 'ui-switch-billing',
label: 'Billing alerts',
description: 'Required while you have an open invoice.',
isChecked: true,
onToggle: isChecked =>
Message.ToggledMentions({ isChecked }),
isDisabled: true,
},
h,
),
],
)Checkbox
A parent checkbox is mixed while some of its steps are done. Foldkit UI marks that with data-indeterminate and aria-checked="mixed", which When.indeterminate selects.
import { Array } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Checkbox } from '@foldkit/ui'
import { Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'
import { Message, type Model, Step } from '../../demo/foldkitUi.ts'
import { focusRing, tokens } from '../../design.ts'
const { color, radius, space, text } = tokens
// PARTS
const box = When.marker('ui-checkbox')
const checkboxBox = Style.make({
display: 'inline-grid',
placeItems: 'center',
flexShrink: 0,
width: 18,
height: 18,
padding: 0,
borderRadius: radius.sm,
border: `1.5px solid ${color.muted}`,
backgroundColor: color.surface,
cursor: 'pointer',
}).pipe(
Style.merge(Style.mark(box)),
Style.merge(focusRing),
Style.when(When.checked, {
backgroundColor: color.accent,
borderColor: color.accent,
}),
// A mixed checkbox gets data-indeterminate and aria-checked="mixed",
// never data-checked, so it needs its own condition.
Style.when(When.indeterminate, {
backgroundColor: color.accent,
borderColor: color.accent,
}),
Style.when(When.disabled, {
opacity: 0.5,
cursor: 'not-allowed',
}),
)
const glyph = Style.make({ gridArea: '1 / 1', opacity: 0 })
const tick = Style.merge(
glyph,
Style.make({
width: 5,
height: 9,
marginTop: -2,
borderRight: `2px solid ${color.onAccent}`,
borderBottom: `2px solid ${color.onAccent}`,
rotate: '45deg',
}),
).pipe(Style.when(When.within(box, When.checked), { opacity: 1 }))
const dash = Style.merge(
glyph,
Style.make({
width: 8,
height: 2,
borderRadius: 1,
backgroundColor: color.onAccent,
}),
).pipe(
Style.when(When.within(box, When.indeterminate), { opacity: 1 }),
)
const checkboxRow = Style.make({
display: 'flex',
alignItems: 'center',
gap: space[3],
fontSize: text.sm,
})
const checklist = Style.make({
display: 'flex',
flexDirection: 'column',
gap: space[3],
})
const nested = Style.merge(
checklist,
Style.make({ paddingInlineStart: space[6] }),
)
// VIEW
type CheckboxConfig = Readonly<{
id: string
label: string
isChecked: boolean
isIndeterminate?: boolean
onToggle: (isChecked: boolean) => Message
}>
const labeledCheckbox = (
config: CheckboxConfig,
h: HtmlBuilder<Message>,
): Html =>
Checkbox.view(
{
id: config.id,
isChecked: config.isChecked,
isIndeterminate: config.isIndeterminate ?? false,
onToggle: config.onToggle,
toView: attributes =>
h.div(
[...css(checkboxRow)],
[
h.button(
[...attributes.checkbox, ...css(checkboxBox)],
[h.span([...css(tick)]), h.span([...css(dash)])],
),
h.span([...attributes.label], [config.label]),
],
),
},
h,
)
export const checkboxDemo = (
model: Model,
h: HtmlBuilder<Message>,
): Html => {
const doneCount = model.doneSteps.length
const isAllDone = doneCount === Step.literals.length
return h.div(
[...css(checklist)],
[
labeledCheckbox(
{
id: 'ui-checkbox-all',
label: 'Finish the hem',
isChecked: isAllDone,
isIndeterminate: doneCount > 0 && !isAllDone,
onToggle: () =>
Message.ToggledAllSteps({ isChecked: !isAllDone }),
},
h,
),
h.div(
[...css(nested)],
Step.literals.map(step =>
labeledCheckbox(
{
id: `ui-checkbox-${step.toLowerCase()}`,
label: step,
isChecked: Array.contains(model.doneSteps, step),
onToggle: isChecked =>
Message.ToggledStep({ step, isChecked }),
},
h,
),
),
),
],
)
}Disclosure
The button and panel get data-open. The chevron is the shared one: it turns inside any open trigger.
import { Array } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Disclosure } from '@foldkit/ui'
import { Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'
import {
Message,
type Model,
type Question,
} from '../../demo/foldkitUi.ts'
import { focusRing, tokens } from '../../design.ts'
import { chevron, trigger } from './kit.ts'
const { color, radius, space, text } = tokens
// PARTS
const disclosureList = Style.make({
width: '100%',
maxWidth: '34rem',
border: `1px solid ${color.line}`,
borderRadius: radius.lg,
backgroundColor: color.surface,
overflow: 'hidden',
})
const disclosureItem = Style.empty.pipe(
Style.when(When.not(When.firstChild), {
borderTop: `1px solid ${color.line}`,
}),
)
// The button carries data-open and aria-expanded, so the shared chevron,
// which turns inside an open trigger, works here unchanged.
const disclosureButton = Style.make({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: space[3],
width: '100%',
paddingBlock: space[3],
paddingInline: space[4],
border: 'none',
backgroundColor: 'transparent',
color: color.ink,
fontFamily: 'inherit',
fontSize: text.sm,
fontWeight: 600,
textAlign: 'start',
cursor: 'pointer',
}).pipe(
Style.merge(Style.mark(trigger)),
Style.merge(focusRing),
Style.when(When.hover, { backgroundColor: color.sunken }),
Style.when(When.open, { color: color.accent }),
)
// animatePanel animates the height with an inline grid, so the panel only
// styles its padding and type.
const disclosurePanel = Style.make({
paddingInline: space[4],
paddingBottom: space[4],
fontSize: text.sm,
color: color.muted,
})
// VIEW
const QUESTIONS: ReadonlyArray<
readonly [Question, string, string]
> = [
[
'Attributes',
'Which attributes does Foldkit UI set?',
'data-open, data-active, data-selected, data-checked, data-disabled, and the ARIA states beside them. Each has a When condition of the same name.',
],
[
'Transitions',
'How do transitions work?',
'Animated parts get data-closed at the start of entering and the end of leaving, with data-transition throughout. Style the open look as the base and the closed look under When.closed.',
],
[
'Inline',
'Is there anything Pleat can’t style?',
'Inline styles win over classes. Foldkit UI positions floating panels inline, and animates this panel’s height inline, so leave those properties to it.',
],
]
export const disclosureDemo = (
model: Model,
h: HtmlBuilder<Message>,
): Html =>
h.div(
[...css(disclosureList)],
QUESTIONS.map(([question, title, answer]) =>
Disclosure.view(
{
id: `ui-disclosure-${question.toLowerCase()}`,
isOpen: Array.contains(model.openQuestions, question),
onToggle: isOpen =>
Message.ToggledQuestion({ question, isOpen }),
toView: ({ button, panel, animatePanel }) =>
h.div(
[...css(disclosureItem)],
[
h.button(
[...button, ...css(disclosureButton)],
[title, h.span([...css(chevron)])],
),
animatePanel(
h.div(
[...panel, ...css(disclosurePanel)],
[answer],
),
),
],
),
},
h,
),
),
)Tabs
The selected tab and its panel get data-selected. Both the list and the tabs take an appearance variant, and the Model holds the choice as a Schema literal.
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Recipe, Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'
import {
Message,
type Model,
Section,
SectionTabs,
} from '../../demo/foldkitUi.ts'
import { focusRing, tokens } from '../../design.ts'
const { color, radius, space, text } = tokens
// PARTS
// appearance is a Schema-typed prop: the Model holds a TabAppearance, and
// tab.schema would reject anything else.
const tabList = Recipe.make({
name: 'TabList',
base: Style.make({
display: 'flex',
gap: space[1],
overflowX: 'auto',
}),
variants: {
appearance: {
Underline: { borderBottom: `1px solid ${color.line}` },
Pill: {
padding: 3,
borderRadius: radius.md,
backgroundColor: color.sunken,
border: `1px solid ${color.line}`,
},
},
},
})
const tab = Recipe.make({
name: 'Tab',
base: Style.make({
paddingBlock: space[2],
paddingInline: space[3],
border: 'none',
backgroundColor: 'transparent',
color: color.muted,
fontFamily: 'inherit',
fontSize: text.sm,
fontWeight: 500,
whiteSpace: 'nowrap',
cursor: 'pointer',
}).pipe(
Style.merge(focusRing),
Style.when(When.hover, { color: color.ink }),
Style.when(When.disabled, {
opacity: 0.45,
cursor: 'not-allowed',
}),
),
variants: {
appearance: {
Underline: Style.make({
marginBottom: -1,
borderBottom: '2px solid transparent',
}).pipe(
Style.when(When.selected, {
color: color.ink,
borderBottomColor: color.accent,
}),
),
Pill: Style.make({ borderRadius: 6 }).pipe(
Style.when(When.selected, {
color: color.ink,
backgroundColor: color.surface,
boxShadow: `0 1px 2px ${color.line}`,
}),
),
},
},
})
const tabPanel = Style.make({
paddingBlock: space[4],
fontSize: text.sm,
color: color.muted,
}).pipe(Style.merge(focusRing))
// VIEW
const PANELS: Readonly<Record<Section, string>> = {
Pattern:
'Six pieces, cut on the grain. Seam allowance is 1.5 cm throughout.',
Fabric:
'Mid-weight linen or chambray, about 2.2 m at 140 cm wide.',
Notions:
'Seven buttons, interfacing for the placket, and matching thread.',
History: 'No revisions yet.',
}
export const tabsDemo = (
model: Model,
h: HtmlBuilder<Message>,
): Html =>
h.submodel({
slotId: 'ui-tabs',
model: model.tabs,
view: SectionTabs.view,
toParentMessage: message => Message.GotTabsMessage({ message }),
viewInputs: {
tabs: Section.literals,
selectedValue: model.section,
ariaLabel: 'Pattern details',
isTabDisabled: section => section === 'History',
toView: ({ tablist, tabs, activeIndex }) => {
const appearance = model.tabAppearance
return h.div(
[],
[
h.div(
[...tablist, ...css(tabList({ appearance }))],
tabs.map(info =>
h.button(
[...info.tab, ...css(tab({ appearance }))],
[info.value],
),
),
),
...tabs
.filter(info => info.index === activeIndex)
.map(info =>
h.div(
[...info.panel, ...css(tabPanel)],
[PANELS[info.value]],
),
),
],
)
},
},
})Select
Select wraps the native element. An empty choice is invalid, which sets data-invalid and aria-invalid; the second select is disabled through the native property.
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Select } from '@foldkit/ui'
import { Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'
import { Message, type Model } from '../../demo/foldkitUi.ts'
import { focusRing, tokens } from '../../design.ts'
import { chevron, cluster, field, fieldLabel, hint } from './kit.ts'
const { color, radius, space, text } = tokens
// PARTS
const selectControl = Style.make({
appearance: 'none',
minWidth: '13rem',
paddingBlock: space[2],
paddingInlineStart: space[3],
paddingInlineEnd: space[6],
border: `1px solid ${color.line}`,
borderRadius: radius.md,
backgroundColor: color.surface,
color: color.ink,
fontFamily: 'inherit',
fontSize: text.sm,
cursor: 'pointer',
}).pipe(
Style.merge(focusRing),
Style.when(When.hover, { borderColor: color.muted }),
// isInvalid sets data-invalid and aria-invalid. Invalid outranks hover.
Style.when(When.invalid, {
borderColor: color.accent,
boxShadow: `0 0 0 3px ${color.accentSoft}`,
}),
// A native select gets the disabled property, which :disabled matches.
Style.when(When.disabled, {
opacity: 0.5,
cursor: 'not-allowed',
}),
)
const selectBox = Style.make({
position: 'relative',
display: 'inline-flex',
})
const selectChevron = Style.merge(
chevron,
Style.make({
position: 'absolute',
insetInlineEnd: space[3],
top: '50%',
pointerEvents: 'none',
}),
)
// VIEW
const WEIGHTS: ReadonlyArray<
readonly [value: string, label: string]
> = [
['', 'Choose a weight'],
['Light', 'Light, under 150 gsm'],
['Medium', 'Medium, 150 to 250 gsm'],
['Heavy', 'Heavy, over 250 gsm'],
]
const weightSelect = (
config: Readonly<{
id: string
label: string
value: string
isDisabled: boolean
}>,
h: HtmlBuilder<Message>,
): Html =>
Select.view(
{
id: config.id,
value: config.value,
isDisabled: config.isDisabled,
isInvalid: !config.isDisabled && config.value === '',
hasDescription: true,
onChange: weight => Message.UpdatedWeight({ weight }),
toView: ({ select, label, description }) =>
h.div(
[...css(field)],
[
h.label([...label, ...css(fieldLabel)], [config.label]),
h.div(
[...css(selectBox)],
[
h.select(
[...select, ...css(selectControl)],
WEIGHTS.map(([value, text]) =>
h.option([h.Value(value)], [text]),
),
),
h.span([...css(selectChevron)]),
],
),
h.span(
[...description, ...css(hint)],
[
config.isDisabled
? 'Set by the pattern.'
: 'Shown on the cutting list.',
],
),
],
),
},
h,
)
export const selectDemo = (
model: Model,
h: HtmlBuilder<Message>,
): Html =>
h.div(
[...css(cluster)],
[
weightSelect(
{
id: 'ui-select-weight',
label: 'Fabric weight',
value: model.weight,
isDisabled: false,
},
h,
),
weightSelect(
{
id: 'ui-select-lining',
label: 'Lining weight',
value: 'Light',
isDisabled: true,
},
h,
),
],
)Listbox
A custom select. Options get data-active while highlighted and data-selected when chosen, and the check mark appears through When.within(option, When.selected). Margaret is disabled.
import { Option } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Style } from '@pleat/core'
import { css, cssClass } from '@pleat/foldkit'
import {
AssigneeListbox,
Message,
type Model,
Person,
} from '../../demo/foldkitUi.ts'
import { tokens } from '../../design.ts'
import {
chevron,
field,
fieldLabel,
optionCheck,
optionItem,
panel,
spread,
triggerButton,
} from './kit.ts'
const { color, text } = tokens
const avatar = Style.make({
display: 'inline-grid',
placeItems: 'center',
width: 22,
height: 22,
borderRadius: 999,
backgroundColor: color.accentSoft,
color: color.accent,
fontSize: text.xs,
fontWeight: 700,
})
const person = Style.make({
display: 'inline-flex',
alignItems: 'center',
gap: 8,
})
const personView = (name: Person, h: HtmlBuilder<Message>): Html =>
h.span(
[...css(person)],
[h.span([...css(avatar)], [name.slice(0, 1)]), name],
)
// The check mark in each option shows through When.within(option,
// When.selected), so itemToConfig never looks at isSelected.
export const listboxDemo = (
model: Model,
h: HtmlBuilder<Message>,
): Html =>
h.div(
[...css(field)],
[
h.span(
[h.Id('ui-listbox-label'), ...css(fieldLabel)],
['Assignee'],
),
h.submodel({
slotId: 'ui-listbox',
model: model.listbox,
view: AssigneeListbox.view,
toParentMessage: message =>
Message.GotListboxMessage({ message }),
viewInputs: {
items: Person.literals,
maybeSelectedValue: model.maybeAssignee,
ariaLabelledBy: 'ui-listbox-label',
isItemDisabled: name => name === 'Margaret',
buttonContent: h.span(
[...css(spread)],
[
Option.match(model.maybeAssignee, {
onNone: () => h.span([], ['Nobody']),
onSome: name => personView(name, h),
}),
h.span([...css(chevron)]),
],
),
buttonClassName: cssClass(triggerButton),
itemsClassName: cssClass(panel),
itemToConfig: name => ({
className: cssClass(optionItem({})),
content: h.span(
[...css(spread)],
[personView(name, h), h.span([...css(optionCheck)])],
),
}),
anchor: { placement: 'bottom-start', gap: 6 },
},
}),
],
)Combobox
Type to filter. The panel is the same one Menu and Listbox use, with the same enter and leave transition.
import { Option, String } from 'effect'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Style, When } from '@pleat/core'
import { css, cssClass } from '@pleat/foldkit'
import {
Fabric,
FabricCombobox,
Message,
type Model,
} from '../../demo/foldkitUi.ts'
import { tokens } from '../../design.ts'
import {
chevron,
field,
fieldLabel,
optionCheck,
optionItem,
panel,
spread,
trigger,
} from './kit.ts'
const { color, radius, space, text } = tokens
// PARTS
const comboboxField = Style.make({
display: 'flex',
alignItems: 'center',
minWidth: '15rem',
border: `1px solid ${color.line}`,
borderRadius: radius.md,
backgroundColor: color.surface,
}).pipe(
Style.when(When.hover, { borderColor: color.muted }),
Style.when(When.focusWithin, {
borderColor: color.accent,
boxShadow: `0 0 0 3px ${color.accentSoft}`,
}),
)
const comboboxInput = Style.make({
flexGrow: 1,
minWidth: 0,
paddingBlock: space[2],
paddingInline: space[3],
border: 'none',
outline: 'none',
backgroundColor: 'transparent',
color: color.ink,
fontFamily: 'inherit',
fontSize: text.sm,
}).pipe(Style.when(When.placeholder, { color: color.muted }))
// The toggle carries aria-expanded, so the shared chevron turns inside it.
const comboboxToggle = Style.make({
display: 'inline-grid',
placeItems: 'center',
alignSelf: 'stretch',
paddingInline: space[3],
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
}).pipe(Style.merge(Style.mark(trigger)))
// VIEW
export const comboboxDemo = (
model: Model,
h: HtmlBuilder<Message>,
): Html => {
const query = String.toLowerCase(model.combobox.inputValue)
const matches = Fabric.literals.filter(fabric =>
String.includes(query)(String.toLowerCase(fabric)),
)
return h.div(
[...css(field)],
[
h.label(
[h.For('ui-combobox-input'), ...css(fieldLabel)],
['Fabric'],
),
h.submodel({
slotId: 'ui-combobox',
model: model.combobox,
view: FabricCombobox.view,
toParentMessage: message =>
Message.GotComboboxMessage({ message }),
viewInputs: {
items: matches,
maybeSelectedValue: model.maybeFabric,
restingInputValue: Option.getOrElse(
model.maybeFabric,
() => '',
),
itemToValue: fabric => fabric,
itemToDisplayText: fabric => fabric,
isItemDisabled: fabric => fabric === 'Organza',
inputPlaceholder: 'Search fabrics',
inputWrapperClassName: cssClass(comboboxField),
inputClassName: cssClass(comboboxInput),
buttonContent: h.span([...css(chevron)]),
buttonClassName: cssClass(comboboxToggle),
itemsClassName: cssClass(panel),
itemToConfig: fabric => ({
className: cssClass(optionItem({})),
content: h.span(
[...css(spread)],
[fabric, h.span([...css(optionCheck)])],
),
}),
anchor: { placement: 'bottom-start', gap: 6 },
},
}),
],
)
}Dialog
The backdrop and the panel share the transition states. The dialog lays itself out only under When.open, since a closed dialog is hidden by the browser’s own stylesheet.
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Dialog } from '@foldkit/ui'
import { Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'
import { Message, type Model } from '../../demo/foldkitUi.ts'
import { tokens } from '../../design.ts'
import { cluster, hint, uiButton } from './kit.ts'
const { color, font, radius, shadow, space, text } = tokens
// PARTS
// A closed <dialog> is hidden by the browser's own stylesheet, which any
// author rule beats, so the layout applies only once Foldkit UI sets data-open.
const dialogRoot = Style.empty.pipe(
Style.when(When.open, {
display: 'grid',
placeItems: 'center',
padding: space[4],
}),
)
const fade = When.all(When.motionSafe, When.transitioning)
// Foldkit UI calls show(), not showModal(), so there is no ::backdrop. The
// backdrop is an element of its own, with the same transition states as the panel.
const dialogBackdrop = Style.make({
position: 'fixed',
inset: 0,
// A scrim darkens in either theme, so it is the one color here that is not a token.
backgroundColor: 'oklch(18% 0.02 272 / 0.5)',
}).pipe(
Style.when(fade, { transition: 'opacity 200ms ease' }),
Style.when(When.closed, { opacity: 0 }),
)
const dialogPanel = Style.make({
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: space[3],
width: '100%',
maxWidth: '26rem',
padding: space[5],
borderRadius: radius.lg,
border: `1px solid ${color.line}`,
backgroundColor: color.surface,
color: color.ink,
boxShadow: shadow.raised,
}).pipe(
Style.when(fade, {
transition:
'opacity 200ms ease, translate 200ms ease, scale 200ms ease',
}),
Style.when(When.closed, {
opacity: 0,
translate: '0 8px',
scale: '0.97',
}),
)
const dialogTitle = Style.make({
fontFamily: font.sans,
fontSize: text.xl,
fontWeight: 600,
})
const dialogText = Style.make({
fontSize: text.sm,
color: color.muted,
})
const dialogActions = Style.merge(
cluster,
Style.make({ justifyContent: 'flex-end' }),
)
// VIEW
export const dialogDemo = (
model: Model,
h: HtmlBuilder<Message>,
): Html =>
h.div(
[...css(cluster)],
[
model.isPatternArchived
? h.button(
[
h.Type('button'),
h.OnClick(Message.ClickedRestore()),
...css(uiButton({})),
],
['Restore pattern'],
)
: h.button(
[
h.Type('button'),
h.OnClick(Message.ClickedArchivePrompt()),
...css(uiButton({})),
],
['Archive pattern…'],
),
h.span(
[...css(hint)],
[model.isPatternArchived ? 'Archived.' : 'Not archived.'],
),
h.submodel({
slotId: 'ui-dialog',
model: model.dialog,
view: Dialog.view,
toParentMessage: message =>
Message.GotDialogMessage({ message }),
viewInputs: {
hasDescription: true,
toView: ({
dialog,
backdrop,
panel,
title,
description,
closeButton,
initialFocus,
}) =>
h.dialog(
[...dialog, ...css(dialogRoot)],
[
h.div([...backdrop, ...css(dialogBackdrop)]),
h.div(
[...panel, ...css(dialogPanel)],
[
h.h2(
[...title, ...css(dialogTitle)],
['Archive this pattern?'],
),
h.p(
[...description, ...css(dialogText)],
[
'It leaves your library, and anyone it was shared with loses access.',
],
),
h.div(
[...css(dialogActions)],
[
h.button(
[
...closeButton,
...initialFocus,
...css(uiButton({ size: 'Medium' })),
],
['Keep it'],
),
h.button(
[
h.Type('button'),
h.OnClick(Message.ClickedArchive()),
...css(
uiButton({
tone: 'Primary',
size: 'Medium',
}),
),
],
['Archive'],
),
],
),
],
),
],
),
},
}),
],
)Tooltip
Hover or focus the button. Tooltip has no transition states, so its panel plays a keyframe animation when it mounts.
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Tooltip } from '@foldkit/ui'
import { Global, Style, When } from '@pleat/core'
import { css } from '@pleat/foldkit'
import { Message, type Model } from '../../demo/foldkitUi.ts'
import { tokens } from '../../design.ts'
import { uiButton } from './kit.ts'
const { color, radius, space, text } = tokens
// PARTS
// Tooltip has no transition states: its panel mounts when it opens. A
// keyframe animation plays on mount instead.
const rise = Global.keyframes('ui-tooltip-rise', {
from: { opacity: 0, translate: '0 3px' },
to: { opacity: 1, translate: '0 0' },
})
const tooltipPanel = Style.make({
maxWidth: '16rem',
paddingBlock: space[1],
paddingInline: space[2],
borderRadius: radius.sm,
backgroundColor: color.ink,
color: color.canvas,
fontSize: text.xs,
lineHeight: 1.4,
zIndex: 40,
}).pipe(
Style.when(When.motionSafe, {
animation: `${rise} 140ms ease-out`,
}),
)
// VIEW
export const tooltipDemo = (
model: Model,
h: HtmlBuilder<Message>,
): Html =>
h.submodel({
slotId: 'ui-tooltip',
model: model.tooltip,
view: Tooltip.view,
toParentMessage: message =>
Message.GotTooltipMessage({ message }),
viewInputs: {
anchor: { placement: 'top', gap: 6 },
toView: ({ trigger, panel, isVisible }) =>
h.span(
[],
[
h.button(
[...trigger, ...css(uiButton({ size: 'Medium' }))],
['Copy link'],
),
...(isVisible
? [
h.div(
[...panel, ...css(tooltipPanel)],
['Anyone with the link can view'],
),
]
: []),
],
),
},
})Wire it into your app
Stateful components are Submodels: a field in your Model, a message that wraps theirs, Update.foldChild in update, and h.submodel in the view. Pleat appears only in the view.
import { Option, Schema } from 'effect'
import { Update } from 'foldkit'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { modifyFields } from 'foldkit/struct'
import { Menu } from '@foldkit/ui'
import { cssClass } from '@pleat/foldkit'
import { optionItem, panel, triggerButton } from './kit.ts'
const Action = Schema.Literals(['Rename', 'Archive'])
type Action = typeof Action.Type
const ActionMenu: Menu.Bundle<Action> = Menu.create<Action>()
// MODEL AND MESSAGE
export const Model = Schema.Struct({
menu: Menu.Model,
maybeAction: Schema.Option(Action),
})
export type Model = typeof Model.Type
export const Message = defineMessageUnion({
GotMenuMessage: { message: Menu.Message },
})
export type Message = typeof Message.Type
export const init = (): Model => ({
menu: Menu.init({ id: 'actions', isAnimated: true }),
maybeAction: Option.none(),
})
// UPDATE
const foldMenu = Update.foldChild({
update: ActionMenu.update,
read: (model: Model) => Option.some(model.menu),
write: (model, menu) => modifyFields(model, { menu: () => menu }),
toParentMessage: message => Message.GotMenuMessage({ message }),
foldOutMessage:
({ value }) =>
model => ({
model: modifyFields(model, {
maybeAction: () => Option.some(value),
}),
}),
})
export const update = (
model: Model,
message: Message,
): Update.Return<Model, Message> =>
Message.match(message, {
GotMenuMessage: ({ message }) => foldMenu(model, message),
})
// VIEW
// Pleat appears only here: class names for the parts, with every state
// (open, highlighted, disabled, closed) handled inside the styles.
export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
h.submodel({
slotId: 'actions',
model: model.menu,
view: ActionMenu.view,
toParentMessage: message => Message.GotMenuMessage({ message }),
viewInputs: {
items: Action.literals,
buttonContent: h.span([], ['Actions']),
buttonClassName: cssClass(triggerButton),
itemsClassName: cssClass(panel),
itemToConfig: action => ({
className: cssClass(optionItem({})),
content: h.span([], [action]),
}),
},
})Menu.create returns a bundle that TypeScript can’t name across files without an annotation, so the example gives it one: Menu.Bundle<Action>.