Skip to main content
On this pageOverview

Disclosure

Overview

A toggle for showing and hiding content inline. Disclosure is a stateless controlled render helper. Call it directly with a ViewConfig in your own view, with no Model, update, or h.submodel wrapping of its own. Your Model owns the value passed as isOpen, and onToggle turns an interaction into a Message for update to store. Use it for FAQs, accordions, and collapsible sections. For content in a floating panel, use Dialog or Popover instead.

See it in an app

Check out how Disclosure is wired up in a real Foldkit app.

Examples

Provide a toView callback that receives the button and panel attribute bundles. Spread them onto your own elements; Disclosure manages the ARIA linking and toggle behavior.

// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt. Fit them into your own Model, init, Message,
// update, and view definitions.
import { Schema } from 'effect'
import type { HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { evo } from 'foldkit/struct'

import { Disclosure } from '@foldkit/ui'

// Store the open state as a plain boolean field in your Model:
const Model = Schema.Struct({
  isFaqOpen: Schema.Boolean,
  // ...your other fields
})

// In your init function, start it closed:
const init = () => ({
  model: {
    isFaqOpen: false,
    // ...your other fields
  },
})

// A verb-first, past-tense Message carries the new open state:

const Message = defineMessageUnion({
  ToggledFaq: { isOpen: Schema.Boolean },
})

// In the corresponding Message.match handler, store the value.
// This is the moment to persist the open state, lazy-load panel content, or
// log analytics.
ToggledFaq: ({ isOpen }) => ({ model: evo(model, { isFaqOpen: () => isOpen }) })

// Inside your view function, render the disclosure with Disclosure.view.
// Render the panel unconditionally and pass it through animatePanel: the
// panel stays mounted while collapsed, so the height transition animates the
// open and close. The toggle text below names the button. When the toggle is
// icon-only, give it a name with `ariaLabel`, or point `ariaLabelledBy` at a
// visible label element (target the toggle id with
// `Disclosure.buttonId('faq-1')` for a native `<label for>`). Either
// attribute is only emitted when provided, so the toggle never carries a
// dangling `aria-labelledby`.
const view = (model, h: HtmlBuilder<Message>) =>
  Disclosure.view(
    {
      id: 'faq-1',
      isOpen: model.isFaqOpen,
      onToggle: isOpen => Message.ToggledFaq({ isOpen }),
      // ariaLabel: 'What is Foldkit?',
      toView: ({ button, panel, animatePanel }) =>
        h.div(
          [h.Class('border rounded-lg overflow-hidden')],
          [
            h.button(
              [
                ...button,
                h.Class('flex items-center justify-between w-full p-4'),
              ],
              [h.span([], ['What is Foldkit?'])],
            ),
            animatePanel(
              h.div(
                [...panel, h.Class('p-4 border-t')],
                [h.p([], ['A functional UI framework built on Effect-TS.'])],
              ),
            ),
          ],
        ),
    },
    h,
  )

The example renders the panel unconditionally and passes it through animatePanel, which wraps the content in a CSS-grid container that transitions its height, keeping the panel mounted while collapsed so there is something to animate. To skip the animation, render the panel only while isOpen.

Styling

Use the data-open attribute to style the button and panel differently when open.

AttributeCondition
data-openPresent on both button and panel when the disclosure is open.
data-disabledPresent on the button when isDisabled is true.

Keyboard Interaction

KeyDescription
EnterToggles the disclosure.
SpaceToggles the disclosure.

Accessibility

The toggle button receives aria-expanded and aria-controls linking to the panel. Toggling is user-driven, so focus stays on the button the user activated; there is no focus Command to handle in update.

Give the toggle an accessible name when its content is not self-describing. For a visible label, wire a native <label for> that targets the toggle id with Disclosure.buttonId(id) rather than hardcoding the -button convention. The for association makes the toggle properly labeled: assistive technology announces it by the visible label text, and clicking the label opens the disclosure. That is why it is the recommended pattern.

Two ViewConfig fields cover the cases a <label for> does not. Pass ariaLabel for an icon-only toggle with no visible label, or ariaLabelledBy when the element that names the toggle is not a <label> you can point for at.

API Reference

ViewConfig

Configuration object passed to Disclosure.view().

NameTypeDefaultDescription
idstringUnique ID for the disclosure instance. Used to derive the button and panel ids for ARIA linking.
isOpenbooleanThe current open state, read from your Model. aria-expanded, the data-open marker, and animatePanel derive from it.
onToggle(isOpen: boolean) => MessageMaps the new open state to a Message when the user toggles the disclosure. Store that value in update.
toView(attributes: DisclosureAttributes) => HtmlCallback that receives the button and panel attribute bundles and returns the composed layout. The consumer reads isOpen from their own Model when they need to render conditionally on it.
isDisabledbooleanfalseWhen true, the button is not clickable, gets aria-disabled and a data-disabled attribute.
ariaLabelstringAccessible name for the toggle button. Use for an icon-only trigger with no visible label. Applied as aria-label, and takes precedence over ariaLabelledBy.
ariaLabelledBystringId of an external element that labels the toggle button, applied as aria-labelledby. Pair with a visible label element.

DisclosureAttributes

Attribute bundles delivered to the toView callback each render.

NameTypeDefaultDescription
buttonReadonlyArray<Attribute<Message>>Spread onto the toggle button element. Includes aria-expanded, aria-controls, tabindex, the click + Enter/Space keyboard handlers, and type="button" so a trigger inside a form does not submit it.
panelReadonlyArray<Attribute<Message>>Spread onto the panel element. Includes the panel id (${id}-panel) and a data-open attribute when open.
animatePanel(content: Html) => HtmlWraps panel content in a CSS-grid container that animates height as the disclosure opens and closes. Render the panel unconditionally (rather than gating on isOpen) and pass it here; the panel stays mounted while collapsed so the height transition has something to animate. The collapsed content is marked aria-hidden.