@tonycodes-ui/mortar 0.0.0-stage → 0.12.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/AGENTS.md +10 -0
- package/CHANGELOG.md +268 -0
- package/CONTRIBUTING.md +60 -0
- package/DESIGN.md +51 -0
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/TESTING.md +83 -0
- package/baseline-policy.json +16 -0
- package/bin/mortar.mjs +258 -0
- package/catalog.json +9900 -0
- package/cli/audit.mjs +943 -0
- package/cli/data.mjs +176 -0
- package/cli/mcp.mjs +216 -0
- package/cli/migrations.mjs +128 -0
- package/cli/packet.mjs +267 -0
- package/cli/search.mjs +246 -0
- package/cli/tools.mjs +229 -0
- package/design-principles.json +30 -0
- package/dist/agent-data.json +1 -0
- package/dist/application.d.ts +340 -0
- package/dist/application.js +240 -0
- package/dist/auth.d.ts +121 -0
- package/dist/auth.js +97 -0
- package/dist/brand.d.ts +69 -0
- package/dist/brand.js +266 -0
- package/dist/calendar.d.ts +75 -0
- package/dist/calendar.js +310 -0
- package/dist/choice.d.ts +36 -0
- package/dist/choice.js +36 -0
- package/dist/composer.d.ts +28 -0
- package/dist/composer.js +47 -0
- package/dist/display.d.ts +136 -0
- package/dist/display.js +75 -0
- package/dist/docs.d.ts +145 -0
- package/dist/docs.js +141 -0
- package/dist/ecommerce.d.ts +261 -0
- package/dist/ecommerce.js +177 -0
- package/dist/feedback.d.ts +80 -0
- package/dist/feedback.js +98 -0
- package/dist/foundation.d.ts +42 -0
- package/dist/foundation.js +121 -0
- package/dist/heading.d.ts +5 -0
- package/dist/heading.js +9 -0
- package/dist/icons.d.ts +50 -0
- package/dist/icons.js +70 -0
- package/dist/illustration.d.ts +47 -0
- package/dist/illustration.js +47 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +18 -0
- package/dist/inspection.d.ts +24 -0
- package/dist/inspection.js +14 -0
- package/dist/internal.d.ts +514 -0
- package/dist/internal.js +332 -0
- package/dist/layout.d.ts +107 -0
- package/dist/layout.js +66 -0
- package/dist/marketing-sections.d.ts +161 -0
- package/dist/marketing-sections.js +58 -0
- package/dist/marketing.d.ts +277 -0
- package/dist/marketing.js +163 -0
- package/dist/navigation.d.ts +35 -0
- package/dist/navigation.js +44 -0
- package/dist/primitives.d.ts +258 -0
- package/dist/primitives.js +244 -0
- package/dist/showcase.d.ts +29 -0
- package/dist/showcase.js +10 -0
- package/dist/workspace.d.ts +80 -0
- package/dist/workspace.js +48 -0
- package/docs/agents.md +127 -0
- package/docs/auth-flows.md +71 -0
- package/docs/migrating-to-0.6.md +94 -0
- package/docs/theming.md +170 -0
- package/package.json +94 -4
- package/skills/mortar/SKILL.md +36 -0
- package/src/application.css +1487 -0
- package/src/application.tsx +1413 -0
- package/src/auth.tsx +452 -0
- package/src/brand.ts +335 -0
- package/src/calendar.css +468 -0
- package/src/calendar.tsx +868 -0
- package/src/choice.tsx +240 -0
- package/src/composer.tsx +158 -0
- package/src/data.css +690 -0
- package/src/display.tsx +452 -0
- package/src/docs.css +704 -0
- package/src/docs.tsx +737 -0
- package/src/ecommerce.css +810 -0
- package/src/ecommerce.tsx +1033 -0
- package/src/feedback.tsx +388 -0
- package/src/foundation.css +1867 -0
- package/src/foundation.tsx +227 -0
- package/src/heading.ts +13 -0
- package/src/icons.tsx +237 -0
- package/src/illustration.tsx +169 -0
- package/src/index.ts +233 -0
- package/src/inspection.tsx +91 -0
- package/src/internal.tsx +700 -0
- package/src/layout.tsx +342 -0
- package/src/marketing-sections.tsx +621 -0
- package/src/marketing.css +2579 -0
- package/src/marketing.tsx +1235 -0
- package/src/navigation.tsx +118 -0
- package/src/overlays.css +350 -0
- package/src/primitives.tsx +1135 -0
- package/src/showcase.tsx +120 -0
- package/src/tokens.css +449 -0
- package/src/workspace.tsx +333 -0
- package/styles.css +56 -0
package/src/choice.tsx
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Field,
|
|
4
|
+
Label,
|
|
5
|
+
Description,
|
|
6
|
+
Listbox as HeadlessListbox,
|
|
7
|
+
ListboxButton,
|
|
8
|
+
ListboxOptions,
|
|
9
|
+
ListboxOption,
|
|
10
|
+
Combobox as HeadlessCombobox,
|
|
11
|
+
ComboboxInput,
|
|
12
|
+
ComboboxButton,
|
|
13
|
+
ComboboxOptions,
|
|
14
|
+
ComboboxOption,
|
|
15
|
+
} from "@headlessui/react";
|
|
16
|
+
import { Check, ChevronDown } from "./icons.js";
|
|
17
|
+
import { RequiredMark, componentRoot, cx } from "./internal.js";
|
|
18
|
+
|
|
19
|
+
export interface ChoiceOption {
|
|
20
|
+
value: string;
|
|
21
|
+
label: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
/** Avatar, icon or status dot shown before the label. */
|
|
24
|
+
leading?: ReactNode;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ChoiceProps {
|
|
29
|
+
label: string;
|
|
30
|
+
options: ChoiceOption[];
|
|
31
|
+
value: string | null;
|
|
32
|
+
onChange: (value: string | null) => void;
|
|
33
|
+
placeholder?: string;
|
|
34
|
+
hint?: string;
|
|
35
|
+
/** Marks the field invalid; the message is announced when it appears. */
|
|
36
|
+
error?: string;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
/** Marks the field required and adds a decorative marker to the label. */
|
|
39
|
+
required?: boolean;
|
|
40
|
+
/** On the field's root element. */
|
|
41
|
+
className?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function OptionContent({ option }: { option: ChoiceOption }) {
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
{option.leading && (
|
|
48
|
+
<span className="m-choice-leading" aria-hidden="true">
|
|
49
|
+
{option.leading}
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
<span className="m-choice-text">
|
|
53
|
+
<span>{option.label}</span>
|
|
54
|
+
{option.description && <small>{option.description}</small>}
|
|
55
|
+
</span>
|
|
56
|
+
<Check className="m-choice-check" />
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Label with the required marker; `spoken` adds "required" for controls that cannot carry aria-required. */
|
|
62
|
+
function ChoiceLabel({
|
|
63
|
+
label,
|
|
64
|
+
required,
|
|
65
|
+
spoken = false,
|
|
66
|
+
}: {
|
|
67
|
+
label: string;
|
|
68
|
+
required?: boolean;
|
|
69
|
+
spoken?: boolean;
|
|
70
|
+
}) {
|
|
71
|
+
return (
|
|
72
|
+
<Label>
|
|
73
|
+
{label}
|
|
74
|
+
{required && <RequiredMark />}
|
|
75
|
+
{required && spoken && <span className="m-sr-only"> (required)</span>}
|
|
76
|
+
</Label>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The hint or error; the error is a separate alert so it is announced when it appears. */
|
|
81
|
+
function Message({ hint, error }: { hint?: string; error?: string }) {
|
|
82
|
+
if (error)
|
|
83
|
+
return (
|
|
84
|
+
<Description key="error" className="m-error" role="alert">
|
|
85
|
+
{error}
|
|
86
|
+
</Description>
|
|
87
|
+
);
|
|
88
|
+
return hint ? <Description key="hint">{hint}</Description> : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Styled single select for rich options (avatars, descriptions). Use Select for plain native choices. */
|
|
92
|
+
export function Listbox({
|
|
93
|
+
label,
|
|
94
|
+
options,
|
|
95
|
+
value,
|
|
96
|
+
onChange,
|
|
97
|
+
placeholder = "Select…",
|
|
98
|
+
hint,
|
|
99
|
+
error,
|
|
100
|
+
disabled = false,
|
|
101
|
+
required,
|
|
102
|
+
className,
|
|
103
|
+
}: ChoiceProps) {
|
|
104
|
+
const selected = options.find((o) => o.value === value);
|
|
105
|
+
return (
|
|
106
|
+
<Field
|
|
107
|
+
{...componentRoot("Listbox")}
|
|
108
|
+
className={cx("m-field", className)}
|
|
109
|
+
disabled={disabled}
|
|
110
|
+
>
|
|
111
|
+
<ChoiceLabel label={label} required={required} spoken />
|
|
112
|
+
<HeadlessListbox value={value} onChange={onChange}>
|
|
113
|
+
<ListboxButton
|
|
114
|
+
className="m-choice-control"
|
|
115
|
+
aria-invalid={error ? true : undefined}
|
|
116
|
+
>
|
|
117
|
+
{selected ? (
|
|
118
|
+
<span className="m-choice-value">
|
|
119
|
+
{selected.leading && (
|
|
120
|
+
<span className="m-choice-leading" aria-hidden="true">
|
|
121
|
+
{selected.leading}
|
|
122
|
+
</span>
|
|
123
|
+
)}
|
|
124
|
+
<span>{selected.label}</span>
|
|
125
|
+
</span>
|
|
126
|
+
) : (
|
|
127
|
+
<span className="m-placeholder">{placeholder}</span>
|
|
128
|
+
)}
|
|
129
|
+
<ChevronDown />
|
|
130
|
+
</ListboxButton>
|
|
131
|
+
<ListboxOptions anchor="bottom start" className="m-choice-options">
|
|
132
|
+
{options.map((option) => (
|
|
133
|
+
<ListboxOption
|
|
134
|
+
key={option.value}
|
|
135
|
+
value={option.value}
|
|
136
|
+
disabled={option.disabled}
|
|
137
|
+
className="m-choice-option"
|
|
138
|
+
>
|
|
139
|
+
<OptionContent option={option} />
|
|
140
|
+
</ListboxOption>
|
|
141
|
+
))}
|
|
142
|
+
</ListboxOptions>
|
|
143
|
+
</HeadlessListbox>
|
|
144
|
+
<Message hint={hint} error={error} />
|
|
145
|
+
</Field>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Searchable single select; filters options by label as the person types. */
|
|
150
|
+
export function Combobox({
|
|
151
|
+
label,
|
|
152
|
+
options,
|
|
153
|
+
value,
|
|
154
|
+
onChange,
|
|
155
|
+
placeholder,
|
|
156
|
+
hint,
|
|
157
|
+
error,
|
|
158
|
+
disabled = false,
|
|
159
|
+
required,
|
|
160
|
+
className,
|
|
161
|
+
empty = { title: "No matches" },
|
|
162
|
+
}: ChoiceProps & {
|
|
163
|
+
/** Shown and announced when the typed text matches no option. */
|
|
164
|
+
empty?: { title: string; description?: string; action?: ReactNode };
|
|
165
|
+
}) {
|
|
166
|
+
const [query, setQuery] = useState("");
|
|
167
|
+
const needle = query.trim().toLowerCase();
|
|
168
|
+
const filtered = needle
|
|
169
|
+
? options.filter((o) => o.label.toLowerCase().includes(needle))
|
|
170
|
+
: options;
|
|
171
|
+
const leading = query
|
|
172
|
+
? undefined
|
|
173
|
+
: options.find((o) => o.value === value)?.leading;
|
|
174
|
+
const nothing = !!needle && !filtered.length;
|
|
175
|
+
return (
|
|
176
|
+
<Field
|
|
177
|
+
{...componentRoot("Combobox")}
|
|
178
|
+
className={cx("m-field", className)}
|
|
179
|
+
disabled={disabled}
|
|
180
|
+
>
|
|
181
|
+
<ChoiceLabel label={label} required={required} />
|
|
182
|
+
<HeadlessCombobox
|
|
183
|
+
value={value}
|
|
184
|
+
onChange={onChange}
|
|
185
|
+
onClose={() => setQuery("")}
|
|
186
|
+
>
|
|
187
|
+
<div className="m-combobox" data-leading={leading ? true : undefined}>
|
|
188
|
+
{leading && (
|
|
189
|
+
<span className="m-choice-leading" aria-hidden="true">
|
|
190
|
+
{leading}
|
|
191
|
+
</span>
|
|
192
|
+
)}
|
|
193
|
+
<ComboboxInput
|
|
194
|
+
className="m-choice-control"
|
|
195
|
+
placeholder={placeholder}
|
|
196
|
+
aria-invalid={error ? true : undefined}
|
|
197
|
+
aria-required={required || undefined}
|
|
198
|
+
displayValue={(v: string | null) =>
|
|
199
|
+
options.find((o) => o.value === v)?.label ?? ""
|
|
200
|
+
}
|
|
201
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
202
|
+
/>
|
|
203
|
+
<ComboboxButton
|
|
204
|
+
className="m-combobox-toggle"
|
|
205
|
+
aria-label={`Show ${label} options`}
|
|
206
|
+
>
|
|
207
|
+
<ChevronDown />
|
|
208
|
+
</ComboboxButton>
|
|
209
|
+
</div>
|
|
210
|
+
{filtered.length > 0 && (
|
|
211
|
+
<ComboboxOptions
|
|
212
|
+
anchor="bottom start"
|
|
213
|
+
className="m-choice-options m-combobox-options"
|
|
214
|
+
>
|
|
215
|
+
{filtered.map((option) => (
|
|
216
|
+
<ComboboxOption
|
|
217
|
+
key={option.value}
|
|
218
|
+
value={option.value}
|
|
219
|
+
disabled={option.disabled}
|
|
220
|
+
className="m-choice-option"
|
|
221
|
+
>
|
|
222
|
+
<OptionContent option={option} />
|
|
223
|
+
</ComboboxOption>
|
|
224
|
+
))}
|
|
225
|
+
</ComboboxOptions>
|
|
226
|
+
)}
|
|
227
|
+
</HeadlessCombobox>
|
|
228
|
+
<p role="status" className={nothing ? undefined : "m-sr-only"}>
|
|
229
|
+
{nothing && (
|
|
230
|
+
<>
|
|
231
|
+
{empty.title}
|
|
232
|
+
{empty.description && <span> {empty.description}</span>}
|
|
233
|
+
</>
|
|
234
|
+
)}
|
|
235
|
+
</p>
|
|
236
|
+
{nothing && empty.action}
|
|
237
|
+
<Message hint={hint} error={error} />
|
|
238
|
+
</Field>
|
|
239
|
+
);
|
|
240
|
+
}
|
package/src/composer.tsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { Button, Tabs } from "./primitives.js";
|
|
3
|
+
import { FieldFeedback, componentRoot, cx, useField } from "./internal.js";
|
|
4
|
+
|
|
5
|
+
/** Characters left at which the count starts being announced. */
|
|
6
|
+
const nearLimit = (maxLength: number) =>
|
|
7
|
+
Math.max(10, Math.ceil(maxLength * 0.1));
|
|
8
|
+
|
|
9
|
+
/** Comment box with optional avatar, toolbar, Write/Preview tabs and a character count. */
|
|
10
|
+
export function CommentComposer({
|
|
11
|
+
onSubmit,
|
|
12
|
+
value: controlled,
|
|
13
|
+
onChange,
|
|
14
|
+
label = "Add your comment",
|
|
15
|
+
labelVisible = false,
|
|
16
|
+
placeholder = "Add your comment…",
|
|
17
|
+
submitLabel = "Comment",
|
|
18
|
+
loading = false,
|
|
19
|
+
avatar,
|
|
20
|
+
actions,
|
|
21
|
+
preview,
|
|
22
|
+
maxLength,
|
|
23
|
+
hint,
|
|
24
|
+
error,
|
|
25
|
+
rows = 3,
|
|
26
|
+
className,
|
|
27
|
+
}: {
|
|
28
|
+
onSubmit: (value: string) => void;
|
|
29
|
+
/** Controlled text; omit to let the composer manage and clear it. */
|
|
30
|
+
value?: string;
|
|
31
|
+
/** Called with the text on every edit. */
|
|
32
|
+
onChange?: (value: string) => void;
|
|
33
|
+
label?: string;
|
|
34
|
+
labelVisible?: boolean;
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
submitLabel?: string;
|
|
37
|
+
/** Busy submitting: the submit button keeps focus but ignores activation. */
|
|
38
|
+
loading?: boolean;
|
|
39
|
+
/** Author avatar shown beside the box (decorative). */
|
|
40
|
+
avatar?: ReactNode;
|
|
41
|
+
/** Toolbar controls such as attach or emoji buttons. */
|
|
42
|
+
actions?: ReactNode;
|
|
43
|
+
/** Renders a preview of the text and enables Write/Preview tabs. */
|
|
44
|
+
preview?: (value: string) => ReactNode;
|
|
45
|
+
/** Shows a visible count; the remaining characters are announced only near the limit. */
|
|
46
|
+
maxLength?: number;
|
|
47
|
+
hint?: string;
|
|
48
|
+
/** Marks the text invalid; the message is announced when it appears. */
|
|
49
|
+
error?: string;
|
|
50
|
+
rows?: number;
|
|
51
|
+
className?: string;
|
|
52
|
+
}) {
|
|
53
|
+
const { fieldId: id, messageId, describedBy } = useField({ hint, error });
|
|
54
|
+
const [own, setOwn] = useState("");
|
|
55
|
+
const [mode, setMode] = useState("write");
|
|
56
|
+
const value = controlled ?? own;
|
|
57
|
+
const setValue = (next: string) => {
|
|
58
|
+
if (controlled === undefined) setOwn(next);
|
|
59
|
+
onChange?.(next);
|
|
60
|
+
};
|
|
61
|
+
const empty = !value.trim();
|
|
62
|
+
const submit = () => {
|
|
63
|
+
if (empty || loading) return;
|
|
64
|
+
onSubmit(value);
|
|
65
|
+
if (controlled === undefined) setOwn("");
|
|
66
|
+
};
|
|
67
|
+
const remaining = maxLength === undefined ? 0 : maxLength - value.length;
|
|
68
|
+
const field = (
|
|
69
|
+
<textarea
|
|
70
|
+
id={id}
|
|
71
|
+
rows={rows}
|
|
72
|
+
value={value}
|
|
73
|
+
placeholder={placeholder}
|
|
74
|
+
maxLength={maxLength}
|
|
75
|
+
aria-invalid={error ? true : undefined}
|
|
76
|
+
aria-describedby={describedBy}
|
|
77
|
+
aria-keyshortcuts="Control+Enter Meta+Enter"
|
|
78
|
+
onChange={(event) => setValue(event.target.value)}
|
|
79
|
+
onKeyDown={(event) => {
|
|
80
|
+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
submit();
|
|
83
|
+
}
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
return (
|
|
88
|
+
<form
|
|
89
|
+
{...componentRoot("CommentComposer")}
|
|
90
|
+
className={cx("m-composer", className)}
|
|
91
|
+
onSubmit={(event) => {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
submit();
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{avatar && (
|
|
97
|
+
<span className="m-composer-avatar" aria-hidden="true">
|
|
98
|
+
{avatar}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
<div className="m-composer-main">
|
|
102
|
+
<label
|
|
103
|
+
className={labelVisible ? "m-composer-label" : "m-sr-only"}
|
|
104
|
+
htmlFor={id}
|
|
105
|
+
>
|
|
106
|
+
{label}
|
|
107
|
+
</label>
|
|
108
|
+
<div className="m-composer-box" data-invalid={error ? true : undefined}>
|
|
109
|
+
{preview ? (
|
|
110
|
+
<Tabs
|
|
111
|
+
label="Comment mode"
|
|
112
|
+
value={mode}
|
|
113
|
+
onChange={setMode}
|
|
114
|
+
items={[
|
|
115
|
+
{ id: "write", label: "Write", content: field },
|
|
116
|
+
{
|
|
117
|
+
id: "preview",
|
|
118
|
+
label: "Preview",
|
|
119
|
+
content: (
|
|
120
|
+
<div className="m-composer-preview">
|
|
121
|
+
{empty ? (
|
|
122
|
+
<p className="m-composer-empty">Nothing to preview</p>
|
|
123
|
+
) : (
|
|
124
|
+
preview(value)
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
]}
|
|
130
|
+
/>
|
|
131
|
+
) : (
|
|
132
|
+
field
|
|
133
|
+
)}
|
|
134
|
+
<div className="m-composer-bar">
|
|
135
|
+
{actions && <div className="m-composer-actions">{actions}</div>}
|
|
136
|
+
{maxLength !== undefined && (
|
|
137
|
+
<span className="m-composer-count">
|
|
138
|
+
{value.length} / {maxLength}
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
<Button type="submit" disabled={empty} loading={loading}>
|
|
142
|
+
{submitLabel}
|
|
143
|
+
</Button>
|
|
144
|
+
{maxLength !== undefined && (
|
|
145
|
+
<span className="m-sr-only" role="status">
|
|
146
|
+
{remaining <= nearLimit(maxLength) &&
|
|
147
|
+
(remaining > 0
|
|
148
|
+
? `${remaining} ${remaining === 1 ? "character" : "characters"} left`
|
|
149
|
+
: "Character limit reached")}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
<FieldFeedback id={messageId} hint={hint} error={error} />
|
|
155
|
+
</div>
|
|
156
|
+
</form>
|
|
157
|
+
);
|
|
158
|
+
}
|