@aoctech/ui 0.1.5 → 0.3.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/README.md +69 -1
- package/dist/components/bottom-nav.d.ts +74 -0
- package/dist/components/bottom-nav.js +88 -0
- package/dist/components/date-picker.d.ts +12 -2
- package/dist/components/date-picker.js +30 -18
- package/dist/components/error-state.d.ts +5 -20
- package/dist/components/error-state.js +3 -8
- package/dist/components/modal.d.ts +6 -1
- package/dist/components/modal.js +4 -2
- package/dist/components/user-menu.d.ts +53 -0
- package/dist/components/user-menu.js +47 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/lib/i18n.d.ts +115 -0
- package/dist/lib/i18n.js +69 -0
- package/dist/lib/link.d.ts +14 -0
- package/dist/lib/link.js +3 -0
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -105,7 +105,69 @@ so a badge renders what the server said rather than a client-side mapping of an
|
|
|
105
105
|
| `Checkbox` | Base UI `Checkbox`. |
|
|
106
106
|
| `Radio` + `RadioGroup` | Base UI `Radio`. |
|
|
107
107
|
| `Switch` | Base UI `Switch`. |
|
|
108
|
-
| `DatePicker` + `Calendar` | Built on `react-day-picker`. |
|
|
108
|
+
| `DatePicker` + `Calendar` | Built on `react-day-picker`. `locale` (BCP-47, default `"pt-BR"`; `"en"` built in) drives calendar names, first weekday, displayed date (Intl) and aria copy; `labels` overrides single strings. `Modal` and `ErrorState` take the same `locale` (Modal also `labels`). Since 0.2.0. |
|
|
109
|
+
| `BottomNav` | Phone-only primary nav (hidden from `md`): up to four tabs, an optional central action, an optional "more" tab that opens a bottom sheet (Base UI `Drawer`). Since 0.3.0. |
|
|
110
|
+
| `UserMenu` | Avatar (initials or image) opening a Base UI `Menu`: full name and e-mail, optional view switcher, extra items, sign-out. Since 0.3.0. |
|
|
111
|
+
|
|
112
|
+
### Navigation: `BottomNav` and `UserMenu`
|
|
113
|
+
|
|
114
|
+
Neither knows anything about routing. The caller says which item is `active` (or `current`) from
|
|
115
|
+
its own router, and passes `renderLink` so links navigate client-side; without it a plain `<a>` is
|
|
116
|
+
rendered. Items take `href` (a link) or `onClick` (a button).
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
import Link from "next/link"
|
|
120
|
+
import {BottomNav, BottomNavSpacer, UserMenu, type RenderLink} from "@aoctech/ui"
|
|
121
|
+
|
|
122
|
+
const renderLink: RenderLink = props => <Link {...props} />
|
|
123
|
+
|
|
124
|
+
<UserMenu
|
|
125
|
+
name={session.name}
|
|
126
|
+
email={session.email}
|
|
127
|
+
imageUrl={session.avatarUrl}
|
|
128
|
+
views={[
|
|
129
|
+
{label: "Portal", href: "/dashboard", current: !inConsole},
|
|
130
|
+
{label: "Console", href: "/console/overview", current: inConsole},
|
|
131
|
+
]}
|
|
132
|
+
onSignOut={logout}
|
|
133
|
+
renderLink={renderLink}
|
|
134
|
+
/>
|
|
135
|
+
|
|
136
|
+
<main>
|
|
137
|
+
{children}
|
|
138
|
+
<BottomNavSpacer /> {/* keeps the page's end clear of the bar; gone from md up */}
|
|
139
|
+
</main>
|
|
140
|
+
<BottomNav
|
|
141
|
+
renderLink={renderLink}
|
|
142
|
+
action={{label: "Novo", icon: <Plus />, onClick: openCreate}} // or null on screens without one
|
|
143
|
+
items={[
|
|
144
|
+
{label: "Resumo", icon: <Home />, href: "/console/finance", active: pathname === "/console/finance"},
|
|
145
|
+
{label: "A pagar/receber", icon: <Receipt />, href: "/console/finance/bills", active: is("/bills")},
|
|
146
|
+
{label: "Extrato", icon: <List />, href: "/console/finance/statement", active: is("/statement")},
|
|
147
|
+
{type: "more", label: "Mais", icon: <Menu />, items: [
|
|
148
|
+
{label: "Recorrências", icon: <Repeat />, href: "/console/finance/recurrences", active: is("/recurrences")},
|
|
149
|
+
{label: "Cartões", icon: <CreditCard />, href: "/console/finance/cards", active: is("/cards")},
|
|
150
|
+
{label: "Importar", icon: <Upload />, href: "/console/finance/import", active: is("/import")},
|
|
151
|
+
{label: "Relatórios", icon: <Chart />, href: "/console/finance/reports", active: is("/reports"), group: "Análise"},
|
|
152
|
+
{label: "Configuração", icon: <Settings />, href: "/console/finance/accounts", active: is("/accounts"), group: "Análise"},
|
|
153
|
+
]},
|
|
154
|
+
]}
|
|
155
|
+
/>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- **Four tabs at most**, split evenly around the action, which stays dead centre. Labels wrap to two
|
|
159
|
+
lines (after a `/` first) rather than truncating; keep them short anyway.
|
|
160
|
+
- **The "more" tab** is shown active when any sheet item is (`active` overrides). Sheet items with
|
|
161
|
+
the same `group` are listed under that heading. Choosing one closes the sheet; Escape, the close
|
|
162
|
+
button and swipe-down close it too, and focus returns to the tab.
|
|
163
|
+
- **Safe area:** the bar pads itself by `env(safe-area-inset-bottom)` — the app needs
|
|
164
|
+
`viewport-fit=cover` in its viewport meta for that inset to be non-zero. Clear the page's end
|
|
165
|
+
with `<BottomNavSpacer />`, or with the `bottomNavInset` class string on a container that owns its
|
|
166
|
+
bottom padding.
|
|
167
|
+
- **Always touch-sized**, whatever `data-density` says: a console that is compact on a desk is still a
|
|
168
|
+
thumb on a phone. Bar targets are 72px tall and at least 60px wide on a 320px screen; sheet rows are 48px tall.
|
|
169
|
+
- **Copy:** `locale` (default `"pt-BR"`) and `labels` as in `Modal` — `BottomNav` labels `nav`,
|
|
170
|
+
`close`; `UserMenu` labels `trigger`, `views`, `signOut`.
|
|
109
171
|
|
|
110
172
|
Deliberately small. Components arrive when a second app needs one, not in anticipation — the whole
|
|
111
173
|
point of extracting this was to stop maintaining four copies, and a component with one consumer is
|
|
@@ -113,6 +175,12 @@ still one copy.
|
|
|
113
175
|
|
|
114
176
|
The extraction queue and non-domain boundaries live in [docs/component-inventory.md](docs/component-inventory.md). The source palette references and the rules for adding a product live in [docs/theme-registry.md](docs/theme-registry.md).
|
|
115
177
|
|
|
178
|
+
## Tests
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
npm test # node:test for pure logic, vitest + jsdom for component behaviour, then the build
|
|
182
|
+
```
|
|
183
|
+
|
|
116
184
|
## Storybook
|
|
117
185
|
|
|
118
186
|
```sh
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type BottomNavLabels, type Locale } from "../lib/i18n";
|
|
3
|
+
import { type RenderLink } from "../lib/link";
|
|
4
|
+
/**
|
|
5
|
+
* The phone's primary navigation: a bar at the bottom, where the thumb already
|
|
6
|
+
* is, instead of a desktop sidebar squeezed into a hamburger. Hidden from `md`
|
|
7
|
+
* up, where the product's own sidebar or tabs take over.
|
|
8
|
+
*
|
|
9
|
+
* Up to four tabs and, between them, one primary action the screen decides
|
|
10
|
+
* ("+" on a list, nothing on a form). One tab may be a "more" tab that opens a
|
|
11
|
+
* bottom sheet with everything secondary — Base UI's Drawer, so the sheet gets
|
|
12
|
+
* the focus trap, Escape, swipe-down and restore-focus without reimplementing
|
|
13
|
+
* any of them.
|
|
14
|
+
*
|
|
15
|
+
* The library knows nothing about routing: the caller says which item is
|
|
16
|
+
* active and how a link navigates (`renderLink`). Touch-sized regardless of
|
|
17
|
+
* `data-density` — a console that is compact on a desk is still a thumb on a
|
|
18
|
+
* phone.
|
|
19
|
+
*/
|
|
20
|
+
/** A destination: `href` renders a link (through `renderLink`), `onClick` alone a button. */
|
|
21
|
+
interface BottomNavTarget {
|
|
22
|
+
href?: string;
|
|
23
|
+
onClick?: () => void;
|
|
24
|
+
}
|
|
25
|
+
interface BottomNavTab extends BottomNavTarget {
|
|
26
|
+
label: string;
|
|
27
|
+
icon: ReactNode;
|
|
28
|
+
/** Supplied by the caller from its router. Renders `aria-current="page"`. */
|
|
29
|
+
active?: boolean;
|
|
30
|
+
}
|
|
31
|
+
interface BottomNavSheetItem extends BottomNavTarget {
|
|
32
|
+
label: string;
|
|
33
|
+
icon?: ReactNode;
|
|
34
|
+
active?: boolean;
|
|
35
|
+
/** Consecutive items with the same group are listed under that heading. */
|
|
36
|
+
group?: string;
|
|
37
|
+
}
|
|
38
|
+
interface BottomNavMore {
|
|
39
|
+
type: "more";
|
|
40
|
+
label: string;
|
|
41
|
+
icon: ReactNode;
|
|
42
|
+
/** Sheet heading; defaults to `label`. */
|
|
43
|
+
title?: string;
|
|
44
|
+
items: BottomNavSheetItem[];
|
|
45
|
+
/** Defaults to "any sheet item is active". */
|
|
46
|
+
active?: boolean;
|
|
47
|
+
}
|
|
48
|
+
type BottomNavItem = BottomNavTab | BottomNavMore;
|
|
49
|
+
interface BottomNavAction extends BottomNavTarget {
|
|
50
|
+
label: string;
|
|
51
|
+
icon: ReactNode;
|
|
52
|
+
}
|
|
53
|
+
interface BottomNavProps {
|
|
54
|
+
/** At most four; any beyond the fourth are not rendered. */
|
|
55
|
+
items: BottomNavItem[];
|
|
56
|
+
/** The central action for this screen, or `null` to leave the slot out. */
|
|
57
|
+
action?: BottomNavAction | null;
|
|
58
|
+
renderLink?: RenderLink;
|
|
59
|
+
/** BCP-47 tag for built-in copy. Defaults to "pt-BR". */
|
|
60
|
+
locale?: Locale;
|
|
61
|
+
labels?: Partial<BottomNavLabels>;
|
|
62
|
+
className?: string;
|
|
63
|
+
}
|
|
64
|
+
declare function BottomNav({ items, action, renderLink, locale, labels, className }: BottomNavProps): import("react").JSX.Element;
|
|
65
|
+
/**
|
|
66
|
+
* Keeps the end of a page clear of the bar on a phone: render it as the last
|
|
67
|
+
* child of the scrolling content. Collapses from `md` up, where the bar is gone.
|
|
68
|
+
*/
|
|
69
|
+
declare function BottomNavSpacer({ className }: {
|
|
70
|
+
className?: string;
|
|
71
|
+
}): import("react").JSX.Element;
|
|
72
|
+
/** The same clearance as padding, for a container that owns its own bottom padding. */
|
|
73
|
+
declare const bottomNavInset = "pb-[calc(4.5rem+env(safe-area-inset-bottom))] md:pb-0";
|
|
74
|
+
export { BottomNav, BottomNavSpacer, bottomNavInset, type BottomNavAction, type BottomNavItem, type BottomNavMore, type BottomNavProps, type BottomNavSheetItem, type BottomNavTab, };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Drawer as Primitive } from "@base-ui/react/drawer";
|
|
4
|
+
import { Fragment, useId, useState } from "react";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { DEFAULT_LOCALE, getBottomNavLabels } from "../lib/i18n";
|
|
7
|
+
import { defaultRenderLink } from "../lib/link";
|
|
8
|
+
import { Button } from "./button";
|
|
9
|
+
import { useThemeScope } from "./theme-provider";
|
|
10
|
+
const MAX_ITEMS = 4;
|
|
11
|
+
/** Row height; the bar adds the device's safe-area inset below it. */
|
|
12
|
+
const BAR_HEIGHT = "4.5rem";
|
|
13
|
+
const ACTION_WIDTH = "4.5rem";
|
|
14
|
+
const TAB = cn("group flex h-full w-full min-w-0 flex-col items-center justify-start gap-1 px-0.5 pt-2", "text-xs/3.5 font-medium text-muted-foreground outline-none select-none [-webkit-tap-highlight-color:transparent]", "aria-[current=page]:text-foreground data-[active]:text-foreground");
|
|
15
|
+
/** The shape behind the icon, so "active" is never colour alone. */
|
|
16
|
+
const PILL = cn("flex h-7 w-14 shrink-0 items-center justify-center rounded-full [&_svg]:size-5 [&_svg]:shrink-0", "transition-[background-color,color,scale] duration-150 ease-out motion-reduce:transition-none", "group-hover:bg-surface group-active:scale-95 motion-reduce:group-active:scale-100", "group-focus-visible:ring-3 group-focus-visible:ring-ring/50", "group-aria-[current=page]:bg-brand-50 group-aria-[current=page]:text-brand-700", "group-data-[active]:bg-brand-50 group-data-[active]:text-brand-700");
|
|
17
|
+
const TAB_LABEL = "line-clamp-2 max-w-full text-center break-words";
|
|
18
|
+
/** Lets "A pagar/receber" wrap after the slash instead of mid-word. */
|
|
19
|
+
function breakable(label) {
|
|
20
|
+
const parts = label.split("/");
|
|
21
|
+
return parts.map((part, i) => (i < parts.length - 1 ? _jsxs(Fragment, { children: [part, "/", _jsx("wbr", {})] }, i) : part));
|
|
22
|
+
}
|
|
23
|
+
function isMore(item) {
|
|
24
|
+
return "type" in item && item.type === "more";
|
|
25
|
+
}
|
|
26
|
+
function BottomNav({ items, action = null, renderLink = defaultRenderLink, locale = DEFAULT_LOCALE, labels, className }) {
|
|
27
|
+
const text = getBottomNavLabels(locale, labels);
|
|
28
|
+
const tabs = items.slice(0, MAX_ITEMS);
|
|
29
|
+
const left = action ? tabs.slice(0, Math.ceil(tabs.length / 2)) : tabs;
|
|
30
|
+
const right = action ? tabs.slice(left.length) : [];
|
|
31
|
+
// With an action the slot stays dead centre even when the sides are uneven
|
|
32
|
+
// (two tabs and one): each side shares half the bar, whatever its count.
|
|
33
|
+
const columns = action
|
|
34
|
+
? [side(left.length), ACTION_WIDTH, side(right.length)].filter(Boolean).join(" ")
|
|
35
|
+
: `repeat(${tabs.length}, minmax(0, 1fr))`;
|
|
36
|
+
const renderItem = (item, index) => (_jsx("li", { className: "min-w-0", children: isMore(item) ? (_jsx(MoreTab, { more: item, renderLink: renderLink, closeLabel: text.close })) : (_jsxs(Target, { target: item, renderLink: renderLink, className: TAB, current: item.active, label: item.label, children: [_jsx("span", { "aria-hidden": true, className: PILL, children: item.icon }), _jsx("span", { className: TAB_LABEL, children: breakable(item.label) })] })) }, index));
|
|
37
|
+
return (_jsx("nav", { "aria-label": text.nav, "data-slot": "bottom-nav", className: cn("fixed inset-x-0 bottom-0 z-30 border-t border-border bg-background text-foreground md:hidden", "pb-[env(safe-area-inset-bottom)]", className), children: _jsxs("ul", { className: "grid list-none p-0 m-0", style: { gridTemplateColumns: columns, height: BAR_HEIGHT }, children: [left.map(renderItem), action && (_jsx("li", { className: "min-w-0", children: _jsxs(Target, { target: action, renderLink: renderLink, className: cn(TAB, "text-foreground"), children: [_jsx("span", { "aria-hidden": true, className: cn(
|
|
38
|
+
// Rises a little over the bar's edge, cut out of it by a ring of the
|
|
39
|
+
// background, so its label still sits on the tabs' baseline.
|
|
40
|
+
"-mt-4 flex size-11 shrink-0 items-center justify-center rounded-full bg-brand-600 text-white ring-4 ring-background [&_svg]:size-6", "shadow-[0_6px_16px_-8px_var(--brand-700)]", "transition-[background-color,scale] duration-150 ease-out motion-reduce:transition-none", "group-hover:bg-brand-700 group-active:scale-95 motion-reduce:group-active:scale-100", "group-focus-visible:outline-3 group-focus-visible:outline-offset-2 group-focus-visible:outline-ring/50"), children: action.icon }), _jsx("span", { className: "max-w-full truncate", children: action.label })] }) })), right.map((item, i) => renderItem(item, left.length + i))] }) }));
|
|
41
|
+
}
|
|
42
|
+
function side(count) {
|
|
43
|
+
return count ? `repeat(${count}, calc((100% - ${ACTION_WIDTH}) / 2 / ${count}))` : "";
|
|
44
|
+
}
|
|
45
|
+
/** A link when there is somewhere to go, a button when there is something to do. */
|
|
46
|
+
function Target({ target, renderLink, className, current, label, children }) {
|
|
47
|
+
const ariaCurrent = current ? "page" : undefined;
|
|
48
|
+
if (target.href !== undefined) {
|
|
49
|
+
return renderLink({ href: target.href, onClick: target.onClick, className, "aria-current": ariaCurrent, "aria-label": label, children });
|
|
50
|
+
}
|
|
51
|
+
return (_jsx("button", { type: "button", onClick: target.onClick, "aria-current": ariaCurrent, "aria-label": label, className: className, children: children }));
|
|
52
|
+
}
|
|
53
|
+
function MoreTab({ more, renderLink, closeLabel }) {
|
|
54
|
+
const [open, setOpen] = useState(false);
|
|
55
|
+
const { theme, density } = useThemeScope();
|
|
56
|
+
const groupIdPrefix = useId();
|
|
57
|
+
const active = more.active ?? more.items.some(i => i.active);
|
|
58
|
+
const groups = groupItems(more.items);
|
|
59
|
+
const title = more.title ?? more.label;
|
|
60
|
+
return (_jsxs(Primitive.Root, { open: open, onOpenChange: setOpen, swipeDirection: "down", children: [_jsxs(Primitive.Trigger, { "aria-label": more.label, className: TAB, "data-active": active || open ? "" : undefined, children: [_jsx("span", { "aria-hidden": true, className: PILL, children: more.icon }), _jsx("span", { className: TAB_LABEL, children: breakable(more.label) })] }), _jsxs(Primitive.Portal, { "data-ctech-theme": theme, "data-density": density, children: [_jsx(Primitive.Backdrop, { className: cn("fixed inset-0 z-40 bg-black/40 transition-opacity duration-200 ease-out md:hidden", "data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none") }), _jsx(Primitive.Viewport, { className: "fixed inset-0 z-50 md:hidden", children: _jsxs(Primitive.Popup, { className: cn("fixed inset-x-0 bottom-0 z-50 flex max-h-[85dvh] flex-col rounded-t-2xl border-t border-border", "bg-background text-foreground shadow-modal outline-none pb-[env(safe-area-inset-bottom)]", "transition-transform duration-200 ease-out data-[swiping]:transition-none motion-reduce:transition-none", "[transform:translateY(var(--drawer-swipe-movement-y,0px))]", "data-[starting-style]:[transform:translateY(100%)] data-[ending-style]:[transform:translateY(100%)]"), children: [_jsx("div", { "aria-hidden": true, className: "mx-auto mt-2 h-1.5 w-10 shrink-0 rounded-full bg-border" }), _jsxs("header", { className: "flex items-center justify-between gap-4 px-5 pt-2 pb-1", children: [_jsx(Primitive.Title, { className: "text-base font-semibold text-foreground", children: title }), _jsx(Primitive.Close, { render: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": closeLabel, className: "-mr-2 size-11 shrink-0 text-muted-foreground" }), children: _jsx(CloseGlyph, {}) })] }), _jsx("div", { className: "flex-1 overflow-y-auto overscroll-contain px-3 pb-4", children: groups.map((group, g) => {
|
|
61
|
+
const groupId = `${groupIdPrefix}-g${g}`;
|
|
62
|
+
return (_jsxs("section", { "aria-labelledby": group.label ? groupId : undefined, className: cn(g > 0 && "mt-2 border-t border-border pt-2"), children: [group.label && (_jsx("h3", { id: groupId, className: "px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground", children: group.label })), _jsx("ul", { className: "m-0 grid list-none gap-0.5 p-0", children: group.items.map((item, i) => (_jsx("li", { children: _jsxs(Target, { target: { href: item.href, onClick: () => { item.onClick?.(); setOpen(false); } }, renderLink: renderLink, current: item.active, className: cn("group/item flex min-h-12 w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-foreground outline-none", "transition-colors duration-150 motion-reduce:transition-none", "hover:bg-surface focus-visible:ring-3 focus-visible:ring-ring/50", "aria-[current=page]:bg-brand-50 aria-[current=page]:font-medium aria-[current=page]:text-brand-700"), children: [item.icon && _jsx("span", { "aria-hidden": true, className: "flex size-5 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-5 group-aria-[current=page]/item:text-brand-700", children: item.icon }), _jsx("span", { className: "min-w-0 flex-1 break-words", children: item.label })] }) }, i))) })] }, g));
|
|
63
|
+
}) })] }) })] })] }));
|
|
64
|
+
}
|
|
65
|
+
function groupItems(items) {
|
|
66
|
+
const groups = [];
|
|
67
|
+
for (const item of items) {
|
|
68
|
+
const last = groups[groups.length - 1];
|
|
69
|
+
if (last && last.label === item.group)
|
|
70
|
+
last.items.push(item);
|
|
71
|
+
else
|
|
72
|
+
groups.push({ label: item.group, items: [item] });
|
|
73
|
+
}
|
|
74
|
+
return groups;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Keeps the end of a page clear of the bar on a phone: render it as the last
|
|
78
|
+
* child of the scrolling content. Collapses from `md` up, where the bar is gone.
|
|
79
|
+
*/
|
|
80
|
+
function BottomNavSpacer({ className }) {
|
|
81
|
+
return _jsx("div", { "aria-hidden": true, "data-slot": "bottom-nav-spacer", className: cn("h-[calc(4.5rem+env(safe-area-inset-bottom))] shrink-0 md:hidden", className) });
|
|
82
|
+
}
|
|
83
|
+
/** The same clearance as padding, for a container that owns its own bottom padding. */
|
|
84
|
+
const bottomNavInset = "pb-[calc(4.5rem+env(safe-area-inset-bottom))] md:pb-0";
|
|
85
|
+
function CloseGlyph() {
|
|
86
|
+
return (_jsx("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": true, className: "size-4", children: _jsx("path", { d: "m4 4 8 8M12 4l-8 8", strokeLinecap: "round" }) }));
|
|
87
|
+
}
|
|
88
|
+
export { BottomNav, BottomNavSpacer, bottomNavInset, };
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import type { Matcher } from "react-day-picker";
|
|
2
|
+
import { type DatePickerLabels, type Locale } from "../lib/i18n";
|
|
2
3
|
interface CalendarProps {
|
|
3
4
|
selected?: Date;
|
|
4
5
|
onSelect?: (date: Date | undefined) => void;
|
|
5
6
|
disabled?: Matcher | Matcher[];
|
|
6
7
|
className?: string;
|
|
8
|
+
/** BCP-47 tag; drives month/weekday names, first day of week and aria copy. Defaults to "pt-BR". */
|
|
9
|
+
locale?: Locale;
|
|
10
|
+
/** Overrides individual built-in strings (aria labels). */
|
|
11
|
+
labels?: Partial<DatePickerLabels>;
|
|
7
12
|
}
|
|
8
|
-
|
|
13
|
+
/** An inline, keyboard-operable calendar. Use DatePicker for a field trigger. */
|
|
14
|
+
declare function Calendar({ selected, onSelect, disabled, className, locale, labels }: CalendarProps): import("react").JSX.Element;
|
|
9
15
|
interface DatePickerProps {
|
|
10
16
|
value?: Date;
|
|
11
17
|
defaultValue?: Date;
|
|
@@ -18,11 +24,15 @@ interface DatePickerProps {
|
|
|
18
24
|
min?: Date;
|
|
19
25
|
max?: Date;
|
|
20
26
|
defaultOpen?: boolean;
|
|
27
|
+
/** BCP-47 tag (e.g. "pt-BR", "en"). Drives calendar, date display and copy. Defaults to "pt-BR". */
|
|
28
|
+
locale?: Locale;
|
|
29
|
+
/** Overrides individual built-in strings (placeholder, today, selected, month navigation). */
|
|
30
|
+
labels?: Partial<DatePickerLabels>;
|
|
21
31
|
}
|
|
22
32
|
/**
|
|
23
33
|
* A single-date field backed by an accessible Popover + Calendar. Dates remain
|
|
24
34
|
* Date objects at the API boundary so products can apply their own timezone
|
|
25
35
|
* and serialization policies explicitly.
|
|
26
36
|
*/
|
|
27
|
-
declare function DatePicker({ value, defaultValue, onValueChange, placeholder, disabled, id, name, className, min, max, defaultOpen, }: DatePickerProps): import("react").JSX.Element;
|
|
37
|
+
declare function DatePicker({ value, defaultValue, onValueChange, placeholder, disabled, id, name, className, min, max, defaultOpen, locale, labels, }: DatePickerProps): import("react").JSX.Element;
|
|
28
38
|
export { Calendar, DatePicker, type CalendarProps, type DatePickerProps };
|
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Popover } from "@base-ui/react/popover";
|
|
4
|
-
import { format } from "date-fns";
|
|
5
|
-
import { ptBR } from "date-fns/locale/pt-BR";
|
|
6
4
|
import { useState } from "react";
|
|
7
|
-
import { DayPicker } from "react-day-picker";
|
|
5
|
+
import { DateLib, DayPicker } from "react-day-picker";
|
|
6
|
+
import { enUS, ptBR } from "react-day-picker/locale";
|
|
8
7
|
import { cn } from "../lib/cn";
|
|
8
|
+
import { DEFAULT_LOCALE, getDatePickerLabels, resolveCatalog } from "../lib/i18n";
|
|
9
9
|
import { useThemeScope } from "./theme-provider";
|
|
10
10
|
/** An inline, keyboard-operable calendar. Use DatePicker for a field trigger. */
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const LABELS = {
|
|
15
|
-
labelPrevious: () => "Mês anterior",
|
|
16
|
-
labelNext: () => "Próximo mês",
|
|
17
|
-
labelDayButton: (date, modifiers) => `${modifiers.today ? "Hoje, " : ""}${format(date, "PPPP", { locale: ptBR })}${modifiers.selected ? ", selecionado" : ""}`,
|
|
18
|
-
};
|
|
19
|
-
function Calendar({ selected, onSelect, disabled, className }) {
|
|
11
|
+
function Calendar({ selected, onSelect, disabled, className, locale = DEFAULT_LOCALE, labels }) {
|
|
12
|
+
const text = getDatePickerLabels(locale, labels);
|
|
13
|
+
const dayLocale = resolveCatalog(locale) === "pt-BR" ? ptBR : enUS;
|
|
20
14
|
return (_jsx(DayPicker, { mode: "single",
|
|
21
15
|
// Open on the selected date's month, not today's: editing a date months
|
|
22
16
|
// away otherwise starts by paging back to it.
|
|
23
|
-
defaultMonth: selected,
|
|
17
|
+
defaultMonth: selected, selected: selected, onSelect: onSelect, disabled: disabled, locale: dayLocale, labels: {
|
|
18
|
+
labelPrevious: () => text.previousMonth,
|
|
19
|
+
labelNext: () => text.nextMonth,
|
|
20
|
+
labelDayButton: (date, modifiers, options, dateLib) => {
|
|
21
|
+
let label = (dateLib ?? new DateLib(options)).format(date, "PPPP");
|
|
22
|
+
if (modifiers.today)
|
|
23
|
+
label = `${text.today}, ${label}`;
|
|
24
|
+
if (modifiers.selected)
|
|
25
|
+
label = `${label}, ${text.selected}`;
|
|
26
|
+
return label;
|
|
27
|
+
},
|
|
28
|
+
}, classNames: {
|
|
24
29
|
month_caption: "calendar-month-caption",
|
|
25
30
|
caption_label: "calendar-caption-label",
|
|
26
31
|
nav: "calendar-nav",
|
|
@@ -38,7 +43,7 @@ function Calendar({ selected, onSelect, disabled, className }) {
|
|
|
38
43
|
* Date objects at the API boundary so products can apply their own timezone
|
|
39
44
|
* and serialization policies explicitly.
|
|
40
45
|
*/
|
|
41
|
-
function DatePicker({ value, defaultValue, onValueChange, placeholder
|
|
46
|
+
function DatePicker({ value, defaultValue, onValueChange, placeholder, disabled = false, id, name, className, min, max, defaultOpen = false, locale = DEFAULT_LOCALE, labels, }) {
|
|
42
47
|
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
43
48
|
const [open, setOpen] = useState(defaultOpen);
|
|
44
49
|
const { theme, density } = useThemeScope();
|
|
@@ -47,9 +52,8 @@ function DatePicker({ value, defaultValue, onValueChange, placeholder = "Selecio
|
|
|
47
52
|
...(min ? [{ before: min }] : []),
|
|
48
53
|
...(max ? [{ after: max }] : []),
|
|
49
54
|
];
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
: placeholder;
|
|
55
|
+
const text = getDatePickerLabels(locale, labels);
|
|
56
|
+
const label = selected ? formatDateLabel(selected, locale) : placeholder ?? text.placeholder;
|
|
53
57
|
function selectDate(date) {
|
|
54
58
|
if (value === undefined)
|
|
55
59
|
setUncontrolledValue(date);
|
|
@@ -57,7 +61,15 @@ function DatePicker({ value, defaultValue, onValueChange, placeholder = "Selecio
|
|
|
57
61
|
if (date)
|
|
58
62
|
setOpen(false);
|
|
59
63
|
}
|
|
60
|
-
return (_jsxs(Popover.Root, { open: open, onOpenChange: setOpen, children: [_jsxs(Popover.Trigger, { id: id, disabled: disabled, className: cn("flex h-11 w-full items-center justify-between rounded-lg border border-border bg-background px-3 text-left text-sm outline-none", "text-foreground transition-[border-color,box-shadow] duration-150 hover:bg-surface focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35", "disabled:cursor-not-allowed disabled:opacity-50 in-data-[density=compact]:h-8 in-data-[density=compact]:px-2.5", !selected && "text-muted-foreground", className), children: [_jsx("span", { className: "truncate", children: label }), _jsx(CalendarGlyph, {})] }), name && _jsx("input", { type: "hidden", name: name, value: selected ? formatDateValue(selected) : "" }), _jsx(Popover.Portal, { "data-ctech-theme": theme, "data-density": density, children: _jsx(Popover.Positioner, { side: "bottom", align: "start", sideOffset: 8, className: "z-50", children: _jsx(Popover.Popup, { className: "rounded-xl border border-border bg-background shadow-modal outline-none transition-[opacity,scale] duration-150 data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0 motion-reduce:transition-none", children: _jsx(Calendar, { selected: selected, onSelect: selectDate, disabled: disabled || disabledDays.length > 0 ? (disabled ? true : disabledDays) : undefined }) }) }) })] }));
|
|
64
|
+
return (_jsxs(Popover.Root, { open: open, onOpenChange: setOpen, children: [_jsxs(Popover.Trigger, { id: id, disabled: disabled, className: cn("flex h-11 w-full items-center justify-between rounded-lg border border-border bg-background px-3 text-left text-sm outline-none", "text-foreground transition-[border-color,box-shadow] duration-150 hover:bg-surface focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35", "disabled:cursor-not-allowed disabled:opacity-50 in-data-[density=compact]:h-8 in-data-[density=compact]:px-2.5", !selected && "text-muted-foreground", className), children: [_jsx("span", { className: "truncate", children: label }), _jsx(CalendarGlyph, {})] }), name && _jsx("input", { type: "hidden", name: name, value: selected ? formatDateValue(selected) : "" }), _jsx(Popover.Portal, { "data-ctech-theme": theme, "data-density": density, children: _jsx(Popover.Positioner, { side: "bottom", align: "start", sideOffset: 8, className: "z-50", children: _jsx(Popover.Popup, { className: "rounded-xl border border-border bg-background shadow-modal outline-none transition-[opacity,scale] duration-150 data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0 motion-reduce:transition-none", children: _jsx(Calendar, { locale: locale, labels: labels, selected: selected, onSelect: selectDate, disabled: disabled || disabledDays.length > 0 ? (disabled ? true : disabledDays) : undefined }) }) }) })] }));
|
|
65
|
+
}
|
|
66
|
+
function formatDateLabel(value, locale) {
|
|
67
|
+
try {
|
|
68
|
+
return new Intl.DateTimeFormat(locale, { day: "2-digit", month: "long", year: "numeric" }).format(value);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return new Intl.DateTimeFormat(DEFAULT_LOCALE, { day: "2-digit", month: "long", year: "numeric" }).format(value);
|
|
72
|
+
}
|
|
61
73
|
}
|
|
62
74
|
function formatDateValue(value) {
|
|
63
75
|
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
|
@@ -1,32 +1,17 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
readonly title: "Não foi possível abrir este endereço";
|
|
5
|
-
readonly description: "Confira os parâmetros do link ou volte ao início para continuar.";
|
|
6
|
-
};
|
|
7
|
-
readonly 404: {
|
|
8
|
-
readonly title: "Não encontramos esta página";
|
|
9
|
-
readonly description: "O endereço pode ter mudado ou o conteúdo não está disponível.";
|
|
10
|
-
};
|
|
11
|
-
readonly 500: {
|
|
12
|
-
readonly title: "Não foi possível carregar a página";
|
|
13
|
-
readonly description: "Ocorreu um erro inesperado. Tente novamente em instantes.";
|
|
14
|
-
};
|
|
15
|
-
readonly 503: {
|
|
16
|
-
readonly title: "Serviço temporariamente indisponível";
|
|
17
|
-
readonly description: "Estamos sem conexão com o serviço. Verifique sua conexão e tente novamente.";
|
|
18
|
-
};
|
|
19
|
-
};
|
|
20
|
-
type ErrorStatus = keyof typeof errorMessages;
|
|
2
|
+
import { type Locale } from "../lib/i18n";
|
|
3
|
+
type ErrorStatus = 400 | 404 | 500 | 503;
|
|
21
4
|
interface ErrorStateProps {
|
|
22
5
|
status: ErrorStatus;
|
|
23
6
|
title?: string;
|
|
24
7
|
description?: string;
|
|
25
8
|
action?: ReactNode;
|
|
26
9
|
className?: string;
|
|
10
|
+
/** BCP-47 tag for the built-in copy. Defaults to "pt-BR". */
|
|
11
|
+
locale?: Locale;
|
|
27
12
|
}
|
|
28
13
|
/** Shared error presentation. Apps own routing, retries and availability checks.
|
|
29
14
|
* Never pass exception messages, stack traces, tokens or private data as copy.
|
|
30
15
|
*/
|
|
31
|
-
declare function ErrorState({ status, title, description, action, className }: ErrorStateProps): import("react").JSX.Element;
|
|
16
|
+
declare function ErrorState({ status, title, description, action, className, locale }: ErrorStateProps): import("react").JSX.Element;
|
|
32
17
|
export { ErrorState, type ErrorStateProps, type ErrorStatus };
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
|
-
|
|
4
|
-
400: { title: "Não foi possível abrir este endereço", description: "Confira os parâmetros do link ou volte ao início para continuar." },
|
|
5
|
-
404: { title: "Não encontramos esta página", description: "O endereço pode ter mudado ou o conteúdo não está disponível." },
|
|
6
|
-
500: { title: "Não foi possível carregar a página", description: "Ocorreu um erro inesperado. Tente novamente em instantes." },
|
|
7
|
-
503: { title: "Serviço temporariamente indisponível", description: "Estamos sem conexão com o serviço. Verifique sua conexão e tente novamente." },
|
|
8
|
-
};
|
|
3
|
+
import { DEFAULT_LOCALE, getErrorMessages } from "../lib/i18n";
|
|
9
4
|
/** Shared error presentation. Apps own routing, retries and availability checks.
|
|
10
5
|
* Never pass exception messages, stack traces, tokens or private data as copy.
|
|
11
6
|
*/
|
|
12
|
-
function ErrorState({ status, title, description, action, className }) {
|
|
13
|
-
const message =
|
|
7
|
+
function ErrorState({ status, title, description, action, className, locale = DEFAULT_LOCALE }) {
|
|
8
|
+
const message = getErrorMessages(locale)[status];
|
|
14
9
|
return (_jsxs("section", { "data-slot": "error-state", role: "alert", className: cn("mx-auto flex max-w-2xl flex-col items-center gap-5 px-5 py-16 text-center", className), children: [_jsx("p", { className: "font-mono text-sm tabular-nums text-muted-foreground", children: status }), _jsx("h1", { className: "text-2xl font-semibold text-foreground sm:text-3xl", children: title || message.title }), _jsx("p", { className: "max-w-[48ch] text-pretty text-base text-muted-foreground", children: description || message.description }), action && _jsx("div", { className: "mt-2 flex flex-wrap justify-center gap-3", children: action })] }));
|
|
15
10
|
}
|
|
16
11
|
export { ErrorState };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { type Locale, type ModalLabels } from "../lib/i18n";
|
|
2
3
|
/**
|
|
3
4
|
* Built on Base UI's Dialog rather than a hand-rolled portal. The focus trap,
|
|
4
5
|
* the scroll lock, the Escape handling, the restore-focus-on-close and the
|
|
@@ -26,6 +27,10 @@ interface ModalProps {
|
|
|
26
27
|
danger?: boolean;
|
|
27
28
|
submitDisabled?: boolean;
|
|
28
29
|
size?: keyof typeof SIZE;
|
|
30
|
+
/** BCP-47 tag for built-in copy. Defaults to "pt-BR". */
|
|
31
|
+
locale?: Locale;
|
|
32
|
+
/** Overrides built-in strings; submitLabel/cancelLabel take precedence. */
|
|
33
|
+
labels?: Partial<ModalLabels>;
|
|
29
34
|
}
|
|
30
|
-
declare function Modal({ open, onClose, title, description, children, onSubmit, submitLabel, cancelLabel, loading, danger, submitDisabled, size, }: ModalProps): import("react").JSX.Element;
|
|
35
|
+
declare function Modal({ open, onClose, title, description, children, onSubmit, submitLabel, cancelLabel, loading, danger, submitDisabled, size, locale, labels, }: ModalProps): import("react").JSX.Element;
|
|
31
36
|
export { Modal, type ModalProps };
|
package/dist/components/modal.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Dialog } from "@base-ui/react/dialog";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
|
+
import { DEFAULT_LOCALE, getModalLabels } from "../lib/i18n";
|
|
5
6
|
import { Button } from "./button";
|
|
6
7
|
import { useThemeScope } from "./theme-provider";
|
|
7
8
|
/**
|
|
@@ -17,9 +18,10 @@ const SIZE = {
|
|
|
17
18
|
lg: "max-w-2xl",
|
|
18
19
|
xl: "max-w-4xl",
|
|
19
20
|
};
|
|
20
|
-
function Modal({ open, onClose, title, description, children, onSubmit, submitLabel
|
|
21
|
+
function Modal({ open, onClose, title, description, children, onSubmit, submitLabel, cancelLabel, loading = false, danger = false, submitDisabled = false, size = "md", locale = DEFAULT_LOCALE, labels, }) {
|
|
22
|
+
const text = getModalLabels(locale, labels);
|
|
21
23
|
const { theme, density } = useThemeScope();
|
|
22
|
-
return (_jsx(Dialog.Root, { open: open, onOpenChange: next => !next && onClose(), children: _jsxs(Dialog.Portal, { "data-ctech-theme": theme, "data-density": density, children: [_jsx(Dialog.Backdrop, { className: cn("fixed inset-0 z-40 bg-black/40 backdrop-blur-[1px]", "transition-opacity duration-200 ease-out", "data-[ending-style]:opacity-0 data-[starting-style]:opacity-0", "motion-reduce:transition-none") }), _jsxs(Dialog.Popup, { className: cn("fixed top-1/2 left-1/2 z-50 -translate-x-1/2 -translate-y-1/2", "w-[calc(100vw-2rem)] max-h-[85vh] overflow-y-auto", "rounded-xl border border-border bg-background text-foreground shadow-modal outline-none", "transition-[opacity,translate,scale] duration-200 ease-out", "data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0", "data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0", "motion-reduce:transition-opacity motion-reduce:data-[ending-style]:scale-100 motion-reduce:data-[starting-style]:scale-100", SIZE[size]), children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-border bg-background px-6 py-4", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Dialog.Title, { className: "text-base font-semibold text-foreground", children: title }), description && (_jsx(Dialog.Description, { className: "text-sm text-muted-foreground", children: description }))] }), _jsx(Dialog.Close, { render: _jsx(Button, { variant: "ghost", size: "icon", "aria-label":
|
|
24
|
+
return (_jsx(Dialog.Root, { open: open, onOpenChange: next => !next && onClose(), children: _jsxs(Dialog.Portal, { "data-ctech-theme": theme, "data-density": density, children: [_jsx(Dialog.Backdrop, { className: cn("fixed inset-0 z-40 bg-black/40 backdrop-blur-[1px]", "transition-opacity duration-200 ease-out", "data-[ending-style]:opacity-0 data-[starting-style]:opacity-0", "motion-reduce:transition-none") }), _jsxs(Dialog.Popup, { className: cn("fixed top-1/2 left-1/2 z-50 -translate-x-1/2 -translate-y-1/2", "w-[calc(100vw-2rem)] max-h-[85vh] overflow-y-auto", "rounded-xl border border-border bg-background text-foreground shadow-modal outline-none", "transition-[opacity,translate,scale] duration-200 ease-out", "data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0", "data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0", "motion-reduce:transition-opacity motion-reduce:data-[ending-style]:scale-100 motion-reduce:data-[starting-style]:scale-100", SIZE[size]), children: [_jsxs("header", { className: "sticky top-0 z-10 flex items-start justify-between gap-4 border-b border-border bg-background px-6 py-4", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Dialog.Title, { className: "text-base font-semibold text-foreground", children: title }), description && (_jsx(Dialog.Description, { className: "text-sm text-muted-foreground", children: description }))] }), _jsx(Dialog.Close, { render: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": text.close, className: "-mr-2 shrink-0 text-muted-foreground" }), children: _jsx(CloseGlyph, {}) })] }), children && _jsx("div", { className: "px-6 py-5", children: children }), _jsxs("footer", { className: "sticky bottom-0 flex justify-end gap-3 border-t border-border bg-surface px-6 py-4", children: [_jsx(Button, { type: "button", variant: "outline", onClick: onClose, disabled: loading, children: cancelLabel ?? text.cancel }), onSubmit && (_jsx(Button, { type: "button", variant: danger ? "danger" : "brand", onClick: onSubmit, disabled: loading || submitDisabled, children: loading ? text.loading : submitLabel ?? text.submit }))] })] })] }) }));
|
|
23
25
|
}
|
|
24
26
|
function CloseGlyph() {
|
|
25
27
|
return (_jsx("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": true, children: _jsx("path", { d: "m4 4 8 8M12 4l-8 8", strokeLinecap: "round" }) }));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type Locale, type UserMenuLabels } from "../lib/i18n";
|
|
3
|
+
import { type RenderLink } from "../lib/link";
|
|
4
|
+
/**
|
|
5
|
+
* The signed-in person, behind their avatar: who they are (the whole name, never
|
|
6
|
+
* truncated — "Maria Aparecida dos Santos" cut to "Maria Apar…" is someone
|
|
7
|
+
* else), which view of the product they are in, and the way out.
|
|
8
|
+
*
|
|
9
|
+
* Base UI's Menu supplies the menu-button pattern: Enter/Space/ArrowDown open
|
|
10
|
+
* it, arrow keys move, type-ahead, Escape closes and focus returns to the
|
|
11
|
+
* avatar. The views are generic — a product passes "Portal" and "Console", or
|
|
12
|
+
* anything else — and the current one is marked with a check and
|
|
13
|
+
* `aria-current`, not with colour alone.
|
|
14
|
+
*/
|
|
15
|
+
interface UserMenuTarget {
|
|
16
|
+
/** Renders a link through `renderLink`; otherwise the item runs `onClick`. */
|
|
17
|
+
href?: string;
|
|
18
|
+
onClick?: () => void;
|
|
19
|
+
}
|
|
20
|
+
interface UserMenuView extends UserMenuTarget {
|
|
21
|
+
label: string;
|
|
22
|
+
icon?: ReactNode;
|
|
23
|
+
current?: boolean;
|
|
24
|
+
}
|
|
25
|
+
interface UserMenuItem extends UserMenuTarget {
|
|
26
|
+
label: string;
|
|
27
|
+
icon?: ReactNode;
|
|
28
|
+
}
|
|
29
|
+
interface UserMenuProps {
|
|
30
|
+
/** Full name, shown whole in the menu and turned into initials on the avatar. */
|
|
31
|
+
name: string;
|
|
32
|
+
email?: string;
|
|
33
|
+
/** Shown instead of the initials once it loads. */
|
|
34
|
+
imageUrl?: string;
|
|
35
|
+
/** A view switcher section (e.g. Portal / Console). Omitted when empty. */
|
|
36
|
+
views?: UserMenuView[];
|
|
37
|
+
/** Extra actions between the views and sign-out. */
|
|
38
|
+
items?: UserMenuItem[];
|
|
39
|
+
/** Renders the sign-out item when given. */
|
|
40
|
+
onSignOut?: () => void;
|
|
41
|
+
renderLink?: RenderLink;
|
|
42
|
+
/** Which edge of the avatar the menu lines up with. Defaults to "end". */
|
|
43
|
+
align?: "start" | "center" | "end";
|
|
44
|
+
/** BCP-47 tag for built-in copy. Defaults to "pt-BR". */
|
|
45
|
+
locale?: Locale;
|
|
46
|
+
labels?: Partial<UserMenuLabels>;
|
|
47
|
+
/** Applied to the trigger. */
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
/** First and last initials, by grapheme-ish code point so "Érica Ödegaard" is "ÉÖ". */
|
|
51
|
+
declare function initialsOf(name: string, email?: string): string;
|
|
52
|
+
declare function UserMenu({ name, email, imageUrl, views, items, onSignOut, renderLink, align, locale, labels, className, }: UserMenuProps): import("react").JSX.Element;
|
|
53
|
+
export { UserMenu, initialsOf, type UserMenuItem, type UserMenuProps, type UserMenuView };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Avatar } from "@base-ui/react/avatar";
|
|
4
|
+
import { Menu } from "@base-ui/react/menu";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { DEFAULT_LOCALE, getUserMenuLabels } from "../lib/i18n";
|
|
7
|
+
import { defaultRenderLink } from "../lib/link";
|
|
8
|
+
import { useThemeScope } from "./theme-provider";
|
|
9
|
+
const ITEM = cn("group/item flex min-h-11 w-full cursor-default items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-foreground outline-none select-none", "in-data-[density=compact]:min-h-9", "data-[highlighted]:bg-surface data-[disabled]:opacity-50", "[&_svg]:size-4 [&_svg]:shrink-0");
|
|
10
|
+
const ICON = "flex size-4 shrink-0 items-center justify-center text-muted-foreground";
|
|
11
|
+
/** First and last initials, by grapheme-ish code point so "Érica Ödegaard" is "ÉÖ". */
|
|
12
|
+
function initialsOf(name, email) {
|
|
13
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
14
|
+
const first = (w) => (w ? Array.from(w)[0] ?? "" : "");
|
|
15
|
+
if (words.length === 0)
|
|
16
|
+
return first(email?.trim()).toUpperCase();
|
|
17
|
+
const letters = words.length === 1 ? first(words[0]) : first(words[0]) + first(words[words.length - 1]);
|
|
18
|
+
return letters.toUpperCase();
|
|
19
|
+
}
|
|
20
|
+
function UserAvatar({ name, email, imageUrl, className }) {
|
|
21
|
+
const initials = initialsOf(name, email);
|
|
22
|
+
return (_jsxs(Avatar.Root, { className: cn("inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-brand-50 align-middle font-semibold text-brand-700 select-none", className), children: [imageUrl && _jsx(Avatar.Image, { src: imageUrl, alt: "", className: "size-full object-cover" }), _jsx(Avatar.Fallback, { className: "flex size-full items-center justify-center", children: initials || _jsx(PersonGlyph, {}) })] }));
|
|
23
|
+
}
|
|
24
|
+
function UserMenu({ name, email, imageUrl, views = [], items = [], onSignOut, renderLink = defaultRenderLink, align = "end", locale = DEFAULT_LOCALE, labels, className, }) {
|
|
25
|
+
const text = getUserMenuLabels(locale, labels);
|
|
26
|
+
const { theme, density } = useThemeScope();
|
|
27
|
+
const renderEntry = (entry, trailing, current) => {
|
|
28
|
+
const content = (_jsxs(_Fragment, { children: [entry.icon && _jsx("span", { "aria-hidden": true, className: ICON, children: entry.icon }), _jsx("span", { className: "min-w-0 flex-1 break-words", children: entry.label }), trailing] }));
|
|
29
|
+
const ariaCurrent = current ? "true" : undefined;
|
|
30
|
+
if (entry.href !== undefined) {
|
|
31
|
+
const href = entry.href;
|
|
32
|
+
return (_jsx(Menu.LinkItem, { href: href, closeOnClick: true, onClick: entry.onClick, "aria-current": ariaCurrent, className: ITEM, render: props => renderLink({ ...props, href }), children: content }, entry.label));
|
|
33
|
+
}
|
|
34
|
+
return (_jsx(Menu.Item, { onClick: entry.onClick, "aria-current": ariaCurrent, className: ITEM, children: content }, entry.label));
|
|
35
|
+
};
|
|
36
|
+
return (_jsxs(Menu.Root, { children: [_jsx(Menu.Trigger, { "aria-label": `${text.trigger}: ${name}`, className: cn("inline-flex size-11 shrink-0 items-center justify-center rounded-full outline-none", "in-data-[density=compact]:size-8", "transition-shadow duration-150 motion-reduce:transition-none", "hover:ring-2 hover:ring-border focus-visible:ring-3 focus-visible:ring-ring/50 data-[popup-open]:ring-2 data-[popup-open]:ring-ring/50", className), children: _jsx(UserAvatar, { name: name, email: email, imageUrl: imageUrl, className: "size-9 text-sm in-data-[density=compact]:size-8 in-data-[density=compact]:text-xs" }) }), _jsx(Menu.Portal, { "data-ctech-theme": theme, "data-density": density, children: _jsx(Menu.Positioner, { sideOffset: 8, align: align, collisionPadding: 8, className: "z-50 outline-none", children: _jsxs(Menu.Popup, { className: cn("w-72 max-w-[calc(100vw-1rem)] origin-[var(--transform-origin)] rounded-xl border border-border bg-background p-1.5 text-foreground shadow-modal outline-none", "transition-[opacity,scale] duration-150 ease-out", "data-[starting-style]:scale-95 data-[starting-style]:opacity-0 data-[ending-style]:scale-95 data-[ending-style]:opacity-0", "motion-reduce:transition-opacity motion-reduce:data-[starting-style]:scale-100 motion-reduce:data-[ending-style]:scale-100"), children: [_jsxs("div", { className: "flex items-start gap-3 px-3 pt-2.5 pb-3", children: [_jsx(UserAvatar, { name: name, email: email, imageUrl: imageUrl, className: "size-10 text-sm" }), _jsxs("div", { className: "min-w-0 flex-1 pt-0.5", children: [_jsx("p", { className: "m-0 text-sm/5 font-semibold text-pretty [overflow-wrap:anywhere]", children: name }), email && _jsx("p", { className: "m-0 mt-0.5 text-xs/4 text-muted-foreground [overflow-wrap:anywhere]", children: email })] })] }), views.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Menu.Separator, { className: "-mx-1.5 my-1 h-px bg-border" }), _jsxs(Menu.Group, { children: [_jsx(Menu.GroupLabel, { className: "px-3 pt-1.5 pb-1 text-xs font-medium text-muted-foreground", children: text.views }), views.map(view => renderEntry(view, view.current ? _jsx(CheckGlyph, {}) : _jsx("span", { "aria-hidden": true, className: "size-4 shrink-0" }), view.current))] })] })), items.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Menu.Separator, { className: "-mx-1.5 my-1 h-px bg-border" }), items.map(item => renderEntry(item))] })), onSignOut && (_jsxs(_Fragment, { children: [_jsx(Menu.Separator, { className: "-mx-1.5 my-1 h-px bg-border" }), _jsxs(Menu.Item, { onClick: onSignOut, className: ITEM, children: [_jsx("span", { "aria-hidden": true, className: ICON, children: _jsx(SignOutGlyph, {}) }), _jsx("span", { className: "min-w-0 flex-1", children: text.signOut })] })] }))] }) }) })] }));
|
|
37
|
+
}
|
|
38
|
+
function CheckGlyph() {
|
|
39
|
+
return (_jsx("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.75, "aria-hidden": true, className: "size-4 shrink-0 text-brand-600", children: _jsx("path", { d: "m3.5 8.5 3 3 6-7", strokeLinecap: "round", strokeLinejoin: "round" }) }));
|
|
40
|
+
}
|
|
41
|
+
function SignOutGlyph() {
|
|
42
|
+
return (_jsx("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": true, children: _jsx("path", { d: "M6 2.75H3.75a1 1 0 0 0-1 1v8.5a1 1 0 0 0 1 1H6M10.5 11l3-3-3-3M13.25 8H6.5", strokeLinecap: "round", strokeLinejoin: "round" }) }));
|
|
43
|
+
}
|
|
44
|
+
function PersonGlyph() {
|
|
45
|
+
return (_jsxs("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.5, "aria-hidden": true, className: "size-1/2", children: [_jsx("circle", { cx: "8", cy: "5.5", r: "2.75" }), _jsx("path", { d: "M2.75 13.5c.9-2.4 2.85-3.75 5.25-3.75s4.35 1.35 5.25 3.75", strokeLinecap: "round" })] }));
|
|
46
|
+
}
|
|
47
|
+
export { UserMenu, initialsOf };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { cn } from "./lib/cn";
|
|
2
|
+
export { type BottomNavLabels, type DatePickerLabels, type Locale, type ModalLabels, type UserMenuLabels } from "./lib/i18n";
|
|
3
|
+
export { type LinkProps, type RenderLink } from "./lib/link";
|
|
2
4
|
export { Badge, badgeVariants, type BadgeTone } from "./components/badge";
|
|
3
5
|
export { Button, buttonVariants } from "./components/button";
|
|
4
6
|
export { Alert, alertVariants, type AlertProps } from "./components/alert";
|
|
@@ -16,3 +18,5 @@ export { Radio, RadioGroup } from "./components/radio";
|
|
|
16
18
|
export { Switch } from "./components/switch";
|
|
17
19
|
export { ThemeProvider, type CTechTheme, type Density, type ThemeProviderProps } from "./components/theme-provider";
|
|
18
20
|
export { ErrorState, type ErrorStateProps, type ErrorStatus } from "./components/error-state";
|
|
21
|
+
export { BottomNav, BottomNavSpacer, bottomNavInset, type BottomNavAction, type BottomNavItem, type BottomNavMore, type BottomNavProps, type BottomNavSheetItem, type BottomNavTab, } from "./components/bottom-nav";
|
|
22
|
+
export { UserMenu, type UserMenuItem, type UserMenuProps, type UserMenuView } from "./components/user-menu";
|
package/dist/index.js
CHANGED
|
@@ -16,3 +16,5 @@ export { Radio, RadioGroup } from "./components/radio";
|
|
|
16
16
|
export { Switch } from "./components/switch";
|
|
17
17
|
export { ThemeProvider } from "./components/theme-provider";
|
|
18
18
|
export { ErrorState } from "./components/error-state";
|
|
19
|
+
export { BottomNav, BottomNavSpacer, bottomNavInset, } from "./components/bottom-nav";
|
|
20
|
+
export { UserMenu } from "./components/user-menu";
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny built-in i18n for the strings shared components own. Two catalogs
|
|
3
|
+
* (pt-BR, en); any BCP-47 tag resolves to one of them by language, while Intl
|
|
4
|
+
* formatting still honours the exact tag. Products override single strings with
|
|
5
|
+
* each component's `labels` prop instead of forking the catalog.
|
|
6
|
+
*/
|
|
7
|
+
type CatalogLocale = "pt-BR" | "en";
|
|
8
|
+
/** A BCP-47 tag such as "pt-BR" or "en". Unknown languages fall back to English copy. */
|
|
9
|
+
type Locale = string;
|
|
10
|
+
declare const DEFAULT_LOCALE: Locale;
|
|
11
|
+
declare function resolveCatalog(locale: Locale | undefined): CatalogLocale;
|
|
12
|
+
declare const datePickerMessages: {
|
|
13
|
+
readonly "pt-BR": {
|
|
14
|
+
readonly placeholder: "Selecionar data";
|
|
15
|
+
readonly today: "Hoje";
|
|
16
|
+
readonly selected: "selecionado";
|
|
17
|
+
readonly previousMonth: "Ir para o mês anterior";
|
|
18
|
+
readonly nextMonth: "Ir para o próximo mês";
|
|
19
|
+
};
|
|
20
|
+
readonly en: {
|
|
21
|
+
readonly placeholder: "Select date";
|
|
22
|
+
readonly today: "Today";
|
|
23
|
+
readonly selected: "selected";
|
|
24
|
+
readonly previousMonth: "Go to the previous month";
|
|
25
|
+
readonly nextMonth: "Go to the next month";
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
declare const modalMessages: {
|
|
29
|
+
readonly "pt-BR": {
|
|
30
|
+
readonly submit: "Salvar";
|
|
31
|
+
readonly cancel: "Cancelar";
|
|
32
|
+
readonly close: "Fechar";
|
|
33
|
+
readonly loading: "Aguarde…";
|
|
34
|
+
};
|
|
35
|
+
readonly en: {
|
|
36
|
+
readonly submit: "Save";
|
|
37
|
+
readonly cancel: "Cancel";
|
|
38
|
+
readonly close: "Close";
|
|
39
|
+
readonly loading: "Please wait…";
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
declare const bottomNavMessages: {
|
|
43
|
+
readonly "pt-BR": {
|
|
44
|
+
readonly nav: "Navegação principal";
|
|
45
|
+
readonly close: "Fechar";
|
|
46
|
+
};
|
|
47
|
+
readonly en: {
|
|
48
|
+
readonly nav: "Main navigation";
|
|
49
|
+
readonly close: "Close";
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
declare const userMenuMessages: {
|
|
53
|
+
readonly "pt-BR": {
|
|
54
|
+
readonly trigger: "Menu da conta";
|
|
55
|
+
readonly views: "Alternar visão";
|
|
56
|
+
readonly signOut: "Sair";
|
|
57
|
+
};
|
|
58
|
+
readonly en: {
|
|
59
|
+
readonly trigger: "Account menu";
|
|
60
|
+
readonly views: "Switch view";
|
|
61
|
+
readonly signOut: "Sign out";
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
type DatePickerLabels = {
|
|
65
|
+
-readonly [K in keyof (typeof datePickerMessages)["en"]]: string;
|
|
66
|
+
};
|
|
67
|
+
type ModalLabels = {
|
|
68
|
+
-readonly [K in keyof (typeof modalMessages)["en"]]: string;
|
|
69
|
+
};
|
|
70
|
+
type BottomNavLabels = {
|
|
71
|
+
-readonly [K in keyof (typeof bottomNavMessages)["en"]]: string;
|
|
72
|
+
};
|
|
73
|
+
type UserMenuLabels = {
|
|
74
|
+
-readonly [K in keyof (typeof userMenuMessages)["en"]]: string;
|
|
75
|
+
};
|
|
76
|
+
declare function getDatePickerLabels(locale: Locale | undefined, overrides?: Partial<DatePickerLabels>): DatePickerLabels;
|
|
77
|
+
declare function getModalLabels(locale: Locale | undefined, overrides?: Partial<ModalLabels>): ModalLabels;
|
|
78
|
+
declare function getBottomNavLabels(locale: Locale | undefined, overrides?: Partial<BottomNavLabels>): BottomNavLabels;
|
|
79
|
+
declare function getUserMenuLabels(locale: Locale | undefined, overrides?: Partial<UserMenuLabels>): UserMenuLabels;
|
|
80
|
+
declare function getErrorMessages(locale: Locale | undefined): {
|
|
81
|
+
readonly 400: {
|
|
82
|
+
readonly title: "Não foi possível abrir este endereço";
|
|
83
|
+
readonly description: "Confira os parâmetros do link ou volte ao início para continuar.";
|
|
84
|
+
};
|
|
85
|
+
readonly 404: {
|
|
86
|
+
readonly title: "Não encontramos esta página";
|
|
87
|
+
readonly description: "O endereço pode ter mudado ou o conteúdo não está disponível.";
|
|
88
|
+
};
|
|
89
|
+
readonly 500: {
|
|
90
|
+
readonly title: "Não foi possível carregar a página";
|
|
91
|
+
readonly description: "Ocorreu um erro inesperado. Tente novamente em instantes.";
|
|
92
|
+
};
|
|
93
|
+
readonly 503: {
|
|
94
|
+
readonly title: "Serviço temporariamente indisponível";
|
|
95
|
+
readonly description: "Estamos sem conexão com o serviço. Verifique sua conexão e tente novamente.";
|
|
96
|
+
};
|
|
97
|
+
} | {
|
|
98
|
+
readonly 400: {
|
|
99
|
+
readonly title: "We couldn't open this address";
|
|
100
|
+
readonly description: "Check the link's parameters or go back to the start to continue.";
|
|
101
|
+
};
|
|
102
|
+
readonly 404: {
|
|
103
|
+
readonly title: "We couldn't find this page";
|
|
104
|
+
readonly description: "The address may have changed or the content is unavailable.";
|
|
105
|
+
};
|
|
106
|
+
readonly 500: {
|
|
107
|
+
readonly title: "We couldn't load the page";
|
|
108
|
+
readonly description: "An unexpected error occurred. Please try again shortly.";
|
|
109
|
+
};
|
|
110
|
+
readonly 503: {
|
|
111
|
+
readonly title: "Service temporarily unavailable";
|
|
112
|
+
readonly description: "We can't reach the service. Check your connection and try again.";
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
export { DEFAULT_LOCALE, getBottomNavLabels, getDatePickerLabels, getErrorMessages, getModalLabels, getUserMenuLabels, resolveCatalog, type BottomNavLabels, type CatalogLocale, type DatePickerLabels, type Locale, type ModalLabels, type UserMenuLabels, };
|
package/dist/lib/i18n.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny built-in i18n for the strings shared components own. Two catalogs
|
|
3
|
+
* (pt-BR, en); any BCP-47 tag resolves to one of them by language, while Intl
|
|
4
|
+
* formatting still honours the exact tag. Products override single strings with
|
|
5
|
+
* each component's `labels` prop instead of forking the catalog.
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_LOCALE = "pt-BR";
|
|
8
|
+
function resolveCatalog(locale) {
|
|
9
|
+
const language = (locale ?? DEFAULT_LOCALE).toLowerCase().split(/[-_]/)[0];
|
|
10
|
+
return language === "pt" ? "pt-BR" : "en";
|
|
11
|
+
}
|
|
12
|
+
const datePickerMessages = {
|
|
13
|
+
"pt-BR": {
|
|
14
|
+
placeholder: "Selecionar data",
|
|
15
|
+
today: "Hoje",
|
|
16
|
+
selected: "selecionado",
|
|
17
|
+
previousMonth: "Ir para o mês anterior",
|
|
18
|
+
nextMonth: "Ir para o próximo mês",
|
|
19
|
+
},
|
|
20
|
+
en: {
|
|
21
|
+
placeholder: "Select date",
|
|
22
|
+
today: "Today",
|
|
23
|
+
selected: "selected",
|
|
24
|
+
previousMonth: "Go to the previous month",
|
|
25
|
+
nextMonth: "Go to the next month",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const modalMessages = {
|
|
29
|
+
"pt-BR": { submit: "Salvar", cancel: "Cancelar", close: "Fechar", loading: "Aguarde…" },
|
|
30
|
+
en: { submit: "Save", cancel: "Cancel", close: "Close", loading: "Please wait…" },
|
|
31
|
+
};
|
|
32
|
+
const bottomNavMessages = {
|
|
33
|
+
"pt-BR": { nav: "Navegação principal", close: "Fechar" },
|
|
34
|
+
en: { nav: "Main navigation", close: "Close" },
|
|
35
|
+
};
|
|
36
|
+
const userMenuMessages = {
|
|
37
|
+
"pt-BR": { trigger: "Menu da conta", views: "Alternar visão", signOut: "Sair" },
|
|
38
|
+
en: { trigger: "Account menu", views: "Switch view", signOut: "Sign out" },
|
|
39
|
+
};
|
|
40
|
+
const errorMessages = {
|
|
41
|
+
"pt-BR": {
|
|
42
|
+
400: { title: "Não foi possível abrir este endereço", description: "Confira os parâmetros do link ou volte ao início para continuar." },
|
|
43
|
+
404: { title: "Não encontramos esta página", description: "O endereço pode ter mudado ou o conteúdo não está disponível." },
|
|
44
|
+
500: { title: "Não foi possível carregar a página", description: "Ocorreu um erro inesperado. Tente novamente em instantes." },
|
|
45
|
+
503: { title: "Serviço temporariamente indisponível", description: "Estamos sem conexão com o serviço. Verifique sua conexão e tente novamente." },
|
|
46
|
+
},
|
|
47
|
+
en: {
|
|
48
|
+
400: { title: "We couldn't open this address", description: "Check the link's parameters or go back to the start to continue." },
|
|
49
|
+
404: { title: "We couldn't find this page", description: "The address may have changed or the content is unavailable." },
|
|
50
|
+
500: { title: "We couldn't load the page", description: "An unexpected error occurred. Please try again shortly." },
|
|
51
|
+
503: { title: "Service temporarily unavailable", description: "We can't reach the service. Check your connection and try again." },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
function getDatePickerLabels(locale, overrides) {
|
|
55
|
+
return { ...datePickerMessages[resolveCatalog(locale)], ...overrides };
|
|
56
|
+
}
|
|
57
|
+
function getModalLabels(locale, overrides) {
|
|
58
|
+
return { ...modalMessages[resolveCatalog(locale)], ...overrides };
|
|
59
|
+
}
|
|
60
|
+
function getBottomNavLabels(locale, overrides) {
|
|
61
|
+
return { ...bottomNavMessages[resolveCatalog(locale)], ...overrides };
|
|
62
|
+
}
|
|
63
|
+
function getUserMenuLabels(locale, overrides) {
|
|
64
|
+
return { ...userMenuMessages[resolveCatalog(locale)], ...overrides };
|
|
65
|
+
}
|
|
66
|
+
function getErrorMessages(locale) {
|
|
67
|
+
return errorMessages[resolveCatalog(locale)];
|
|
68
|
+
}
|
|
69
|
+
export { DEFAULT_LOCALE, getBottomNavLabels, getDatePickerLabels, getErrorMessages, getModalLabels, getUserMenuLabels, resolveCatalog, };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AnchorHTMLAttributes, ReactElement, Ref } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The library knows nothing about routing. A component that renders links
|
|
4
|
+
* takes a `renderLink` so the product's router does the navigating — for
|
|
5
|
+
* Next.js, `renderLink={props => <Link {...props} />}`. Without one, a plain
|
|
6
|
+
* `<a>` is rendered and the browser navigates.
|
|
7
|
+
*/
|
|
8
|
+
type LinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
9
|
+
href: string;
|
|
10
|
+
ref?: Ref<HTMLAnchorElement>;
|
|
11
|
+
};
|
|
12
|
+
type RenderLink = (props: LinkProps) => ReactElement;
|
|
13
|
+
declare const defaultRenderLink: RenderLink;
|
|
14
|
+
export { defaultRenderLink, type LinkProps, type RenderLink };
|
package/dist/lib/link.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aoctech/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Shared CTech UI primitives: Base UI behaviour, Tailwind classes, no colours of its own. Themed entirely by the consuming app's CSS variables.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json && node scripts/copy-styles.mjs",
|
|
39
|
-
"test": "npm run build",
|
|
39
|
+
"test": "node --test src/lib/i18n.test.ts && vitest run --maxWorkers=2 && npm run build",
|
|
40
40
|
"storybook": "storybook dev -p 6006",
|
|
41
41
|
"build-storybook": "storybook build"
|
|
42
42
|
},
|
|
@@ -52,12 +52,17 @@
|
|
|
52
52
|
"@base-ui/react": "^1.7.0",
|
|
53
53
|
"@storybook/react-vite": "^10.5.8",
|
|
54
54
|
"@tailwindcss/vite": "^4.3.3",
|
|
55
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
56
|
+
"@testing-library/react": "^16.3.3",
|
|
57
|
+
"@testing-library/user-event": "^14.6.7",
|
|
55
58
|
"@types/react": "^19.2.18",
|
|
56
59
|
"@types/react-dom": "^19.2.4",
|
|
60
|
+
"jsdom": "^30.1.2",
|
|
57
61
|
"react": "^19.2.8",
|
|
58
62
|
"react-dom": "^19.2.8",
|
|
59
63
|
"typescript": "^6.0.3",
|
|
60
|
-
"vite": "^8.2.1"
|
|
64
|
+
"vite": "^8.2.1",
|
|
65
|
+
"vitest": "^5.0.3"
|
|
61
66
|
},
|
|
62
67
|
"engines": {
|
|
63
68
|
"node": ">=24"
|