@forwardreach/saas-ui 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +127 -4
- package/dist/components/badge.d.ts +0 -1
- package/dist/components/badge.js +0 -1
- package/dist/components/card.d.ts +15 -0
- package/dist/components/card.js +17 -0
- package/dist/components/combobox.d.ts +7 -33
- package/dist/components/combobox.js +20 -70
- package/dist/components/index.d.ts +5 -0
- package/dist/components/index.js +5 -0
- package/dist/components/option-list.d.ts +140 -0
- package/dist/components/option-list.js +142 -0
- package/dist/components/page-container.d.ts +7 -0
- package/dist/components/page-container.js +20 -0
- package/dist/components/page-header.d.ts +21 -1
- package/dist/components/page-header.js +14 -2
- package/dist/components/pill.d.ts +20 -0
- package/dist/components/pill.js +23 -0
- package/dist/components/popover.d.ts +23 -0
- package/dist/components/popover.js +32 -0
- package/dist/components/role-menu.d.ts +7 -5
- package/dist/components/role-menu.js +7 -5
- package/dist/components/select-menu.d.ts +128 -0
- package/dist/components/select-menu.js +219 -0
- package/dist/components/select.d.ts +6 -0
- package/dist/components/select.js +6 -0
- package/dist/styles/index.css +14 -0
- package/package.json +3 -2
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Check } from "lucide-react";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../utils/cn.js";
|
|
6
|
+
/**
|
|
7
|
+
* Case-insensitive substring match on the option's value, label and
|
|
8
|
+
* `inputLabel`, with spaces treated as underscores so `new york` finds
|
|
9
|
+
* `America/New_York`. Returns true for an empty query.
|
|
10
|
+
*/
|
|
11
|
+
export function defaultOptionFilter(option, query) {
|
|
12
|
+
const normalized = query.trim().toLowerCase().replaceAll(" ", "_");
|
|
13
|
+
if (!normalized)
|
|
14
|
+
return true;
|
|
15
|
+
return (option.value.toLowerCase().includes(normalized) ||
|
|
16
|
+
option.label.toLowerCase().replaceAll(" ", "_").includes(normalized) ||
|
|
17
|
+
// `inputLabel` is what the collapsed control showed, so it is what a user
|
|
18
|
+
// retypes after focusing clears the box. Searching only `label` would leave
|
|
19
|
+
// an option findable by a string it never displayed and unfindable by the
|
|
20
|
+
// one it did.
|
|
21
|
+
(option.inputLabel !== undefined &&
|
|
22
|
+
option.inputLabel.toLowerCase().replaceAll(" ", "_").includes(normalized)));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The DOM id of an option row, which `aria-activedescendant` points at.
|
|
26
|
+
*
|
|
27
|
+
* The value is a consumer's data — a user-defined field's option is whatever a
|
|
28
|
+
* person typed — and an id may not contain whitespace, while the single-IDREF
|
|
29
|
+
* attribute pointing at it has to resolve in every browser and screen reader.
|
|
30
|
+
* So the value is encoded into `[A-Za-z0-9-]`: every other character, `_`
|
|
31
|
+
* included so the encoding stays one-to-one, becomes `_<code point in hex>_`.
|
|
32
|
+
* Distinct values give distinct ids; a value that already fits passes through
|
|
33
|
+
* unchanged.
|
|
34
|
+
*/
|
|
35
|
+
export function optionElementId(prefix, value) {
|
|
36
|
+
const encoded = value.replace(/[^A-Za-z0-9-]/g, (char) => `_${char.codePointAt(0)?.toString(16)}_`);
|
|
37
|
+
return `${prefix}-option-${encoded}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The next highlight position after one arrow press: from nothing highlighted,
|
|
41
|
+
* down lands on the first entry and up on the last; otherwise the highlight
|
|
42
|
+
* wraps at both ends. `-1` when there is nothing to highlight.
|
|
43
|
+
*/
|
|
44
|
+
export function nextActiveIndex(current, delta, length) {
|
|
45
|
+
if (length === 0)
|
|
46
|
+
return -1;
|
|
47
|
+
if (current < 0)
|
|
48
|
+
return delta === 1 ? 0 : length - 1;
|
|
49
|
+
return (current + delta + length) % length;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Bring the highlighted row into view, and its group heading with it.
|
|
53
|
+
*
|
|
54
|
+
* Arriving at the first option of a group brings that group's heading into
|
|
55
|
+
* view as well as the option, so a keyboard user entering a group can see which
|
|
56
|
+
* one they are in. Both, and in this order — scrolling only the option leaves
|
|
57
|
+
* the 16px heading clipped above the scrollport, and scrolling only the heading
|
|
58
|
+
* is worse, because entering a group from below aligns the heading's bottom
|
|
59
|
+
* edge with the scrollport's and leaves the newly highlighted option out of
|
|
60
|
+
* view entirely.
|
|
61
|
+
*
|
|
62
|
+
* Two `nearest` calls settle both directions. Downward: the first brings the
|
|
63
|
+
* heading to the bottom edge, the second scrolls one option further, leaving
|
|
64
|
+
* heading and option both visible. Upward: the first aligns the heading to the
|
|
65
|
+
* top and the second is a no-op, the option having come with it. Never losing
|
|
66
|
+
* the highlight is the constraint; showing the heading is the preference.
|
|
67
|
+
*/
|
|
68
|
+
export function scrollOptionIntoView(list, index) {
|
|
69
|
+
const element = list?.querySelector(`[data-index="${index}"]`);
|
|
70
|
+
const previous = element?.previousElementSibling ?? null;
|
|
71
|
+
if (previous?.getAttribute("role") === "presentation") {
|
|
72
|
+
previous.scrollIntoView({ block: "nearest" });
|
|
73
|
+
}
|
|
74
|
+
element?.scrollIntoView({ block: "nearest" });
|
|
75
|
+
}
|
|
76
|
+
/** One `role="option"` row. Pointer moves highlight it; clicks commit it. */
|
|
77
|
+
export function OptionRow({ option, index, active, selected, idPrefix, onCommit, onActivate, children, }) {
|
|
78
|
+
return (_jsxs("button", { "aria-selected": selected, className: cn(
|
|
79
|
+
// Every row takes the full text colour. The muted weight this used to
|
|
80
|
+
// carry belongs to a list read *beside* the answer — `Combobox`'s field
|
|
81
|
+
// holds the committed value above its own popup — and reads wrong where
|
|
82
|
+
// the popup is the choice: options dimmer than the heading over them
|
|
83
|
+
// make the things you may pick look less available than the label for
|
|
84
|
+
// them.
|
|
85
|
+
// The label takes the free space rather than the row distributing it:
|
|
86
|
+
// `justify-between` spreads three children across the row, so a row
|
|
87
|
+
// carrying both trailing content and the selected mark would strand its
|
|
88
|
+
// trailing content in the middle. Pushing off the first child instead
|
|
89
|
+
// packs everything after it to the right, for any number of them.
|
|
90
|
+
"flex w-full items-center gap-3 rounded-[var(--ssui-radius-sm)] px-2 py-1.5 text-left text-sm text-[color:var(--ssui-text)] transition-colors [&>:first-child]:mr-auto",
|
|
91
|
+
// Selection is carried by the check and the weight, with the fill as
|
|
92
|
+
// support rather than as the signal. The fill is `--ssui-surface-muted`
|
|
93
|
+
// on `--ssui-surface-elevated`, which is a clear step in a light theme
|
|
94
|
+
// and almost none in a dark one, so a reader in dark had no reliable way
|
|
95
|
+
// to tell which row was current.
|
|
96
|
+
selected && "bg-[color:var(--ssui-surface-muted)] font-medium",
|
|
97
|
+
// The highlight is where the pointer or the keyboard is *now*, so it
|
|
98
|
+
// wins the background: it lands last, and `cn` is tailwind-merge. The
|
|
99
|
+
// other order hid the highlight on the selected row, which is the row a
|
|
100
|
+
// popup opens on.
|
|
101
|
+
active ? "bg-[color:var(--ssui-overlay-hover)]" : "hover:bg-[color:var(--ssui-overlay-hover)]"), "data-index": index, id: optionElementId(idPrefix, option.value), onClick: () => onCommit(option),
|
|
102
|
+
// Keep focus where the keyboard model lives — the input or the listbox —
|
|
103
|
+
// rather than letting a click move it onto the row.
|
|
104
|
+
onMouseDown: (event) => event.preventDefault(), onMouseMove: () => onActivate(index), role: "option", tabIndex: -1, type: "button", children: [children ??
|
|
105
|
+
(option.description !== undefined ? (_jsxs("span", { className: "flex min-w-0 items-baseline gap-1.5", children: [_jsx("span", { className: "truncate", children: option.label }), _jsx("span", { className: "truncate text-xs text-[color:var(--ssui-text-subtle)]", children: option.description })] })) : (_jsx("span", { className: "truncate", children: option.label }))), option.trailing !== undefined ? (_jsx("span", { className: "shrink-0 font-mono text-xs text-[color:var(--ssui-text-subtle)]", children: option.trailing })) : null, selected ? _jsx(Check, { "aria-hidden": "true", className: "size-4 shrink-0" }) : null] }));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The popup list both `Combobox` and `SelectMenu` render: options walked into
|
|
109
|
+
* grouped runs under headings, each row a `role="option"` button, the highlight
|
|
110
|
+
* driven from outside by `activeIndex`. It owns no positioning and no keyboard
|
|
111
|
+
* handling — the owner decides which element holds focus and where the list
|
|
112
|
+
* sits — so the surrounding chrome is the owner's `className`.
|
|
113
|
+
*
|
|
114
|
+
* Internal to the package. Not exported from the barrel, so it is not an API
|
|
115
|
+
* this package has to keep.
|
|
116
|
+
*/
|
|
117
|
+
export const OptionListbox = React.forwardRef(({ id, idPrefix, options, value, activeIndex, onActivate, onCommit, emptyMessage = "No matches", children, className, ...rest }, ref) => {
|
|
118
|
+
const rows = [];
|
|
119
|
+
options.forEach((option, index) => {
|
|
120
|
+
const last = rows[rows.length - 1];
|
|
121
|
+
if (option.group === undefined) {
|
|
122
|
+
rows.push({ kind: "option", entry: { option, index } });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (last?.kind === "group" && last.group === option.group) {
|
|
126
|
+
last.entries.push({ option, index });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
rows.push({ kind: "group", group: option.group, entries: [{ option, index }] });
|
|
130
|
+
});
|
|
131
|
+
const renderOption = ({ option, index }) => (_jsx(OptionRow, { active: index === activeIndex, idPrefix: idPrefix, index: index, onActivate: onActivate, onCommit: onCommit, option: option, selected: option.value === value }, option.value));
|
|
132
|
+
return (_jsxs("div", { ...rest, className: className, id: id, ref: ref, role: "listbox", children: [rows.map((row, rowIndex) => row.kind === "option" ? (renderOption(row.entry)) : (
|
|
133
|
+
// A run is wrapped in `role="group"` labelled by its heading — the
|
|
134
|
+
// APG grouped-listbox shape. Without it the grouping is conveyed to
|
|
135
|
+
// sighted users only: a roleless heading is not in the listbox's
|
|
136
|
+
// content model, so a screen-reader user hears a flat list of
|
|
137
|
+
// options and never learns which kind each is. The heading takes
|
|
138
|
+
// `role="presentation"` so it stays out of that content model while
|
|
139
|
+
// `aria-labelledby` still names the group from its text.
|
|
140
|
+
_jsxs("div", { "aria-labelledby": `${idPrefix}-group-${rowIndex}`, role: "group", children: [_jsx("div", { className: cn("px-2 pb-0.5 pt-4 text-xs font-medium uppercase tracking-wide text-[color:var(--ssui-text-subtle)]", rowIndex === 0 && "pt-1"), id: `${idPrefix}-group-${rowIndex}`, role: "presentation", children: row.group }), row.entries.map(renderOption)] }, `group-${rowIndex}`))), children, rows.length === 0 && emptyMessage !== null ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-[color:var(--ssui-text-subtle)]", children: emptyMessage })) : null] }));
|
|
141
|
+
});
|
|
142
|
+
OptionListbox.displayName = "OptionListbox";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type PageContainerWidth = "narrow" | "wide" | "full";
|
|
3
|
+
export interface PageContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
4
|
+
asChild?: boolean;
|
|
5
|
+
width?: PageContainerWidth;
|
|
6
|
+
}
|
|
7
|
+
export declare const PageContainer: React.ForwardRefExoticComponent<PageContainerProps & React.RefAttributes<HTMLDivElement>>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../utils/cn.js";
|
|
5
|
+
/**
|
|
6
|
+
* The centered column a page sits in. The widths are a vocabulary, not numbers: `narrow` is a
|
|
7
|
+
* reading column (a timeline, a record), `wide` a working column (a collection, a list of
|
|
8
|
+
* things to act on), and `full` a workspace that fills what it is given (an inbox, an editor).
|
|
9
|
+
* `full` exists so a full-bleed page still declares that it made a choice.
|
|
10
|
+
*/
|
|
11
|
+
const widthClasses = {
|
|
12
|
+
narrow: "mx-auto w-full max-w-3xl px-4 py-5 md:px-8 md:py-8",
|
|
13
|
+
wide: "mx-auto w-full max-w-5xl px-4 py-5 md:px-8 md:py-8",
|
|
14
|
+
full: "w-full"
|
|
15
|
+
};
|
|
16
|
+
export const PageContainer = React.forwardRef(({ asChild = false, className, width = "wide", ...props }, ref) => {
|
|
17
|
+
const Comp = asChild ? Slot : "div";
|
|
18
|
+
return (_jsx(Comp, { ref: ref, className: cn(widthClasses[width], className), "data-width": width, ...props }));
|
|
19
|
+
});
|
|
20
|
+
PageContainer.displayName = "PageContainer";
|
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
export interface PageHeaderProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
|
|
3
3
|
actions?: React.ReactNode;
|
|
4
|
+
/**
|
|
5
|
+
* Context that is not an action — today's date, a count — rendered on the title's baseline
|
|
6
|
+
* at the right edge of the title row. The slot sets no type; size it as the page wants.
|
|
7
|
+
* `actions` keeps its own slot, so the two never share a line at narrow widths.
|
|
8
|
+
*/
|
|
9
|
+
aside?: React.ReactNode;
|
|
4
10
|
breadcrumbs?: React.ReactNode;
|
|
5
11
|
description?: React.ReactNode;
|
|
12
|
+
/** Draw the bottom rule. Off by default: a main page has none, a settings page opts in. */
|
|
13
|
+
divider?: boolean;
|
|
6
14
|
eyebrow?: React.ReactNode;
|
|
7
15
|
metadata?: React.ReactNode;
|
|
8
16
|
title: React.ReactNode;
|
|
9
17
|
}
|
|
10
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The page title in the shape a host draws its own main pages in: `text-xl` rising to
|
|
20
|
+
* `text-2xl` at the small breakpoint, no rule beneath unless asked for. A page built only
|
|
21
|
+
* from shared parts should be indistinguishable from one the host draws by hand, which is
|
|
22
|
+
* why the default is the host's shape and the previous one is a prop away.
|
|
23
|
+
*
|
|
24
|
+
* From the small breakpoint the title and actions share a row only while both fit: the row
|
|
25
|
+
* wraps, so a toolbar wider than the space beside the title drops beneath it (as it does on a
|
|
26
|
+
* phone) rather than squeezing the title to nothing or running past the page edge. The title
|
|
27
|
+
* column's basis is its own min-content — the whole title, since the heading does not wrap — so
|
|
28
|
+
* the row breaks exactly when the full title and the actions no longer fit side by side.
|
|
29
|
+
*/
|
|
30
|
+
export declare function PageHeader({ actions, aside, breadcrumbs, className, description, divider, eyebrow, metadata, title, ...props }: PageHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { cn } from "../utils/cn.js";
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The page title in the shape a host draws its own main pages in: `text-xl` rising to
|
|
6
|
+
* `text-2xl` at the small breakpoint, no rule beneath unless asked for. A page built only
|
|
7
|
+
* from shared parts should be indistinguishable from one the host draws by hand, which is
|
|
8
|
+
* why the default is the host's shape and the previous one is a prop away.
|
|
9
|
+
*
|
|
10
|
+
* From the small breakpoint the title and actions share a row only while both fit: the row
|
|
11
|
+
* wraps, so a toolbar wider than the space beside the title drops beneath it (as it does on a
|
|
12
|
+
* phone) rather than squeezing the title to nothing or running past the page edge. The title
|
|
13
|
+
* column's basis is its own min-content — the whole title, since the heading does not wrap — so
|
|
14
|
+
* the row breaks exactly when the full title and the actions no longer fit side by side.
|
|
15
|
+
*/
|
|
16
|
+
export function PageHeader({ actions, aside, breadcrumbs, className, description, divider = false, eyebrow, metadata, title, ...props }) {
|
|
17
|
+
return (_jsxs("header", { className: cn("flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between", divider && "border-b border-[color:var(--ssui-border)] pb-4", className), ...props, children: [_jsxs("div", { className: "min-w-0 flex-[1_1_min-content] space-y-2", children: [breadcrumbs ? _jsx("div", { children: breadcrumbs }) : null, eyebrow ? (_jsx("div", { className: "text-xs font-medium uppercase text-[color:var(--ssui-text-muted)]", children: eyebrow })) : null, _jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsx("h1", { className: "min-w-0 truncate text-xl font-semibold text-[color:var(--ssui-text)] sm:text-2xl", children: title }), aside ? (_jsx("div", { className: "ml-auto shrink-0", "data-slot": "aside", children: aside })) : null] }), description ? (_jsx("div", { className: "max-w-3xl text-sm text-[color:var(--ssui-text-muted)]", children: description })) : null] }), metadata ? _jsx("div", { className: "flex flex-wrap gap-2", children: metadata }) : null] }), actions ? _jsx("div", { className: "flex max-w-full shrink-0 flex-wrap gap-2", children: actions }) : null] }));
|
|
6
18
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The tones are roles, not colors: what the pill refers to. `reference` is a link to a record
|
|
4
|
+
* (a person's name), `tag` a label attached to one, `attribute` a field, `date` a point in
|
|
5
|
+
* time, `category` a grouping tag. Each reads its own `--ssui-pill-<tone>-bg` / `-text` pair.
|
|
6
|
+
*/
|
|
7
|
+
export type PillTone = "reference" | "tag" | "attribute" | "date" | "category";
|
|
8
|
+
export interface PillProps extends React.HTMLAttributes<HTMLSpanElement> {
|
|
9
|
+
/**
|
|
10
|
+
* Render the consumer's own element (a router link, a button) with the pill's presentation.
|
|
11
|
+
* The pill carries no `href` or `onClick` of its own: the consumer's element does.
|
|
12
|
+
*/
|
|
13
|
+
asChild?: boolean;
|
|
14
|
+
tone?: PillTone;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* An inline reference chip. Sized to sit in a line of text (`align-baseline`), which is where
|
|
18
|
+
* a reference lives; for a status, use `StatusPill`, whose capitalization and dot say "state".
|
|
19
|
+
*/
|
|
20
|
+
export declare const Pill: React.ForwardRefExoticComponent<PillProps & React.RefAttributes<HTMLSpanElement>>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../utils/cn.js";
|
|
5
|
+
const toneClasses = {
|
|
6
|
+
// A reference reaches the record it names, so it carries a hairline border: that, and the
|
|
7
|
+
// border strengthening under a pointer when the consumer has made it a link, is what
|
|
8
|
+
// separates it from a chip that is only a label.
|
|
9
|
+
reference: "border border-[color:var(--ssui-border)] bg-[color:var(--ssui-pill-reference-bg)] text-[color:var(--ssui-pill-reference-text)] [&:is(a,button)]:hover:border-[color:var(--ssui-border-strong)]",
|
|
10
|
+
tag: "bg-[color:var(--ssui-pill-tag-bg)] text-[color:var(--ssui-pill-tag-text)]",
|
|
11
|
+
attribute: "bg-[color:var(--ssui-pill-attribute-bg)] text-[color:var(--ssui-pill-attribute-text)]",
|
|
12
|
+
date: "bg-[color:var(--ssui-pill-date-bg)] text-[color:var(--ssui-pill-date-text)]",
|
|
13
|
+
category: "bg-[color:var(--ssui-pill-category-bg)] text-[color:var(--ssui-pill-category-text)]"
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* An inline reference chip. Sized to sit in a line of text (`align-baseline`), which is where
|
|
17
|
+
* a reference lives; for a status, use `StatusPill`, whose capitalization and dot say "state".
|
|
18
|
+
*/
|
|
19
|
+
export const Pill = React.forwardRef(({ asChild = false, className, tone = "reference", ...props }, ref) => {
|
|
20
|
+
const Comp = asChild ? Slot : "span";
|
|
21
|
+
return (_jsx(Comp, { ref: ref, className: cn("inline-flex items-center gap-1 whitespace-nowrap rounded-[var(--ssui-radius)] px-1.5 py-0.5 align-baseline text-xs font-medium leading-tight transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)]", toneClasses[tone], className), "data-tone": tone, ...props }));
|
|
22
|
+
});
|
|
23
|
+
Pill.displayName = "Pill";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
/**
|
|
4
|
+
* The thin styled wrapper `dropdown-menu.tsx` gives its primitive, for the
|
|
5
|
+
* anchored panel that is not a menu: a popup holding a filter field, a form, or
|
|
6
|
+
* a list that keeps its own keyboard model. Radix supplies the portal, the
|
|
7
|
+
* collision-aware placement, and dismissal on outside click or Escape, and
|
|
8
|
+
* imposes no roving focus or typeahead of its own — which is what separates it
|
|
9
|
+
* from `DropdownMenu` and why `SelectMenu` is built on it.
|
|
10
|
+
*/
|
|
11
|
+
export declare const Popover: React.FC<PopoverPrimitive.PopoverProps>;
|
|
12
|
+
export declare const PopoverTrigger: React.ForwardRefExoticComponent<PopoverPrimitive.PopoverTriggerProps & React.RefAttributes<HTMLButtonElement>>;
|
|
13
|
+
export declare const PopoverAnchor: React.ForwardRefExoticComponent<PopoverPrimitive.PopoverAnchorProps & React.RefAttributes<HTMLDivElement>>;
|
|
14
|
+
export declare const PopoverClose: React.ForwardRefExoticComponent<PopoverPrimitive.PopoverCloseProps & React.RefAttributes<HTMLButtonElement>>;
|
|
15
|
+
export declare const PopoverPortal: React.FC<PopoverPrimitive.PopoverPortalProps>;
|
|
16
|
+
export declare const PopoverContent: React.ForwardRefExoticComponent<Omit<PopoverPrimitive.PopoverContentProps & React.RefAttributes<HTMLDivElement>, "ref"> & {
|
|
17
|
+
/**
|
|
18
|
+
* Render the panel through a portal to `document.body` (the default). Pass
|
|
19
|
+
* `false` to render it inline, next to the trigger, when the panel must stay
|
|
20
|
+
* inside a subtree that tracks focus or owns its own stacking context.
|
|
21
|
+
*/
|
|
22
|
+
portal?: boolean;
|
|
23
|
+
} & React.RefAttributes<HTMLDivElement>>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { cn } from "../utils/cn.js";
|
|
6
|
+
/**
|
|
7
|
+
* The thin styled wrapper `dropdown-menu.tsx` gives its primitive, for the
|
|
8
|
+
* anchored panel that is not a menu: a popup holding a filter field, a form, or
|
|
9
|
+
* a list that keeps its own keyboard model. Radix supplies the portal, the
|
|
10
|
+
* collision-aware placement, and dismissal on outside click or Escape, and
|
|
11
|
+
* imposes no roving focus or typeahead of its own — which is what separates it
|
|
12
|
+
* from `DropdownMenu` and why `SelectMenu` is built on it.
|
|
13
|
+
*/
|
|
14
|
+
export const Popover = PopoverPrimitive.Root;
|
|
15
|
+
export const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
16
|
+
export const PopoverAnchor = PopoverPrimitive.Anchor;
|
|
17
|
+
export const PopoverClose = PopoverPrimitive.Close;
|
|
18
|
+
export const PopoverPortal = PopoverPrimitive.Portal;
|
|
19
|
+
export const PopoverContent = React.forwardRef(({ align = "start", className, collisionPadding = 8, portal = true, sideOffset = 6, ...props }, ref) => {
|
|
20
|
+
const content = (_jsx(PopoverPrimitive.Content, { ref: ref, align: align, collisionPadding: collisionPadding, sideOffset: sideOffset, className: cn(
|
|
21
|
+
// The panel is bounded by the space actually available at its
|
|
22
|
+
// placement. Radix measures that per placement and publishes it here.
|
|
23
|
+
// Without a ceiling the panel is sized by its widest content, and
|
|
24
|
+
// collision handling *shifts* a panel rather than shrinking it, so past
|
|
25
|
+
// the viewport there is nowhere left to shift to. Content that can
|
|
26
|
+
// ellipsize then does; content that cannot scrolls. `cn` is
|
|
27
|
+
// tailwind-merge, so a caller needing a different bound says so in
|
|
28
|
+
// `className` and replaces this rather than racing it.
|
|
29
|
+
"z-50 min-w-44 max-w-[var(--radix-popover-content-available-width)] rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-elevated)] p-1 text-[color:var(--ssui-text)] shadow-[var(--ssui-shadow-md)] outline-none", className), ...props }));
|
|
30
|
+
return portal ? _jsx(PopoverPrimitive.Portal, { children: content }) : content;
|
|
31
|
+
});
|
|
32
|
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
|
@@ -72,11 +72,13 @@ export interface RoleMenuProps {
|
|
|
72
72
|
/**
|
|
73
73
|
* Single choice from a small closed set where each option needs a sentence.
|
|
74
74
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* options may hold only text,
|
|
78
|
-
* enough to search
|
|
79
|
-
*
|
|
75
|
+
* One of four answers to "pick one" in this package, and the one to reach for
|
|
76
|
+
* when the options need explaining: `Select` is a native `<select>` whose
|
|
77
|
+
* options may hold only text, `Combobox` is a filterable input for lists long
|
|
78
|
+
* enough to search, and `SelectMenu` is the `Select` slot drawn as a menu. A
|
|
79
|
+
* role set is none of those — two or three options, each carrying a meaning the
|
|
80
|
+
* reader cannot infer from its name. The four and which case each answers are
|
|
81
|
+
* tabled once, in `docs/packages/saas-ui.md`.
|
|
80
82
|
*
|
|
81
83
|
* Options are radio items rather than plain menu items, so assistive technology
|
|
82
84
|
* announces the option set and which member of it is current instead of leaving
|
|
@@ -9,11 +9,13 @@ import { useFormField, useFormFieldProps } from "./form-field.js";
|
|
|
9
9
|
/**
|
|
10
10
|
* Single choice from a small closed set where each option needs a sentence.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* options may hold only text,
|
|
15
|
-
* enough to search
|
|
16
|
-
*
|
|
12
|
+
* One of four answers to "pick one" in this package, and the one to reach for
|
|
13
|
+
* when the options need explaining: `Select` is a native `<select>` whose
|
|
14
|
+
* options may hold only text, `Combobox` is a filterable input for lists long
|
|
15
|
+
* enough to search, and `SelectMenu` is the `Select` slot drawn as a menu. A
|
|
16
|
+
* role set is none of those — two or three options, each carrying a meaning the
|
|
17
|
+
* reader cannot infer from its name. The four and which case each answers are
|
|
18
|
+
* tabled once, in `docs/packages/saas-ui.md`.
|
|
17
19
|
*
|
|
18
20
|
* Options are radio items rather than plain menu items, so assistive technology
|
|
19
21
|
* announces the option set and which member of it is current instead of leaving
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { ComboboxOption } from "./option-list.js";
|
|
3
|
+
import type { SelectSize } from "./select.js";
|
|
4
|
+
export type SelectMenuVariant = "control" | "inline";
|
|
5
|
+
/**
|
|
6
|
+
* The option count at which `SelectMenu` shows a filter when the consumer has
|
|
7
|
+
* not said either way. Below it the whole list is on screen at once, and a
|
|
8
|
+
* filter is a row to skip past on the way to an answer already visible; above
|
|
9
|
+
* it the reader is scanning. Exported so a consumer can compare against it
|
|
10
|
+
* rather than duplicate the number.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SELECT_MENU_SEARCH_THRESHOLD = 8;
|
|
13
|
+
export interface SelectMenuProps {
|
|
14
|
+
/** The same option shape `Combobox` takes, groups included. Never reordered. */
|
|
15
|
+
options: ReadonlyArray<ComboboxOption>;
|
|
16
|
+
/** Selected option value. The component never changes this itself. */
|
|
17
|
+
value?: string;
|
|
18
|
+
/** Called with the chosen value; never fired for the option already selected. */
|
|
19
|
+
onValueChange?: (value: string) => void | Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* How the trigger is drawn. `"control"` (the default) is `Select`'s bounded
|
|
22
|
+
* field with a chevron, in the same `sm`/`md` scale, so swapping one for the
|
|
23
|
+
* other returns the same field with a themed popup. `"inline"` draws no box
|
|
24
|
+
* at rest: the trigger is the label's own text on the row's own background,
|
|
25
|
+
* sized by the line it sits in, with the chevron and hover surface appearing
|
|
26
|
+
* on hover and focus. That is the presentation a dense properties row needs,
|
|
27
|
+
* and the reason this component exists — see `docs/packages/saas-ui.md`.
|
|
28
|
+
*/
|
|
29
|
+
variant?: SelectMenuVariant;
|
|
30
|
+
/** Control scale for `variant="control"`. Ignored by `"inline"`, which takes its line from the text around it. */
|
|
31
|
+
size?: SelectSize;
|
|
32
|
+
/**
|
|
33
|
+
* Whether the popup carries a filter field. Left unset, the component decides
|
|
34
|
+
* from the option count: a filter at `SELECT_MENU_SEARCH_THRESHOLD` options
|
|
35
|
+
* or more, none below. `true` forces one onto a short list; `false` keeps one
|
|
36
|
+
* off a long one.
|
|
37
|
+
*/
|
|
38
|
+
searchable?: boolean;
|
|
39
|
+
/** Placeholder for the filter field. Defaults to "Search". */
|
|
40
|
+
searchPlaceholder?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Shown in the popup when there is nothing to list. Defaults to "No matches"
|
|
43
|
+
* when a filter is shown and "No options" when there is none — a list that
|
|
44
|
+
* has not loaded has not failed a search. `null` shows nothing at all, the
|
|
45
|
+
* meaning it carries on `Combobox`.
|
|
46
|
+
*/
|
|
47
|
+
emptyMessage?: React.ReactNode;
|
|
48
|
+
/**
|
|
49
|
+
* Offer clearing the value as a row among the options, labelled with this
|
|
50
|
+
* text — "Empty", "None", "Unassigned". Choosing it reports `""`. Absent, no
|
|
51
|
+
* such row is offered.
|
|
52
|
+
*/
|
|
53
|
+
emptyLabel?: string;
|
|
54
|
+
/**
|
|
55
|
+
* What the trigger shows when no option matches `value` — a field with
|
|
56
|
+
* nothing chosen, or one holding a value the list no longer offers. Rendered
|
|
57
|
+
* in the subtle text colour so it reads as a prompt rather than a choice.
|
|
58
|
+
*/
|
|
59
|
+
placeholder?: React.ReactNode;
|
|
60
|
+
/** Submits the selected value with the surrounding form through a hidden input. */
|
|
61
|
+
name?: string;
|
|
62
|
+
disabled?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Which edge of the trigger the popup aligns to. Defaults to `"start"`; a
|
|
65
|
+
* right-aligned trigger wants `"end"` so the popup opens inward rather than
|
|
66
|
+
* relying on collision detection to rescue it.
|
|
67
|
+
*/
|
|
68
|
+
align?: "start" | "center" | "end";
|
|
69
|
+
/** Controlled open state. */
|
|
70
|
+
open?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Open on mount. For a consumer whose control appears in response to an edit
|
|
73
|
+
* gesture — a click on the value it replaces — so that choosing a value is
|
|
74
|
+
* one click from the row rather than two.
|
|
75
|
+
*/
|
|
76
|
+
defaultOpen?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Notified whenever the popup opens or closes, whichever side asked for it.
|
|
79
|
+
* `false` after a choice and after a dismissal alike; a consumer that treats
|
|
80
|
+
* "closed without choosing" as a cancel reads it together with
|
|
81
|
+
* `onValueChange`, which fires first.
|
|
82
|
+
*/
|
|
83
|
+
onOpenChange?: (open: boolean) => void;
|
|
84
|
+
/** Trigger id. Supplied by an enclosing `FormField` when there is one. */
|
|
85
|
+
id?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Names the trigger's purpose, e.g. `"Owner"` on a record's properties. The
|
|
88
|
+
* current value is appended — the accessible name has to contain the
|
|
89
|
+
* trigger's visible text for voice control (WCAG 2.5.3), and a card of ten
|
|
90
|
+
* triggers all named by their values says nothing about what each one sets.
|
|
91
|
+
* Inside a `FormField` the field's visible label already names the control,
|
|
92
|
+
* so leave this unset there: an explicit `aria-label` still wins over the
|
|
93
|
+
* label, as it does on `RoleMenu`. With neither, the visible text is the name.
|
|
94
|
+
*/
|
|
95
|
+
"aria-label"?: string;
|
|
96
|
+
"aria-describedby"?: string;
|
|
97
|
+
"aria-invalid"?: React.AriaAttributes["aria-invalid"];
|
|
98
|
+
/** Extra classes for the trigger. */
|
|
99
|
+
className?: string;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Single choice from a list, in a popup this package draws.
|
|
103
|
+
*
|
|
104
|
+
* The fourth answer to "choose one" here, and the one to reach for when the
|
|
105
|
+
* popup's appearance is the point: `Select` is the native control, whose popup
|
|
106
|
+
* belongs to the platform and cannot be themed past its background colour;
|
|
107
|
+
* `Combobox` is a search field for lists too long to read; `RoleMenu` is for
|
|
108
|
+
* two or three options that each need a sentence. `SelectMenu` is the `Select`
|
|
109
|
+
* slot drawn as a menu — a trigger that can read as bounded field or as plain
|
|
110
|
+
* text, an option list with headings, and a filter once the list is long
|
|
111
|
+
* enough to want one. The table in `docs/packages/saas-ui.md` says which of the
|
|
112
|
+
* four answers which case.
|
|
113
|
+
*
|
|
114
|
+
* Built on `Popover` rather than `DropdownMenu` because a menu's roving focus
|
|
115
|
+
* and typeahead both fight a filter field. Inside the popup the list runs the
|
|
116
|
+
* listbox pattern with `aria-activedescendant`: focus stays in the filter field
|
|
117
|
+
* when there is one, or on the listbox itself when there is not, and the
|
|
118
|
+
* highlight moves independently — the model `Combobox` already implements, and
|
|
119
|
+
* the same option list rendering, extracted so the two cannot drift.
|
|
120
|
+
*
|
|
121
|
+
* What it gives up, stated plainly: on a phone a native `<select>` opens the
|
|
122
|
+
* platform's own picker, and this cannot. `Select` stays for the bounded form
|
|
123
|
+
* field where that is worth more than a themed popup.
|
|
124
|
+
*/
|
|
125
|
+
export declare function SelectMenu({ options, value, onValueChange, variant, size, searchable, searchPlaceholder, emptyMessage, emptyLabel, placeholder, name, disabled, align, open: openProp, defaultOpen, onOpenChange, className, ...ariaProps }: SelectMenuProps): import("react/jsx-runtime").JSX.Element;
|
|
126
|
+
export declare namespace SelectMenu {
|
|
127
|
+
var displayName: string;
|
|
128
|
+
}
|