forty-cdk
llms.txt

Utilities

Shared

The contract surface the primitives share, imported from forty-cdk/shared.

forty-cdk/shared

Every primitive ships from its own entry point, but their public APIs speak a common vocabulary: a dir-resolved WritingDirection, the VetoableEvent a dismiss handler can cancel, the DateAdapter every date/time primitive delegates its arithmetic to, the FloatingSide / FloatingAlign an anchored overlay is placed on. Those types are declared once, published once, and carry the library's semver guarantee.

Why this exists

The types live in forty-cdk/core, which is not a public entry point: it also holds the engines and DI singletons the library refactors freely, and it exists so every primitive resolves the shared implementation to exactly one compiled module. Publishing the contract types from forty-cdk/shared gives them a specifier a consumer can depend on without depending on the engines next to them.

import { ForTabs, ForTabsList, ForTabsTrigger } from 'forty-cdk/tabs';
import type { WritingDirection } from 'forty-cdk/shared';

There is nothing to install and, unless you mount more than one forty-cdk app on a page (see Multiple apps on one page), nothing to provide: 35 of the 45 exports are structural types erased at compile time, seven runtime values are re-exported from core and resolve to the same singly-compiled module every primitive already loads, and FOR_DATE_ADAPTER, NativeDateAdapter and provideNativeDateAdapter are re-exported from forty-cdk/date-adapter. Import those three from forty-cdk/date-adapter when you provide an adapter at the application root: this entry point loads the core, so a root import of it puts the code every primitive shares in your main bundle.

What it exports

FamilyExports
Direction / navigationWritingDirection, ListNavigationAction, RovingTabindex, HostRovingItemHandle
Floating / geometryFloatingSide, FloatingAlign, FloatingFallbackAxisSideDirection, Point, ElementBox
Vetoable eventsVetoableEvent, VetoableNativeEvent
Date / timeDateAdapter, TimeCapableDateAdapter, assertTimeCapable, FOR_DATE_ADAPTER, injectDateAdapter, NativeDateAdapter, provideNativeDateAdapter, DateRange, FieldSegment, SegmentEditorContext, SegmentEditorDelegate, SegmentHandle, SegmentType, DateSegmentType, TimeSegmentType, FieldGranularity, TimeGranularity
Menu familyFOR_MENU_CONTEXT, ForMenuContext, ForMenuCloseReason, ForMenuItemHandle, MenuActivationModality, MenuOpenerPositioning, MenuSiblingNavigator
FieldsetFOR_FIELDSET_CONTEXT, ForFieldsetContext
Accessible textaccessibleTextContent
LocalizationLocalizableText
Id generationFOR_ID_SALT, provideForIdSalt
OtherListboxOverlayContext, DragPreview, SwipeDirection, SwipeEventDetail

Eight blessed symbols are not here, because a primitive is their semantic home rather than a second path to the same symbol: ForVisuallyHidden and LiveAnnouncer ship from forty-cdk/visually-hidden, ForDrawerSide from forty-cdk/drawer, injectPrefersReducedMotion from forty-cdk/breakpoints, and the field-wiring set FOR_FIELD_CONTEXT / ForFieldContext / FieldControlHandle / injectFieldWiring from forty-cdk/field.

Reading a control's accessible text

accessibleTextContent(node) concatenates a node's text content while skipping any subtree marked aria-hidden="true", so a decorative indicator glyph, badge or icon contributes nothing while visually-hidden but announced content is kept. It is what the library itself calls to decide what a [forSelectOption] / [forComboboxOption] is named, what its typeahead matches on, how a reorder announcement labels the moved option, and what a [forTreeItem] carrying no [textValue] matches typeahead and drag announcements on.

Reach for it whenever your own code has to reason about that same text, such as a truncation tooltip that shows a cell's full label or a filter that matches what the user actually perceives:

import { accessibleTextContent } from 'forty-cdk/shared';

const label = accessibleTextContent(host).trim();

The result is untrimmed, so apply your own .trim() when comparing. Deriving the text yourself with textContent works until an aria-hidden glyph appears inside the host, at which point your definition and the library's silently disagree.

Localizing default text

Every provideFor<X>Defaults key that holds text a screen reader speaks or a field shows is typed LocalizableText: a string, or a function returning one. The library calls the function each time it renders or announces the text, so a function that reads a signal keeps the text in the current language. A plain string behaves as it always has.

Every provider also accepts a factory in place of the object. It runs in an injection context, so it can inject() the service that holds your translations:

import { inject } from '@angular/core';
import { provideForComboboxDefaults } from 'forty-cdk/defaults';

provideForComboboxDefaults(() => {
  const i18n = inject(AppI18n);
  return {
    clearAriaLabel: () => i18n.t('combobox.clear'),
    chipRemoveLabel: (label) => i18n.t('combobox.remove', { label }),
  };
});

An app that loads or switches its language after bootstrap needs both. The factory runs once per injector, so on its own it only reads the translations available at that moment; the functions it returns are what follow a later switch. The label builders (chipRemoveLabel, slideLabel, stepValueText and the rest) are functions already, and follow a signal they read in the same way.

A per-instance input still wins over the key: [ariaLabel]="'Clear search'" names that one control, and [ariaLabel]="null" drops the attribute.

Multiple apps on one page

Primitives generate the ids that wire aria-labelledby, aria-controls, and aria-describedby between their pieces. Each id is salted, and the salt defaults to Angular's APP_ID so that a server render and its client hydration produce byte-identical ids.

Angular's default APP_ID is the literal 'ng'. Two forty-cdk apps mounted side-by-side on the same page therefore start from the same salt and the same counter, and emit identical id sequences, producing duplicate DOM ids. aria-labelledby resolves to whichever element appears first in the document, so a screen reader can announce app A's label for app B's control. Nothing looks wrong on screen; the failure is only audible.

Give each app its own salt:

import { provideForIdSalt } from 'forty-cdk/shared';

bootstrapApplication(AppA, { providers: [provideForIdSalt('a')] });
bootstrapApplication(AppB, { providers: [provideForIdSalt('b')] });

Two constraints:

  • The salt must be stable per app, not random. A runtime random value would make every render unique and break SSR hydration, because the server and client renders would no longer agree on the ids.
  • Prefer this over overriding APP_ID. Setting a distinct global APP_ID per app fixes the ids too, but it also drives Angular's hydration store and event replay; provideForIdSalt changes only what forty-cdk salts its ids with.

A single app (the common case, including SSR) needs no provider at all.

Incremental hydration

@defer (hydrate on interaction | viewport | timer | …) renders a block on the server and leaves it dehydrated on the client: the markup sits in the DOM, but no directive inside it constructs until the trigger fires. That interacts with generated ids, so there is one rule to know before cutting a @defer through a primitive.

Keep a primitive's pieces in the same hydration unit. Put the whole primitive inside the @defer block, or leave all of it outside. Never split one across a boundary the root itself sits behind:

<!-- Don't: the root hydrates on its own trigger, so it re-mints the panel's id
     off a drifted counter — and the panel is not there to correct it. -->
@defer (hydrate on viewport) {
<div forDisclosure [(open)]="open">
  <button forDisclosureTrigger>Details</button>
  @defer (hydrate on interaction) {
  <section forDisclosureContent>…</section>
  }
</div>
}

<!-- Do: the whole primitive hydrates as one unit. -->
@defer (hydrate on interaction) {
<div forDisclosure [(open)]="open">
  <button forDisclosureTrigger>Details</button>
  <section forDisclosureContent>…</section>
</div>
}

The ids behind aria-controls, aria-labelledby, aria-describedby and aria-activedescendant come from a per-application counter, so the server and the client agree only while they mint in the same order. Two properties normally guarantee that, and both survive incremental hydration:

  • Every piece that emits a generated id adopts the id already on its host element (the same seam that preserves a consumer's static id), and during hydration that value is the one the server wrote. A hydrated piece therefore keeps the server's id even when the client counter has drifted.
  • Every piece outside all @defer blocks mints before any dehydrated block hydrates, on both sides: the server renders a deferred block's content after the view that contains it, and the client hydrates no block until the initial pass is done. Ids at that level are byte-identical.

Neither covers an id a deferred root minted for a piece that is still dehydrated. The root hydrated late, so its counter has drifted from the server's; nothing constructs inside the still-dehydrated piece, so nothing adopts on its behalf; and the reference keeps whatever the drifted counter produced. Two symptoms follow, both invisible on screen:

  • A reference that resolves to nothing. A screen reader announces no name (or no controlled region) for a control that looks correctly wired in devtools.
  • A reference that resolves to the wrong element. The client-minted value can equal an id the server minted for a different element, which is still sitting in another dehydrated block. That one does not heal when the blocks hydrate: each element keeps the id the server gave it, so the reference stays pointed at the wrong panel.

A root outside every @defer is the one split that survives, and it survives on the second property rather than on adoption: the root minted the panel's id during the initial pass, where the two counters are byte-identical, so the reference still matches the id the server left on the dehydrated panel. The suite below pins that. Treat it as a shape that happens to hold rather than one to reach for. Two ordinary edits end it, and neither looks like it touches ids: moving the root behind a @defer of its own (the case above), and pinning the panel with a static id (the case below).

A static id is not the workaround

Pinning the ids yourself is the natural first idea and it makes this shape worse: it is the one split that breaks even with the root outside every @defer, where a generated id would have resolved. The library adopts a consumer-set static id when the piece's directive constructs; a dehydrated piece never constructs, so the server render adopts your id and the client render cannot. The referring attribute then points at the generated fallback, and nothing in the document carries it:

<div forDisclosure [open]="true">
  <button forDisclosureTrigger>Details</button>
  @defer (hydrate on interaction) {
  <!-- Server: aria-controls="panel". Client: a generated id, resolving to nothing. -->
  <section forDisclosureContent id="panel">…</section>
  }
</div>

provideForIdSalt does not help either, because it changes the salt, not the counter.

aria-activedescendant is the one id relationship that needs no rule: it always names a mounted option, whose directive has therefore adopted the server's id, and while the options are dehydrated the attribute is simply absent rather than stale.

Shadow DOM

Focus, and the question "did that interaction land inside my surface?", are resolved against the composed tree, through open shadow roots. That matters as soon as a design system is involved: any web component rendered inside a dialog, drawer or panel (an icon button, a rich-text editor, a third-party widget) puts a shadow root in the middle of the surface, and ViewEncapsulation.ShadowDom on one of your own wrappers gets there with no web component at all, because that component's whole template renders into a shadow root on its host.

What that buys, library-wide: a modal focus trap cycles Tab across a shadow boundary instead of leaking at it, a press on a control inside a web component does not read as a press outside the overlay rendering it, and the surface's own shadow root counts as inside the surface. The helpers are core/composed-tree.

Three limits are known, and each is invisible in devtools: every role and aria-* stays correct, so the symptom is a keyboard or screen-reader one. They are listed with the markup that triggers them because nothing in the DOM will tell you.

Focusable order is composed only for a host that renders no <slot>

Symptom. The first / last pair a focus trap cycles between can disagree with the browser's real Tab order. Initial focus under the default initialFocus="first" can land on a control that is not the visually first one, and a Tab at the surface's real last control is not recognised as the cycle's end, so focus leaves the surface. In a modal dialog, where the rest of the page is inert, it typically lands on the browser's own UI, and the next Tab is pulled back to whichever control the walk thinks is first.

Markup. A shadow host inside the trapped surface that renders a focusable after its <slot>. The walk visits assigned (slotted) nodes with the light tree, after the host's whole shadow tree, whereas the browser sequences them at the <slot>'s position. One trailing focusable is therefore enough, with no reordering involved:

<div forDialog>
  <!-- shadow root of <my-panel>: <slot></slot><button>Save</button> -->
  <my-panel>
    <input />
    <!-- slotted: the browser tabs here first, the walk visits it last -->
  </my-panel>
</div>

Workaround. Render a host's own focusables before its <slot>, or project them instead of shadowing them. initialFocus="container" fixes the initial-focus half only: the cycle's two edges are re-resolved on every Tab press.

The traversal is queryFocusableCandidates; closing the gap needs an assignedElements() pass per slot, deliberately not paid for on every keystroke.

A panel's focusable-content measurement does not re-measure across a boundary

Symptom. A Tabs / Stepper panel is a tab stop of its own (tabindex="0") only while it holds no focusable content, so screen-reader users can focus and read it. The measurement re-runs on mutations of the panel's own subtree, but a change inside a shadow root is not observable, so the previously measured answer stands: a panel that gains its first focusable control inside a web component keeps a redundant tab stop, and one that loses its last keeps none, which leaves the panel unreachable by keyboard.

Markup. A panel whose focusable content appears inside a shadow root after the first render: a web component that renders its controls on a later tick, or swaps them.

Workaround. In Tabs, bind [interactiveContent]: an explicit value wins over the detection in either direction. In Stepper, which has no override, render the panel's focusable content in the light tree, or remount the panel with @if when it changes so a fresh measurement runs.

The same staleness applies to a purely CSS-driven visibility flip: the observer behind injectHasFocusableContent filters attributes and does not watch class or style.

Escape is observed on the bubble phase

Symptom. Escape stops dismissing the overlay. Nothing else changes (the pointer and focus channels register on the capture phase and keep working), so it reads as a broken overlay rather than as a handler in your own content. This is the one of the three most likely to be reported as a library bug.

Markup. Any keydown handler on content inside the overlay that calls stopPropagation(). The layer stack listens on document in the bubble phase, so an event stopped inside the surface never arrives. The phase is a deliberate trade-off, recorded on DismissibleLayerStack together with why the pointer and focus channels do not share it.

Workaround. Do not stop keydown propagation unconditionally inside overlay content. Narrow it to the keys you actually handle:

onKeyDown(event: KeyboardEvent): void {
  if (event.key === 'Escape') {
    return; // let the overlay's own Escape handling see it
  }
  event.stopPropagation();
}

Keeping an overlay open is a different thing, and it has two channels of its own. The explicit one is the primitive's vetoable (escapeKeyDown) output (or the matching callback on an imperative manager's config): preventDefault() on the emitted veto suppresses the close and leaves the rest of the lifecycle alone. The cooperative one is preventDefault() on the native keydown inside your content: the stack skips an event whose default is already prevented, so a control that consumes Escape itself (cancelling an inline edit, closing its own popup) keeps the overlay open without knowing the overlay exists. Neither is stopPropagation(), which suppresses the dismissal for reasons the stack never gets to see.

Closed shadow roots are out of scope

A closed shadow root exposes neither activeElement nor shadowRoot, so nothing above can see into one: its controls are neither initial-focus candidates nor edges of a Tab cycle. The walk sees only the host, and only when the host is focusable in its own right. Give such a host a tab stop of its own (tabindex="0", paired with delegatesFocus: true so the browser forwards focus to the right control inside), or keep closed roots out of a trapped surface.

Behavior notes

  • Not a primitive. There are no directives here and nothing to add to imports.
  • Not forty-cdk/core. core stays resolvable (the primitives import it by specifier, which is what keeps LiveAnnouncer, the focus-trap and dismissible-layer stacks, and the id-generator salt single-instance), but it carries no semver guarantee. If a symbol you need is not exported here, it is internal by design; open an issue rather than importing from core.
  • Tree-shakes to nothing. The types vanish at compile time and the eight values sit in the core module your primitives already pull, so importing from here adds no code to your bundle.