@tonycodes-ui/mortar 0.0.0-stage → 0.11.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 +276 -0
- package/CONTRIBUTING.md +48 -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 +9703 -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/index.d.ts +34 -0
- package/dist/index.js +17 -0
- package/dist/inspection.d.ts +24 -0
- package/dist/inspection.js +14 -0
- package/dist/internal.d.ts +492 -0
- package/dist/internal.js +273 -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 +254 -0
- package/dist/marketing.js +150 -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/index.ts +230 -0
- package/src/inspection.tsx +91 -0
- package/src/internal.tsx +614 -0
- package/src/layout.tsx +342 -0
- package/src/marketing-sections.tsx +621 -0
- package/src/marketing.css +2400 -0
- package/src/marketing.tsx +1156 -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/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type InputHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { type FieldDescription } from "./primitives.js";
|
|
3
|
+
import { type HeadingLevel } from "./heading.js";
|
|
4
|
+
/** A sign-in provider such as GitHub or Google; the auth SDK handles `onSelect`. */
|
|
5
|
+
export interface SignInProvider {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** Decorative logo; the label stays the accessible name. */
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
onSelect: () => void;
|
|
11
|
+
}
|
|
12
|
+
export interface ProviderButtonsProps {
|
|
13
|
+
providers: SignInProvider[];
|
|
14
|
+
/** `grid` sets the buttons side by side where they fit, e.g. two short labels. */
|
|
15
|
+
layout?: "list" | "grid";
|
|
16
|
+
/** Names the group of buttons, e.g. "Sign in with". */
|
|
17
|
+
label?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* One full-width secondary button per sign-in provider. Pair it with a
|
|
22
|
+
* labelled Divider ("or continue with") above or below the email form.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ProviderButtons({ providers, layout, label, className, }: ProviderButtonsProps): import("react").JSX.Element;
|
|
25
|
+
/** The logo, heading and description that open an auth panel (internal). */
|
|
26
|
+
export declare function AuthHeader({ logo, title, description, headingLevel, headingId, className, titleClassName, }: {
|
|
27
|
+
logo?: ReactNode;
|
|
28
|
+
title: string;
|
|
29
|
+
description?: ReactNode;
|
|
30
|
+
headingLevel: HeadingLevel;
|
|
31
|
+
headingId: string;
|
|
32
|
+
className?: string;
|
|
33
|
+
titleClassName: string;
|
|
34
|
+
}): import("react").JSX.Element;
|
|
35
|
+
export interface AuthLayoutProps {
|
|
36
|
+
/** The screen's heading, e.g. "Sign in to your account". */
|
|
37
|
+
title: string;
|
|
38
|
+
description?: ReactNode;
|
|
39
|
+
/** Logo node above the heading. */
|
|
40
|
+
logo?: ReactNode;
|
|
41
|
+
/**
|
|
42
|
+
* `center` sets the form on the page, `cards` on a raised card, and `split`
|
|
43
|
+
* beside the `aside` on wide screens.
|
|
44
|
+
*/
|
|
45
|
+
layout?: "center" | "cards" | "split";
|
|
46
|
+
/** The page's h1 by default; lower it only when another h1 precedes the layout. */
|
|
47
|
+
headingLevel?: HeadingLevel;
|
|
48
|
+
/** The form. A `<form>` placed directly here stacks its fields, and its direct Button children fill the width. */
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
/** Text under the form, e.g. "Don't have an account? Sign up". */
|
|
51
|
+
footer?: ReactNode;
|
|
52
|
+
/**
|
|
53
|
+
* Image, quote or product art beside the form; shown only with `layout="split"`.
|
|
54
|
+
* On narrow screens text moves below the form and a lone image or video is hidden.
|
|
55
|
+
*/
|
|
56
|
+
aside?: ReactNode;
|
|
57
|
+
id?: string;
|
|
58
|
+
className?: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The page shell for sign-in, sign-up, password reset and verification
|
|
62
|
+
* screens. It renders the page's `main` landmark, named by the heading.
|
|
63
|
+
*/
|
|
64
|
+
export declare function AuthLayout({ title, description, logo, layout, headingLevel, children, footer, aside, id, className, }: AuthLayoutProps): import("react").JSX.Element;
|
|
65
|
+
export interface PasswordRequirement {
|
|
66
|
+
/** Shown before the password is typed, e.g. "At least 12 characters". */
|
|
67
|
+
label: string;
|
|
68
|
+
met: boolean;
|
|
69
|
+
}
|
|
70
|
+
export interface PasswordStrength {
|
|
71
|
+
/** 0 (very weak) to 4 (very strong), e.g. from zxcvbn's score. */
|
|
72
|
+
value: 0 | 1 | 2 | 3 | 4;
|
|
73
|
+
/** Visible and announced value, e.g. "Strong". */
|
|
74
|
+
label: string;
|
|
75
|
+
}
|
|
76
|
+
export interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type">, FieldDescription {
|
|
77
|
+
/**
|
|
78
|
+
* `current` (sign-in) sets autocomplete="current-password"; `new` (sign-up,
|
|
79
|
+
* reset) sets "new-password" so password managers offer to generate one.
|
|
80
|
+
*/
|
|
81
|
+
purpose?: "current" | "new";
|
|
82
|
+
/** Rules shown under the field from the start, each marked met or not met as the person types. */
|
|
83
|
+
requirements?: PasswordRequirement[];
|
|
84
|
+
/** Strength meter under the field. */
|
|
85
|
+
strength?: PasswordStrength;
|
|
86
|
+
/** Accessible name of the show/hide toggle; its pressed state says whether the password is visible. */
|
|
87
|
+
toggleLabel?: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Password field with a show/hide toggle, optional requirement checklist and
|
|
91
|
+
* strength meter. Paste is never blocked.
|
|
92
|
+
*/
|
|
93
|
+
export declare const PasswordInput: import("react").ForwardRefExoticComponent<PasswordInputProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
94
|
+
export interface CodeInputProps extends FieldDescription {
|
|
95
|
+
/** Number of characters; one visual cell each. */
|
|
96
|
+
length?: number;
|
|
97
|
+
/** Controlled value; pair with `onChange`. */
|
|
98
|
+
value?: string;
|
|
99
|
+
defaultValue?: string;
|
|
100
|
+
/** The cleaned code after each edit: allowed characters only, at most `length`. */
|
|
101
|
+
onChange?: (value: string) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Called once when the code reaches `length` characters, by typing, paste or
|
|
104
|
+
* autofill. Submitting here is opt-in; without it the person submits the form.
|
|
105
|
+
*/
|
|
106
|
+
onComplete?: (code: string) => void;
|
|
107
|
+
/** Accept letters as well as digits (upper-cased); digits only by default. */
|
|
108
|
+
alphanumeric?: boolean;
|
|
109
|
+
/** Id of the input, e.g. the target of an error summary link. */
|
|
110
|
+
id?: string;
|
|
111
|
+
name?: string;
|
|
112
|
+
required?: boolean;
|
|
113
|
+
disabled?: boolean;
|
|
114
|
+
autoFocus?: boolean;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* One-time code field: a single input shown as one cell per character, so
|
|
118
|
+
* screen readers, paste and SMS or password-manager autofill all see one
|
|
119
|
+
* field (autocomplete="one-time-code").
|
|
120
|
+
*/
|
|
121
|
+
export declare const CodeInput: import("react").ForwardRefExoticComponent<CodeInputProps & import("react").RefAttributes<HTMLInputElement>>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// Sign-in and registration building blocks: the auth page shell, provider
|
|
3
|
+
// buttons and the password and one-time-code fields. See docs/auth-flows.md.
|
|
4
|
+
import { forwardRef, useId, useState, } from "react";
|
|
5
|
+
import { Button, FieldLabel } from "./primitives.js";
|
|
6
|
+
import { BrandMark } from "./layout.js";
|
|
7
|
+
import { Check, Circle, Eye, EyeOff } from "./icons.js";
|
|
8
|
+
import { headingTag } from "./heading.js";
|
|
9
|
+
import { FieldFeedback, componentRoot, cx, useField } from "./internal.js";
|
|
10
|
+
/**
|
|
11
|
+
* One full-width secondary button per sign-in provider. Pair it with a
|
|
12
|
+
* labelled Divider ("or continue with") above or below the email form.
|
|
13
|
+
*/
|
|
14
|
+
export function ProviderButtons({ providers, layout = "list", label, className, }) {
|
|
15
|
+
return (_jsx("div", { ...componentRoot("ProviderButtons"), className: cx("m-provider-buttons", className), role: label ? "group" : undefined, "aria-label": label, "data-layout": layout === "list" ? undefined : layout, children: providers.map((provider) => (_jsx(Button, { variant: "secondary", icon: provider.icon, onClick: provider.onSelect, children: provider.label }, provider.id))) }));
|
|
16
|
+
}
|
|
17
|
+
/** The logo, heading and description that open an auth panel (internal). */
|
|
18
|
+
export function AuthHeader({ logo, title, description, headingLevel, headingId, className, titleClassName, }) {
|
|
19
|
+
const Heading = headingTag(headingLevel);
|
|
20
|
+
return (_jsxs("header", { className: className, children: [logo && _jsx(BrandMark, { logo: logo }), _jsx(Heading, { id: headingId, className: titleClassName, children: title }), description && _jsx("p", { children: description })] }));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The page shell for sign-in, sign-up, password reset and verification
|
|
24
|
+
* screens. It renders the page's `main` landmark, named by the heading.
|
|
25
|
+
*/
|
|
26
|
+
export function AuthLayout({ title, description, logo, layout = "center", headingLevel = 1, children, footer, aside, id, className, }) {
|
|
27
|
+
const headingId = useId();
|
|
28
|
+
return (_jsxs("div", { id: id, ...componentRoot("AuthLayout"), className: cx("m-auth", className), "data-layout": layout === "center" ? undefined : layout, children: [_jsx("main", { className: "m-auth-main", "aria-labelledby": headingId, children: _jsxs("div", { className: "m-auth-panel", children: [_jsx(AuthHeader, { className: "m-auth-header", titleClassName: "m-auth-title", logo: logo, title: title, description: description, headingLevel: headingLevel, headingId: headingId }), _jsx("div", { className: "m-auth-body", children: children }), footer && _jsx("footer", { className: "m-auth-footer", children: footer })] }) }), layout === "split" && aside && (_jsx("aside", { className: "m-auth-aside", children: aside }))] }));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Password field with a show/hide toggle, optional requirement checklist and
|
|
32
|
+
* strength meter. Paste is never blocked.
|
|
33
|
+
*/
|
|
34
|
+
export const PasswordInput = forwardRef(function PasswordInput({ label, hint, error, id, className, purpose = "current", requirements, strength, toggleLabel = "Show password", autoComplete, ...props }, ref) {
|
|
35
|
+
const [visible, setVisible] = useState(false);
|
|
36
|
+
const requirementsId = useId();
|
|
37
|
+
const { fieldId, messageId, describedBy } = useField({
|
|
38
|
+
id,
|
|
39
|
+
hint,
|
|
40
|
+
error,
|
|
41
|
+
describedBy: props["aria-describedby"],
|
|
42
|
+
});
|
|
43
|
+
const allMet = !!requirements?.length && requirements.every((r) => r.met);
|
|
44
|
+
// The status region speaks only when the overall state flips, not on
|
|
45
|
+
// every keystroke: silent until every rule has been met once.
|
|
46
|
+
const [everMet, setEverMet] = useState(false);
|
|
47
|
+
if (allMet && !everMet)
|
|
48
|
+
setEverMet(true);
|
|
49
|
+
const status = allMet
|
|
50
|
+
? "All password requirements met."
|
|
51
|
+
: everMet
|
|
52
|
+
? "Password no longer meets every requirement."
|
|
53
|
+
: "";
|
|
54
|
+
return (_jsxs("div", { ...componentRoot("PasswordInput"), className: cx("m-field", "m-password", className), children: [_jsx(FieldLabel, { htmlFor: fieldId, label: label, required: props.required }), _jsxs("div", { className: "m-input-group", "data-invalid": !!error || undefined, children: [_jsx("input", { ...props, ref: ref, id: fieldId, type: visible ? "text" : "password", autoComplete: autoComplete ??
|
|
55
|
+
(purpose === "new" ? "new-password" : "current-password"), autoCapitalize: "off", spellCheck: false, "aria-invalid": !!error || props["aria-invalid"], "aria-describedby": [describedBy, requirements?.length && requirementsId]
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join(" ") || undefined }), _jsxs("button", { type: "button", className: "m-password-toggle", "aria-pressed": visible, "aria-controls": fieldId, disabled: props.disabled,
|
|
58
|
+
// A pointer press keeps focus where it was (usually the field).
|
|
59
|
+
onMouseDown: (event) => event.preventDefault(), onClick: () => setVisible((v) => !v), children: [visible ? _jsx(EyeOff, {}) : _jsx(Eye, {}), _jsx("span", { className: "m-sr-only", children: toggleLabel })] })] }), _jsx(FieldFeedback, { id: messageId, hint: hint, error: error }), strength && _jsx(StrengthMeter, { strength: strength }), requirements && requirements.length > 0 && (_jsxs(_Fragment, { children: [_jsx("ul", { id: requirementsId, className: "m-password-requirements", "aria-label": "Password requirements", children: requirements.map((rule) => (_jsxs("li", { "data-met": rule.met || undefined, children: [rule.met ? _jsx(Check, {}) : _jsx(Circle, {}), _jsx("span", { children: rule.label }), _jsx("span", { className: "m-sr-only", children: rule.met ? ", met" : ", not met" })] }, rule.label))) }), _jsx("p", { role: "status", className: "m-sr-only", children: status })] }))] }));
|
|
60
|
+
});
|
|
61
|
+
function StrengthMeter({ strength }) {
|
|
62
|
+
const labelId = useId();
|
|
63
|
+
const value = Math.max(0, Math.min(4, Math.round(strength.value)));
|
|
64
|
+
return (_jsxs("div", { className: "m-password-strength", "data-value": value, children: [_jsx("span", { id: labelId, className: "m-sr-only", children: "Password strength" }), _jsx("div", { className: "m-password-meter", role: "meter", "aria-labelledby": labelId, "aria-valuemin": 0, "aria-valuemax": 4, "aria-valuenow": value, "aria-valuetext": strength.label, children: [1, 2, 3, 4].map((step) => (_jsx("span", { "data-on": value >= step || undefined }, step))) }), _jsx("span", { "aria-hidden": "true", children: strength.label })] }));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* One-time code field: a single input shown as one cell per character, so
|
|
68
|
+
* screen readers, paste and SMS or password-manager autofill all see one
|
|
69
|
+
* field (autocomplete="one-time-code").
|
|
70
|
+
*/
|
|
71
|
+
export const CodeInput = forwardRef(function CodeInput({ label, hint, error, className, length = 6, value: controlled, defaultValue = "", onChange, onComplete, alphanumeric = false, id, name, required, disabled, autoFocus, }, ref) {
|
|
72
|
+
const clean = (text) => (alphanumeric
|
|
73
|
+
? text.replace(/[^a-z0-9]/gi, "").toUpperCase()
|
|
74
|
+
: text.replace(/\D/g, "")).slice(0, length);
|
|
75
|
+
const [local, setLocal] = useState(() => clean(defaultValue));
|
|
76
|
+
const value = controlled === undefined ? local : clean(controlled);
|
|
77
|
+
const [focused, setFocused] = useState(false);
|
|
78
|
+
const { fieldId, messageId, describedBy } = useField({ id, hint, error });
|
|
79
|
+
const active = focused ? Math.min(value.length, length - 1) : -1;
|
|
80
|
+
return (_jsxs("div", { ...componentRoot("CodeInput"), className: cx("m-field", "m-code-input", className), "data-disabled": disabled || undefined, children: [_jsx(FieldLabel, { htmlFor: fieldId, label: label, required: required }), _jsxs("div", { className: "m-code-control", "data-invalid": !!error || undefined, style: { "--m-code-length": length }, children: [_jsx("input", { ref: ref, id: fieldId, name: name, type: "text", inputMode: alphanumeric ? "text" : "numeric", autoComplete: "one-time-code", autoCapitalize: alphanumeric ? "characters" : "off", autoCorrect: "off", spellCheck: false, pattern: alphanumeric ? `[A-Za-z0-9]{${length}}` : `[0-9]{${length}}`, required: required, disabled: disabled, autoFocus: autoFocus, value: value, "aria-invalid": !!error || undefined, "aria-describedby": describedBy, onFocus: () => setFocused(true), onBlur: () => setFocused(false),
|
|
81
|
+
// Keep the caret after the last character, so typing fills the
|
|
82
|
+
// next cell and Backspace clears the previous one.
|
|
83
|
+
onSelect: (event) => {
|
|
84
|
+
const input = event.currentTarget;
|
|
85
|
+
const end = input.value.length;
|
|
86
|
+
if (input.selectionStart === input.selectionEnd &&
|
|
87
|
+
input.selectionStart !== end)
|
|
88
|
+
input.setSelectionRange(end, end);
|
|
89
|
+
}, onChange: (event) => {
|
|
90
|
+
const next = clean(event.target.value);
|
|
91
|
+
if (controlled === undefined)
|
|
92
|
+
setLocal(next);
|
|
93
|
+
onChange?.(next);
|
|
94
|
+
if (next !== value && next.length === length)
|
|
95
|
+
onComplete?.(next);
|
|
96
|
+
} }), _jsx("div", { className: "m-code-cells", "aria-hidden": "true", children: Array.from({ length }, (_, index) => (_jsx("span", { className: "m-code-cell", "data-filled": index < value.length || undefined, "data-active": index === active || undefined, children: value[index] }, index))) })] }), _jsx(FieldFeedback, { id: messageId, hint: hint, error: error })] }));
|
|
97
|
+
});
|
package/dist/brand.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface BrandAppearanceTokens {
|
|
2
|
+
primary?: string;
|
|
3
|
+
primaryHover?: string;
|
|
4
|
+
primaryActive?: string;
|
|
5
|
+
onPrimary?: string;
|
|
6
|
+
accent?: string;
|
|
7
|
+
onAccent?: string;
|
|
8
|
+
focus?: string;
|
|
9
|
+
link?: string;
|
|
10
|
+
selected?: string;
|
|
11
|
+
/** The page background. */
|
|
12
|
+
paper?: string;
|
|
13
|
+
/** Cards, panels and raised controls. */
|
|
14
|
+
surface?: string;
|
|
15
|
+
/** Recessed areas: wells, muted bands and hover fills. */
|
|
16
|
+
sunken?: string;
|
|
17
|
+
/**
|
|
18
|
+
* An optional accent fill: `linear-gradient()`, `radial-gradient()` or
|
|
19
|
+
* `conic-gradient()` (or a `repeating-` form) whose stops are colours. Only
|
|
20
|
+
* components that opt in paint it (Button `variant="accent"`, and
|
|
21
|
+
* `tone="accent"` on CallToAction, Hero and Banner), with `onAccent` text,
|
|
22
|
+
* so `onAccent` must reach 4.5:1 against every stop. The flat `accent`
|
|
23
|
+
* stays the fallback and the input for derived colours.
|
|
24
|
+
*/
|
|
25
|
+
accentGradient?: string;
|
|
26
|
+
}
|
|
27
|
+
/** Font stacks, the same in light and dark. */
|
|
28
|
+
export interface BrandFonts {
|
|
29
|
+
/** Body text: a font-family list, e.g. `"Inter", sans-serif`. */
|
|
30
|
+
body?: string;
|
|
31
|
+
/** Headings and component titles. Defaults to the body font. */
|
|
32
|
+
heading?: string;
|
|
33
|
+
}
|
|
34
|
+
/** A brand's light and dark values. Inverse bands use the other appearance's. */
|
|
35
|
+
export interface BrandDefinition {
|
|
36
|
+
light?: BrandAppearanceTokens;
|
|
37
|
+
dark?: BrandAppearanceTokens;
|
|
38
|
+
fonts?: BrandFonts;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* True for colour syntax only: hex, a colour name (including `transparent`
|
|
42
|
+
* and `currentcolor`), or rgb(), rgba(), hsl(), hsla(), hwb(), lab(), lch(),
|
|
43
|
+
* oklab(), oklch(), color(), color-mix(), light-dark() or var(), whose
|
|
44
|
+
* arguments may nest those functions and calc(), min(), max() or clamp().
|
|
45
|
+
*/
|
|
46
|
+
export declare function isBrandColour(value: string): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* True for a CSS gradient whose stops are colour syntax: linear-, radial- or
|
|
49
|
+
* conic-gradient() (or a repeating- form) with at least two colour stops.
|
|
50
|
+
* Angles, positions, percentages and keywords such as `to right` are allowed;
|
|
51
|
+
* other functions (url(), image-set(), attr() …) are not.
|
|
52
|
+
*/
|
|
53
|
+
export declare function isBrandGradient(value: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* True for a font-family list: family names, quoted names, generic families
|
|
56
|
+
* and bare `var(--name)` references (as framework font loaders hand out),
|
|
57
|
+
* separated by commas. No other functions, url() or CSS punctuation.
|
|
58
|
+
*/
|
|
59
|
+
export declare function isBrandFont(value: string): boolean;
|
|
60
|
+
/** The brand's custom properties, validated, as `--m-brand-*` entries. */
|
|
61
|
+
export declare function brandToStyle(brand: BrandDefinition): Record<`--m-brand-${string}`, string>;
|
|
62
|
+
/** The brand as a CSS rule, for a stylesheet or a `<style>` element. */
|
|
63
|
+
export declare function brandToCss(name: string, brand: BrandDefinition): string;
|
|
64
|
+
/**
|
|
65
|
+
* Adds the brand's stylesheet to the document, for apps that don't use
|
|
66
|
+
* ThemeProvider's `brandTokens`. Each call returns a disposer; the stylesheet
|
|
67
|
+
* stays until every caller for that name has disposed.
|
|
68
|
+
*/
|
|
69
|
+
export declare function registerBrand(name: string, brand: BrandDefinition): () => void;
|
package/dist/brand.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/* Programmatic brands. Mirrors the brand contract in tokens.css, so an app can
|
|
2
|
+
define a brand from a config object, a CMS value or a tenant record without
|
|
3
|
+
shipping brand CSS. Every value is optional; omitted values derive from
|
|
4
|
+
primary, or keep Mortar's defaults, exactly as the CSS layer does. */
|
|
5
|
+
const KEYS = {
|
|
6
|
+
primary: "primary",
|
|
7
|
+
primaryHover: "primary-hover",
|
|
8
|
+
primaryActive: "primary-active",
|
|
9
|
+
onPrimary: "on-primary",
|
|
10
|
+
accent: "accent",
|
|
11
|
+
onAccent: "on-accent",
|
|
12
|
+
focus: "focus",
|
|
13
|
+
link: "link",
|
|
14
|
+
selected: "selected",
|
|
15
|
+
paper: "paper",
|
|
16
|
+
surface: "surface",
|
|
17
|
+
sunken: "sunken",
|
|
18
|
+
accentGradient: "accent-gradient",
|
|
19
|
+
};
|
|
20
|
+
const FONT_KEYS = {
|
|
21
|
+
body: "font",
|
|
22
|
+
heading: "font-heading",
|
|
23
|
+
};
|
|
24
|
+
const NAME = /^[a-z0-9][a-z0-9-]*$/i;
|
|
25
|
+
/* Value validation. A value from tenant data must be colour (or gradient, or
|
|
26
|
+
font-family) syntax and nothing else, so it can neither end the declaration
|
|
27
|
+
nor inject other CSS, and a value of the wrong kind throws here instead of
|
|
28
|
+
failing silently in the browser. Values are tokenised, and every function is
|
|
29
|
+
checked against an allowlist, recursively. */
|
|
30
|
+
const NAMED = new Set(("transparent currentcolor aliceblue antiquewhite aqua aquamarine azure beige bisque black " +
|
|
31
|
+
"blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral " +
|
|
32
|
+
"cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen " +
|
|
33
|
+
"darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon " +
|
|
34
|
+
"darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink " +
|
|
35
|
+
"deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro " +
|
|
36
|
+
"ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo " +
|
|
37
|
+
"ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan " +
|
|
38
|
+
"lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen " +
|
|
39
|
+
"lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen " +
|
|
40
|
+
"magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen " +
|
|
41
|
+
"mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream " +
|
|
42
|
+
"mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid " +
|
|
43
|
+
"palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum " +
|
|
44
|
+
"powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown " +
|
|
45
|
+
"seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen " +
|
|
46
|
+
"steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen").split(" "));
|
|
47
|
+
const COLOUR_FUNCTIONS = new Set([
|
|
48
|
+
"rgb",
|
|
49
|
+
"rgba",
|
|
50
|
+
"hsl",
|
|
51
|
+
"hsla",
|
|
52
|
+
"hwb",
|
|
53
|
+
"lab",
|
|
54
|
+
"lch",
|
|
55
|
+
"oklab",
|
|
56
|
+
"oklch",
|
|
57
|
+
"color",
|
|
58
|
+
"color-mix",
|
|
59
|
+
"light-dark",
|
|
60
|
+
"var",
|
|
61
|
+
]);
|
|
62
|
+
/** Allowed only inside a colour or gradient, e.g. for relative colour channels. */
|
|
63
|
+
const MATH_FUNCTIONS = new Set(["calc", "min", "max", "clamp"]);
|
|
64
|
+
const GRADIENTS = new Set(["linear-gradient", "radial-gradient", "conic-gradient"].flatMap((name) => [
|
|
65
|
+
name,
|
|
66
|
+
`repeating-${name}`,
|
|
67
|
+
]));
|
|
68
|
+
// One token per match: whitespace, #hash, function name with its "(", ident
|
|
69
|
+
// (including --custom-property), number with optional unit, ")", ",", "/" or
|
|
70
|
+
// an arithmetic operator. Anything else (quotes, ; { } < > comments, bare
|
|
71
|
+
// parentheses, backslashes) fails to tokenise.
|
|
72
|
+
const TOKEN = /(\s+)|(#[0-9a-z]+)|(-{0,2}[a-z_][a-z0-9_-]*)\(|(-{0,2}[a-z_][a-z0-9_-]*)|([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?(?:%|[a-z]+)?)|(\))|(,)|(\/)|([*+-])/giy;
|
|
73
|
+
/** The value as a tree of functions and tokens, or undefined if it holds anything else. */
|
|
74
|
+
function parse(value) {
|
|
75
|
+
const root = [];
|
|
76
|
+
const open = [];
|
|
77
|
+
TOKEN.lastIndex = 0;
|
|
78
|
+
while (TOKEN.lastIndex < value.length) {
|
|
79
|
+
const at = TOKEN.lastIndex;
|
|
80
|
+
const m = TOKEN.exec(value);
|
|
81
|
+
if (!m || m.index !== at)
|
|
82
|
+
return undefined;
|
|
83
|
+
const [, space, hash, fn, ident, number, close, comma, slash, op] = m;
|
|
84
|
+
const top = open.length ? open[open.length - 1].args : root;
|
|
85
|
+
if (space)
|
|
86
|
+
continue;
|
|
87
|
+
if (fn) {
|
|
88
|
+
const node = { kind: "function", name: fn.toLowerCase(), args: [] };
|
|
89
|
+
top.push(node);
|
|
90
|
+
open.push(node);
|
|
91
|
+
}
|
|
92
|
+
else if (close) {
|
|
93
|
+
if (!open.pop())
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
else if (hash)
|
|
97
|
+
top.push({ kind: "hash", value: hash });
|
|
98
|
+
else if (ident)
|
|
99
|
+
top.push({ kind: "ident", value: ident.toLowerCase() });
|
|
100
|
+
else if (number)
|
|
101
|
+
top.push({ kind: "number", value: number });
|
|
102
|
+
else if (comma)
|
|
103
|
+
top.push({ kind: "comma", value: comma });
|
|
104
|
+
else if (slash)
|
|
105
|
+
top.push({ kind: "slash", value: slash });
|
|
106
|
+
else if (op)
|
|
107
|
+
top.push({ kind: "op", value: op });
|
|
108
|
+
}
|
|
109
|
+
return open.length ? undefined : root;
|
|
110
|
+
}
|
|
111
|
+
/** Arguments of an allowed function: plain tokens and allowed functions only. */
|
|
112
|
+
function validArgs(fn) {
|
|
113
|
+
if (fn.name === "var") {
|
|
114
|
+
const [name, next] = fn.args;
|
|
115
|
+
if (name?.kind !== "ident" || !name.value.startsWith("--"))
|
|
116
|
+
return false;
|
|
117
|
+
if (next && next.kind !== "comma")
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return fn.args.every((node) => node.kind !== "function" ||
|
|
121
|
+
((COLOUR_FUNCTIONS.has(node.name) || MATH_FUNCTIONS.has(node.name)) &&
|
|
122
|
+
validArgs(node)));
|
|
123
|
+
}
|
|
124
|
+
function isColourNode(node) {
|
|
125
|
+
if (node.kind === "hash")
|
|
126
|
+
return /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(node.value);
|
|
127
|
+
if (node.kind === "ident")
|
|
128
|
+
return NAMED.has(node.value);
|
|
129
|
+
return (node.kind === "function" &&
|
|
130
|
+
COLOUR_FUNCTIONS.has(node.name) &&
|
|
131
|
+
validArgs(node));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* True for colour syntax only: hex, a colour name (including `transparent`
|
|
135
|
+
* and `currentcolor`), or rgb(), rgba(), hsl(), hsla(), hwb(), lab(), lch(),
|
|
136
|
+
* oklab(), oklch(), color(), color-mix(), light-dark() or var(), whose
|
|
137
|
+
* arguments may nest those functions and calc(), min(), max() or clamp().
|
|
138
|
+
*/
|
|
139
|
+
export function isBrandColour(value) {
|
|
140
|
+
const nodes = parse(value.trim());
|
|
141
|
+
return nodes?.length === 1 && isColourNode(nodes[0]);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* True for a CSS gradient whose stops are colour syntax: linear-, radial- or
|
|
145
|
+
* conic-gradient() (or a repeating- form) with at least two colour stops.
|
|
146
|
+
* Angles, positions, percentages and keywords such as `to right` are allowed;
|
|
147
|
+
* other functions (url(), image-set(), attr() …) are not.
|
|
148
|
+
*/
|
|
149
|
+
export function isBrandGradient(value) {
|
|
150
|
+
const nodes = parse(value.trim());
|
|
151
|
+
if (nodes?.length !== 1)
|
|
152
|
+
return false;
|
|
153
|
+
const [gradient] = nodes;
|
|
154
|
+
if (gradient.kind !== "function" || !GRADIENTS.has(gradient.name))
|
|
155
|
+
return false;
|
|
156
|
+
if (!validArgs(gradient))
|
|
157
|
+
return false;
|
|
158
|
+
const segments = [[]];
|
|
159
|
+
for (const node of gradient.args)
|
|
160
|
+
if (node.kind === "comma")
|
|
161
|
+
segments.push([]);
|
|
162
|
+
else
|
|
163
|
+
segments[segments.length - 1].push(node);
|
|
164
|
+
return segments.filter((s) => s.some(isColourNode)).length >= 2;
|
|
165
|
+
}
|
|
166
|
+
const FAMILY = /\s*("[^"]*"|'[^']*'|-?[a-z_][a-z0-9_-]*(?:\s+-?[a-z_][a-z0-9_-]*)*)\s*(,|$)/iy;
|
|
167
|
+
/**
|
|
168
|
+
* True for a font-family list: family names, quoted names, generic families
|
|
169
|
+
* and bare `var(--name)` references (as framework font loaders hand out),
|
|
170
|
+
* separated by commas. No other functions, url() or CSS punctuation.
|
|
171
|
+
*/
|
|
172
|
+
export function isBrandFont(value) {
|
|
173
|
+
// A bare var(--name) stands in for one whole entry, like a quoted name, so
|
|
174
|
+
// two in a row without a comma fail. Anything else with parentheses (a
|
|
175
|
+
// fallback, url(), local()) is still rejected.
|
|
176
|
+
const text = value.trim().replace(/var\(\s*--[a-z0-9_-]+\s*\)/gi, '""');
|
|
177
|
+
if (!text || /[;{}<>\\()]|\/\*/.test(text))
|
|
178
|
+
return false;
|
|
179
|
+
FAMILY.lastIndex = 0;
|
|
180
|
+
let last = "";
|
|
181
|
+
while (FAMILY.lastIndex < text.length) {
|
|
182
|
+
const at = FAMILY.lastIndex;
|
|
183
|
+
const m = FAMILY.exec(text);
|
|
184
|
+
if (!m || m.index !== at)
|
|
185
|
+
return false;
|
|
186
|
+
last = m[2];
|
|
187
|
+
}
|
|
188
|
+
return last !== ",";
|
|
189
|
+
}
|
|
190
|
+
function assertName(name) {
|
|
191
|
+
if (!NAME.test(name))
|
|
192
|
+
throw new Error(`Mortar brand names use letters, digits and hyphens: "${name}"`);
|
|
193
|
+
}
|
|
194
|
+
const COLOUR_SYNTAX = "a hex value, a colour name, or rgb(), hsl(), hwb(), lab(), lch(), oklab(), oklch(), color(), color-mix(), light-dark() or var()";
|
|
195
|
+
/** The brand's custom properties, validated, as `--m-brand-*` entries. */
|
|
196
|
+
export function brandToStyle(brand) {
|
|
197
|
+
const out = {};
|
|
198
|
+
for (const [prefix, mode, tokens] of [
|
|
199
|
+
["l", "light", brand.light],
|
|
200
|
+
["d", "dark", brand.dark],
|
|
201
|
+
]) {
|
|
202
|
+
if (!tokens)
|
|
203
|
+
continue;
|
|
204
|
+
for (const key of Object.keys(KEYS)) {
|
|
205
|
+
const value = tokens[key]?.trim();
|
|
206
|
+
if (!value)
|
|
207
|
+
continue;
|
|
208
|
+
if (key === "accentGradient") {
|
|
209
|
+
if (!isBrandGradient(value))
|
|
210
|
+
throw new Error(`Invalid Mortar brand gradient for ${mode}.${key}: "${value}". Use linear-gradient(), radial-gradient() or conic-gradient(), or a repeating- form, with at least two colour stops, each ${COLOUR_SYNTAX}.`);
|
|
211
|
+
}
|
|
212
|
+
else if (!isBrandColour(value))
|
|
213
|
+
throw new Error(`Invalid Mortar brand colour for ${mode}.${key}: "${value}". Use ${COLOUR_SYNTAX}.`);
|
|
214
|
+
out[`--m-brand-${prefix}-${KEYS[key]}`] = value;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const key of Object.keys(FONT_KEYS)) {
|
|
218
|
+
const value = brand.fonts?.[key]?.trim();
|
|
219
|
+
if (!value)
|
|
220
|
+
continue;
|
|
221
|
+
if (!isBrandFont(value))
|
|
222
|
+
throw new Error(`Invalid Mortar brand font for fonts.${key}: "${value}". Use family names, quoted names and generic families separated by commas, e.g. "Inter", sans-serif.`);
|
|
223
|
+
out[`--m-brand-${FONT_KEYS[key]}`] = value;
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
/** The brand as a CSS rule, for a stylesheet or a `<style>` element. */
|
|
228
|
+
export function brandToCss(name, brand) {
|
|
229
|
+
assertName(name);
|
|
230
|
+
const body = Object.entries(brandToStyle(brand))
|
|
231
|
+
.map(([property, value]) => ` ${property}: ${value};`)
|
|
232
|
+
.join("\n");
|
|
233
|
+
return `[data-mortar-brand="${name}"] {\n${body}\n}`;
|
|
234
|
+
}
|
|
235
|
+
const registered = new Map();
|
|
236
|
+
/**
|
|
237
|
+
* Adds the brand's stylesheet to the document, for apps that don't use
|
|
238
|
+
* ThemeProvider's `brandTokens`. Each call returns a disposer; the stylesheet
|
|
239
|
+
* stays until every caller for that name has disposed.
|
|
240
|
+
*/
|
|
241
|
+
export function registerBrand(name, brand) {
|
|
242
|
+
const css = brandToCss(name, brand);
|
|
243
|
+
if (typeof document === "undefined")
|
|
244
|
+
return () => { };
|
|
245
|
+
let entry = registered.get(name);
|
|
246
|
+
if (!entry) {
|
|
247
|
+
const element = document.createElement("style");
|
|
248
|
+
element.dataset.mortarBrandStyle = name;
|
|
249
|
+
document.head.append(element);
|
|
250
|
+
entry = { element, count: 0 };
|
|
251
|
+
registered.set(name, entry);
|
|
252
|
+
}
|
|
253
|
+
entry.element.textContent = css;
|
|
254
|
+
entry.count += 1;
|
|
255
|
+
let disposed = false;
|
|
256
|
+
return () => {
|
|
257
|
+
if (disposed || !entry)
|
|
258
|
+
return;
|
|
259
|
+
disposed = true;
|
|
260
|
+
entry.count -= 1;
|
|
261
|
+
if (entry.count === 0) {
|
|
262
|
+
entry.element.remove();
|
|
263
|
+
registered.delete(name);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
3
|
+
import { type HeadingLevel } from "./heading.js";
|
|
4
|
+
export interface CalendarEvent {
|
|
5
|
+
id: string;
|
|
6
|
+
/** Day of the event as YYYY-MM-DD. */
|
|
7
|
+
date: string;
|
|
8
|
+
title: string;
|
|
9
|
+
/** Display time, e.g. "10:00". */
|
|
10
|
+
time?: string;
|
|
11
|
+
/** Start as HH:MM; positions the event in week and day views. Omit for all-day. */
|
|
12
|
+
start?: string;
|
|
13
|
+
/** End as HH:MM; defaults to an hour after start. */
|
|
14
|
+
end?: string;
|
|
15
|
+
/** Avatar or icon beside the event in the month view's `agenda`. */
|
|
16
|
+
media?: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
export type CalendarView = "year" | "month" | "week" | "day";
|
|
19
|
+
export interface CalendarProps {
|
|
20
|
+
/** Displayed month as YYYY-MM. Omit to follow `date`. */
|
|
21
|
+
month?: string;
|
|
22
|
+
/** Called with YYYY-MM whenever the current date moves into another month, in any view. */
|
|
23
|
+
onMonthChange?: (month: string) => void;
|
|
24
|
+
/**
|
|
25
|
+
* The current date as YYYY-MM-DD: the month view's focusable day, the
|
|
26
|
+
* year shown in the year view and the week or day shown in the time views.
|
|
27
|
+
* Omit to let the calendar manage it; it starts at `selected` or today.
|
|
28
|
+
*/
|
|
29
|
+
date?: string;
|
|
30
|
+
/** Called with the new current date after navigation in any view. */
|
|
31
|
+
onDateChange?: (date: string) => void;
|
|
32
|
+
/** Selected day as YYYY-MM-DD. */
|
|
33
|
+
selected?: string;
|
|
34
|
+
onSelect?: (date: string) => void;
|
|
35
|
+
events?: CalendarEvent[];
|
|
36
|
+
/** Month view only: `full` lists each day's events in its cell. */
|
|
37
|
+
variant?: "compact" | "full";
|
|
38
|
+
weekStartsOn?: 0 | 1;
|
|
39
|
+
/** Override today's date (YYYY-MM-DD), e.g. for tests or a user's time zone. */
|
|
40
|
+
today?: string;
|
|
41
|
+
locale?: string;
|
|
42
|
+
/** Month view with `variant="full"` only: events listed per day before "+N more". */
|
|
43
|
+
maxEvents?: number;
|
|
44
|
+
/** Twelve months, one month grid, or a time grid for one week or one day. */
|
|
45
|
+
view?: CalendarView;
|
|
46
|
+
/** Shows a Year/Month/Week/Day switcher when provided. */
|
|
47
|
+
onViewChange?: (view: CalendarView) => void;
|
|
48
|
+
/** Makes events in week and day views, and in the agenda, buttons. */
|
|
49
|
+
onEventSelect?: (id: string) => void;
|
|
50
|
+
/**
|
|
51
|
+
* Month view only: lists the events of the selected day (or the current
|
|
52
|
+
* date when nothing is selected) beside the month, or below it when
|
|
53
|
+
* narrow, under a "Schedule for" heading one level below the title.
|
|
54
|
+
*/
|
|
55
|
+
agenda?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* First and last hour shown in week and day views. Events that start or
|
|
58
|
+
* end outside them are drawn at the edge with a dashed border and keep
|
|
59
|
+
* their real times in their text.
|
|
60
|
+
*/
|
|
61
|
+
dayStartHour?: number;
|
|
62
|
+
dayEndHour?: number;
|
|
63
|
+
/** Level of the title (month, year, week or day); month and day headings are one below. */
|
|
64
|
+
headingLevel?: HeadingLevel;
|
|
65
|
+
id?: string;
|
|
66
|
+
className?: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Calendar with year, month, week and day views. One current date (`date`)
|
|
70
|
+
* is shared by every view, so moving through months and then switching to
|
|
71
|
+
* the week view shows a week of the month you reached. The month and year
|
|
72
|
+
* views are grids with keyboard navigation; the week and day views place
|
|
73
|
+
* timed events on an hour grid.
|
|
74
|
+
*/
|
|
75
|
+
export declare function Calendar({ month: controlledMonth, onMonthChange, date: controlledDate, onDateChange, selected, onSelect, events, variant, weekStartsOn, today: todayProp, locale, maxEvents, view, onViewChange, onEventSelect, agenda, dayStartHour, dayEndHour, headingLevel, id, className, }: CalendarProps): React.JSX.Element;
|