@aoctech/ui 0.1.4 → 0.2.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 +1 -1
- package/dist/components/date-picker.d.ts +12 -2
- package/dist/components/date-picker.js +30 -18
- package/dist/components/drawer.d.ts +26 -0
- package/dist/components/drawer.js +47 -0
- 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/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/lib/i18n.d.ts +85 -0
- package/dist/lib/i18n.js +55 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -105,7 +105,7 @@ 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
109
|
|
|
110
110
|
Deliberately small. Components arrive when a second app needs one, not in anticipation — the whole
|
|
111
111
|
point of extracting this was to stop maintaining four copies, and a component with one consumer is
|
|
@@ -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")}`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* A panel for a task that needs a form but not a new page (shadcn's Drawer, on
|
|
4
|
+
* Base UI's Drawer primitive): from the right on a wide screen, where the page
|
|
5
|
+
* behind stays readable, and from the bottom on a phone, where a side panel
|
|
6
|
+
* would be the whole screen anyway. Swipe it away in the direction it came
|
|
7
|
+
* from; focus trap, Escape, scroll lock and restore-focus come from Base UI.
|
|
8
|
+
*
|
|
9
|
+
* The body is the caller's: a form brings its own buttons, so there is no fixed
|
|
10
|
+
* footer unless `footer` is given (it stays visible while the body scrolls).
|
|
11
|
+
*/
|
|
12
|
+
declare const WIDTH: {
|
|
13
|
+
readonly md: "sm:max-w-md";
|
|
14
|
+
readonly lg: "sm:max-w-xl";
|
|
15
|
+
};
|
|
16
|
+
interface DrawerProps {
|
|
17
|
+
open: boolean;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
title: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
children?: ReactNode;
|
|
22
|
+
footer?: ReactNode;
|
|
23
|
+
size?: keyof typeof WIDTH;
|
|
24
|
+
}
|
|
25
|
+
declare function Drawer({ open, onClose, title, description, children, footer, size }: DrawerProps): import("react").JSX.Element;
|
|
26
|
+
export { Drawer, type DrawerProps };
|
|
@@ -0,0 +1,47 @@
|
|
|
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 { useEffect, useState } from "react";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { useThemeScope } from "./theme-provider";
|
|
8
|
+
/**
|
|
9
|
+
* A panel for a task that needs a form but not a new page (shadcn's Drawer, on
|
|
10
|
+
* Base UI's Drawer primitive): from the right on a wide screen, where the page
|
|
11
|
+
* behind stays readable, and from the bottom on a phone, where a side panel
|
|
12
|
+
* would be the whole screen anyway. Swipe it away in the direction it came
|
|
13
|
+
* from; focus trap, Escape, scroll lock and restore-focus come from Base UI.
|
|
14
|
+
*
|
|
15
|
+
* The body is the caller's: a form brings its own buttons, so there is no fixed
|
|
16
|
+
* footer unless `footer` is given (it stays visible while the body scrolls).
|
|
17
|
+
*/
|
|
18
|
+
const WIDTH = {
|
|
19
|
+
md: "sm:max-w-md",
|
|
20
|
+
lg: "sm:max-w-xl",
|
|
21
|
+
};
|
|
22
|
+
/** Right on a wide screen, down on a phone; down until the first effect runs (SSR). */
|
|
23
|
+
function useSide() {
|
|
24
|
+
const [wide, setWide] = useState(false);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
// Test environments (jsdom) and very old browsers have no matchMedia.
|
|
27
|
+
if (typeof window.matchMedia !== "function")
|
|
28
|
+
return;
|
|
29
|
+
const q = window.matchMedia("(min-width: 640px)");
|
|
30
|
+
const update = () => setWide(q.matches);
|
|
31
|
+
update();
|
|
32
|
+
q.addEventListener("change", update);
|
|
33
|
+
return () => q.removeEventListener("change", update);
|
|
34
|
+
}, []);
|
|
35
|
+
return wide ? "right" : "down";
|
|
36
|
+
}
|
|
37
|
+
function Drawer({ open, onClose, title, description, children, footer, size = "md" }) {
|
|
38
|
+
const { theme, density } = useThemeScope();
|
|
39
|
+
const side = useSide();
|
|
40
|
+
return (_jsx(Primitive.Root, { open: open, onOpenChange: next => !next && onClose(), swipeDirection: side, children: _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", "data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none") }), _jsx(Primitive.Viewport, { className: "fixed inset-0 z-50", children: _jsxs(Primitive.Popup, { className: cn("fixed z-50 flex flex-col bg-background text-foreground shadow-modal outline-none", "transition-transform duration-200 ease-out data-[swiping]:transition-none motion-reduce:transition-none", side === "right"
|
|
41
|
+
? cn("inset-y-0 right-0 w-full border-l border-border", WIDTH[size], "[transform:translateX(var(--drawer-swipe-movement-x,0px))]", "data-[starting-style]:[transform:translateX(100%)] data-[ending-style]:[transform:translateX(100%)]")
|
|
42
|
+
: cn("inset-x-0 bottom-0 max-h-[90dvh] rounded-t-xl border-t border-border", "[transform:translateY(var(--drawer-swipe-movement-y,0px))]", "data-[starting-style]:[transform:translateY(100%)] data-[ending-style]:[transform:translateY(100%)]")), children: [side === "down" && _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-start justify-between gap-4 border-b border-border px-5 py-4", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Primitive.Title, { className: "text-base font-semibold text-foreground", children: title }), description && _jsx(Primitive.Description, { className: "text-sm text-muted-foreground", children: description })] }), _jsx(Primitive.Close, { render: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": "Fechar", className: "-mr-2 shrink-0 text-muted-foreground" }), children: _jsx(CloseGlyph, {}) })] }), _jsx("div", { className: "flex-1 overflow-y-auto px-5 py-4", children: children }), footer && _jsx("footer", { className: "border-t border-border bg-surface px-5 py-3", children: footer })] }) })] }) }));
|
|
43
|
+
}
|
|
44
|
+
function CloseGlyph() {
|
|
45
|
+
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" }) }));
|
|
46
|
+
}
|
|
47
|
+
export { Drawer };
|
|
@@ -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" }) }));
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { cn } from "./lib/cn";
|
|
2
|
+
export { type DatePickerLabels, type Locale, type ModalLabels } from "./lib/i18n";
|
|
2
3
|
export { Badge, badgeVariants, type BadgeTone } from "./components/badge";
|
|
3
4
|
export { Button, buttonVariants } from "./components/button";
|
|
4
5
|
export { Alert, alertVariants, type AlertProps } from "./components/alert";
|
|
@@ -8,6 +9,7 @@ export { EmptyState, type EmptyStateProps } from "./components/empty-state";
|
|
|
8
9
|
export { Field, type FieldProps } from "./components/field";
|
|
9
10
|
export { Input } from "./components/input";
|
|
10
11
|
export { Modal, type ModalProps } from "./components/modal";
|
|
12
|
+
export { Drawer, type DrawerProps } from "./components/drawer";
|
|
11
13
|
export { PageHeader, type PageHeaderProps } from "./components/page-header";
|
|
12
14
|
export { Separator } from "./components/separator";
|
|
13
15
|
export { Skeleton } from "./components/skeleton";
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { EmptyState } from "./components/empty-state";
|
|
|
8
8
|
export { Field } from "./components/field";
|
|
9
9
|
export { Input } from "./components/input";
|
|
10
10
|
export { Modal } from "./components/modal";
|
|
11
|
+
export { Drawer } from "./components/drawer";
|
|
11
12
|
export { PageHeader } from "./components/page-header";
|
|
12
13
|
export { Separator } from "./components/separator";
|
|
13
14
|
export { Skeleton } from "./components/skeleton";
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
type DatePickerLabels = {
|
|
43
|
+
-readonly [K in keyof (typeof datePickerMessages)["en"]]: string;
|
|
44
|
+
};
|
|
45
|
+
type ModalLabels = {
|
|
46
|
+
-readonly [K in keyof (typeof modalMessages)["en"]]: string;
|
|
47
|
+
};
|
|
48
|
+
declare function getDatePickerLabels(locale: Locale | undefined, overrides?: Partial<DatePickerLabels>): DatePickerLabels;
|
|
49
|
+
declare function getModalLabels(locale: Locale | undefined, overrides?: Partial<ModalLabels>): ModalLabels;
|
|
50
|
+
declare function getErrorMessages(locale: Locale | undefined): {
|
|
51
|
+
readonly 400: {
|
|
52
|
+
readonly title: "Não foi possível abrir este endereço";
|
|
53
|
+
readonly description: "Confira os parâmetros do link ou volte ao início para continuar.";
|
|
54
|
+
};
|
|
55
|
+
readonly 404: {
|
|
56
|
+
readonly title: "Não encontramos esta página";
|
|
57
|
+
readonly description: "O endereço pode ter mudado ou o conteúdo não está disponível.";
|
|
58
|
+
};
|
|
59
|
+
readonly 500: {
|
|
60
|
+
readonly title: "Não foi possível carregar a página";
|
|
61
|
+
readonly description: "Ocorreu um erro inesperado. Tente novamente em instantes.";
|
|
62
|
+
};
|
|
63
|
+
readonly 503: {
|
|
64
|
+
readonly title: "Serviço temporariamente indisponível";
|
|
65
|
+
readonly description: "Estamos sem conexão com o serviço. Verifique sua conexão e tente novamente.";
|
|
66
|
+
};
|
|
67
|
+
} | {
|
|
68
|
+
readonly 400: {
|
|
69
|
+
readonly title: "We couldn't open this address";
|
|
70
|
+
readonly description: "Check the link's parameters or go back to the start to continue.";
|
|
71
|
+
};
|
|
72
|
+
readonly 404: {
|
|
73
|
+
readonly title: "We couldn't find this page";
|
|
74
|
+
readonly description: "The address may have changed or the content is unavailable.";
|
|
75
|
+
};
|
|
76
|
+
readonly 500: {
|
|
77
|
+
readonly title: "We couldn't load the page";
|
|
78
|
+
readonly description: "An unexpected error occurred. Please try again shortly.";
|
|
79
|
+
};
|
|
80
|
+
readonly 503: {
|
|
81
|
+
readonly title: "Service temporarily unavailable";
|
|
82
|
+
readonly description: "We can't reach the service. Check your connection and try again.";
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
export { DEFAULT_LOCALE, getDatePickerLabels, getErrorMessages, getModalLabels, resolveCatalog, type CatalogLocale, type DatePickerLabels, type Locale, type ModalLabels, };
|
package/dist/lib/i18n.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
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 errorMessages = {
|
|
33
|
+
"pt-BR": {
|
|
34
|
+
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." },
|
|
35
|
+
404: { title: "Não encontramos esta página", description: "O endereço pode ter mudado ou o conteúdo não está disponível." },
|
|
36
|
+
500: { title: "Não foi possível carregar a página", description: "Ocorreu um erro inesperado. Tente novamente em instantes." },
|
|
37
|
+
503: { title: "Serviço temporariamente indisponível", description: "Estamos sem conexão com o serviço. Verifique sua conexão e tente novamente." },
|
|
38
|
+
},
|
|
39
|
+
en: {
|
|
40
|
+
400: { title: "We couldn't open this address", description: "Check the link's parameters or go back to the start to continue." },
|
|
41
|
+
404: { title: "We couldn't find this page", description: "The address may have changed or the content is unavailable." },
|
|
42
|
+
500: { title: "We couldn't load the page", description: "An unexpected error occurred. Please try again shortly." },
|
|
43
|
+
503: { title: "Service temporarily unavailable", description: "We can't reach the service. Check your connection and try again." },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
function getDatePickerLabels(locale, overrides) {
|
|
47
|
+
return { ...datePickerMessages[resolveCatalog(locale)], ...overrides };
|
|
48
|
+
}
|
|
49
|
+
function getModalLabels(locale, overrides) {
|
|
50
|
+
return { ...modalMessages[resolveCatalog(locale)], ...overrides };
|
|
51
|
+
}
|
|
52
|
+
function getErrorMessages(locale) {
|
|
53
|
+
return errorMessages[resolveCatalog(locale)];
|
|
54
|
+
}
|
|
55
|
+
export { DEFAULT_LOCALE, getDatePickerLabels, getErrorMessages, getModalLabels, resolveCatalog, };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aoctech/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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 && npm run build",
|
|
40
40
|
"storybook": "storybook dev -p 6006",
|
|
41
41
|
"build-storybook": "storybook build"
|
|
42
42
|
},
|