Start with uf ui add. It writes a styled, accessible component into your
project, and you import it as one name. Every component it can write is below,
running, with its source. Under the look, each is built on @uniflowed/ui,
whose parts, keys and ARIA the rest of this page documents.
Add a component
uf ui list # what the registry hasuf ui add dialog # writes app/components/ui/dialog.js, and button.js, which it importsuf ui diff dialog # how your copy differs from this uf's versionuf ui update dialog # merge this uf's changes into your edited copy
A component with parts is one namespace. Import the copy as a module, and its
parts are members of it:
// @flowimport * as Dialog from "./components/ui/dialog.js";<Dialog.Root> <Dialog.Trigger>Rename</Dialog.Trigger> <Dialog.Content> <Dialog.Header> <Dialog.Title>Rename project</Dialog.Title> <Dialog.Description>Everyone on the team sees the new name.</Dialog.Description> </Dialog.Header> <Dialog.Footer> <Dialog.Close>Cancel</Dialog.Close> </Dialog.Footer> </Dialog.Content></Dialog.Root>;
A component that is one element is one name:
import { Button } from "./components/ui/button.js". uf ui add prints the
import line for each component you name, and each entry below shows it too.
Commands has every flag, and
Importing: one name per component
explains why components are imported as namespaces.
Registry components
Every component the registry has: the command that writes it, the line that
imports it, its example running on this page, and its two files, folded under
it. The list is npm/ui/registry/ itself, read when this site is built from the
same directory uf embeds, so every component uf ui list offers is shown
here. The examples render in the tokens' default theme, whatever this site's
theme is, because that is what a project sees before it applies a theme of its
own. Each file under an example is that StyleX source, which is what
uf ui add writes. The command does not rewrite it into plain CSS. Styling a
part yourself is the headless package in the next section, and
owned or headless is the boundary between the
two.
accordion
a stack of headed sections, each opened by its heading, one at a time or several.
uf ui add accordion
import * as Accordion from "./components/ui/accordion.js";
Two working days within the country, five abroad.
Within thirty days, in the box it came in.
Not yet.
The example, accordion.example.js
// @flow
import * as React from "@uniflowed/react";
import * as Accordion from "./accordion.js";
/** Three questions, one answer open at a time. */
export component Example() {
return (
<Accordion.Root defaultValue={["shipping"]}>
<Accordion.Item value="shipping">
<Accordion.Trigger>How long does shipping take?</Accordion.Trigger>
<Accordion.Content>Two working days within the country, five abroad.</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="returns">
<Accordion.Trigger>Can I return an order?</Accordion.Trigger>
<Accordion.Content>Within thirty days, in the box it came in.</Accordion.Content>
</Accordion.Item>
<Accordion.Item disabled value="gifts">
<Accordion.Trigger>Can I send it as a gift?</Accordion.Trigger>
<Accordion.Content>Not yet.</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
);
}
The component, accordion.js
"use client";
// @flow
//
// Accordion: a stack of headed sections, each opened by its heading, one at a
// time or several.
//
// `uf ui add accordion` wrote this file into the project, and it is the
// project's from then on. `uf ui diff accordion` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The rules between sections, the headings' look, the chevrons and the panels'
// spacing. The pattern is imported from `@uniflowed/ui`'s `Accordion`: each section
// is a real heading holding a button with `aria-expanded`, each panel a region
// named by that button, `type="single"` closing one section as another opens,
// and closed panels kept in the document as `hidden="until-found"` so
// find-in-page still reaches them. The chevron turns from `aria-expanded`, and
// a panel's height slides open and shut to the height the part measures for
// it, `--uf-collapsible-height`.
//
// # What to keep true when you change it
//
// * **The heading level fits the page.** `Accordion.Trigger` takes `level`, 3 by
// default; an accordion under an `<h1>` wants 2, and a skipped level is an
// outline a reader cannot navigate.
// * **An accordion holds its own items.** `Accordion` takes
// `renders* AccordionItem`, and an item holds triggers and panels, so a stray
// `<div>` between sections is a Flow error.
// * **Nothing essential lives only in a closed panel.**
// * **Colour comes from tokens, in measured pairs:** `ink` on `surface` and
// `muted` on `surface`, which `crates/uf_stylex/src/tests/preset.rs` holds to
// 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { AccordionType } from "@uniflowed/ui";
import { Accordion } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "grid",
fontFamily: ufTokens.fontSans,
color : ufTokens.ink,
},
item: {
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: ufTokens.border,
},
heading: {
margin : 0,
fontSize : "inherit",
fontWeight: "inherit",
},
trigger: {
// Read by the chevron, which cannot see the button's state.
"--uf-accordion-turn": { default: "0deg", ":is([aria-expanded=true])": "180deg" },
display : "flex",
alignItems : "center",
justifyContent : "space-between",
gap : ufTokens.space3,
boxSizing : "border-box",
width : "100%",
minHeight : "44px",
margin : 0,
paddingBlock : ufTokens.space3,
paddingInline : 0,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
textAlign : "start",
color : ufTokens.ink,
backgroundColor : "transparent",
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
textDecorationLine : { default: "none", ":hover": "underline" },
cursor: {
default : "pointer",
":disabled" : "not-allowed",
":is([aria-disabled=true])": "not-allowed",
},
opacity : { default: 1, ":disabled": 0.55, ":is([aria-disabled=true])": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
},
chevron: {
flexShrink: 0,
color : ufTokens.muted,
transform : "rotate(var(--uf-accordion-turn))",
// A half turn is travel, so it takes `durationBase`; under reduced
// motion the chevron is simply the other way up.
transitionProperty: { default: "transform", "@media (prefers-reduced-motion: reduce)": "none" },
transitionDuration: ufTokens.durationBase,
transitionTimingFunction: ufTokens.easing,
},
content: {
// Its height moves, open and closed, to the number `@uniflowed/ui`
// measures (`--uf-collapsible-height`, from `measure` on the root): up
// from 0 on a `@starting-style` in `durationBase` on the decelerating
// curve, and back down to 0 in `durationFast` on the accelerating one.
// The part keeps a closing panel shown, `inert`, until that has finished,
// and only then makes it `hidden`. Before the number is measured — on the
// server — the property is unset and the panel is its natural height.
// Under reduced motion it opens and closes at once.
//
// Clipped rather than hidden, so it is not a scroll container, and with
// room at the edge for the focus ring of a link inside it.
boxSizing : "border-box",
overflow : "clip",
overflowClipMargin: "4px",
height: {
default : "var(--uf-collapsible-height)",
"@starting-style" : "0",
":is([data-state=closed])": "0",
},
transitionProperty: { default: "height", "@media (prefers-reduced-motion: reduce)": "none" },
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
// The spacing is inside the part that moves, so a closed panel is 0 high
// rather than as high as its padding.
body: {
paddingBottom: ufTokens.space4,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
},
});
/**
* The stack. `type="single"` keeps one section open, and `collapsible` lets it
* close again; `type="multiple"` opens any number.
*/
component AccordionRoot(
children : renders* AccordionItem,
type? : AccordionType = "single",
collapsible? : boolean = true,
defaultValue? : $ReadOnlyArray<string>,
value? : $ReadOnlyArray<string>,
onValueChange?: (value: $ReadOnlyArray<string>) => void,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Accordion.Root {
return (
<Accordion.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
collapsible={collapsible}
defaultValue={defaultValue}
measure
onValueChange={onValueChange}
type={type}
value={value}
>
{children}
</Accordion.Root>
);
}
/** One section: its trigger and its panel. */
component AccordionItem(
value : string,
children : renders* (AccordionTrigger | AccordionContent),
disabled? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Accordion.Item {
return (
<Accordion.Item
{...forwarded(rest)}
className={classNames(props(styles.item, xstyle).className, className)}
disabled={disabled}
value={value}
>
{children}
</Accordion.Item>
);
}
/** A section's heading, and the button inside it that opens the section. */
component AccordionTrigger(
children : React.Node,
level? : number = 3,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Accordion.Header {
return (
<Accordion.Header className={props(styles.heading).className} level={level}>
<Accordion.Trigger
{...forwarded(rest)}
className={classNames(props(styles.trigger, xstyle).className, className)}
>
{children}
<svg
{...props(styles.chevron)}
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="m6 9 6 6 6-6" />
</svg>
</Accordion.Trigger>
</Accordion.Header>
);
}
/** A section's panel, named by its trigger. */
component AccordionContent(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Accordion.Content {
return (
<Accordion.Content
{...forwarded(rest)}
className={classNames(props(styles.content, xstyle).className, className)}
>
<div {...props(styles.body)}>{children}</div>
</Accordion.Content>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Accordion from "./accordion.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Accordion.Root>`
* and `<Accordion.Item>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `AccordionRoot` rather than `Root`.
*/
export {
AccordionRoot as Root,
AccordionItem as Item,
AccordionTrigger as Trigger,
AccordionContent as Content,
};
alert
a callout that draws attention on the page, and announces itself only when it appears after the page has loaded.
uf ui add alert
import * as Alert from "./components/ui/alert.js";
Your trial ends in 3 days
Choose a plan to keep your projects after Friday.
The last export failed
The file was too large. Try exporting one project at a time.
The example, alert.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Alert from "./alert.js";
const styles = stylex.create({
stack: {
display : "grid",
gap : ufTokens.space3,
maxWidth: "32rem",
},
});
/** Two notices that were on the page when it loaded, so neither is live. */
export component Example() {
return (
<div {...props(styles.stack)}>
<Alert.Root tone="info">
<Alert.Title level={2}>Your trial ends in 3 days</Alert.Title>
<Alert.Description>Choose a plan to keep your projects after Friday.</Alert.Description>
</Alert.Root>
<Alert.Root tone="danger">
<Alert.Title level={2}>The last export failed</Alert.Title>
<Alert.Description>
The file was too large. Try exporting one project at a time.
</Alert.Description>
</Alert.Root>
</div>
);
}
The component, alert.js
// @flow
//
// Alert: a callout that draws attention on the page, and announces itself only
// when it appears after the page has loaded.
//
// `uf ui add alert` wrote this file into the project, and it is the project's
// from then on. `uf ui diff alert` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The box, its tones and its type. `@uniflowed/ui`'s `Alert` owns the decision that
// makes it more than a box: `role="alert"` is a live region, so it is only put
// on an alert rendered with `live`, and a callout that was on the page when it
// loaded gets none — otherwise it interrupts every reader on every load, or is
// never heard at all. The title is a real heading at the level it is given.
//
// # What to keep true when you change it
//
// * **`live` is for an alert that appears.** A save that failed, a connection
// that dropped. A notice that is always on the page leaves it off.
// * **The heading level fits the page.** `Alert.Title` takes `level`, 3 by
// default.
// * **A tone is more than a colour.** `danger` and `info` add a thick edge and
// the title's words should say what happened; the colour alone says nothing
// to a reader who cannot see it.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes; the
// tone colours are edges, never text.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Alert } from "@uniflowed/ui";
/** What the alert is about, drawn as the colour of its edge. */
export type AlertTone = "neutral" | "info" | "danger";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "grid",
gap : ufTokens.space1,
boxSizing : "border-box",
width : "100%",
paddingBlock : ufTokens.space3,
paddingInline : ufTokens.space4,
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
},
info: {
borderInlineStartWidth: "4px",
borderInlineStartColor: ufTokens.accent,
},
danger: {
borderInlineStartWidth: "4px",
borderInlineStartColor: ufTokens.danger,
},
title: {
margin : 0,
fontSize : ufTokens.textSm,
fontWeight: ufTokens.weightBold,
lineHeight: ufTokens.leadingTight,
},
description: {
margin: 0,
color : ufTokens.muted,
},
});
/** A callout. `live` only for one that appears after the page has loaded. */
component AlertRoot(
children : React.Node,
tone? : AlertTone = "neutral",
live? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Alert.Root {
const styled = props(
styles.root,
match (tone) {
"neutral" => null,
"info" => styles.info,
"danger" => styles.danger,
},
xstyle,
);
return (
<Alert.Root
{...forwarded(rest)}
className={classNames(styled.className, className)}
live={live}
>
{children}
</Alert.Root>
);
}
/** What happened, as a heading at `level`. */
component AlertTitle(
children : React.Node,
level? : number = 3,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Alert.Title {
return (
<Alert.Title
{...forwarded(rest)}
className={classNames(props(styles.title, xstyle).className, className)}
level={level}
>
{children}
</Alert.Title>
);
}
/** What it means, and what to do about it. */
component AlertDescription(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Alert.Description {
return (
<Alert.Description
{...forwarded(rest)}
className={classNames(props(styles.description, xstyle).className, className)}
>
{children}
</Alert.Description>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Alert from "./alert.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Alert.Root>`
* and `<Alert.Title>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `AlertRoot` rather than `Root`.
*/
export { AlertRoot as Root, AlertTitle as Title, AlertDescription as Description };
alert-dialog
a confirmation that interrupts, says what answering costs, and waits for an answer.
uf ui add alert-dialog
import * as AlertDialog from "./components/ui/alert-dialog.js";
The example, alert-dialog.example.js
// @flow
import * as React from "@uniflowed/react";
import * as AlertDialog from "./alert-dialog.js";
/** A deletion, asked about before it happens. */
export component Example() {
return (
<AlertDialog.Root>
<AlertDialog.Trigger tone="danger">Delete project</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete this project?</AlertDialog.Title>
<AlertDialog.Description>
Its pages, its history and its settings go with it, and this cannot be undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Keep it</AlertDialog.Cancel>
<AlertDialog.Action tone="danger">Delete</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
);
}
The component, alert-dialog.js
"use client";
// @flow
//
// Alert dialog: a confirmation that interrupts, says what answering costs,
// and waits for an answer.
//
// `uf ui add alert-dialog` wrote this file into the project, and it is the
// project's from then on. `uf ui diff alert-dialog` shows how it has moved away
// from the registry in the uf you are running.
//
// # When this rather than `dialog`
//
// Only when the action is destructive or has to be answered before anything
// else happens: deleting, discarding, signing out with unsaved work. Everything
// else is a `dialog`, because a reader who is interrupted for a question that
// did not need asking learns to press the first button without reading.
//
// # What this file owns, and what it does not
//
// The look: the scrim, the panel, the two answers in the footer. The behaviour
// is imported from `@uniflowed/ui`'s `AlertDialog`: `role="alertdialog"`, which
// makes a screen reader announce the description the moment focus arrives;
// focus starting on the answer that does least; no dismissal by a press beside
// the panel; `Escape` as the way to decline; focus given back to the trigger;
// and the page behind made inert and held still. A fix to any of that reaches
// this project by upgrading `@uniflowed/ui`.
//
// # What to keep true when you change it
//
// * **It always has a description.** `AlertDialog.Description` is the sentence
// the role exists to announce, and the part raises when there is none — an
// alert dialog with nothing to say interrupts the reader to say nothing.
// * **Focus starts on `AlertDialog.Cancel`.** Keep one in the footer. The least
// destructive answer is where a reader who pressed `Enter` too early should
// land.
// * **The destructive answer looks destructive.** `AlertDialog.Action` takes a
// `tone`, and a deletion is `tone="danger"`; a confirmation whose two buttons
// look the same is one where the colour carries no warning.
// * **There is no close button in the corner.** A confirmation is answered,
// not dismissed, and `Escape` is already the way to decline.
// * **Colour comes from tokens, in measured pairs.** `ink` and `muted` on
// `surface`, and the buttons' own pairs, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in the light default
// and the dark theme.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { AlertDialog } from "@uniflowed/ui";
import type { ButtonSize, ButtonTone } from "./button.js";
import { Button } from "./button.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* part underneath. See `forwarded` for the one thing Flow cannot say about it.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** A caller's own element in place of the one a part renders. */
type RenderProp = (props: Rest) => React.Node;
const styles = stylex.create({
overlay: {
position : "fixed",
inset : 0,
zIndex : 50,
backgroundColor: ufTokens.scrim,
// Enter: the page dims as the panel arrives, over the panel's duration,
// rather than going dark first and then showing a dialog. Opacity only, so
// it is the same under reduced motion.
// Exit: it clears with the panel, in the panel's shorter exit time.
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transitionProperty: "opacity",
transitionDuration: {
default : ufTokens.durationSlow,
":is([data-state=closed])": ufTokens.durationBase,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
panel: {
position : "fixed",
inset : 0,
zIndex : 50,
boxSizing : "border-box",
display : "grid",
gap : ufTokens.space4,
width : "calc(100% - 32px)",
maxWidth : "28rem",
height : "fit-content",
maxHeight : "calc(100% - 32px)",
margin : "auto",
overflowY : "auto",
padding : ufTokens.space6,
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusLg,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
// Enter: it fades in and comes forward from 96% of its size, as if it
// rose out of the page. 0.96 is enough to be seen at this size and too
// little to read as growing from nothing, and there is no overshoot back
// past 1. `durationSlow`, because a surface this large moving as fast as
// a menu looks thrown. The scale is gone when it settles (`none`), so the
// text is not left on a half pixel. Under reduced motion it only fades.
//
// Exit: it sinks back to 96% as it fades, in `durationBase` on the
// accelerating curve, so it is out of the way sooner than it arrived.
// `@uniflowed/ui` keeps it on the page, closed and `inert`, until then,
// and focus is already back on the trigger. Under reduced motion it only
// fades: `--uf-exit-travel` is 0 there, so the scale does not jump.
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style" : "scale(0.96)",
":is([data-state=closed])": "scale(calc(1 - 0.04 * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationSlow,
":is([data-state=closed])": ufTokens.durationBase,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
header: {
display: "grid",
gap : ufTokens.space2,
},
footer: {
display : "flex",
flexWrap : "wrap",
justifyContent: "flex-end",
gap : ufTokens.space2,
},
title: {
margin : 0,
fontSize : ufTokens.textLg,
fontWeight: ufTokens.weightBold,
lineHeight: ufTokens.leadingTight,
},
description: {
margin: 0,
color : ufTokens.muted,
},
});
/** The alert dialog, open or closed. Uncontrolled unless `open` is given. */
component AlertDialogRoot(
children : React.Node,
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange?: (open: boolean) => void,
) renders AlertDialog.Root {
return (
<AlertDialog.Root defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open}>
{children}
</AlertDialog.Root>
);
}
/**
* The button that asks the question, and that focus comes back to. It is a
* `Button` unless `render` says otherwise.
*/
component AlertDialogTrigger(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders AlertDialog.Trigger {
return (
<AlertDialog.Trigger
{...forwarded(rest)}
render={
match (render) {
undefined =>
(trigger) => (
<Button
{...forwarded(trigger)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
),
const custom => custom,
}
}
>
{children}
</AlertDialog.Trigger>
);
}
/** The scrim and the panel. There is no close button: see the header. */
component AlertDialogContent(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<>
<AlertDialog.Overlay className={props(styles.overlay).className} />
<AlertDialog.Body
{...forwarded(rest)}
className={classNames(props(styles.panel, xstyle).className, className)}
>
{children}
</AlertDialog.Body>
</>
);
}
/** The question and what answering costs, stacked. */
component AlertDialogHeader(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders AlertDialog.Header {
return (
<AlertDialog.Header
{...forwarded(rest)}
className={classNames(props(styles.header, xstyle).className, className)}
>
{children}
</AlertDialog.Header>
);
}
/** The row the two answers sit in, at the end of the reading direction. */
component AlertDialogFooter(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders AlertDialog.Footer {
return (
<AlertDialog.Footer
{...forwarded(rest)}
className={classNames(props(styles.footer, xstyle).className, className)}
>
{children}
</AlertDialog.Footer>
);
}
/** The question, which is the alert dialog's accessible name. */
component AlertDialogTitle(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders AlertDialog.Title {
return (
<AlertDialog.Title
{...forwarded(rest)}
className={classNames(props(styles.title, xstyle).className, className)}
>
{children}
</AlertDialog.Title>
);
}
/** What answering costs, announced the moment focus arrives. Required. */
component AlertDialogDescription(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders AlertDialog.Description {
return (
<AlertDialog.Description
{...forwarded(rest)}
className={classNames(props(styles.description, xstyle).className, className)}
>
{children}
</AlertDialog.Description>
);
}
/** The answer that does the thing. A deletion is `tone="danger"`. */
component AlertDialogAction(
children : React.Node,
tone? : ButtonTone = "primary",
size? : ButtonSize = "md",
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders AlertDialog.Action {
return (
<AlertDialog.Action
{...forwarded(rest)}
render={
match (render) {
undefined =>
(action) => (
<Button
{...forwarded(action)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
),
const custom => custom,
}
}
>
{children}
</AlertDialog.Action>
);
}
/** The answer that declines, and the one focus starts on. */
component AlertDialogCancel(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders AlertDialog.Cancel {
return (
<AlertDialog.Cancel
{...forwarded(rest)}
render={
match (render) {
undefined =>
(cancel) => (
<Button
{...forwarded(cancel)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
),
const custom => custom,
}
}
>
{children}
</AlertDialog.Cancel>
);
}
/**
* A caller's props on their way into a part, rather than onto an element.
*
* Flow checks a spread into a component against that component's own
* `...rest`, `key` included, and the indexer in `Rest` answers `mixed` where
* the part says `empty`. `@uniflowed/ui` meets the same hole between its own
* parts, and papers over it the same way in one named place,
* `internal/merge-props.js`'s `forwarded`: nothing that was ever checked is
* lost, because every element those parts render has `any`-typed props in uf's
* library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. See `button.js`. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as AlertDialog from "./alert-dialog.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<AlertDialog.Root>`
* and `<AlertDialog.Trigger>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `AlertDialogRoot` rather than `Root`.
*/
export {
AlertDialogRoot as Root,
AlertDialogTrigger as Trigger,
AlertDialogContent as Content,
AlertDialogHeader as Header,
AlertDialogFooter as Footer,
AlertDialogTitle as Title,
AlertDialogDescription as Description,
AlertDialogAction as Action,
AlertDialogCancel as Cancel,
};
avatar
a person's picture in a circle, and their initials when the picture cannot be shown.
uf ui add avatar
import * as Avatar from "./components/ui/avatar.js";
ALAda Lovelace
The example, avatar.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Avatar from "./avatar.js";
const styles = stylex.create({
row: {
display : "flex",
alignItems: "center",
gap : ufTokens.space3,
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
},
});
/** A reviewer beside their name, with initials while the picture is missing. */
export component Example() {
return (
<div {...props(styles.row)}>
<Avatar.Root>
{/* No picture yet: an empty source is nothing coming, so the initials show at once. */}
<Avatar.Image src="" />
<Avatar.Fallback>AL</Avatar.Fallback>
</Avatar.Root>
<span>Ada Lovelace</span>
</div>
);
}
The component, avatar.js
"use client";
// @flow
//
// Avatar: a person's picture in a circle, and their initials when the picture
// cannot be shown.
//
// `uf ui add avatar` wrote this file into the project, and it is the project's
// from then on. `uf ui diff avatar` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The circle, its three sizes, the picture's fit and the initials' type.
// `@uniflowed/ui`'s `Avatar` owns the three states: loading, loaded and failed,
// with the initials held back for a moment so a cached picture does not flash
// somebody's initials first, and an `alt` that is empty unless it is given.
//
// # What to keep true when you change it
//
// * **`alt` is empty beside a name.** An avatar next to "Ada Lovelace" is
// decoration, and naming it again makes a screen reader say the name twice.
// An avatar standing alone passes `alt="Ada Lovelace"`.
// * **The initials are decoration too** when a name is beside them; they are
// what a sighted reader sees while the picture is missing.
// * **The initials sit on a measured pair:** `ink` on `sunken`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Avatar } from "@uniflowed/ui";
/** How big the circle is. */
export type AvatarSize = "sm" | "md" | "lg";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
position : "relative",
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
flexShrink : 0,
overflow : "hidden",
borderRadius : ufTokens.radiusPill,
backgroundColor: ufTokens.sunken,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontWeight : ufTokens.weightMedium,
lineHeight : 1,
userSelect : "none",
},
sm: {
width : "32px",
height : "32px",
fontSize: ufTokens.textXs,
},
md: {
width : "40px",
height : "40px",
fontSize: ufTokens.textSm,
},
lg: {
width : "56px",
height : "56px",
fontSize: ufTokens.textMd,
},
image: {
width : "100%",
height : "100%",
objectFit: "cover",
},
fallback: {
display : "inline-flex",
alignItems : "center",
justifyContent: "center",
width : "100%",
height : "100%",
},
});
/** The circle. */
component AvatarRoot(
children : React.Node,
size? : AvatarSize = "md",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Avatar.Root {
const styled = props(
styles.root,
match (size) {
"sm" => styles.sm,
"md" => styles.md,
"lg" => styles.lg,
},
xstyle,
);
return (
<Avatar.Root {...forwarded(rest)} className={classNames(styled.className, className)}>
{children}
</Avatar.Root>
);
}
/** The picture. `alt` stays empty when the person's name is beside it. */
component AvatarImage(
src? : string | null,
alt? : string = "",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Avatar.Image {
return (
<Avatar.Image
{...forwarded(rest)}
alt={alt}
className={classNames(props(styles.image, xstyle).className, className)}
src={src}
/>
);
}
/** What shows while there is no picture: initials, usually. */
component AvatarFallback(
children : React.Node,
delay? : number = 300,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Avatar.Fallback {
return (
<Avatar.Fallback
{...forwarded(rest)}
className={classNames(props(styles.fallback, xstyle).className, className)}
delay={delay}
>
{children}
</Avatar.Fallback>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Avatar from "./avatar.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Avatar.Root>`
* and `<Avatar.Image>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `AvatarRoot` rather than `Root`.
*/
export { AvatarRoot as Root, AvatarImage as Image, AvatarFallback as Fallback };
badge
a short label beside something, such as a status, a count or a category, in five tones.
uf ui add badge
import { Badge } from "./components/ui/badge.js";
DraftBetaNewFailedv0.1.0
The example, badge.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Badge } from "./badge.js";
const styles = stylex.create({
row: {
display : "flex",
flexWrap : "wrap",
alignItems: "center",
gap : ufTokens.space2,
},
});
/** Every tone, each with the words that carry its meaning. */
export component Example() {
return (
<div {...props(styles.row)}>
<Badge>Draft</Badge>
<Badge tone="accent">Beta</Badge>
<Badge tone="solid">New</Badge>
<Badge tone="danger">Failed</Badge>
<Badge tone="outline">v0.1.0</Badge>
</div>
);
}
The component, badge.js
// @flow
//
// Badge: a short label beside something, such as a status, a count or a
// category, in five tones.
//
// `uf ui add badge` wrote this file into the project, and it is the project's
// from then on. `uf ui diff badge` shows how it has moved away from the
// registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// A badge makes no accessibility decision: it has no role, no state and no
// key. `@uniflowed/ui` declines one for that reason (`crates/uf_lib/src/ui.rs`
// records it). An application still wants every badge to look the same, and
// that is a decision about this application. So the badge is written here, in
// the application's repository, as a `<span>` that passes through everything a
// caller gives it.
//
// # What to keep true when you change it
//
// * **The words carry the meaning.** "Failed" in a red badge is read aloud as
// "Failed". A red dot with no text is read as nothing. A badge that
// stands for a status holds the status in text, even if the text is visually
// hidden.
// * **A badge is not a button.** Nothing about it can be focused or pressed.
// If clicking it does something, it is a `Button` (or a link) styled small,
// because a keyboard user has to be able to reach it.
// * **A count needs its noun.** "3" beside "Inbox" is read as "Inbox 3". Put
// the noun in an `aria-label` on the thing the badge belongs to, or in
// visually hidden text, so it is read as "3 unread".
// * **Text stays on measured pairs.** `ink` on `sunken`, `accent` on
// `accentSoft`, `accentInk` on `accent`, `danger` on `dangerSoft` and `ink`
// on `surface`. `crates/uf_stylex/src/tests/preset.rs` holds each of these
// to 4.5:1 in the light default and the dark theme, so a theme of your own
// that keeps them keeps the contrast.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/** How loud a badge is. `solid` is the one to reach for least. */
export type BadgeTone = "neutral" | "accent" | "solid" | "danger" | "outline";
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
base: {
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space1,
boxSizing : "border-box",
minHeight : "20px",
paddingBlock : 0,
paddingInline: ufTokens.space2,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textXs,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
whiteSpace : "nowrap",
verticalAlign: "middle",
borderWidth : "1px",
borderStyle : "solid",
borderColor : "transparent",
borderRadius : ufTokens.radiusSm,
},
neutral: {
backgroundColor: ufTokens.sunken,
color : ufTokens.ink,
},
accent: {
backgroundColor: ufTokens.accentSoft,
color : ufTokens.accent,
},
solid: {
backgroundColor: ufTokens.accent,
color : ufTokens.accentInk,
},
danger: {
backgroundColor: ufTokens.dangerSoft,
color : ufTokens.danger,
},
outline: {
backgroundColor: ufTokens.surface,
borderColor : ufTokens.border,
color : ufTokens.ink,
},
});
/**
* A badge.
*
* <Badge tone="danger">Failed</Badge>
*
* `xstyle` takes a `stylex.create` namespace and wins property by property;
* `className` adds a class of your own beside these.
*/
export component Badge(
children : React.Node,
tone? : BadgeTone = "neutral",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
const styled = props(
styles.base,
match (tone) {
"neutral" => styles.neutral,
"accent" => styles.accent,
"solid" => styles.solid,
"danger" => styles.danger,
"outline" => styles.outline,
},
xstyle,
);
return (
<span {...rest} className={classNames(styled.className, className)}>
{children}
</span>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
breadcrumb
the trail from the top of a site to the page a reader is on, as a row of links.
uf ui add breadcrumb
import * as Breadcrumb from "./components/ui/breadcrumb.js";
The example, breadcrumb.example.js
// @flow
import * as React from "@uniflowed/react";
import * as Breadcrumb from "./breadcrumb.js";
/** Three levels down into a project's settings. */
export component Example() {
return (
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Link href="/">Home</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Link href="?project=atlas">Atlas</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Page>Settings</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
);
}
The component, breadcrumb.js
// @flow
//
// Breadcrumb: the trail from the top of a site to the page a reader is on, as
// a row of links.
//
// `uf ui add breadcrumb` wrote this file into the project, and it is the
// project's from then on. `uf ui diff breadcrumb` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The row, the links' look, the chevrons between them and the current page.
// `@uniflowed/ui`'s `Breadcrumb` owns the markup a reader navigates: a named
// `<nav>` around an ordered list, `aria-current="page"` on the last crumb,
// which is not a link, and separators that are `aria-hidden` so the trail is
// not read as "Home slash Settings slash Billing".
//
// # What to keep true when you change it
//
// * **The last crumb is the page, not a link to it.** Use `Breadcrumb.Page`.
// * **A list holds crumbs and separators.** `Breadcrumb.List` takes
// `renders* (BreadcrumbItem | BreadcrumbSeparator)`, so anything else in the
// `<ol>` is a Flow error.
// * **A link looks like a link on hover and on focus,** and the page it leads
// to is named by its words.
// * **Colour comes from tokens, in measured pairs:** `muted` and `ink` on
// `canvas` and `surface`, which `crates/uf_stylex/src/tests/preset.rs` holds
// to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Breadcrumb } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** A caller's own element in place of the one a part renders. */
type RenderProp = (props: Rest) => React.Node;
const styles = stylex.create({
list: {
display : "flex",
flexWrap : "wrap",
alignItems: "center",
gap : ufTokens.space1,
margin : 0,
padding : 0,
listStyle : "none",
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight: ufTokens.leadingTight,
color : ufTokens.muted,
},
item: {
display : "inline-flex",
alignItems: "center",
},
link: {
color : { default: ufTokens.muted, ":hover": ufTokens.ink },
textDecorationLine : { default: "none", ":hover": "underline" },
textUnderlineOffset: "3px",
borderRadius : ufTokens.radiusSm,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
page: {
color : ufTokens.ink,
fontWeight: ufTokens.weightMedium,
},
separator: {
display : "inline-flex",
alignItems: "center",
color : ufTokens.muted,
},
});
/** The trail. `label` names the `<nav>`, "Breadcrumb" unless it is given. */
component BreadcrumbRoot(
children : React.Node,
label? : string = "Breadcrumb",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Breadcrumb.Root {
return (
<Breadcrumb.Root
{...forwarded(rest)}
className={classNames(props(xstyle).className, className)}
label={label}
>
{children}
</Breadcrumb.Root>
);
}
/** The ordered list of crumbs and the separators between them. */
component BreadcrumbList(
children : renders* (BreadcrumbItem | BreadcrumbSeparator),
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Breadcrumb.List {
return (
<Breadcrumb.List
{...forwarded(rest)}
className={classNames(props(styles.list, xstyle).className, className)}
>
{children}
</Breadcrumb.List>
);
}
/** One crumb. */
component BreadcrumbItem(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Breadcrumb.Item {
return (
<Breadcrumb.Item
{...forwarded(rest)}
className={classNames(props(styles.item, xstyle).className, className)}
>
{children}
</Breadcrumb.Item>
);
}
/** A crumb that leads somewhere. `render` for the router's own link. */
component BreadcrumbLink(
children : React.Node,
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Breadcrumb.Link {
return (
<Breadcrumb.Link
{...forwarded(rest)}
className={classNames(props(styles.link, xstyle).className, className)}
render={render}
>
{children}
</Breadcrumb.Link>
);
}
/** The page the reader is on, which is not a link. */
component BreadcrumbPage(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Breadcrumb.Page {
return (
<Breadcrumb.Page
{...forwarded(rest)}
className={classNames(props(styles.page, xstyle).className, className)}
>
{children}
</Breadcrumb.Page>
);
}
/** The mark between two crumbs, a chevron unless it is given; decoration. */
component BreadcrumbSeparator(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Breadcrumb.Separator {
return (
<Breadcrumb.Separator
{...forwarded(rest)}
className={classNames(props(styles.separator, xstyle).className, className)}
>
{
match (children) {
null | undefined =>
<svg
fill="none"
focusable="false"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="14"
>
<path d="m9 18 6-6-6-6" />
</svg>,
const given => given,
}
}
</Breadcrumb.Separator>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Breadcrumb from "./breadcrumb.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Breadcrumb.Root>`
* and `<Breadcrumb.List>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `BreadcrumbRoot` rather than `Root`.
*/
export {
BreadcrumbRoot as Root,
BreadcrumbList as List,
BreadcrumbItem as Item,
BreadcrumbLink as Link,
BreadcrumbPage as Page,
BreadcrumbSeparator as Separator,
};
button
a <button> in four tones and four sizes, styled with uf's tokens.
uf ui add button
import { Button } from "./components/ui/button.js";
The example, button.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Button } from "./button.js";
const styles = stylex.create({
row: {
display : "flex",
flexWrap : "wrap",
alignItems: "center",
gap : ufTokens.space2,
},
});
/** Every tone, the sizes, both spellings of disabled, and an icon button. */
export component Example() {
return (
<div {...props(styles.row)}>
<Button tone="primary">Save</Button>
<Button>Cancel</Button>
<Button tone="ghost">Skip</Button>
<Button tone="danger">Delete</Button>
<Button size="sm">Small</Button>
<Button size="lg">Large</Button>
<Button disabled tone="primary">
Saving…
</Button>
<Button aria-disabled="true">Not yet</Button>
<Button aria-label="Add a row" size="icon">
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="M12 5v14M5 12h14" />
</svg>
</Button>
</div>
);
}
The component, button.js
// @flow
//
// Button: a `<button>` in four tones and four sizes, styled with uf's tokens.
//
// `uf ui add button` wrote this file into the project, and from then on it is
// the project's: change the tones, the sizes, the markup. `uf ui diff button`
// shows how it differs from the registry in the uf you are running, and the
// comment `uf ui add` put on its last line records which uf this copy began as.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// The platform's `<button>` already has the role, the keyboard and the focus,
// so a headless package has nothing to add to it, and `@uniflowed/ui` declines
// a Button for exactly that reason. What an application wants beside it is one
// button its pages agree on. That is a decision about this application, so it
// belongs in the application's repository, which is where this file is.
//
// Everything a caller passes reaches the element: `form`, `formAction`,
// `name`, `value`, a `ref`, every `aria-*` and every handler. A wrapper that
// forwarded a chosen few would lose the rest quietly, which is the objection
// `@uniflowed/ui`'s header makes to wrapping a `<button>` at all.
//
// # What to keep true when you change it
//
// * **`type` is `"button"` unless you say otherwise.** A `<button>` inside a
// form submits it by default, and a Cancel that posts the form is the classic
// bug. A submit button says `type="submit"`.
// * **The focus ring is drawn for the keyboard.** `:focus-visible`, two pixels
// of `ufTokens.focus` two pixels outside the edge, which is the 3:1 a focus
// indicator needs against `surface` and `canvas` in both shipped themes. A
// button without it is one a keyboard user cannot find.
// * **Every size is a target a finger can hit.** The smallest is 32px tall,
// over the 24px WCAG 2.5.8 asks for, and `icon` is 36px square.
// * **An icon button needs a name.** `size="icon"` holds no text, so pass
// `aria-label`, and give the icon `aria-hidden`.
// * **Disabled has two spellings, and both look disabled.** `disabled` takes the
// button out of the tab order. `aria-disabled="true"` keeps it focusable, so
// the reason it is unavailable can still be reached and read; ignoring the
// click is then yours to do.
// * **Colour comes from tokens, in pairs that are measured.** Every text colour
// here sits on the background `@uniflowed/stylex`'s own button puts it on,
// and `crates/uf_stylex/src/tests/preset.rs` holds those pairs to 4.5:1 in
// the light default and the dark theme. `ufAutoTheme`, `ufDarkTheme` and a
// theme of your own restyle every tone without an edit here; a literal colour
// is where that stops being true.
// * **A press is felt.** The button gives to 97% of its size while it is held
// and comes back on release, and a keyboard focus ring draws outward from
// its edge; both in `durationFast`. Under `prefers-reduced-motion: reduce`
// neither moves, and only the colours still change.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/** How loud a button is, and therefore what it is for. */
export type ButtonTone = "primary" | "neutral" | "ghost" | "danger";
/** How big it is. `icon` is square, for a button whose only content is an icon. */
export type ButtonSize = "sm" | "md" | "lg" | "icon";
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is named `empty` because React takes it off before a component
* is called, which is what lets the rest be spread onto a `<button>`.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
base: {
display : "inline-flex",
alignItems : "center",
justifyContent: "center",
gap : ufTokens.space2,
flexShrink : 0,
boxSizing : "border-box",
margin : 0,
fontFamily : ufTokens.fontSans,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
whiteSpace : "nowrap",
textDecoration: "none",
borderWidth : "1px",
borderStyle : "solid",
borderColor : "transparent",
borderRadius : ufTokens.radiusMd,
cursor: {
default : "pointer",
":disabled" : "not-allowed",
":is([aria-disabled=true])": "not-allowed",
},
opacity: {
default : 1,
":disabled" : 0.55,
":is([aria-disabled=true])": 0.55,
},
// The ring is drawn for a keyboard focus only, which is what
// `:focus-visible` is for: a click should not light the control up.
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
// Press: the button gives a little under the pointer, to 97% of its size
// in `durationFast`, and comes back on release without passing 1. A
// disabled button does not move, and under reduced motion none does: only
// the colours still change.
transform: {
default : "none",
":active" : "scale(0.97)",
":disabled" : "none",
":is([aria-disabled=true])" : "none",
"@media (prefers-reduced-motion: reduce)": "none",
},
// The focus ring draws itself outward from the edge (`outline-width`
// 0 → 2px) rather than blinking on, in `durationFast`: it is where a
// keyboard reader's eye is, so it should arrive, not flash.
transitionProperty: {
default: "background-color, border-color, color, outline-width, transform",
"@media (prefers-reduced-motion: reduce)": "background-color, border-color, color",
},
transitionDuration : ufTokens.durationFast,
transitionTimingFunction: ufTokens.easing,
},
// Each tone restates its resting colour for both disabled states, because
// `:hover` would otherwise still repaint a button that does nothing.
primary: {
backgroundColor: {
default : ufTokens.accent,
":hover" : ufTokens.accentHover,
":disabled" : ufTokens.accent,
":is([aria-disabled=true])": ufTokens.accent,
},
color: ufTokens.accentInk,
},
neutral: {
backgroundColor: {
default : ufTokens.surface,
":hover" : ufTokens.surfaceHover,
":disabled" : ufTokens.surface,
":is([aria-disabled=true])": ufTokens.surface,
},
borderColor: ufTokens.border,
color : ufTokens.ink,
},
ghost: {
backgroundColor: {
default : "transparent",
":hover" : ufTokens.surfaceHover,
":disabled" : "transparent",
":is([aria-disabled=true])": "transparent",
},
color: ufTokens.ink,
},
danger: {
backgroundColor: {
default : ufTokens.danger,
":hover" : ufTokens.dangerHover,
":disabled" : ufTokens.danger,
":is([aria-disabled=true])": ufTokens.danger,
},
color: ufTokens.dangerInk,
},
sm: {
minHeight : "32px",
paddingBlock : ufTokens.space1,
paddingInline: ufTokens.space3,
fontSize : ufTokens.textSm,
},
md: {
minHeight : "36px",
paddingBlock : ufTokens.space2,
paddingInline: ufTokens.space4,
fontSize : ufTokens.textSm,
},
lg: {
minHeight : "44px",
paddingBlock : ufTokens.space3,
paddingInline: ufTokens.space6,
fontSize : ufTokens.textMd,
},
icon: {
width : "36px",
height : "36px",
padding : 0,
fontSize: ufTokens.textSm,
},
});
/**
* A button.
*
* <Button tone="primary" type="submit">Save</Button>
* <Button aria-label="Close" size="icon"><CloseIcon aria-hidden /></Button>
*
* `xstyle` takes a `stylex.create` namespace and wins property by property,
* which is how to change one declaration here from the call site. `className`
* adds a class of your own beside these.
*/
export component Button(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
type? : "button" | "submit" | "reset" = "button",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
const styled = props(
styles.base,
match (tone) {
"primary" => styles.primary,
"neutral" => styles.neutral,
"ghost" => styles.ghost,
"danger" => styles.danger,
},
match (size) {
"sm" => styles.sm,
"md" => styles.md,
"lg" => styles.lg,
"icon" => styles.icon,
},
xstyle,
);
return (
<button {...rest} className={classNames(styled.className, className)} type={type}>
{children}
</button>
);
}
/**
* The classes this file chose, then the caller's.
*
* Appended rather than merged: a class from a stylesheet cannot be ordered
* against StyleX's property by property, so `xstyle` is the way to replace a
* declaration here and `className` is the way to add one.
*/
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
button-group
related buttons joined into one bar, named as a group.
uf ui add button-group
import * as ButtonGroup from "./components/ui/button-group.js";
The example, button-group.example.js
// @flow
import * as React from "@uniflowed/react";
import * as ButtonGroup from "./button-group.js";
/** Three actions on one message, joined into a named bar. */
export component Example() {
return (
<ButtonGroup.Root label="Message actions">
<ButtonGroup.Item>Archive</ButtonGroup.Item>
<ButtonGroup.Item>Report</ButtonGroup.Item>
<ButtonGroup.Item disabled>Snooze</ButtonGroup.Item>
</ButtonGroup.Root>
);
}
The component, button-group.js
// @flow
//
// ButtonGroup: related buttons joined into one bar, named as a group.
//
// `uf ui add button-group` wrote this file into the project, and it is the
// project's from then on. `uf ui diff button-group` shows how it has moved
// away from the registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// A button group is `role="group"` around some buttons, with their inner
// corners squared. The role is the one attribute in it, and every button keeps
// the keys the platform gives it, so `@uniflowed/ui` declines a `ButtonGroup`
// (`crates/uf_lib/src/ui.rs` records it). What an application wants is the
// joined look, the same everywhere, and that is written here.
//
// # What to keep true when you change it
//
// * **The group has a name.** `role="group"` with no `aria-label` or
// `aria-labelledby` is announced as "group" and nothing else. `label` is
// required for that reason, and lands as `aria-label`.
// * **Every button is its own tab stop.** `Tab` walks through them in order,
// the way it walks through any buttons. A bar where the arrow keys move
// between the buttons and `Tab` leaves it in one step is a toolbar, which is
// a keyboard contract rather than a look; do not add arrow keys here without
// the rest of it.
// * **A choice of one is not a button group.** Buttons where exactly one is on
// ("Day", "Week", "Month") are a `ToggleGroup`, whose `type="single"` is the
// radio pattern and announces which one is chosen.
// * **An icon-only item needs a name.** Pass `aria-label`, and give the icon
// `aria-hidden`.
// * **`type` is `"button"` unless you say otherwise,** as in `button.js`: an
// item inside a form does not submit it by accident.
// * **The focus ring is drawn over the neighbours.** A focused item is raised
// above the ones beside it, so its ring is not cut off by their borders.
// * **Text stays on measured pairs.** `ink` on `surface` and `surfaceHover`,
// which `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both shipped
// themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "inline-flex",
alignItems: "stretch",
isolation : "isolate",
},
item: {
position : "relative",
zIndex : { default: 0, ":hover": 1, ":focus-visible": 2 },
display : "inline-flex",
alignItems : "center",
justifyContent: "center",
gap : ufTokens.space2,
boxSizing : "border-box",
minHeight : "36px",
margin : 0,
// Each item after the first overlaps the one before by its border, so
// two neighbours draw one line between them rather than two.
marginInlineStart: { default: "-1px", ":first-child": 0 },
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space4,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
whiteSpace : "nowrap",
color : ufTokens.ink,
backgroundColor: {
default : ufTokens.surface,
":hover" : ufTokens.surfaceHover,
":disabled": ufTokens.surface,
},
borderWidth: "1px",
borderStyle: "solid",
borderColor: ufTokens.border,
// Square inside, rounded at the two ends of the bar.
borderRadius : 0,
borderStartStartRadius: { default: 0, ":first-child": ufTokens.radiusMd },
borderEndStartRadius : { default: 0, ":first-child": ufTokens.radiusMd },
borderStartEndRadius : { default: 0, ":last-child": ufTokens.radiusMd },
borderEndEndRadius : { default: 0, ":last-child": ufTokens.radiusMd },
cursor : { default: "pointer", ":disabled": "not-allowed" },
opacity : { default: 1, ":disabled": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
// Only colour changes, so it is the same under reduced motion.
transitionProperty : "background-color",
transitionDuration : ufTokens.durationFast,
transitionTimingFunction: ufTokens.easing,
},
});
/** The bar, named as a group by `label`. */
component ButtonGroupRoot(
children : React.Node,
label : string,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div
{...rest}
aria-label={label}
className={classNames(props(styles.root, xstyle).className, className)}
role="group"
>
{children}
</div>
);
}
/** One button in the bar. */
component ButtonGroupItem(
children : React.Node,
type? : "button" | "submit" | "reset" = "button",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<button
{...rest}
className={classNames(props(styles.item, xstyle).className, className)}
type={type}
>
{children}
</button>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as ButtonGroup from "./button-group.js"`
* gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes
* `<ButtonGroup.Root>` and `<ButtonGroup.Item>`. Each is declared under its
* full name, so React DevTools and an error say `ButtonGroupItem` rather than
* `Item`.
*/
export { ButtonGroupRoot as Root, ButtonGroupItem as Item };
calendar
a month of days to choose one from, moved through from the keyboard and paged a month at a time.
uf ui add calendar
import * as Calendar from "./components/ui/calendar.js";
September 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
The example, calendar.example.js
// @flow
import * as Calendar from "./calendar.js";
/** Whether a date falls on a Saturday or a Sunday. */
function weekend(date: { readonly dayOfWeek: number, ... }): boolean {
return date.dayOfWeek > 5;
}
/** September 2026, the 14th chosen and today, with weekends out of reach. */
export component Example() {
return (
<Calendar.Root
defaultValue="2026-09-14"
isDateDisabled={weekend}
locale="en-US"
today="2026-09-14"
>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Next />
</Calendar.Header>
<Calendar.Month />
</Calendar.Root>
);
}
The component, calendar.js
"use client";
// @flow
//
// Calendar: a month of days to choose one from, moved through from the keyboard
// and paged a month at a time.
//
// `uf ui add calendar` wrote this file into the project, and it is the project's
// from then on. `uf ui diff calendar` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The month's frame, the buttons to the months either side, and each day:
// today in the accent's numeral, the chosen day filled, a range's middle as a
// pale band between its filled ends, and a day that cannot be chosen muted and
// struck through. `@uniflowed/ui`'s `Calendar` owns the grid: a `<table
// role="grid">` named by its caption, weekday headings that give a reader the
// whole weekday name, one day in the tab order with the keyboard moving between
// days and months, and a polite announcement of the month shown. A day is drawn
// from `aria-selected`, `aria-current="date"`, `aria-disabled`, and
// `data-selection-start` / `data-selection-end` on the ends of a chosen run.
//
// The caption and the weekday headings are the part's own elements and take
// this file's classes through `captionClassName` and `columnHeaderClassName`.
// The month buttons hang on the caption's line, at its inline ends. Every prop
// of the part's root, such as `value`, `locale` or `isDateDisabled`, passes
// through `Calendar` unchanged.
//
// # What to keep true when you change it
//
// * **Name the month buttons.** They show only an arrow; `label` is what a
// reader hears.
// * **Chosen is more than a colour.** The chosen day is filled, and a reader
// hears it as selected. Today is bold as well as coloured.
// * **Numerals line up.** Days are `tabular-nums` and each weekday heading is
// a day's width, so the columns are columns in any locale.
// * **Keep the ring.** One day takes focus at a time, and its outline is how a
// sighted keyboard user follows it.
// * **Text stays on measured pairs.** `ink`, `muted` and `accent` on `surface`
// and `surfaceHover`, `ink` and `accent` on `accentSoft` inside a range, and
// `accentInk` on `accent` for the chosen day, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Calendar } from "@uniflowed/ui";
export type { DateValue } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
// A block rather than a grid: the header is a zero-height row the month
// slides under (see `header`), and a grid's gap would open a space between
// the two that pushed the caption off the buttons' line.
root: {
display : "inline-block",
boxSizing : "border-box",
padding : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor: ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
},
// The month buttons sit on the caption's line, one at each end, with the
// month's name between them. The caption belongs to the grid — it is the
// grid's accessible name — so it cannot move into this row; instead this row
// takes no height, and its buttons hang over the caption's ends. `inline`
// ends, so a right-to-left page puts "previous" on the right.
header: {
position : "relative",
zIndex : 1,
display : "flex",
alignItems : "flex-start",
justifyContent: "space-between",
height : 0,
},
step: {
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
boxSizing : "border-box",
width : "32px",
height : "32px",
margin : 0,
padding : 0,
color : { default: ufTokens.muted, ":hover": ufTokens.ink },
backgroundColor: { default: "transparent", ":hover": ufTokens.surfaceHover },
borderWidth : "1px",
borderStyle : "solid",
borderColor : "transparent",
borderRadius : ufTokens.radiusSm,
cursor : { default: "pointer", ":disabled": "not-allowed" },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "-2px",
},
// Mirrored in a right-to-left page, where "previous" points right.
chevron: {
display : "block",
transform: { default: "none", ":dir(rtl)": "scaleX(-1)" },
},
month: {
// No space between columns, so a range reads as one band; a little
// between weeks, so the rows stay rows.
borderCollapse: "separate",
borderSpacing : "0 2px",
// Columns a day wide whatever the headings say: some locales' short
// weekday names are whole words, and an automatic layout widened their
// columns and pulled the numerals out of line.
tableLayout: "fixed",
margin : 0,
fontSize : ufTokens.textSm,
textAlign : "center",
},
// The month and year, on the buttons' line and clear of them.
caption: {
captionSide : "top",
boxSizing : "border-box",
height : "32px",
paddingInline: "36px",
marginBottom : ufTokens.space1,
overflow : "hidden",
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
lineHeight : "32px",
whiteSpace : "nowrap",
textOverflow : "ellipsis",
color : ufTokens.ink,
},
// As wide as a day, so each initial stands over its column.
weekday: {
boxSizing : "border-box",
width : "36px",
height : "28px",
padding : 0,
fontSize : ufTokens.textXs,
fontWeight : ufTokens.weightMedium,
lineHeight : 1,
color : ufTokens.muted,
textAlign : "center",
verticalAlign: "middle",
// A long short name is clipped rather than allowed to widen its column;
// the heading's `aria-label` still gives the reader the whole name.
overflow : "hidden",
whiteSpace: "nowrap",
},
day: {
boxSizing : "border-box",
width : "36px",
height : "36px",
padding : 0,
fontSize : ufTokens.textSm,
lineHeight: 1,
// Every numeral the same width, so 11 does not sit narrower than 18.
fontVariantNumeric: "tabular-nums",
textAlign : "center",
verticalAlign : "middle",
cursor : { default: "pointer", ":is([aria-disabled=true])": "not-allowed" },
// Today is the accent's numeral in bold, not a ring or a fill: a mark you
// find when you look for it. The chosen day is the one fill in the month,
// and the days between a range's ends are a pale band.
//
// The states overlap — today can be chosen, or unavailable, or inside a
// range — and a key with more conditions sorts later, so each overlap is
// written as its own key rather than left to the order of two equal ones.
color: {
default : ufTokens.ink,
":is([aria-current=date])" : ufTokens.accent,
":is([aria-disabled=true]):not([aria-selected=true])": ufTokens.muted,
":is([aria-selected=true]):not([aria-disabled=true])": ufTokens.accentInk,
":is([aria-selected=true]):not([data-selection-start]):not([data-selection-end])":
ufTokens.ink,
":is([aria-current=date]):is([aria-selected=true]):not([data-selection-start]):not([data-selection-end])":
ufTokens.accent,
},
backgroundColor: {
default : "transparent",
":hover" : ufTokens.surfaceHover,
":is([aria-disabled=true])": "transparent",
":is([aria-selected=true])": ufTokens.accent,
":is([aria-selected=true]):not([data-selection-start]):not([data-selection-end])":
ufTokens.accentSoft,
},
textDecorationLine: { default: "none", ":is([aria-disabled=true])": "line-through" },
fontWeight: {
default : ufTokens.weightRegular,
":is([aria-selected=true])" : ufTokens.weightMedium,
":is([aria-current=date]):not([aria-disabled=true])": ufTokens.weightBold,
},
borderWidth: 0,
// A range's ends are square where they meet the band, named by inline
// side so the join is right in a right-to-left page too.
borderStartStartRadius: {
default : ufTokens.radiusSm,
":is([aria-selected=true]):not([data-selection-start])": 0,
},
borderEndStartRadius: {
default : ufTokens.radiusSm,
":is([aria-selected=true]):not([data-selection-start])": 0,
},
borderStartEndRadius: {
default : ufTokens.radiusSm,
":is([aria-selected=true]):not([data-selection-end])": 0,
},
borderEndEndRadius: {
default : ufTokens.radiusSm,
":is([aria-selected=true]):not([data-selection-end])": 0,
},
// Inside the cell, so neighbours never cover it; light on the filled day,
// where the focus colour would vanish into the accent.
outlineWidth: { default: "0", ":focus-visible": "2px" },
outlineStyle: "solid",
outlineColor: {
default : ufTokens.focus,
":is([aria-selected=true])": ufTokens.accentInk,
":is([aria-selected=true]):not([data-selection-start]):not([data-selection-end])":
ufTokens.focus,
},
outlineOffset: "-3px",
},
});
/**
* The calendar. Every prop of `Calendar.Root` passes through: `value` or
* `defaultValue`, `onValueChange`, `locale`, `today`, `isDateDisabled` and the
* rest.
*/
component CalendarRoot(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Calendar.Root {
return (
<Calendar.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
>
{children}
</Calendar.Root>
);
}
/** The row over the month that holds the buttons to the months either side. */
component CalendarHeader(children: React.Node, xstyle?: StyleArgument, className?: string) {
return (
<div className={classNames(props(styles.header, xstyle).className, className)}>{children}</div>
);
}
/** The button to the month before. */
component CalendarPrevious(
label? : string = "Previous month",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Calendar.Previous {
return (
<Calendar.Previous
{...forwarded(rest)}
aria-label={label}
className={classNames(props(styles.step, xstyle).className, className)}
>
<Chevron path="m15 18-6-6 6-6" />
</Calendar.Previous>
);
}
/** The button to the month after. */
component CalendarNext(
label? : string = "Next month",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Calendar.Next {
return (
<Calendar.Next
{...forwarded(rest)}
aria-label={label}
className={classNames(props(styles.step, xstyle).className, className)}
>
<Chevron path="m9 18 6-6-6-6" />
</Calendar.Next>
);
}
/** The month shown: its caption, its weekday headings, and a cell for every day. */
component CalendarMonth(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Calendar.Month {
const day = props(styles.day).className;
return (
<Calendar.Month
{...forwarded(rest)}
captionClassName={props(styles.caption).className}
className={classNames(props(styles.month, xstyle).className, className)}
columnHeaderClassName={props(styles.weekday).className}
>
{(date) => <Calendar.Day className={day} date={date} />}
</Calendar.Month>
);
}
/** An arrow for a month button. */
component Chevron(path: string) {
return (
<svg
{...props(styles.chevron)}
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d={path} />
</svg>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Calendar from "./calendar.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Calendar.Root>`
* and `<Calendar.Header>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `CalendarRoot` rather than `Root`.
*/
export {
CalendarRoot as Root,
CalendarHeader as Header,
CalendarPrevious as Previous,
CalendarNext as Next,
CalendarMonth as Month,
};
card
a surface that groups one thing's content, with an optional header, body and footer.
uf ui add card
import * as Card from "./components/ui/card.js";
Weekly digest
Sent every Monday at 9:00.
A summary of the week's changes to the projects you follow.
The example, card.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Button } from "./button.js";
import * as Card from "./card.js";
const styles = stylex.create({
frame: {
maxWidth: "24rem",
},
body: {
margin: 0,
},
});
/** A card with every part, its title a heading and its actions last. */
export component Example() {
return (
<div {...props(styles.frame)}>
<Card.Root>
<Card.Header>
<Card.Title level={2}>Weekly digest</Card.Title>
<Card.Description>Sent every Monday at 9:00.</Card.Description>
</Card.Header>
<Card.Content>
<p {...props(styles.body)}>A summary of the week's changes to the projects you follow.</p>
</Card.Content>
<Card.Footer>
<Button tone="primary">Subscribe</Button>
<Button tone="ghost">Preview</Button>
</Card.Footer>
</Card.Root>
</div>
);
}
The component, card.js
// @flow
//
// Card: a surface that groups one thing's content, with an optional header,
// body and footer.
//
// `uf ui add card` wrote this file into the project, and it is the project's
// from then on. `uf ui diff card` shows how it has moved away from the
// registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// Each part of a card is a `<div>` with a class. A card has no role:
// `role="region"` needs a name, and naming every card on a page fills the
// landmark list with noise. So `@uniflowed/ui` declines a Card
// (`crates/uf_lib/src/ui.rs` says so), and the only semantics a card has come
// from its title, which is a real heading. What an application wants from
// this file is one card that all its pages agree on.
//
// # What to keep true when you change it
//
// * **The title is a heading at the level the page needs.** `Card.Title` takes
// `level`, which is 3 by default and typed as 1 to 6, so a level that does
// not exist fails `uf check` instead of rendering a `<div>`. Screen reader
// users move through a page by its headings. A card title styled to look like
// a heading, but not marked up as one, cannot be reached that way.
// * **A card that goes somewhere has one link, on its title.** Wrapping the
// whole card in `<a>` makes a screen reader read every word in the card as the
// link's name. It also cannot hold a second control. Put the link in
// `Card.Title`, and stretch its hit area with CSS if the whole card should
// respond to a click.
// * **Content order is reading order.** The footer's actions come after the
// content in the DOM, so a keyboard reaches them after reading what they act
// on.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both shipped
// themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/** A heading level, which is all a card title can be. */
export type CardTitleLevel = 1 | 2 | 3 | 4 | 5 | 6;
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
card: {
display : "grid",
gap : ufTokens.space4,
boxSizing : "border-box",
paddingBlock : ufTokens.space6,
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusLg,
},
header: {
display : "grid",
gap : ufTokens.space1,
paddingInline: ufTokens.space6,
},
title: {
margin : 0,
fontSize : ufTokens.textMd,
fontWeight: ufTokens.weightBold,
lineHeight: ufTokens.leadingTight,
},
description: {
margin: 0,
color : ufTokens.muted,
},
content: {
paddingInline: ufTokens.space6,
},
footer: {
display : "flex",
flexWrap : "wrap",
alignItems : "center",
gap : ufTokens.space2,
paddingInline: ufTokens.space6,
},
});
/** The surface. */
component CardRoot(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.card, xstyle).className, className)}>
{children}
</div>
);
}
/** The title, the description under it, and anything that sits beside them. */
component CardHeader(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.header, xstyle).className, className)}>
{children}
</div>
);
}
/** What the card is about, as a heading at `level`. */
component CardTitle(
children : React.Node,
level? : CardTitleLevel = 3,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
const shared = {
...rest,
className: classNames(props(styles.title, xstyle).className, className),
};
return match (level) {
1 => <h1 {...shared}>{children}</h1>,
2 => <h2 {...shared}>{children}</h2>,
3 => <h3 {...shared}>{children}</h3>,
4 => <h4 {...shared}>{children}</h4>,
5 => <h5 {...shared}>{children}</h5>,
6 => <h6 {...shared}>{children}</h6>,
};
}
/** A line under the title, in the quieter colour. */
component CardDescription(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<p {...rest} className={classNames(props(styles.description, xstyle).className, className)}>
{children}
</p>
);
}
/** The body. */
component CardContent(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.content, xstyle).className, className)}>
{children}
</div>
);
}
/** The actions, after the content they act on. */
component CardFooter(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.footer, xstyle).className, className)}>
{children}
</div>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Card from "./card.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Card.Root>`
* and `<Card.Header>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `CardRoot` rather than `Root`.
*/
export {
CardRoot as Root,
CardHeader as Header,
CardTitle as Title,
CardDescription as Description,
CardContent as Content,
CardFooter as Footer,
};
carousel
slides shown one at a time, with buttons to move between them and one to stop them when they turn on their own.
uf ui add carousel
import * as Carousel from "./components/ui/carousel.js";
Routing
Styling
Testing
The example, carousel.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Carousel from "./carousel.js";
const GUIDES: $ReadOnlyArray<string> = ["Routing", "Styling", "Testing"];
const styles = stylex.create({
frame: {
maxWidth: "24rem",
},
slide: {
display : "flex",
alignItems : "center",
justifyContent: "center",
height : "10rem",
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
},
});
/** Three guides, one at a time, moved between with the buttons under them. */
export component Example() {
return (
<Carousel.Root count={GUIDES.length} label="Guides" xstyle={styles.frame}>
<Carousel.Content>
{GUIDES.map((guide, index) => (
<Carousel.Item index={index} key={guide}>
<div {...props(styles.slide)}>{guide}</div>
</Carousel.Item>
))}
</Carousel.Content>
<Carousel.Controls>
<Carousel.Previous />
<Carousel.Next />
</Carousel.Controls>
</Carousel.Root>
);
}
The component, carousel.js
"use client";
// @flow
//
// Carousel: slides shown one at a time, with buttons to move between them and
// one to stop them when they turn on their own.
//
// `uf ui add carousel` wrote this file into the project, and it is the
// project's from then on. `uf ui diff carousel` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The frame, the slide that shows, and the buttons. `@uniflowed/ui`'s `Carousel`
// owns the pattern: a `role="group"` described as a carousel and named by
// `label`, each slide a group described as a slide and named such as "2 of 5",
// slides out of sight also out of reach (`inert`), previous and next buttons
// that control the slides, a polite announcement of the slide that comes in,
// kept silent while the carousel turns on its own, and a pause button that
// says whether it is stopped. Which slide shows is drawn from `data-state`, and
// the pause button's icon from `aria-pressed`.
//
// # What to keep true when you change it
//
// * **A carousel that turns on its own has a pause button.** `Carousel.Pause`
// is how a reader stops movement they did not ask for.
// * **Name it.** `label` says what the slides are, such as "Featured guides".
// * **Keep the buttons in sight.** Previous and next are how a keyboard user
// moves between slides; do not show them only on hover.
// * **Text stays on measured pairs.** `ink` on `surface`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Carousel } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "grid",
gap : ufTokens.space2,
fontFamily: ufTokens.fontSans,
color : ufTokens.ink,
},
content: {
overflow : "hidden",
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
backgroundColor: ufTokens.surface,
},
item: {
display: { default: "block", ":is([data-state=inactive])": "none" },
},
controls: {
display : "flex",
alignItems : "center",
justifyContent: "flex-end",
gap : ufTokens.space2,
},
button: {
// Read by the pause button's two icons, which cannot see its state.
"--uf-carousel-playing": { default: "1", ":is([aria-pressed=true])": "0" },
"--uf-carousel-stopped": { default: "0", ":is([aria-pressed=true])": "1" },
position : "relative",
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
width : "36px",
height : "36px",
margin : 0,
padding : 0,
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
cursor : { default: "pointer", ":disabled": "not-allowed" },
opacity : { default: 1, ":disabled": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
whilePlaying: {
position: "absolute",
opacity : "var(--uf-carousel-playing, 1)",
},
whileStopped: {
position: "absolute",
opacity : "var(--uf-carousel-stopped, 0)",
},
});
/**
* The carousel. `count` is how many slides there are and `label` what they are;
* `autoplay` is the milliseconds between turns, or `null` to wait for a reader.
*/
component CarouselRoot(
children : React.Node,
count : number,
label : string,
autoplay? : number | null = null,
loop? : boolean = true,
index? : number,
defaultIndex? : number = 0,
onIndexChange?: (index: number) => void,
orientation? : "horizontal" | "vertical" = "horizontal",
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Carousel.Root {
return (
<Carousel.Root
{...forwarded(rest)}
autoplay={autoplay}
className={classNames(props(styles.root, xstyle).className, className)}
count={count}
defaultIndex={defaultIndex}
index={index}
label={label}
loop={loop}
onIndexChange={onIndexChange}
orientation={orientation}
>
{children}
</Carousel.Root>
);
}
/** The frame the slides show in. */
component CarouselContent(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Carousel.Content {
return (
<Carousel.Content
{...forwarded(rest)}
className={classNames(props(styles.content, xstyle).className, className)}
>
{children}
</Carousel.Content>
);
}
/** One slide, shown while it is the current one. */
component CarouselItem(
index : number,
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Carousel.Item {
return (
<Carousel.Item
{...forwarded(rest)}
className={classNames(props(styles.item, xstyle).className, className)}
index={index}
>
{children}
</Carousel.Item>
);
}
/** A row for the buttons, at the frame's inline end. */
component CarouselControls(children: React.Node, xstyle?: StyleArgument, className?: string) {
return (
<div className={classNames(props(styles.controls, xstyle).className, className)}>
{children}
</div>
);
}
/** The button to the slide before. */
component CarouselPrevious(
label? : string = "Previous slide",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Carousel.Previous {
return (
<Carousel.Previous
{...forwarded(rest)}
className={classNames(props(styles.button, xstyle).className, className)}
label={label}
>
<Chevron path="m15 18-6-6 6-6" />
</Carousel.Previous>
);
}
/** The button to the slide after. */
component CarouselNext(
label? : string = "Next slide",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Carousel.Next {
return (
<Carousel.Next
{...forwarded(rest)}
className={classNames(props(styles.button, xstyle).className, className)}
label={label}
>
<Chevron path="m9 18 6-6-6-6" />
</Carousel.Next>
);
}
/** The button that stops the slides turning on their own, and starts them again. */
component CarouselPause(
pauseLabel?: string = "Stop the carousel",
playLabel? : string = "Start the carousel",
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Carousel.Pause {
return (
<Carousel.Pause
{...forwarded(rest)}
className={classNames(props(styles.button, xstyle).className, className)}
pauseLabel={pauseLabel}
playLabel={playLabel}
>
<svg
{...props(styles.whilePlaying)}
aria-hidden="true"
fill="currentColor"
focusable="false"
height="14"
viewBox="0 0 24 24"
width="14"
>
<path d="M6 4h4v16H6zM14 4h4v16h-4z" />
</svg>
<svg
{...props(styles.whileStopped)}
aria-hidden="true"
fill="currentColor"
focusable="false"
height="14"
viewBox="0 0 24 24"
width="14"
>
<path d="M7 4v16l13-8z" />
</svg>
</Carousel.Pause>
);
}
/** An arrow for a step button. */
component Chevron(path: string) {
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d={path} />
</svg>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Carousel from "./carousel.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Carousel.Root>`
* and `<Carousel.Content>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `CarouselRoot` rather than `Root`.
*/
export {
CarouselRoot as Root,
CarouselContent as Content,
CarouselItem as Item,
CarouselControls as Controls,
CarouselPrevious as Previous,
CarouselNext as Next,
CarouselPause as Pause,
};
checkbox
one answer in a form, checked, unchecked or mixed, drawn as a box beside its label.
uf ui add checkbox
import { Checkbox } from "./components/ui/checkbox.js";
// @flow
//
// Checkbox: one answer in a form, checked, unchecked or mixed, drawn as a box
// beside its label.
//
// `uf ui add checkbox` wrote this file into the project, and it is the
// project's from then on. `uf ui diff checkbox` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The box, the tick, the dash and the label beside them, all inside the one
// button the part renders, so the whole row is the target and its words are
// the name. `@uniflowed/ui`'s `Checkbox` owns `role="checkbox"`, an `aria-checked`
// of `true`, `false` or `mixed`, `Space` toggling it, and `Enter` submitting
// the form it is in rather than toggling. The box follows `aria-checked`
// through `:is([aria-checked=true])` and `:is([aria-checked=mixed])`.
//
// # What to keep true when you change it
//
// * **Mixed is its own drawing, and its owner's to clear.** A "select all"
// with some rows selected shows a dash, not a tick and not an empty box.
// `indeterminate` is a prop rather than state: a press reports `true`
// through `onCheckedChange`, and the rows it summarises decide what it shows
// next.
// * **Its words are its name.** Put the label inside `Checkbox`; a checkbox
// with no words needs `aria-label`.
// * **The box is an edge a reader can see.** Unchecked, a `muted` border on
// `surface`; checked, an `accentInk` mark on `accent`. Both are pairs
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
// * **A setting that applies at once is a `switch`,** which a reader is told
// is on or off rather than checked.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Checkbox as CheckboxPart } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
// Read by the box and its marks, which cannot see the button's state.
"--uf-checkbox-fill": {
default : ufTokens.surface,
":is([aria-checked=true])" : ufTokens.accent,
":is([aria-checked=mixed])": ufTokens.accent,
},
"--uf-checkbox-edge": {
default : ufTokens.muted,
":is([aria-checked=true])" : ufTokens.accent,
":is([aria-checked=mixed])": ufTokens.accent,
},
"--uf-checkbox-tick": { default: "0", ":is([aria-checked=true])": "1" },
"--uf-checkbox-dash": { default: "0", ":is([aria-checked=mixed])": "1" },
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space2,
minHeight : "32px",
margin : 0,
padding : 0,
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
backgroundColor : "transparent",
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingTight,
textAlign : "start",
cursor : { default: "pointer", ":disabled": "not-allowed" },
opacity : { default: 1, ":disabled": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
// The focus ring draws itself outward from the edge (`outline-width`
// 0 → 2px) rather than blinking on, in `durationFast`: it is where a
// keyboard reader's eye is, so it should arrive, not flash.
transitionProperty: {
default : "outline-width",
"@media (prefers-reduced-motion: reduce)": "none",
},
transitionDuration : ufTokens.durationFast,
transitionTimingFunction: ufTokens.easing,
},
box: {
position : "relative",
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
flexShrink : 0,
boxSizing : "border-box",
width : ufTokens.sizeControl,
height : ufTokens.sizeControl,
borderWidth : "1px",
borderStyle : "solid",
borderColor : "var(--uf-checkbox-edge)",
borderRadius : ufTokens.radiusSm,
backgroundColor: "var(--uf-checkbox-fill)",
color : ufTokens.accentInk,
// Fills as the tick is drawn. Colour, so it stays under reduced motion.
transitionProperty : "background-color, border-color",
transitionDuration : ufTokens.durationFast,
transitionTimingFunction: ufTokens.easing,
},
tick: {
position: "absolute",
opacity : "var(--uf-checkbox-tick)",
// Checked, the tick is drawn from its short arm to its long one, the way
// a pen makes it, rather than appearing. The path `M20 6 9 17l-5-5` is
// 22.6 units long, so a dash of 23 covers it; a negative offset of the
// same length hides it with the gap and walks the dash in from the end the
// short arm is at. Unchecked, it fades out rather than being undrawn.
// Under reduced motion it only fades.
strokeDasharray : "23",
strokeDashoffset: "calc((var(--uf-checkbox-tick) - 1) * 23)",
transitionProperty: {
default : "opacity, stroke-dashoffset",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration : ufTokens.durationBase,
transitionTimingFunction: ufTokens.easing,
},
dash: {
position : "absolute",
opacity : "var(--uf-checkbox-dash)",
transitionProperty : "opacity",
transitionDuration : ufTokens.durationFast,
transitionTimingFunction: ufTokens.easing,
},
});
/** A checkbox and its label. Uncontrolled from `defaultChecked` unless `checked` is given. */
export component Checkbox(
children? : React.Node,
checked? : boolean,
defaultChecked? : boolean = false,
indeterminate? : boolean = false,
onCheckedChange?: (checked: boolean) => void,
disabled? : boolean = false,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders CheckboxPart {
return (
<CheckboxPart
{...forwarded(rest)}
checked={checked}
className={classNames(props(styles.root, xstyle).className, className)}
defaultChecked={defaultChecked}
disabled={disabled}
indeterminate={indeterminate}
onCheckedChange={onCheckedChange}
>
<span {...props(styles.box)} aria-hidden="true">
<svg
{...props(styles.tick)}
fill="none"
focusable="false"
height="12"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
viewBox="0 0 24 24"
width="12"
>
<path d="M20 6 9 17l-5-5" />
</svg>
<svg
{...props(styles.dash)}
fill="none"
focusable="false"
height="12"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="3"
viewBox="0 0 24 24"
width="12"
>
<path d="M5 12h14" />
</svg>
</span>
{children}
</CheckboxPart>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
collapsible
a button that shows and hides one region, with the region's text still findable while it is closed.
uf ui add collapsible
import * as Collapsible from "./components/ui/collapsible.js";
Looks good to me.
Could the button say what it saves?
Changed, thank you.
The example, collapsible.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Collapsible from "./collapsible.js";
const styles = stylex.create({
list: {
display : "grid",
gap : ufTokens.space1,
margin : 0,
paddingInlineStart: ufTokens.space6,
},
});
/** Older replies, tucked away until someone asks for them. */
export component Example() {
return (
<Collapsible.Root>
<Collapsible.Trigger>Show the 3 older replies</Collapsible.Trigger>
<Collapsible.Content>
<ul {...props(styles.list)}>
<li>Looks good to me.</li>
<li>Could the button say what it saves?</li>
<li>Changed, thank you.</li>
</ul>
</Collapsible.Content>
</Collapsible.Root>
);
}
The component, collapsible.js
"use client";
// @flow
//
// Collapsible: a button that shows and hides one region, with the region's
// text still findable while it is closed.
//
// `uf ui add collapsible` wrote this file into the project, and it is the
// project's from then on. `uf ui diff collapsible` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The trigger, its chevron and the spacing of the region. The disclosure
// pattern is imported from `@uniflowed/ui`'s `Collapsible`: `aria-expanded` and
// `aria-controls` on the trigger, and a closed region that is
// `hidden="until-found"` rather than removed, so the browser's find-in-page
// still reaches its text and opens it. The chevron turns from `aria-expanded`
// through `:is([aria-expanded=true])`, and the region's height slides open and
// shut to the height the part measures for it, `--uf-collapsible-height`.
//
// # What to keep true when you change it
//
// * **The trigger's words say what opens.** "Show the 3 older replies" rather
// than "More"; a reader hears the words and whether they are expanded.
// * **Nothing is only in the closed region.** A reader who never opens it has
// to be able to finish the task.
// * **The chevron is decoration.** It is `aria-hidden`: `aria-expanded` is what
// a reader is told.
// * **Colour comes from tokens, in measured pairs:** `ink` on `surface` and on
// `surfaceHover`, which `crates/uf_stylex/src/tests/preset.rs` holds to
// 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Collapsible } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "grid",
gap : ufTokens.space2,
fontFamily: ufTokens.fontSans,
color : ufTokens.ink,
},
trigger: {
// Read by the chevron, which cannot see the button's state.
"--uf-collapsible-turn": { default: "0deg", ":is([aria-expanded=true])": "180deg" },
display : "inline-flex",
alignItems : "center",
justifyContent : "space-between",
gap : ufTokens.space2,
boxSizing : "border-box",
minHeight : "36px",
margin : 0,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
textAlign : "start",
color : ufTokens.ink,
backgroundColor : { default: "transparent", ":hover": ufTokens.surfaceHover },
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
cursor : { default: "pointer", ":disabled": "not-allowed" },
opacity : { default: 1, ":disabled": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
chevron: {
flexShrink: 0,
transform : "rotate(var(--uf-collapsible-turn))",
// A half turn is travel, so it takes `durationBase`; under reduced
// motion the chevron is simply the other way up.
transitionProperty: { default: "transform", "@media (prefers-reduced-motion: reduce)": "none" },
transitionDuration: ufTokens.durationBase,
transitionTimingFunction: ufTokens.easing,
},
content: {
// Its height moves, open and closed, to the number `@uniflowed/ui`
// measures (`--uf-collapsible-height`, from `measure` on the root): up
// from 0 on a `@starting-style` in `durationBase` on the decelerating
// curve, and back down to 0 in `durationFast` on the accelerating one.
// The part keeps a closing panel shown, `inert`, until that has finished,
// and only then makes it `hidden`. Before the number is measured — on the
// server — the property is unset and the panel is its natural height.
// Under reduced motion it opens and closes at once.
//
// Clipped rather than hidden, so it is not a scroll container, and with
// room at the edge for the focus ring of a link inside it.
boxSizing : "border-box",
overflow : "clip",
overflowClipMargin: "4px",
height: {
default : "var(--uf-collapsible-height)",
"@starting-style" : "0",
":is([data-state=closed])": "0",
},
transitionProperty: { default: "height", "@media (prefers-reduced-motion: reduce)": "none" },
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
fontSize : ufTokens.textSm,
lineHeight: ufTokens.leadingBase,
},
});
/** The trigger and its region, open or closed. Uncontrolled unless `open` is given. */
component CollapsibleRoot(
children : React.Node,
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange?: (open: boolean) => void,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.root, xstyle).className, className)}>
<Collapsible.Root defaultOpen={defaultOpen} measure onOpenChange={onOpenChange} open={open}>
{children}
</Collapsible.Root>
</div>
);
}
/** The button that shows and hides the region. */
component CollapsibleTrigger(
children : React.Node,
disabled? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Collapsible.Trigger {
return (
<Collapsible.Trigger
{...forwarded(rest)}
className={classNames(props(styles.trigger, xstyle).className, className)}
disabled={disabled}
>
{children}
<svg
{...props(styles.chevron)}
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="m6 9 6 6 6-6" />
</svg>
</Collapsible.Trigger>
);
}
/** The region the trigger shows. */
component CollapsibleContent(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Collapsible.Content {
return (
<Collapsible.Content
{...forwarded(rest)}
className={classNames(props(styles.content, xstyle).className, className)}
>
{children}
</Collapsible.Content>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Collapsible from "./collapsible.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Collapsible.Root>`
* and `<Collapsible.Trigger>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `CollapsibleRoot` rather than `Root`.
*/
export { CollapsibleRoot as Root, CollapsibleTrigger as Trigger, CollapsibleContent as Content };
color-picker
hex, native color and channel inputs with a preview.
uf ui add color-picker
import * as ColorPicker from "./components/ui/color-picker.js";
The example, color-picker.example.js
// @flow
import * as React from "@uniflowed/react";
import * as ColorPicker from "./color-picker.js";
/** A named, keyboard-operable example using the default presentation. */
export component Example() {
return (
<ColorPicker.Root defaultValue="#336699">
<ColorPicker.Input aria-label="Choose color" />
<ColorPicker.Field aria-label="Hex color" />
<ColorPicker.Channel channel="red" aria-label="Red" />
<ColorPicker.Swatch />
</ColorPicker.Root>
);
}
The component, color-picker.js
"use client";
// @flow
// ColorPicker: hex, native color and channel inputs with a preview.
//
// `uf ui add color-picker` copies this component into the project. The headless
// primitive owns semantics and input behavior; this file owns its presentation.
// Keep accessible names, focus rings and disabled/selected states when editing.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { ColorPicker } from "@uniflowed/ui";
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "inline-flex",
flexDirection: "column",
alignItems : "start",
gap : ufTokens.space2,
},
field: {
boxSizing : "border-box",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderRadius : ufTokens.radiusMd,
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space1,
minHeight : "36px",
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space2,
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
outlineWidth : { default: "0", ":focus-within": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
fontVariantNumeric: "tabular-nums",
width : "10rem",
},
input: {
width : "44px",
height : "36px",
padding : "2px",
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius: ufTokens.radiusMd,
},
channel: { width: "12rem", accentColor: ufTokens.accent },
swatch: {
display : "inline-block",
width : "36px",
height : "36px",
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius: ufTokens.radiusSm,
},
});
component ColorPickerRoot(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders ColorPicker.Root {
return (
<ColorPicker.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
>
{children}
</ColorPicker.Root>
);
}
component ColorPickerInput(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders ColorPicker.Input {
return (
<ColorPicker.Input
{...forwarded(rest)}
className={classNames(props(styles.input, xstyle).className, className)}
/>
);
}
component ColorPickerField(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders ColorPicker.Field {
return (
<ColorPicker.Field
{...forwarded(rest)}
className={classNames(props(styles.field, xstyle).className, className)}
/>
);
}
component ColorPickerChannel(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders ColorPicker.Channel {
return (
<ColorPicker.Channel
{...forwarded(rest)}
className={classNames(props(styles.channel, xstyle).className, className)}
/>
);
}
component ColorPickerSwatch(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders ColorPicker.Swatch {
return (
<ColorPicker.Swatch
{...forwarded(rest)}
className={classNames(props(styles.swatch, xstyle).className, className)}
/>
);
}
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as ColorPicker from "./color-picker.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<ColorPicker.Root>`
* and `<ColorPicker.Input>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `ColorPickerRoot` rather than `Root`.
*/
export {
ColorPickerRoot as Root,
ColorPickerInput as Input,
ColorPickerField as Field,
ColorPickerChannel as Channel,
ColorPickerSwatch as Swatch,
};
combobox
a text field with a list of suggestions, navigated without leaving the field, and filtered by the page that owns the options.
uf ui add combobox
import * as Combobox from "./components/ui/combobox.js";
The example, combobox.example.js
"use client";
// @flow
import * as React from "@uniflowed/react";
import { useState } from "@uniflowed/react";
import * as Combobox from "./combobox.js";
const COUNTRIES: $ReadOnlyArray<string> = ["France", "Germany", "Japan", "Kenya", "Norway"];
/** A country, filtered by the page as the reader types. */
export component Example() {
const [query, setQuery] = useState("");
const typed = query.trim().toLowerCase();
const matches = COUNTRIES.filter((country) => country.toLowerCase().includes(typed));
return (
<Combobox.Root inputValue={query} name="country" onInputValueChange={setQuery}>
<Combobox.Label>Country</Combobox.Label>
<Combobox.Input placeholder="Start typing a country" />
<Combobox.List>
{matches.map((country) => (
<Combobox.Option key={country} value={country}>
{country}
</Combobox.Option>
))}
</Combobox.List>
<Combobox.Empty>No country matches.</Combobox.Empty>
<Combobox.Status />
</Combobox.Root>
);
}
The component, combobox.js
"use client";
// @flow
//
// Combobox: a text field with a list of suggestions, navigated without leaving
// the field, and filtered by the page that owns the options.
//
// `uf ui add combobox` wrote this file into the project, and it is the
// project's from then on. `uf ui diff combobox` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The field, the list as a panel under it, the highlight on the option the
// keyboard is on and the check beside the chosen one. The pattern is imported
// from `@uniflowed/ui`'s `Combobox`: focus that stays in the field while
// `aria-activedescendant` moves over the options, `Enter` taking the
// highlighted option and leaving the form alone when there is none, `Escape`
// closing and then clearing, and a status that counts the results for a reader
// who cannot see the list. The highlight is drawn from `data-active`, and the
// check from `aria-selected`.
//
// # What to keep true when you change it
//
// * **The page filters.** The component shows the options it is given; which
// ones match the text is the page's decision, because a list can be remote,
// fuzzy or grouped.
// * **Keep `Combobox.Status` in.** It is visually hidden, and it is how a reader
// hears "3 results available".
// * **Draw the cursor.** However an option is restyled, `data-active` needs a
// difference a sighted keyboard user can see, since focus never leaves the
// field: `accent` on `accentSoft` here, a pair
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
// * **Label it.** `Combobox.Label` names the field; a placeholder is not a
// label.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { Align, LogicalSide } from "@uniflowed/ui";
import { Combobox } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "grid",
gap : ufTokens.space2,
justifyItems: "start",
fontFamily : ufTokens.fontSans,
color : ufTokens.ink,
},
label: {
fontSize : ufTokens.textSm,
fontWeight: ufTokens.weightMedium,
lineHeight: ufTokens.leadingTight,
},
input: {
boxSizing : "border-box",
width : "16rem",
maxWidth : "100%",
minHeight : "36px",
margin : 0,
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor: ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-expanded=true])": ufTokens.accent },
borderRadius : ufTokens.radiusMd,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "1px",
"::placeholder": {
color : ufTokens.muted,
opacity: 1,
},
},
list: {
zIndex : 50,
boxSizing : "border-box",
minWidth : "var(--uf-anchor-trigger-width)",
maxHeight : "min(20rem, var(--uf-anchor-available-height, 20rem))",
overflowY : "auto",
margin : 0,
padding : ufTokens.space1,
listStyle : "none",
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
// Enter: it fades in while travelling 4px out of its trigger, from the
// side `data-side` says it opened on, so the eye is led from the button
// to what it opened. `durationBase` on the decelerating curve: most of
// the distance is covered at once, so it is legible before it has
// settled. Under reduced motion it only fades.
"--uf-enter-x": {
default : "0px",
":is([data-side=left])" : "4px",
":is([data-side=right])": "-4px",
},
"--uf-enter-y": {
default : "0px",
":is([data-side=top])" : "4px",
":is([data-side=bottom])": "-4px",
},
// Exit: back towards the trigger it came from, on the accelerating curve
// and in `durationFast` against the entrance's `durationBase`, so it gets
// out of the way rather than lingering. `@uniflowed/ui` keeps it on the
// page, closed and `inert`, until this has finished. Under reduced motion
// it only fades: `--uf-exit-travel` is 0 there, so nothing jumps either.
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style": "translate(var(--uf-enter-x), var(--uf-enter-y))",
":is([data-state=closed])":
"translate(calc(var(--uf-enter-x) * var(--uf-exit-travel)), calc(var(--uf-enter-y) * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
option: {
// Read by the check inside, which cannot see this element's state.
"--uf-combobox-check": { default: "0", ":is([aria-selected=true])": "1" },
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: ufTokens.space3,
minHeight: "32px",
paddingBlock: ufTokens.space1,
paddingInline: ufTokens.space2,
borderRadius: ufTokens.radiusSm,
lineHeight: ufTokens.leadingTight,
cursor: "pointer",
userSelect: "none",
color: { default: ufTokens.ink, ":is([data-active=true])": ufTokens.accent },
backgroundColor: { default: "transparent", ":is([data-active=true])": ufTokens.accentSoft },
fontWeight: {
default : ufTokens.weightRegular,
":is([aria-selected=true])": ufTokens.weightMedium,
},
},
optionDisabled: {
color : ufTokens.muted,
backgroundColor: "transparent",
cursor : "not-allowed",
},
check: {
flexShrink: 0,
color : ufTokens.accent,
opacity : "var(--uf-combobox-check)",
},
group: {
display : "grid",
margin : 0,
padding : 0,
paddingBlock: ufTokens.space1,
listStyle : "none",
},
groupLabel: {
paddingBlock : ufTokens.space1,
paddingInline: ufTokens.space2,
fontSize : ufTokens.textXs,
fontWeight : ufTokens.weightMedium,
color : ufTokens.muted,
},
empty: {
margin : 0,
paddingBlock: ufTokens.space2,
fontSize : ufTokens.textSm,
color : ufTokens.muted,
},
// Out of sight and still read: the rule every screen-reader-only class uses.
hidden: {
position : "absolute",
width : "1px",
height : "1px",
margin : "-1px",
padding : 0,
overflow : "hidden",
clip : "rect(0, 0, 0, 0)",
whiteSpace : "nowrap",
borderWidth: 0,
},
});
/**
* The combobox. `inputValue` and `onInputValueChange` are what the page filters
* its options by; `value` is the option taken.
*/
component ComboboxRoot(
children : React.Node,
value? : string | null,
defaultValue? : string | null = null,
onValueChange? : (value: string | null) => void,
inputValue? : string,
defaultInputValue? : string = "",
onInputValueChange?: (text: string) => void,
open? : boolean,
defaultOpen? : boolean = false,
onOpenChange? : (open: boolean) => void,
name? : string,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Combobox.Root {
return (
<Combobox.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
defaultInputValue={defaultInputValue}
defaultOpen={defaultOpen}
defaultValue={defaultValue}
inputValue={inputValue}
name={name}
onInputValueChange={onInputValueChange}
onOpenChange={onOpenChange}
onValueChange={onValueChange}
open={open}
value={value}
>
{children}
</Combobox.Root>
);
}
/** The field's name. */
component ComboboxLabel(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Combobox.Label {
return (
<Combobox.Label
{...forwarded(rest)}
className={classNames(props(styles.label, xstyle).className, className)}
>
{children}
</Combobox.Label>
);
}
/** The text field. */
component ComboboxInput(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Combobox.Input {
return (
<Combobox.Input
{...forwarded(rest)}
className={classNames(props(styles.input, xstyle).className, className)}
/>
);
}
/** The list of options, under the field and at least as wide as it. */
component ComboboxList(
children : renders* (ComboboxOption | ComboboxGroup),
align? : Align = "start",
alignOffset? : number = 0,
avoidCollisions? : boolean = true,
collisionPadding?: number = 8,
side? : LogicalSide = "bottom",
sideOffset? : number = 4,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Combobox.List {
return (
<Combobox.List
{...forwarded(rest)}
align={align}
alignOffset={alignOffset}
avoidCollisions={avoidCollisions}
className={classNames(props(styles.list, xstyle).className, className)}
collisionPadding={collisionPadding}
side={side}
sideOffset={sideOffset}
>
{children}
</Combobox.List>
);
}
/** One option, with the check that shows when it is the chosen one. */
component ComboboxOption(
value : string,
children : React.Node,
label? : string,
disabled? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Combobox.Option {
const styled = props(styles.option, disabled && styles.optionDisabled, xstyle);
return (
<Combobox.Option
{...forwarded(rest)}
className={classNames(styled.className, className)}
disabled={disabled}
label={label}
value={value}
>
<span>{children}</span>
<svg
{...props(styles.check)}
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</Combobox.Option>
);
}
/** A named group of options. */
component ComboboxGroup(
children : renders* (ComboboxOption | ComboboxGroupLabel),
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Combobox.Group {
return (
<Combobox.Group
{...forwarded(rest)}
className={classNames(props(styles.group, xstyle).className, className)}
>
{children}
</Combobox.Group>
);
}
/** The heading that names a group, which the arrow keys pass over. */
component ComboboxGroupLabel(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Combobox.GroupLabel {
return (
<Combobox.GroupLabel
{...forwarded(rest)}
className={classNames(props(styles.groupLabel, xstyle).className, className)}
>
{children}
</Combobox.GroupLabel>
);
}
/** What shows when nothing matches. */
component ComboboxEmpty(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Combobox.Empty {
return (
<Combobox.Empty
{...forwarded(rest)}
className={classNames(props(styles.empty, xstyle).className, className)}
>
{children}
</Combobox.Empty>
);
}
/** The result count a reader hears, out of sight. Words of your own go inside. */
component ComboboxStatus(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Combobox.Status {
return (
<Combobox.Status
{...forwarded(rest)}
className={classNames(props(styles.hidden, xstyle).className, className)}
>
{children}
</Combobox.Status>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Combobox from "./combobox.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Combobox.Root>`
* and `<Combobox.Label>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `ComboboxRoot` rather than `Root`.
*/
export {
ComboboxRoot as Root,
ComboboxLabel as Label,
ComboboxInput as Input,
ComboboxList as List,
ComboboxOption as Option,
ComboboxGroup as Group,
ComboboxGroupLabel as GroupLabel,
ComboboxEmpty as Empty,
ComboboxStatus as Status,
};
context-menu
the menu a right-click or a long press opens over an area of the page, where the reader is.
uf ui add context-menu
import * as ContextMenu from "./components/ui/context-menu.js";
Right-click here
The example, context-menu.example.js
// @flow
import { stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as ContextMenu from "./context-menu.js";
const styles = stylex.create({
area: {
display : "flex",
alignItems : "center",
justifyContent: "center",
boxSizing : "border-box",
width : "100%",
maxWidth : "20rem",
height : "8rem",
color : ufTokens.muted,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
borderWidth : "1px",
borderStyle : "dashed",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
},
});
/** An area whose menu opens where it is right-clicked or long-pressed. */
export component Example() {
return (
<ContextMenu.Root>
<ContextMenu.Trigger tabIndex={0} xstyle={styles.area}>
Right-click here
</ContextMenu.Trigger>
<ContextMenu.Content aria-label="Page actions">
<ContextMenu.Item>Back</ContextMenu.Item>
<ContextMenu.Item disabled>Forward</ContextMenu.Item>
<ContextMenu.Item>Reload</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.CheckboxItem defaultChecked>Show bookmarks</ContextMenu.CheckboxItem>
</ContextMenu.Content>
</ContextMenu.Root>
);
}
The component, context-menu.js
"use client";
// @flow
//
// Context menu: the menu a right-click or a long press opens over an area of
// the page, where the reader is.
//
// `uf ui add context-menu` wrote this file into the project, and it is the
// project's from then on. `uf ui diff context-menu` shows how it has moved
// away from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// Only a focus ring for the area: what the area looks like is the page's. The
// panel and its rows are `menu.js`'s parts under context-menu names, so a
// context menu and a menu look alike and change together.
// `@uniflowed/ui`'s `ContextMenu` owns opening at the pointer, a long press on a
// touch screen, opening from the keyboard on a focused area, and the menu
// pattern after that.
//
// # What to keep true when you change it
//
// * **Name the panel.** No trigger names it, so `ContextMenu.Content` needs an
// `aria-label`.
// * **Keep the area focusable.** Its menu opens from the keyboard only when the
// area can take focus.
// * **Offer the actions elsewhere too.** A context menu is a shortcut; whatever
// is in it should also be reachable some other way.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { ContextMenu } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
area: {
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
},
});
/** The context menu, open or closed. Uncontrolled unless `open` is given. */
component ContextMenuRoot(
children : React.Node,
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange?: (open: boolean) => void,
) renders ContextMenu.Root {
return (
<ContextMenu.Root defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open}>
{children}
</ContextMenu.Root>
);
}
/** The area whose menu this is. */
component ContextMenuTrigger(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders ContextMenu.Trigger {
return (
<ContextMenu.Trigger
{...forwarded(rest)}
className={classNames(props(styles.area, xstyle).className, className)}
>
{children}
</ContextMenu.Trigger>
);
}
/**
* The menu parts, which are `menu.js`'s own: a context menu is a menu opened another
* way, so `<ContextMenu.Item>` is the styled `Menu.Item` and a change to it there
* reaches both.
*/
export {
CheckboxItem,
Content,
Group,
Item,
Label,
RadioGroup,
RadioItem,
Separator,
Shortcut,
Sub,
SubTrigger,
} from "./menu.js";
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as ContextMenu from "./context-menu.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<ContextMenu.Root>`
* and `<ContextMenu.Trigger>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `ContextMenuRoot` rather than `Root`.
*/
export { ContextMenuRoot as Root, ContextMenuTrigger as Trigger };
date-field
locale-aware segments in a bordered field.
uf ui add date-field
import { DateField } from "./components/ui/date-field.js";
//
The example, date-field.example.js
// @flow
import * as React from "@uniflowed/react";
import { DateField } from "./date-field.js";
/** A named, keyboard-operable example using the default presentation. */
export component Example() {
return <DateField aria-label="Start date" defaultValue="2026-09-14" />;
}
The component, date-field.js
"use client";
// @flow
// DateField: locale-aware segments in a bordered field.
//
// `uf ui add date-field` copies this component into the project. The headless
// primitive owns semantics and input behavior; this file owns its presentation.
// Keep accessible names, focus rings and disabled/selected states when editing.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { DateField as HeadlessDateField } from "@uniflowed/ui";
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
boxSizing : "border-box",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderRadius : ufTokens.radiusMd,
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space1,
minHeight : "36px",
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space2,
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
outlineWidth : { default: "0", ":focus-within": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
fontVariantNumeric: "tabular-nums",
},
});
export component DateField(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders HeadlessDateField {
return (
<HeadlessDateField
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
/>
);
}
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
date-picker
a text field for a date, with a calendar that opens beside it to choose one from.
uf ui add date-picker
import * as DatePicker from "./components/ui/date-picker.js";
Year, month and day, such as 2026-09-14.
The example, date-picker.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as DatePicker from "./date-picker.js";
const styles = stylex.create({
field: {
display : "grid",
gap : ufTokens.space1,
justifyItems: "start",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
},
label: {
fontWeight: ufTokens.weightMedium,
},
hint: {
margin: 0,
color : ufTokens.muted,
},
});
/** A start date typed or chosen, with the format it takes said under it. */
export component Example() {
return (
<div {...props(styles.field)}>
<label {...props(styles.label)} htmlFor="start-date">
Start date
</label>
<DatePicker.Root defaultValue="2026-09-14" locale="en-US" today="2026-09-14">
<DatePicker.Group>
<DatePicker.Input aria-describedby="start-date-format" id="start-date" />
<DatePicker.Trigger />
</DatePicker.Group>
<DatePicker.Content />
</DatePicker.Root>
<p {...props(styles.hint)} id="start-date-format">
Year, month and day, such as 2026-09-14.
</p>
</div>
);
}
The component, date-picker.js
"use client";
// @flow
//
// Date picker: a text field for a date, with a calendar that opens beside it to
// choose one from.
//
// `uf ui add date-picker` wrote this file into the project, and it is the
// project's from then on. `uf ui diff date-picker` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The field, the button that opens the calendar, and the panel the calendar
// shows in; the calendar itself is `calendar.js`'s. `@uniflowed/ui`'s `DatePicker`
// owns the rest: a real text field a reader can type a date into, read by
// `parse`, written by `format` and marked `aria-invalid` while it does not hold
// a date, a popover that opens from the button, and the field and the calendar
// kept to the same date. Every prop of the part's root, such as `value`,
// `locale`, `parse` and `format`, passes through `DatePicker` unchanged.
//
// # What to keep true when you change it
//
// * **Typing stays possible.** The calendar is a second way to enter a date,
// not the only one.
// * **Say the format.** Text under the field, tied to it with
// `aria-describedby`, tells a reader what `parse` accepts.
// * **Name the field.** A `<label>` for the input; the button's `label` says
// what it opens.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes. An
// invalid field's border is `danger`.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { DatePicker } from "@uniflowed/ui";
import type { ButtonSize, ButtonTone } from "./button.js";
import { Button } from "./button.js";
import * as Calendar from "./calendar.js";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
group: {
display : "inline-flex",
alignItems: "center",
gap : ufTokens.space2,
},
input: {
boxSizing : "border-box",
width : "11rem",
minHeight : "36px",
margin : 0,
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontVariantNumeric: "tabular-nums",
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
borderRadius : ufTokens.radiusMd,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "1px",
},
// A block, not a grid: the month buttons hang on the caption's line from a
// zero-height row, and a grid's gap would push the caption off it.
content: {
zIndex : 50,
display : "block",
boxSizing : "border-box",
padding : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor: ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
// Enter: it fades in while travelling 4px out of its trigger, from the
// side `data-side` says it opened on, so the eye is led from the button
// to what it opened. `durationBase` on the decelerating curve: most of
// the distance is covered at once, so it is legible before it has
// settled. Under reduced motion it only fades.
"--uf-enter-x": {
default : "0px",
":is([data-side=left])" : "4px",
":is([data-side=right])": "-4px",
},
"--uf-enter-y": {
default : "0px",
":is([data-side=top])" : "4px",
":is([data-side=bottom])": "-4px",
},
// Exit: back towards the trigger it came from, on the accelerating curve
// and in `durationFast` against the entrance's `durationBase`, so it gets
// out of the way rather than lingering. `@uniflowed/ui` keeps it on the
// page, closed and `inert`, until this has finished. Under reduced motion
// it only fades: `--uf-exit-travel` is 0 there, so nothing jumps either.
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style": "translate(var(--uf-enter-x), var(--uf-enter-y))",
":is([data-state=closed])":
"translate(calc(var(--uf-enter-x) * var(--uf-exit-travel)), calc(var(--uf-enter-y) * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
});
/** The date picker. Every prop of `DatePicker.Root` passes through. */
component DatePickerRoot(children: React.Node, ...rest: Rest) renders DatePicker.Root {
return <DatePicker.Root {...forwarded(rest)}>{children}</DatePicker.Root>;
}
/** The field and its button, side by side. */
component DatePickerGroup(children: React.Node, xstyle?: StyleArgument, className?: string) {
return (
<div className={classNames(props(styles.group, xstyle).className, className)}>{children}</div>
);
}
/** The text field the date is typed into. Give it an `id` a `<label>` points at. */
component DatePickerInput(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders DatePicker.Input {
return (
<DatePicker.Input
{...forwarded(rest)}
className={classNames(props(styles.input, xstyle).className, className)}
/>
);
}
/** The button that opens the calendar. It is a `Button` showing a calendar icon. */
component DatePickerTrigger(
label? : string = "Choose a date",
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders DatePicker.Trigger {
return (
<DatePicker.Trigger
{...forwarded(rest)}
aria-label={label}
render={(trigger: Rest) => (
<Button
{...forwarded(trigger)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
)}
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<rect height="18" rx="2" width="18" x="3" y="4" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
</DatePicker.Trigger>
);
}
/**
* The panel the calendar opens in. With no children it holds the month buttons
* and the month; give it children to lay the calendar out another way.
*/
component DatePickerContent(
children? : React.Node,
sideOffset?: number = 4,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders DatePicker.Calendar {
return (
<DatePicker.Calendar
{...forwarded(rest)}
className={classNames(props(styles.content, xstyle).className, className)}
sideOffset={sideOffset}
>
{
match (children) {
null | undefined =>
<>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Next />
</Calendar.Header>
<Calendar.Month />
</>,
const given => given,
}
}
</DatePicker.Calendar>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as DatePicker from "./date-picker.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<DatePicker.Root>`
* and `<DatePicker.Group>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `DatePickerRoot` rather than `Root`.
*/
export {
DatePickerRoot as Root,
DatePickerGroup as Group,
DatePickerInput as Input,
DatePickerTrigger as Trigger,
DatePickerContent as Content,
};
date-range-picker
two segmented date fields and a shared range calendar.
uf ui add date-range-picker
import * as DateRangePicker from "./components/ui/date-range-picker.js";
//
//
The example, date-range-picker.example.js
// @flow
import * as React from "@uniflowed/react";
import * as DateRangePicker from "./date-range-picker.js";
/** A named, keyboard-operable example using the default presentation. */
export component Example() {
return (
<DateRangePicker.Root defaultValue={{ start: "2026-09-14", end: "2026-09-16" }}>
<DateRangePicker.StartField aria-label="Start date" />
<DateRangePicker.EndField aria-label="End date" />
<DateRangePicker.Trigger>Choose range</DateRangePicker.Trigger>
<DateRangePicker.Calendar aria-label="Date range" />
</DateRangePicker.Root>
);
}
The component, date-range-picker.js
"use client";
// @flow
// DateRangePicker: two segmented date fields and a shared range calendar.
//
// `uf ui add date-range-picker` copies this component into the project. The headless
// primitive owns semantics and input behavior; this file owns its presentation.
// Keep accessible names, focus rings and disabled/selected states when editing.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { DateRangePicker } from "@uniflowed/ui";
type Rest = { readonly key?: empty, readonly [string]: mixed };
import * as Calendar from "./calendar.js";
const styles = stylex.create({
root: { display: "inline-flex", flexWrap: "wrap", alignItems: "center", gap: ufTokens.space2 },
field: {
boxSizing : "border-box",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderRadius : ufTokens.radiusMd,
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space1,
minHeight : "36px",
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space2,
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
outlineWidth : { default: "0", ":focus-within": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
fontVariantNumeric: "tabular-nums",
},
trigger: {
boxSizing : "border-box",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderRadius : ufTokens.radiusMd,
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space1,
minHeight : "36px",
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space2,
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
outlineWidth : { default: "0", ":focus-within": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
fontVariantNumeric: "tabular-nums",
cursor : "pointer",
},
panel: {
boxSizing : "border-box",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor: ufTokens.surface,
borderRadius : ufTokens.radiusMd,
padding : ufTokens.space3,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
// Enter: it fades in while travelling 4px out of its trigger, from the
// side `data-side` says it opened on, so the eye is led from the button
// to what it opened. `durationBase` on the decelerating curve: most of
// the distance is covered at once, so it is legible before it has
// settled. Under reduced motion it only fades.
"--uf-enter-x": {
default : "0px",
":is([data-side=left])" : "4px",
":is([data-side=right])": "-4px",
},
"--uf-enter-y": {
default : "0px",
":is([data-side=top])" : "4px",
":is([data-side=bottom])": "-4px",
},
// Exit: back towards the trigger it came from, on the accelerating curve
// and in `durationFast` against the entrance's `durationBase`, so it gets
// out of the way rather than lingering. `@uniflowed/ui` keeps it on the
// page, closed and `inert`, until this has finished. Under reduced motion
// it only fades: `--uf-exit-travel` is 0 there, so nothing jumps either.
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style": "translate(var(--uf-enter-x), var(--uf-enter-y))",
":is([data-state=closed])":
"translate(calc(var(--uf-enter-x) * var(--uf-exit-travel)), calc(var(--uf-enter-y) * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
});
component DateRangePickerRoot(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders DateRangePicker.Root {
return (
<DateRangePicker.Root {...forwarded(rest)}>
<div className={classNames(props(styles.root, xstyle).className, className)}>{children}</div>
</DateRangePicker.Root>
);
}
component DateRangePickerStartField(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders DateRangePicker.StartField {
return (
<DateRangePicker.StartField
{...forwarded(rest)}
className={classNames(props(styles.field, xstyle).className, className)}
/>
);
}
component DateRangePickerEndField(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders DateRangePicker.EndField {
return (
<DateRangePicker.EndField
{...forwarded(rest)}
className={classNames(props(styles.field, xstyle).className, className)}
/>
);
}
component DateRangePickerTrigger(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders DateRangePicker.Trigger {
return (
<DateRangePicker.Trigger
{...forwarded(rest)}
className={classNames(props(styles.trigger, xstyle).className, className)}
>
{children}
</DateRangePicker.Trigger>
);
}
component DateRangePickerCalendar(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders DateRangePicker.Calendar {
return (
<DateRangePicker.Calendar
{...forwarded(rest)}
className={classNames(props(styles.panel, xstyle).className, className)}
>
{
match (children) {
null | undefined =>
<>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Next />
</Calendar.Header>
<Calendar.Month />
</>,
const given => given,
}
}
</DateRangePicker.Calendar>
);
}
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as DateRangePicker from "./date-range-picker.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<DateRangePicker.Root>`
* and `<DateRangePicker.StartField>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `DateRangePickerRoot` rather than `Root`.
*/
export {
DateRangePickerRoot as Root,
DateRangePickerStartField as StartField,
DateRangePickerEndField as EndField,
DateRangePickerTrigger as Trigger,
DateRangePickerCalendar as Calendar,
};
dialog
@uniflowed/ui's modal dialog, dressed in uf's tokens.
uf ui add dialog
import * as Dialog from "./components/ui/dialog.js";
The example, dialog.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Dialog from "./dialog.js";
const styles = stylex.create({
field: {
display : "grid",
gap : ufTokens.space1,
fontWeight: ufTokens.weightMedium,
},
input: {
boxSizing : "border-box",
width : "100%",
minHeight : "36px",
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor: ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "1px",
},
});
/** A rename: a title, a description, one field, and the two ways out. */
export component Example() {
return (
<Dialog.Root>
<Dialog.Trigger>Rename project</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Rename project</Dialog.Title>
<Dialog.Description>The new name is what everyone on the team sees.</Dialog.Description>
</Dialog.Header>
<label {...props(styles.field)}>
Name
<input {...props(styles.input)} defaultValue="Atlas" name="name" type="text" />
</label>
<Dialog.Footer>
<Dialog.Close>Cancel</Dialog.Close>
<Dialog.Close tone="primary">Save</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
}
The component, dialog.js
"use client";
// @flow
//
// Dialog: `@uniflowed/ui`'s modal dialog, dressed in uf's tokens.
//
// `uf ui add dialog` wrote this file into the project, and it is the project's
// from then on. `uf ui diff dialog` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The look: the scrim, the panel, the spacing, the close button in the corner.
// The behaviour is imported from `@uniflowed/ui`'s `Dialog`, and staying imported
// is the point of the arrangement. Focus moved into the dialog and kept there,
// `Escape`, the press outside, focus given back to whatever opened it, the page
// behind made inert and held still: each of those is a fix that reaches this
// project by upgrading `@uniflowed/ui`, which a copy of the behaviour would
// never receive. Editing this file changes how the dialog looks. It cannot
// quietly unmake what the dialog does.
//
// # What to keep true when you change it
//
// * **A dialog needs a name.** `Dialog.Title` is what `aria-labelledby` points
// at, and a dialog without one is announced as "dialog" and nothing else. A
// design with no visible title passes `aria-label` to `Dialog.Content`.
// * **The description is read when focus arrives.** "This cannot be undone"
// belongs in `Dialog.Description`, which a screen reader reads with the name,
// rather than in body text further down.
// * **The close button is last in the markup and first on the screen.** Focus
// moves to the first thing worth acting on when the dialog opens, and that
// should be the task rather than the corner `×`; the button is put in the
// corner by position, so the order a reader tabs through is still the order
// that makes sense. Its name is `closeLabel`, which is "Close" until you
// translate it.
// * **The panel scrolls and the page does not.** It is capped at the viewport
// less a margin and scrolls inside itself, so a long form stays reachable on
// a phone held sideways.
// * **Colour comes from tokens, in measured pairs.** `ink` and `muted` on
// `surface`, which `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in
// the light default and the dark theme.
// * **It enters, and it leaves.** The scrim fades in and the panel fades in
// from 96% of its size, over `durationSlow`, from a `@starting-style` — a
// transition from the style the element is taken to have had before it was
// inserted, which needs neither `@keyframes` (uf's StyleX has none) nor the
// panel mounted while closed. Leaving is the same way back, shorter
// (`durationBase`) and on the accelerating curve, from
// `:is([data-state=closed])`: `@uniflowed/ui` keeps the closing panel and
// scrim on the page, `inert`, until their transitions finish, while focus,
// the page and its scroll are given back at once. Under reduced motion the
// panel only fades.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Dialog } from "@uniflowed/ui";
import type { ButtonSize, ButtonTone } from "./button.js";
import { Button } from "./button.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* part underneath. See `forwarded` for the one thing Flow cannot say about it.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
overlay: {
position : "fixed",
inset : 0,
zIndex : 50,
backgroundColor: ufTokens.scrim,
// Enter: the page dims as the panel arrives, over the panel's duration,
// rather than going dark first and then showing a dialog. Opacity only, so
// it is the same under reduced motion.
// Exit: it clears with the panel, in the panel's shorter exit time.
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transitionProperty: "opacity",
transitionDuration: {
default : ufTokens.durationSlow,
":is([data-state=closed])": ufTokens.durationBase,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
// Centred by `inset: 0` and `margin: auto` rather than by a translate, which
// leaves text on a half pixel and blurs it on a screen with no scaling.
panel: {
position : "fixed",
inset : 0,
zIndex : 50,
boxSizing : "border-box",
display : "grid",
gap : ufTokens.space4,
width : "calc(100% - 32px)",
maxWidth : "32rem",
height : "fit-content",
maxHeight : "calc(100% - 32px)",
margin : "auto",
overflowY : "auto",
padding : ufTokens.space6,
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusLg,
// The panel takes focus itself when it holds nothing focusable, so it is
// drawn like anything else that can.
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
// Enter: it fades in and comes forward from 96% of its size, as if it
// rose out of the page. 0.96 is enough to be seen at this size and too
// little to read as growing from nothing, and there is no overshoot back
// past 1. `durationSlow`, because a surface this large moving as fast as
// a menu looks thrown. The scale is gone when it settles (`none`), so the
// text is not left on a half pixel. Under reduced motion it only fades.
//
// Exit: it sinks back to 96% as it fades, in `durationBase` on the
// accelerating curve, so it is out of the way sooner than it arrived.
// `@uniflowed/ui` keeps it on the page, closed and `inert`, until then,
// and focus is already back on the trigger. Under reduced motion it only
// fades: `--uf-exit-travel` is 0 there, so the scale does not jump.
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style" : "scale(0.96)",
":is([data-state=closed])": "scale(calc(1 - 0.04 * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationSlow,
":is([data-state=closed])": ufTokens.durationBase,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
header: {
display: "grid",
gap : ufTokens.space2,
// Room for the close button, which is drawn over this corner.
paddingInlineEnd: ufTokens.space8,
},
footer: {
display : "flex",
flexWrap : "wrap",
justifyContent: "flex-end",
gap : ufTokens.space2,
},
title: {
margin : 0,
fontSize : ufTokens.textLg,
fontWeight: ufTokens.weightBold,
lineHeight: ufTokens.leadingTight,
},
description: {
margin: 0,
color : ufTokens.muted,
},
close: {
position : "absolute",
top : ufTokens.space4,
insetInlineEnd : ufTokens.space4,
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
width : "32px",
height : "32px",
padding : 0,
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
backgroundColor: { default: "transparent", ":hover": ufTokens.surfaceHover },
color : { default: ufTokens.muted, ":hover": ufTokens.ink },
cursor : "pointer",
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
});
/** The dialog, open or closed. Uncontrolled unless `open` is given. */
component DialogRoot(
children : React.Node,
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange?: (open: boolean) => void,
) renders Dialog.Root {
return (
<Dialog.Root defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open}>
{children}
</Dialog.Root>
);
}
/**
* The button that opens the dialog, and that focus comes back to when it
* closes. It is a `Button`, so it takes a `tone` and a `size`.
*/
component DialogTrigger(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Dialog.Trigger {
return (
<Dialog.Trigger
{...forwarded(rest)}
render={(trigger) => (
<Button
{...forwarded(trigger)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
)}
>
{children}
</Dialog.Trigger>
);
}
/**
* The scrim and the panel, with a close button in the corner.
*
* Everything it does not name reaches `Dialog.Body`: `aria-label` for a dialog
* with no visible title, `initialFocus`, `dismissOnOutsidePress`.
*/
component DialogContent(
children : React.Node,
closeLabel?: string = "Close",
hideClose? : boolean = false,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) {
return (
<>
<Dialog.Overlay className={props(styles.overlay).className} />
<Dialog.Body
{...forwarded(rest)}
className={classNames(props(styles.panel, xstyle).className, className)}
>
{children}
{
match (hideClose) {
true => null,
false =>
<Dialog.Close aria-label={closeLabel} className={props(styles.close).className}>
<CloseIcon />
</Dialog.Close>,
}
}
</Dialog.Body>
</>
);
}
/** The title and description, stacked. */
component DialogHeader(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Dialog.Header {
return (
<Dialog.Header
{...forwarded(rest)}
className={classNames(props(styles.header, xstyle).className, className)}
>
{children}
</Dialog.Header>
);
}
/** The row the actions sit in, at the end of the reading direction. */
component DialogFooter(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Dialog.Footer {
return (
<Dialog.Footer
{...forwarded(rest)}
className={classNames(props(styles.footer, xstyle).className, className)}
>
{children}
</Dialog.Footer>
);
}
/**
* The dialog's name. An `<h2>` unless `render` says which heading it is, which
* is a fact about the page around the dialog rather than about the dialog.
*/
component DialogTitle(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Dialog.Title {
return (
<Dialog.Title
{...forwarded(rest)}
className={classNames(props(styles.title, xstyle).className, className)}
>
{children}
</Dialog.Title>
);
}
/** What the dialog is for, read with its name when focus arrives. */
component DialogDescription(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Dialog.Description {
return (
<Dialog.Description
{...forwarded(rest)}
className={classNames(props(styles.description, xstyle).className, className)}
>
{children}
</Dialog.Description>
);
}
/** A button that closes the dialog: Cancel, Done, or the action itself. */
component DialogClose(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Dialog.Close {
return (
<Dialog.Close
{...forwarded(rest)}
render={(close) => (
<Button
{...forwarded(close)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
)}
>
{children}
</Dialog.Close>
);
}
/** The `×`, which is decoration: the button it sits in carries the name. */
component CloseIcon() {
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
/**
* A caller's props on their way into a part, rather than onto an element.
*
* Flow checks a spread into a component against that component's own
* `...rest`, `key` included, and the indexer in `Rest` answers `mixed` where
* the part says `empty`. `@uniflowed/ui` meets the same hole between its own
* parts, and papers over it the same way in one named place,
* `internal/merge-props.js`'s `forwarded`: nothing that was ever checked is
* lost, because every element those parts render has `any`-typed props in uf's
* library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. See `button.js`. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Dialog from "./dialog.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Dialog.Root>`
* and `<Dialog.Trigger>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `DialogRoot` rather than `Root`.
*/
export {
DialogRoot as Root,
DialogTrigger as Trigger,
DialogContent as Content,
DialogHeader as Header,
DialogFooter as Footer,
DialogTitle as Title,
DialogDescription as Description,
DialogClose as Close,
};
drawer
a sheet with a handle, dragged between snap points or moved there with the arrow keys.
uf ui add drawer
import * as Drawer from "./components/ui/drawer.js";
The example, drawer.example.js
// @flow
import * as React from "@uniflowed/react";
import * as Drawer from "./drawer.js";
/** An order's details, half open and then all the way. */
export component Example() {
return (
<Drawer.Root snapPoints={[0.5, 1]}>
<Drawer.Trigger>Order details</Drawer.Trigger>
<Drawer.Content handleLabel="Resize the order details">
<Drawer.Header>
<Drawer.Title>Order 1024</Drawer.Title>
<Drawer.Description>Placed this morning, arriving on Thursday.</Drawer.Description>
</Drawer.Header>
<p>Two notebooks, a fountain pen and a bottle of ink.</p>
<Drawer.Footer>
<Drawer.Close>Close</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
</Drawer.Root>
);
}
The component, drawer.js
"use client";
// @flow
//
// Drawer: a sheet with a handle, dragged between snap points or moved there
// with the arrow keys.
//
// `uf ui add drawer` wrote this file into the project, and it is the project's
// from then on. `uf ui diff drawer` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The look: the panel at its snap point, the grip, and the way the panel
// follows a drag. The behaviour is imported from `@uniflowed/ui`'s `Drawer`: every
// modal promise a sheet makes, a handle that is a `role="slider"` over the snap
// points with `Home`, `End` and the arrow keys, the closing key at the smallest
// snap point, and the refusal to render a handle with no `Drawer.Close` beside
// it. A fix to any of that reaches this project by upgrading `@uniflowed/ui`.
//
// The part writes two numbers straight onto the panel rather than rendering
// them: `--uf-drawer-snap`, the fraction of the full size it is open to, and
// `--uf-drawer-drag`, how far a finger has pulled it towards its edge. The
// panel's size and position below are those two properties and nothing else.
//
// # What to keep true when you change it
//
// * **A drag is never the only way.** WCAG 2.2 SC 2.5.7 asks for everything a
// drag does to be possible without one, so a drawer with a handle needs a
// `Drawer.Close`, and the part raises without one. The handle's arrow keys are
// the keyboard's half of the same rule.
// * **The handle is a control, so it looks like one.** It is in the tab order,
// it has a focus ring, and its target is at least 24px across. Its name is
// `handleLabel`, because a slider with no name is announced as "slider".
// * **The handle comes last in the markup.** Focus moves to the first thing
// worth acting on when the drawer opens, and that is its content; the handle
// is drawn at the edge by position rather than by order.
// * **A drag follows the finger, and only a drag does.** The panel eases between
// snap points, stops easing while it is being dragged, and never eases for a
// reader who asked for reduced motion.
// * **Colour comes from tokens, in measured pairs.** `ink` and `muted` on
// `surface`, which `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in
// the light default and the dark theme.
import * as React from "@uniflowed/react";
import { createContext, useContext } from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { Edge } from "@uniflowed/ui";
import { Drawer } from "@uniflowed/ui";
import type { ButtonSize, ButtonTone } from "./button.js";
import { Button } from "./button.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* part underneath. See `forwarded` for the one thing Flow cannot say about it.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** A caller's own element in place of the one a part renders. */
type RenderProp = (props: Rest) => React.Node;
/**
* The edge the caller gave `Drawer`, for the handle, which is drawn along it.
* The caller's own prop handed down rather than state the part owns, so there
* is nothing here that could disagree with the part.
*/
const SideContext: React.Context<Edge> = createContext("bottom");
const styles = stylex.create({
overlay: {
position : "fixed",
inset : 0,
zIndex : 50,
backgroundColor: ufTokens.scrim,
// Enter: the page dims as the panel arrives, over the panel's duration,
// rather than going dark first and then showing a dialog. Opacity only, so
// it is the same under reduced motion.
// Exit: it clears with the panel, in the panel's shorter exit time.
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transitionProperty: "opacity",
transitionDuration: {
default : ufTokens.durationSlow,
":is([data-state=closed])": ufTokens.durationBase,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
panel: {
position : "fixed",
zIndex : 50,
boxSizing : "border-box",
display : "flex",
flexDirection : "column",
gap : ufTokens.space4,
overflowY : "auto",
paddingBlock : ufTokens.space4,
paddingInline : ufTokens.space6,
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
// Separated from the page by a rule on the edge that faces it, not by a
// shadow: the scrim already says the page is behind.
borderWidth : 0,
borderStyle : "solid",
borderColor : ufTokens.border,
borderTopWidth : { default: 0, ":is([data-side=bottom])": "1px" },
borderBottomWidth: { default: 0, ":is([data-side=top])": "1px" },
borderLeftWidth : { default: 0, ":is([data-side=right])": "1px" },
borderRightWidth : { default: 0, ":is([data-side=left])": "1px" },
top : { default: 0, ":is([data-side=bottom])": "auto" },
bottom : { default: 0, ":is([data-side=top])": "auto" },
left : { default: 0, ":is([data-side=right])": "auto" },
right : { default: 0, ":is([data-side=left])": "auto" },
// The snap point is a fraction of the full size, written by the part. The
// full size is at most 85% of the screen's height, or 24rem across less a
// margin, spelled out in each value because StyleX reads values at compile
// time and refuses one it would have to resolve.
height: {
default : "auto",
":is([data-side=top])" : "calc(min(85vh, 40rem) * var(--uf-drawer-snap, 1))",
":is([data-side=bottom])": "calc(min(85vh, 40rem) * var(--uf-drawer-snap, 1))",
},
width: {
default : "auto",
":is([data-side=left])" : "calc(min(24rem, 100vw - 48px) * var(--uf-drawer-snap, 1))",
":is([data-side=right])": "calc(min(24rem, 100vw - 48px) * var(--uf-drawer-snap, 1))",
},
// Enter: it slides in from the edge it is attached to, the whole of its
// own size, on the decelerating curve over `durationSlow`: it is fast
// off the edge and settles against the page. Under reduced motion it does
// not travel; it fades in instead (`--uf-enter-opacity`).
"--uf-enter-x": {
default : "0px",
":is([data-side=left])" : "-100%",
":is([data-side=right])": "100%",
},
"--uf-enter-y": {
default : "0px",
":is([data-side=top])" : "-100%",
":is([data-side=bottom])": "100%",
},
//
// Exit: back off the same edge from wherever the finger left it, in
// `durationBase` on the accelerating curve, while `@uniflowed/ui` keeps
// the closing panel on the page and `inert`. Under reduced motion it fades
// out instead (`--uf-exit-travel` is 0, so it does not travel).
"--uf-enter-opacity": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
"--uf-exit-travel" : { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity: {
default : 1,
"@starting-style" : "var(--uf-enter-opacity)",
":is([data-state=closed])": "var(--uf-enter-opacity)",
},
// And the drag is how far the finger has pulled it towards its edge.
transform: {
default : "none",
":is([data-side=bottom])": "translateY(var(--uf-drawer-drag, 0px))",
":is([data-side=top])" : "translateY(calc(-1 * var(--uf-drawer-drag, 0px)))",
":is([data-side=right])" : "translateX(var(--uf-drawer-drag, 0px))",
":is([data-side=left])" : "translateX(calc(-1 * var(--uf-drawer-drag, 0px)))",
"@starting-style" : "translate(var(--uf-enter-x), var(--uf-enter-y))",
// Two attributes, so it sorts after the one-attribute `data-side` keys
// above and wins over them while closing.
":is([data-state=closed]):is([data-side])":
"translate(calc(var(--uf-enter-x) * var(--uf-exit-travel)), calc(var(--uf-enter-y) * var(--uf-exit-travel)))",
},
borderTopLeftRadius : { default: 0, ":is([data-side=bottom])": ufTokens.radiusLg },
borderTopRightRadius : { default: 0, ":is([data-side=bottom])": ufTokens.radiusLg },
borderBottomLeftRadius : { default: 0, ":is([data-side=top])": ufTokens.radiusLg },
borderBottomRightRadius: { default: 0, ":is([data-side=top])": ufTokens.radiusLg },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "-2px",
transitionProperty: {
default : "opacity, transform, height, width",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
// Still while a finger holds it, so the panel stays under the finger;
// released, it settles on the same decelerating curve it entered with.
transitionDuration: {
default : ufTokens.durationSlow,
":has([data-dragging=true])": "0s",
":is([data-state=closed])" : ufTokens.durationBase,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
handle: {
// Read by the grip inside, which is drawn along the axis the drag runs on.
"--uf-drawer-grip-width" : { default: "40px", ":is([aria-orientation=horizontal])": "5px" },
"--uf-drawer-grip-height": { default: "5px", ":is([aria-orientation=horizontal])": "40px" },
display : "flex",
alignItems : "center",
justifyContent : "center",
flexShrink : 0,
alignSelf : "center",
boxSizing : "border-box",
width : { default: "64px", ":is([aria-orientation=horizontal])": "24px" },
height : { default: "24px", ":is([aria-orientation=horizontal])": "64px" },
// First on the screen for a bottom drawer and last for a top one: the edge
// that faces the page. A side drawer's handle is at that edge's middle.
order : { default: -1, ":is([data-side=top])": 0 },
position : { default: "static", ":is([aria-orientation=horizontal])": "absolute" },
top : { default: "auto", ":is([aria-orientation=horizontal])": "calc(50% - 32px)" },
left : { default: "auto", ":is([data-side=right])": ufTokens.space1 },
right : { default: "auto", ":is([data-side=left])": ufTokens.space1 },
borderRadius : ufTokens.radiusSm,
cursor : { default: "grab", ":is([data-dragging=true])": "grabbing" },
touchAction : "none",
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
},
grip: {
width : "var(--uf-drawer-grip-width)",
height : "var(--uf-drawer-grip-height)",
borderRadius : ufTokens.radiusPill,
backgroundColor: ufTokens.border,
},
header: {
display: "grid",
gap : ufTokens.space2,
},
footer: {
display : "flex",
flexWrap : "wrap",
justifyContent: "flex-end",
gap : ufTokens.space2,
marginTop : "auto",
},
title: {
margin : 0,
fontSize : ufTokens.textLg,
fontWeight: ufTokens.weightBold,
lineHeight: ufTokens.leadingTight,
},
description: {
margin: 0,
color : ufTokens.muted,
},
});
/**
* The drawer, open or closed, against `side`, at one of `snapPoints`.
*
* `snapPoints` are fractions of the full size in ascending order: `[0.5, 1]` is
* "half, then all of it". `snapPoint` is an index into them.
*/
component DrawerRoot(
children : React.Node,
side? : Edge = "bottom",
snapPoints? : $ReadOnlyArray<number>,
defaultSnapPoint? : number = 0,
snapPoint? : number,
onSnapPointChange?: (index: number) => void,
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange? : (open: boolean) => void,
) {
return (
<SideContext.Provider value={side}>
<Drawer.Root
defaultOpen={defaultOpen}
defaultSnapPoint={defaultSnapPoint}
onOpenChange={onOpenChange}
onSnapPointChange={onSnapPointChange}
open={open}
side={side}
snapPoint={snapPoint}
snapPoints={snapPoints}
>
{children}
</Drawer.Root>
</SideContext.Provider>
);
}
/** The button that opens the drawer. It is a `Button` unless `render` says otherwise. */
component DrawerTrigger(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Drawer.Trigger {
return (
<Drawer.Trigger
{...forwarded(rest)}
render={
match (render) {
undefined =>
(trigger) => (
<Button
{...forwarded(trigger)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
),
const custom => custom,
}
}
>
{children}
</Drawer.Trigger>
);
}
/**
* The scrim and the panel, with the handle at the edge that faces the page.
* `hideHandle` for a drawer that does not move, which then needs no
* `Drawer.Close` either.
*/
component DrawerContent(
children : React.Node,
handleLabel?: string = "Resize the drawer",
hideHandle? : boolean = false,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) {
const side = useContext(SideContext);
return (
<>
<Drawer.Overlay className={props(styles.overlay).className} />
<Drawer.Body
{...forwarded(rest)}
className={classNames(props(styles.panel, xstyle).className, className)}
>
{children}
{
match (hideHandle) {
true => null,
false =>
<Drawer.Handle
className={props(styles.handle).className}
data-side={side}
label={handleLabel}
render={(handle) => (
<div {...forwarded(handle)}>
<span {...props(styles.grip)} aria-hidden="true" />
</div>
)}
/>,
}
}
</Drawer.Body>
</>
);
}
/** The title and description, stacked. */
component DrawerHeader(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Drawer.Header {
return (
<Drawer.Header
{...forwarded(rest)}
className={classNames(props(styles.header, xstyle).className, className)}
>
{children}
</Drawer.Header>
);
}
/** The row the actions sit in, pushed to the far end of the panel. */
component DrawerFooter(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Drawer.Footer {
return (
<Drawer.Footer
{...forwarded(rest)}
className={classNames(props(styles.footer, xstyle).className, className)}
>
{children}
</Drawer.Footer>
);
}
/** The drawer's name. */
component DrawerTitle(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Drawer.Title {
return (
<Drawer.Title
{...forwarded(rest)}
className={classNames(props(styles.title, xstyle).className, className)}
>
{children}
</Drawer.Title>
);
}
/** What the drawer is for, read with its name when focus arrives. */
component DrawerDescription(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Drawer.Description {
return (
<Drawer.Description
{...forwarded(rest)}
className={classNames(props(styles.description, xstyle).className, className)}
>
{children}
</Drawer.Description>
);
}
/**
* A button that closes the drawer, and the single-pointer alternative to the
* drag that WCAG 2.5.7 asks for.
*/
component DrawerClose(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Drawer.Close {
return (
<Drawer.Close
{...forwarded(rest)}
render={
match (render) {
undefined =>
(close) => (
<Button
{...forwarded(close)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
),
const custom => custom,
}
}
>
{children}
</Drawer.Close>
);
}
/**
* A caller's props on their way into a part, rather than onto an element.
*
* Flow checks a spread into a component against that component's own
* `...rest`, `key` included, and the indexer in `Rest` answers `mixed` where
* the part says `empty`. `@uniflowed/ui` meets the same hole between its own
* parts, and papers over it the same way in one named place,
* `internal/merge-props.js`'s `forwarded`: nothing that was ever checked is
* lost, because every element those parts render has `any`-typed props in uf's
* library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. See `button.js`. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Drawer from "./drawer.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Drawer.Root>`
* and `<Drawer.Trigger>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `DrawerRoot` rather than `Root`.
*/
export {
DrawerRoot as Root,
DrawerTrigger as Trigger,
DrawerContent as Content,
DrawerHeader as Header,
DrawerFooter as Footer,
DrawerTitle as Title,
DrawerDescription as Description,
DrawerClose as Close,
};
empty
what a list, a table or a page shows when it has nothing in it yet, and the action that fills it.
uf ui add empty
import * as Empty from "./components/ui/empty.js";
No invoices yet
Invoices you create or import appear here, newest first.
The example, empty.example.js
// @flow
import * as React from "@uniflowed/react";
import { Button } from "./button.js";
import * as Empty from "./empty.js";
/** An invoice list with nothing in it yet, and the two ways to fill it. */
export component Example() {
return (
<Empty.Root>
<Empty.Media>
<svg
fill="none"
focusable="false"
height="20"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="20"
>
<path d="M6 3h9l3 3v15H6z" />
<path d="M9 12h6M9 16h4" />
</svg>
</Empty.Media>
<Empty.Title>No invoices yet</Empty.Title>
<Empty.Description>
Invoices you create or import appear here, newest first.
</Empty.Description>
<Empty.Content>
<Button tone="primary">Create an invoice</Button>
<Button>Import from CSV</Button>
</Empty.Content>
</Empty.Root>
);
}
The component, empty.js
// @flow
//
// Empty: what a list, a table or a page shows when it has nothing in it yet,
// and the action that fills it.
//
// `uf ui add empty` wrote this file into the project, and it is the project's
// from then on. `uf ui diff empty` shows how it has moved away from the
// registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// An empty state is a heading, a sentence and a button, and each is an
// element a caller already writes. It has no role of its own: it is rendered
// with the page rather than arriving later, so it is not a live region, and a
// `role="region"` would need a name and add one more landmark to a list that
// is useful only while it is short. `@uniflowed/ui` declines an `Empty` for
// that reason (`crates/uf_lib/src/ui.rs` records it). What an application wants
// is every empty screen looking and reading the same, so it is written here.
//
// # What to keep true when you change it
//
// * **The title is a heading at the level the page needs.** `Empty.Title`
// takes `level`, 2 by default and typed as 1 to 6, because an empty state
// usually stands where a section's content would be. A reader moving by
// headings then finds it where the content would have been.
// * **Say what is empty and what to do next.** "No invoices yet" and a button
// that creates one. A picture on its own tells a screen reader user nothing,
// and a title with no next step leaves everybody stuck.
// * **The picture is decoration.** `Empty.Media` is `aria-hidden`, because the
// title already says what it shows. Nothing inside it can be focused.
// * **When the empty state replaces results someone just asked for,** a search
// that found nothing, announce it: render `Empty` inside the region that
// was already announcing the results, or use `announce()` from
// `@uniflowed/ui`. A panel that silently swaps its content is not heard.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface` and
// `canvas`, which `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in
// both shipped themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/** A heading level, which is all an empty state's title can be. */
export type EmptyTitleLevel = 1 | 2 | 3 | 4 | 5 | 6;
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "grid",
justifyItems : "center",
gap : ufTokens.space4,
boxSizing : "border-box",
paddingBlock : ufTokens.space12,
paddingInline: ufTokens.space6,
textAlign : "center",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
color : ufTokens.ink,
borderWidth : "1px",
borderStyle : "dashed",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusLg,
},
media: {
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
width : "40px",
height : "40px",
color : ufTokens.muted,
backgroundColor: ufTokens.sunken,
borderRadius : ufTokens.radiusMd,
},
title: {
margin : 0,
fontSize : ufTokens.textMd,
fontWeight: ufTokens.weightBold,
lineHeight: ufTokens.leadingTight,
},
description: {
margin : 0,
maxWidth: "32rem",
color : ufTokens.muted,
},
content: {
display : "flex",
flexWrap : "wrap",
justifyContent: "center",
alignItems : "center",
gap : ufTokens.space2,
},
});
/** The frame. */
component EmptyRoot(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.root, xstyle).className, className)}>
{children}
</div>
);
}
/** An icon or an illustration, hidden from assistive technology. */
component EmptyMedia(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div
{...rest}
aria-hidden="true"
className={classNames(props(styles.media, xstyle).className, className)}
>
{children}
</div>
);
}
/** What is empty, as a heading at `level`. */
component EmptyTitle(
children : React.Node,
level? : EmptyTitleLevel = 2,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
const shared = {
...rest,
className: classNames(props(styles.title, xstyle).className, className),
};
return match (level) {
1 => <h1 {...shared}>{children}</h1>,
2 => <h2 {...shared}>{children}</h2>,
3 => <h3 {...shared}>{children}</h3>,
4 => <h4 {...shared}>{children}</h4>,
5 => <h5 {...shared}>{children}</h5>,
6 => <h6 {...shared}>{children}</h6>,
};
}
/** Why it is empty, or what will appear here. */
component EmptyDescription(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<p {...rest} className={classNames(props(styles.description, xstyle).className, className)}>
{children}
</p>
);
}
/** The next step: the button or link that fills it. */
component EmptyContent(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.content, xstyle).className, className)}>
{children}
</div>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Empty from "./empty.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Empty.Root>`
* and `<Empty.Title>`. Each is declared under its full name, so React DevTools
* and an error say `EmptyRoot` rather than `Root`.
*/
export {
EmptyRoot as Root,
EmptyMedia as Media,
EmptyTitle as Title,
EmptyDescription as Description,
EmptyContent as Content,
};
field
a label, a text input or text area, help text and an error, wired to each other and drawn as one form row.
uf ui add field
import * as Field from "./components/ui/field.js";
The example, field.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Field from "./field.js";
const styles = stylex.create({
form: {
display : "grid",
gap : ufTokens.space4,
maxWidth: "24rem",
},
});
/** A field with help, one that is invalid and says why, and a text area. */
export component Example() {
return (
<form {...props(styles.form)}>
<Field.Root required>
<Field.Label>Name</Field.Label>
<Field.Input autoComplete="name" defaultValue="Ada Lovelace" name="name" />
<Field.Description>As it should appear on receipts.</Field.Description>
</Field.Root>
<Field.Root invalid required>
<Field.Label>Email</Field.Label>
<Field.Input autoComplete="email" defaultValue="ada@example" name="email" type="email" />
<Field.Error>Enter an address with a domain, like ada@example.com.</Field.Error>
</Field.Root>
<Field.Root>
<Field.Label>Note</Field.Label>
<Field.Textarea name="note" placeholder="Anything we should know" />
</Field.Root>
</form>
);
}
The component, field.js
"use client";
// @flow
//
// Field: a label, a text input or text area, help text and an error, wired to
// each other and drawn as one form row.
//
// `uf ui add field` wrote this file into the project, and it is the project's
// from then on. `uf ui diff field` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The label's type, the input's box and the help and error lines under it.
// `@uniflowed/ui`'s `Field` owns the wiring: the ids the label and the control
// share, one `aria-describedby` that names the description and the error only
// while each is there, `aria-invalid` only while the field is invalid,
// `aria-required`, and an error that is announced when it appears. The box's
// invalid look follows `aria-invalid` through `:is([aria-invalid=true])`.
//
// # What to keep true when you change it
//
// * **Every field has a label.** A placeholder is not one: it disappears as
// soon as a reader types.
// * **Invalid is more than a colour.** The border turns `danger` and
// `Field.Error` says what is wrong in words, which is what a reader hears.
// * **Say how to fix it.** "Enter an address with a domain" rather than
// "Invalid".
// * **Text stays on measured pairs.** `ink` on `surface`, `muted` on `surface`
// and `sunken`, which `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1
// in both themes. The error is `danger` on `surface`, about 6.6:1 in the
// light default and 6.1:1 in the dark theme, a pair that suite does not
// measure yet — check it if you change either colour.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Field, type FieldSource } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "grid",
gap : ufTokens.space1,
fontFamily: ufTokens.fontSans,
color : ufTokens.ink,
},
label: {
fontSize : ufTokens.textSm,
fontWeight: ufTokens.weightMedium,
lineHeight: ufTokens.leadingTight,
},
control: {
boxSizing : "border-box",
width : "100%",
minHeight : "36px",
margin : 0,
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
color : { default: ufTokens.ink, ":disabled": ufTokens.muted },
backgroundColor: { default: ufTokens.surface, ":disabled": ufTokens.sunken },
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
borderRadius : ufTokens.radiusMd,
cursor : { default: "text", ":disabled": "not-allowed" },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "1px",
"::placeholder": {
color : ufTokens.muted,
opacity: 1,
},
},
textarea: {
minHeight: "5rem",
resize : "vertical",
},
description: {
margin : 0,
fontSize: ufTokens.textSm,
color : ufTokens.muted,
},
status: {
margin : 0,
fontSize: ufTokens.textSm,
color : ufTokens.muted,
},
error: {
margin : 0,
fontSize : ufTokens.textSm,
fontWeight: ufTokens.weightMedium,
color : ufTokens.danger,
},
});
/**
* The row. `invalid` and `required` reach the control; a field from
* `@uniflowed/form` passes `field` instead.
*/
component FieldRoot(
children : React.Node,
invalid? : boolean = false,
required? : boolean = false,
busy? : boolean = false,
field? : FieldSource,
group? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Field.Root {
return (
<Field.Root
{...forwarded(rest)}
busy={busy}
className={classNames(props(styles.root, xstyle).className, className)}
field={field}
group={group}
invalid={invalid}
required={required}
>
{children}
</Field.Root>
);
}
/** The field's name. */
component FieldLabel(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Field.Label {
return (
<Field.Label
{...forwarded(rest)}
className={classNames(props(styles.label, xstyle).className, className)}
>
{children}
</Field.Label>
);
}
/** A text input, wired to the label, the description and the error. */
component FieldInput(
type? : string = "text",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Field.Control {
return (
<Field.Control
render={(control) => (
<input
{...rest}
{...forwarded(control)}
className={classNames(props(styles.control, xstyle).className, className)}
type={type}
/>
)}
/>
);
}
/** A text area, wired the same way as `Field.Input`. */
component FieldTextarea(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Field.Control {
return (
<Field.Control
render={(control) => (
<textarea
{...rest}
{...forwarded(control)}
className={classNames(
props(styles.control, styles.textarea, xstyle).className,
className,
)}
/>
)}
/>
);
}
/** Help that stays under the field, and is read with it. */
component FieldDescription(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Field.Description {
return (
<Field.Description
{...forwarded(rest)}
className={classNames(props(styles.description, xstyle).className, className)}
>
{children}
</Field.Description>
);
}
/** Progress while the field is being saved or checked, announced politely. */
component FieldStatus(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Field.Status {
return (
<Field.Status
{...forwarded(rest)}
className={classNames(props(styles.status, xstyle).className, className)}
>
{children}
</Field.Status>
);
}
/** What is wrong and how to fix it, shown only while the field is invalid. */
component FieldError(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Field.Error {
return (
<Field.Error
{...forwarded(rest)}
className={classNames(props(styles.error, xstyle).className, className)}
>
{children}
</Field.Error>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Field from "./field.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Field.Root>`
* and `<Field.Label>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `FieldRoot` rather than `Root`.
*/
export {
FieldRoot as Root,
FieldLabel as Label,
FieldInput as Input,
FieldTextarea as Textarea,
FieldDescription as Description,
FieldStatus as Status,
FieldError as Error,
};
grid-list
a named collection with visible selection and keyboard focus.
uf ui add grid-list
import { GridList } from "./components/ui/grid-list.js";
Alpha
Beta
Gamma
The example, grid-list.example.js
// @flow
import * as React from "@uniflowed/react";
import { GridList } from "./grid-list.js";
const items = [
{ key: "alpha", textValue: "Alpha" },
{ key: "beta", textValue: "Beta" },
{ key: "gamma", textValue: "Gamma", disabled: true },
];
/** A named, keyboard-operable example using the default presentation. */
export component Example() {
return <GridList aria-label="Options" items={items} selectionMode="multiple" />;
}
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as HoverCard from "./hover-card.js";
const styles = stylex.create({
stack: {
display: "grid",
gap : ufTokens.space2,
},
name: {
margin : 0,
fontWeight: ufTokens.weightBold,
},
note: {
margin: 0,
color : ufTokens.muted,
},
link: {
color : ufTokens.ink,
textDecorationLine : "underline",
textUnderlineOffset: "3px",
},
});
/** A name in a sentence, and the profile it previews. */
export component Example() {
return (
<div>
Reviewed by{" "}
<HoverCard.Root>
<HoverCard.Trigger href="?person=ada">Ada Lovelace</HoverCard.Trigger>
<HoverCard.Content>
<div {...props(styles.stack)}>
<p {...props(styles.name)}>Ada Lovelace</p>
<p {...props(styles.note)}>Writes the notes that outlive the engine.</p>
<a {...props(styles.link)} href="?person=ada&view=notes">
Read her notes
</a>
</div>
</HoverCard.Content>
</HoverCard.Root>
.
</div>
);
}
The component, hover-card.js
"use client";
// @flow
//
// Hover card: the preview a link expands into on hover and on focus, with
// links of its own a keyboard can reach.
//
// `uf ui add hover-card` wrote this file into the project, and it is the
// project's from then on. `uf ui diff hover-card` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The look: the trigger as a link, and the card as a panel beside it. The
// behaviour is imported from `@uniflowed/ui`'s `HoverCard`: it waits for a pointer
// and not for focus, it stays while the pointer travels onto it and while focus
// is inside it, `Escape` dismisses it and gives focus back, and it is neither a
// tooltip nor a dialog — its content follows its trigger in the reading order,
// so `Tab` walks from the link into the card. A fix to any of that reaches this
// project by upgrading `@uniflowed/ui`.
//
// # What to keep true when you change it
//
// * **The link works on its own.** A hover card never opens on touch, so
// `HoverCard.Trigger`'s `href` has to go somewhere useful without it, and
// nothing may live only in the card.
// * **The trigger is focusable.** It is an `<a href>`, and the part refuses a
// trigger a keyboard cannot reach: a hover card on a `<span>` is one a
// keyboard reader never sees.
// * **A link looks like a link.** Underlined, because colour alone does not say
// "link" (WCAG 1.4.1), and in `ink`, whose pairs are measured; the accent is
// only the underline.
// * **Colour comes from tokens, in measured pairs.** `ink` and `muted` on
// `surface` and `canvas`, which `crates/uf_stylex/src/tests/preset.rs` holds
// to 4.5:1 in the light default and the dark theme.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { HoverCard } from "@uniflowed/ui";
/**
* Every prop a caller passes that this file does not name, on its way to the
* part underneath. See `forwarded` for the one thing Flow cannot say about it.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** A caller's own element in place of the one a part renders. */
type RenderProp = (props: Rest) => React.Node;
const styles = stylex.create({
trigger: {
color : ufTokens.ink,
fontWeight : ufTokens.weightMedium,
textDecorationLine : "underline",
textDecorationColor : { default: ufTokens.accent, ":hover": ufTokens.ink },
textDecorationThickness: "2px",
textUnderlineOffset : "3px",
borderRadius : ufTokens.radiusSm,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
content: {
zIndex : 50,
boxSizing : "border-box",
width : "20rem",
maxWidth : "calc(100vw - 16px)",
margin : 0,
padding : ufTokens.space4,
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
// Enter: it fades in while travelling 4px out of its trigger, from the
// side `data-side` says it opened on, so the eye is led from the button
// to what it opened. `durationBase` on the decelerating curve: most of
// the distance is covered at once, so it is legible before it has
// settled. Under reduced motion it only fades.
"--uf-enter-x": {
default : "0px",
":is([data-side=left])" : "4px",
":is([data-side=right])": "-4px",
},
"--uf-enter-y": {
default : "0px",
":is([data-side=top])" : "4px",
":is([data-side=bottom])": "-4px",
},
// Exit: back towards the trigger it came from, on the accelerating curve
// and in `durationFast` against the entrance's `durationBase`, so it gets
// out of the way rather than lingering. `@uniflowed/ui` keeps it on the
// page, closed and `inert`, until this has finished. Under reduced motion
// it only fades: `--uf-exit-travel` is 0 there, so nothing jumps either.
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style": "translate(var(--uf-enter-x), var(--uf-enter-y))",
":is([data-state=closed])":
"translate(calc(var(--uf-enter-x) * var(--uf-exit-travel)), calc(var(--uf-enter-y) * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
});
/** A hover card and the link it previews. */
component HoverCardRoot(
children : React.Node,
openDelay? : number,
closeDelay? : number,
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange?: (open: boolean) => void,
) renders HoverCard.Root {
return (
<HoverCard.Root
closeDelay={closeDelay}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
open={open}
openDelay={openDelay}
>
{children}
</HoverCard.Root>
);
}
/**
* The link the card is about: an `<a href>` unless `render` says otherwise, and
* whatever it is has to be reachable by keyboard.
*/
component HoverCardTrigger(
children : React.Node,
href? : string,
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders HoverCard.Trigger {
return (
<HoverCard.Trigger
{...forwarded(rest)}
render={
match (render) {
undefined =>
(trigger) => (
<a
{...forwarded(trigger)}
className={classNames(props(styles.trigger, xstyle).className, className)}
href={href}
/>
),
const custom => custom,
}
}
>
{children}
</HoverCard.Trigger>
);
}
/**
* The card, under its link unless it does not fit. `side` and `align` reach
* `HoverCard.Body` untouched; `sideOffset` is the gap.
*/
component HoverCardContent(
children : React.Node,
sideOffset? : number = 8,
collisionPadding?: number = 8,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders HoverCard.Body {
return (
<HoverCard.Body
{...forwarded(rest)}
className={classNames(props(styles.content, xstyle).className, className)}
collisionPadding={collisionPadding}
sideOffset={sideOffset}
>
{children}
</HoverCard.Body>
);
}
/**
* A caller's props on their way into a part, rather than onto an element.
*
* Flow checks a spread into a component against that component's own
* `...rest`, `key` included, and the indexer in `Rest` answers `mixed` where
* the part says `empty`. `@uniflowed/ui` meets the same hole between its own
* parts, and papers over it the same way in one named place,
* `internal/merge-props.js`'s `forwarded`: nothing that was ever checked is
* lost, because every element those parts render has `any`-typed props in uf's
* library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. See `button.js`. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as HoverCard from "./hover-card.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<HoverCard.Root>`
* and `<HoverCard.Trigger>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `HoverCardRoot` rather than `Root`.
*/
export { HoverCardRoot as Root, HoverCardTrigger as Trigger, HoverCardContent as Content };
i18n-provider
an explicit locale and direction for a region.
uf ui add i18n-provider
import { I18nProvider } from "./components/ui/i18n-provider.js";
The example, i18n-provider.example.js
// @flow
import * as React from "@uniflowed/react";
import { I18nProvider } from "./i18n-provider.js";
import * as NumberField from "./number-field.js";
/** A named, keyboard-operable example using the default presentation. */
export component Example() {
return (
<I18nProvider locale="ar-EG" aria-label="Arabic settings" role="region">
<NumberField.Root defaultValue={2}>
<NumberField.Input aria-label="Quantity" />
</NumberField.Root>
</I18nProvider>
);
}
The component, i18n-provider.js
"use client";
// @flow
// I18nProvider: an explicit locale and direction for a region.
//
// `uf ui add i18n-provider` copies this component into the project. The headless
// primitive owns semantics and input behavior; this file owns its presentation.
// Keep accessible names, focus rings and disabled/selected states when editing.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { I18nProvider as HeadlessI18nProvider } from "@uniflowed/ui";
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: { fontFamily: ufTokens.fontSans, color: ufTokens.ink, textAlign: "start" },
});
export component I18nProvider(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders HeadlessI18nProvider {
return (
<HeadlessI18nProvider
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
>
{children}
</HeadlessI18nProvider>
);
}
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
input
a styled single-line <input>, for a search box or any control that has no field row around it.
uf ui add input
import { Input } from "./components/ui/input.js";
The example, input.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Input } from "./input.js";
import { Label } from "./label.js";
const styles = stylex.create({
stack: {
display : "grid",
gap : ufTokens.space2,
maxWidth: "20rem",
},
});
/** A labelled search box, an invalid one, and a disabled one. */
export component Example() {
return (
<div {...props(styles.stack)}>
<Label htmlFor="input-example-search">Search projects</Label>
<Input id="input-example-search" name="q" placeholder="Name or owner" type="search" />
<Label htmlFor="input-example-slug">Slug</Label>
<Input aria-invalid="true" defaultValue="My Project" id="input-example-slug" />
<Label htmlFor="input-example-id">Project id</Label>
<Input defaultValue="prj_42" disabled id="input-example-id" />
</div>
);
}
The component, input.js
// @flow
//
// Input: a styled single-line `<input>`, for a search box or any control that
// has no field row around it.
//
// `uf ui add input` wrote this file into the project, and it is the project's
// from then on. `uf ui diff input` shows how it has moved away from the
// registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// An `<input>` already is the component. It has a role, a value, a keyboard
// and a form, and `@uniflowed/ui` declines to wrap it for that reason. When
// the input is part of a labelled row with help text and an error, use
// `field.js`, whose `Field` wires the ids and `aria-describedby`. This file is
// for the input on its own, drawn the same way `FieldInput` draws it, so the
// two match on one page.
//
// # What to keep true when you change it
//
// * **Every input has a name a reader hears.** Use a `<label htmlFor>` (see
// `label.js`) or an `aria-label`. A placeholder is not a name: it disappears
// once a reader types, and it is too faint to be text anyone has to rely on.
// * **Invalid is an attribute, and more than a colour.** The border turns
// `danger` under `aria-invalid="true"`, so the look cannot drift from what is
// announced. Say what is wrong in words, near the input.
// * **The focus ring is drawn for the keyboard.** `:focus-visible` shows two
// pixels of `ufTokens.focus`.
// * **Nothing is forwarded selectively.** `type`, `name`, `value`,
// `onChange`, `autoComplete`, `inputMode`, a `ref` and every `aria-*` reach
// the element. A wrapper that passed through only some of them would drop the
// rest without saying so.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface` and
// `sunken`, which `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in
// both shipped themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
control: {
boxSizing : "border-box",
width : "100%",
minHeight : "36px",
margin : 0,
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
color : { default: ufTokens.ink, ":disabled": ufTokens.muted },
backgroundColor: { default: ufTokens.surface, ":disabled": ufTokens.sunken },
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
borderRadius : ufTokens.radiusMd,
cursor : { default: "text", ":disabled": "not-allowed" },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "1px",
"::placeholder": {
color : ufTokens.muted,
opacity: 1,
},
},
});
/**
* An input.
*
* <Label htmlFor="q">Search</Label>
* <Input id="q" type="search" name="q" />
*
* `xstyle` takes a `stylex.create` namespace and wins property by property;
* `className` adds a class of your own beside these.
*/
export component Input(
type? : string = "text",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<input
{...rest}
className={classNames(props(styles.control, xstyle).className, className)}
type={type}
/>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
input-group
an input with text, icons or buttons attached to its sides, drawn as one field.
uf ui add input-group
import * as InputGroup from "./components/ui/input-group.js";
https://
$USD
The example, input-group.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as InputGroup from "./input-group.js";
import { Label } from "./label.js";
const styles = stylex.create({
stack: {
display : "grid",
gap : ufTokens.space2,
maxWidth: "20rem",
},
});
/**
* A search box with a decorative icon, a website whose prefix is read with
* it, and an amount whose currency is in its label.
*/
export component Example() {
return (
<div {...props(styles.stack)}>
<Label htmlFor="input-group-example-search">Search</Label>
<InputGroup.Root>
<InputGroup.Addon aria-hidden="true">
<svg
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
</InputGroup.Addon>
<InputGroup.Input id="input-group-example-search" name="q" type="search" />
</InputGroup.Root>
<Label htmlFor="input-group-example-site">Website</Label>
<InputGroup.Root>
<InputGroup.Addon id="input-group-example-site-prefix">https://</InputGroup.Addon>
<InputGroup.Input
aria-describedby="input-group-example-site-prefix"
id="input-group-example-site"
name="site"
/>
</InputGroup.Root>
<Label htmlFor="input-group-example-amount">Amount in USD</Label>
<InputGroup.Root>
<InputGroup.Addon aria-hidden="true">$</InputGroup.Addon>
<InputGroup.Input
aria-invalid="true"
defaultValue="-5"
id="input-group-example-amount"
inputMode="decimal"
name="amount"
/>
<InputGroup.Addon aria-hidden="true">USD</InputGroup.Addon>
</InputGroup.Root>
</div>
);
}
The component, input-group.js
// @flow
//
// InputGroup: an input with text, icons or buttons attached to its sides,
// drawn as one field.
//
// `uf ui add input-group` wrote this file into the project, and it is the
// project's from then on. `uf ui diff input-group` shows how it has moved away
// from the registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// The input is an `<input>`, and the addons are text and buttons the platform
// already makes accessible. The one thing that looks like behaviour, the
// focus ring drawn round the whole group while the input has focus, is
// `:focus-within` in CSS. So `@uniflowed/ui` declines an `InputGroup`
// (`crates/uf_lib/src/ui.rs` records it), and this file is the look: a border
// round the input and its addons, the ring on the group, and the addons in the
// quieter colour.
//
// # What to keep true when you change it
//
// * **An addon is not the input's name.** "USD" beside a field is read as
// nothing when the field is reached, because a screen reader announces the
// input's label, not the text beside it. The label says "Amount in USD", with
// a `<label htmlFor>` (see `label.js`) or an `aria-label` on `InputGroup.Input`.
// * **Decoration is hidden, words are not.** An icon that repeats the label
// (a magnifier beside "Search") goes in an addon with `aria-hidden`. Text a
// reader needs ("https://") stays readable; `aria-describedby` on the input,
// pointing at the addon's `id`, is how it is heard with the input.
// * **A button in an addon is a real button with a name.** A clear or copy
// button is `button.js` at `size="icon"` with an `aria-label`, and it is its
// own tab stop after the input.
// * **The ring is the group's.** The input draws no outline of its own, and
// the group draws two pixels of `ufTokens.focus` while anything inside it has
// focus. Removing one without the other leaves a keyboard user with no ring.
// * **Invalid is an attribute, and more than a colour.** The group's border
// turns `danger` when the input has `aria-invalid="true"`. Say what is wrong
// in words, near the field.
// * **Everything reaches the input.** `type`, `name`, `value`, `onChange`,
// `autoComplete`, `inputMode`, a `ref` and every `aria-*` on
// `InputGroup.Input` land on the `<input>`.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both shipped
// themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "flex",
alignItems: "center",
boxSizing : "border-box",
width : "100%",
minHeight : "36px",
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight: ufTokens.leadingBase,
color : ufTokens.ink,
backgroundColor: {
default : ufTokens.surface,
":has(input:disabled)": ufTokens.sunken,
},
borderWidth: "1px",
borderStyle: "solid",
borderColor: {
default : ufTokens.border,
":has([aria-invalid=true])": ufTokens.danger,
},
borderRadius : ufTokens.radiusMd,
outlineWidth : { default: "0", ":focus-within": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "1px",
},
input: {
flexGrow : 1,
flexShrink : 1,
minWidth : 0,
boxSizing : "border-box",
margin : 0,
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
fontFamily : "inherit",
fontSize : "inherit",
lineHeight : "inherit",
color : { default: ufTokens.ink, ":disabled": ufTokens.muted },
backgroundColor: "transparent",
borderWidth : 0,
borderRadius : 0,
// The group draws the ring; see the header.
outlineStyle: "none",
cursor : { default: "text", ":disabled": "not-allowed" },
"::placeholder": {
color : ufTokens.muted,
opacity: 1,
},
},
addon: {
display : "inline-flex",
alignItems : "center",
flexShrink : 0,
gap : ufTokens.space1,
paddingInline: ufTokens.space3,
color : ufTokens.muted,
whiteSpace : "nowrap",
},
});
/** The frame round the input and its addons. */
component InputGroupRoot(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.root, xstyle).className, className)}>
{children}
</div>
);
}
/** The input itself. Its label is its name; see the header. */
component InputGroupInput(
type? : string = "text",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<input
{...rest}
className={classNames(props(styles.input, xstyle).className, className)}
type={type}
/>
);
}
/** Text, an icon or a button beside the input, before or after it in reading order. */
component InputGroupAddon(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<span {...rest} className={classNames(props(styles.addon, xstyle).className, className)}>
{children}
</span>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as InputGroup from "./input-group.js"`
* gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes
* `<InputGroup.Root>` and `<InputGroup.Input>`. Each is declared under its
* full name, so React DevTools and an error say `InputGroupInput` rather than
* `Input`.
*/
export { InputGroupRoot as Root, InputGroupInput as Input, InputGroupAddon as Addon };
input-otp
a one-time code typed into one field and drawn as a row of boxes, one character in each.
uf ui add input-otp
import * as InputOtp from "./components/ui/input-otp.js";
The example, input-otp.example.js
// @flow
import * as InputOtp from "./input-otp.js";
/** A six-digit code in two groups of three. */
export component Example() {
return (
<InputOtp.Root label="Verification code" length={6} name="code">
<InputOtp.Group>
<InputOtp.Slot index={0} />
<InputOtp.Slot index={1} />
<InputOtp.Slot index={2} />
</InputOtp.Group>
<InputOtp.Separator />
<InputOtp.Group>
<InputOtp.Slot index={3} />
<InputOtp.Slot index={4} />
<InputOtp.Slot index={5} />
</InputOtp.Group>
</InputOtp.Root>
);
}
The component, input-otp.js
"use client";
// @flow
//
// Input OTP: a one-time code typed into one field and drawn as a row of boxes,
// one character in each.
//
// `uf ui add input-otp` wrote this file into the project, and it is the
// project's from then on. `uf ui diff input-otp` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The boxes, the gap between groups, the separator, and the one field laid
// invisibly over them so that a click anywhere in the row lands in it.
// `@uniflowed/ui`'s `InputOtp` owns the field itself: one real `<input>` with
// `autocomplete="one-time-code"`, so a phone can offer the code from a
// message, the characters a `kind` allows, `onComplete` once every box is
// full, and boxes that are `aria-hidden` because the field already says
// everything. The box the next character goes into is drawn from
// `data-active`, which is only there while the field has focus, so it is the
// focus ring.
//
// # What to keep true when you change it
//
// * **One field.** However the boxes are restyled, typing, pasting and
// autofill go into the single `<input>`; do not give each box its own.
// * **Keep the ring.** The field is invisible, so the active box's outline is
// the only sign of focus a sighted keyboard user gets.
// * **Name it.** `label` is what a reader hears; the boxes say nothing.
// * **Text stays on measured pairs.** `ink` on `surface`, and `muted` on
// `sunken` while disabled, which `crates/uf_stylex/src/tests/preset.rs`
// holds to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import { createContext, useContext } from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { InputOtpKind } from "@uniflowed/ui";
import { InputOtp } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** Whether the row is disabled, for the boxes, which cannot see the field. */
const DisabledContext = createContext<boolean>(false);
const styles = stylex.create({
root: {
position : "relative",
display : "inline-flex",
alignItems: "center",
gap : ufTokens.space2,
fontFamily: ufTokens.fontSans,
color : ufTokens.ink,
},
// Over the whole row, so a click on any box focuses the field, and invisible,
// because the boxes draw what it holds.
input: {
position : "absolute",
insetBlockStart : 0,
insetInlineStart: 0,
zIndex : 1,
boxSizing : "border-box",
width : "100%",
height : "100%",
margin : 0,
padding : 0,
borderWidth : 0,
opacity : 0,
cursor : { default: "text", ":disabled": "not-allowed" },
},
group: {
display : "flex",
alignItems: "center",
gap : ufTokens.space1,
},
slot: {
display : "flex",
alignItems : "center",
justifyContent : "center",
boxSizing : "border-box",
width : "40px",
height : "44px",
fontSize : "1.125rem",
fontWeight : ufTokens.weightMedium,
fontVariantNumeric: "tabular-nums",
lineHeight : ufTokens.leadingTight,
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([data-active=true])": ufTokens.accent },
borderRadius : ufTokens.radiusMd,
outlineWidth : { default: "0", ":is([data-active=true])": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "1px",
},
slotDisabled: {
color : ufTokens.muted,
backgroundColor: ufTokens.sunken,
},
separator: {
width : "10px",
height : "2px",
borderRadius : ufTokens.radiusSm,
backgroundColor: ufTokens.muted,
},
});
/**
* The row. `label` names the one field under the boxes; `length` is how many
* characters the code has.
*/
component InputOtpRoot(
children : React.Node,
label : string,
length : number,
value? : string,
defaultValue? : string = "",
onValueChange?: (value: string) => void,
onComplete? : (code: string) => void,
kind? : InputOtpKind = "numeric",
disabled? : boolean = false,
name? : string,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) {
return (
<DisabledContext.Provider value={disabled}>
<div className={classNames(props(styles.root, xstyle).className, className)}>
<InputOtp.Root
{...forwarded(rest)}
className={props(styles.input).className}
defaultValue={defaultValue}
disabled={disabled}
kind={kind}
label={label}
length={length}
name={name}
onComplete={onComplete}
onValueChange={onValueChange}
value={value}
>
{children}
</InputOtp.Root>
</div>
</DisabledContext.Provider>
);
}
/** Boxes kept together, such as the two halves of a six-digit code. */
component InputOtpGroup(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders InputOtp.Group {
return (
<InputOtp.Group
{...forwarded(rest)}
className={classNames(props(styles.group, xstyle).className, className)}
>
{children}
</InputOtp.Group>
);
}
/** The box that shows the character at `index`. */
component InputOtpSlot(
index : number,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders InputOtp.Slot {
const disabled = useContext(DisabledContext);
const styled = props(styles.slot, disabled && styles.slotDisabled, xstyle);
return (
<InputOtp.Slot
{...forwarded(rest)}
className={classNames(styled.className, className)}
index={index}
/>
);
}
/** A short dash between groups, silent to a reader. */
component InputOtpSeparator(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders InputOtp.Separator {
return (
<InputOtp.Separator
{...forwarded(rest)}
className={classNames(props(styles.separator, xstyle).className, className)}
/>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as InputOtp from "./input-otp.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<InputOtp.Root>`
* and `<InputOtp.Group>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `InputOtpRoot` rather than `Root`.
*/
export {
InputOtpRoot as Root,
InputOtpGroup as Group,
InputOtpSlot as Slot,
InputOtpSeparator as Separator,
};
item
one row of media, a title, a description and actions, alone or in a list of them.
// @flow
import * as React from "@uniflowed/react";
import { Button } from "./button.js";
import * as Item from "./item.js";
/** A list of team members, each with a decorative avatar and one action. */
export component Example() {
return (
<Item.Group aria-label="Team members">
<Item.Root as="li">
<Item.Media>
<Initials>AL</Initials>
</Item.Media>
<Item.Content>
<Item.Title>Ada Lovelace</Item.Title>
<Item.Description>Owner · ada@example.com</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="sm">Manage</Button>
</Item.Actions>
</Item.Root>
<Item.Root as="li">
<Item.Media>
<Initials>GH</Initials>
</Item.Media>
<Item.Content>
<Item.Title>
<a href="?member=grace">Grace Hopper</a>
</Item.Title>
<Item.Description>Member · invited yesterday</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="sm" tone="ghost">
Resend invite
</Button>
</Item.Actions>
</Item.Root>
</Item.Group>
);
}
/** A stand-in avatar the name beside it already says, so nobody hears it. */
component Initials(children: string) {
return (
<svg aria-hidden="true" focusable="false" height="32" viewBox="0 0 32 32" width="32">
<rect fill="currentColor" height="32" opacity="0.15" rx="4" width="32" />
<text fill="currentColor" fontSize="12" textAnchor="middle" x="16" y="20">
{children}
</text>
</svg>
);
}
The component, item.js
// @flow
//
// Item: one row of media, a title, a description and actions, alone or in a
// list of them.
//
// `uf ui add item` wrote this file into the project, and it is the project's
// from then on. `uf ui diff item` shows how it has moved away from the
// registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// Every part of an item is an element with a class, and when the rows are a
// list the list is `<ul>` and `<li>`. Nothing in it is a role, a state or a
// key, so `@uniflowed/ui` declines an `Item` (`crates/uf_lib/src/ui.rs` records
// it). What an application wants is one row its settings pages, member lists
// and notification panels agree on, and that is written here.
//
// # What to keep true when you change it
//
// * **Rows that belong together are a list.** `Item.Group` is a `<ul>` and
// `<Item.Root as="li">` its rows, so a reader hears "list, 3 items" before
// the first one. Name the group with `aria-label` when the page has more
// than one.
// * **A row the arrow keys move through is not an item.** Rows a reader
// selects, or moves between with the arrow keys, are a `ListBox`, a
// `GridList` or a `Menu`, each of which has the keyboard contract that makes
// it one. An item's controls are ordinary tab stops.
// * **An item that goes somewhere has one link, on its title.** Wrapping the
// whole row in `<a>` makes a screen reader read every word in it as the
// link's name, and it cannot hold the buttons in `Item.Actions`. Put the link
// in `Item.Title`.
// * **The media is decoration unless you say otherwise.** An avatar beside the
// person's name takes `alt=""`, and an icon takes `aria-hidden`, so the name
// is not read twice. `Item.Media` does not hide its content itself, because
// an image that carries meaning belongs there too.
// * **Actions come last in the DOM,** after what they act on, so a keyboard
// reaches them after reading it. An icon-only action needs an `aria-label`.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both shipped
// themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/** Whether a row draws its own border, or sits plain in a list that does. */
export type ItemVariant = "outline" | "plain";
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
group: {
display : "grid",
gap : ufTokens.space2,
margin : 0,
padding : 0,
listStyle: "none",
},
item: {
display : "flex",
alignItems : "center",
gap : ufTokens.space4,
boxSizing : "border-box",
paddingBlock : ufTokens.space3,
paddingInline: ufTokens.space4,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
color : ufTokens.ink,
borderWidth : "1px",
borderStyle : "solid",
borderRadius : ufTokens.radiusMd,
},
outline: {
backgroundColor: ufTokens.surface,
borderColor : ufTokens.border,
},
plain: {
backgroundColor: "transparent",
borderColor : "transparent",
},
media: {
display : "inline-flex",
flexShrink : 0,
alignItems : "center",
justifyContent: "center",
color : ufTokens.muted,
},
content: {
display : "grid",
flexGrow: 1,
gap : "2px",
minWidth: 0,
},
title: {
margin : 0,
fontWeight: ufTokens.weightMedium,
lineHeight: ufTokens.leadingTight,
},
description: {
margin: 0,
color : ufTokens.muted,
},
actions: {
display : "flex",
flexShrink: 0,
alignItems: "center",
gap : ufTokens.space2,
},
});
/** A list of items, announced as one. */
component ItemGroup(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<ul {...rest} className={classNames(props(styles.group, xstyle).className, className)}>
{children}
</ul>
);
}
/** One row: a `<div>`, or with `as="li"` a row of an `Item.Group`. */
component ItemRoot(
children : React.Node,
as? : "div" | "li" = "div",
variant? : ItemVariant = "outline",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
const shared = {
...rest,
className: classNames(
props(
styles.item,
match (variant) {
"outline" => styles.outline,
"plain" => styles.plain,
},
xstyle,
).className,
className,
),
};
return match (as) {
"li" => <li {...shared}>{children}</li>,
"div" => <div {...shared}>{children}</div>,
};
}
/** An icon, an avatar or a thumbnail at the start of the row. */
component ItemMedia(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.media, xstyle).className, className)}>
{children}
</div>
);
}
/** The title and the description, which take the room the row has. */
component ItemContent(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.content, xstyle).className, className)}>
{children}
</div>
);
}
/** What the row is. Not a heading: a row of a list is not a section. */
component ItemTitle(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<p {...rest} className={classNames(props(styles.title, xstyle).className, className)}>
{children}
</p>
);
}
/** A line under the title, in the quieter colour. */
component ItemDescription(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<p {...rest} className={classNames(props(styles.description, xstyle).className, className)}>
{children}
</p>
);
}
/** The buttons or links at the end of the row, after what they act on. */
component ItemActions(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<div {...rest} className={classNames(props(styles.actions, xstyle).className, className)}>
{children}
</div>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Item from "./item.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Item.Root>`
* and `<Item.Title>`. Each is declared under its full name, so React DevTools
* and an error say `ItemRoot` rather than `Root`.
*/
export {
ItemGroup as Group,
ItemRoot as Root,
ItemMedia as Media,
ItemContent as Content,
ItemTitle as Title,
ItemDescription as Description,
ItemActions as Actions,
};
kbd
a key, or a combination of keys, that a reader presses, drawn as a keycap.
uf ui add kbd
import { Kbd } from "./components/ui/kbd.js";
Press Ctrl+K to search, and Esc to close it.
The example, kbd.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Kbd } from "./kbd.js";
const styles = stylex.create({
text: {
margin : 0,
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight: ufTokens.leadingBase,
color : ufTokens.ink,
},
});
/** A single key and a combination, in the sentence that needs them. */
export component Example() {
return (
<p {...props(styles.text)}>
Press <Kbd keys={["Ctrl", "K"]} /> to search, and <Kbd>Esc</Kbd> to close it.
</p>
);
}
The component, kbd.js
// @flow
//
// Kbd: a key, or a combination of keys, that a reader presses, drawn as a
// keycap.
//
// `uf ui add kbd` wrote this file into the project, and it is the project's
// from then on. `uf ui diff kbd` shows how it has moved away from the
// registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// `<kbd>` is the platform's element for keyboard input. It has no role, no
// state and nothing to focus, so `@uniflowed/ui` declines a `Kbd`
// (`crates/uf_lib/src/ui.rs` records it). What an application wants is every
// shortcut in its menus, tooltips and help pages drawn the same way, and that
// is a decision about the application, so it is written here.
//
// # What to keep true when you change it
//
// * **It stays a `<kbd>`.** The element is what tells a reader, a search
// engine and a stylesheet that this is a key rather than any short grey
// word.
// * **A combination is keys inside one key.** The HTML specification writes
// `Ctrl+K` as a `<kbd>` holding a `<kbd>` for each key, and `keys` renders
// exactly that: the outer element draws nothing, the inner ones draw the
// caps, and the `+` between them is text, so it is read as "Control plus K".
// * **A symbol is not a name.** `⌘` is read as "place of interest sign", or not
// at all. Write the key's name ("Cmd"), or give the symbol a `title` and put
// the name in visually hidden text beside it.
// * **It is not a control.** A keycap that runs its shortcut when clicked is a
// `Button`, because a keyboard user has to be able to reach it.
// * **Text stays on a measured pair.** `ink` on `sunken`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both shipped
// themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
cap: {
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
boxSizing : "border-box",
minWidth : "20px",
minHeight : "20px",
paddingBlock : 0,
paddingInline : ufTokens.space1,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textXs,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
whiteSpace : "nowrap",
verticalAlign : "middle",
color : ufTokens.ink,
backgroundColor: ufTokens.sunken,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusSm,
},
combination: {
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space1,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textXs,
color : ufTokens.muted,
whiteSpace : "nowrap",
verticalAlign: "middle",
},
});
/**
* A key, or with `keys` a combination of them.
*
* <Kbd>Esc</Kbd>
* <Kbd keys={["Ctrl", "K"]} />
*
* `xstyle` takes a `stylex.create` namespace and wins property by property;
* `className` adds a class of your own beside these. Both go on the outer
* element.
*/
export component Kbd(
children? : React.Node,
keys? : $ReadOnlyArray<string>,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
if (keys == null) {
return (
<kbd {...rest} className={classNames(props(styles.cap, xstyle).className, className)}>
{children}
</kbd>
);
}
return (
<kbd {...rest} className={classNames(props(styles.combination, xstyle).className, className)}>
{keys.map((key, index) => (
<React.Fragment key={`${index}:${key}`}>
{index > 0 ? "+" : null}
<kbd {...props(styles.cap)}>{key}</kbd>
</React.Fragment>
))}
</kbd>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
label
a styled <label>, which names the control its htmlFor points at.
uf ui add label
import { Label } from "./components/ui/label.js";
The example, label.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Input } from "./input.js";
import { Label } from "./label.js";
const styles = stylex.create({
stack: {
display : "grid",
gap : ufTokens.space2,
maxWidth: "20rem",
},
});
/** A label naming the input its `htmlFor` points at, and saying it is required. */
export component Example() {
return (
<div {...props(styles.stack)}>
<Label htmlFor="label-example-email">Email (required)</Label>
<Input autoComplete="email" id="label-example-email" required type="email" />
</div>
);
}
The component, label.js
// @flow
//
// Label: a styled `<label>`, which names the control its `htmlFor` points at.
//
// `uf ui add label` wrote this file into the project, and it is the project's
// from then on. `uf ui diff label` shows how it has moved away from the
// registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// The platform already does everything a label needs: `<label htmlFor>` gives
// the control its accessible name and makes a click on the label focus or
// toggle the control. `@uniflowed/ui` declines a Label for that reason, and
// `field.js`'s `FieldLabel` is the one to use inside a field row, where the id
// is wired for you. This file is the label for a control on its own, such as
// an `Input`, a `Checkbox` or a `Switch`, drawn the same way `FieldLabel` is.
//
// # What to keep true when you change it
//
// * **It stays a `<label>`.** A `<span>` that looks the same names nothing,
// and clicking it does nothing. `htmlFor` must match the control's `id`, or
// the control has no name.
// * **Required is said in words.** An asterisk alone is read as "star" or not
// at all. Put `required` on the control, which is announced, and write
// "(required)" in the label when the page does not say it elsewhere.
// * **Text stays on a measured pair.** `ink` on `surface` and `canvas`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both shipped
// themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
label: {
display : "inline-flex",
alignItems: "center",
gap : ufTokens.space2,
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight: ufTokens.weightMedium,
lineHeight: ufTokens.leadingTight,
color : ufTokens.ink,
cursor : "default",
},
});
/**
* A label.
*
* <Label htmlFor="email">Email</Label>
* <Input id="email" type="email" />
*/
export component Label(
children : React.Node,
htmlFor? : string,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<label
{...rest}
className={classNames(props(styles.label, xstyle).className, className)}
htmlFor={htmlFor}
>
{children}
</label>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
list-box
a named collection with visible selection and keyboard focus.
uf ui add list-box
import { ListBox } from "./components/ui/list-box.js";
Alpha
Beta
Gamma
The example, list-box.example.js
// @flow
import * as React from "@uniflowed/react";
import { ListBox } from "./list-box.js";
const items = [
{ key: "alpha", textValue: "Alpha" },
{ key: "beta", textValue: "Beta" },
{ key: "gamma", textValue: "Gamma", disabled: true },
];
/** A named, keyboard-operable example using the default presentation. */
export component Example() {
return <ListBox aria-label="Options" items={items} selectionMode="multiple" />;
}
a list of actions and options that opens from a button and is moved through from the keyboard.
uf ui add menu
import * as Menu from "./components/ui/menu.js";
The example, menu.example.js
// @flow
import * as Menu from "./menu.js";
/** A file menu: actions, a setting, a choice, a submenu and a disabled row. */
export component Example() {
return (
<Menu.Root>
<Menu.Trigger>File</Menu.Trigger>
<Menu.Content>
<Menu.Group>
<Menu.Label>Document</Menu.Label>
<Menu.Item aria-keyshortcuts="Control+N">
New file
<Menu.Shortcut>Ctrl N</Menu.Shortcut>
</Menu.Item>
<Menu.Item disabled>Save</Menu.Item>
</Menu.Group>
<Menu.Separator />
<Menu.CheckboxItem defaultChecked>Show ruler</Menu.CheckboxItem>
<Menu.RadioGroup defaultValue="comfortable">
<Menu.Label>Density</Menu.Label>
<Menu.RadioItem value="compact">Compact</Menu.RadioItem>
<Menu.RadioItem value="comfortable">Comfortable</Menu.RadioItem>
</Menu.RadioGroup>
<Menu.Separator />
<Menu.Sub>
<Menu.SubTrigger>Share</Menu.SubTrigger>
<Menu.Content>
<Menu.Item>Copy link</Menu.Item>
<Menu.Item>Email</Menu.Item>
</Menu.Content>
</Menu.Sub>
</Menu.Content>
</Menu.Root>
);
}
The component, menu.js
"use client";
// @flow
//
// Menu: a list of actions and options that opens from a button and is moved
// through from the keyboard.
//
// `uf ui add menu` wrote this file into the project, and it is the project's
// from then on. `uf ui diff menu` shows how it has moved away from the registry
// in the uf you are running.
//
// # What this file owns, and what it does not
//
// The panel, its rows, the check beside a checked row and the dot beside a
// chosen one, shortcut text, the labels and lines that group rows, and the
// arrow on a row that opens a submenu. `context-menu.js` and `menubar.js` dress
// their menus with these same parts. `@uniflowed/ui`'s `Menu` owns the pattern:
// `role="menu"` named by its trigger, focus on the rows and between them from
// the keyboard, submenus, `aria-checked` on checkbox and radio rows, and
// `aria-expanded` on a row whose submenu is open. The focused row is drawn from
// `:focus-visible` and `:hover`, an open submenu's row from `aria-expanded`, a
// disabled row from `aria-disabled`, and a mark from `aria-checked`.
//
// # What to keep true when you change it
//
// * **Draw the focused row.** Focus sits on the rows, and the highlight is the
// sign of it: `accent` on `accentSoft`, a pair
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes. The
// transparent outline is for forced colours, where backgrounds are dropped.
// * **Draw marks from what is announced.** The check and the dot follow
// `aria-checked`, so what a sighted reader sees is what a screen reader
// hears.
// * **Keep shortcuts out of the name.** `Menu.Shortcut` is `aria-hidden`; tell
// assistive technology the keys with `aria-keyshortcuts` on the row.
// * **Name a menu nothing names.** A context menu's panel needs an
// `aria-label`.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { Align, LogicalSide, MenuSelect } from "@uniflowed/ui";
import { Menu } from "@uniflowed/ui";
import type { ButtonSize, ButtonTone } from "./button.js";
import { Button } from "./button.js";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** A caller's own element in place of the one a part renders. */
type RenderProp = (props: Rest) => React.Node;
const styles = stylex.create({
content: {
zIndex : 50,
boxSizing : "border-box",
minWidth : "12rem",
maxWidth : "calc(100vw - 16px)",
maxHeight : "var(--uf-anchor-available-height, none)",
overflowY : "auto",
margin : 0,
padding : ufTokens.space1,
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingTight,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
// Enter: it fades in while travelling 4px out of its trigger, from the
// side `data-side` says it opened on, so the eye is led from the button
// to what it opened. `durationBase` on the decelerating curve: most of
// the distance is covered at once, so it is legible before it has
// settled. Under reduced motion it only fades.
"--uf-enter-x": {
default : "0px",
":is([data-side=left])" : "4px",
":is([data-side=right])": "-4px",
},
"--uf-enter-y": {
default : "0px",
":is([data-side=top])" : "4px",
":is([data-side=bottom])": "-4px",
},
// Exit: back towards the trigger it came from, on the accelerating curve
// and in `durationFast` against the entrance's `durationBase`, so it gets
// out of the way rather than lingering. `@uniflowed/ui` keeps it on the
// page, closed and `inert`, until this has finished. Under reduced motion
// it only fades: `--uf-exit-travel` is 0 there, so nothing jumps either.
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style": "translate(var(--uf-enter-x), var(--uf-enter-y))",
":is([data-state=closed])":
"translate(calc(var(--uf-enter-x) * var(--uf-exit-travel)), calc(var(--uf-enter-y) * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
item: {
// Read by the check or the dot inside, which cannot see this row's state.
"--uf-menu-mark": { default: "0", ":is([aria-checked=true])": "1" },
display : "flex",
alignItems : "center",
gap : ufTokens.space2,
boxSizing : "border-box",
width : "100%",
minHeight : "32px",
margin : 0,
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space2,
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingTight,
textAlign : "start",
userSelect : "none",
cursor : { default: "pointer", ":is([aria-disabled=true])": "not-allowed" },
color: {
default : ufTokens.ink,
":hover" : ufTokens.accent,
":focus-visible" : ufTokens.accent,
":is([aria-expanded=true])": ufTokens.accent,
":is([aria-disabled=true])": ufTokens.muted,
},
backgroundColor: {
default : "transparent",
":hover" : ufTokens.accentSoft,
":focus-visible" : ufTokens.accentSoft,
":is([aria-expanded=true])": ufTokens.accentSoft,
":is([aria-disabled=true])": "transparent",
},
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : "transparent",
outlineOffset: "-2px",
},
mark: {
display : "inline-flex",
alignItems : "center",
justifyContent: "center",
flexShrink : 0,
width : "16px",
height : "16px",
opacity : "var(--uf-menu-mark, 0)",
},
shortcut: {
marginInlineStart : "auto",
paddingInlineStart: ufTokens.space4,
fontSize : ufTokens.textXs,
letterSpacing : "0.05em",
},
chevron: {
flexShrink : 0,
marginInlineStart: "auto",
},
group: {
display: "grid",
},
label: {
paddingBlock : ufTokens.space1,
paddingInline: ufTokens.space2,
fontSize : ufTokens.textXs,
fontWeight : ufTokens.weightMedium,
color : ufTokens.muted,
},
separator: {
height : "1px",
marginBlock : ufTokens.space1,
backgroundColor: ufTokens.border,
},
});
/** The menu, open or closed. Uncontrolled unless `open` is given. */
component MenuRoot(
children : React.Node,
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange?: (open: boolean) => void,
) renders Menu.Root {
return (
<Menu.Root defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open}>
{children}
</Menu.Root>
);
}
/**
* The button that opens the menu, names it, and gets focus back. It is a
* `Button` unless `render` says otherwise.
*/
component MenuTrigger(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Menu.Trigger {
return (
<Menu.Trigger
{...forwarded(rest)}
render={
match (render) {
undefined =>
(trigger) => (
<Button
{...forwarded(trigger)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
),
const custom => custom,
}
}
>
{children}
</Menu.Trigger>
);
}
/**
* The panel of rows: under its trigger unless it does not fit, and to the
* inline end of the row that opens it inside a `Menu.Sub`.
*/
component MenuContent(
children: renders* (
| MenuItem
| MenuCheckboxItem
| MenuRadioGroup
| MenuSeparator
| MenuGroup
| MenuSub
),
align? : Align = "start",
side? : LogicalSide,
sideOffset? : number = 4,
collisionPadding?: number = 8,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Menu.Body {
return (
<Menu.Body
{...forwarded(rest)}
align={align}
className={classNames(props(styles.content, xstyle).className, className)}
collisionPadding={collisionPadding}
side={side}
sideOffset={sideOffset}
>
{children}
</Menu.Body>
);
}
/** A row that does something, and closes the menu unless `closeOnSelect` is false. */
component MenuItem(
children : React.Node,
disabled? : boolean = false,
closeOnSelect?: boolean = true,
onSelect? : (event: MenuSelect) => mixed,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Menu.Item {
return (
<Menu.Item
{...forwarded(rest)}
className={classNames(props(styles.item, xstyle).className, className)}
closeOnSelect={closeOnSelect}
disabled={disabled}
onSelect={onSelect}
>
{children}
</Menu.Item>
);
}
/** A row that turns something on or off, with a check while it is on. */
component MenuCheckboxItem(
children : React.Node,
checked? : boolean,
defaultChecked? : boolean = false,
onCheckedChange?: (checked: boolean) => void,
disabled? : boolean = false,
closeOnSelect? : boolean = false,
onSelect? : (event: MenuSelect) => mixed,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Menu.CheckboxItem {
return (
<Menu.CheckboxItem
{...forwarded(rest)}
checked={checked}
className={classNames(props(styles.item, xstyle).className, className)}
closeOnSelect={closeOnSelect}
defaultChecked={defaultChecked}
disabled={disabled}
onCheckedChange={onCheckedChange}
onSelect={onSelect}
>
<span {...props(styles.mark)}>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
viewBox="0 0 24 24"
width="14"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
{children}
</Menu.CheckboxItem>
);
}
/** Rows of which one is chosen. A `Menu.Label` inside names the group. */
component MenuRadioGroup(
children : renders* (MenuRadioItem | MenuLabel | MenuSeparator),
value? : string | null,
defaultValue? : string | null = null,
onValueChange?: (value: string) => void,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Menu.RadioGroup {
return (
<Menu.RadioGroup
{...forwarded(rest)}
className={classNames(props(styles.group, xstyle).className, className)}
defaultValue={defaultValue}
onValueChange={onValueChange}
value={value}
>
{children}
</Menu.RadioGroup>
);
}
/** One choice in a `Menu.RadioGroup`, with a dot while it is the chosen one. */
component MenuRadioItem(
children : React.Node,
value : string,
disabled? : boolean = false,
closeOnSelect?: boolean = false,
onSelect? : (event: MenuSelect) => mixed,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Menu.RadioItem {
return (
<Menu.RadioItem
{...forwarded(rest)}
className={classNames(props(styles.item, xstyle).className, className)}
closeOnSelect={closeOnSelect}
disabled={disabled}
onSelect={onSelect}
value={value}
>
<span {...props(styles.mark)}>
<svg aria-hidden="true" focusable="false" height="8" viewBox="0 0 8 8" width="8">
<circle cx="4" cy="4" fill="currentColor" r="4" />
</svg>
</span>
{children}
</Menu.RadioItem>
);
}
/** Rows kept together. A `Menu.Label` inside names the group. */
component MenuGroup(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Menu.Group {
return (
<Menu.Group
{...forwarded(rest)}
className={classNames(props(styles.group, xstyle).className, className)}
>
{children}
</Menu.Group>
);
}
/** The name of the group it is in, which the keyboard passes over. */
component MenuLabel(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Menu.Label {
return (
<Menu.Label
{...forwarded(rest)}
className={classNames(props(styles.label, xstyle).className, className)}
>
{children}
</Menu.Label>
);
}
/** A line between groups of rows. */
component MenuSeparator(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Menu.Separator {
return (
<Menu.Separator
{...forwarded(rest)}
className={classNames(props(styles.separator, xstyle).className, className)}
/>
);
}
/** The keys that do the same as a row, at its end and out of its name. */
component MenuShortcut(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<span
{...rest}
aria-hidden="true"
className={classNames(props(styles.shortcut, xstyle).className, className)}
>
{children}
</span>
);
}
/** A submenu: a `Menu.SubTrigger` and the `Menu.Content` it opens. */
component MenuSub(
children : React.Node,
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange?: (open: boolean) => void,
) renders Menu.Sub {
return (
<Menu.Sub defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open}>
{children}
</Menu.Sub>
);
}
/** The row that opens a submenu, with an arrow toward it. */
component MenuSubTrigger(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Menu.SubTrigger {
return (
<Menu.SubTrigger
{...forwarded(rest)}
className={classNames(props(styles.item, xstyle).className, className)}
>
{children}
<svg
{...props(styles.chevron)}
aria-hidden="true"
fill="none"
focusable="false"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="14"
>
<path d="m9 18 6-6-6-6" />
</svg>
</Menu.SubTrigger>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Menu from "./menu.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Menu.Root>`
* and `<Menu.Trigger>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `MenuRoot` rather than `Root`.
*/
export {
MenuRoot as Root,
MenuTrigger as Trigger,
MenuContent as Content,
MenuItem as Item,
MenuCheckboxItem as CheckboxItem,
MenuRadioGroup as RadioGroup,
MenuRadioItem as RadioItem,
MenuGroup as Group,
MenuLabel as Label,
MenuSeparator as Separator,
MenuShortcut as Shortcut,
MenuSub as Sub,
MenuSubTrigger as SubTrigger,
};
menubar
a row of menus, as along the top of a desktop application, moved along with the arrow keys.
uf ui add menubar
import * as Menubar from "./components/ui/menubar.js";
"use client";
// @flow
//
// Menubar: a row of menus, as along the top of a desktop application, moved
// along with the arrow keys.
//
// `uf ui add menubar` wrote this file into the project, and it is the
// project's from then on. `uf ui diff menubar` shows how it has moved away from
// the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The bar and its triggers. The panels and their rows are `menu.js`'s parts
// under menubar names, so a menubar's menus and a menu look alike and change
// together. `@uniflowed/ui`'s `Menubar` owns the pattern: `role="menubar"`, one tab
// stop for the whole bar with the arrow keys moving between triggers, each
// trigger opening and naming its menu, and moving on to the next menu while
// one is open. A trigger is drawn highlighted while `aria-expanded` says its
// menu is open.
//
// # What to keep true when you change it
//
// * **Name the bar.** An `aria-label` on `Menubar` says what the menus are for
// when a page has more than one bar.
// * **Keep the ring.** Triggers take focus one at a time from the keyboard, and
// the outline is how a sighted keyboard user follows it.
// * **Text stays on measured pairs.** `ink` on `surface`, and `accent` on
// `accentSoft` while highlighted, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Menubar } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
bar: {
display : "flex",
alignItems : "center",
gap : ufTokens.space1,
boxSizing : "border-box",
padding : ufTokens.space1,
backgroundColor: ufTokens.surface,
fontFamily : ufTokens.fontSans,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
},
trigger: {
display : "inline-flex",
alignItems : "center",
minHeight : "32px",
margin : 0,
paddingBlock : ufTokens.space1,
paddingInline: ufTokens.space3,
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
cursor : "pointer",
userSelect : "none",
color: {
default : ufTokens.ink,
":hover" : ufTokens.accent,
":focus-visible" : ufTokens.accent,
":is([aria-expanded=true])": ufTokens.accent,
},
backgroundColor: {
default : "transparent",
":hover" : ufTokens.accentSoft,
":focus-visible" : ufTokens.accentSoft,
":is([aria-expanded=true])": ufTokens.accentSoft,
},
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "1px",
},
});
/** The bar. Every child is a `Menubar.Menu`. */
component MenubarRoot(
children : renders* MenubarMenu,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Menubar.Root {
return (
<Menubar.Root
{...forwarded(rest)}
className={classNames(props(styles.bar, xstyle).className, className)}
>
{children}
</Menubar.Root>
);
}
/** One menu on the bar: a `Menubar.Trigger` and the `Menubar.Content` it opens. */
component MenubarMenu(children: React.Node, value: string) renders Menubar.Menu {
return <Menubar.Menu value={value}>{children}</Menubar.Menu>;
}
/** The button on the bar that opens its menu and names it. */
component MenubarTrigger(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Menubar.Trigger {
return (
<Menubar.Trigger
{...forwarded(rest)}
className={classNames(props(styles.trigger, xstyle).className, className)}
>
{children}
</Menubar.Trigger>
);
}
/**
* The menu parts, which are `menu.js`'s own: a menubar is a menu opened another
* way, so `<Menubar.Item>` is the styled `Menu.Item` and a change to it there
* reaches both.
*/
export {
CheckboxItem,
Content,
Group,
Item,
Label,
RadioGroup,
RadioItem,
Separator,
Shortcut,
Sub,
SubTrigger,
} from "./menu.js";
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Menubar from "./menubar.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Menubar.Root>`
* and `<Menubar.Menu>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `MenubarRoot` rather than `Root`.
*/
export { MenubarRoot as Root, MenubarMenu as Menu, MenubarTrigger as Trigger };
native-select
the platform's <select>, drawn to match the registry's inputs, with a chevron in place of the one the browser draws.
uf ui add native-select
import { NativeSelect } from "./components/ui/native-select.js";
The example, native-select.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Label } from "./label.js";
import { NativeSelect } from "./native-select.js";
const styles = stylex.create({
stack: {
display : "grid",
gap : ufTokens.space2,
maxWidth: "20rem",
},
});
/** A labelled select with option groups, an invalid one, and a disabled one. */
export component Example() {
return (
<div {...props(styles.stack)}>
<Label htmlFor="native-select-example-region">Region</Label>
<NativeSelect defaultValue="eu-west" id="native-select-example-region" name="region">
<optgroup label="Americas">
<option value="us-east">US East</option>
<option value="us-west">US West</option>
</optgroup>
<optgroup label="Europe">
<option value="eu-west">EU West</option>
<option disabled value="eu-north">
EU North (full)
</option>
</optgroup>
</NativeSelect>
<Label htmlFor="native-select-example-plan">Plan</Label>
<NativeSelect aria-invalid="true" defaultValue="" id="native-select-example-plan" required>
<option value="">Choose a plan</option>
<option value="hobby">Hobby</option>
<option value="team">Team</option>
</NativeSelect>
<Label htmlFor="native-select-example-currency">Currency</Label>
<NativeSelect defaultValue="usd" disabled id="native-select-example-currency">
<option value="usd">US dollar</option>
</NativeSelect>
</div>
);
}
The component, native-select.js
// @flow
//
// NativeSelect: the platform's `<select>`, drawn to match the registry's
// inputs, with a chevron in place of the one the browser draws.
//
// `uf ui add native-select` wrote this file into the project, and it is the
// project's from then on. `uf ui diff native-select` shows how it has moved
// away from the registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// A `<select>` already is the component: every screen reader announces it, a
// phone opens its own picker for it, and a form submits, validates and resets
// it without a line of script. `@uniflowed/ui` declines to wrap it for that
// reason (`crates/uf_lib/src/ui.rs` records it), and its `Select` is the other
// answer, for options that need more than text. What is left is the look,
// which this file owns: the field the other inputs are drawn as, and a chevron.
//
// # Use this before `select.js`
//
// Reach for `select.js` only when an option needs an icon, a second line or a
// check the platform cannot draw. Everything else is better native.
//
// # What to keep true when you change it
//
// * **It stays a real `<select>`.** `name`, `value`, `defaultValue`,
// `onChange`, `required`, `form`, `autoComplete`, a `ref` and every `aria-*`
// reach the element, and the options are the caller's own `<option>` and
// `<optgroup>` elements. A wrapper that rebuilt the list from data would lose
// `<optgroup>` and disabled options without saying so.
// * **Every select has a name a reader hears.** Use a `<label htmlFor>` (see
// `label.js`) or an `aria-label`. The first option is not a name.
// * **The chevron is decoration.** It is `aria-hidden`, and it lets a click
// through to the `<select>` under it, so pressing on it opens the list.
// `multiple` draws no chevron, because a list box shows its rows and has
// nothing to open.
// * **Invalid is an attribute, and more than a colour.** The border turns
// `danger` under `aria-invalid="true"`, as `input.js` does. Say what is wrong
// in words, near the select.
// * **The focus ring is drawn for the keyboard.** `:focus-visible` shows two
// pixels of `ufTokens.focus`.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface` and
// `sunken`, which `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in
// both shipped themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* `<select>`. `key` is `empty` because React takes it off before a component
* is called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
// The select and the chevron share one grid cell, so the chevron sits over
// the select's end without being positioned against anything.
wrapper: {
display : "inline-grid",
alignItems: "center",
width : "100%",
minWidth : "12rem",
},
control: {
gridArea : "1 / 1",
boxSizing : "border-box",
width : "100%",
minHeight : "36px",
margin : 0,
paddingBlock : ufTokens.space2,
paddingInlineStart: ufTokens.space3,
paddingInlineEnd : ufTokens.space8,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingTight,
color : { default: ufTokens.ink, ":disabled": ufTokens.muted },
backgroundColor : { default: ufTokens.surface, ":disabled": ufTokens.sunken },
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
borderRadius : ufTokens.radiusMd,
// The browser's own arrow goes, and the chevron below replaces it.
appearance : "none",
cursor : { default: "pointer", ":disabled": "not-allowed" },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "1px",
},
// A list box shows its rows, so it needs no room for a chevron.
multiple: {
paddingInlineEnd: ufTokens.space3,
},
chevron: {
gridArea : "1 / 1",
justifySelf : "end",
marginInlineEnd: ufTokens.space3,
color : ufTokens.muted,
pointerEvents : "none",
},
});
/**
* A native select.
*
* <Label htmlFor="region">Region</Label>
* <NativeSelect id="region" name="region" defaultValue="eu">
* <option value="us">United States</option>
* <option value="eu">Europe</option>
* </NativeSelect>
*
* `xstyle` and `className` go on the `<select>`: `xstyle` takes a
* `stylex.create` namespace and wins property by property, and `className`
* adds a class of your own beside these.
*/
export component NativeSelect(
children : React.Node,
multiple? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<span {...props(styles.wrapper)}>
<select
{...rest}
className={classNames(
props(styles.control, multiple && styles.multiple, xstyle).className,
className,
)}
multiple={multiple}
>
{children}
</select>
{multiple ? null : <ChevronIcon />}
</span>
);
}
/** The chevron, where the browser's own arrow was. */
component ChevronIcon() {
return (
<svg
{...props(styles.chevron)}
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
navigation-menu
a site's top-level links, some of which open a panel of further links, as along the top of a documentation site.
uf ui add navigation-menu
import * as NavigationMenu from "./components/ui/navigation-menu.js";
The example, navigation-menu.example.js
// @flow
import * as NavigationMenu from "./navigation-menu.js";
/** A documentation site's navigation: two panels of links and a plain link. */
export component Example() {
return (
<NavigationMenu.Root aria-label="Main">
<NavigationMenu.List>
<NavigationMenu.Item value="guides">
<NavigationMenu.Trigger>Guides</NavigationMenu.Trigger>
<NavigationMenu.Content>
<NavigationMenu.Link href="?section=install">Installation</NavigationMenu.Link>
<NavigationMenu.Link href="?section=routing">Routing</NavigationMenu.Link>
</NavigationMenu.Content>
</NavigationMenu.Item>
<NavigationMenu.Item value="reference">
<NavigationMenu.Trigger>Reference</NavigationMenu.Trigger>
<NavigationMenu.Content>
<NavigationMenu.Link href="?section=cli">CLI</NavigationMenu.Link>
<NavigationMenu.Link href="?section=config">Configuration</NavigationMenu.Link>
</NavigationMenu.Content>
</NavigationMenu.Item>
<NavigationMenu.Item value="blog">
<NavigationMenu.TopLink href="?section=blog">Blog</NavigationMenu.TopLink>
</NavigationMenu.Item>
</NavigationMenu.List>
</NavigationMenu.Root>
);
}
The component, navigation-menu.js
"use client";
// @flow
//
// Navigation menu: a site's top-level links, some of which open a panel of
// further links, as along the top of a documentation site.
//
// `uf ui add navigation-menu` wrote this file into the project, and it is the
// project's from then on. `uf ui diff navigation-menu` shows how it has moved
// away from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The bar, its triggers and plain links, the arrow beside a trigger, and the
// panel of links a trigger opens. `@uniflowed/ui`'s `NavigationMenu` owns the
// pattern: a `<nav>` of list items, a disclosure button for each panel with
// `aria-expanded` and `aria-controls`, a panel named by its trigger that is not
// in the page while it is closed, one panel open at a time, and a link that
// closes its panel when it is followed. A trigger's arrow turns from
// `aria-expanded`.
//
// # What to keep true when you change it
//
// * **Name the navigation.** An `aria-label` on `NavigationMenu` tells a reader
// which navigation this is when a page has more than one.
// * **Links stay links.** A panel holds `<a>` elements with real `href`s, so a
// reader can open one in a new tab and a crawler can follow it.
// * **Keep the ring.** Triggers and links take focus, and their outline is how
// a sighted keyboard user follows it.
// * **Text stays on measured pairs.** `ink` on `surface`, and `accent` on
// `accentSoft` while highlighted, which `crates/uf_stylex/src/tests/preset.rs`
// holds to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { NavigationMenu } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
position : "relative",
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
},
list: {
display : "flex",
alignItems: "center",
gap : ufTokens.space1,
margin : 0,
padding : 0,
listStyle : "none",
},
item: {
position: "relative",
},
trigger: {
// Read by the arrow inside, which cannot see this button's state.
"--uf-navigation-arrow-turn": { default: "0deg", ":is([aria-expanded=true])": "180deg" },
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space1,
boxSizing : "border-box",
minHeight : "36px",
margin : 0,
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
textDecoration : "none",
cursor : "pointer",
color: {
default : ufTokens.ink,
":hover" : ufTokens.accent,
":focus-visible" : ufTokens.accent,
":is([aria-expanded=true])": ufTokens.accent,
},
backgroundColor: {
default : "transparent",
":hover" : ufTokens.accentSoft,
":focus-visible" : ufTokens.accentSoft,
":is([aria-expanded=true])": ufTokens.accentSoft,
},
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
},
arrow: {
flexShrink: 0,
transform : "rotate(var(--uf-navigation-arrow-turn, 0deg))",
// A half turn as its panel opens, like every other chevron. Under reduced
// motion the arrow is simply the other way up.
transitionProperty: { default: "transform", "@media (prefers-reduced-motion: reduce)": "none" },
transitionDuration: ufTokens.durationBase,
transitionTimingFunction: ufTokens.easing,
},
content: {
position : "absolute",
zIndex : 50,
insetBlockStart : "calc(100% + 4px)",
insetInlineStart: 0,
display : "grid",
gap : "2px",
boxSizing : "border-box",
minWidth : "14rem",
margin : 0,
padding : ufTokens.space2,
listStyle : "none",
backgroundColor : ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
// Enter: it fades in while dropping 4px out of the bar, the way every
// other anchored surface leaves its trigger. Exit: back up into the bar,
// in `durationFast` on the accelerating curve, while `@uniflowed/ui` keeps
// the closing group on the page and `inert`. Under reduced motion both
// only fade (`--uf-exit-travel` is 0 there).
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style" : "translateY(-4px)",
":is([data-state=closed])": "translateY(calc(-4px * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
link: {
display: "block",
paddingBlock: ufTokens.space2,
paddingInline: ufTokens.space3,
borderRadius: ufTokens.radiusSm,
textDecoration: "none",
color: { default: ufTokens.ink, ":hover": ufTokens.accent, ":focus-visible": ufTokens.accent },
backgroundColor: {
default : "transparent",
":hover" : ufTokens.accentSoft,
":focus-visible": ufTokens.accentSoft,
},
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
},
});
/** The navigation. Uncontrolled unless `value`, the open item's, is given. */
component NavigationMenuRoot(
children : renders* NavigationMenuList,
defaultValue? : string | null = null,
value? : string | null,
onValueChange?: (value: string | null) => void,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders NavigationMenu.Root {
return (
<NavigationMenu.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
defaultValue={defaultValue}
onValueChange={onValueChange}
value={value}
>
{children}
</NavigationMenu.Root>
);
}
/** The row of items. */
component NavigationMenuList(
children : renders* NavigationMenuItem,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders NavigationMenu.List {
return (
<NavigationMenu.List
{...forwarded(rest)}
className={classNames(props(styles.list, xstyle).className, className)}
>
{children}
</NavigationMenu.List>
);
}
/**
* One item: a `NavigationMenu.Trigger` and the `NavigationMenu.Content` it opens,
* or a `NavigationMenu.TopLink`.
*/
component NavigationMenuItem(
value : string,
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders NavigationMenu.Item {
return (
<NavigationMenu.Item
{...forwarded(rest)}
className={classNames(props(styles.item, xstyle).className, className)}
value={value}
>
{children}
</NavigationMenu.Item>
);
}
/** The button that opens an item's panel, with an arrow that turns while it is open. */
component NavigationMenuTrigger(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders NavigationMenu.Trigger {
return (
<NavigationMenu.Trigger
{...forwarded(rest)}
className={classNames(props(styles.trigger, xstyle).className, className)}
>
{children}
<svg
{...props(styles.arrow)}
aria-hidden="true"
fill="none"
focusable="false"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="14"
>
<path d="m6 9 6 6 6-6" />
</svg>
</NavigationMenu.Trigger>
);
}
/** The panel of links under an item, named by its trigger. */
component NavigationMenuContent(
children : renders* NavigationMenuLink,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders NavigationMenu.Body {
return (
<NavigationMenu.Body
{...forwarded(rest)}
className={classNames(props(styles.content, xstyle).className, className)}
>
{children}
</NavigationMenu.Body>
);
}
/** A link in a panel, which closes the panel when it is followed. */
component NavigationMenuLink(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders NavigationMenu.Link {
return (
<NavigationMenu.Link
{...forwarded(rest)}
className={classNames(props(styles.link, xstyle).className, className)}
>
{children}
</NavigationMenu.Link>
);
}
/** A link on the bar itself, drawn like a trigger, for an item with no panel. */
component NavigationMenuTopLink(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<a {...rest} className={classNames(props(styles.trigger, xstyle).className, className)}>
{children}
</a>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as NavigationMenu from "./navigation-menu.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<NavigationMenu.Root>`
* and `<NavigationMenu.List>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `NavigationMenuRoot` rather than `Root`.
*/
export {
NavigationMenuRoot as Root,
NavigationMenuList as List,
NavigationMenuItem as Item,
NavigationMenuTrigger as Trigger,
NavigationMenuContent as Content,
NavigationMenuLink as Link,
NavigationMenuTopLink as TopLink,
};
number-field
a localized numeric input with named step buttons.
uf ui add number-field
import * as NumberField from "./components/ui/number-field.js";
The example, number-field.example.js
// @flow
import * as React from "@uniflowed/react";
import * as NumberField from "./number-field.js";
/** A named, keyboard-operable example using the default presentation. */
export component Example() {
return (
<NumberField.Root defaultValue={2} min={0} max={5}>
<NumberField.Decrement />
<NumberField.Input aria-label="Quantity" />
<NumberField.Increment />
</NumberField.Root>
);
}
The component, number-field.js
"use client";
// @flow
// NumberField: a localized numeric input with named step buttons.
//
// `uf ui add number-field` copies this component into the project. The headless
// primitive owns semantics and input behavior; this file owns its presentation.
// Keep accessible names, focus rings and disabled/selected states when editing.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { NumberField } from "@uniflowed/ui";
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: { display: "inline-flex", alignItems: "center", gap: ufTokens.space1 },
input: {
boxSizing : "border-box",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderRadius : ufTokens.radiusMd,
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space1,
minHeight : "36px",
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space2,
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
outlineWidth : { default: "0", ":focus-within": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
fontVariantNumeric: "tabular-nums",
width : "8rem",
},
step: {
boxSizing : "border-box",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor: ufTokens.surface,
borderRadius : ufTokens.radiusSm,
minWidth : "36px",
minHeight : "36px",
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
cursor : { default: "pointer", ":disabled": "not-allowed" },
color : { default: ufTokens.ink, ":disabled": ufTokens.muted },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
});
component NumberFieldRoot(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders NumberField.Root {
return (
<NumberField.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
>
{children}
</NumberField.Root>
);
}
component NumberFieldInput(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders NumberField.Input {
return (
<NumberField.Input
{...forwarded(rest)}
className={classNames(props(styles.input, xstyle).className, className)}
/>
);
}
component NumberFieldIncrement(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders NumberField.Increment {
return (
<NumberField.Increment
{...forwarded(rest)}
className={classNames(props(styles.step, xstyle).className, className)}
>
{children}
</NumberField.Increment>
);
}
component NumberFieldDecrement(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders NumberField.Decrement {
return (
<NumberField.Decrement
{...forwarded(rest)}
className={classNames(props(styles.step, xstyle).className, className)}
>
{children}
</NumberField.Decrement>
);
}
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as NumberField from "./number-field.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<NumberField.Root>`
* and `<NumberField.Input>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `NumberFieldRoot` rather than `Root`.
*/
export {
NumberFieldRoot as Root,
NumberFieldInput as Input,
NumberFieldIncrement as Increment,
NumberFieldDecrement as Decrement,
};
pagination
links to the pages of a long list, with the current page marked and a change of page announced.
uf ui add pagination
import * as Pagination from "./components/ui/pagination.js";
Page 2 of 5.
The example, pagination.example.js
// @flow
import * as React from "@uniflowed/react";
import * as Pagination from "./pagination.js";
/** The second of five pages, with a page before it and a page after it. */
export component Example() {
return (
<Pagination.Root page={2} pageCount={5}>
<Pagination.Content>
<Pagination.Previous href="?page=1" />
<Pagination.Item href="?page=1">1</Pagination.Item>
<Pagination.Item current href="?page=2">
2
</Pagination.Item>
<Pagination.Item href="?page=3">3</Pagination.Item>
<Pagination.Item href="?page=5">5</Pagination.Item>
<Pagination.Next href="?page=3" />
</Pagination.Content>
</Pagination.Root>
);
}
The component, pagination.js
"use client";
// @flow
//
// Pagination: links to the pages of a long list, with the current page marked
// and a change of page announced.
//
// `uf ui add pagination` wrote this file into the project, and it is the
// project's from then on. `uf ui diff pagination` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The row of page links, their targets, and the current page's look.
// `@uniflowed/ui`'s `Pagination` owns the markup a reader relies on: a named
// `<nav>`, one `aria-current="page"`, previous and next named in words rather
// than in chevrons, a disabled end that is not a link, and a polite status that
// says "Page 3 of 12" when the page changes. The current page is drawn from
// `aria-current` through `:is([aria-current=page])`.
//
// # What to keep true when you change it
//
// * **Pass `page` and `pageCount` to `Pagination`,** so the change of page is
// announced rather than only drawn.
// * **Previous and next keep their words.** The chevrons are decoration;
// "Previous page" is the name.
// * **A row holds pages, previous and next.** `Pagination.Content` takes
// `renders* (PaginationItem | PaginationPrevious | PaginationNext)`, so an
// ellipsis is a gap in the numbers rather than an item, and anything else in
// the list is a Flow error.
// * **Every link is a target a finger can hit:** 36px, over the 24px WCAG
// 2.5.8 asks for.
// * **Colour comes from tokens, in measured pairs:** `ink` on `surface` and
// `surfaceHover`, which `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1
// in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Pagination } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** A caller's own element in place of the one a part renders. */
type RenderProp = (props: Rest) => React.Node;
const styles = stylex.create({
content: {
display : "flex",
flexWrap : "wrap",
alignItems: "center",
gap : ufTokens.space1,
margin : 0,
padding : 0,
listStyle : "none",
},
link: {
display : "inline-flex",
alignItems : "center",
justifyContent: "center",
gap : ufTokens.space1,
boxSizing : "border-box",
minWidth : "36px",
minHeight : "36px",
paddingInline : ufTokens.space2,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight: {
default : ufTokens.weightRegular,
":is([aria-current=page])": ufTokens.weightBold,
},
lineHeight : ufTokens.leadingTight,
color : ufTokens.ink,
textDecorationLine: "none",
backgroundColor: {
default : "transparent",
":hover" : ufTokens.surfaceHover,
":is([aria-disabled=true])": "transparent",
},
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: "transparent", ":is([aria-current=page])": ufTokens.border },
borderRadius : ufTokens.radiusMd,
cursor : { default: "pointer", ":is([aria-disabled=true])": "not-allowed" },
opacity : { default: 1, ":is([aria-disabled=true])": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
},
});
/**
* The navigation. `page` and `pageCount` are what a change of page is
* announced with.
*/
component PaginationRoot(
children : React.Node,
page? : number | null = null,
pageCount?: number | null = null,
label? : string = "Pagination",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Pagination.Root {
return (
<Pagination.Root
{...forwarded(rest)}
className={classNames(props(xstyle).className, className)}
label={label}
page={page}
pageCount={pageCount}
>
{children}
</Pagination.Root>
);
}
/** The row of pages, previous and next. */
component PaginationContent(
children : renders* (PaginationItem | PaginationPrevious | PaginationNext),
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Pagination.Content {
return (
<Pagination.Content
{...forwarded(rest)}
className={classNames(props(styles.content, xstyle).className, className)}
>
{children}
</Pagination.Content>
);
}
/** A link to one page; `current` for the page the reader is on. */
component PaginationItem(
children : React.Node,
current? : boolean = false,
disabled? : boolean = false,
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Pagination.Item {
return (
<Pagination.Item
{...forwarded(rest)}
className={classNames(props(styles.link, xstyle).className, className)}
current={current}
disabled={disabled}
render={render}
>
{children}
</Pagination.Item>
);
}
/** The link to the page before, named "Previous page" unless `label` says otherwise. */
component PaginationPrevious(
label? : string = "Previous page",
disabled? : boolean = false,
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Pagination.Previous {
return (
<Pagination.Previous
{...forwarded(rest)}
className={classNames(props(styles.link, xstyle).className, className)}
disabled={disabled}
label={label}
render={render}
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="m15 18-6-6 6-6" />
</svg>
</Pagination.Previous>
);
}
/** The link to the page after, named "Next page" unless `label` says otherwise. */
component PaginationNext(
label? : string = "Next page",
disabled? : boolean = false,
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Pagination.Next {
return (
<Pagination.Next
{...forwarded(rest)}
className={classNames(props(styles.link, xstyle).className, className)}
disabled={disabled}
label={label}
render={render}
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="m9 18 6-6-6-6" />
</svg>
</Pagination.Next>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Pagination from "./pagination.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Pagination.Root>`
* and `<Pagination.Content>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `PaginationRoot` rather than `Root`.
*/
export {
PaginationRoot as Root,
PaginationContent as Content,
PaginationItem as Item,
PaginationPrevious as Previous,
PaginationNext as Next,
};
popover
a small panel anchored to the button that opened it, with the page still usable around it.
uf ui add popover
import * as Popover from "./components/ui/popover.js";
The example, popover.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Button } from "./button.js";
import * as Popover from "./popover.js";
const styles = stylex.create({
stack: {
display: "grid",
gap : ufTokens.space3,
},
note: {
margin: 0,
color : ufTokens.muted,
},
field: {
display : "grid",
gap : ufTokens.space1,
fontWeight: ufTokens.weightMedium,
},
input: {
boxSizing : "border-box",
width : "100%",
minHeight : "36px",
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor: ufTokens.sunken,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "1px",
},
});
/** A share link, beside the button that asked for it. */
export component Example() {
return (
<Popover.Root>
<Popover.Trigger>Share</Popover.Trigger>
<Popover.Content>
<div {...props(styles.stack)}>
<p {...props(styles.note)}>Anyone with the link can read this page.</p>
<div {...props(styles.field)}>
<label htmlFor="share-link">Link</label>
<input
{...props(styles.input)}
id="share-link"
name="link"
readOnly
type="text"
value="https://example.com/pages/1024"
/>
</div>
<Button size="sm" tone="primary">
Copy link
</Button>
</div>
</Popover.Content>
</Popover.Root>
);
}
The component, popover.js
"use client";
// @flow
//
// Popover: a small panel anchored to the button that opened it, with the page
// still usable around it.
//
// `uf ui add popover` wrote this file into the project, and it is the
// project's from then on. `uf ui diff popover` shows how it has moved away from
// the registry in the uf you are running.
//
// # When this rather than `dialog`
//
// When the page behind should stay usable: a filter, a share link, a colour
// picker. A popover is `role="dialog"` with no `aria-modal`, nothing inert and
// no focus trap, so `Tab` leaves it and closes it. A task a reader must finish
// before doing anything else is a `dialog`.
//
// # What this file owns, and what it does not
//
// The look: the panel, its width, and its height against the room the page has
// left. The behaviour is imported from `@uniflowed/ui`'s `Popover`: focus moved in
// when it opens, `Escape` and a press outside that close it and give focus back
// to the trigger, `Tab` that leaves, and the panel kept against its trigger as
// the page scrolls, flipped to the other side when it does not fit. The part
// writes the room it found as `--uf-anchor-available-height`, and the panel
// below is never taller than that.
//
// # What to keep true when you change it
//
// * **It is named by its trigger.** A popover with no `aria-label` of its own
// is announced with the trigger's text, so the trigger's text should say
// what the popover is for.
// * **Focus goes in and comes back.** Keep something focusable inside, or the
// panel takes focus itself — and then it needs the focus ring it has here.
// * **It never hides the only way to do something.** A popover can be closed
// by moving focus away from it, which is right for a shortcut and wrong for
// the one place an action lives.
// * **Colour comes from tokens, in measured pairs.** `ink` on `surface`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in the light default
// and the dark theme.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Popover } from "@uniflowed/ui";
import type { ButtonSize, ButtonTone } from "./button.js";
import { Button } from "./button.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* part underneath. See `forwarded` for the one thing Flow cannot say about it.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** A caller's own element in place of the one a part renders. */
type RenderProp = (props: Rest) => React.Node;
const styles = stylex.create({
content: {
zIndex : 50,
boxSizing : "border-box",
width : "18rem",
maxWidth : "calc(100vw - 16px)",
maxHeight : "var(--uf-anchor-available-height, none)",
overflowY : "auto",
margin : 0,
padding : ufTokens.space4,
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
// Enter: it fades in while travelling 4px out of its trigger, from the
// side `data-side` says it opened on, so the eye is led from the button
// to what it opened. `durationBase` on the decelerating curve: most of
// the distance is covered at once, so it is legible before it has
// settled. Under reduced motion it only fades.
"--uf-enter-x": {
default : "0px",
":is([data-side=left])" : "4px",
":is([data-side=right])": "-4px",
},
"--uf-enter-y": {
default : "0px",
":is([data-side=top])" : "4px",
":is([data-side=bottom])": "-4px",
},
// Exit: back towards the trigger it came from, on the accelerating curve
// and in `durationFast` against the entrance's `durationBase`, so it gets
// out of the way rather than lingering. `@uniflowed/ui` keeps it on the
// page, closed and `inert`, until this has finished. Under reduced motion
// it only fades: `--uf-exit-travel` is 0 there, so nothing jumps either.
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style": "translate(var(--uf-enter-x), var(--uf-enter-y))",
":is([data-state=closed])":
"translate(calc(var(--uf-enter-x) * var(--uf-exit-travel)), calc(var(--uf-enter-y) * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
});
/** The popover, open or closed. Uncontrolled unless `open` is given. */
component PopoverRoot(
children : React.Node,
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange?: (open: boolean) => void,
) renders Popover.Root {
return (
<Popover.Root defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open}>
{children}
</Popover.Root>
);
}
/**
* The button that opens the popover, names it, and gets focus back. It is a
* `Button` unless `render` says otherwise.
*/
component PopoverTrigger(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Popover.Trigger {
return (
<Popover.Trigger
{...forwarded(rest)}
render={
match (render) {
undefined =>
(trigger) => (
<Button
{...forwarded(trigger)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
),
const custom => custom,
}
}
>
{children}
</Popover.Trigger>
);
}
/**
* The panel, under its trigger unless it does not fit. `side`, `align` and
* `initialFocus` reach `Popover.Body` untouched; `sideOffset` is the gap.
*/
component PopoverContent(
children : React.Node,
sideOffset? : number = 8,
collisionPadding?: number = 8,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Popover.Body {
return (
<Popover.Body
{...forwarded(rest)}
className={classNames(props(styles.content, xstyle).className, className)}
collisionPadding={collisionPadding}
sideOffset={sideOffset}
>
{children}
</Popover.Body>
);
}
/**
* A caller's props on their way into a part, rather than onto an element.
*
* Flow checks a spread into a component against that component's own
* `...rest`, `key` included, and the indexer in `Rest` answers `mixed` where
* the part says `empty`. `@uniflowed/ui` meets the same hole between its own
* parts, and papers over it the same way in one named place,
* `internal/merge-props.js`'s `forwarded`: nothing that was ever checked is
* lost, because every element those parts render has `any`-typed props in uf's
* library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. See `button.js`. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Popover from "./popover.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Popover.Root>`
* and `<Popover.Trigger>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `PopoverRoot` rather than `Root`.
*/
export { PopoverRoot as Root, PopoverTrigger as Trigger, PopoverContent as Content };
progress
a bar that says how far a task has got, and says nothing about an amount it does not know.
uf ui add progress
import { Progress } from "./components/ui/progress.js";
Uploading photos
Preparing the export
The example, progress.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Progress } from "./progress.js";
const styles = stylex.create({
stack: {
display : "grid",
gap : ufTokens.space2,
maxWidth : "24rem",
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
},
});
/** An upload that knows how far it has got, and an export that does not yet. */
export component Example() {
return (
<div {...props(styles.stack)}>
<span id="upload-progress">Uploading photos</span>
<Progress aria-labelledby="upload-progress" max={10} value={3} valueText="3 of 10 photos" />
<span id="export-progress">Preparing the export</span>
<Progress aria-labelledby="export-progress" />
</div>
);
}
The component, progress.js
// @flow
//
// Progress: a bar that says how far a task has got, and says nothing about an
// amount it does not know.
//
// `uf ui add progress` wrote this file into the project, and it is the
// project's from then on. `uf ui diff progress` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The track and the fill. `@uniflowed/ui`'s `Progress` owns what a reader is told:
// `role="progressbar"`, `aria-valuemin`, `aria-valuemax`, and an
// `aria-valuenow` that is left off, rather than set to zero, while the amount
// is unknown — zero would say that nothing has happened. The fill is drawn from
// the same `value`, `min` and `max`, through the `--uf-progress` property this
// file sets, so the bar and the announcement cannot disagree.
//
// # What to keep true when you change it
//
// * **Name it.** `aria-label`, or `aria-labelledby` pointing at the words
// beside it; a progress bar with no name is announced as a number.
// * **Say the value in words when a number is not the answer.** `valueText`,
// such as "3 of 10 photos", is what a reader hears instead of "30".
// * **An unknown amount looks unknown.** The whole track turns `accentSoft`
// and no fill is drawn, so it cannot be mistaken for a bar at zero.
// * **The fill is `accent` on `sunken`,** a boundary rather than text, and it
// stops easing for a reader who asked for reduced motion.
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Progress as ProgressPart } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
track: {
position : "relative",
display : "block",
width : "100%",
height : "8px",
overflow : "hidden",
borderRadius : ufTokens.radiusSm,
backgroundColor: { default: ufTokens.sunken, ":not([aria-valuenow])": ufTokens.accentSoft },
},
// Full width, scaled along the inline axis to the fraction done: moving a
// transform repaints the bar, where moving `width` laid it out again on
// every frame.
fill: {
display : "block",
width : "100%",
height : "100%",
borderRadius : ufTokens.radiusSm,
backgroundColor: ufTokens.accent,
transform : "scaleX(var(--uf-progress, 0))",
transformOrigin: { default: "left", ":dir(rtl)": "right" },
// `durationSlow` on the standard curve: a step of progress is seen to
// fill in rather than jump, and a run of quick updates still reads as one
// steady movement. Under reduced motion the bar is set, not moved.
transitionProperty: { default: "transform", "@media (prefers-reduced-motion: reduce)": "none" },
transitionDuration: ufTokens.durationSlow,
transitionTimingFunction: ufTokens.easing,
},
});
/** A progress bar. Leave `value` off while the amount is unknown. */
export component Progress(
value? : number | null = null,
min? : number = 0,
max? : number = 100,
valueText?: string,
style? : { readonly [string]: mixed },
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders ProgressPart {
const fraction =
value == null || max <= min ? null : Math.min(1, Math.max(0, (value - min) / (max - min)));
return (
<ProgressPart
{...forwarded(rest)}
className={classNames(props(styles.track, xstyle).className, className)}
max={max}
min={min}
style={fraction == null ? style : { ...style, "--uf-progress": String(fraction) }}
value={value}
valueText={valueText}
>
<span {...props(styles.fill)} />
</ProgressPart>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
radio-group
one answer out of several, drawn as a list of circles beside their labels.
uf ui add radio-group
import * as RadioGroup from "./components/ui/radio-group.js";
The example, radio-group.example.js
// @flow
import * as React from "@uniflowed/react";
import * as RadioGroup from "./radio-group.js";
/** A plan, one of which is chosen and one of which is unavailable. */
export component Example() {
return (
<RadioGroup.Root aria-label="Plan" defaultValue="team" name="plan">
<RadioGroup.Item value="personal">Personal</RadioGroup.Item>
<RadioGroup.Item value="team">Team</RadioGroup.Item>
<RadioGroup.Item disabled value="enterprise">
Enterprise
</RadioGroup.Item>
</RadioGroup.Root>
);
}
The component, radio-group.js
// @flow
//
// Radio group: one answer out of several, drawn as a list of circles beside
// their labels.
//
// `uf ui add radio-group` wrote this file into the project, and it is the
// project's from then on. `uf ui diff radio-group` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The circles, the dot in the chosen one, and the layout of the list.
// `@uniflowed/ui`'s `RadioGroup` owns the pattern: `role="radiogroup"`, radios
// with `aria-checked`, one tab stop for the whole group — the chosen answer, or
// the first while there is none — arrow keys that check as they move, and the
// hidden input a form submits under `name`. The dot follows `aria-checked`
// through `:is([aria-checked=true])`.
//
// # What to keep true when you change it
//
// * **Name the group.** `aria-label` on `RadioGroup`, or `aria-labelledby`
// pointing at the question it answers.
// * **Each answer's words are its name.** Put them inside `RadioGroup.Item`.
// * **The circle is an edge a reader can see.** A `muted` ring on `surface`,
// and an `accent` ring and dot when chosen; `muted` on `surface` is a pair
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
// * **A disabled answer stays in the list,** announced as unavailable and
// passed over by the arrow keys.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { RadioGroup } from "@uniflowed/ui";
/** Which way the answers are listed, and which arrow keys move between them. */
export type RadioGroupOrientation = "horizontal" | "vertical";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display: "flex",
flexDirection: { default: "column", ":is([aria-orientation=horizontal])": "row" },
flexWrap: "wrap",
alignItems: "flex-start",
gap: { default: ufTokens.space1, ":is([aria-orientation=horizontal])": ufTokens.space4 },
},
item: {
// Read by the circle and the dot, which cannot see the button's state.
"--uf-radio-ring": { default: ufTokens.muted, ":is([aria-checked=true])": ufTokens.accent },
"--uf-radio-dot" : { default: "0", ":is([aria-checked=true])": "1" },
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space2,
minHeight : "32px",
margin : 0,
padding : 0,
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
backgroundColor : "transparent",
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingTight,
textAlign : "start",
cursor: {
default : "pointer",
":disabled" : "not-allowed",
":is([aria-disabled=true])": "not-allowed",
},
opacity : { default: 1, ":disabled": 0.55, ":is([aria-disabled=true])": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
// The focus ring draws itself outward from the edge (`outline-width`
// 0 → 2px) rather than blinking on, in `durationFast`: it is where a
// keyboard reader's eye is, so it should arrive, not flash.
transitionProperty: {
default : "outline-width",
"@media (prefers-reduced-motion: reduce)": "none",
},
transitionDuration : ufTokens.durationFast,
transitionTimingFunction: ufTokens.easing,
},
circle: {
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
flexShrink : 0,
boxSizing : "border-box",
width : ufTokens.sizeControl,
height : ufTokens.sizeControl,
borderWidth : "1px",
borderStyle : "solid",
borderColor : "var(--uf-radio-ring)",
borderRadius : ufTokens.radiusPill,
backgroundColor : ufTokens.surface,
transitionProperty : "border-color",
transitionDuration : ufTokens.durationFast,
transitionTimingFunction: ufTokens.easing,
},
dot: {
width : "8px",
height : "8px",
borderRadius : ufTokens.radiusPill,
backgroundColor: ufTokens.accent,
// Fades in rather than growing from nothing: a dot that scales up reads
// as a pop, and the change is the same without the motion.
opacity : "var(--uf-radio-dot)",
transitionProperty : "opacity",
transitionDuration : ufTokens.durationBase,
transitionTimingFunction: ufTokens.easingEnter,
},
});
/** The group. Uncontrolled from `defaultValue` unless `value` is given. */
component RadioGroupRoot(
children : React.Node,
defaultValue? : string | null = null,
value? : string | null,
onValueChange?: (value: string) => void,
orientation? : RadioGroupOrientation = "vertical",
name? : string,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders RadioGroup.Root {
return (
<RadioGroup.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
defaultValue={defaultValue}
name={name}
onValueChange={onValueChange}
orientation={orientation}
value={value}
>
{children}
</RadioGroup.Root>
);
}
/** One answer and its words. */
component RadioGroupItem(
value : string,
children? : React.Node,
disabled? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders RadioGroup.Item {
return (
<RadioGroup.Item
{...forwarded(rest)}
className={classNames(props(styles.item, xstyle).className, className)}
disabled={disabled}
value={value}
>
<span {...props(styles.circle)} aria-hidden="true">
<span {...props(styles.dot)} />
</span>
{children}
</RadioGroup.Item>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as RadioGroup from "./radio-group.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<RadioGroup.Root>`
* and `<RadioGroup.Item>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `RadioGroupRoot` rather than `Root`.
*/
export { RadioGroupRoot as Root, RadioGroupItem as Item };
range-calendar
an inclusive date range on the shared styled calendar.
uf ui add range-calendar
import { RangeCalendar } from "./components/ui/range-calendar.js";
September 2026
Sun
Mon
Tue
Wed
Thu
Fri
Sat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
The example, range-calendar.example.js
// @flow
import * as React from "@uniflowed/react";
import { RangeCalendar } from "./range-calendar.js";
/** A named, keyboard-operable example using the default presentation. */
export component Example() {
return (
<RangeCalendar
defaultValue={{ start: "2026-09-14", end: "2026-09-16" }}
locale="en-US"
today="2026-09-14"
/>
);
}
The component, range-calendar.js
"use client";
// @flow
// RangeCalendar: an inclusive date range on the shared styled calendar.
//
// `uf ui add range-calendar` copies this component into the project. The headless
// primitive owns semantics and input behavior; this file owns its presentation.
// Keep accessible names, focus rings and disabled/selected states when editing.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { RangeCalendar as HeadlessRangeCalendar } from "@uniflowed/ui";
type Rest = { readonly key?: empty, readonly [string]: mixed };
import * as Calendar from "./calendar.js";
const styles = stylex.create({
// The same frame as `Calendar`'s. A block, because the month buttons hang
// on the caption's line from a zero-height row; `calendar.js` says why.
root: {
display : "inline-block",
boxSizing : "border-box",
padding : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor: ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
},
});
export component RangeCalendar(
children? : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders HeadlessRangeCalendar.Root {
return (
<HeadlessRangeCalendar.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
>
{
match (children) {
null | undefined =>
<>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Next />
</Calendar.Header>
<Calendar.Month />
</>,
const given => given,
}
}
</HeadlessRangeCalendar.Root>
);
}
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
resizable
two panels side by side or stacked, with a handle between them that moves the boundary.
uf ui add resizable
import * as Resizable from "./components/ui/resizable.js";
Files
Editor
The example, resizable.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Resizable from "./resizable.js";
const styles = stylex.create({
frame: {
width : "100%",
maxWidth : "32rem",
height : "12rem",
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius: ufTokens.radiusMd,
overflow : "hidden",
},
pane: {
boxSizing : "border-box",
height : "100%",
padding : ufTokens.space4,
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
},
});
/** A file list and an editor, the list between a fifth and four fifths wide. */
export component Example() {
return (
<div {...props(styles.frame)}>
<Resizable.PanelGroup defaultValue={30} max={80} min={20}>
<Resizable.Panel primary>
<div {...props(styles.pane)}>Files</div>
</Resizable.Panel>
<Resizable.Handle label="Resize file list" />
<Resizable.Panel>
<div {...props(styles.pane)}>Editor</div>
</Resizable.Panel>
</Resizable.PanelGroup>
</div>
);
}
The component, resizable.js
"use client";
// @flow
//
// Resizable: two panels side by side or stacked, with a handle between them
// that moves the boundary.
//
// `uf ui add resizable` wrote this file into the project, and it is the
// project's from then on. `uf ui diff resizable` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The group's direction, the panels' overflow, and the handle's line, the area
// around it a pointer can land on, and its ring. How big each panel is comes
// from what `@uniflowed/ui`'s `Resizable` writes: `--uf-resizable-size` on each
// panel, a percentage of the group. The part owns what the handle is: a
// focusable `role="separator"` with `aria-valuenow` between `aria-valuemin` and
// `aria-valuemax`, `aria-controls` naming the primary panel, the keys that move
// it, and a drag.
//
// # What to keep true when you change it
//
// * **Name the handle.** `label` says what it resizes, such as "Resize
// sidebar", when a page has more than one.
// * **Keep the ring.** The handle is a tab stop, and its outline is how a
// sighted keyboard user finds it.
// * **Mark one panel `primary`.** It is the panel the value measures and the
// handle's `aria-controls` names; the other takes what is left.
// * **Keep the hit area.** The line is one pixel wide; the transparent area
// either side of it is what a pointer can actually grab.
import * as React from "@uniflowed/react";
import { createContext, useContext } from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Resizable } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
type Orientation = "horizontal" | "vertical";
/** Which way the group runs, for the handle, which draws its line across it. */
const OrientationContext = createContext<Orientation>("horizontal");
const styles = stylex.create({
group: {
display : "flex",
boxSizing: "border-box",
width : "100%",
height : "100%",
overflow : "hidden",
},
groupVertical: {
flexDirection: "column",
},
panel: {
flexBasis : "var(--uf-resizable-size, 50%)",
flexGrow : 1,
flexShrink: 1,
minWidth : 0,
minHeight : 0,
overflow : "auto",
},
// Nine pixels a pointer can grab, drawn as a one-pixel line: transparent
// borders either side of a one-pixel box whose background stops at them, and
// negative margins so the grab area overlaps the panels rather than pushing
// them apart.
handle: {
position : "relative",
zIndex : 1,
flexShrink : 0,
alignSelf : "stretch",
boxSizing : "content-box",
width : "1px",
marginInlineStart : "-4px",
marginInlineEnd : "-4px",
borderInlineStartWidth: "4px",
borderInlineEndWidth : "4px",
borderInlineStartStyle: "solid",
borderInlineEndStyle : "solid",
borderInlineStartColor: "transparent",
borderInlineEndColor : "transparent",
backgroundClip : "padding-box",
backgroundColor: {
default : ufTokens.border,
":hover" : ufTokens.accent,
":focus-visible" : ufTokens.accent,
":is([aria-disabled=true])": ufTokens.border,
},
cursor : { default: "col-resize", ":is([aria-disabled=true])": "default" },
touchAction : "none",
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "0",
},
handleVertical: {
width : "auto",
height : "1px",
marginInlineStart : 0,
marginInlineEnd : 0,
marginBlockStart : "-4px",
marginBlockEnd : "-4px",
borderInlineStartWidth: 0,
borderInlineEndWidth : 0,
borderBlockStartWidth : "4px",
borderBlockEndWidth : "4px",
borderBlockStartStyle : "solid",
borderBlockEndStyle : "solid",
borderBlockStartColor : "transparent",
borderBlockEndColor : "transparent",
cursor : { default: "row-resize", ":is([aria-disabled=true])": "default" },
},
});
/**
* The group. `value` is the primary panel's share, a percentage between `min`
* and `max`; `orientation` says whether the panels sit side by side or stacked.
*/
component ResizablePanelGroup(
children : React.Node,
value? : number,
defaultValue? : number = 50,
onValueChange?: (value: number) => void,
min? : number = 0,
max? : number = 100,
step? : number = 10,
orientation? : Orientation = "horizontal",
disabled? : boolean = false,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) {
const vertical = orientation === "vertical";
return (
<OrientationContext.Provider value={orientation}>
<Resizable.PanelGroup
{...forwarded(rest)}
className={classNames(
props(styles.group, vertical && styles.groupVertical, xstyle).className,
className,
)}
defaultValue={defaultValue}
disabled={disabled}
max={max}
min={min}
onValueChange={onValueChange}
orientation={orientation}
step={step}
value={value}
>
{children}
</Resizable.PanelGroup>
</OrientationContext.Provider>
);
}
/** One panel. The `primary` one is the panel the group's value measures. */
component ResizablePanel(
children : React.Node,
primary? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Resizable.Panel {
return (
<Resizable.Panel
{...forwarded(rest)}
className={classNames(props(styles.panel, xstyle).className, className)}
primary={primary}
>
{children}
</Resizable.Panel>
);
}
/** The handle between the panels. */
component ResizableHandle(
label? : string = "Resize",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Resizable.Handle {
const vertical = useContext(OrientationContext) === "vertical";
return (
<Resizable.Handle
{...forwarded(rest)}
className={classNames(
props(styles.handle, vertical && styles.handleVertical, xstyle).className,
className,
)}
label={label}
/>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Resizable from "./resizable.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Resizable.PanelGroup>`
* and `<Resizable.Panel>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `ResizablePanelGroup` rather than `PanelGroup`.
*/
export { ResizablePanelGroup as PanelGroup, ResizablePanel as Panel, ResizableHandle as Handle };
scroll-area
a box that scrolls what it holds, with thin scrollbars drawn in the page's colours instead of the platform's.
uf ui add scroll-area
import { ScrollArea } from "./components/ui/scroll-area.js";
"use client";
// @flow
//
// Scroll area: a box that scrolls what it holds, with thin scrollbars drawn in
// the page's colours instead of the platform's.
//
// `uf ui add scroll-area` wrote this file into the project, and it is the
// project's from then on. `uf ui diff scroll-area` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The box, the tracks and the thumbs, and hiding the platform's own scrollbar
// inside the box. A thumb's length and position come from what
// `@uniflowed/ui`'s `ScrollArea` writes on its scrollbar: `--uf-scroll-thumb-size`
// and `--uf-scroll-thumb-offset` down the box, and the same names ending in
// `-x` across it, each a fraction of the track. The part owns the rest: a
// viewport that is a named `role="region"` and a tab stop, so the keyboard
// scrolls it, and scrollbars that are `aria-hidden`, because the viewport
// already scrolls for every reader.
//
// # What to keep true when you change it
//
// * **Name it.** `label` names the region a keyboard user tabs into.
// * **Keep the ring.** The viewport takes focus, and its outline is how a
// sighted keyboard user knows the arrow keys will scroll it.
// * **The drawn bars show; they do not scroll.** The wheel, a touch and the
// keyboard scroll the viewport. A thumb fades out while everything fits.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { ScrollArea as HeadlessScrollArea } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
position : "relative",
display : "flex",
flexDirection: "column",
boxSizing : "border-box",
overflow : "hidden",
},
viewport: {
flexGrow : 1,
minHeight : 0,
overflow : "auto",
scrollbarWidth: "none",
borderRadius : "inherit",
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "-2px",
},
scrollbar: {
position : "absolute",
borderRadius : ufTokens.radiusPill,
pointerEvents: "none",
},
scrollbarVertical: {
insetBlockStart: "2px",
insetBlockEnd : "2px",
insetInlineEnd : "2px",
width : "6px",
},
scrollbarHorizontal: {
insetInlineStart: "2px",
insetInlineEnd : "2px",
insetBlockEnd : "2px",
height : "6px",
},
thumb: {
position : "absolute",
borderRadius : "inherit",
backgroundColor: ufTokens.muted,
},
// Gone while the thumb is the whole track, which is when nothing overflows.
thumbVertical: {
insetInlineStart: 0,
insetInlineEnd : 0,
insetBlockStart : "calc(var(--uf-scroll-thumb-offset, 0) * 100%)",
height : "calc(var(--uf-scroll-thumb-size, 1) * 100%)",
opacity : "calc((1 - var(--uf-scroll-thumb-size, 1)) * 1000)",
},
thumbHorizontal: {
insetBlockStart : 0,
insetBlockEnd : 0,
insetInlineStart: "calc(var(--uf-scroll-thumb-offset-x, 0) * 100%)",
width : "calc(var(--uf-scroll-thumb-size-x, 1) * 100%)",
opacity : "calc((1 - var(--uf-scroll-thumb-size-x, 1)) * 1000)",
},
});
/**
* The box. Give it a height through `xstyle`; `orientation` says which way
* its content overflows, and so which scrollbars it draws.
*/
export component ScrollArea(
children : React.Node,
label : string,
orientation?: "vertical" | "horizontal" | "both" = "vertical",
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders HeadlessScrollArea.Root {
const down = orientation !== "horizontal";
const across = orientation !== "vertical";
return (
<HeadlessScrollArea.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
label={label}
>
<HeadlessScrollArea.Viewport className={props(styles.viewport).className}>
{children}
</HeadlessScrollArea.Viewport>
{
match (down) {
true =>
<HeadlessScrollArea.Scrollbar
className={props(styles.scrollbar, styles.scrollbarVertical).className}
orientation="vertical"
>
<div {...props(styles.thumb, styles.thumbVertical)} />
</HeadlessScrollArea.Scrollbar>,
false => null,
}
}
{
match (across) {
true =>
<HeadlessScrollArea.Scrollbar
className={props(styles.scrollbar, styles.scrollbarHorizontal).className}
orientation="horizontal"
>
<div {...props(styles.thumb, styles.thumbHorizontal)} />
</HeadlessScrollArea.Scrollbar>,
false => null,
}
}
</HeadlessScrollArea.Root>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
select
@uniflowed/ui's select-only combobox, drawn as a field and a list.
uf ui add select
import * as Select from "./components/ui/select.js";
The example, select.example.js
// @flow
import * as React from "@uniflowed/react";
import * as Select from "./select.js";
/** A country, in two named groups, with one option unavailable. */
export component Example() {
return (
<Select.Root name="country">
<Select.Label>Country</Select.Label>
<Select.Trigger>
<Select.Value placeholder="Choose a country" />
</Select.Trigger>
<Select.List>
<Select.Group>
<Select.GroupLabel>Europe</Select.GroupLabel>
<Select.Option value="fr">France</Select.Option>
<Select.Option value="de">Germany</Select.Option>
<Select.Option value="gb">United Kingdom</Select.Option>
</Select.Group>
<Select.Separator />
<Select.Group>
<Select.GroupLabel>Asia</Select.GroupLabel>
<Select.Option value="jp">Japan</Select.Option>
<Select.Option disabled value="kr">
South Korea
</Select.Option>
</Select.Group>
</Select.List>
</Select.Root>
);
}
The component, select.js
"use client";
// @flow
//
// Select: `@uniflowed/ui`'s select-only combobox, drawn as a field and a list.
//
// `uf ui add select` wrote this file into the project, and it is the project's
// from then on. `uf ui diff select` shows how it has moved away from the
// registry in the uf you are running.
//
// # Use a native `<select>` when one will do
//
// `@uniflowed/ui`'s `Select` says it first and means it: the platform's control is
// announced correctly by software nobody here has tested against, is a wheel
// on a phone, autofills and validates. This component is for the list a native
// select cannot draw — an option with an icon, a second line or a check.
//
// # What this file owns, and what it does not
//
// The look: the trigger drawn as a field with a chevron, the list as a panel
// under it, the highlight on the option the keyboard is on, the check beside
// the chosen one. The behaviour is imported: focus that never leaves the
// trigger, `aria-activedescendant` moving over the options, typeahead, `Home`
// and `End`, an `Escape` that changes nothing, a `Tab` that takes the option
// under the cursor, the hidden input a form submits, and the list kept against
// its trigger as the page scrolls.
//
// Both states the list draws are attributes the part writes. The option under
// the cursor says `data-active="true"`, and no pseudo-class can find it,
// because focus stays on the trigger — so the highlight is the only focus
// indicator a keyboard user has inside the list, and it is drawn from that
// attribute. The chosen option says `aria-selected="true"`, and shows its check
// through a custom property it sets for the icon inside it.
//
// # What to keep true when you change it
//
// * **Label it.** `Select.Label` names the trigger through `aria-labelledby`,
// and a select with no label is a combobox with no name. A design with no
// visible label passes `aria-label` to `Select.Trigger`.
// * **Draw the cursor.** However you restyle an option, `data-active` needs a
// difference a sighted keyboard user can see. Here the background and the
// text both change, `accent` on `accentSoft`, a pair
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both shipped
// themes.
// * **The chosen option is marked twice.** A check and a heavier weight, so the
// choice is not carried by colour alone.
// * **The list is as wide as its trigger and no taller than its room.** The
// part writes `--uf-anchor-trigger-width` and `--uf-anchor-available-height`
// on the list, and those are what size it.
// * **The icons are decoration.** The chevron and the check are `aria-hidden`:
// the trigger's text and `aria-selected` already say what they draw.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { Align, LogicalSide } from "@uniflowed/ui";
import { Select } from "@uniflowed/ui";
/**
* Every prop a caller passes that this file does not name, on its way to the
* part underneath. See `forwarded` for the one thing Flow cannot say about it.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "grid",
gap : ufTokens.space2,
justifyItems: "start",
fontFamily : ufTokens.fontSans,
color : ufTokens.ink,
},
label: {
fontSize : ufTokens.textSm,
fontWeight: ufTokens.weightMedium,
lineHeight: ufTokens.leadingTight,
},
trigger: {
// Read by the chevron inside, which cannot see this element's state.
"--uf-select-chevron-turn": { default: "0deg", ":is([aria-expanded=true])": "180deg" },
display : "inline-flex",
alignItems : "center",
justifyContent : "space-between",
gap : ufTokens.space2,
boxSizing : "border-box",
minWidth : "12rem",
minHeight : "36px",
margin : 0,
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingTight,
textAlign : "start",
color : ufTokens.ink,
backgroundColor: {
default : ufTokens.surface,
":hover" : ufTokens.surfaceHover,
":disabled": ufTokens.surface,
},
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-expanded=true])": ufTokens.accent },
borderRadius : ufTokens.radiusMd,
cursor : { default: "pointer", ":disabled": "not-allowed" },
opacity : { default: 1, ":disabled": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "1px",
},
chevron: {
flexShrink: 0,
color : ufTokens.muted,
transform : "rotate(var(--uf-select-chevron-turn))",
// A half turn is travel, so it takes `durationBase`; under reduced
// motion the chevron is simply the other way up.
transitionProperty: { default: "transform", "@media (prefers-reduced-motion: reduce)": "none" },
transitionDuration: ufTokens.durationBase,
transitionTimingFunction: ufTokens.easing,
},
list: {
zIndex : 50,
boxSizing : "border-box",
minWidth : "var(--uf-anchor-trigger-width)",
maxHeight : "min(20rem, var(--uf-anchor-available-height, 20rem))",
overflowY : "auto",
margin : 0,
padding : ufTokens.space1,
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
// Enter: it fades in while travelling 4px out of its trigger, from the
// side `data-side` says it opened on, so the eye is led from the button
// to what it opened. `durationBase` on the decelerating curve: most of
// the distance is covered at once, so it is legible before it has
// settled. Under reduced motion it only fades.
"--uf-enter-x": {
default : "0px",
":is([data-side=left])" : "4px",
":is([data-side=right])": "-4px",
},
"--uf-enter-y": {
default : "0px",
":is([data-side=top])" : "4px",
":is([data-side=bottom])": "-4px",
},
// Exit: back towards the trigger it came from, on the accelerating curve
// and in `durationFast` against the entrance's `durationBase`, so it gets
// out of the way rather than lingering. `@uniflowed/ui` keeps it on the
// page, closed and `inert`, until this has finished. Under reduced motion
// it only fades: `--uf-exit-travel` is 0 there, so nothing jumps either.
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style": "translate(var(--uf-enter-x), var(--uf-enter-y))",
":is([data-state=closed])":
"translate(calc(var(--uf-enter-x) * var(--uf-exit-travel)), calc(var(--uf-enter-y) * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
option: {
// Read by the check inside, which cannot see this element's state.
"--uf-select-check": { default: "0", ":is([aria-selected=true])": "1" },
display : "flex",
alignItems : "center",
justifyContent : "space-between",
gap : ufTokens.space3,
minHeight : "32px",
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space2,
borderRadius : ufTokens.radiusSm,
lineHeight : ufTokens.leadingTight,
cursor : "pointer",
userSelect : "none",
color : { default: ufTokens.ink, ":is([data-active=true])": ufTokens.accent },
backgroundColor : { default: "transparent", ":is([data-active=true])": ufTokens.accentSoft },
fontWeight: {
default : ufTokens.weightRegular,
":is([aria-selected=true])": ufTokens.weightMedium,
},
},
// Merged after `option`, so it replaces the highlight a disabled option
// would otherwise still be drawn with.
optionDisabled: {
color : ufTokens.muted,
backgroundColor: "transparent",
cursor : "not-allowed",
},
optionText: {
minWidth: 0,
},
check: {
flexShrink: 0,
color : ufTokens.accent,
opacity : "var(--uf-select-check)",
},
group: {
display : "grid",
paddingBlock: ufTokens.space1,
},
groupLabel: {
paddingBlock : ufTokens.space1,
paddingInline: ufTokens.space2,
fontSize : ufTokens.textXs,
fontWeight : ufTokens.weightMedium,
color : ufTokens.muted,
},
separator: {
height : "1px",
marginBlock : ufTokens.space1,
backgroundColor: ufTokens.border,
},
});
/**
* The select. Uncontrolled from `defaultValue` unless `value` is given, and
* submitted with a form under `name`.
*
* <Select name="country">
* <SelectLabel>Country</SelectLabel>
* <SelectTrigger>
* <SelectValue placeholder="Choose a country" />
* </SelectTrigger>
* <SelectList>
* <SelectOption value="fr">France</SelectOption>
* <SelectOption value="jp">Japan</SelectOption>
* </SelectList>
* </Select>
*/
component SelectRoot(
children : React.Node,
value? : string | null,
defaultValue? : string | null = null,
onValueChange?: (value: string | null) => void,
open? : boolean,
defaultOpen? : boolean = false,
onOpenChange? : (open: boolean) => void,
name? : string,
disabled? : boolean = false,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Select.Root {
return (
<Select.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
defaultOpen={defaultOpen}
defaultValue={defaultValue}
disabled={disabled}
name={name}
onOpenChange={onOpenChange}
onValueChange={onValueChange}
open={open}
value={value}
>
{children}
</Select.Root>
);
}
/** The field's name, which is also the trigger's accessible name. */
component SelectLabel(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Select.Label {
return (
<Select.Label
{...forwarded(rest)}
className={classNames(props(styles.label, xstyle).className, className)}
>
{children}
</Select.Label>
);
}
/** The button that opens the list, drawn as a field with a chevron. */
component SelectTrigger(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Select.Trigger {
return (
<Select.Trigger
{...forwarded(rest)}
className={classNames(props(styles.trigger, xstyle).className, className)}
>
{children}
<ChevronIcon />
</Select.Trigger>
);
}
/** What the trigger says: the chosen option, or the placeholder. */
component SelectValue(
placeholder?: React.Node,
children? : React.Node,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Select.Value {
return (
<Select.Value
{...forwarded(rest)}
className={classNames(props(xstyle).className, className)}
placeholder={placeholder}
>
{children}
</Select.Value>
);
}
/** The list of options, under the trigger and as wide as it. */
component SelectList(
children : renders* (SelectOption | SelectGroup | SelectSeparator),
align? : Align = "start",
alignOffset? : number = 0,
avoidCollisions? : boolean = true,
collisionPadding?: number = 8,
side? : LogicalSide = "bottom",
sideOffset? : number = 4,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Select.List {
return (
<Select.List
{...forwarded(rest)}
align={align}
alignOffset={alignOffset}
avoidCollisions={avoidCollisions}
className={classNames(props(styles.list, xstyle).className, className)}
collisionPadding={collisionPadding}
side={side}
sideOffset={sideOffset}
>
{children}
</Select.List>
);
}
/** One option, with the check that shows when it is the chosen one. */
component SelectOption(
value : string,
children : React.Node,
label? : string,
disabled? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Select.Option {
const styled = props(styles.option, disabled && styles.optionDisabled, xstyle);
return (
<Select.Option
{...forwarded(rest)}
className={classNames(styled.className, className)}
disabled={disabled}
label={label}
value={value}
>
<span {...props(styles.optionText)}>{children}</span>
<CheckIcon />
</Select.Option>
);
}
/** A named group of options. */
component SelectGroup(
children : renders* (SelectOption | SelectGroupLabel),
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Select.Group {
return (
<Select.Group
{...forwarded(rest)}
className={classNames(props(styles.group, xstyle).className, className)}
>
{children}
</Select.Group>
);
}
/** The heading that names a group, which the arrow keys pass over. */
component SelectGroupLabel(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Select.GroupLabel {
return (
<Select.GroupLabel
{...forwarded(rest)}
className={classNames(props(styles.groupLabel, xstyle).className, className)}
>
{children}
</Select.GroupLabel>
);
}
/** A rule between groups: decoration, and out of the accessibility tree. */
component SelectSeparator(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Select.Separator {
return (
<Select.Separator
{...forwarded(rest)}
className={classNames(props(styles.separator, xstyle).className, className)}
/>
);
}
/** The chevron, which turns while the list is open. */
component ChevronIcon() {
return (
<svg
{...props(styles.chevron)}
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
/** The check beside the chosen option. */
component CheckIcon() {
return (
<svg
{...props(styles.check)}
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
/**
* A caller's props on their way into a part, rather than onto an element.
*
* Flow checks a spread into a component against that component's own
* `...rest`, `key` included, and the indexer in `Rest` answers `mixed` where
* the part says `empty`. `@uniflowed/ui` meets the same hole between its own
* parts, and papers over it the same way in one named place,
* `internal/merge-props.js`'s `forwarded`: nothing that was ever checked is
* lost, because every element those parts render has `any`-typed props in uf's
* library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. See `button.js`. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Select from "./select.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Select.Root>`
* and `<Select.Label>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `SelectRoot` rather than `Root`.
*/
export {
SelectRoot as Root,
SelectLabel as Label,
SelectTrigger as Trigger,
SelectValue as Value,
SelectList as List,
SelectOption as Option,
SelectGroup as Group,
SelectGroupLabel as GroupLabel,
SelectSeparator as Separator,
};
separator
a line between groups of content, announced when it marks a boundary and silent when it is only a line.
uf ui add separator
import { Separator } from "./components/ui/separator.js";
Your name, your email and your password.
The example, separator.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Separator } from "./separator.js";
const styles = stylex.create({
text: {
margin : 0,
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
},
row: {
display : "flex",
alignItems: "center",
height : "24px",
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
},
link: {
color: ufTokens.ink,
},
});
/** A boundary between two sections, and decorative rules between links. */
export component Example() {
return (
<div>
<p {...props(styles.text)}>Your name, your email and your password.</p>
<Separator />
<nav {...props(styles.row)} aria-label="Account">
<a {...props(styles.link)} href="?section=profile">
Profile
</a>
<Separator decorative orientation="vertical" />
<a {...props(styles.link)} href="?section=billing">
Billing
</a>
</nav>
</div>
);
}
The component, separator.js
// @flow
//
// Separator: a line between groups of content, announced when it marks a
// boundary and silent when it is only a line.
//
// `uf ui add separator` wrote this file into the project, and it is the
// project's from then on. `uf ui diff separator` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The line: its colour, thickness, length and the space around it.
// `@uniflowed/ui`'s `Separator` owns the one decision in it: `role="separator"`
// with an `aria-orientation` for a boundary a reader should be told about, and
// `aria-hidden` for a rule that is only decoration.
//
// # What to keep true when you change it
//
// * **Say which it is.** `decorative` for a rule that is only visual. Left off,
// a reader is told the content changes here.
// * **A vertical separator takes its height from its row.** It stretches to the
// flex row it sits in, and a row with no height draws nothing.
// * **It carries no text,** so `border` has no contrast pair to keep; a line a
// reader needs in order to understand the page is not decorative.
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Separator as SeparatorPart } from "@uniflowed/ui";
/** Which way the line runs. */
export type SeparatorOrientation = "horizontal" | "vertical";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
base: {
flexShrink : 0,
margin : 0,
borderWidth : 0,
backgroundColor: ufTokens.border,
},
horizontal: {
width : "100%",
height : "1px",
marginBlock: ufTokens.space4,
},
vertical: {
width : "1px",
alignSelf : "stretch",
marginInline: ufTokens.space2,
},
});
/** A line between groups of content. */
export component Separator(
decorative? : boolean = false,
orientation?: SeparatorOrientation = "horizontal",
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders SeparatorPart {
const styled = props(
styles.base,
match (orientation) {
"horizontal" => styles.horizontal,
"vertical" => styles.vertical,
},
xstyle,
);
return (
<SeparatorPart
{...forwarded(rest)}
className={classNames(styled.className, className)}
decorative={decorative}
orientation={orientation}
/>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
sheet
a modal panel against one edge of the screen, for the settings or filters a page slides in beside itself.
uf ui add sheet
import * as Sheet from "./components/ui/sheet.js";
The example, sheet.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Sheet from "./sheet.js";
const styles = stylex.create({
option: {
display : "flex",
alignItems: "center",
gap : ufTokens.space2,
minHeight : "32px",
},
box: {
width : "16px",
height : "16px",
margin : 0,
accentColor: ufTokens.accent,
},
});
/** A panel of filters, against the right edge. */
export component Example() {
return (
<Sheet.Root side="right">
<Sheet.Trigger>Filters</Sheet.Trigger>
<Sheet.Content>
<Sheet.Header>
<Sheet.Title>Filters</Sheet.Title>
<Sheet.Description>Narrow the list to what you are looking for.</Sheet.Description>
</Sheet.Header>
<div {...props(styles.option)}>
<input {...props(styles.box)} id="filter-open" name="open" type="checkbox" />
<label htmlFor="filter-open">Only open issues</label>
</div>
<div {...props(styles.option)}>
<input {...props(styles.box)} id="filter-mine" name="mine" type="checkbox" />
<label htmlFor="filter-mine">Only mine</label>
</div>
<Sheet.Footer>
<Sheet.Close tone="primary">Show results</Sheet.Close>
</Sheet.Footer>
</Sheet.Content>
</Sheet.Root>
);
}
The component, sheet.js
"use client";
// @flow
//
// Sheet: a modal panel against one edge of the screen, for the settings or
// filters a page slides in beside itself.
//
// `uf ui add sheet` wrote this file into the project, and it is the project's
// from then on. `uf ui diff sheet` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The look: the scrim, a panel attached to `side`, the close button in its
// corner. The behaviour is imported from `@uniflowed/ui`'s `Sheet`, which is
// `@uniflowed/ui`'s `Dialog` with an edge: focus moved in and kept in, `Escape`,
// the press outside, focus given back, the page behind made inert and held
// still. The part writes the edge as `data-side` on the panel, and every
// position below is drawn from that attribute through
// `:is([data-side=left])`, so the panel and the part cannot come to disagree
// about which edge "left" is.
//
// # What to keep true when you change it
//
// * **`side` is physical.** `left` is the left of the screen in every writing
// direction, because a design that puts a panel against the left edge means
// that edge; what the writing direction changes is the reading order inside.
// * **A sheet needs a name.** `Sheet.Title` is what `aria-labelledby` points
// at. A design with no visible title passes `aria-label` to `Sheet.Content`.
// * **The close button is last in the markup and first on the screen.** Focus
// moves to the first thing worth acting on, which should be the sheet's own
// content rather than the corner `×`. Its name is `closeLabel`.
// * **It scrolls inside, and it never covers everything.** A side sheet is
// capped at 24rem and a top or bottom one at 32rem, each less a margin, so
// part of the page stays visible behind the scrim and a reader can see they
// are still on it.
// * **Colour comes from tokens, in measured pairs.** `ink` and `muted` on
// `surface`, which `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in
// the light default and the dark theme.
// * **It slides in from its edge, and back out to it.** The panel travels its
// own size from `data-side` over `durationSlow` on the decelerating curve,
// from a `@starting-style`, and the scrim fades in with it. Closing is the
// same way back, shorter (`durationBase`) and on the accelerating curve:
// `@uniflowed/ui` keeps the closing panel on the page, `data-state="closed"`
// and `inert`, until it has left, while focus and the page's scroll are
// already back. Under reduced motion the panel fades in and out instead.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { Edge } from "@uniflowed/ui";
import { Sheet } from "@uniflowed/ui";
import type { ButtonSize, ButtonTone } from "./button.js";
import { Button } from "./button.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* part underneath. See `forwarded` for the one thing Flow cannot say about it.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** A caller's own element in place of the one a part renders. */
type RenderProp = (props: Rest) => React.Node;
const styles = stylex.create({
overlay: {
position : "fixed",
inset : 0,
zIndex : 50,
backgroundColor: ufTokens.scrim,
// Enter: the page dims as the panel arrives, over the panel's duration,
// rather than going dark first and then showing a dialog. Opacity only, so
// it is the same under reduced motion.
// Exit: it clears with the panel, in the panel's shorter exit time.
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transitionProperty: "opacity",
transitionDuration: {
default : ufTokens.durationSlow,
":is([data-state=closed])": ufTokens.durationBase,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
panel: {
position : "fixed",
zIndex : 50,
boxSizing : "border-box",
display : "flex",
flexDirection : "column",
gap : ufTokens.space4,
overflowY : "auto",
padding : ufTokens.space6,
backgroundColor: ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
// Attached to every edge except the one opposite `side`, which is what
// makes a side sheet as tall as the screen and a top one as wide as it.
top : { default: 0, ":is([data-side=bottom])": "auto" },
bottom: { default: 0, ":is([data-side=top])": "auto" },
left : { default: 0, ":is([data-side=right])": "auto" },
right : { default: 0, ":is([data-side=left])": "auto" },
// Literals rather than shared constants: StyleX reads a value at compile
// time, and a name it would have to resolve is refused.
width: {
default : "auto",
":is([data-side=left])" : "min(24rem, calc(100% - 48px))",
":is([data-side=right])": "min(24rem, calc(100% - 48px))",
},
maxHeight: {
default : "none",
":is([data-side=top])" : "min(32rem, calc(100% - 48px))",
":is([data-side=bottom])": "min(32rem, calc(100% - 48px))",
},
// A line on the edge that meets the page, which is what sets the panel
// apart from it: there is no shadow.
borderWidth : 0,
borderStyle : "solid",
borderColor : ufTokens.border,
borderLeftWidth : { default: 0, ":is([data-side=right])": "1px" },
borderRightWidth : { default: 0, ":is([data-side=left])": "1px" },
borderTopWidth : { default: 0, ":is([data-side=bottom])": "1px" },
borderBottomWidth : { default: 0, ":is([data-side=top])": "1px" },
borderTopLeftRadius : { default: 0, ":is([data-side=bottom])": ufTokens.radiusLg },
borderTopRightRadius : { default: 0, ":is([data-side=bottom])": ufTokens.radiusLg },
borderBottomLeftRadius : { default: 0, ":is([data-side=top])": ufTokens.radiusLg },
borderBottomRightRadius: { default: 0, ":is([data-side=top])": ufTokens.radiusLg },
// The panel takes focus itself when it holds nothing focusable.
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "-2px",
// Enter: it slides in from the edge it is attached to, the whole of its
// own size, on the decelerating curve over `durationSlow`: it is fast
// off the edge and settles against the page. Under reduced motion it does
// not travel; it fades in instead (`--uf-enter-opacity`).
"--uf-enter-x": {
default : "0px",
":is([data-side=left])" : "-100%",
":is([data-side=right])": "100%",
},
"--uf-enter-y": {
default : "0px",
":is([data-side=top])" : "-100%",
":is([data-side=bottom])": "100%",
},
//
// Exit: back off the same edge, in `durationBase` on the accelerating
// curve, while `@uniflowed/ui` keeps the closing panel on the page and
// `inert`. Under reduced motion it fades out instead: it does not travel
// (`--uf-exit-travel` is 0) and its opacity goes to `--uf-enter-opacity`.
"--uf-enter-opacity": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
"--uf-exit-travel" : { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity: {
default : 1,
"@starting-style" : "var(--uf-enter-opacity)",
":is([data-state=closed])": "var(--uf-enter-opacity)",
},
transform: {
default : "none",
"@starting-style": "translate(var(--uf-enter-x), var(--uf-enter-y))",
":is([data-state=closed])":
"translate(calc(var(--uf-enter-x) * var(--uf-exit-travel)), calc(var(--uf-enter-y) * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationSlow,
":is([data-state=closed])": ufTokens.durationBase,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
header: {
display: "grid",
gap : ufTokens.space2,
// Room for the close button, which is drawn over this corner.
paddingInlineEnd: ufTokens.space8,
},
footer: {
display : "flex",
flexWrap : "wrap",
justifyContent: "flex-end",
gap : ufTokens.space2,
marginTop : "auto",
},
title: {
margin : 0,
fontSize : ufTokens.textLg,
fontWeight: ufTokens.weightBold,
lineHeight: ufTokens.leadingTight,
},
description: {
margin: 0,
color : ufTokens.muted,
},
close: {
position : "absolute",
top : ufTokens.space4,
insetInlineEnd : ufTokens.space4,
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
width : "32px",
height : "32px",
padding : 0,
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
backgroundColor: { default: "transparent", ":hover": ufTokens.surfaceHover },
color : { default: ufTokens.muted, ":hover": ufTokens.ink },
cursor : "pointer",
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
});
/** The sheet, open or closed, against `side`. Uncontrolled unless `open` is given. */
component SheetRoot(
children : React.Node,
side? : Edge = "right",
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange?: (open: boolean) => void,
) renders Sheet.Root {
return (
<Sheet.Root defaultOpen={defaultOpen} onOpenChange={onOpenChange} open={open} side={side}>
{children}
</Sheet.Root>
);
}
/** The button that opens the sheet. It is a `Button` unless `render` says otherwise. */
component SheetTrigger(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Sheet.Trigger {
return (
<Sheet.Trigger
{...forwarded(rest)}
render={
match (render) {
undefined =>
(trigger) => (
<Button
{...forwarded(trigger)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
),
const custom => custom,
}
}
>
{children}
</Sheet.Trigger>
);
}
/**
* The scrim and the panel, with a close button in the corner. Everything it
* does not name reaches `Sheet.Body`, `aria-label` included.
*/
component SheetContent(
children : React.Node,
closeLabel?: string = "Close",
hideClose? : boolean = false,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) {
return (
<>
<Sheet.Overlay className={props(styles.overlay).className} />
<Sheet.Body
{...forwarded(rest)}
className={classNames(props(styles.panel, xstyle).className, className)}
>
{children}
{
match (hideClose) {
true => null,
false =>
<Sheet.Close aria-label={closeLabel} className={props(styles.close).className}>
<CloseIcon />
</Sheet.Close>,
}
}
</Sheet.Body>
</>
);
}
/** The title and description, stacked. */
component SheetHeader(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Sheet.Header {
return (
<Sheet.Header
{...forwarded(rest)}
className={classNames(props(styles.header, xstyle).className, className)}
>
{children}
</Sheet.Header>
);
}
/** The row the actions sit in, pushed to the far end of the panel. */
component SheetFooter(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Sheet.Footer {
return (
<Sheet.Footer
{...forwarded(rest)}
className={classNames(props(styles.footer, xstyle).className, className)}
>
{children}
</Sheet.Footer>
);
}
/** The sheet's name. */
component SheetTitle(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Sheet.Title {
return (
<Sheet.Title
{...forwarded(rest)}
className={classNames(props(styles.title, xstyle).className, className)}
>
{children}
</Sheet.Title>
);
}
/** What the sheet is for, read with its name when focus arrives. */
component SheetDescription(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Sheet.Description {
return (
<Sheet.Description
{...forwarded(rest)}
className={classNames(props(styles.description, xstyle).className, className)}
>
{children}
</Sheet.Description>
);
}
/** A button that closes the sheet: Done, Apply, or Cancel. */
component SheetClose(
children : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Sheet.Close {
return (
<Sheet.Close
{...forwarded(rest)}
render={
match (render) {
undefined =>
(close) => (
<Button
{...forwarded(close)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
),
const custom => custom,
}
}
>
{children}
</Sheet.Close>
);
}
/** The `×`, which is decoration: the button it sits in carries the name. */
component CloseIcon() {
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
/**
* A caller's props on their way into a part, rather than onto an element.
*
* Flow checks a spread into a component against that component's own
* `...rest`, `key` included, and the indexer in `Rest` answers `mixed` where
* the part says `empty`. `@uniflowed/ui` meets the same hole between its own
* parts, and papers over it the same way in one named place,
* `internal/merge-props.js`'s `forwarded`: nothing that was ever checked is
* lost, because every element those parts render has `any`-typed props in uf's
* library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. See `button.js`. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Sheet from "./sheet.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Sheet.Root>`
* and `<Sheet.Trigger>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `SheetRoot` rather than `Root`.
*/
export {
SheetRoot as Root,
SheetTrigger as Trigger,
SheetContent as Content,
SheetHeader as Header,
SheetFooter as Footer,
SheetTitle as Title,
SheetDescription as Description,
SheetClose as Close,
};
sidebar
a column of navigation beside the page, collapsible to icons on a wide screen and a sheet that slides in on a narrow one.
uf ui add sidebar
import * as Sidebar from "./components/ui/sidebar.js";
The example, sidebar.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Sidebar from "./sidebar.js";
const styles = stylex.create({
frame: {
height : "20rem",
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius: ufTokens.radiusMd,
overflow : "hidden",
},
main: {
flexGrow: 1,
padding : ufTokens.space4,
},
});
/** A dot standing in for an item's icon. */
component Dot() {
return (
<svg fill="currentColor" focusable="false" height="8" viewBox="0 0 8 8" width="8">
<circle cx="4" cy="4" r="4" />
</svg>
);
}
/** An app's main navigation beside its page, with a button that collapses it. */
export component Example() {
return (
<div {...props(styles.frame)}>
<Sidebar.Root>
<Sidebar.Content label="Main">
<Sidebar.Header>Acme</Sidebar.Header>
<Sidebar.Item aria-current="page" icon={<Dot />} label="Home">
Home
</Sidebar.Item>
<Sidebar.Item icon={<Dot />} label="Projects">
Projects
</Sidebar.Item>
<Sidebar.Item icon={<Dot />} label="Settings">
Settings
</Sidebar.Item>
<Sidebar.Footer>v1.30.0</Sidebar.Footer>
</Sidebar.Content>
<main {...props(styles.main)}>
<Sidebar.Trigger />
</main>
</Sidebar.Root>
</div>
);
}
The component, sidebar.js
"use client";
// @flow
//
// Sidebar: a column of navigation beside the page, collapsible to icons on a
// wide screen and a sheet that slides in on a narrow one.
//
// `uf ui add sidebar` wrote this file into the project, and it is the project's
// from then on. `uf ui diff sidebar` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The layout beside the page, the column's width open and collapsed, its
// header and footer, the items with their icons, and the button that opens and
// collapses it. `@uniflowed/ui`'s `Sidebar` owns the rest: a named `<nav>`, a
// button that says with `aria-expanded` whether the column is open and names
// what it controls, a sheet in place of the column on a narrow screen, and an
// item that keeps its name while collapsed to an icon, with a tooltip saying
// it. The column's width and an item's label are drawn from `data-collapsed`,
// and the current page's item from `aria-current`.
//
// # What to keep true when you change it
//
// * **Name the navigation.** `label` on `Sidebar.Content` says which navigation
// this is, such as "Main".
// * **Give every item a `label`.** It is the item's name while only its icon
// shows.
// * **Mark where the reader is.** `aria-current="page"` on the current item, so
// a reader hears it, not only sees it.
// * **Text stays on measured pairs.** `ink` on `surface`, and `accent` on
// `accentSoft` for the current item, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { SidebarSide } from "@uniflowed/ui";
import { Sidebar } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** A caller's own element in place of the one a part renders. */
type RenderProp = (props: Rest) => React.Node;
const styles = stylex.create({
layout: {
display : "flex",
boxSizing : "border-box",
minHeight : "100%",
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
},
content: {
display : "flex",
flexDirection : "column",
flexShrink : 0,
gap : ufTokens.space1,
boxSizing : "border-box",
width : { default: "16rem", ":is([data-collapsed=true])": "3.5rem" },
height : "100%",
padding : ufTokens.space2,
overflowY : "auto",
backgroundColor : ufTokens.surface,
borderInlineEndWidth: "1px",
borderInlineEndStyle: "solid",
borderInlineEndColor: ufTokens.border,
},
panel: {
position : "fixed",
zIndex : 50,
boxSizing : "border-box",
display : "flex",
flexDirection : "column",
top : 0,
bottom : 0,
width : "min(20rem, calc(100% - 48px))",
padding : 0,
overflowY : "auto",
backgroundColor : ufTokens.surface,
color : ufTokens.ink,
borderWidth : 0,
borderStyle : "solid",
borderColor : ufTokens.border,
borderLeftWidth : { default: 0, ":is([data-side=right])": "1px" },
borderRightWidth: { default: 0, ":is([data-side=left])": "1px" },
left : { default: 0, ":is([data-side=right])": "auto" },
right : { default: 0, ":is([data-side=left])": "auto" },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "-2px",
},
header: {
display : "flex",
alignItems : "center",
gap : ufTokens.space2,
minHeight : "40px",
paddingInline: ufTokens.space2,
fontWeight : ufTokens.weightMedium,
},
footer: {
marginBlockStart: "auto",
paddingInline : ufTokens.space2,
paddingBlock : ufTokens.space2,
},
item: {
// Read by the label inside, which cannot see whether the column is collapsed.
"--uf-sidebar-label": { default: "inline", ":is([data-collapsed=true])": "none" },
display : "flex",
alignItems : "center",
gap : ufTokens.space2,
boxSizing : "border-box",
width : "100%",
minHeight : "36px",
margin : 0,
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space2,
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingTight,
textAlign : "start",
textDecoration : "none",
cursor : "pointer",
color: {
default : ufTokens.ink,
":hover" : ufTokens.accent,
":is([aria-current=page])": ufTokens.accent,
},
backgroundColor: {
default : "transparent",
":hover" : ufTokens.accentSoft,
":is([aria-current=page])": ufTokens.accentSoft,
},
fontWeight: {
default : ufTokens.weightRegular,
":is([aria-current=page])": ufTokens.weightMedium,
},
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "-2px",
},
icon: {
display : "inline-flex",
flexShrink : 0,
width : "20px",
justifyContent: "center",
},
label: {
display : "var(--uf-sidebar-label, inline)",
overflow : "hidden",
whiteSpace : "nowrap",
textOverflow: "ellipsis",
},
trigger: {
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
width : "36px",
height : "36px",
margin : 0,
padding : 0,
color : { default: ufTokens.ink, ":hover": ufTokens.accent },
backgroundColor: { default: "transparent", ":hover": ufTokens.accentSoft },
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusSm,
cursor : "pointer",
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
tooltip: {
zIndex : 60,
boxSizing : "border-box",
maxWidth : "20rem",
margin : 0,
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space2,
backgroundColor: ufTokens.ink,
color : ufTokens.canvas,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textXs,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
overflowWrap : "break-word",
borderRadius : ufTokens.radiusSm,
},
});
/**
* The sidebar and the page beside it. Open unless `defaultOpen` or `open` says
* otherwise; `narrowQuery` is the media query under which it is a sheet.
*/
component SidebarRoot(
children : React.Node,
defaultOpen? : boolean = true,
open? : boolean,
onOpenChange?: (open: boolean) => void,
side? : SidebarSide = "left",
narrowQuery? : string,
xstyle? : StyleArgument,
className? : string,
) renders Sidebar.Root {
return (
<Sidebar.Root
defaultOpen={defaultOpen}
narrowQuery={narrowQuery}
onOpenChange={onOpenChange}
open={open}
side={side}
>
<div className={classNames(props(styles.layout, xstyle).className, className)}>
{children}
</div>
</Sidebar.Root>
);
}
/** The column itself, a `<nav>` named by `label`. */
component SidebarContent(
children : React.Node,
label : string,
panelXstyle? : StyleArgument,
panelClassName?: string,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Sidebar.Body {
return (
<Sidebar.Body
{...forwarded(rest)}
className={classNames(props(styles.content, xstyle).className, className)}
label={label}
sheetProps={{
className: classNames(props(styles.panel, panelXstyle).className, panelClassName),
}}
>
{children}
</Sidebar.Body>
);
}
/** The top of the column, such as the product's name. */
component SidebarHeader(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Sidebar.Header {
return (
<Sidebar.Header
{...forwarded(rest)}
className={classNames(props(styles.header, xstyle).className, className)}
>
{children}
</Sidebar.Header>
);
}
/** The bottom of the column, such as the signed-in account. */
component SidebarFooter(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Sidebar.Footer {
return (
<Sidebar.Footer
{...forwarded(rest)}
className={classNames(props(styles.footer, xstyle).className, className)}
>
{children}
</Sidebar.Footer>
);
}
/**
* One item: an icon and its words, and only the icon while the column is
* collapsed. It is a button unless `render` gives it another element, such as
* a link.
*/
component SidebarItem(
children : React.Node,
label : string,
icon? : React.Node,
render? : RenderProp,
tooltipXstyle? : StyleArgument,
tooltipClassName?: string,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Sidebar.Item {
return (
<Sidebar.Item
{...forwarded(rest)}
className={classNames(props(styles.item, xstyle).className, className)}
label={label}
render={render}
tooltipProps={{
className: classNames(props(styles.tooltip, tooltipXstyle).className, tooltipClassName),
}}
>
{
match (icon) {
null | undefined => null,
const shown =>
<span {...props(styles.icon)} aria-hidden="true">
{shown}
</span>,
}
}
<span {...props(styles.label)}>{children}</span>
</Sidebar.Item>
);
}
/** The button that opens and collapses the column. `label` says what it does. */
component SidebarTrigger(
label? : string = "Toggle sidebar",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Sidebar.Trigger {
return (
<Sidebar.Trigger
{...forwarded(rest)}
aria-label={label}
className={classNames(props(styles.trigger, xstyle).className, className)}
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<rect height="18" rx="2" width="18" x="3" y="3" />
<path d="M9 3v18" />
</svg>
</Sidebar.Trigger>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Sidebar from "./sidebar.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Sidebar.Root>`
* and `<Sidebar.Content>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `SidebarRoot` rather than `Root`.
*/
export {
SidebarRoot as Root,
SidebarContent as Content,
SidebarHeader as Header,
SidebarFooter as Footer,
SidebarItem as Item,
SidebarTrigger as Trigger,
};
skeleton
the still shapes a page shows while its content loads, with the loading announced rather than only drawn.
uf ui add skeleton
import * as Skeleton from "./components/ui/skeleton.js";
The example, skeleton.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Skeleton from "./skeleton.js";
const styles = stylex.create({
row: {
display : "flex",
alignItems: "center",
gap : ufTokens.space3,
},
lines: {
display : "grid",
flexGrow: 1,
gap : ufTokens.space2,
},
short: {
width: "60%",
},
});
/** A comment still on its way: an avatar, two lines and the text under them. */
export component Example() {
return (
<Skeleton.Root>
<div {...props(styles.row)}>
<Skeleton.Box shape="circle" />
<div {...props(styles.lines)}>
<Skeleton.Box />
<Skeleton.Box xstyle={styles.short} />
</div>
</div>
<Skeleton.Box shape="block" />
</Skeleton.Root>
);
}
The component, skeleton.js
// @flow
//
// Skeleton: the still shapes a page shows while its content loads, with the
// loading announced rather than only drawn.
//
// `uf ui add skeleton` wrote this file into the project, and it is the
// project's from then on. `uf ui diff skeleton` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The shapes: a line, a block and a circle in `sunken`. `@uniflowed/ui`'s `Skeleton`
// owns what a reader who cannot see them is told: every box is `aria-hidden`,
// the region they stand in is `aria-busy`, and a polite status that was in the
// document from the first render says "Loading…" and then "Loaded".
//
// # What to keep true when you change it
//
// * **Set `busy` to false when the content arrives,** or pass `fallback` and
// let a suspending child say when the wait starts and ends. A skeleton left
// busy tells a reader the page is still loading.
// * **Nothing real goes in a box.** A box is `aria-hidden`, so text inside one
// is text nobody using a screen reader hears.
// * **Match the content's shape,** so the page does not jump when it arrives.
// * **It does not shimmer.** uf's StyleX has no `@keyframes` yet, and a still
// shape is also the one a reader who asked for reduced motion wants.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Skeleton } from "@uniflowed/ui";
/** The shape a box stands in for. */
export type SkeletonShape = "line" | "block" | "circle";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display: "grid",
gap : ufTokens.space3,
},
box: {
display : "block",
flexShrink : 0,
backgroundColor: ufTokens.sunken,
borderRadius : ufTokens.radiusSm,
},
line: {
width : "100%",
height: ufTokens.textMd,
},
block: {
width : "100%",
height : "5rem",
borderRadius: ufTokens.radiusMd,
},
circle: {
width : "40px",
height : "40px",
borderRadius: ufTokens.radiusPill,
},
});
/** The region a skeleton stands in for. `busy` goes false once the content is there, or `fallback` follows a child that suspends. */
component SkeletonRoot(
children : React.Node,
busy? : boolean,
fallback? : React.Node,
label? : string = "Loading…",
doneLabel?: string = "Loaded",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Skeleton.Root {
return (
<Skeleton.Root
{...forwarded(rest)}
busy={busy}
className={classNames(props(styles.root, xstyle).className, className)}
doneLabel={doneLabel}
fallback={fallback}
label={label}
>
{children}
</Skeleton.Root>
);
}
/** One shape. Size it with `xstyle` when the content it stands in for is another size. */
component SkeletonBox(
shape? : SkeletonShape = "line",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Skeleton.Box {
const styled = props(
styles.box,
match (shape) {
"line" => styles.line,
"block" => styles.block,
"circle" => styles.circle,
},
xstyle,
);
return <Skeleton.Box {...forwarded(rest)} className={classNames(styled.className, className)} />;
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Skeleton from "./skeleton.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Skeleton.Root>`
* and `<Skeleton.Box>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `SkeletonRoot` rather than `Root`.
*/
export { SkeletonRoot as Root, SkeletonBox as Box };
slider
a value, or a range between two values, chosen by dragging a thumb along a rail or with the arrow keys.
uf ui add slider
import { Slider } from "./components/ui/slider.js";
The example, slider.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Slider } from "./slider.js";
const styles = stylex.create({
stack: {
display : "grid",
gap : ufTokens.space6,
maxWidth: "20rem",
},
});
/** A volume, and a price range whose thumbs cannot pass each other. */
export component Example() {
return (
<div {...props(styles.stack)}>
<Slider defaultValue={[50]} labels={["Volume"]} />
<Slider
defaultValue={[20, 80]}
labels={["Minimum price", "Maximum price"]}
valueText={(value) => `$${value}`}
/>
</div>
);
}
The component, slider.js
"use client";
// @flow
//
// Slider: a value, or a range between two values, chosen by dragging a thumb
// along a rail or with the arrow keys.
//
// `uf ui add slider` wrote this file into the project, and it is the project's
// from then on. `uf ui diff slider` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The rail, the filled part of it and the thumbs, across or down. Where they
// sit comes from what `@uniflowed/ui`'s `Slider` writes: `--uf-slider-start` and
// `--uf-slider-end` on the range, and `--uf-slider-fraction` on each thumb,
// all fractions of the way from `min` to `max`. The part owns the rest: a
// `role="slider"` thumb for every value with `aria-valuenow` and the bounds
// its neighbours set, the arrow keys, `Page Up` and `Page Down`, `Home` and
// `End`, a drag anywhere on the track, and a right-to-left page reversing the
// horizontal axis, which the logical `inset-inline` properties here follow.
//
// # What to keep true when you change it
//
// * **Every thumb has a name.** `labels` gives one per value, in order: a
// reader hears "Minimum price, 20", not "slider, 20".
// * **Keep the thumbs inside the track.** The track takes the pointer; a thumb
// outside it cannot be dragged.
// * **Keep the ring.** A thumb is focused by the keyboard, and its outline is
// how a sighted keyboard user finds it.
// * **The fill is not the only sign.** The thumb's position says the value
// too, and `valueText` says it in words for a reader.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Slider as HeadlessSlider } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "flex",
alignItems : "center",
boxSizing : "border-box",
width : "100%",
minWidth : "8rem",
touchAction: "none",
userSelect : "none",
},
rootVertical: {
display : "inline-flex",
flexDirection: "column",
width : "auto",
minWidth : 0,
height : "10rem",
},
// Taller than the rail it draws, so the pointer has something to land on.
track: {
position : "relative",
display : "flex",
alignItems: "center",
flexGrow : 1,
height : "20px",
cursor : "pointer",
},
trackVertical: {
flexDirection: "column",
width : "20px",
height : "100%",
},
trackDisabled: {
cursor: "not-allowed",
},
rail: {
position : "relative",
flexGrow : 1,
height : "4px",
borderRadius : ufTokens.radiusPill,
backgroundColor: ufTokens.border,
},
railVertical: {
width : "4px",
height: "auto",
},
range: {
position : "absolute",
insetBlockStart : 0,
insetBlockEnd : 0,
insetInlineStart: "calc(var(--uf-slider-start, 0) * 100%)",
insetInlineEnd : "calc((1 - var(--uf-slider-end, 0)) * 100%)",
borderRadius : "inherit",
backgroundColor : ufTokens.accent,
},
rangeVertical: {
insetBlockStart : "calc((1 - var(--uf-slider-end, 0)) * 100%)",
insetBlockEnd : "calc(var(--uf-slider-start, 0) * 100%)",
insetInlineStart: 0,
insetInlineEnd : 0,
},
rangeDisabled: {
backgroundColor: ufTokens.muted,
},
thumb: {
position : "absolute",
insetBlockStart : "50%",
insetInlineStart : "calc(var(--uf-slider-fraction, 0) * 100%)",
boxSizing : "border-box",
width : "18px",
height : "18px",
marginBlockStart : "-9px",
marginInlineStart: "-9px",
borderRadius : "50%",
backgroundColor : ufTokens.surface,
borderWidth : "2px",
borderStyle : "solid",
borderColor : { default: ufTokens.accent, ":is([aria-disabled=true])": ufTokens.muted },
cursor : { default: "grab", ":is([aria-disabled=true])": "not-allowed" },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
// Only its colour and its ring move. Its position is where the pointer
// is, and a thumb that eased after the finger would feel disconnected.
// The focus ring draws itself outward from the edge (`outline-width`
// 0 → 2px) rather than blinking on, in `durationFast`: it is where a
// keyboard reader's eye is, so it should arrive, not flash.
transitionProperty: {
default : "border-color, outline-width",
"@media (prefers-reduced-motion: reduce)": "border-color",
},
transitionDuration : ufTokens.durationFast,
transitionTimingFunction: ufTokens.easing,
},
thumbVertical: {
insetBlockStart : "auto",
insetBlockEnd : "calc(var(--uf-slider-fraction, 0) * 100%)",
insetInlineStart: "50%",
marginBlockStart: 0,
marginBlockEnd : "-9px",
},
});
/**
* The slider. `labels` names each thumb and sets how many there are: one for
* a value, two for a range.
*/
export component Slider(
labels : $ReadOnlyArray<string>,
value? : $ReadOnlyArray<number>,
defaultValue? : $ReadOnlyArray<number> = [0],
onValueChange?: (value: $ReadOnlyArray<number>) => void,
min? : number = 0,
max? : number = 100,
step? : number = 1,
largeStep? : number = 10,
orientation? : "horizontal" | "vertical" = "horizontal",
disabled? : boolean = false,
valueText? : (value: number, index: number) => string,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders HeadlessSlider.Root {
const vertical = orientation === "vertical";
return (
<HeadlessSlider.Root
{...forwarded(rest)}
className={classNames(
props(styles.root, vertical && styles.rootVertical, xstyle).className,
className,
)}
defaultValue={defaultValue}
disabled={disabled}
largeStep={largeStep}
max={max}
min={min}
onValueChange={onValueChange}
orientation={orientation}
step={step}
value={value}
valueText={valueText}
>
<HeadlessSlider.Track
className={
props(styles.track, vertical && styles.trackVertical, disabled && styles.trackDisabled)
.className
}
>
<span {...props(styles.rail, vertical && styles.railVertical)}>
<HeadlessSlider.Range
className={
props(
styles.range,
vertical && styles.rangeVertical,
disabled && styles.rangeDisabled,
).className
}
/>
</span>
{labels.map((label, index) => (
<HeadlessSlider.Thumb
aria-label={label}
className={props(styles.thumb, vertical && styles.thumbVertical).className}
index={index}
key={`${index}:${label}`}
/>
))}
</HeadlessSlider.Track>
</HeadlessSlider.Root>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
spinner
something is under way and nobody knows how far along it is, drawn as a turning arc and announced as a progress bar with no value.
uf ui add spinner
import { Spinner } from "./components/ui/spinner.js";
The example, spinner.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Button } from "./button.js";
import { Spinner } from "./spinner.js";
const styles = stylex.create({
row: {
display : "flex",
flexWrap : "wrap",
alignItems: "center",
gap : ufTokens.space4,
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
},
});
/**
* Three sizes, one of them named for what it is loading, and one inside a
* button whose words already say it, where the spinner is silent.
*/
export component Example() {
return (
<div {...props(styles.row)}>
<Spinner size="sm" />
<Spinner label="Loading invoices" />
<Spinner size="lg" />
<Button aria-disabled="true" tone="primary">
<Spinner label={null} size="sm" tone="inherit" />
Saving…
</Button>
</div>
);
}
The component, spinner.js
// @flow
//
// Spinner: something is under way and nobody knows how far along it is, drawn
// as a turning arc and announced as a progress bar with no value.
//
// `uf ui add spinner` wrote this file into the project, and it is the
// project's from then on. `uf ui diff spinner` shows how it has moved away
// from the registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// `@uniflowed/ui` already has the spinner's one decision, in `Progress`: a bar
// that does not know how far along it is omits `aria-valuenow`, so a reader
// hears "in progress" rather than a zero that never moves. A second headless
// component with the same role would be a second place for that to go wrong,
// so the package declines a `Spinner` (`crates/uf_lib/src/ui.rs` records it)
// and this file renders `Progress` with no value around a drawing.
//
// # What to keep true when you change it
//
// * **A spinner has a name.** It is `role="progressbar"`, and one with no name
// is announced as "progressbar" with nothing to say what is progressing. The
// name is "Loading" until you pass `label`; say what is loading when you can
// ("Loading invoices").
// * **Unless something beside it already says so.** A spinner inside a button
// that reads "Saving…" would be announced twice. Pass `label={null}` there:
// the drawing is then `aria-hidden` and nothing about it is announced.
// * **It never claims a value.** The day the amount is known, use `Progress`
// with a `value`, which is the component that can say how far along it is.
// * **Reduced motion is a mark that fades, not one that turns.** uf's StyleX
// has no `@keyframes`, and a spinner cannot be a transition, because nothing
// about it changes state. So the drawing is an SVG with two marks in it: an
// arc that turns once a second through SMIL's `<animateTransform>`, and the
// same arc still, whose opacity pulses once every two seconds. A
// `prefers-reduced-motion: reduce` media query decides which one is
// displayed, in CSS, so the choice is made before any script runs and a page
// rendered on a server gets it right. Turning is travel and fading is not,
// which is the line every other default style in the registry draws.
// * **It is a `<span>`.** `Progress` renders a `<div>` unless told otherwise,
// and a `<div>` inside a `<button>` or a `<p>` is not allowed there. The
// role and the attributes are the part's either way.
// * **The arc is drawn in `currentColor`.** It takes the colour of the text
// around it, so a spinner in a primary button is `accentInk` and one on a
// page is `accent`, without a prop.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Progress } from "@uniflowed/ui";
/** How big it is: `sm` sits in a line of text or a button, `lg` fills a panel. */
export type SpinnerSize = "sm" | "md" | "lg";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
display : "inline-flex",
flexShrink : 0,
color : ufTokens.accent,
verticalAlign: "middle",
},
inherit: {
color: "inherit",
},
sm: {
width : "16px",
height: "16px",
},
md: {
width : "24px",
height: "24px",
},
lg: {
width : "40px",
height: "40px",
},
svg: {
display: "block",
width : "100%",
height : "100%",
},
// Shown unless the reader asked for reduced motion.
turning: {
display: { default: "inline", "@media (prefers-reduced-motion: reduce)": "none" },
},
// Shown only when they did.
still: {
display: { default: "none", "@media (prefers-reduced-motion: reduce)": "inline" },
},
});
/**
* A spinner.
*
* <Spinner label="Loading invoices" />
* <Button disabled><Spinner label={null} size="sm" tone="inherit" /> Saving…</Button>
*
* `tone="inherit"` draws it in the colour of the text around it rather than in
* `accent`. `xstyle` takes a `stylex.create` namespace and wins property by
* property; `className` adds a class of your own beside these.
*/
export component Spinner(
label? : string | null = "Loading",
size? : SpinnerSize = "md",
tone? : "accent" | "inherit" = "accent",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
const styled = classNames(
props(
styles.root,
tone === "inherit" && styles.inherit,
match (size) {
"sm" => styles.sm,
"md" => styles.md,
"lg" => styles.lg,
},
xstyle,
).className,
className,
);
if (label == null) {
return (
<span {...rest} aria-hidden="true" className={styled}>
<Drawing />
</span>
);
}
// A `<span>` rather than `Progress`'s `<div>`, so a spinner can sit in a
// line of text or inside a `<button>`, which may hold only phrasing content.
return (
<Progress
{...forwarded(rest)}
aria-label={label}
className={styled}
render={(part) => <span {...part} />}
>
<Drawing />
</Progress>
);
}
/** The arc, turning, and the same arc still and fading for reduced motion. */
component Drawing() {
return (
<svg
{...props(styles.svg)}
aria-hidden="true"
fill="none"
focusable="false"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" opacity="0.2" r="9" stroke="currentColor" strokeWidth="2.5" />
<g {...props(styles.turning)}>
<path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" strokeLinecap="round" strokeWidth="2.5">
<animateTransform
attributeName="transform"
dur="1s"
from="0 12 12"
repeatCount="indefinite"
to="360 12 12"
type="rotate"
/>
</path>
</g>
<g {...props(styles.still)}>
<path d="M12 3a9 9 0 0 1 9 9" stroke="currentColor" strokeLinecap="round" strokeWidth="2.5">
<animate attributeName="opacity" dur="2s" repeatCount="indefinite" values="1;0.35;1" />
</path>
</g>
</svg>
);
}
/**
* A caller's props on their way into `Progress` rather than onto an element.
* The same widening `progress.js` makes, for the same reason: Flow checks the
* spread against the part's own `...rest`, whose `key` is `empty` where this
* file's indexer says `mixed`, and nothing checked is lost, because the element
* `Progress` renders has `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
switch
a setting that takes effect the moment it is flipped, drawn as a track and a thumb beside its label.
uf ui add switch
import { Switch } from "./components/ui/switch.js";
The example, switch.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Switch } from "./switch.js";
const styles = stylex.create({
stack: {
display : "grid",
justifyItems: "start",
gap : ufTokens.space2,
},
});
/** Three notification settings, one of them unavailable. */
export component Example() {
return (
<div {...props(styles.stack)}>
<Switch defaultChecked>Email me when someone replies</Switch>
<Switch>Show a preview of each message</Switch>
<Switch disabled>Sync with my calendar</Switch>
</div>
);
}
The component, switch.js
// @flow
//
// Switch: a setting that takes effect the moment it is flipped, drawn as a
// track and a thumb beside its label.
//
// `uf ui add switch` wrote this file into the project, and it is the project's
// from then on. `uf ui diff switch` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The track, the thumb and the label beside them, all inside the one button the
// part renders, so the whole row is the target and its words are the name.
// `@uniflowed/ui`'s `Switch` owns `role="switch"`, `aria-checked`, and `Space` and
// `Enter` both flipping it. The thumb and the track follow `aria-checked`
// through `:is([aria-checked=true])`, so a click, a key and a controlled
// `checked` all draw the same thing.
//
// # What to keep true when you change it
//
// * **It is for a setting that applies at once.** An answer on the way to a
// submit button is a `checkbox`, and a reader is told something different.
// * **Its words are its name.** Put the label inside `Switch`; a switch with no
// words needs `aria-label`.
// * **On is more than a colour.** The thumb moves as the track fills, so the
// state is not carried by colour alone (WCAG 1.4.1).
// * **The colours are measured pairs.** Off, a `surface` thumb on a `muted`
// track; on, an `accentInk` thumb on an `accent` track. Both are pairs
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Switch as SwitchPart } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
// Read by the track and the thumb, which cannot see the button's state.
"--uf-switch-track": { default: ufTokens.muted, ":is([aria-checked=true])": ufTokens.accent },
"--uf-switch-thumb": {
default : ufTokens.surface,
":is([aria-checked=true])": ufTokens.accentInk,
},
"--uf-switch-shift": { default: "0px", ":is([aria-checked=true])": "16px" },
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space3,
minHeight : "32px",
margin : 0,
padding : 0,
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
backgroundColor : "transparent",
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingTight,
textAlign : "start",
cursor : { default: "pointer", ":disabled": "not-allowed" },
opacity : { default: 1, ":disabled": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
// The focus ring draws itself outward from the edge (`outline-width`
// 0 → 2px) rather than blinking on, in `durationFast`: it is where a
// keyboard reader's eye is, so it should arrive, not flash.
transitionProperty: {
default : "outline-width",
"@media (prefers-reduced-motion: reduce)": "none",
},
transitionDuration : ufTokens.durationFast,
transitionTimingFunction: ufTokens.easing,
},
track: {
position : "relative",
flexShrink : 0,
boxSizing : "border-box",
width : "36px",
height : "20px",
borderRadius : ufTokens.radiusPill,
backgroundColor: "var(--uf-switch-track)",
// The track's colour turns with the thumb, over the same time and curve,
// so the two read as one movement. Colour, so it stays under reduced
// motion.
transitionProperty : "background-color",
transitionDuration : ufTokens.durationBase,
transitionTimingFunction: ufTokens.easing,
},
thumb: {
position : "absolute",
top : "2px",
left : "2px",
width : "16px",
height : "16px",
borderRadius : ufTokens.radiusPill,
backgroundColor: "var(--uf-switch-thumb)",
transform : "translateX(var(--uf-switch-shift))",
// The thumb travels 16px, which at `durationFast` was a jump; at
// `durationBase` on the standard curve it is seen to slide and settle.
// Under reduced motion it moves at once.
transitionProperty: { default: "transform", "@media (prefers-reduced-motion: reduce)": "none" },
transitionDuration: ufTokens.durationBase,
transitionTimingFunction: ufTokens.easing,
},
});
/** A switch and its label. Uncontrolled from `defaultChecked` unless `checked` is given. */
export component Switch(
children? : React.Node,
checked? : boolean,
defaultChecked? : boolean = false,
onCheckedChange?: (checked: boolean) => void,
disabled? : boolean = false,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders SwitchPart {
return (
<SwitchPart
{...forwarded(rest)}
checked={checked}
className={classNames(props(styles.root, xstyle).className, className)}
defaultChecked={defaultChecked}
disabled={disabled}
onCheckedChange={onCheckedChange}
>
<span {...props(styles.track)} aria-hidden="true">
<span {...props(styles.thumb)} />
</span>
{children}
</SwitchPart>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
table
rows of data under column headings, sortable by a heading and selectable a row at a time.
uf ui add table
import * as Table from "./components/ui/table.js";
Invoices this month
Status
Ada Lovelace
$250
Paid
Grace Hopper
$150
Pending
Alan Turing
$350
Paid
The example, table.example.js
"use client";
// @flow
import { useState } from "@uniflowed/react";
import type { Sort } from "./table.js";
import * as Table from "./table.js";
type Invoice = {|
readonly id : string,
readonly customer: string,
readonly amount : number,
readonly status : string,
|};
const INVOICES: $ReadOnlyArray<Invoice> = [
{ id: "INV-001", customer: "Ada Lovelace", amount: 250, status: "Paid" },
{ id: "INV-002", customer: "Grace Hopper", amount: 150, status: "Pending" },
{ id: "INV-003", customer: "Alan Turing", amount: 350, status: "Paid" },
];
/** Invoices the page sorts by customer or amount, with rows to choose. */
export component Example() {
const [sort, setSort] = useState<Sort | null>(null);
const [chosen, setChosen] = useState<$ReadOnlySet<string>>(new Set());
const all = chosen.size === INVOICES.length ? true : chosen.size === 0 ? false : "mixed";
return (
<Table.Root onSortChange={setSort} sort={sort}>
<Table.Caption>Invoices this month</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>
<Table.SelectAll
checked={all}
onCheckedChange={(on) => {
setChosen(new Set(on ? INVOICES.map((invoice) => invoice.id) : []));
}}
/>
</Table.Head>
<Table.Head column="customer">Customer</Table.Head>
<Table.Head column="amount">Amount</Table.Head>
<Table.Head>Status</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{sorted(INVOICES, sort).map((invoice) => (
<Table.Row key={invoice.id}>
<Table.Cell>
<Table.RowSelect
checked={chosen.has(invoice.id)}
label={`Select ${invoice.customer}`}
onCheckedChange={(on) => {
setChosen(toggled(chosen, invoice.id, on));
}}
/>
</Table.Cell>
<Table.RowHeader>{invoice.customer}</Table.RowHeader>
<Table.Cell>{`$${invoice.amount}`}</Table.Cell>
<Table.Cell>{invoice.status}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
);
}
/** The invoices in the order `sort` asks for. */
function sorted(invoices: $ReadOnlyArray<Invoice>, sort: Sort | null): $ReadOnlyArray<Invoice> {
if (sort == null) {
return invoices;
}
const way = sort.direction === "ascending" ? 1 : -1;
const column = sort.column;
return [...invoices].sort(
(a, b) =>
way * (column === "amount" ? a.amount - b.amount : a.customer.localeCompare(b.customer)),
);
}
/** `chosen` with `id` added or taken away. */
function toggled(chosen: $ReadOnlySet<string>, id: string, on: boolean): $ReadOnlySet<string> {
const next = new Set(chosen);
if (on) {
next.add(id);
} else {
next.delete(id);
}
return next;
}
The component, table.js
"use client";
// @flow
//
// Table: rows of data under column headings, sortable by a heading and
// selectable a row at a time.
//
// `uf ui add table` wrote this file into the project, and it is the project's
// from then on. `uf ui diff table` shows how it has moved away from the registry
// in the uf you are running.
//
// # What this file owns, and what it does not
//
// The frame the table scrolls sideways in, the rules between rows, the headings'
// type, the sort buttons and the arrow beside a sorted heading.
// `@uniflowed/ui`'s `Table` owns the markup and what it says: a real `<table>` with
// `<th scope="col">`, a button inside every sortable heading and `aria-sort` on
// the heading the rows are sorted by, a polite announcement when the sort
// changes, and `aria-rowcount` and `aria-rowindex` for a table that shows part
// of its rows. The arrow is drawn from `aria-sort`. Rows are chosen with this
// registry's `Checkbox`, and a select-all shows mixed while only some are.
//
// The part gives a caller no prop for its sort button. This file reaches the
// button through `render`, which hands it over as the heading's child. The
// part's own select checkboxes are not used, because they take no children and
// so cannot hold a box and its marks.
//
// # What to keep true when you change it
//
// * **Give it a caption.** `Table.Caption` names the table for a reader moving
// between tables.
// * **The page sorts.** A heading asks for a sort; the order the rows come in
// is the page's, because the data can be remote or paged.
// * **Name every checkbox.** `Table.RowSelect` takes a `label` saying which row
// it chooses, such as the row's name.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { Sort } from "@uniflowed/ui";
import { Table } from "@uniflowed/ui";
import { Checkbox } from "./checkbox.js";
export type { Sort } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
frame: {
boxSizing : "border-box",
width : "100%",
overflowX : "auto",
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.muted,
},
table: {
width : "100%",
borderCollapse : "collapse",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
color : ufTokens.ink,
backgroundColor: ufTokens.surface,
},
caption: {
captionSide : "top",
paddingBlock: ufTokens.space2,
textAlign : "start",
fontWeight : ufTokens.weightMedium,
color : ufTokens.ink,
},
row: {
borderBlockEndWidth: "1px",
borderBlockEndStyle: "solid",
borderBlockEndColor: ufTokens.border,
},
head: {
// Read by the arrow inside the sort button, which cannot see this heading.
"--uf-table-arrow-shown": { default: "0", ":is([aria-sort])": "1" },
"--uf-table-arrow-turn" : { default: "0deg", ":is([aria-sort=descending])": "180deg" },
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
textAlign : "start",
verticalAlign : "bottom",
fontWeight : ufTokens.weightMedium,
whiteSpace : "nowrap",
borderBlockEndWidth : "2px",
borderBlockEndStyle : "solid",
borderBlockEndColor : ufTokens.border,
},
sort: {
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space1,
margin : 0,
padding : 0,
fontFamily : "inherit",
fontSize : "inherit",
fontWeight : "inherit",
lineHeight : "inherit",
color : "inherit",
backgroundColor: "transparent",
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
cursor : "pointer",
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
arrow: {
flexShrink: 0,
opacity : "var(--uf-table-arrow-shown, 0)",
transform : "rotate(var(--uf-table-arrow-turn, 0deg))",
},
cell: {
paddingBlock : ufTokens.space2,
paddingInline: ufTokens.space3,
textAlign : "start",
verticalAlign: "middle",
},
rowHeader: {
paddingBlock : ufTokens.space2,
paddingInline: ufTokens.space3,
textAlign : "start",
verticalAlign: "middle",
fontWeight : ufTokens.weightMedium,
},
});
/**
* The table, in a frame it scrolls sideways in when it is wider than the page.
* `sort` and `onSortChange` are the page's to act on.
*/
component TableRoot(
children : React.Node,
sort? : Sort | null,
defaultSort? : Sort | null = null,
onSortChange?: (sort: Sort | null) => void,
rowCount? : number | null = null,
rowOffset? : number = 0,
announceSort?: (column: string, direction: "ascending" | "descending") => string,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) {
return (
<div {...props(styles.frame)}>
<Table.Root
{...forwarded(rest)}
announceSort={announceSort}
className={classNames(props(styles.table, xstyle).className, className)}
defaultSort={defaultSort}
onSortChange={onSortChange}
rowCount={rowCount}
rowOffset={rowOffset}
sort={sort}
>
{children}
</Table.Root>
</div>
);
}
/** The table's name, over it. */
component TableCaption(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Table.Caption {
return (
<Table.Caption
{...forwarded(rest)}
className={classNames(props(styles.caption, xstyle).className, className)}
>
{children}
</Table.Caption>
);
}
/** The rows of headings. */
component TableHeader(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Table.Header {
return (
<Table.Header {...forwarded(rest)} className={classNames(props(xstyle).className, className)}>
{children}
</Table.Header>
);
}
/** The rows of data. */
component TableBody(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Table.Body {
return (
<Table.Body {...forwarded(rest)} className={classNames(props(xstyle).className, className)}>
{children}
</Table.Body>
);
}
/** One row. `index` is its place in the whole data when the table shows part of it. */
component TableRow(
children : React.Node,
index? : number | null = null,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Table.Row {
return (
<Table.Row
{...forwarded(rest)}
className={classNames(props(styles.row, xstyle).className, className)}
index={index}
>
{children}
</Table.Row>
);
}
/**
* A column heading. With a `column`, it is a button that asks for the rows to
* be sorted by it, with an arrow while they are.
*/
component TableHead(
children : React.Node,
column? : string | null = null,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
const classes = classNames(props(styles.head, xstyle).className, className);
if (column == null) {
return (
<Table.Head {...forwarded(rest)} className={classes}>
{children}
</Table.Head>
);
}
return (
<Table.Head
{...forwarded(rest)}
className={classes}
column={column}
render={(heading) => <th {...heading}>{dressed(heading.children)}</th>}
>
{children}
<svg
{...props(styles.arrow)}
aria-hidden="true"
fill="none"
focusable="false"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="14"
>
<path d="m6 15 6-6 6 6" />
</svg>
</Table.Head>
);
}
/** A data cell. */
component TableCell(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Table.Cell {
return (
<Table.Cell
{...forwarded(rest)}
className={classNames(props(styles.cell, xstyle).className, className)}
>
{children}
</Table.Cell>
);
}
/** The cell that names its row, such as a person's name. */
component TableRowHeader(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Table.RowHeader {
return (
<Table.RowHeader
{...forwarded(rest)}
className={classNames(props(styles.rowHeader, xstyle).className, className)}
>
{children}
</Table.RowHeader>
);
}
/** The checkbox that chooses every row, mixed while only some are chosen. */
component TableSelectAll(
checked : boolean | "mixed",
onCheckedChange: (checked: boolean) => void,
label? : string = "Select all rows",
disabled? : boolean = false,
xstyle? : StyleArgument,
className? : string,
) renders Checkbox {
return (
<Checkbox
aria-label={label}
checked={checked === "mixed" ? false : checked}
className={className}
disabled={disabled}
indeterminate={checked === "mixed"}
onCheckedChange={onCheckedChange}
xstyle={xstyle}
/>
);
}
/** The checkbox that chooses one row. `label` says which. */
component TableRowSelect(
label : string,
checked : boolean,
onCheckedChange: (checked: boolean) => void,
disabled? : boolean = false,
xstyle? : StyleArgument,
className? : string,
) renders Checkbox {
return (
<Checkbox
aria-label={label}
checked={checked}
className={className}
disabled={disabled}
onCheckedChange={onCheckedChange}
xstyle={xstyle}
/>
);
}
/**
* The part's sort button, with this file's class on it. `Table.Head` builds the
* button itself and has no prop for it, so it is reached through `render`,
* which hands the button over as the heading's child: ubugeeei-prod/uf#1074.
*/
function dressed(button: mixed): React.Node {
if (!React.isValidElement(button)) {
return button as $FlowFixMe;
}
return React.cloneElement(button as $FlowFixMe, { className: props(styles.sort).className });
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Table from "./table.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Table.Root>`
* and `<Table.Caption>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `TableRoot` rather than `Root`.
*/
export {
TableRoot as Root,
TableCaption as Caption,
TableHeader as Header,
TableBody as Body,
TableRow as Row,
TableHead as Head,
TableCell as Cell,
TableRowHeader as RowHeader,
TableSelectAll as SelectAll,
TableRowSelect as RowSelect,
};
tabs
@uniflowed/ui's tab set, with the selected tab underlined.
uf ui add tabs
import * as Tabs from "./components/ui/tabs.js";
Everything the project is doing, at a glance.
The example, tabs.example.js
// @flow
import * as React from "@uniflowed/react";
import * as Tabs from "./tabs.js";
/** Three sections of a project, one of them unavailable. */
export component Example() {
return (
<Tabs.Root defaultValue="overview">
<Tabs.List aria-label="Project">
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="activity">Activity</Tabs.Tab>
<Tabs.Tab disabled value="billing">
Billing
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">Everything the project is doing, at a glance.</Tabs.Panel>
<Tabs.Panel value="activity">The last thirty days of changes.</Tabs.Panel>
<Tabs.Panel value="billing">Invoices, and the plan they are for.</Tabs.Panel>
</Tabs.Root>
);
}
The component, tabs.js
"use client";
// @flow
//
// Tabs: `@uniflowed/ui`'s tab set, with the selected tab underlined.
//
// `uf ui add tabs` wrote this file into the project, and it is the project's
// from then on. `uf ui diff tabs` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The look: the row, the underline under the selected tab, the dimmed disabled
// tab, the focus rings. The behaviour is imported from `@uniflowed/ui`'s `Tabs`:
// one tab stop for the whole row, arrow keys that move and wrap, `Home` and
// `End`, automatic or manual activation, and a panel that points back at the
// tab that shows it. A fix to any of that reaches this project by upgrading
// `@uniflowed/ui`.
//
// The selected look follows `aria-selected`, the attribute the part already
// writes, through the condition `:is([aria-selected=true])`. Nothing here
// tracks which tab is selected, so an arrow key, a click and a controlled
// `value` all draw the same underline without this file hearing about any of
// them.
//
// The underline slides. `Tabs.List` writes where the selected tab is as four
// unitless custom properties (`--uf-tabs-indicator-left`, `-top`, `-width`,
// `-height`), and one bar in the list follows them with `transform` alone: a
// one-pixel bar moved by `translateX` and stretched to the tab by `scaleX`, so
// nothing is laid out while it travels. Until the part has measured — on the
// server, and before hydration — each selected tab draws its own underline
// instead, so the selection is never shown by colour alone.
//
// # What to keep true when you change it
//
// * **A tab list holds tabs and nothing else.** `Tabs.List` takes
// `renders* TabsTab`, and `Tabs.Tab` renders `@uniflowed/ui`'s tab, so a
// `<button>` dropped into the row is a Flow error here exactly as it is in
// the package. The constraint survives the copy.
// * **Name the list.** `aria-label` on `Tabs.List` says what the tabs choose
// between; without it a reader hears "tab list" and no subject.
// * **Selected is drawn twice.** The underline, and the text going from `muted`
// to `ink`, because colour alone does not tell a reader which tab is chosen
// (WCAG 1.4.1) and a two-pixel line alone is easy to miss.
// * **A disabled tab stays in the row.** It is `aria-disabled`, dimmed and
// passed over by the arrow keys rather than removed, so a reader learns the
// section exists and is unavailable.
// * **The panel is focusable, and shows it.** `Tab` out of the row lands on
// the panel, and the ring says where focus went.
// * **Colour comes from tokens, in measured pairs:** `ink` and `muted` on
// `canvas` and `surface`, which `crates/uf_stylex/src/tests/preset.rs` holds
// to 4.5:1 in the light default and the dark theme.
import * as React from "@uniflowed/react";
import { createContext, useContext, useEffect, useState } from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { ActivationMode } from "@uniflowed/ui";
import { Tabs } from "@uniflowed/ui";
/** Which way the row runs, and so which arrow keys move along it. */
export type TabsOrientation = "horizontal" | "vertical";
/**
* Every prop a caller passes that this file does not name, on its way to the
* part underneath. See `forwarded` for the one thing Flow cannot say about it.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
/**
* The orientation the caller gave `Tabs`, for the parts that draw differently
* along each axis. This is the caller's own prop handed down, not state the
* part owns, so there is nothing here that could disagree with it.
*/
const OrientationContext: React.Context<TabsOrientation> = createContext("horizontal");
/**
* Whether the list's sliding bar is drawn, so a tab stops drawing its own
* underline. False on the server and until the list has been measured.
*/
const SlidingContext: React.Context<boolean> = createContext(false);
const styles = stylex.create({
root: {
display : "flex",
flexDirection: "column",
gap : ufTokens.space4,
fontFamily : ufTokens.fontSans,
color : ufTokens.ink,
},
rootVertical: {
flexDirection: "row",
},
list: {
// The containing block the sliding bar is placed in.
position : "relative",
display : "flex",
gap : ufTokens.space1,
borderBottomWidth: "1px",
borderBottomStyle: "solid",
borderBottomColor: ufTokens.border,
},
listVertical: {
flexDirection : "column",
borderBottomWidth : 0,
borderInlineEndWidth: "1px",
borderInlineEndStyle: "solid",
borderInlineEndColor: ufTokens.border,
},
tab: {
display : "inline-flex",
alignItems : "center",
justifyContent: "center",
gap : ufTokens.space2,
boxSizing : "border-box",
minHeight : "36px",
margin : 0,
// Over the list's own border, so the underline replaces that line under
// the selected tab rather than sitting on top of it.
marginBottom : "-1px",
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
borderWidth : 0,
borderStyle : "solid",
borderColor : "transparent",
borderBottomWidth: "2px",
borderBottomColor: {
default : "transparent",
":is([aria-selected=true])": ufTokens.accent,
},
backgroundColor: "transparent",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
color: {
default : ufTokens.muted,
":hover" : ufTokens.ink,
":is([aria-selected=true])": ufTokens.ink,
},
cursor : { default: "pointer", ":is([aria-disabled=true])": "not-allowed" },
opacity: { default: 1, ":is([aria-disabled=true])": 0.55 },
// Inside the tab, so the list's edge never clips the ring.
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "-2px",
// The underline and the label change together in `durationBase`: long
// enough to see which tab took the mark from which. Colours, so they stay
// under reduced motion; the ring does not.
transitionProperty: {
default : "border-color, color, outline-width",
"@media (prefers-reduced-motion: reduce)": "border-color, color",
},
transitionDuration : ufTokens.durationBase,
transitionTimingFunction: ufTokens.easing,
},
// Once the bar is drawn, the tab's own underline goes: the bar is the mark.
tabSliding: {
borderBottomColor: "transparent",
},
tabSlidingVertical: {
borderInlineEndColor: "transparent",
},
// The bar: one pixel along the row, moved to the selected tab and stretched
// to its width by the part's measurements, over the list's own border line
// where a tab's underline would be. `transform` only, so it slides without
// laying anything out; `durationBase` on the standard curve, because it is
// travelling between two places that are both on screen, not arriving.
// `scaleX` here is the tab's width in pixels, geometry rather than a grow.
// It fades in when it is first drawn. Under reduced motion it moves at once.
indicator: {
position : "absolute",
left : 0,
bottom : "-1px",
width : "1px",
height : "2px",
backgroundColor: ufTokens.accent,
pointerEvents : "none",
transformOrigin: "0 0",
transform:
"translateX(calc(var(--uf-tabs-indicator-left, 0) * 1px)) scaleX(var(--uf-tabs-indicator-width, 0))",
opacity: { default: 1, "@starting-style": 0 },
transitionProperty: {
default : "transform, opacity",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration : ufTokens.durationBase,
transitionTimingFunction: ufTokens.easing,
},
// Down the inline-end edge of a vertical list, where its border is.
indicatorVertical: {
left : "auto",
bottom : "auto",
top : 0,
insetInlineEnd: "-1px",
width : "2px",
height : "1px",
transform:
"translateY(calc(var(--uf-tabs-indicator-top, 0) * 1px)) scaleY(var(--uf-tabs-indicator-height, 0))",
},
tabVertical: {
justifyContent : "flex-start",
marginBottom : 0,
marginInlineEnd : "-1px",
borderBottomWidth : 0,
borderInlineEndWidth: "2px",
borderInlineEndColor: {
default : "transparent",
":is([aria-selected=true])": ufTokens.accent,
},
},
panel: {
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
borderRadius : ufTokens.radiusSm,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
},
});
/**
* The tab set. Uncontrolled from `defaultValue` unless `value` is given.
*
* <Tabs defaultValue="overview">
* <TabsList aria-label="Project">
* <TabsTab value="overview">Overview</TabsTab>
* <TabsTab value="activity">Activity</TabsTab>
* </TabsList>
* <TabsPanel value="overview">…</TabsPanel>
* <TabsPanel value="activity">…</TabsPanel>
* </Tabs>
*/
component TabsRoot(
children : React.Node,
defaultValue : string,
value? : string,
onValueChange? : (value: string) => void,
activationMode?: ActivationMode = "automatic",
orientation? : TabsOrientation = "horizontal",
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) {
const styled = props(styles.root, orientation === "vertical" && styles.rootVertical, xstyle);
return (
<OrientationContext.Provider value={orientation}>
<Tabs.Root
{...forwarded(rest)}
activationMode={activationMode}
className={classNames(styled.className, className)}
defaultValue={defaultValue}
onValueChange={onValueChange}
orientation={orientation}
value={value}
>
{children}
</Tabs.Root>
</OrientationContext.Provider>
);
}
/** The row of tabs. Give it an `aria-label` that says what they choose between. */
component TabsList(
children : renders* TabsTab,
xstyle? : StyleArgument,
className?: string,
...given : Rest
) renders Tabs.List {
const orientation = useContext(OrientationContext);
const vertical = orientation === "vertical";
const styled = props(styles.list, orientation === "vertical" && styles.listVertical, xstyle);
// The sliding bar, drawn from the commit after the first, by which time
// `Tabs.List` has written where the selected tab is: a bar drawn before
// that would slide in from the list's corner on every page load. It goes
// after the tabs, through the part's `render`, and tells them through
// `SlidingContext` that they need not underline themselves.
const [sliding, setSliding] = useState(false);
useEffect(() => {
// uf-lint-disable-next-line react-compiler/set-state-in-effect
setSliding(true);
}, []);
const rest: Rest = {
...given,
render: (list: Rest) => (
<SlidingContext.Provider value={sliding}>
<div {...forwarded(list)}>
{children}
{
match (sliding) {
true =>
<span
{...props(styles.indicator, vertical && styles.indicatorVertical)}
aria-hidden="true"
/>,
false => null,
}
}
</div>
</SlidingContext.Provider>
),
};
return (
<Tabs.List {...forwarded(rest)} className={classNames(styled.className, className)}>
{children}
</Tabs.List>
);
}
/** One tab. `disabled` keeps it in the row, announced and unavailable. */
component TabsTab(
value : string,
children : React.Node,
disabled? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Tabs.Tab {
const orientation = useContext(OrientationContext);
const sliding = useContext(SlidingContext);
const vertical = orientation === "vertical";
const styled = props(
styles.tab,
vertical && styles.tabVertical,
sliding && (vertical ? styles.tabSlidingVertical : styles.tabSliding),
xstyle,
);
return (
<Tabs.Tab
{...forwarded(rest)}
className={classNames(styled.className, className)}
disabled={disabled}
value={value}
>
{children}
</Tabs.Tab>
);
}
/**
* The panel a tab shows. Automatic activation keeps the others mounted and
* hidden; manual activation omits a panel until its tab is selected.
*/
component TabsPanel(
value : string,
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Tabs.Panel {
return (
<Tabs.Panel
{...forwarded(rest)}
className={classNames(props(styles.panel, xstyle).className, className)}
value={value}
>
{children}
</Tabs.Panel>
);
}
/**
* A caller's props on their way into a part, rather than onto an element.
*
* Flow checks a spread into a component against that component's own
* `...rest`, `key` included, and the indexer in `Rest` answers `mixed` where
* the part says `empty`. `@uniflowed/ui` meets the same hole between its own
* parts, and papers over it the same way in one named place,
* `internal/merge-props.js`'s `forwarded`: nothing that was ever checked is
* lost, because every element those parts render has `any`-typed props in uf's
* library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. See `button.js`. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Tabs from "./tabs.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Tabs.Root>`
* and `<Tabs.List>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `TabsRoot` rather than `Root`.
*/
export { TabsRoot as Root, TabsList as List, TabsTab as Tab, TabsPanel as Panel };
tag-group
a named collection with visible selection and keyboard focus.
uf ui add tag-group
import { TagGroup } from "./components/ui/tag-group.js";
a styled multi-line <textarea>, for a control that has no field row around it.
uf ui add textarea
import { Textarea } from "./components/ui/textarea.js";
The example, textarea.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Label } from "./label.js";
import { Textarea } from "./textarea.js";
const styles = stylex.create({
stack: {
display : "grid",
gap : ufTokens.space2,
maxWidth: "28rem",
},
});
/** A labelled text area that starts four rows tall. */
export component Example() {
return (
<div {...props(styles.stack)}>
<Label htmlFor="textarea-example-note">Release note</Label>
<Textarea
id="textarea-example-note"
name="note"
placeholder="What changed, for someone who was not there"
rows={4}
/>
</div>
);
}
The component, textarea.js
// @flow
//
// Textarea: a styled multi-line `<textarea>`, for a control that has no field
// row around it.
//
// `uf ui add textarea` wrote this file into the project, and it is the
// project's from then on. `uf ui diff textarea` shows how it has moved away
// from the registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// For the same reasons as `input.js`. A `<textarea>` already is the component,
// so `@uniflowed/ui` declines to wrap it, and `field.js`'s `FieldTextarea` is
// the one to use inside a labelled row. This file draws the text area on its
// own, the same way `FieldTextarea` draws it.
//
// # What to keep true when you change it
//
// * **Every text area has a name a reader hears.** Use a `<label htmlFor>` or
// an `aria-label`. A placeholder is not a name.
// * **It grows the way a reader expects.** The browser resizes it vertically
// only, so a reader can make room for a long answer without breaking the
// page's width. `rows` sets where it starts.
// * **Invalid is an attribute, and more than a colour.** The border follows
// `aria-invalid="true"`, and the words saying what is wrong sit near it.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface` and
// `sunken`, which `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in
// both shipped themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
control: {
boxSizing : "border-box",
width : "100%",
minHeight : "5rem",
margin : 0,
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
color : { default: ufTokens.ink, ":disabled": ufTokens.muted },
backgroundColor: { default: ufTokens.surface, ":disabled": ufTokens.sunken },
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
borderRadius : ufTokens.radiusMd,
cursor : { default: "text", ":disabled": "not-allowed" },
resize : "vertical",
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "1px",
"::placeholder": {
color : ufTokens.muted,
opacity: 1,
},
},
});
/**
* A text area.
*
* <Label htmlFor="note">Note</Label>
* <Textarea id="note" name="note" rows={4} />
*/
export component Textarea(xstyle?: StyleArgument, className?: string, ...rest: Rest) {
return (
<textarea
{...rest}
className={classNames(props(styles.control, xstyle).className, className)}
/>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
time-field
locale-aware segments in a bordered field.
uf ui add time-field
import { TimeField } from "./components/ui/time-field.js";
:
The example, time-field.example.js
// @flow
import * as React from "@uniflowed/react";
import { TimeField } from "./time-field.js";
/** A named, keyboard-operable example using the default presentation. */
export component Example() {
return <TimeField aria-label="Meeting time" defaultValue="14:30" hourCycle="h23" />;
}
The component, time-field.js
"use client";
// @flow
// TimeField: locale-aware segments in a bordered field.
//
// `uf ui add time-field` copies this component into the project. The headless
// primitive owns semantics and input behavior; this file owns its presentation.
// Keep accessible names, focus rings and disabled/selected states when editing.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { TimeField as HeadlessTimeField } from "@uniflowed/ui";
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
root: {
boxSizing : "border-box",
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderRadius : ufTokens.radiusMd,
display : "inline-flex",
alignItems : "center",
gap : ufTokens.space1,
minHeight : "36px",
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space2,
borderWidth : "1px",
borderStyle : "solid",
borderColor : { default: ufTokens.border, ":is([aria-invalid=true])": ufTokens.danger },
outlineWidth : { default: "0", ":focus-within": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
fontVariantNumeric: "tabular-nums",
},
});
export component TimeField(
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders HeadlessTimeField {
return (
<HeadlessTimeField
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
/>
);
}
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
toast
a short notice that appears in a corner of the page and goes away on its own or when dismissed, without taking focus from what the reader is doing.
uf ui add toast
import * as Toast from "./components/ui/toast.js";
The example, toast.example.js
"use client";
// @flow
import { Button } from "./button.js";
import { toast } from "./toast.js";
import * as Toast from "./toast.js";
/** A button that raises a notice, and the corner the notice shows in. */
export component Example() {
return (
<>
<Button
onClick={() => {
toast(
<>
<Toast.Title>Draft saved</Toast.Title>
<Toast.Description>Your changes are kept on this device.</Toast.Description>
</>,
{ duration: null },
);
}}
>
Save draft
</Button>
<Toast.Region />
</>
);
}
The component, toast.js
"use client";
// @flow
//
// Toast: a short notice that appears in a corner of the page and goes away on
// its own or when dismissed, without taking focus from what the reader is doing.
//
// `uf ui add toast` wrote this file into the project, and it is the project's
// from then on. `uf ui diff toast` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The corner the notices stack in, each notice's card, its title and
// description type, and its buttons. `@uniflowed/ui`'s `Toast` owns the rest: a
// `toast()` a page calls from anywhere, a named `role="region"` that `F6`
// reaches without joining the tab order, a polite live region for ordinary
// notices and an assertive one for urgent ones, a limit on how many show at
// once, each notice a `role="group"` named by its title and described by its
// description, and dismissal from its action and its close button.
//
// # What to keep true when you change it
//
// * **Render `Toast.Region` once.** Notices raised with `toast()` show wherever a
// `Toast.Region` is rendered, so two would show every notice twice.
// * **Nothing only a toast can do.** A notice can go before a reader reaches it,
// so what its action does must also be reachable somewhere else on the page.
// * **Name the close button for what it closes** when the notice's title does
// not make it obvious: `Toast.Close` says "Dismiss" unless told otherwise.
// * **Text stays on measured pairs.** `ink` and `muted` on `surface`, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Toast } from "@uniflowed/ui";
export { dismissAllToasts, dismissToast, toast, updateToast } from "@uniflowed/ui";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
region: {
position : "fixed",
zIndex : 100,
insetBlockEnd : ufTokens.space4,
insetInlineEnd: ufTokens.space4,
boxSizing : "border-box",
width : "min(24rem, calc(100vw - 32px))",
margin : 0,
padding : 0,
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "4px",
},
toast: {
display : "grid",
gridTemplateColumns: "1fr auto",
alignItems : "start",
columnGap : ufTokens.space3,
rowGap : ufTokens.space1,
boxSizing : "border-box",
width : "100%",
marginBlockStart : ufTokens.space2,
padding : ufTokens.space4,
backgroundColor : ufTokens.surface,
color : ufTokens.ink,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
lineHeight : ufTokens.leadingBase,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
// Enter: it rises 16px into its place in the stack as it fades in, over
// `durationSlow`, so a notification arriving in a corner is noticed
// without being startling. Vertical, so it reads the same in a
// right-to-left page. Exit: it sinks back the way it came as it fades,
// over `durationBase` on the accelerating curve; `@uniflowed/ui` keeps a
// dismissed notification in its place, `inert`, until that has finished.
// Under reduced motion both only fade (`--uf-exit-travel` is 0 there).
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style" : "translateY(16px)",
":is([data-state=closed])": "translateY(calc(16px * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationSlow,
":is([data-state=closed])": ufTokens.durationBase,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
title: {
gridColumn: "1",
fontWeight: ufTokens.weightMedium,
},
description: {
gridColumn: "1",
color : ufTokens.muted,
},
action: {
gridColumn : "1",
justifySelf : "start",
marginBlockStart: ufTokens.space2,
minHeight : "32px",
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusSm,
cursor : "pointer",
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
close: {
gridColumn : "2",
gridRow : "1",
display : "inline-flex",
alignItems : "center",
justifyContent : "center",
width : "24px",
height : "24px",
margin : 0,
padding : 0,
color : { default: ufTokens.muted, ":hover": ufTokens.ink },
backgroundColor: "transparent",
borderWidth : 0,
borderRadius : ufTokens.radiusSm,
cursor : "pointer",
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "1px",
},
});
/**
* Where notices raised with `toast()` show: a stack in the page's bottom inline
* end corner, each with a close button.
*/
component Toaster(
label? : string = "Notifications",
limit? : number = 3,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Toast.Region {
return (
<Toast.Region
{...forwarded(rest)}
className={classNames(props(styles.region, xstyle).className, className)}
label={label}
limit={limit}
>
{(notification) => (
<ToastRoot>
{notification.content}
<ToastClose />
</ToastRoot>
)}
</Toast.Region>
);
}
/** One notice's card. `Toast.Region` draws one for every notice. */
component ToastRoot(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Toast.Root {
return (
<Toast.Root
{...forwarded(rest)}
className={classNames(props(styles.toast, xstyle).className, className)}
>
{children}
</Toast.Root>
);
}
/** What happened, in a few words. It names the notice. */
component ToastTitle(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Toast.Title {
return (
<Toast.Title
{...forwarded(rest)}
className={classNames(props(styles.title, xstyle).className, className)}
>
{children}
</Toast.Title>
);
}
/** The detail under the title. It describes the notice. */
component ToastDescription(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Toast.Description {
return (
<Toast.Description
{...forwarded(rest)}
className={classNames(props(styles.description, xstyle).className, className)}
>
{children}
</Toast.Description>
);
}
/** A button that acts on the notice and then dismisses it. */
component ToastAction(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Toast.Action {
return (
<Toast.Action
{...forwarded(rest)}
className={classNames(props(styles.action, xstyle).className, className)}
type="button"
>
{children}
</Toast.Action>
);
}
/** The button in the corner that dismisses the notice. */
component ToastClose(
label? : string = "Dismiss",
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Toast.Close {
return (
<Toast.Close
{...forwarded(rest)}
className={classNames(props(styles.close, xstyle).className, className)}
label={label}
type="button"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="14"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="14"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</Toast.Close>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Toast from "./toast.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Toast.Region>`
* and `<Toast.Root>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `Toaster` rather than `Region`.
*/
export {
Toaster as Region,
ToastRoot as Root,
ToastTitle as Title,
ToastDescription as Description,
ToastAction as Action,
ToastClose as Close,
};
toggle
a button that stays pressed until it is pressed again, for a mode such as bold or a filter that is on.
uf ui add toggle
import { Toggle } from "./components/ui/toggle.js";
The example, toggle.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Toggle } from "./toggle.js";
const styles = stylex.create({
row: {
display : "flex",
flexWrap : "wrap",
alignItems: "center",
gap : ufTokens.space2,
},
});
/** An icon toggle that is on, an outlined one that is off, and one with words. */
export component Example() {
return (
<div {...props(styles.row)}>
<Toggle aria-label="Bold" defaultPressed size="icon">
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
viewBox="0 0 24 24"
width="16"
>
<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" />
</svg>
</Toggle>
<Toggle aria-label="Italic" size="icon" tone="outline">
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="M14 5h-4M14 19h-4M13 5l-2 14" />
</svg>
</Toggle>
<Toggle tone="outline">Show hidden files</Toggle>
</div>
);
}
The component, toggle.js
// @flow
//
// Toggle: a button that stays pressed until it is pressed again, for a mode
// such as bold or a filter that is on.
//
// `uf ui add toggle` wrote this file into the project, and it is the project's
// from then on. `uf ui diff toggle` shows how it has moved away from the
// registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The look: two tones and three sizes, and a pressed state drawn from
// `aria-pressed` through `:is([aria-pressed=true])`. `@uniflowed/ui`'s `Toggle`
// owns `aria-pressed` itself and the press that flips it.
//
// # What to keep true when you change it
//
// * **Its name does not change with its state.** "Bold", pressed or not; a
// name that says "Turn bold off" contradicts the pressed state a reader is
// already told.
// * **An icon toggle needs `aria-label`,** and the icon `aria-hidden`.
// * **Pressed is more than a colour.** Pressed takes a background as well as the
// `accent` text, so the state is not carried by the hue alone.
// * **The colours are measured pairs:** `ink` on `surfaceHover`, and `accent`
// on `accentSoft` when pressed, both held to 4.5:1 by
// `crates/uf_stylex/src/tests/preset.rs` in both themes.
// * **A group of toggles is a `toggle-group`,** which gives the row one tab
// stop and arrow keys.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Toggle as TogglePart } from "@uniflowed/ui";
/** Whether a toggle has an edge while it is not pressed. */
export type ToggleTone = "ghost" | "outline";
/** How big it is. `icon` is square, for a toggle whose only content is an icon. */
export type ToggleSize = "sm" | "md" | "icon";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
base: {
display : "inline-flex",
alignItems : "center",
justifyContent: "center",
gap : ufTokens.space2,
flexShrink : 0,
boxSizing : "border-box",
margin : 0,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
borderWidth : "1px",
borderStyle : "solid",
borderRadius : ufTokens.radiusMd,
color : { default: ufTokens.ink, ":is([aria-pressed=true])": ufTokens.accent },
backgroundColor: {
default : "transparent",
":hover" : ufTokens.surfaceHover,
":disabled" : "transparent",
":is([aria-pressed=true])": ufTokens.accentSoft,
},
cursor : { default: "pointer", ":disabled": "not-allowed" },
opacity : { default: 1, ":disabled": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "2px",
// The focus ring draws itself outward from the edge (`outline-width`
// 0 → 2px) rather than blinking on, in `durationFast`: it is where a
// keyboard reader's eye is, so it should arrive, not flash.
transitionProperty: {
default : "background-color, color, outline-width",
"@media (prefers-reduced-motion: reduce)": "background-color, color",
},
transitionDuration : ufTokens.durationFast,
transitionTimingFunction: ufTokens.easing,
},
ghost: {
borderColor: "transparent",
},
outline: {
borderColor: ufTokens.border,
},
sm: {
minHeight : "32px",
paddingInline: ufTokens.space2,
},
md: {
minHeight : "36px",
paddingInline: ufTokens.space3,
},
icon: {
width : "36px",
height : "36px",
padding: 0,
},
});
/** A toggle. Uncontrolled from `defaultPressed` unless `pressed` is given. */
export component Toggle(
children? : React.Node,
pressed? : boolean,
defaultPressed? : boolean = false,
onPressedChange?: (pressed: boolean) => void,
disabled? : boolean = false,
tone? : ToggleTone = "ghost",
size? : ToggleSize = "md",
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders TogglePart {
const styled = props(
styles.base,
match (tone) {
"ghost" => styles.ghost,
"outline" => styles.outline,
},
match (size) {
"sm" => styles.sm,
"md" => styles.md,
"icon" => styles.icon,
},
xstyle,
);
return (
<TogglePart
{...forwarded(rest)}
className={classNames(styled.className, className)}
defaultPressed={defaultPressed}
disabled={disabled}
onPressedChange={onPressedChange}
pressed={pressed}
>
{children}
</TogglePart>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
toggle-group
a row of toggles that behaves as one control, pressing any number of them or choosing one.
uf ui add toggle-group
import * as ToggleGroup from "./components/ui/toggle-group.js";
The example, toggle-group.example.js
// @flow
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as ToggleGroup from "./toggle-group.js";
const styles = stylex.create({
stack: {
display : "grid",
justifyItems: "start",
gap : ufTokens.space3,
},
});
/** Formatting, any number of which can be on, and an alignment, which is one. */
export component Example() {
return (
<div {...props(styles.stack)}>
<ToggleGroup.Root aria-label="Formatting" defaultValue={["bold"]} type="multiple">
<ToggleGroup.Item aria-label="Bold" value="bold">
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2.5"
viewBox="0 0 24 24"
width="16"
>
<path d="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" />
</svg>
</ToggleGroup.Item>
<ToggleGroup.Item aria-label="Italic" value="italic">
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="16"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="16"
>
<path d="M14 5h-4M14 19h-4M13 5l-2 14" />
</svg>
</ToggleGroup.Item>
</ToggleGroup.Root>
<ToggleGroup.Root aria-label="Alignment" defaultValue={["left"]} type="single">
<ToggleGroup.Item value="left">Left</ToggleGroup.Item>
<ToggleGroup.Item value="center">Centre</ToggleGroup.Item>
<ToggleGroup.Item value="right">Right</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
);
}
The component, toggle-group.js
"use client";
// @flow
//
// Toggle group: a row of toggles that behaves as one control, pressing any
// number of them or choosing one.
//
// `uf ui add toggle-group` wrote this file into the project, and it is the
// project's from then on. `uf ui diff toggle-group` shows how it has moved away
// from the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The look: segments joined into one bordered row or column, with the chosen
// ones filled. `@uniflowed/ui`'s `ToggleGroup` owns the two behaviours `type`
// selects between. `multiple` is a `group` of `aria-pressed` buttons; `single`
// is a radio group drawn as segments, with `aria-checked` and the arrow keys
// that check as they move. Both have one tab stop for the whole row. A segment
// is drawn chosen from either attribute, through `:is([aria-pressed=true])`
// and `:is([aria-checked=true])`, so both modes look the same.
//
// # What to keep true when you change it
//
// * **Name the group.** `aria-label` on `ToggleGroup` says what the segments
// choose between.
// * **A group holds its own items and nothing else.** `ToggleGroup` takes
// `renders* ToggleGroupItem`, and each item renders `@uniflowed/ui`'s, so a
// stray button in the row is a Flow error.
// * **An icon segment needs `aria-label`,** and the icon `aria-hidden`.
// * **Chosen is more than a colour:** a filled background as well as `accent`
// text, on `accentSoft`, a pair `crates/uf_stylex/src/tests/preset.rs` holds
// to 4.5:1 in both themes.
import * as React from "@uniflowed/react";
import { createContext, useContext } from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import type { ToggleGroupType } from "@uniflowed/ui";
import { ToggleGroup } from "@uniflowed/ui";
/** Which way the segments run. */
export type ToggleGroupOrientation = "horizontal" | "vertical";
/** Every prop a caller passes that this file does not name, for the part. */
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** The orientation the caller gave `ToggleGroup`, for the items' dividers. */
const OrientationContext: React.Context<ToggleGroupOrientation> = createContext("horizontal");
const styles = stylex.create({
root: {
display : "inline-flex",
flexDirection : { default: "row", ":is([aria-orientation=vertical])": "column" },
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusMd,
backgroundColor: ufTokens.surface,
padding : 0,
},
item: {
display : "inline-flex",
alignItems : "center",
justifyContent: "center",
gap : ufTokens.space2,
boxSizing : "border-box",
minWidth : "36px",
minHeight : "36px",
margin : 0,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
borderWidth : 0,
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : 0,
color: {
default : ufTokens.ink,
":is([aria-pressed=true])": ufTokens.accent,
":is([aria-checked=true])": ufTokens.accent,
},
backgroundColor: {
default : "transparent",
":hover" : ufTokens.surfaceHover,
":is([aria-pressed=true])": ufTokens.accentSoft,
":is([aria-checked=true])": ufTokens.accentSoft,
},
cursor: {
default : "pointer",
":disabled" : "not-allowed",
":is([aria-disabled=true])": "not-allowed",
},
opacity : { default: 1, ":disabled": 0.55, ":is([aria-disabled=true])": 0.55 },
outlineWidth : { default: "0", ":focus-visible": "2px" },
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset: "-2px",
},
// A divider before every segment but the first, along the row's axis.
horizontal: {
borderInlineStartWidth: { default: "1px", ":first-child": 0 },
},
vertical: {
borderTopWidth: { default: "1px", ":first-child": 0 },
},
});
/**
* The row. `type="multiple"` presses any number of items and `type="single"`
* chooses one; `value` is a list either way.
*/
component ToggleGroupRoot(
children : renders* ToggleGroupItem,
type? : ToggleGroupType = "multiple",
defaultValue? : $ReadOnlyArray<string>,
value? : $ReadOnlyArray<string>,
onValueChange?: (value: $ReadOnlyArray<string>) => void,
orientation? : ToggleGroupOrientation = "horizontal",
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) {
return (
<OrientationContext.Provider value={orientation}>
<ToggleGroup.Root
{...forwarded(rest)}
className={classNames(props(styles.root, xstyle).className, className)}
defaultValue={defaultValue}
onValueChange={onValueChange}
orientation={orientation}
type={type}
value={value}
>
{children}
</ToggleGroup.Root>
</OrientationContext.Provider>
);
}
/** One segment. `disabled` keeps it in the row, announced as unavailable. */
component ToggleGroupItem(
value : string,
children? : React.Node,
disabled? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders ToggleGroup.Item {
const orientation = useContext(OrientationContext);
const styled = props(
styles.item,
orientation === "vertical" ? styles.vertical : styles.horizontal,
xstyle,
);
return (
<ToggleGroup.Item
{...forwarded(rest)}
className={classNames(styled.className, className)}
disabled={disabled}
value={value}
>
{children}
</ToggleGroup.Item>
);
}
/**
* A caller's props on their way into a part rather than onto an element. Flow
* checks that spread against the part's own `...rest`, whose `key` is `empty`
* where this file's indexer says `mixed`; `@uniflowed/ui` papers over the same
* hole the same way, and nothing checked is lost, because the elements its
* parts render have `any`-typed props in uf's library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as ToggleGroup from "./toggle-group.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<ToggleGroup.Root>`
* and `<ToggleGroup.Item>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `ToggleGroupRoot` rather than `Root`.
*/
export { ToggleGroupRoot as Root, ToggleGroupItem as Item };
tooltip
a short phrase about a control, shown on hover and on focus, and never in the way.
uf ui add tooltip
import * as Tooltip from "./components/ui/tooltip.js";
"use client";
// @flow
//
// Tooltip: a short phrase about a control, shown on hover and on focus, and
// never in the way.
//
// `uf ui add tooltip` wrote this file into the project, and it is the
// project's from then on. `uf ui diff tooltip` shows how it has moved away from
// the registry in the uf you are running.
//
// # What this file owns, and what it does not
//
// The look: a small dark label beside its trigger. The behaviour is imported
// from `@uniflowed/ui`'s `Tooltip`, and it is what WCAG 1.4.13 asks of content that
// appears on hover: it waits for a pointer and not for focus, it stays while
// the pointer travels onto it, `Escape` dismisses it from wherever focus is,
// and it describes its trigger with `aria-describedby` only while it is there.
// `Tooltip.Provider` shares one clock across a toolbar, so the second icon a
// reader points at answers at once. A fix to any of that reaches this project
// by upgrading `@uniflowed/ui`.
//
// # What to keep true when you change it
//
// * **It holds a phrase and nothing a reader can act on.** A tooltip never
// takes focus, so a link or a button inside one is one nobody can reach. A
// preview with links in it is a `hover-card`.
// * **The trigger has its own name.** A tooltip does not open on touch, so an
// icon button's name is its `aria-label`, and the tooltip only repeats it.
// * **It is inverted, and the pair is measured.** `canvas` on `ink` is the pair
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 as `ink` on `canvas`
// — contrast is the same in both directions — and it stays inverted in the
// dark theme, where the two swap.
// * **It is short.** Capped at 20rem; a tooltip that needs more is a
// description on the page.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { Tooltip } from "@uniflowed/ui";
import type { ButtonSize, ButtonTone } from "./button.js";
import { Button } from "./button.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* part underneath. See `forwarded` for the one thing Flow cannot say about it.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
/** A caller's own element in place of the one a part renders. */
type RenderProp = (props: Rest) => React.Node;
const styles = stylex.create({
content: {
zIndex : 60,
boxSizing : "border-box",
maxWidth : "20rem",
margin : 0,
paddingBlock : ufTokens.space1,
paddingInline : ufTokens.space2,
backgroundColor: ufTokens.ink,
color : ufTokens.canvas,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textXs,
fontWeight : ufTokens.weightMedium,
lineHeight : ufTokens.leadingTight,
overflowWrap : "break-word",
borderRadius : ufTokens.radiusSm,
// Enter: it fades in while travelling 4px out of its trigger, from the
// side `data-side` says it opened on, so the eye is led from the button
// to what it opened. `durationBase` on the decelerating curve: most of
// the distance is covered at once, so it is legible before it has
// settled. Under reduced motion it only fades.
"--uf-enter-x": {
default : "0px",
":is([data-side=left])" : "4px",
":is([data-side=right])": "-4px",
},
"--uf-enter-y": {
default : "0px",
":is([data-side=top])" : "4px",
":is([data-side=bottom])": "-4px",
},
// Exit: back towards the trigger it came from, on the accelerating curve
// and in `durationFast` against the entrance's `durationBase`, so it gets
// out of the way rather than lingering. `@uniflowed/ui` keeps it on the
// page, closed and `inert`, until this has finished. Under reduced motion
// it only fades: `--uf-exit-travel` is 0 there, so nothing jumps either.
"--uf-exit-travel": { default: "1", "@media (prefers-reduced-motion: reduce)": "0" },
opacity : { default: 1, "@starting-style": 0, ":is([data-state=closed])": 0 },
transform: {
default : "none",
"@starting-style": "translate(var(--uf-enter-x), var(--uf-enter-y))",
":is([data-state=closed])":
"translate(calc(var(--uf-enter-x) * var(--uf-exit-travel)), calc(var(--uf-enter-y) * var(--uf-exit-travel)))",
},
transitionProperty: {
default : "opacity, transform",
"@media (prefers-reduced-motion: reduce)": "opacity",
},
transitionDuration: {
default : ufTokens.durationBase,
":is([data-state=closed])": ufTokens.durationFast,
},
transitionTimingFunction: {
default : ufTokens.easingEnter,
":is([data-state=closed])": ufTokens.easingExit,
},
},
});
/**
* The clock a group of tooltips shares, so the next one opens at once once a
* reader has waited for the first.
*/
component TooltipProvider(
children : React.Node,
delayDuration? : number,
skipDelayDuration?: number,
) {
return (
<Tooltip.Provider delayDuration={delayDuration} skipDelayDuration={skipDelayDuration}>
{children}
</Tooltip.Provider>
);
}
/** One tooltip and its trigger. */
component TooltipRoot(
children : React.Node,
openDelay? : number,
closeDelay? : number,
defaultOpen? : boolean = false,
open? : boolean,
onOpenChange?: (open: boolean) => void,
) renders Tooltip.Root {
return (
<Tooltip.Root
closeDelay={closeDelay}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
open={open}
openDelay={openDelay}
>
{children}
</Tooltip.Root>
);
}
/**
* The control the tooltip describes. It is a `Button` unless `render` says
* otherwise, and whatever it is needs a name of its own.
*/
component TooltipTrigger(
children? : React.Node,
tone? : ButtonTone = "neutral",
size? : ButtonSize = "md",
render? : RenderProp,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders Tooltip.Trigger {
return (
<Tooltip.Trigger
{...forwarded(rest)}
render={
match (render) {
undefined =>
(trigger) => (
<Button
{...forwarded(trigger)}
className={className}
size={size}
tone={tone}
xstyle={xstyle}
/>
),
const custom => custom,
}
}
>
{children}
</Tooltip.Trigger>
);
}
/**
* The phrase, above its trigger unless it does not fit. `side` and `align`
* reach `Tooltip.Body` untouched; `sideOffset` is the gap.
*/
component TooltipContent(
children : React.Node,
sideOffset? : number = 6,
collisionPadding?: number = 8,
xstyle? : StyleArgument,
className? : string,
...rest : Rest
) renders Tooltip.Body {
return (
<Tooltip.Body
{...forwarded(rest)}
className={classNames(props(styles.content, xstyle).className, className)}
collisionPadding={collisionPadding}
sideOffset={sideOffset}
>
{children}
</Tooltip.Body>
);
}
/**
* A caller's props on their way into a part, rather than onto an element.
*
* Flow checks a spread into a component against that component's own
* `...rest`, `key` included, and the indexer in `Rest` answers `mixed` where
* the part says `empty`. `@uniflowed/ui` meets the same hole between its own
* parts, and papers over it the same way in one named place,
* `internal/merge-props.js`'s `forwarded`: nothing that was ever checked is
* lost, because every element those parts render has `any`-typed props in uf's
* library today.
*/
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
/** The classes this file chose, then the caller's. See `button.js`. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Tooltip from "./tooltip.js"` gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes `<Tooltip.Provider>`
* and `<Tooltip.Root>`, the way it writes `@uniflowed/ui`'s own parts. Each
* is declared under its full name, so React DevTools and an error say
* `TooltipProvider` rather than `Provider`.
*/
export {
TooltipProvider as Provider,
TooltipRoot as Root,
TooltipTrigger as Trigger,
TooltipContent as Content,
};
tree
a named collection with visible selection and keyboard focus.
uf ui add tree
import { Tree } from "./components/ui/tree.js";
Folder
The example, tree.example.js
// @flow
import * as React from "@uniflowed/react";
import { Tree } from "./tree.js";
/** A named, keyboard-operable example using the default presentation. */
export component Example() {
return (
<Tree
aria-label="Files"
items={[
{ key: "folder", textValue: "Folder", children: [{ key: "child", textValue: "Child" }] },
]}
/>
);
}
headings, paragraphs, a quotation, a list and inline code, on one type scale, each part the element its name says.
uf ui add typography
import * as Typography from "./components/ui/typography.js";
Release notes
What changed in this version, and what to do about it.
Upgrading
Run uf ui update to bring an edited copy up to date.
What moved
Spinners respect reduced motion.
Native selects draw their own chevron.
Steps
Upgrade the package.
Run the update.
A heading is the outline, not a font size.
Published 26 September 2026.
The example, typography.example.js
// @flow
import * as React from "@uniflowed/react";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import * as Typography from "./typography.js";
const styles = stylex.create({
article: {
display : "grid",
gap : ufTokens.space4,
maxWidth: "40rem",
},
});
/** A short release note, in every part, with the heading levels in order. */
export component Example() {
return (
<article {...props(styles.article)}>
<Typography.H1>Release notes</Typography.H1>
<Typography.Lead>What changed in this version, and what to do about it.</Typography.Lead>
<Typography.H2>Upgrading</Typography.H2>
<Typography.P>
Run <Typography.InlineCode>uf ui update</Typography.InlineCode> to bring an edited copy up
to date.
</Typography.P>
<Typography.H3>What moved</Typography.H3>
<Typography.List>
<li>Spinners respect reduced motion.</li>
<li>Native selects draw their own chevron.</li>
</Typography.List>
<Typography.H4>Steps</Typography.H4>
<Typography.List ordered>
<li>Upgrade the package.</li>
<li>Run the update.</li>
</Typography.List>
<Typography.Blockquote>A heading is the outline, not a font size.</Typography.Blockquote>
<Typography.Muted>Published 26 September 2026.</Typography.Muted>
</article>
);
}
The component, typography.js
// @flow
//
// Typography: headings, paragraphs, a quotation, a list and inline code, on
// one type scale, each part the element its name says.
//
// `uf ui add typography` wrote this file into the project, and it is the
// project's from then on. `uf ui diff typography` shows how it has moved away
// from the registry in the uf you are running.
//
// # Why a component here, when `@uniflowed/ui` has none
//
// Every part is one of the platform's own elements, and the only decision in
// any of them is which element a caller picks, which a component cannot make
// for them. So `@uniflowed/ui` declines a `Typography` (`crates/uf_lib/src/ui.rs`
// records it), and `textStyles` in `@uniflowed/stylex/preset` is the class list
// on its own. What an application wants from this file is prose that looks the
// same on every page: a changelog, a help article, an empty settings screen.
//
// # What to keep true when you change it
//
// * **Each part is its element.** `H2` is an `<h2>`, `List` is a `<ul>` (or an
// `<ol>` when `ordered`), `Blockquote` is a `<blockquote>` and `InlineCode`
// is a `<code>`. Screen reader users move through a page by its headings and
// hear a list's length before its items, and neither works when the element
// is a styled `<div>`.
// * **Pick the heading by its level, not its size.** The level is the outline:
// an `H3` belongs under an `H2`. A heading that should look smaller than its
// level takes an `xstyle`, rather than skipping a level to get the size.
// * **A list keeps its markers.** Bullets and numbers are how a sighted
// reader sees that the lines are one list and in what order. A list of rows
// with no markers is `item.js`'s `Item.Group`.
// * **`Lead` and `Muted` are paragraphs.** They are quieter or larger text,
// not headings, and are read as prose.
// * **Text stays on measured pairs.** `ink` and `muted` on `canvas` and
// `surface`, and `ink` on `sunken` for code, which
// `crates/uf_stylex/src/tests/preset.rs` holds to 4.5:1 in both shipped
// themes.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
/**
* Every prop a caller passes that this file does not name, on its way to the
* element. `key` is `empty` because React takes it off before a component is
* called.
*/
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
heading: {
margin : 0,
fontFamily: ufTokens.fontSans,
fontWeight: ufTokens.weightBold,
lineHeight: ufTokens.leadingTight,
color : ufTokens.ink,
textWrap : "balance",
},
h1: {
fontSize: ufTokens.text2Xl,
},
h2: {
paddingBlockEnd : ufTokens.space2,
fontSize : ufTokens.textXl,
borderBlockEndWidth: "1px",
borderBlockEndStyle: "solid",
borderBlockEndColor: ufTokens.border,
},
h3: {
fontSize: ufTokens.textLg,
},
h4: {
fontSize: ufTokens.textMd,
},
prose: {
margin : 0,
fontFamily: ufTokens.fontSans,
fontSize : ufTokens.textMd,
lineHeight: ufTokens.leadingBase,
color : ufTokens.ink,
},
lead: {
fontSize: ufTokens.textLg,
color : ufTokens.muted,
},
muted: {
fontSize: ufTokens.textSm,
color : ufTokens.muted,
},
blockquote: {
paddingInlineStart : ufTokens.space4,
fontStyle : "italic",
borderInlineStartWidth: "2px",
borderInlineStartStyle: "solid",
borderInlineStartColor: ufTokens.border,
},
list: {
display : "grid",
gap : ufTokens.space2,
paddingInlineStart: ufTokens.space6,
},
code: {
paddingBlock : "1px",
paddingInline : ufTokens.space1,
fontFamily : ufTokens.fontMono,
fontSize : "0.875em",
color : ufTokens.ink,
backgroundColor: ufTokens.sunken,
borderRadius : ufTokens.radiusSm,
},
});
/** A first-level heading: the page's title, once. */
component TypographyH1(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<h1
{...rest}
className={classNames(props(styles.heading, styles.h1, xstyle).className, className)}
>
{children}
</h1>
);
}
/** A second-level heading, ruled underneath: a section of the page. */
component TypographyH2(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<h2
{...rest}
className={classNames(props(styles.heading, styles.h2, xstyle).className, className)}
>
{children}
</h2>
);
}
/** A third-level heading: a part of a section. */
component TypographyH3(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<h3
{...rest}
className={classNames(props(styles.heading, styles.h3, xstyle).className, className)}
>
{children}
</h3>
);
}
/** A fourth-level heading. */
component TypographyH4(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<h4
{...rest}
className={classNames(props(styles.heading, styles.h4, xstyle).className, className)}
>
{children}
</h4>
);
}
/** A paragraph of body text. */
component TypographyP(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<p {...rest} className={classNames(props(styles.prose, xstyle).className, className)}>
{children}
</p>
);
}
/** The paragraph that opens a page, larger and quieter than the rest. */
component TypographyLead(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<p
{...rest}
className={classNames(props(styles.prose, styles.lead, xstyle).className, className)}
>
{children}
</p>
);
}
/** A paragraph of secondary text: a note, a date, a caption. */
component TypographyMuted(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<p
{...rest}
className={classNames(props(styles.prose, styles.muted, xstyle).className, className)}
>
{children}
</p>
);
}
/** A quotation from somewhere else. `cite` takes the URL it came from. */
component TypographyBlockquote(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<blockquote
{...rest}
className={classNames(props(styles.prose, styles.blockquote, xstyle).className, className)}
>
{children}
</blockquote>
);
}
/** A list of `<li>`s: bulleted, or numbered when `ordered`. */
component TypographyList(
children : React.Node,
ordered? : boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
const shared = {
...rest,
className: classNames(props(styles.prose, styles.list, xstyle).className, className),
};
return match (ordered) {
true => <ol {...shared}>{children}</ol>,
false => <ul {...shared}>{children}</ul>,
};
}
/** Code inside a sentence: a name, a command, a value. */
component TypographyInlineCode(
children : React.Node,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) {
return (
<code {...rest} className={classNames(props(styles.code, xstyle).className, className)}>
{children}
</code>
);
}
/** The classes this file chose, then the caller's. */
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
/**
* The parts, under the names `import * as Typography from "./typography.js"`
* gives them.
*
* One name per component (ubugeeei-prod/uf#1453): a page writes
* `<Typography.H2>` and `<Typography.P>`. Each is declared under its full name,
* so React DevTools and an error say `TypographyH2` rather than `H2`.
*/
export {
TypographyH1 as H1,
TypographyH2 as H2,
TypographyH3 as H3,
TypographyH4 as H4,
TypographyP as P,
TypographyLead as Lead,
TypographyMuted as Muted,
TypographyBlockquote as Blockquote,
TypographyList as List,
TypographyInlineCode as InlineCode,
};
visually-hidden
text for a screen reader, and a skip link that appears on focus.
uf ui add visually-hidden
import { VisuallyHidden } from "./components/ui/visually-hidden.js";
// @flow
import * as React from "@uniflowed/react";
import { VisuallyHidden } from "./visually-hidden.js";
/** A skip link that appears when a keyboard reaches it, and a named icon button. */
export component Example() {
return (
<div>
<VisuallyHidden focusable>
<a href="#content">Skip to content</a>
</VisuallyHidden>
<button type="button">
<span aria-hidden="true">×</span>
<VisuallyHidden>Close the panel</VisuallyHidden>
</button>
<main id="content">Content</main>
</div>
);
}
The component, visually-hidden.js
"use client";
// @flow
// VisuallyHidden: text for a screen reader, and a skip link that appears on focus.
//
// `uf ui add visually-hidden` copies this component into the project. The
// headless primitive owns the hiding — a clipped one-pixel box that stays in the
// accessibility tree — and this file owns only what a `focusable` one looks like
// while a keyboard is on it: a skip link pinned to the top of the page.
// Keep the focus ring and the contrast when editing; that state is the whole
// reason this file has styles.
import * as React from "@uniflowed/react";
import type { StyleArgument } from "@uniflowed/stylex";
import { props, stylex } from "@uniflowed/stylex";
import { ufTokens } from "@uniflowed/stylex/tokens.stylex.js";
import { VisuallyHidden as HeadlessVisuallyHidden } from "@uniflowed/ui";
type Rest = { readonly key?: empty, readonly [string]: mixed };
const styles = stylex.create({
shown: {
position : "fixed",
insetBlockStart : ufTokens.space2,
insetInlineStart: ufTokens.space2,
zIndex : 50,
paddingBlock : ufTokens.space2,
paddingInline : ufTokens.space3,
fontFamily : ufTokens.fontSans,
fontSize : ufTokens.textSm,
color : ufTokens.ink,
backgroundColor : ufTokens.surface,
borderWidth : "1px",
borderStyle : "solid",
borderColor : ufTokens.border,
borderRadius : ufTokens.radiusSm,
outlineWidth : "2px",
outlineStyle : "solid",
outlineColor : ufTokens.focus,
outlineOffset : "2px",
},
});
export component VisuallyHidden(
children? : React.Node,
focusable?: boolean = false,
xstyle? : StyleArgument,
className?: string,
...rest : Rest
) renders HeadlessVisuallyHidden {
return (
<HeadlessVisuallyHidden
{...forwarded(rest)}
focusable={focusable}
// Only visible while a `focusable` one holds focus; hidden, the
// primitive's inline style wins over any class.
className={classNames(props(styles.shown, xstyle).className, className)}
>
{children}
</HeadlessVisuallyHidden>
);
}
function forwarded(rest: Rest): $FlowFixMe {
return rest;
}
function classNames(...names: $ReadOnlyArray<?string>): string | void {
const present = names.filter((name) => name != null && name !== "");
return present.length === 0 ? undefined : present.join(" ");
}
The headless parts: @uniflowed/ui
Reach for the parts directly when your design system lives somewhere else,
when your styling is not StyleX, or when a surface is unusual enough that an
owned component would be edited beyond recognition.
Owned or headless compares the two.
@uniflowed/ui ships behaviour and no styles. Every part takes className and
every other DOM prop and passes it through; what it contributes is the keyboard
map and the screen-reader wiring, which is the half that does not show up in a
screenshot and the half that is missing from almost every hand-written
component.
Each primitive implements the WAI-ARIA authoring practices pattern for it — the
roles, the aria-* wiring, the focus management and the whole keyboard map.
Each module's header says which interaction it exists to get right and what a
naive version breaks; this page is the shorter version.
// @flow// One entry point, and one name per component: `Select` is the namespace,// `Select.Root` and `Select.Option` are its parts.import { Select } from "@uniflowed/ui";
The confirmation: alertdialog, no dismissal by a press outside, focus on Cancel
alert-dialog.js
Sheet
A modal dialog attached to an edge, and one data-side contract
sheet.js
Drawer
The sheet you can drag away, with the keyboard WCAG 2.5.7 requires
drawer.js
Sidebar
Navigation beside the page that becomes a modal sheet on a narrow one
sidebar.js
Menu
Menu button: arrow keys, typeahead, submenus, Escape stacking, checkable items. shadcn's Dropdown Menu is this.
menu.js
ContextMenu
The same menu opened by the right button, by Shift+F10 and by a long press, at the pointer
context-menu.js
Menubar
A row of menus as one control: one tab stop, and arrows that walk between the open ones
menubar.js
NavigationMenu
Disclosure navigation: a named <nav>, expanding groups, ordinary links
navigation-menu.js
Combobox
Editable combobox: aria-activedescendant over a list you filter, in groups or not
combobox.js
Select
Select-only combobox: typeahead, option groups, a form value
select.js
Popover
A dialog that is not modal: focus in, Escape out, Tab leaves
popover.js
Tooltip
A phrase on hover and focus that WCAG 2.1 SC 1.4.13 would accept
tooltip.js
HoverCard
The preview a name expands into, with links Tab can reach
hover-card.js
Tabs
Tabs: a roving tabindex, automatic or manual activation
tabs.js
Calendar
A month as one tab stop: two axes, month boundaries, and a live region that says which month
calendar.js
DatePicker
The field, the button and the calendar in a popover
date-picker.js
Toast
Notifications: a live region that was already watching, and timers that stop
toast.js
Slider
A value in a range, with the slider role on the thumb
slider.js
Resizable
The window splitter: a separator that behaves like a slider
resizable.js
Progress
How far along something is, or that it is under way at all
progress.js
Table
The sort that is announced, the selection that can be mixed, the rows a page is not showing
table.js
Pagination
Navigation with a name, one current page, and a sentence saying it moved
pagination.js
Field
The label, description, error, aria-invalid and aria-required wiring — for one control or for a group
field.js
Switch, Checkbox
The two two-state controls, including mixed, and the Enter that submits rather than toggles
switch.js, checkbox.js
Carousel
Slides that can be stopped, and that Tab cannot fall into
carousel.js
ScrollArea
An overflow container a keyboard can actually scroll
scroll-area.js
InputOtp
Six boxes drawn over one real <input>, so autofill still works
input-otp.js
Breadcrumb
A named <nav> around an ordered list, one aria-current="page", and separators nobody hears
breadcrumb.js
Alert
A callout, and the live region it must not be until something happens
alert.js
Avatar
Loading, loaded, failed — and the alt that is empty on purpose
avatar.js
Separator
A rule a reader is told about, or one that is only a rule
separator.js
Skeleton
Grey boxes nobody hears, in a region that says it is loading
skeleton.js
VisuallyHidden, announce
Text only a screen reader gets, a skip link that appears on focus, and one live announcer for the whole document
visually-hidden.js
Composition is type-checked. Tabs.List declares renders* Tabs.Tab, so a
<button> in a tab list is a type error rather than a review comment;
Menu.Body and Select.List state the same constraint about what may appear
inside a menu and a listbox, which ARIA also requires and which nothing else
checks.
Thirteen containers here state a constraint like that, and each of them is held
to it. tests/type-tests/composition.js writes out the wrong child for every
one — a <button> in a tab list, a <div> in a listbox, a Select.Option in
a menu, a render function that returns something that is not a Toast.Root —
beside the composition that must keep compiling, and uf check is run over the
pair. The guarantee that cannot be removed without a test going red is the only
kind worth stating on this page.
What the suite holds
The reference below is prose, but the contract is executable. The UI suite is
structured around the failures these primitives are meant to prevent:
Contract
Held by
Composition types reject the wrong child before a browser exists
tests/type-tests/composition.js, run from npm/ui/ui.test.js
Overlay side, alignment, modal role, input alphabet and orientation are checked unions
tests/type-tests/anchoring.js and tests/type-tests/overlays.js
A caller's className, id, data-*, aria-*, handlers and refs arrive without replacing the component's own behaviour
the prop-composition block in npm/ui/ui.test.js
render exists only where it is implemented, and every exported part is classified
the render-hatch table in npm/ui/ui.test.js
No aria-labelledby, aria-describedby, aria-controls or aria-activedescendant points at a missing element
the dangling-reference helper used across the suite
Keyboard behaviour is asserted as user-visible focus, state and announcement changes
the component blocks below, one primitive at a time
Those tests are intentionally not snapshots. A snapshot would preserve markup
while missing the thing this package exists to preserve: whether a user can
reach the control, hear its name, move it with the right keys, and leave it
again.
Choosing between similar parts
Most mistakes with headless UI happen between two components that look alike in
the screenshot and mean different things to the browser.
Use
When the meaning is
Do not reach for
NavigationMenu
Site navigation: destinations a reader may open in a new tab, copy, or find in the list of links
Menu, whose roles are application commands
Menu
Commands such as Export, Duplicate, Mute, Archive
NavigationMenu, because links and commands have different keyboard maps
Select
The user chooses one fixed value
Combobox, unless the input text itself is editable
Combobox
The user types, filters, or accepts a suggestion
Select, which owns no text cursor
Tooltip
A short description of its trigger; it never takes focus
Popover or HoverCard, if the content has links or buttons
HoverCard
A preview that can contain links and is reachable by Tab
Tooltip, which describes rather than contains
Dialog
A modal task with focus trapped inside it
Popover, if Tab should leave into the page
Alert
A visible callout, optionally live when it appears dynamically
Toast, if the message is transient and queued outside the page flow
Switch
A setting that takes effect when it changes
Checkbox, if the control is one answer in a form being submitted
Toggle
A pressed/unpressed button state
Switch, whose name tells a reader this is on or off
What is deliberately not a component
The package refuses a few familiar names on purpose. They are useful surfaces,
but they are not primitives with a behaviour contract of their own here.
Name people expect
Use instead
Why it is not exported
Button
a real <button> plus a style preset, or uf ui add button
The behaviour is already in HTML; the design decision is visual
Card, Badge, Label, Input, Textarea
plain elements plus presets, or uf ui add card badge label input textarea
With no styles they are wrappers around one element, and wrappers lose props
Kbd, Typography, Empty, Item
<kbd>, headings, <p>, <ul> and textStyles, or uf ui add kbd typography empty item
Each part is a platform element; the only decision is which element, and that is the caller's
ButtonGroup, InputGroup
role="group" or :focus-within around your own controls, or uf ui add button-group input-group
The buttons and the input keep the platform's keys; a group that moves focus with arrows is a toolbar, which is a different contract
NativeSelect
a real <select>, or uf ui add native-select
The platform's select already is the component. Select is the custom one, for options that need more than text
Spinner
Progress with no value, or uf ui add spinner
"Amount unknown" is already Progress's decision: it omits aria-valuenow rather than announcing zero
Command
Dialog + Combobox + grouped options + useKeyCombo
The composition is the feature; a module would duplicate two finished contracts
DataTable
Table plus your table state and fetching layer
Sorting, selection and row counts are here; query/state orchestration is app-specific
FormField
Field plus the control you render
The field owns ids and errors; the form library owns values and validation
The styled half is different. A styled button, card or badge that the whole
application agrees on is a decision about that application, so the
registry carries one of each. So it does for the eight
rows after them: a keycap, a type scale, an empty state, a row, a joined button
bar, an input with addons, a native select with its chevron, and a spinner that
fades rather than turns under reduced motion. uf ui add writes it into
the project as a file the project owns, and that file passes every attribute
through to the element. The headless package declines these names, and the
registry does not: they sit on opposite sides of the same line.
This is not minimalism for its own sake. A missing component should be a clear
"compose these parts" or "not implemented yet", never a thin wrapper that looks
like progress while weakening the guarantees above.
React Aria parity
React Aria is the comparison set because it names accessible behaviours product
teams need. This is a feature audit, not a claim that every exported primitive
has React Aria's full behaviour. Implemented means code and tests cover the
named behaviour; Partial names what remains; Missing means no public
equivalent; Declined is an explicit product decision. The detailed findings
and proposed order of work are tracked in #1368.
React Aria hook or component
Status in uf 0.7.0
usePress, useHover, focus-visible, long press, move, keyboard, mergeProps, useInteractOutside
Implemented in interactions.js, with interaction tests
FocusScope, useFocusManager
Partial: dialog focus handling is internal; there is no public scope or manager
VisuallyHidden, shared live announce()
Implemented in visually-hidden.js, with screen-reader and lifecycle tests
useLandmark
Missing: only the Toast region handles F6
usePreventScroll, ariaHideOutside
Partial: scroll lock lacks iOS touch/viewport handling; a dialog does not hide siblings added after opening
ListBox, GridList, Tree, TagGroup
Partial: selection behaviour, ranges, actions and paging work; sections, interactive grid cells and localised announcements do not
Virtualizer and collection drag/drop
Partial: fixed row heights and single-item reordering only; no variable heights or cross-collection drops
Table
Partial: sorting and row counts work; range selection, grid navigation and column resizing do not
Dialog, AlertDialog, Sheet, Drawer
Implemented for focus containment, restore and nested Escape; a public global dismiss stack is Missing
Popover, Tooltip, HoverCard
Partial: positioning works; no Arrow part, and tooltip warmup is shared only inside one provider
Menu, ContextMenu, Menubar
Implemented for keyboard and submenu behaviour; hover-open safe triangles are Declined
Select, Combobox, Autocomplete
Partial: Select is single-value and opens on typeahead; Combobox lacks custom values and async loading; standalone Autocomplete is Missing
NumberField, Slider
Partial: parsing, stepping and multiple slider thumbs work; form names, wheel/hold stepping and onChangeEnd do not
Checkbox, Switch, RadioGroup
Partial: RadioGroup has form names; Checkbox and Switch lack native form participation and reset
Partial: hex and RGBA controls exist; HSL/HSB and the other colour primitives are Missing
Calendar, RangeCalendar, DateField, TimeField, DatePicker, DateRangePicker
Partial: Gregorian dates and segmented fields work; no non-Gregorian calendar or zoned date-time values, and DatePicker uses a text input rather than segmented DateField
I18nProvider, useLocale, useCollator, useFilter
Implemented for locale and direction; built-in component announcements remain mostly English
The npm/ui tests cover the shipped behaviours, but component-wide
hydration coverage and strict-mode coverage remain incomplete. A missing row
above should be treated as unavailable when planning an application.
Locale, collections and value fields
I18nProvider supplies locale, lang and dir to its subtree. It defaults to
en-US and ltr for the same server and client output; pass the request's
locale explicitly. Direction follows the locale unless overridden. Nested
providers inherit the surrounding locale. useLocale() returns both values,
useCollator(options) returns an Intl collator for application sorting, and
useFilter() provides locale-aware startsWith and contains functions.
ListBox, GridList, Tree and TagGroup accept items with unique key and
textValue strings. An item can be disabled; tree items can have children.
A children(item, state) render function supplies the visible row content.
state exposes selected, active, disabled and the one-based tree level;
custom rows can draw keyboard focus without tracking it separately.
selectionMode is single, multiple or none; controlled selectedKeys
and onSelectionChange have defaultSelectedKeys counterparts. Disabled rows
are skipped. Arrow keys, Home/End, PageUp/PageDown, collator-backed typeahead,
Space/Enter and Ctrl/Cmd+A operate on enabled rows; Shift with any movement key
or a click extends the selection from its anchor, and a second Shift movement
back towards the anchor shrinks the range rather than growing it.
The selection model is React Aria's:
selectionBehavior="toggle" (the default) flips a row on click or Space.
"replace" is the file-manager model: a click or an arrow key makes that row
the whole selection, Ctrl/Cmd+click and Ctrl/Cmd+Space add or remove one row,
and Ctrl/Cmd+arrows move without selecting. A touch or a screen reader's
activation toggles under either, because neither has a modifier to hold.
disallowEmptySelection refuses the gesture that would leave nothing
selected. Without it, choosing the selected row of a single-select collection
clears it.
Escape clears the selection. It is only claimed when it cleared something, so
the same key still closes a surrounding popover; escapeKeyBehavior="none"
turns it off.
onAction(key) opens a row instead of selecting it: Enter runs it (Space still
selects), a click runs it when selectionMode is none, and a double click
runs it under selectionBehavior="replace".
onSelectionChange receives keys in collection order, followed by any selected
key the collection is not showing — a collapsed tree child or a filtered-out
row stays selected rather than disappearing unseen.
Trees additionally accept expandedKeys, defaultExpandedKeys and
onExpandedChange; horizontal arrows expand/collapse and follow the text
direction, and * expands every sibling of the active row. Tag groups move with
the horizontal arrows (mirrored right to left) as well as the vertical ones,
and use onRemove(key) for their remove buttons and Delete/Backspace commands.
The application owns item order, removal and loading. loading exposes busy
state; onLoadMore fires at the scroll boundary or End. Set virtualized,
height and rowHeight for fixed-height rows: the active descendant stays
mounted while the rest of the DOM follows the visible window. Variable-height
rows are not measured. Give each collection an accessible label and keep
ListBox/Tree row content noninteractive.
For reordering, provide onReorder(keys) and update your items from the returned
order. Native drag/drop moves an item before its target. Ctrl/Cmd+Space lifts
the active item; arrow keys move to a target, Enter drops and Escape cancels.
Tree reordering operates on root items. The lower-level useDragAndDrop({ onDrop, disabled }) returns getDragProps(key, label),
getDropProps(key, label), start, drop, cancel, dragging and
announcement. Apply the props to focusable elements and render announcement
in a polite live region. Drop callbacks receive { keys, target }.
NumberField values are numbers or null. Input commits on blur or Enter;
invalid drafts remain visible with aria-invalid and do not change the value.
onValidationChange(invalid) lets the application display a Field.Error.
ArrowUp/Down step, PageUp/Down take ten steps and Home/End use finite bounds.
The step origin is minValue or zero; an unaligned maximum is rounded down to
the last permitted step. Percent formats default to a step of 0.01 and other
formats to 1. Currency, unit, accounting, decimal and locale digit forms are
parsed; arbitrary expressions are rejected. disabled and readOnly guard
editing and stepping.
DateField stores a Gregorian ISO date (YYYY-MM-DD); TimeField stores a
24-hour ISO time (HH:mm, or HH:mm:ss with granularity="second"). Both use
locale ordering, digits and segment names. Time display follows the locale,
with hourCycle="h12" or "h23" available as an override. ArrowUp/Down,
PageUp/Down and Home/End edit segments, horizontal arrows move between them,
and Enter or leaving the group commits a valid value. Month/year changes
clamp the day. Values, default values and callbacks use strings or null;
minValue, maxValue, required, disabled, readOnly, segmentLabels and
onValidationChange configure the fields. DateField also accepts
isDateUnavailable(isoDate). These fields do not select time zones or other
calendar systems.
RangeCalendar.Root accepts { start, end } or null, plus the single-date
calendar's bounds and isDateDisabled(plainDate) predicate. The first choice
anchors a range and the second completes it, inclusive and ordered; a range
containing an unavailable day is rejected and announced. Its Month, Day,
Previous and Next parts compose like Calendar. DateRangePicker adds two
segmented fields and a popover that restores trigger focus after a completed
choice. Typed ranges use the same unavailable-day validation as the calendar.
ColorPicker.Root controls a CSS hex string through value, defaultValue
and onValueChange. Short hex forms expand to #rrggbb or #rrggbbaa.
Input is a native color input, Field retains and marks invalid hex drafts,
Channel is a range input for red, green, blue or alpha, and Swatch
shows the current color. Channels support arrows, PageUp/Down and Home/End;
horizontal arrows mirror in RTL. Alpha is announced as a percentage. Supply
labels for Input and Field; named channels have default labels. Named CSS
colors, HSL and wide-gamut color spaces are not accepted by this hex contract.
Screen-reader-only text and announcements
VisuallyHidden keeps its content in the accessibility tree and takes it off
the screen: a one-pixel clipped box, not display: none, which would hide it
from both. focusable makes it the skip-link pattern — hidden until focus
lands inside it, on screen while focus stays:
announce(message, { politeness, timeout }) speaks through one pair of
role="log" live regions that uf keeps at the top of <body>. It is a function
rather than a component, so a Server Action's error path, an effect or a click
handler can call it without rendering a region first:
announce(`${results.length} results`);announce("Could not save the draft", { politeness: "assertive" });
It works like React Aria's LiveAnnouncer. Every message is added as a new
node, so the same text sent twice is heard twice, and it is removed after
timeout: 7 seconds by default, or never with 0. The first message waits
100 ms after the regions are created, because a region inserted together with
its content is silent. A modal Dialog leaves the announcer readable while it
hides the rest of the page. clearAnnouncements() empties the regions. On the
server announce does nothing.
Import shapes
@uniflowed/ui is the package's one entry point, and each component in the
table above is one name in it. A component with parts is a namespace, which is
the module itself re-exported (export * as Dialog from "./dialog.js"). A
single-element control is a name of its own. Types, hooks and functions stay
flat:
import { Checkbox, Dialog, Field, Select, toast } from "@uniflowed/ui";import type { DialogRole, Side } from "@uniflowed/ui";
The parts have no prefixed names. DialogRoot is gone, and uf codemod
rewrites it to Dialog.Root. Importing: one name per
component explains why. There is
no subpath to import instead. A bundler keeps the modules in the table
above that the imported names come from and drops the rest, because the package
declares sideEffects: false. internal/ is not exported; helpers such as
roving focus and prop composition are part of the primitive contracts, not a
public toolkit for building a second library beside this one.
Changing the element
Most UI packages answer "I need this menu item to be a link" with either a copy
step or an asChild clone. uf answers with an explicit render prop:
The part still computes the role, ids, state attributes, handlers, refs and
children. The caller chooses the element and spreads the props onto it. That is
why this is the escape hatch for a no-copy component library: a Menu.Item
rendered as an <a> is still a Menu.Item to Flow's renders* constraint,
still participates in the menu's typeahead, and still closes the same menu tree.
A copied file can change the element, but it cannot keep receiving fixes to the
shared roving-focus, focus-trap or prop-composition code.
The rule for render
Spread the props onto one focusable element when the part is interactive:
That renders a switch, not a presentational span, because role, tabIndex,
aria-checked, keyboard handlers and refs are part of the switch contract. If
you intentionally need to replace one of those, do it with the same care you
would use editing a copied component source: you are changing the accessibility
contract, not the styling hook.
Where it exists
The exact source of truth is the audited table in npm/ui/ui.test.js; it
fails if a part is exported but not classified. At this release line:
Pagination.Item, Pagination.Previous and Pagination.Next are included in
the Pagination row. They render through the shared PageLink helper, but the
public parts still forward render to the link element; a design-system link can
therefore sit inside the pagination contract without copying the pagination
source.
Parts in the no-element column render only context or delegate to another part.
Examples: Dialog.Root, Menu.Root, Tooltip.Provider, Collapsible.Root,
Calendar.Next and Carousel.Next. There is no wrapper there to change.
The fixed remainder is deliberately visible rather than implied. Calendar.Day,
Combobox.Option, Select.Option, Toast.Root, ScrollArea.Viewport and the
other remaining rows are still listed in the same test, so adding render to
one of them means moving it in the table and adding the behaviour case in the
same change.
Select
A button that opens a list of options and takes one of them — the select-only
combobox of ARIA 1.2, where Combobox is the editable one.
Use a native <select> if a native <select> will do
That is the recommendation and not a disclaimer. A <select> is announced
correctly by software this package has never been tested against, is a wheel
the thumb already knows on a phone, autofills, and validates. Nothing here
improves on it.
What it cannot do is be styled. Its popup is drawn by the operating system, so
an option cannot hold an icon, a second line, a shortcut or a checkmark. A
design that needs any of those has two options — this pattern, or a div with
a click handler that no keyboard reaches. Select is the first one, done
properly: role="combobox" on the trigger, typeahead, and the arrow keys
moving a cursor a screen reader is told about.
Parts
Part
Renders
Notes
Select.Root
div
value, open, name, disabled. Controlled the moment you pass value or open
Select.Label
label
Names the field. Without it the trigger has no accessible name at all
In the document only while open, and while its exit plays (data-state="closed", inert)
Select.Option
div role="option"
value, optional label, disabled
Select.Group
div role="group"
Named by its Select.GroupLabel, and only when one is rendered. Takes options and that label, and nothing else
Select.GroupLabel
div role="presentation"
Names a group of options
Select.Separator
div aria-hidden
A rule between groups. Decoration, so it says so
Select.Label names the field and Select.GroupLabel names a group of
options. shadcn has one SelectLabel and it is the second of those; a select
needs both.
Select.Label is a real <label htmlFor>and the trigger's
aria-labelledby, and the second one is not redundant. role="combobox" is not
a role that takes its name from its own content, and a <label for> does not
name a <button> either — so a select with only the label is a combobox
announced as "combobox" and nothing else, while looking correct in the markup.
The keyboard
Focus never leaves the trigger. The arrow keys move aria-activedescendant, a
second cursor naming which option is current, which is how a screen reader is
told the option changed — a highlight drawn in CSS moves the same pixels and
says nothing.
Key
Closed
Open
ArrowDown / ArrowUp
Open, cursor on the chosen option or the first / last
Move the cursor. Does not wrap
Home / End
Open, cursor on the first / last option
Cursor to the first / last option
Enter, Space
Open
Take the option under the cursor, close
Alt+ArrowDown
Open with no cursor
—
Alt+ArrowUp
—
Take the option under the cursor, close
Escape
Passes through, so a dialog around it still closes
Close, changing nothing
Tab
Moves on
Take the option under the cursor, then move on
A letter
Open, cursor on the first match
Move the cursor to the match
Two of those differ from Combobox on purpose, and the pair is the clearest
statement of why there are two components:
Home and End. A combobox leaves them to the text cursor, because a
combobox that steals them has made its own text field harder to edit than a
plain <input>. A select has no text, so they go to the ends of the list.
Tab. A combobox closes without taking the highlight: the reader typed
something, the highlight is a suggestion about text they still own, and
committing it turns "leave this field" into an edit. A select has nothing
typed and nothing to lose, so moving the cursor is the act of choosing, and
APG's rule for this pattern is that Tab takes it.
Moving the cursor does not change the value. A native <select> on Windows does
the opposite, and copying it would fire your onValueChange — and with it your
validation, your form store or your server mutation — once per arrow press.
What a form submits
Give Select.Root a name and it renders a hidden control carrying the
value:
Without a name nothing is submitted, which is right for a select that drives a
filter. With one, the entry carries GB and not "United Kingdom" — the bug this
exists for, since a div wearing an ARIA role is not one of a form's listed
elements and a form collected nothing at all for it. A disabled select submits
nothing, as a native <select disabled> does. Combobox.Root takes the same
name and does the same thing, for the same reason: Combobox.Input renders
the label, which is not the value.
It is an <input type="hidden"> rather than a concealed <select> on purpose.
A real <select> is focusable, so a reader tabbing in hears the styled combobox
and then a second, invisible one with the same options; aria-hidden on a
focusable element is itself the violation it would be reached for to avoid. Two
things are given up with it, and both are written down rather than hidden: the
browser will not autofill a hidden input, and required gets no native
constraint validation. The second is not a loss here — the browser reports a
constraint failure by focusing the control and drawing a bubble at it, and on a
control with no box it refuses to submit the form with nothing shown to the
reader — so a headless select's required belongs to @uniflowed/validator,
with the message in Field.Error.
With @uniflowed/form
No name, and no import in either direction. That library keeps its values in
its own store and prevents the native submission, so it never builds a
FormData and never sees the hidden control. The binding is useController,
whose own header names this exact case:
In order: its own children if you passed any; the label of the option with
that value, learned from the options themselves and remembered after the list
closes; the value itself; the placeholder when nothing is chosen at all.
The third of those is a boundary worth knowing about. A select whose value
arrived from outside — a defaultValue from a saved form — has no option
rendered for it until the list has been opened once, so the trigger shows GB
rather than "United Kingdom". That is wrong and true; showing the placeholder
there would be wrong and confident, telling a reader nothing is chosen when
something is. Pass children to say what the value is called:
shadcn's Select has ScrollUpButton and ScrollDownButton. Both exist to
scroll a popup that Radix positions and sizes; this package positions nothing,
so a scroll button here would be a button with no idea what to scroll. The
behaviour they are actually for — the cursor staying visible as the arrow keys
move it — is already in the component, as the scrollIntoView({ block: "nearest" }) every move performs, and it works inside your own scroll
container without either button.
Combobox: groups, and the palette they make possible
The rest of Combobox is in the guide and in combobox.js's own
header; this is the part Select had and it did not.
Named by its Combobox.GroupLabel, and only when one is rendered
Combobox.GroupLabel
div role="presentation"
Names a group of options. Combobox.Label names the field
Three things follow from a listbox being allowed to own option and group
and nothing else.
Combobox.List and Combobox.Option are divs. They were a <ul> of
<li>s, and an <li> cannot contain an <li>, so a group could not hold
its options without a second list role between them. If your stylesheet
names ul or li inside a combobox, this is the change to make.
The arrow keys still move by option. A group is not a listbox, so the
keys cross a boundary without stopping, and the heading is never somewhere
the cursor can land — it is not an option.
There is no Combobox.Separator. A rule between two groups cannot be a
role="separator" inside a listbox, so it is aria-hidden decoration, which
is a div you write. Select.Separator exists because a select's options
are a fixed list; a combobox's are whatever survived your filter, and a fixed
rule between them is decoration in the wrong place.
Combobox.Status counts options, not headings.
A command palette
There is no Command component and there will not be one. A palette is a
Combobox inside a Dialog, and now that groups exist every part of it is
already here:
useKeyCombo is @uniflowed/hooks/keyboard. The active
option scrolls itself into view, the count is announced, and Escape closes
the list before it closes the dialog, because the innermost thing wins.
Shipping that composition as a seventh module would be a module whose entire
content is what you just read — the argument ubugeeei-redundancy.md makes
against small lookalikes, applied to this package. So it is a page.
One thing a Command module would genuinely add is not here, because it is not
implemented anywhere: a palette whose filter matched nothing still traps focus,
so Tab cycles around an empty dialog while the reader is told there are no
results. That is a question about Dialog — a modal with nothing to reach is
the general case — and it is open rather than answered by a component that
would only look as though it had.
Tabs
Tabs are a set of panels with one selected, and one tab stop for the whole list.
That second half is the component: a row of twelve buttons is twelve Tab
presses before the reader reaches the panel, while a tab list is one stop and
the arrow keys do the movement inside it.
defaultValue is required. Controlled the moment value is given
Tabs.List
div role="tablist"
renders* Tabs.Tab; owns the arrow-key handler, and writes where the selected tab is for a sliding indicator
Tabs.Tab
button role="tab"
value, optional disabled; exactly one tab has tabIndex={0}
Tabs.Panel
div role="tabpanel"
Automatic activation keeps it mounted and hides it; manual activation renders it only while selected. Labelled by its tab
The root can be rendered as another element, but it is not the interesting
part. The contract is between the list, the tabs and the mounted panel:
aria-selected says which tab is active, aria-labelledby points the panel
back to that tab, and aria-controls only appears on the selected tab. A hidden
panel can be in the document and still not be named: display: none is not
somewhere a reader can land, and naming it tells them there is somewhere to go.
Tabs.List is one of the places composition is checked rather than wished for:
it accepts renders* Tabs.Tab. A stray <button> in the list is a type error,
because it would be announced as a button among tabs and the keyboard handler
would still be trying to treat it as part of the tab set.
The keyboard
Only the selected tab is in the page's tab order. Tab moves into that tab, and
the next Tab moves into the selected panel. The arrows move between tabs
without making the reader visit every tab as a separate stop.
Key
Horizontal list
Vertical list
ArrowRight / ArrowLeft
Previous or next tab, mirrored in right-to-left pages
Left to the page
ArrowDown / ArrowUp
Left to the page
Next or previous tab
Home / End
First or last tab
First or last tab
Enter, Space
Select the focused tab
Select the focused tab
The keys the component does not take are as important as the keys it does.
ArrowDown in a horizontal tab list scrolls or reads the page; swallowing it
would make a tab component break a reader's ordinary way through the document.
Disabled tabs stay in the accessibility tree with aria-disabled="true", so a
reader hears that the section exists and is unavailable, and the roving focus
steps over them.
Automatic and manual activation
The default is automatic activation:
<Tabs.Root defaultValue="preview"> …</Tabs.Root>
When focus moves to a tab, that tab is selected. This is the right default for
panels that are already present and cheap to show: reaching the panel is one
arrow key, not an arrow key and then a second confirmation.
Manual activation is for a panel that costs something to reveal:
There the arrows move focus only, and Enter or Space selects. A reader
arrowing from the first tab to the fourth should not start three fetches, mount
three expensive trees, or hear three panels they were only crossing on the way.
That is why this is a prop on the root rather than a styling convention: it is
a behaviour decision, not a presentation decision.
Panels are mounted facts
Automatic activation keeps every panel mounted. The one that is not selected is
hidden with <Activity>, so a field there keeps what was typed, and a panel
that reads a promise can have it ready before it is shown. Hiding is
display: none: the panel is not announced, and find-in-page does not land in
it. Each panel's <Suspense> is its own — pass fallback for what the wait
shows — so a suspending panel replaces itself and not the tab list.
<ViewTransition> plays the enter and the exit when the selection changes, and
plays nothing when the reader has asked for less motion.
Manual activation still returns null for a panel that is not selected, so
passing it starts nothing.
Either way a panel registers only while it is selected, and that registration is
what lets its tab write aria-controls. The root does not assume a panel exists
because a tab with the same value exists; callers may render only a subset, wait
for data, or put a tab in the list before its panel is ready.
The panel itself has tabIndex={0}. That is deliberate even when the panel only
contains prose: Tab out of the tab list lands on the content the tab selected,
which is where the reader expects to go. If the panel contains controls, the
browser moves on from the panel to the controls normally.
Toast
Notifications, and the live region that was watching before them.
// @flowimport { Toast, toast } from "@uniflowed/ui";// Once, in the layout.<Toast.Region> {(each) => ( <Toast.Root> <Toast.Title>{each.content}</Toast.Title> <Toast.Action onClick={undo}>Undo</Toast.Action> <Toast.Close /> </Toast.Root> )}</Toast.Region>;// Anywhere at all.toast("Saved");toast("Could not save", { urgency: "assertive" });
The bug it exists for
Every hand-written toast mounts a <div role="alert"> at the moment the
message arrives, and that is exactly the commit that makes it silent: a screen
reader announces a live region's changes, so it has to have been watching the
region before the change. Mounting the region and its first message together
gives it nothing to have been watching.
It looks perfect on screen, it passes review, and the reader who most needs to
be told that the save failed is told nothing. Toast.Region renders on mount
and stays, holding nothing; notifications are appended into it. That is the
whole component in one sentence, and everything else here follows from it.
Parts
Part
Renders
Notes
Toast.Region
div role="region"
The named landmark, and the two live regions inside it. Render it once
Toast.Root
div role="group"
One notification, and its countdown
Toast.Title
div
Names the group
Toast.Description
div
Describes it
Toast.Action
button
Undo, Retry, View. Taking it dismisses the notification
Toast.Close
button
label becomes its accessible name, defaulting to "Dismiss"
Function
What it does
toast(content, options?)
Queues a notification; returns its id
updateToast(id, changes)
Changes one already queued
dismissToast(id)
Takes one away
dismissAllToasts()
Empties the queue
Toast.Region takes label (default "Notifications") and limit (default
3). toast takes urgency ("polite" or "assertive") and duration in
milliseconds, or null for one that never expires.
Two regions, not one
"Saved" and "could not save" interrupt a reader differently and should, and
which of the two a notification is belongs to the notification rather than to
the application. That cannot be a role that changes: swapping a live region's
role while it is being watched is the same bug as mounting it late. So there
are two regions inside the landmark, role="status" and role="alert", both
mounted from the start and both empty, and a notification is appended into the
one that matches it.
What that costs is chronological order between the two: a failure and a success
on screen together are in separate containers, and no CSS interleaves them. It
buys the thing that matters more, which is that what a reader hears is exactly
what a reader sees — the alternative, one visible stack plus hidden announcers
mirroring its text, puts every notification in the document twice.
Both regions are aria-atomic="true", so a change presents the whole region.
With one notification showing that is exactly right. With three, adding a fourth
reads all four — so limit is not only how tall the stack may get, it is how
much a reader is made to listen to.
Focus, and the key that gives it
Nothing here moves focus when a notification appears. That interrupts whatever
the reader was typing, and it is the single failure that makes people turn
notifications off.
But a notification carrying an Undo button that vanishes after four seconds is a
control no keyboard reader can operate, so the region is a named landmark and
F6 — the key the APG suggests for moving between panes — moves focus into
it. Focus lands on the region itself rather than on the first button in it, so a
screen reader reads the region's name and its contents; Tab from there reaches
the buttons. Pressing F6 again while focus is inside gives it back to wherever
it came from. The binding fires while the reader is typing, because a
notification that arrives during a form fill is the one most likely to be about
the form.
Timers that stop
WCAG 2.2.1, Timing Adjustable, applies to anything that disappears on its own.
The countdown stops while the pointer is over a notification, while focus is
inside it, and while the document is hidden — the third because a reader who
switches tabs for a minute should not come back to an empty region.
It stops; it does not restart. What is left is banked and resumed, so brushing
the stack with the pointer does not hand a notification its whole life again.
Anything carrying an action should be given duration: null and dismissed by
the action or the close button: the countdown stopping while focus is inside
makes the button reachable, and that is not a promise that five seconds was
enough time to decide.
The queue
toast("Saved") is called from an event handler, from a catch, from a Server
Action's error path — none of which have a component to put state in. So the
queue lives outside React as an atom in @uniflowed/state, declared at module
scope in toast.js and read through React's external-store hook. One page has
one set of notifications: a function in a module has no context with which to
find a scoped store, so Toast.Region reads the same default store that
toast() writes.
The queue is what makes the fiddly parts ordinary. limit notifications are
shown and the rest wait behind them, oldest first — and a waiting one's
countdown has not started, because it is not rendered. updateToast changes one
in place, so "Uploading…" becoming "Uploaded" is one notification rather than
two contradicting each other.
A value in a range
Three components report a number between two others, and all three make the
same four promises through aria-valuemin, aria-valuemax, aria-valuenow
and aria-valuetext. The arithmetic that keeps those true about each other
lives once, in internal/range.js.
role="slider" goes on the thumb, not on the track and not on the wrapper.
That single placement is the difference between a control a keyboard reader can
operate and a decorative div, because the element carrying the role is the
element carrying tabindex="0".
It follows that a range is two sliders. Two thumbs are two elements with
role="slider", each in the tab order, each named — "Minimum" and "Maximum" —
and each bounded by its neighbour's current value rather than by the slider's
ends. Announcing both as 0–100 while the behaviour stops them passing each
other is worse than not shipping the range at all: the reader is told they may
set the low thumb to 90, they try, and the control silently refuses.
Key
What it does
ArrowRight / ArrowUp
Add a step
ArrowLeft / ArrowDown
Subtract a step
PageUp / PageDown
Move by largeStep
Home / End
That thumb's own ends, which for a range thumb is its neighbour
The horizontal keys mirror in a right-to-left page — ArrowRight means
"further along", and further along is to the left there. The vertical keys and
Home/End are unaffected.
valueText is the reason to reach for this component rather than an
<input type="range"> you cannot style. aria-valuenow="3" is announced as
"3"; if the scale is Low, Medium, High then 3 is the implementation and
"Medium" is the meaning. It is a function on the root rather than a string on
the thumb, because an uncontrolled slider's value is the component's and a
caller cannot write down a string for a number they have not been told.
A press anywhere on Slider.Track moves the nearest thumb there. That is WCAG
2.5.7, Dragging Movements: a control operated by dragging needs a way that is
not a drag, and the arrow keys are only half of it.
Nothing here has a width or a position. Each thumb carries
--uf-slider-fraction and the range carries --uf-slider-start and
--uf-slider-end, all numbers between 0 and 1, and your stylesheet decides
what to do with them. A caller who writes no CSS sees nothing.
The APG calls this a window splitter and defines it as a separator that
behaves like a slider, which is why it is documented beside one. Almost every
resizable panel on the web is pointer-only — a WCAG 2.1.1 failure, no keyboard
operation at all — so here the keyboard came first: the arrows move the
splitter by a step, Home and End go to the ends of its range, and Enter
collapses the primary pane and restores it.
It drags too. A press takes hold of the handle without moving it, the pointer
is captured so a drag that wanders off a four-pixel bar keeps arriving, and the
position is read out of the group's box — so panes laid out with flex, with
grid or by hand all behave the same. aria-valuenow follows the drag, which is
the half a pointer-only splitter has nothing to say about.
aria-controls names the primary pane, and only while one is rendered.
aria-valuenow is that pane's percentage of the group.
step is the keyboard's. It defaults to 10 because ten presses of an arrow
key ought to cross the pane, and snapping a drag to it would move the bar in
tenths under a pointer that moves smoothly. A drag moves by one percent, which
is not a second prop to set: the value is already a percentage of the group, so
one is the smallest move that means anything, and both paths go through the
same snapping and clamping — a drag cannot leave the pane anywhere an arrow key
could not put it.
min and max bound the drag rather than scaling it. Dragging to five percent
of a group whose min is 20 leaves the pane at 20; the handle stops where the
pane stops, instead of the pointer being mapped across the range the way a
slider's is.
Two more things are worth knowing:
aria-orientation describes the separator, not the layout. Two panes
side by side are divided by a vertical line, so a PanelGroup whose
orientation is "horizontal" renders a handle whose aria-orientation is
"vertical". ARIA's default for the role is horizontal, so a vertical
splitter that says nothing is announced as a horizontal rule.
This is not Menu.Separator. Both are role="separator" and only one is
a control. A menu's separator is a rule between groups: not focusable, no
value. A splitter is focusable, carries a value, and changes the layout.
Two panes is the pattern rather than a limitation — the window splitter is
defined between a primary pane and the rest. A group of five panels is a
layout-constraint problem and a different component.
The second one is indeterminate, and its one line is the line everybody gets
wrong: a progress bar that does not know how far along it is omits
aria-valuenow entirely. It does not set it to 0.
The two are opposite statements. aria-valuenow="0" says "nothing has happened
yet", and a reader who asks again in ten seconds and hears zero again concludes
the operation is stuck. Omitting it says "in progress, amount unknown", which
is what a spinner means and what is actually true.
valueText replaces what is announced without touching aria-valuenow, so a
reader hears "3 of 10 files" and anything drawing a gauge still has the number.
It is a prop rather than something you add afterwards because the two have to
agree.
This one renders on a server: it holds no state, listens to nothing and manages
no focus.
The markup is not what this owns. A <table> with <th scope="col"> and a
<caption> is already accessible, and a component that only renames those
elements has added a dependency and no behaviour. What it owns is four things
that are invisible until somebody uses a screen reader.
Sorting.aria-sort goes on the sorted header only — omitted on the
others, not set to "none", because eleven headers each announcing "not
sorted" is eleven announcements of nothing on every pass through the table. The
header's content is a button, so the sort is reachable at all. And the
re-order is announced: the rows change places, nothing a reader is looking
at moves, and without a sentence they are told nothing happened.
Selection.Table.SelectAll takes checked: boolean | "mixed", and the
union is the point. A boolean would let you pass false for "two of three rows
are selected", and a reader would be told nothing is selected while three
checkboxes below say otherwise. Choosing a mixed box reports true — select
all — which is the state a reader expects it to move to, and not the "clears
itself on the first click" behaviour every table gets wrong.
Table.RowSelectrequireslabel. "Select row" repeated forty times is
forty identical announcements with no way to tell which row you are on.
Counting.rowCount is how many rows the whole set has — five hundred
people, not the ten on this page — and rowOffset is where the rendered ones
start. The component works out aria-rowcount (the data plus the header row)
and each row's aria-rowindex, because a reader on page ten being told "row 1
of 10" looks exactly like a reader being told the truth. A table showing
everything it has needs neither, and gets neither.
table, not grid
"Make it a role="grid"" is the advice that circulates and it is usually
wrong. A grid takes the arrow keys away from the reader and gives them to the
component — right for a spreadsheet, wrong for a list of records, because a
screen reader already has table-reading commands that work perfectly on a plain
<table> and readers rely on them.
There is no role="grid" opt-in here, and that is deliberate rather than
missing. A grid is not an attribute; it is a two-dimensional keyboard contract,
and role="grid" without one is strictly worse than the table it replaced,
because the reader is told the arrow keys will do something and they do
nothing. An editable grid is a component of its own.
DataTable is not here, for shadcn's own reason
The benchmark ships a guide rather than a component, and composes TanStack
Table with its Table. uf has no TanStack Table equivalent — @uniflowed/query
is the fetching layer, not table state — so a DataTable would mean shipping
that library first. Table is the half that is uf's to own: the accessibility
of a table whose state you hold.
A <nav> with a name, because a page has more than one and an unnamed one is
announced as "navigation" with no way to tell it from the site's menu. The
current page is aria-current="page" — not a class, not bold text. Previous and
next are named in words, because "link, single left-pointing angle quotation
mark" is not something anybody can act on; the glyph stays, the name is words.
And the change is announced, in a region that was already in the document.
The controls are <a> elements, which constrains you and is meant to: page four
of a table is a place, and a reader expects to open it in a new tab, copy it
and come back to it. A list paginated with buttons is a list whose fourth page
does not exist as far as the rest of the web is concerned. An application that
genuinely has no URL for a page is the case where this is the wrong component.
disabled drops the href rather than adding an attribute, because there is no
such thing as a disabled link — an <a> with no href is out of the tab order
and is not announced as a link, which is exactly what "there is no previous
page" means.
The five that look like a class list and are not
Alert, Avatar, Breadcrumb, Separator and Skeleton are one or two
elements each. They are here rather than in @uniflowed/stylex/preset — where
Badge, Card and Button went — because the line this package draws is not
"styled versus headless" but whether the thing has a decision in it, and
each of these is a decision that is invisible until somebody is listening to the
page rather than looking at it.
Pagination's shape one door along, and the same three things. A <nav> with a
name, because a page has more than one and an unnamed one is announced as
"navigation" — Breadcrumb is the name assistive technology's own documentation
tells readers to look for, and label translates it. The separators are
aria-hidden, so the trail is not read as "Home slash Settings slash Billing";
they are <li> elements carrying role="presentation", because an <ol> may
hold nothing else and the count a reader is given has to be the number of places
rather than the number of places plus the punctuation.
And the last crumb is a Breadcrumb.Page rather than a link, because it is where
the reader already is. The shape this is usually copied with gives it
role="link" with aria-disabled, which announces "link, dimmed" — a control
the reader cannot use, instead of the place they arrived at.
Alert
<Alert.Root> <Alert.Title>Your trial ends on Friday</Alert.Title> <Alert.Description>Add a card to keep your projects.</Alert.Description></Alert.Root>{error != null && ( <Alert.Root live> <Alert.Title>Could not save</Alert.Title> <Alert.Description>{error}</Alert.Description> </Alert.Root>)}
The first has no role at all, and that is the component. role="alert" is a
live region — it announces changes — so an element carrying one that was in
the document when the page loaded announces on insertion or not at all. A
permanently rendered "your trial ends soon" box with that role is therefore an
interruption on every page load, on the engines that treat the initial render as
an insertion, or silence on the ones that do not. Neither is what anybody wanted
and neither shows up in a screenshot.
The second appeared because something happened, which is what the role is for.
Field.Error is the same call made for a form message.
There is no polite version, deliberately. role="status" is polite, and a polite
region has to have been in the document first, empty, before it will announce
anything — which a component you render at the moment the thing happens never
was. The polite, page-level shape is Toast, which exists to have been
watching already.
Alert.Title is a real heading, because a heading is how a screen reader user
reaches a region of a page without reading the page. level is yours for the
reason Accordion.Header's is; the default is 3 and anything outside the six
HTML has is clamped, since <h9> is announced as nothing at all.
Three states, not two: loading, loaded and failed. The version with two renders
the fallback whenever the image has not painted, which on a cached image is a
flash of somebody's initials for one frame on every navigation, for ever. So the
fallback is held back while the image is loading — delay, 300 ms by default,
0 to show it at once — and appears immediately for the two cases that are
answers rather than waits: an image that failed, and an avatar with no
Avatar.Image in it at all.
The image is also asked whether it has painted, in an effect, and not only
listened to: a cached image can finish before React attaches onLoad, and a
component waiting for an event that already happened waits for ever. It is asked
in one direction only. complete with pixels means loaded; complete without
them is not read as a failure, because that is also what a DOM which does not
fetch images says about a perfectly good src, and the error event is what
reports a real one.
alt defaults to "". An avatar almost always sits beside the name of the
person it is a picture of, and a component that helpfully puts that name in alt
makes every screen reader say it twice. Pass alt where the picture is the only
thing identifying the person — a bare grid of faces, a comment with no byline.
The fallback's content is announced, because this component cannot know whether
"AL" is a decoration beside a name or the whole of the row; a decorative avatar
wants aria-hidden on its fallback for the same reason its image wants alt="".
A rule between two groups of content is role="separator" with an
aria-orientation, and it is the only way a reader who is not looking at the
page is told the subject changed. A decorative rule — the hairline under a
heading, the line inside a card — is aria-hidden and announced to nobody.
The default is the semantic one, because the two mistakes do not cost the same: a
rule wrongly announced is noise a reader can hear and skip, and a boundary
wrongly silent is information that is simply not there.
Use an <hr> where an <hr> will do — it carries role="separator" already.
This exists for what it cannot be: it arrives with a border and a margin from the
browser's own stylesheet, which a package that ships no styles cannot ship, and
it is horizontal by definition. Menu.Separator is the rule inside a menu, which
the arrow keys have to skip; Resizable.Handle is a separator that behaves like
a slider, and is a control.
A screen of skeletons tells a sighted reader that content is coming. To everybody
else it is a screen of empty <div>s, and the honest summary of the page is that
it has none — which is the conclusion somebody reaches just before they leave.
Three attributes fix it and none of them is on the grey box's class name. The
boxes are aria-hidden="true", because a placeholder is a picture of content.
The region is aria-busy="true", which is what says a subtree is being filled in.
And a live region says so out loud, because aria-busy is a property a reader
may ask about rather than an announcement they are given.
That last one is the hard part, and it is why this is a component. A skeleton
screen is busy on its first render, so the obvious version — render
<div role="status">Loading…</div> while pending — mounts the region with the
sentence already inside it, which is the one arrangement a screen reader does not
announce, and then unmounts the lot when the content arrives. It says nothing,
ever. Skeleton.Root renders the region empty and fills it one commit later.
Which is also the one thing it asks of you: keep the root mounted across the
load and toggle busy. Unmount it when the content arrives and the region goes
with it, so "loaded" is said to nobody and the reader is left holding the last
thing they heard. label and doneLabel are the wording.
A child that suspends can be the wait. Pass fallback and omit busy:
<Suspense> shows the fallback, the region is busy for exactly that wait, and
<ViewTransition> reveals the content unless the reader has asked for less
motion.
Popover, Tooltip and HoverCard are one component seen from three
distances: one you click, one you hover, and one you hover and then read. They
share where they go and they share nothing else, because what a reader is told
differs in every one.
Opens on
Role
Takes focus
Describes its trigger
Popover
Click, Enter, Space, tap
dialog, never aria-modal
Yes, and gives it back
No
Tooltip
Hover, focus
tooltip
Never
aria-describedby, while it is open
HoverCard
Hover, focus
None
No, and Tab reaches into it
No
A popover is not a Dialog with a flag
Dialog is modal and only modal: it writes aria-modal, marks everything
outside inert, locks the page's scroll and traps Tab. Every one of those is
wrong for a popover, and the last one is the one that matters — Tab out of a
popover is how a reader leaves it, and a trap turns it into a hole in the page.
So they are two modules rather than one with a modal prop: a flag would be one
flag every behaviour had to read, and the behaviour that forgot would announce a
modal that is not one.
Focus moves to the first thing worth acting on when it opens. Escape closes it
and gives focus back to the trigger. A press outside closes it and leaves focus
where the reader put it. Tab past the last control inside leaves — and
dismisses the popover on the way, because a non-modal overlay the reader has
left is one they can no longer press Escape at.
Its accessible name comes from the button that opened it, unless you pass
aria-label or aria-labelledby, in which case yours is kept.
A tooltip, and the specification it usually fails
WCAG 2.1 SC 1.4.13, Content on Hover or Focus, requires content shown that way
to be dismissible with Escape, hoverable — the pointer can travel onto
it without it vanishing — and persistent. A title attribute fails all
three. A div that appears on :hover fails the first two.
The tooltip never takes focus and holds no tab stop: a focusable tooltip is a
stop in the tab order the reader did not ask for and cannot leave in the
direction they expect. It carries role="tooltip", it is named in the trigger's
aria-describedby only while it is in the document, and it has no
aria-haspopup — that attribute promises something to interact with, and a
reader sent to a tooltip finds nothing there.
The pointer waits; focus does not. A reader who tabbed to a control has
already said what they want, and a delay exists to filter out intent nobody
expressed. Tooltip.Provider shares one clock between a group of tooltips, so
the second icon in a toolbar opens at once rather than making a reader who has
already waited out the delay wait it out again — that is skipDelayDuration,
the window after one closes during which the next opens immediately. A tooltip
outside a provider is a complete tooltip with a delay of its own.
Tooltip.Trigger renders a <button>, or whatever you pass to render:
Whatever it renders has to be something the keyboard can reach, and the
component throws if it is not. A tooltip on a <span> is one only a mouse can
find, and it looks perfect in the markup.
It does not open on touch. There is no hover on a touch screen, and the only
gesture left is the tap the control itself needs — a tooltip that opened on it
would either swallow that tap or show something the next tap dismisses. What
follows is a rule for you rather than for the component: a tooltip may not be
the only place something is said, so give an icon button an aria-label and let
the tooltip repeat it.
It is not a tooltip, because its contents are reachable — by pointer and by
Tab — and not a dialog, because nothing about it is modal and announcing a
dialog the reader never asked for is worse than announcing nothing. Its content
sits immediately after the trigger, so the reading order carries it. Escape
closes it, and gives focus back to the trigger when focus was inside.
Like a tooltip it does not open on touch, which makes it an enrichment: the
link under it has to go somewhere useful on its own, because a reader on a phone
will only ever get the link.
Where they go
All three take the same placement props:
Prop
Default
What it does
side
"bottom", "top" for a tooltip
Which side of the trigger it opens onto
align
"center"
Where it sits along the trigger's other axis
sideOffset
0
The gap between the trigger and the overlay
alignOffset
0
A nudge along the other axis, in the direction the page reads
avoidCollisions
true
Whether it may move to stay on the screen
collisionPadding
0
How close to the edge of the page it may come
side is a union of "top" | "right" | "bottom" | "left" and align is
"start" | "center" | "end". Misspelling one is a type error at the call rather
than an overlay that quietly opens somewhere else, and
tests/type-tests/anchoring.js is that promise written down as the code
that must fail to check, and tests/type-tests/overlays.js is the same file for
a sheet's edge, a sidebar's side, a modal's role and what a one-time code is
made of.
When there is no room, the overlay does two things, and needing both is why it
is measured rather than left to CSS:
it flips to the opposite side — the menu near the bottom of a long page
that opens upwards;
it slides along the trigger to stay on the screen. A flip alone cannot
answer an overlay that is wider than the room on either side of a trigger
near the edge: both alignments overflow, and only sliding it back brings it
in.
Where it ended up is reported on the overlay as data-side and data-align, so
a stylesheet can point an arrow without measuring anything. data-align is
always the alignment you asked for — a slide moves where the overlay sits, not
which end of it the arrow belongs on.
Five custom properties are written on the overlay, and two of them are
measurements you cannot make from CSS:
Property
What it holds
--uf-anchor-trigger-width
The trigger's width, for a popup that must match it
--uf-anchor-trigger-height
The trigger's height
--uf-anchor-available-width
How much room there was across the page
--uf-anchor-available-height
How much room was left on the side it chose
--uf-anchor-shift
How far it slid, so an arrow can be moved back
Menu.Body, Combobox.List and Select.List use the same module, which is
what makes it a primitive rather than three copies: a menu in a table row is no
longer cut off at the row's edge, a menu near the bottom of a long page opens
upwards and says data-side="top", and a select's popup can be exactly as wide
as the button it came out of because the button's width is written on it.
A submenu asks for side="inline-end" rather than side="right". LogicalSide
is the four physical names plus inline-start and inline-end, which resolve
against the trigger's own writing direction — so a submenu opens to the left in
an Arabic page, which is the side the key that opens it already pointed at.
data-side always reports the physical answer, because a stylesheet drawing an
arrow needs the direction it will be drawn in.
An overlay is position: fixed, which is also how it escapes an ancestor's
overflow: hidden — a popover in a table row or a scroll container is not cut
in half — without being moved away from its trigger in the accessibility tree.
The exception is an ancestor with transform, filter or will-change, which
becomes the containing block for anything fixed inside it.
Why it is measured, and not CSS anchor positioning
The platform grew this: anchor-name, position-area and
position-try-fallbacks place an element against another in the compositor,
with no JavaScript on the scroll path. It is the better mechanism and it is
deliberately not used, because it cannot express the slide. position-area
chooses one of a grid of regions around the anchor and offers no spelling for
"and then move it back inside"; every position-try fallback, built-in or
hand-written, is another placement rather than a nudge. Shipping the
declarative tier where it works and the measured one elsewhere would put the
same overlay in two different places, and which one a reader got would depend on
their browser. One tier both engines run is worth more than the faster one for
some of them.
A month, and the field beside it
A calendar is role="grid": a row of column headers naming the days, rows of
gridcells, and one roving tab stop, so the whole month is one stop in the
page's tab order. It is the one pattern internal/roving-focus.js does not
cover, and internal/date-grid.js says why at length — the short version is that
the movement is arithmetic on a date rather than an index in a list of elements,
and that the cell an arrow key asks for is often one the grid has not rendered
yet.
Running off the end of a month shows the next one and leaves focus on its
first day — a cell that did not exist when the key was pressed, which is why the
grid carries the same pendingFocus machinery Menu.Body uses to open onto its
last item.
Three things a month of buttons in a grid does not do
An unavailable day stays reachable.isDateDisabled marks a day
aria-disabled="true" and the arrow keys still land on it. That is the
deliberate opposite of what a disabled menu item does — moveTo steps over
those — because a reader arrowing through October has to be able to pass over
the days that cannot be booked. A grid that skipped them presents a month with
holes in it and no way to find out what is in the holes.
The month is announced when it changes. A sighted reader sees the caption
change; a screen reader is told nothing, because nothing it was watching moved.
Calendar.Root renders a role="status" that is in the document from the first
render and empty until the month actually changes — the same constraint
Combobox.Status documents, and the reason it is not a part a caller could
forget to place.
Today and the selection are different marks.aria-current="date" on today,
aria-selected="true" on the chosen day, and nothing on the rest: a grid where
every cell says aria-selected="false" makes a reader hear "not selected" on all
thirty-one of them.
Dates are Temporal's
Every date in and out is a PlainDate from @uniflowed/temporal, or the ISO
8601 string it reads. Nothing here computes a month length or a day of the week
with Date — a package that ships a temporal library and then reaches for
new Date(y, m + 1, 0) is the opposite of building uf with uf, and Date's
zero-based months and local-timezone arithmetic are where calendar bugs come
from. PlainDate.dayOfWeek, PlainDate.daysInMonth and
PlainDate.toLocaleString were added to @uniflowed/core/temporal for this, in
the shapes native Temporal already has.
The month name, the day names and the day the week starts on come from the
locale. weekStartsOn overrides it, and is what a test names so that a suite
does not depend on the host's locale data.
Date Picker
The composition, and the field is the control:
// @flowimport { Calendar, DatePicker } from "@uniflowed/ui";<DatePicker.Root onValueChange={setWhen} value={when}> <DatePicker.Input aria-label="Arrive on" /> <DatePicker.Trigger>Choose a date</DatePicker.Trigger> <DatePicker.Calendar> <Calendar.Previous>Previous month</Calendar.Previous> <Calendar.Next>Next month</Calendar.Next> <Calendar.Month /> </DatePicker.Calendar></DatePicker.Root>;
A date picker whose only input is the grid is slower for everybody who already
knows the date and unusable for anybody who cannot operate a grid, so the text
field is the primary control and the calendar is the second way in. Escape and
a chosen date both put focus back in the field; the calendar opens with focus
on the date rather than on the button that steps back a month, which is
Popover.Body's initialFocus meeting Calendar.Root's focusedDayRef.
format and parse are ISO 8601 both ways unless you pass your own. Reading
12/03/26 is the locale's date patterns — CLDR data, which is what
@uniflowed/temporal's calendar surface waits on the native runtime for — and a
UI package that guessed at it would be a wrong date in production rather than a
missing feature.
Range selection is not implemented. One date. A range is two values with both
ends announced, aria-selected across everything between them and a second set
of keyboard rules for extending it; none of that is written, and a value that
quietly accepted a pair would be the lookalike this package refuses.
Dialog
A modal dialog is the component whose broken version looks most correct. The
backdrop darkens, the box is centred, the close button works with a pointer —
and a keyboard reader can still leave it, lose the page behind it, or come back
from it at the top of the document. Dialog is the part that makes "modal" true
to someone who cannot see the dim.
<Dialog.Root> <Dialog.Trigger>Edit profile</Dialog.Trigger> <Dialog.Overlay /> <Dialog.Body> <Dialog.Header> <Dialog.Title>Edit profile</Dialog.Title> <Dialog.Description>Change the public details for this account.</Dialog.Description> </Dialog.Header> <Dialog.Footer> <Dialog.Close>Cancel</Dialog.Close> <button type="submit">Save</button> </Dialog.Footer> </Dialog.Body></Dialog.Root>
Parts
Part
Renders
Notes
Dialog.Root
No element
open, defaultOpen, onOpenChange; owns the ids and open state
Dialog.Trigger
button
Opens the dialog, carries aria-haspopup="dialog", and receives focus back
Dialog.Overlay
div aria-hidden
Rendered while open and while its exit plays; decoration, not the dismissal logic
Dialog.Title and Dialog.Description register themselves, so Dialog.Body
only writes aria-labelledby and aria-describedby when the elements they name
exist. A conditional title that is not currently rendered is not a label; an id
that points at nothing is worse than falling back to an aria-label the caller
passed to the body.
Dialog.Header renders a div, not a <header>. A header inside a dialog is a
landmark, and a second banner in the landmark list is not a service to a reader.
The part exists because styling a dialog's top and bottom is common; it adds no
semantics because there are none to add.
The modal promises
Opening a dialog does four things at once:
Focus moves in. The target is initialFocus when that ref points inside
the body, otherwise the first focusable element, otherwise the body itself.
Focus cannot leave by Tab. The first and last stops wrap, and a dialog
with no focusable children keeps focus on the body instead of letting Tab
reach the page behind it.
The page behind is gone. Everything outside the dialog is marked
aria-hidden and inert, so it is hidden from the accessibility tree and
from the browser's own focus and pointer model.
Focus comes back. Closing restores the outside page first, then focuses the
trigger or whatever element had focus when the self-opening dialog appeared.
The hiding walks from the dialog up the tree and marks each level's siblings,
rather than hiding the body's children once. That is what keeps stacked dialogs
honest: an inner dialog can hide the outer dialog's other content without
hiding itself, and closing it restores the exact attributes the outer dialog
had already set.
The page is scroll-locked while the dialog is open. This is not cosmetic. A
wheel event over a modal that scrolls the document behind it changes the reader's
place in a page they cannot currently reach.
Closing it
Escape closes the dialog and stops there. The stop matters when dialogs are
stacked: one key press dismisses the one the reader is in, not every ancestor
that also heard the key.
A press outside closes by default, but the behaviour belongs to Dialog.Body,
not Dialog.Overlay. The overlay is only the visible rectangle; the body is the
part that knows what "outside" means. The listener runs in capture, so a menu or
popover inside the dialog cannot accidentally hide the press from it, and a
press on the trigger is ignored so a click does not close and immediately reopen
the same dialog.
dismissOnOutsidePress={false} leaves Escape alone. That is the line between a
dialog and a trap: an alert-style confirmation may refuse a stray pointer press,
but a keyboard reader still needs a way to decline.
That example is exactly why AlertDialog exists: it fixes the role, the
outside-press rule and the initial focus for the confirmation case. Dialog
keeps the lower-level controls because Sheet, Drawer, Sidebar and custom
modals need the same machinery without the same bundle of choices.
No portal
The body renders where it is written. A portal solves a CSS stacking problem by
moving the dialog somewhere else in the document, and this component is about
the accessibility tree before it is about stacking. Keeping the dialog beside
its trigger preserves that tree; making the rest of the page inert provides the
modal guarantee without moving the node.
Use CSS to put it where it should appear. The component contributes ids, roles,
focus movement, scroll locking and page hiding; it does not own the fixed
positioning, the backdrop colour, or the animation.
The four built on Dialog
AlertDialog, Sheet, Drawer and Sidebar are all Dialog underneath, and
each differs from it in a specific way. They are four components rather than one
with a variant, because the bundles do not line up: an alert dialog sets three
things, a sheet sets none of them and adds an edge, a sidebar is most often not
modal at all.
What dialog.js grew for them is three props, and each is a behaviour that
fails silently when it is hard-coded:
Prop
Default
What it decides
role
"dialog"
"alertdialog" makes a screen reader announce the description the moment focus arrives
dismissOnOutsidePress
true
Whether a press beside the dialog closes it. Escape closes it either way
initialFocus
The first focus stop inside
A ref to the element focus should land on instead
role is a union of "dialog" | "alertdialog", so role="alertdailog" is a
type error at the call rather than a modal announced as a div.
Alert dialog
<AlertDialog.Root> <AlertDialog.Trigger>Delete the project</AlertDialog.Trigger> <AlertDialog.Overlay /> <AlertDialog.Body> <AlertDialog.Header> <AlertDialog.Title>Delete this project?</AlertDialog.Title> <AlertDialog.Description>This cannot be undone.</AlertDialog.Description> </AlertDialog.Header> <AlertDialog.Footer> <AlertDialog.Cancel>Cancel</AlertDialog.Cancel> <AlertDialog.Action onClick={remove}>Delete</AlertDialog.Action> </AlertDialog.Footer> </AlertDialog.Body></AlertDialog.Root>
role="alertdialog", a press outside that does nothing, and focus on
AlertDialog.Cancel — the least destructive action, which is a fact about which
part it is rather than a ref you remember to pass. Escape still closes it: a
modal a reader cannot leave from the keyboard is a trap, and declining is what
Escape means.
AlertDialog.Description is required, and AlertDialog.Body raises without
one. aria-describedby is the whole of what the role buys, so an alert dialog
with nothing to announce has interrupted the reader to say only its title —
which is worse than the Dialog it replaced.
Action and Cancel rather than one Close with a variant: they mean opposite
things, and one of them is where focus goes.
Deliberately small. Every modal promise is Dialog's, unchanged; what a sheet
adds is the two things a class name cannot be — side as a checked union of
"top" | "right" | "bottom" | "left", and data-side on the body and the
overlay, which is the same attribute Popover.Body writes. Drawer and
Sidebar are both defined in terms of this module, so a drawer and a
sidebar-on-a-phone cannot come to disagree about what "left" means.
The edge is not announced. There is no aria-* for "this came in from the
right", and a sheet that put its edge in its accessible name would have a reader
hearing "Filters, right" and wondering what "right" meant.
A sheet plus a gesture, and the gesture is where the specifications land.
WCAG 2.2 SC 2.5.7, Dragging Movements, requires anything achievable by
dragging to be achievable with a single pointer and no drag — so
Drawer.Bodyraises when it holds a Drawer.Handle and no Drawer.Close.
WCAG 2.1.1 requires it from the keyboard, so Drawer.Handle is a
role="slider" over the snap points:
Key
What it does
The arrow away from the edge
One snap point larger
The arrow towards the edge
One snap point smaller, and closes at the smallest
Home, End
The smallest and the largest snap point
Escape
Closes it, like any dialog
snapPoints are fractions of the drawer's full size, ascending; the state is
the index into that list, which is what makes aria-valuemin, aria-valuemax
and aria-valuenow true about it and aria-valuetext the percentage a reader
can act on. The current fraction is written as --uf-drawer-snap and the live
drag offset as --uf-drawer-drag, so a stylesheet sizes the drawer and this
module has no opinion about how it looks. usePrefersReducedMotion puts
data-reduced-motion="true" on the body for a reader who asked their system to
stop moving things; the drag still follows the finger, because direct
manipulation is not animation.
The one of the four that is usually not a dialog. It is part of the page:
nothing behind it is inert, nothing is scroll-locked, there is no focus trap,
and the trigger is a disclosure button carrying aria-expanded. Sidebar.Body
is a named <nav> landmark rather than a <div>, which is how a screen reader
offers to jump to it.
Then the viewport gets narrow and it becomes a Sheet — modal, over the
content, gone when it is closed. Focus moves into it when it opens and back to
the trigger when it closes, because at that point it is a dialog; on a wide
screen opening it moves focus nowhere, because nothing was taken away.
Collapsed to icons is where this pattern usually goes wrong: a button whose name
came from its text is announced as "button". Sidebar.Item takes a label,
which becomes aria-label the moment the sidebar collapses — so the name
survives whatever the stylesheet does to the text — and shows the same words in
a Tooltip for a reader looking at the icon.
open is yours to own. shadcn persists it in a cookie so the server renders the
state the reader left it in, and that matters more under RSC than it looks: an
uncontrolled sidebar renders expanded on the server and collapses on hydration,
which is a layout shift on every navigation.
The three that replace something the browser does
Carousel, ScrollArea and InputOtp each draw their own version of something
the platform already provides. That makes them the three most likely to make a
page worse than the plain HTML they replaced, so each module's header opens by
saying what it gives that the plain element does not — and if that ever stops
being true, the component should be deleted rather than fixed.
For a reader who can see it, a carousel gives nothing a list does not. So the
component is the part that keeps the replacement from being worse:
It can be stopped. WCAG 2.2.2, Pause, Stop, Hide. Carousel.Pause is
that mechanism and it must be the first focusable thing inside the
carousel — a pause button after the slides is one nobody reaches in time — so
an autoplaying carousel that has not been given one, or has put it second,
raises.
It says what it is.aria-roledescription="carousel" on a named group,
and aria-roledescription="slide" with "3 of 7" on each slide.
It stops announcing itself while it moves. The slide container is
aria-live="off" while it rotates and "polite" while it does not.
Tab cannot walk into a slide nobody can see. The slides that are not
showing are inert, which takes them out of the tab order and out of the
accessibility tree.
prefers-reduced-motion turns rotation off, and rotation also stops while
the pointer is over it or focus is inside it — and stays stopped once
Carousel.Pause has been pressed, because that was a decision.
count and index are yours, the way Table.Root's rowCount and
Table.Row's index are: counting the children is wrong the first time a slide
is rendered conditionally, and it is wrong silently.
The thing a custom scrollbar takes away is keyboard scrolling. Firefox makes a
scrollable region focusable and Chromium historically does not, so a region that
must be scrolled to be read is, in most browsers, one a keyboard reader can see
the top of and nothing else. ScrollArea.Viewport is role="region" with a
name and tabindex="0", and then intercepts no key at all: every key that
scrolls a native overflow container scrolls this one, because it is one.
scrollIntoView({ block: "nearest" }) still works, which matters because
Combobox and Select both call it to keep the active option visible. And the
scroll position survives a re-render: replacing the content of a scroll
container makes the browser clamp the offset and never put it back, so the
viewport remembers where the reader actually was — from the scroll event, so a
reader who scrolled to the top themselves is never dragged back down.
ScrollArea.Scrollbar is aria-hidden and holds no controls. That is what
keeps it clear of WCAG 2.5.7: nothing here is achievable only by dragging. It
reports --uf-scroll-thumb-size and --uf-scroll-thumb-offset (and the -x
pair for the other axis), both between 0 and 1, so a stylesheet draws a thumb
without measuring anything.
There is one real <input>, and the slots are a picture of its value. That
decision is the component. <input autocomplete="one-time-code" inputmode="numeric"> is already an excellent one-time-code field — the
operating system offers the code it just received by SMS, a password manager
fills it, a screen reader announces one field with one name — and six
<input maxlength="1">es lose every one of those silently.
So pasting fills every box, because a paste into one input is a single input
event carrying the whole string. Backspace, the arrow keys, Home and End
are the browser's text editing, unmodified. A <form> submits one value under
one name. kind is "numeric" | "alphanumeric" and decides three things at
once — which characters survive typing and pasting, which keyboard a phone
shows, and the field's pattern.
What it costs is that the input has to be drawn over the slots by your
stylesheet, which is why InputOtp.Root puts your DOM props on the input and
renders no wrapper of its own: the input is the field, so Field.Control's
id, aria-describedby and aria-invalid land where they mean something.
The four menus, which are three modules
shadcn ships Dropdown Menu, Context Menu, Menubar and the checkable items all
three share. uf ships three modules, because DropdownMenu is Menu — a
dropdown menu is a menu whose trigger is a button, and a second name for one
component is a second surface to keep in step. crates/uf_lib/src/ui.rs says so
in one line rather than carrying a fourth entry.
Menu.CheckboxItem, Menu.RadioGroup and Menu.RadioItem are the checkable
kinds, available from all three. They do not close the menu when they are
chosen, and a plain Menu.Item does:
Ticking three boxes is one visit to the menu on every platform, which is why the
defaults differ; closeOnSelect says otherwise for a part, and
onSelect={(event) => event.preventDefault()} says it for one press — the same
"I handled this" composeHandlers already reads between a caller's handler and
the component's.
A context menu is reachable from the keyboard, which is the half that is
usually left out: Shift+F10 and the ContextMenu key open it, because a
command reachable only by right-click is reachable only by a pointer. Its
trigger is therefore in the tab order — pass tabIndex={-1} when the trigger
already contains something focusable — and its body needs an aria-label,
because arbitrary content is not a name. Opened by the pointer it appears at
the pointer; opened by the keyboard it appears against the trigger.
A menubar has one tab stop for the whole bar. ArrowLeft / ArrowRight
move between the menus, mirrored in a right-to-left page, and — with a menu open
— close it and open the adjacent one, so File → Edit → View needs no Escape
between them. Escape closes the open menu and leaves focus on its trigger.
Exits
Every overlay part — Dialog.Body and Dialog.Overlay (and so the sheet,
drawer and alert dialog built on them), Popover.Body, Menu.Body,
Select.List, Combobox.List, Tooltip.Body, HoverCard.Body,
NavigationMenu.Body and a Toast.Root — writes data-state="open" while it
is open. When it closes it stays in the document, with data-state="closed"
and inert, until the transitions and animations on it have finished
(getAnimations()), and is then removed. So an exit is a stylesheet rule:
With nothing animating — no stylesheet, or 0s under reduced motion — the part
is removed in the commit that closed it, as if exits did not exist. What a
reader acts on does not wait for the exit: focus goes back to the trigger, the
scroll lock lifts and a modal gives the page back at the moment of closing,
and inert keeps the fading part out of the tab order, the accessibility tree
and the pointer's way. A part reopened while it is closing is the same element,
so its transition turns round from where it had got to. A dismissed toast stays
in its place in the stack while it leaves. Animations that would never end on
their own — one that repeats for ever, or a paused one — are not waited for.
Animating a disclosure
Collapsible and Accordion can carry the height their content would have,
which is the one number a stylesheet cannot compute: height: 0 → var(--uf-collapsible-height) has to know its destination while the panel is
still closed, and a closed panel has no box for ResizeObserver,
getBoundingClientRect or scrollHeight to report on.
The panel opens from the @starting-style and closes on data-state="closed",
and it becomes hidden — until-found, so find-in-page still reaches it —
only when the closing transition has finished. It is inert while it closes.
The height is measured again whenever the panel renders or its content changes
size, but never while it is moving, so a measurement cannot cut a transition
short.
A sliding tabs indicator
Tabs.List writes where the selected tab is on itself, relative to the list's
padding box: --uf-tabs-indicator-left, --uf-tabs-indicator-top,
--uf-tabs-indicator-width and --uf-tabs-indicator-height, in CSS pixels and
without a unit, so one property serves as a length and as a scale. A
one-pixel bar positioned in the list follows them with transform alone:
They are measured whenever the list renders and whenever the list or a tab
changes size, and removed when no tab is selected. The registry's tabs draws
exactly this, and before hydration lets the selected tab underline itself.
measure is opt-in, and on Accordion.Root it covers every panel. It is a prop
rather than a default because whether a stylesheet reads the property is not
something the component can ask, and the alternative is a forced layout per
panel per render on every page that has a collapsible — animated or not.
Field and @uniflowed/form
Both packages know how to make a control accessible, and both used to produce
aria-invalid and aria-describedby. Spread onto one input the later one won,
so the control was described by the form's message or by Field.Description
and never by both. One hook ends it:
useFieldSource returns facts and a binding — invalid, required, the message,
and the name/ref/onChange/onBlur the store needs — and no aria-* at
all. Field composes every attribute from them in one place, so
aria-describedby is a token list naming the description and the message, and
Field.Error shows what the store said without being handed it. name is a key
of the form's values, so a renamed schema key is a type error at the call.
It lives in @uniflowed/form rather than in @uniflowed/ui so the form
store and field bindings remain usable without the UI components. Both
packages are published to npm.
Field.Root group is for a set with no single control to point a <label for>
at — a radio group, a checkbox group, three selects making a date. The root
becomes role="group" named by the label, the label stops being a <label>,
and the description and the error describe the set.
Two things are deliberately not here. A server error needs no new part:
setError(name, …, { shouldFocus: true }) reaches the same Field.Error and
moves focus the same way a client-side one does. An error summary at the top
of a long form is a documented pattern rather than a component: it is a list of
links to the fields that failed, and what makes it work is the ids Field
already owns — a component would have to be told what every field on the page is
called, which is the form's knowledge and not a container's.
Switch and Checkbox
Switch and Checkbox are the two controls that look alike and must not be the
same component. Both render a focusable control with aria-checked, both take
checked / defaultChecked / onCheckedChange, and both accept render when
the app needs to put those semantics on its own element:
The role is the contract. Switch renders role="switch" and never a checkbox:
screen readers announce it as on or off, because it operates a setting.
Checkbox renders role="checkbox" and may be unchecked, checked or mixed,
because it answers a question. The mixed state is caller-owned through
indeterminate; clicking a mixed checkbox reports true, the "select all"
direction a table header needs.
Keyboard handling follows that split:
Key
Switch
Checkbox
Space
Toggles
Toggles
Enter
Toggles
Submits the form implicitly
The checkbox Enter path reproduces native checkbox behaviour rather than the
button it happens to render. It prevents the button click, leaves the checked
state alone, and calls requestSubmit on the owning form with that form's
default submit button, including a button outside the form that names it with
form="…". A buttonless form follows the platform's implicit-submission rule:
it submits with at most one blocking text-like field and stays quiet with two.
render changes the element, not the semantics. A rendered switch is still a
switch with aria-checked and the switch keyboard contract; a rendered checkbox
keeps role="checkbox" and preserves aria-checked="mixed" when
indeterminate is set.
What this page is
A reference for what @uniflowed/ui guarantees, not a gallery. The only styling
on it is under Registry components, because those are
files a project owns, not parts of the package. The guarantees
are stated as tests in npm/ui/ui.test.js, where every assertion is
either "a reader is told X" or "this key does Y" — a snapshot of the markup
would pass while every one of them was broken.
Combobox still has only its groups and the palette they make possible; until
the rest of it is written here, its module header is the documentation, and it
is written to be read.
For a live region that is only a live region — a route change announced, a
count that updates where nothing visible moves — Announcer in
@uniflowed/web/regions is the smaller thing, and the
right one. The test that module states is whether a sighted reader would notice
its absence: if only a screen reader would, it belongs there rather than here.