@godxjp/ui 23.2.1 → 23.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/dist/app/timezones.js +2 -1
- package/dist/components/charts/chart-cartesian.js +2 -4
- package/dist/components/charts/chart-frame.js +2 -1
- package/dist/components/data-entry/date-picker.js +12 -14
- package/dist/components/data-entry/number-input.js +2 -1
- package/dist/components/feedback/index.d.ts +2 -2
- package/dist/components/feedback/index.js +2 -0
- package/dist/components/feedback/skeleton.d.ts +11 -2
- package/dist/components/feedback/skeleton.js +8 -0
- package/dist/components/general/button.js +2 -1
- package/dist/components/layout/error-surface.js +3 -2
- package/dist/components/ui/toggle.js +2 -1
- package/dist/i18n/translate.js +3 -2
- package/dist/lib/datetime/picker-format.js +2 -1
- package/dist/lib/format.js +3 -2
- package/dist/lib/intl-cache.d.ts +32 -0
- package/dist/lib/intl-cache.js +29 -0
- package/dist/props/components/data-entry.prop.d.ts +11 -0
- package/dist/props/components/feedback.prop.d.ts +19 -0
- package/dist/props/registry.d.ts +5 -0
- package/dist/props/registry.js +5 -0
- package/dist/styles/alert-layout.css +22 -0
- package/dist/styles/card-layout.css +4 -0
- package/dist/styles/form-layout.css +11 -1
- package/dist/tokens/axes.css +5 -0
- package/dist/tokens/foundation.css +4 -4
- package/docs/DESIGN-AUTHORITY.md +44 -20
- package/package.json +21 -5
- package/scripts/_agent-setup.mjs +36 -5
- package/scripts/guinea-pig-skill.md +14 -0
- package/scripts/ui-audit.mjs +69 -9
package/dist/app/timezones.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { translate } from "../i18n/translate.js";
|
|
2
|
+
import { dateTimeFormat } from "../lib/intl-cache.js";
|
|
2
3
|
const APP_TIMEZONE_PRESET = [
|
|
3
4
|
"UTC",
|
|
4
5
|
"Asia/Ho_Chi_Minh",
|
|
@@ -113,7 +114,7 @@ function getTimezoneCityName(timezone) {
|
|
|
113
114
|
function getTimezoneOffsetLabel(timezone, locale = "en") {
|
|
114
115
|
if (timezone === "UTC") return "UTC";
|
|
115
116
|
try {
|
|
116
|
-
const parts =
|
|
117
|
+
const parts = dateTimeFormat(locale, {
|
|
117
118
|
timeZone: resolveTimezoneForIntl(timezone),
|
|
118
119
|
timeZoneName: "shortOffset"
|
|
119
120
|
}).formatToParts(/* @__PURE__ */ new Date());
|
|
@@ -22,6 +22,7 @@ import { useTranslation } from "../../i18n/use-translation.js";
|
|
|
22
22
|
import { ChartFrame, chartColor, chartHeight, useChartNumberFormat } from "./chart-frame.js";
|
|
23
23
|
import { buildCartesianSummary } from "./chart-summary.js";
|
|
24
24
|
import { useCategoryAxisMetrics } from "./chart-category-axis.js";
|
|
25
|
+
import { listFormat } from "../../lib/intl-cache.js";
|
|
25
26
|
function CartesianChart({
|
|
26
27
|
kind,
|
|
27
28
|
data,
|
|
@@ -45,10 +46,7 @@ function CartesianChart({
|
|
|
45
46
|
assertRechartsPeer();
|
|
46
47
|
const { t, locale } = useTranslation();
|
|
47
48
|
const fmt = useChartNumberFormat(numberFormat);
|
|
48
|
-
const list = React.useMemo(
|
|
49
|
-
() => new Intl.ListFormat(locale, { style: "narrow", type: "unit" }),
|
|
50
|
-
[locale]
|
|
51
|
-
);
|
|
49
|
+
const list = React.useMemo(() => listFormat(locale, { style: "narrow", type: "unit" }), [locale]);
|
|
52
50
|
const hasData = data.length > 0 && series.length > 0;
|
|
53
51
|
const summary = buildCartesianSummary(
|
|
54
52
|
data,
|
|
@@ -3,6 +3,7 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "../../lib/utils.js";
|
|
5
5
|
import { useTranslation } from "../../i18n/use-translation.js";
|
|
6
|
+
import { numberFormat } from "../../lib/intl-cache.js";
|
|
6
7
|
const CHART_COLORS = [
|
|
7
8
|
"var(--chart-1)",
|
|
8
9
|
"var(--chart-2)",
|
|
@@ -25,7 +26,7 @@ function chartHeight(size = "md", height) {
|
|
|
25
26
|
}
|
|
26
27
|
function useChartNumberFormat(options) {
|
|
27
28
|
const { locale } = useTranslation();
|
|
28
|
-
return React.useMemo(() =>
|
|
29
|
+
return React.useMemo(() => numberFormat(locale, options), [locale, options]);
|
|
29
30
|
}
|
|
30
31
|
function ChartFrame({
|
|
31
32
|
label,
|
|
@@ -22,6 +22,7 @@ import { CONTROL_STATUS_CHROME_CLASS, CONTROL_VARIANT_CHROME_CLASS } from "./con
|
|
|
22
22
|
import { Input } from "./input.js";
|
|
23
23
|
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "../data-display/popover.js";
|
|
24
24
|
import { Calendar } from "./calendar.js";
|
|
25
|
+
import { dateTimeFormat, numberFormat } from "../../lib/intl-cache.js";
|
|
25
26
|
const ISO_HINT = "yyyy-mm-dd";
|
|
26
27
|
const PERIOD_PICKERS = /* @__PURE__ */ new Set(["month", "quarter", "year"]);
|
|
27
28
|
const PERIOD_COLUMNS = 3;
|
|
@@ -49,6 +50,7 @@ function DatePicker(props) {
|
|
|
49
50
|
disabledDate,
|
|
50
51
|
cellRender,
|
|
51
52
|
allowClear,
|
|
53
|
+
triggerLabel,
|
|
52
54
|
format: formatProp,
|
|
53
55
|
parseFormat,
|
|
54
56
|
minDate,
|
|
@@ -296,13 +298,9 @@ function DatePicker(props) {
|
|
|
296
298
|
(index) => index < periodCells
|
|
297
299
|
);
|
|
298
300
|
const pageStep = picker === "year" ? periodCells : 1;
|
|
299
|
-
const prevDisabled = !pickerDateAllowed(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
void 0
|
|
303
|
-
);
|
|
304
|
-
const nextDisabled = !pickerDateAllowed(periodPage(viewYear + pageStep)[0], void 0, maximum);
|
|
305
|
-
const periodLabel = (date, index) => picker === "year" ? new Intl.DateTimeFormat(locale, { year: "numeric" }).format(date) : picker === "quarter" ? t("dataEntry.datePicker.quarter", { quarter: index + 1 }) ?? `Q${index + 1}` : new Intl.DateTimeFormat(locale, { month: "short" }).format(date);
|
|
301
|
+
const prevDisabled = () => !pickerDateAllowed(periodPage(viewYear - pageStep)[periodCells - 1], minimum, void 0);
|
|
302
|
+
const nextDisabled = () => !pickerDateAllowed(periodPage(viewYear + pageStep)[0], void 0, maximum);
|
|
303
|
+
const periodLabel = (date, index) => picker === "year" ? dateTimeFormat(locale, { year: "numeric" }).format(date) : picker === "quarter" ? t("dataEntry.datePicker.quarter", { quarter: index + 1 }) ?? `Q${index + 1}` : dateTimeFormat(locale, { month: "short" }).format(date);
|
|
306
304
|
const periodPick = (date) => {
|
|
307
305
|
if (!range) {
|
|
308
306
|
choose(date);
|
|
@@ -318,7 +316,7 @@ function DatePicker(props) {
|
|
|
318
316
|
choose({ from: pendingFrom, to: date });
|
|
319
317
|
if (!needConfirm) setOpen(false);
|
|
320
318
|
};
|
|
321
|
-
const
|
|
319
|
+
const renderPeriodPanel = () => /* @__PURE__ */ jsxs("div", { className: "ui-month-picker-panel", children: [
|
|
322
320
|
/* @__PURE__ */ jsxs("div", { className: "ui-month-picker-nav", children: [
|
|
323
321
|
/* @__PURE__ */ jsx(
|
|
324
322
|
Button,
|
|
@@ -326,21 +324,21 @@ function DatePicker(props) {
|
|
|
326
324
|
type: "button",
|
|
327
325
|
variant: "outline",
|
|
328
326
|
size: "icon-sm",
|
|
329
|
-
disabled: prevDisabled,
|
|
327
|
+
disabled: prevDisabled(),
|
|
330
328
|
"aria-label": t("dataEntry.monthPicker.previousYear") ?? "Previous year",
|
|
331
329
|
className: "ui-month-picker-nav-button",
|
|
332
330
|
onClick: () => setViewYear(viewYear - pageStep),
|
|
333
331
|
children: /* @__PURE__ */ jsx(ChevronLeft, { className: "ui-month-picker-icon", "aria-hidden": "true" })
|
|
334
332
|
}
|
|
335
333
|
),
|
|
336
|
-
/* @__PURE__ */ jsx("span", { className: "ui-month-picker-nav-label", "aria-live": "polite", children:
|
|
334
|
+
/* @__PURE__ */ jsx("span", { className: "ui-month-picker-nav-label", "aria-live": "polite", children: numberFormat(locale, { useGrouping: false }).format(viewYear) }),
|
|
337
335
|
/* @__PURE__ */ jsx(
|
|
338
336
|
Button,
|
|
339
337
|
{
|
|
340
338
|
type: "button",
|
|
341
339
|
variant: "outline",
|
|
342
340
|
size: "icon-sm",
|
|
343
|
-
disabled: nextDisabled,
|
|
341
|
+
disabled: nextDisabled(),
|
|
344
342
|
"aria-label": t("dataEntry.monthPicker.nextYear") ?? "Next year",
|
|
345
343
|
className: "ui-month-picker-nav-button",
|
|
346
344
|
onClick: () => setViewYear(viewYear + pageStep),
|
|
@@ -390,7 +388,7 @@ function DatePicker(props) {
|
|
|
390
388
|
showClose,
|
|
391
389
|
onClose: () => setOpen(false)
|
|
392
390
|
};
|
|
393
|
-
const
|
|
391
|
+
const renderDayPanel = () => range ? /* @__PURE__ */ jsx(
|
|
394
392
|
Calendar,
|
|
395
393
|
{
|
|
396
394
|
mode: "range",
|
|
@@ -458,7 +456,7 @@ function DatePicker(props) {
|
|
|
458
456
|
type: "button",
|
|
459
457
|
disabled: allDisabled,
|
|
460
458
|
tabIndex: -1,
|
|
461
|
-
"aria-label": (isPeriod ? t("dataEntry.monthPicker.openGrid") : range ? t("dataEntry.dateRangePicker.openCalendar") : t("dataEntry.datePicker.openCalendar")) ?? "Open calendar",
|
|
459
|
+
"aria-label": triggerLabel ?? (isPeriod ? t("dataEntry.monthPicker.openGrid") : range ? t("dataEntry.dateRangePicker.openCalendar") : t("dataEntry.datePicker.openCalendar")) ?? "Open calendar",
|
|
462
460
|
className: range ? "text-muted-foreground hover:text-foreground shrink-0" : "ui-control-inline-affix-action",
|
|
463
461
|
children: /* @__PURE__ */ jsx(
|
|
464
462
|
CalendarIcon,
|
|
@@ -502,7 +500,7 @@ function DatePicker(props) {
|
|
|
502
500
|
},
|
|
503
501
|
index
|
|
504
502
|
)) }) : null,
|
|
505
|
-
isPeriod ?
|
|
503
|
+
isPeriod ? renderPeriodPanel() : renderDayPanel(),
|
|
506
504
|
showTime ? /* @__PURE__ */ jsx(Flex, { pad: "sm", children: /* @__PURE__ */ jsx(
|
|
507
505
|
TimePicker,
|
|
508
506
|
{
|
|
@@ -7,6 +7,7 @@ import { cn } from "../../lib/utils.js";
|
|
|
7
7
|
import { pickFieldA11y } from "../../lib/field-a11y.js";
|
|
8
8
|
import { Button } from "../general/button.js";
|
|
9
9
|
import { Input } from "./input.js";
|
|
10
|
+
import { numberFormat } from "../../lib/intl-cache.js";
|
|
10
11
|
function decimalsOf(n) {
|
|
11
12
|
if (!Number.isFinite(n)) return 0;
|
|
12
13
|
const s = String(n);
|
|
@@ -77,7 +78,7 @@ const NumberInput = React.forwardRef(
|
|
|
77
78
|
const numericValue = isControlled ? controlledValue ?? null : internal;
|
|
78
79
|
const effectivePrecision = precision ?? decimalsOf(step);
|
|
79
80
|
const intlFormatter = React.useMemo(
|
|
80
|
-
() =>
|
|
81
|
+
() => numberFormat(locale, {
|
|
81
82
|
minimumFractionDigits: 0,
|
|
82
83
|
maximumFractionDigits: Math.max(effectivePrecision, 0),
|
|
83
84
|
useGrouping: false
|
|
@@ -6,8 +6,8 @@ export { TwoFactorSetup } from "./two-factor-setup.js";
|
|
|
6
6
|
export type { TwoFactorSetupLabels, TwoFactorSetupProps } from "./two-factor-setup.js";
|
|
7
7
|
export { Toaster } from "./sonner.js";
|
|
8
8
|
export { toast } from "./use-toast.js";
|
|
9
|
-
export { Skeleton, SkeletonRows, SkeletonTable, SkeletonDetail, SkeletonStat, SkeletonArticle, SkeletonAvatar, SkeletonButton, SkeletonInput, SkeletonNode, SkeletonImage, } from "./skeleton.js";
|
|
10
|
-
export type { SkeletonProp, SkeletonProps, SkeletonWidth, SkeletonArticleProp, SkeletonArticleProps, SkeletonAvatarProp, SkeletonAvatarProps, SkeletonButtonProp, SkeletonButtonProps, SkeletonInputProp, SkeletonInputProps, SkeletonNodeProp, SkeletonNodeProps, SkeletonImageProp, SkeletonImageProps, } from "./skeleton.js";
|
|
9
|
+
export { Skeleton, SkeletonForm, SkeletonRows, SkeletonTable, SkeletonDetail, SkeletonStat, SkeletonArticle, SkeletonAvatar, SkeletonButton, SkeletonInput, SkeletonNode, SkeletonImage, } from "./skeleton.js";
|
|
10
|
+
export type { SkeletonProp, SkeletonProps, SkeletonWidth, SkeletonArticleProp, SkeletonArticleProps, SkeletonAvatarProp, SkeletonAvatarProps, SkeletonButtonProp, SkeletonButtonProps, SkeletonFormProp, SkeletonFormProps, SkeletonInputProp, SkeletonInputProps, SkeletonNodeProp, SkeletonNodeProps, SkeletonImageProp, SkeletonImageProps, } from "./skeleton.js";
|
|
11
11
|
export { Banner } from "./banner.js";
|
|
12
12
|
export type { BannerProp, BannerProps } from "./banner.js";
|
|
13
13
|
export { Alert, AlertTitle, AlertContent, AlertDescription, AlertActions, AlertQueryError, } from "./alert.js";
|
|
@@ -44,6 +44,7 @@ import { Toaster } from "./sonner.js";
|
|
|
44
44
|
import { toast } from "./use-toast.js";
|
|
45
45
|
import {
|
|
46
46
|
Skeleton,
|
|
47
|
+
SkeletonForm,
|
|
47
48
|
SkeletonRows,
|
|
48
49
|
SkeletonTable,
|
|
49
50
|
SkeletonDetail,
|
|
@@ -114,6 +115,7 @@ export {
|
|
|
114
115
|
SkeletonAvatar,
|
|
115
116
|
SkeletonButton,
|
|
116
117
|
SkeletonDetail,
|
|
118
|
+
SkeletonForm,
|
|
117
119
|
SkeletonImage,
|
|
118
120
|
SkeletonInput,
|
|
119
121
|
SkeletonNode,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import type { SkeletonArticleProp, SkeletonAvatarProp, SkeletonButtonProp, SkeletonImageProp, SkeletonInputProp, SkeletonNodeProp, SkeletonProp } from "../../props/components/feedback.prop.js";
|
|
3
|
-
export type { SkeletonProp, SkeletonProp as SkeletonProps, SkeletonWidth, SkeletonArticleProp, SkeletonArticleProp as SkeletonArticleProps, SkeletonAvatarProp, SkeletonAvatarProp as SkeletonAvatarProps, SkeletonButtonProp, SkeletonButtonProp as SkeletonButtonProps, SkeletonInputProp, SkeletonInputProp as SkeletonInputProps, SkeletonNodeProp, SkeletonNodeProp as SkeletonNodeProps, SkeletonImageProp, SkeletonImageProp as SkeletonImageProps, } from "../../props/components/feedback.prop.js";
|
|
2
|
+
import type { SkeletonArticleProp, SkeletonAvatarProp, SkeletonButtonProp, SkeletonFormProp, SkeletonImageProp, SkeletonInputProp, SkeletonNodeProp, SkeletonProp } from "../../props/components/feedback.prop.js";
|
|
3
|
+
export type { SkeletonProp, SkeletonProp as SkeletonProps, SkeletonFormProp, SkeletonFormProp as SkeletonFormProps, SkeletonWidth, SkeletonArticleProp, SkeletonArticleProp as SkeletonArticleProps, SkeletonAvatarProp, SkeletonAvatarProp as SkeletonAvatarProps, SkeletonButtonProp, SkeletonButtonProp as SkeletonButtonProps, SkeletonInputProp, SkeletonInputProp as SkeletonInputProps, SkeletonNodeProp, SkeletonNodeProp as SkeletonNodeProps, SkeletonImageProp, SkeletonImageProp as SkeletonImageProps, } from "../../props/components/feedback.prop.js";
|
|
4
4
|
declare function SkeletonBlock({ active, loading, className, children, ...props }: SkeletonProp): React.JSX.Element;
|
|
5
5
|
/** Stands in for an `Avatar` — circle for a person, square for an entity mark. */
|
|
6
6
|
export declare function SkeletonAvatar({ size, shape, active, className }: SkeletonAvatarProp): React.JSX.Element;
|
|
@@ -39,6 +39,15 @@ interface SkeletonRowsProps {
|
|
|
39
39
|
}
|
|
40
40
|
/** Skeleton for a flat list of rows (use inside a Card or section). */
|
|
41
41
|
export declare function SkeletonRows({ rows, columns, className }: SkeletonRowsProps): React.JSX.Element;
|
|
42
|
+
/**
|
|
43
|
+
* Skeleton of a `Form columns={N}` — label + control PAIRS on the form's own grid (gh#552).
|
|
44
|
+
*
|
|
45
|
+
* It renders through `ResponsiveGrid` with the SAME `columns` value the form takes, so the two
|
|
46
|
+
* share one breakpoint ladder rather than two that agree today. `SkeletonRows` could get the column
|
|
47
|
+
* count right and never the inside of a cell: a form field is a short label stacked on a full-width
|
|
48
|
+
* control, and a flat line list is neither.
|
|
49
|
+
*/
|
|
50
|
+
export declare function SkeletonForm({ columns, fields, active, className }: SkeletonFormProp): React.JSX.Element;
|
|
42
51
|
/** Skeleton matching the DataTable layout — header row + N body rows. */
|
|
43
52
|
export declare function SkeletonTable({ rows, columns }: SkeletonRowsProps): React.JSX.Element;
|
|
44
53
|
/** Skeleton matching a Card detail layout — title + 6 metadata rows. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { ImageIcon } from "lucide-react";
|
|
3
|
+
import { ResponsiveGrid } from "../layout/responsive-grid.js";
|
|
3
4
|
import { cn } from "../../lib/utils.js";
|
|
4
5
|
import { tableCellPaddingClass, tableRowHeightClass } from "../../lib/control-styles.js";
|
|
5
6
|
function measureStyle(width) {
|
|
@@ -169,6 +170,12 @@ function SkeletonRows({ rows = 6, columns = 4, className }) {
|
|
|
169
170
|
j
|
|
170
171
|
)) }, i)) });
|
|
171
172
|
}
|
|
173
|
+
function SkeletonForm({ columns = 1, fields = 6, active, className }) {
|
|
174
|
+
return /* @__PURE__ */ jsx("div", { className: cn("ui-skeleton-form", className), "aria-busy": "true", children: /* @__PURE__ */ jsx(ResponsiveGrid, { columns, children: Array.from({ length: fields }).map((_, i) => /* @__PURE__ */ jsxs("div", { className: "ui-skeleton-form-field", children: [
|
|
175
|
+
/* @__PURE__ */ jsx(Skeleton, { active, className: "ui-skeleton-form-label" }),
|
|
176
|
+
/* @__PURE__ */ jsx(Skeleton, { active, className: "ui-skeleton-form-control" })
|
|
177
|
+
] }, i)) }) });
|
|
178
|
+
}
|
|
172
179
|
function SkeletonTable({ rows = 8, columns = 5 }) {
|
|
173
180
|
return /* @__PURE__ */ jsxs("div", { className: "ui-skeleton-table", "aria-busy": "true", children: [
|
|
174
181
|
/* @__PURE__ */ jsx("div", { className: cn("ui-skeleton-table-head", tableCellPaddingClass, tableRowHeightClass), children: Array.from({ length: columns }).map((_, j) => /* @__PURE__ */ jsx(Skeleton, { className: cn("ui-skeleton-caption", j === 0 ? "w-1/5" : "flex-1") }, j)) }),
|
|
@@ -205,6 +212,7 @@ export {
|
|
|
205
212
|
SkeletonAvatar,
|
|
206
213
|
SkeletonButton,
|
|
207
214
|
SkeletonDetail,
|
|
215
|
+
SkeletonForm,
|
|
208
216
|
SkeletonImage,
|
|
209
217
|
SkeletonInput,
|
|
210
218
|
SkeletonNode,
|
|
@@ -6,6 +6,7 @@ import { cva } from "class-variance-authority";
|
|
|
6
6
|
import { Loader2 } from "lucide-react";
|
|
7
7
|
import { cn } from "../../lib/utils.js";
|
|
8
8
|
import { useTranslation } from "../../i18n/use-translation.js";
|
|
9
|
+
import { numberFormat } from "../../lib/intl-cache.js";
|
|
9
10
|
const buttonVariants = cva("ui-button", {
|
|
10
11
|
variants: {
|
|
11
12
|
variant: {
|
|
@@ -78,7 +79,7 @@ const Button = React.forwardRef(
|
|
|
78
79
|
loadingText ?? children
|
|
79
80
|
] }) : children;
|
|
80
81
|
const showCount = !asChild && count != null && (count !== 0 || showZero);
|
|
81
|
-
const countLabel = showCount && count != null && count > overflowCount ? `${
|
|
82
|
+
const countLabel = showCount && count != null && count > overflowCount ? `${numberFormat(locale).format(overflowCount)}+` : count != null ? numberFormat(locale).format(count) : "";
|
|
82
83
|
const countNode = showCount ? /* @__PURE__ */ jsx("span", { "data-slot": "button-count", className: "ui-button-count", children: countLabel }) : null;
|
|
83
84
|
return /* @__PURE__ */ jsx(
|
|
84
85
|
Comp,
|
|
@@ -8,6 +8,7 @@ import { EmptyState } from "../data-display/empty-state.js";
|
|
|
8
8
|
import { Progress } from "../data-display/progress.js";
|
|
9
9
|
import { Text } from "../general/typography.js";
|
|
10
10
|
import { CenteredShell } from "./centered-shell.js";
|
|
11
|
+
import { dateTimeFormat, numberFormat } from "../../lib/intl-cache.js";
|
|
11
12
|
const STATUS_META = {
|
|
12
13
|
400: { icon: TriangleAlert, tone: "warning" },
|
|
13
14
|
403: { icon: ShieldAlert, tone: "warning" },
|
|
@@ -37,7 +38,7 @@ function formatMaintenanceWindow(maintenance, locale) {
|
|
|
37
38
|
};
|
|
38
39
|
const start = new Date(maintenance.start);
|
|
39
40
|
if (Number.isNaN(start.getTime())) return maintenance.start;
|
|
40
|
-
const formatter =
|
|
41
|
+
const formatter = dateTimeFormat(locale, options);
|
|
41
42
|
if (maintenance.end === void 0) return formatter.format(start);
|
|
42
43
|
const end = new Date(maintenance.end);
|
|
43
44
|
if (Number.isNaN(end.getTime())) return formatter.format(start);
|
|
@@ -72,7 +73,7 @@ const ErrorSurface = React.forwardRef(
|
|
|
72
73
|
const maintenanceProgress = maintenance?.progress;
|
|
73
74
|
const hasProgress = typeof maintenanceProgress === "number" && Number.isFinite(maintenanceProgress);
|
|
74
75
|
const progressLabel = hasProgress ? t("layout.errorSurface.maintenanceProgress", {
|
|
75
|
-
percent:
|
|
76
|
+
percent: numberFormat(locale, {
|
|
76
77
|
style: "percent",
|
|
77
78
|
maximumFractionDigits: 0
|
|
78
79
|
}).format(Math.max(0, Math.min(100, maintenanceProgress)) / 100)
|
|
@@ -5,6 +5,7 @@ import { ToggleButton } from "react-aria-components";
|
|
|
5
5
|
import { cva } from "class-variance-authority";
|
|
6
6
|
import { cn } from "../../lib/utils.js";
|
|
7
7
|
import { useTranslation } from "../../i18n/use-translation.js";
|
|
8
|
+
import { numberFormat } from "../../lib/intl-cache.js";
|
|
8
9
|
const toggleVariants = cva("ui-toggle", {
|
|
9
10
|
variants: {
|
|
10
11
|
variant: {
|
|
@@ -33,7 +34,7 @@ function useCounterPill({
|
|
|
33
34
|
const visible = count != null && (count !== 0 || showZero);
|
|
34
35
|
const formatted = React.useMemo(() => {
|
|
35
36
|
if (count == null) return "";
|
|
36
|
-
const format =
|
|
37
|
+
const format = numberFormat(locale);
|
|
37
38
|
return count > overflowCount ? `${format.format(overflowCount)}+` : format.format(count);
|
|
38
39
|
}, [count, locale, overflowCount]);
|
|
39
40
|
if (!visible) {
|
package/dist/i18n/translate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import en from "./messages/en.json" with { type: "json" };
|
|
2
2
|
import ja from "./messages/ja.json" with { type: "json" };
|
|
3
3
|
import vi from "./messages/vi.json" with { type: "json" };
|
|
4
|
+
import { numberFormat, pluralRules } from "../lib/intl-cache.js";
|
|
4
5
|
const MESSAGE_CATALOG = {
|
|
5
6
|
vi,
|
|
6
7
|
en,
|
|
@@ -56,7 +57,7 @@ function selectPlural(value, locale, params) {
|
|
|
56
57
|
if (count === void 0) {
|
|
57
58
|
return value.other ?? Object.values(value)[0] ?? "";
|
|
58
59
|
}
|
|
59
|
-
const category =
|
|
60
|
+
const category = pluralRules(locale).select(count);
|
|
60
61
|
return value[category] ?? value.other ?? Object.values(value)[0] ?? "";
|
|
61
62
|
}
|
|
62
63
|
function interpolate(template, locale, params) {
|
|
@@ -64,7 +65,7 @@ function interpolate(template, locale, params) {
|
|
|
64
65
|
return Object.entries(params).reduce(
|
|
65
66
|
(text, [key, value]) => text.replaceAll(
|
|
66
67
|
`{${key}}`,
|
|
67
|
-
typeof value === "number" ?
|
|
68
|
+
typeof value === "number" ? numberFormat(locale).format(value) : String(value)
|
|
68
69
|
),
|
|
69
70
|
template
|
|
70
71
|
);
|
|
@@ -9,10 +9,11 @@ import {
|
|
|
9
9
|
startOfYear
|
|
10
10
|
} from "date-fns";
|
|
11
11
|
import { parseDateInput, toIsoDate } from "./parse.js";
|
|
12
|
+
import { dateTimeFormat } from "../intl-cache.js";
|
|
12
13
|
function formatPickerDate(date, format, locale, withTime = false) {
|
|
13
14
|
if (!date || !isValid(date)) return "";
|
|
14
15
|
if (typeof format === "function") return format(date);
|
|
15
|
-
if (typeof format === "object") return
|
|
16
|
+
if (typeof format === "object") return dateTimeFormat(locale, format).format(date);
|
|
16
17
|
if (typeof format === "string") return formatDate(date, format);
|
|
17
18
|
return withTime ? formatDate(date, "yyyy-MM-dd HH:mm") : toIsoDate(date);
|
|
18
19
|
}
|
package/dist/lib/format.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { getSyncedLocale, translateCurrent } from "../i18n/translate.js";
|
|
2
|
+
import { numberFormat } from "./intl-cache.js";
|
|
2
3
|
function formatBytes(n, locale = getSyncedLocale()) {
|
|
3
4
|
if (n == null) return "\u2014";
|
|
4
|
-
const num = (digits, scaled) =>
|
|
5
|
+
const num = (digits, scaled) => numberFormat(locale, {
|
|
5
6
|
minimumFractionDigits: digits,
|
|
6
7
|
maximumFractionDigits: digits
|
|
7
8
|
}).format(scaled);
|
|
@@ -12,7 +13,7 @@ function formatBytes(n, locale = getSyncedLocale()) {
|
|
|
12
13
|
}
|
|
13
14
|
function formatCurrency(amountMinor, currency, locale = getSyncedLocale()) {
|
|
14
15
|
if (amountMinor == null || !currency) return "\u2014";
|
|
15
|
-
const formatter =
|
|
16
|
+
const formatter = numberFormat(locale, { style: "currency", currency });
|
|
16
17
|
const minorUnitDigits = formatter.resolvedOptions().maximumFractionDigits ?? 2;
|
|
17
18
|
const major = amountMinor / Math.pow(10, minorUnitDigits);
|
|
18
19
|
return formatter.format(major);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memoised `Intl` formatters, keyed by locale + options.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS, AS A MEASUREMENT (gh#557). A consumer ported a legacy CakePHP grid that keeps
|
|
5
|
+
* every row in the DOM — 8,262 rows, no virtualisation available — and measured 27-30s against
|
|
6
|
+
* 2.3s for the same markup built from native `<input>` / `<select>`. They had already ruled out
|
|
7
|
+
* the obvious suspect: deleting `Popover` / `PopoverContent` from `DatePicker` entirely changed
|
|
8
|
+
* nothing.
|
|
9
|
+
*
|
|
10
|
+
* Rendered here through `react-dom/server`, 2,000 rows at a time, one CLOSED `DatePicker` costs
|
|
11
|
+
* **412.6us** against **4.2us** for `<input type="date">` — 98x. Counting constructor calls during
|
|
12
|
+
* that render, each closed instance built:
|
|
13
|
+
*
|
|
14
|
+
* 12 x new Intl.DateTimeFormat
|
|
15
|
+
* 1 x new Intl.NumberFormat
|
|
16
|
+
*
|
|
17
|
+
* Constructing an `Intl` formatter is one of the most expensive things a JS engine does: it
|
|
18
|
+
* resolves locale data and compiles a pattern. Routing those same constructions through this
|
|
19
|
+
* cache, changing nothing else, took the instance from **412.6us to 178us — a 57% cut**. For the
|
|
20
|
+
* reporter's grid that is roughly 99,000 formatter constructions that no longer happen.
|
|
21
|
+
*
|
|
22
|
+
* WHY A SHARED MODULE AND NOT A `useMemo` AT EACH CALL SITE. `useMemo` memoises per INSTANCE, and
|
|
23
|
+
* the instance is exactly what there are 8,262 of. Two rows formatting dates in the same locale
|
|
24
|
+
* want the same formatter object, and nothing below module scope can give them one.
|
|
25
|
+
*
|
|
26
|
+
* SAFETY. `Intl` formatters are immutable and stateless — `format()` mutates nothing — so a single
|
|
27
|
+
* instance is safely shared across every caller and every React tree, concurrent SSR included.
|
|
28
|
+
*/
|
|
29
|
+
export declare function dateTimeFormat(locale: string, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat;
|
|
30
|
+
export declare function numberFormat(locale: string, options?: Intl.NumberFormatOptions): Intl.NumberFormat;
|
|
31
|
+
export declare function pluralRules(locale: string, options?: Intl.PluralRulesOptions): Intl.PluralRules;
|
|
32
|
+
export declare function listFormat(locale: string, options?: Intl.ListFormatOptions): Intl.ListFormat;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const MAX_ENTRIES = 256;
|
|
2
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3
|
+
function memoise(kind, locale, options, make) {
|
|
4
|
+
const key = `${kind} ${locale} ${options === void 0 ? "" : JSON.stringify(options)}`;
|
|
5
|
+
const hit = cache.get(key);
|
|
6
|
+
if (hit !== void 0) return hit;
|
|
7
|
+
const made = make();
|
|
8
|
+
if (cache.size >= MAX_ENTRIES) cache.delete(cache.keys().next().value);
|
|
9
|
+
cache.set(key, made);
|
|
10
|
+
return made;
|
|
11
|
+
}
|
|
12
|
+
function dateTimeFormat(locale, options) {
|
|
13
|
+
return memoise("dtf", locale, options, () => new Intl.DateTimeFormat(locale, options));
|
|
14
|
+
}
|
|
15
|
+
function numberFormat(locale, options) {
|
|
16
|
+
return memoise("nf", locale, options, () => new Intl.NumberFormat(locale, options));
|
|
17
|
+
}
|
|
18
|
+
function pluralRules(locale, options) {
|
|
19
|
+
return memoise("pr", locale, options, () => new Intl.PluralRules(locale, options));
|
|
20
|
+
}
|
|
21
|
+
function listFormat(locale, options) {
|
|
22
|
+
return memoise("lf", locale, options, () => new Intl.ListFormat(locale, options));
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
dateTimeFormat,
|
|
26
|
+
listFormat,
|
|
27
|
+
numberFormat,
|
|
28
|
+
pluralRules
|
|
29
|
+
};
|
|
@@ -778,6 +778,17 @@ export type PickerDateFormatProp = string | Intl.DateTimeFormatOptions | ((date:
|
|
|
778
778
|
* `value`/`defaultValue`/`onValueChange` rather than antd's `onChange`.
|
|
779
779
|
*/
|
|
780
780
|
export type DatePickerBaseProp = FieldA11yProps & PickerChromeProp & {
|
|
781
|
+
/**
|
|
782
|
+
* The accessible NAME of the button that opens the calendar, for a screen where more than one
|
|
783
|
+
* date field exists. Default 「カレンダーを開く」 / "Open calendar" — correct for one picker on a
|
|
784
|
+
* page, and useless for three: a reader hears the same sentence three times with nothing to
|
|
785
|
+
* say which field each button belongs to (WCAG 2.2 SC 2.4.6, gh#551).
|
|
786
|
+
*
|
|
787
|
+
* Same axis, same shape, same reason as Select's `clearLabel`: name the control after the
|
|
788
|
+
* FIELD it serves — `開始日のカレンダーを開く`. The default is unchanged, so nothing moves until
|
|
789
|
+
* a caller says something.
|
|
790
|
+
*/
|
|
791
|
+
triggerLabel?: string;
|
|
781
792
|
/** Display format; native submission remains ISO. */
|
|
782
793
|
format?: PickerDateFormatProp;
|
|
783
794
|
/** Parser for a custom display function or Intl era display; ISO always remains accepted. */
|
|
@@ -83,6 +83,25 @@ export type SkeletonRowsProp = {
|
|
|
83
83
|
rows?: number;
|
|
84
84
|
columns?: number;
|
|
85
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* The skeleton of a `Form columns={N}` — label + control PAIRS on the same grid the form uses,
|
|
88
|
+
* not the flat line list `SkeletonRows` draws (gh#552).
|
|
89
|
+
*
|
|
90
|
+
* Why it is its own component rather than `SkeletonRows` with better defaults: `SkeletonRows` has
|
|
91
|
+
* no idea a form field is two stacked things, so a consumer approximating one got the column count
|
|
92
|
+
* right and the inside of every cell wrong. Worse, "close enough" is a thing that DRIFTS — change
|
|
93
|
+
* the form's `columns` and forget the skeleton and the layout jumps again on load, with nothing
|
|
94
|
+
* red to say so. Sharing `ResponsiveGrid` is what stops that: one `columns` value, one ladder.
|
|
95
|
+
*/
|
|
96
|
+
export type SkeletonFormProp = {
|
|
97
|
+
/** Columns of the form this stands in for — passed straight to `ResponsiveGrid`. */
|
|
98
|
+
columns?: number;
|
|
99
|
+
/** How many label+control pairs to draw. */
|
|
100
|
+
fields?: number;
|
|
101
|
+
/** Shimmer, matching the rest of the family. */
|
|
102
|
+
active?: boolean;
|
|
103
|
+
className?: string;
|
|
104
|
+
};
|
|
86
105
|
/**
|
|
87
106
|
* A skeleton line's MEASURE. `number` is read as pixels, matching antd's
|
|
88
107
|
* `SkeletonParagraphProps["width"]` (components/skeleton/Paragraph.tsx); a string is any CSS length
|
package/dist/props/registry.d.ts
CHANGED
|
@@ -1955,6 +1955,11 @@ export declare const COMPONENT_PROP_REGISTRY: {
|
|
|
1955
1955
|
readonly file: "components/feedback.prop.ts";
|
|
1956
1956
|
readonly vocabulary: readonly ["SizeProp", "ShapeProp", "ClassNameProp"];
|
|
1957
1957
|
};
|
|
1958
|
+
readonly SkeletonFormProp: {
|
|
1959
|
+
readonly group: "feedback";
|
|
1960
|
+
readonly file: "components/feedback.prop.ts";
|
|
1961
|
+
readonly vocabulary: readonly ["ClassNameProp"];
|
|
1962
|
+
};
|
|
1958
1963
|
readonly SkeletonInputProp: {
|
|
1959
1964
|
readonly group: "feedback";
|
|
1960
1965
|
readonly file: "components/feedback.prop.ts";
|
package/dist/props/registry.js
CHANGED
|
@@ -2254,6 +2254,11 @@ const COMPONENT_PROP_REGISTRY = {
|
|
|
2254
2254
|
file: "components/feedback.prop.ts",
|
|
2255
2255
|
vocabulary: ["SizeProp", "ShapeProp", "ClassNameProp"]
|
|
2256
2256
|
},
|
|
2257
|
+
SkeletonFormProp: {
|
|
2258
|
+
group: "feedback",
|
|
2259
|
+
file: "components/feedback.prop.ts",
|
|
2260
|
+
vocabulary: ["ClassNameProp"]
|
|
2261
|
+
},
|
|
2257
2262
|
SkeletonInputProp: {
|
|
2258
2263
|
group: "feedback",
|
|
2259
2264
|
file: "components/feedback.prop.ts",
|
|
@@ -216,6 +216,28 @@
|
|
|
216
216
|
gap: var(--skeleton-cell-gap);
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
.ui-skeleton-form-field {
|
|
220
|
+
display: grid;
|
|
221
|
+
grid-template-columns: minmax(0, 1fr);
|
|
222
|
+
gap: var(--space-stack-xs);
|
|
223
|
+
min-inline-size: 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.ui-skeleton-form-label {
|
|
227
|
+
block-size: var(--skeleton-caption-height);
|
|
228
|
+
inline-size: var(--skeleton-label-width);
|
|
229
|
+
max-inline-size: 100%;
|
|
230
|
+
border-radius: var(--skeleton-radius);
|
|
231
|
+
background: var(--skeleton-background, hsl(var(--muted)));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.ui-skeleton-form-control {
|
|
235
|
+
block-size: var(--control-height);
|
|
236
|
+
inline-size: 100%;
|
|
237
|
+
border-radius: var(--skeleton-element-radius);
|
|
238
|
+
background: var(--skeleton-background, hsl(var(--muted)));
|
|
239
|
+
}
|
|
240
|
+
|
|
219
241
|
.ui-skeleton-table {
|
|
220
242
|
overflow: hidden;
|
|
221
243
|
border: 1px solid hsl(var(--border));
|
|
@@ -329,6 +329,10 @@
|
|
|
329
329
|
padding: var(--card-space-body-y) var(--card-space-inset);
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
+
[data-slot="card-content"][data-flush] [data-slot="tabs-panel"] {
|
|
333
|
+
padding-inline: 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
332
336
|
[data-slot="card-footer"] {
|
|
333
337
|
display: flex;
|
|
334
338
|
flex-wrap: wrap;
|
|
@@ -39,7 +39,17 @@
|
|
|
39
39
|
|
|
40
40
|
@container responsive-grid (min-width: 40rem) {
|
|
41
41
|
.ui-responsive-grid > .ui-form-field {
|
|
42
|
-
grid-column: span var(--form-field-col-span, 1);
|
|
42
|
+
grid-column: span min(var(--responsive-grid-sm, 1), var(--form-field-col-span, 1));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
@container responsive-grid (min-width: 48rem) {
|
|
46
|
+
.ui-responsive-grid > .ui-form-field {
|
|
47
|
+
grid-column: span min(var(--responsive-grid-md, 1), var(--form-field-col-span, 1));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
@container responsive-grid (min-width: 64rem) {
|
|
51
|
+
.ui-responsive-grid > .ui-form-field {
|
|
52
|
+
grid-column: span min(var(--responsive-grid-lg, 1), var(--form-field-col-span, 1));
|
|
43
53
|
}
|
|
44
54
|
}
|
|
45
55
|
|
package/dist/tokens/axes.css
CHANGED
|
@@ -108,9 +108,10 @@
|
|
|
108
108
|
transparent 70%
|
|
109
109
|
);
|
|
110
110
|
|
|
111
|
-
--focus-outline:
|
|
111
|
+
--focus-outline: 1;
|
|
112
112
|
--focus-outline-weight: var(--stroke-hairline);
|
|
113
113
|
--focus-outline-color: var(--focus-ring-color, var(--ring));
|
|
114
|
+
|
|
114
115
|
--control-outline-width: calc(var(--stroke-md) * var(--focus-outline));
|
|
115
116
|
--focus-outline-offset: 0px;
|
|
116
117
|
|
|
@@ -125,7 +126,8 @@
|
|
|
125
126
|
--focus-ring-glow-color: var(--control-outline);
|
|
126
127
|
--focus-ring-glow-alpha: var(--control-outline-alpha);
|
|
127
128
|
|
|
128
|
-
--focus-field-shadow:
|
|
129
|
+
--focus-field-shadow: 0 0 0 var(--focus-ring-glow-width)
|
|
130
|
+
hsl(var(--focus-ring-glow-color) / var(--focus-ring-glow-alpha));
|
|
129
131
|
|
|
130
132
|
--focus-ring-offset: var(--focus-outline-offset);
|
|
131
133
|
|
|
@@ -255,7 +257,6 @@
|
|
|
255
257
|
--activity-interval: var(--duration-loop);
|
|
256
258
|
|
|
257
259
|
--activity-stagger-step: 160ms;
|
|
258
|
-
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
.ui-scale-fixed {
|
|
@@ -306,7 +307,6 @@
|
|
|
306
307
|
--space-chrome-y: var(--space-4);
|
|
307
308
|
--space-chrome-gap: var(--space-2);
|
|
308
309
|
--field-label-gap: var(--space-2);
|
|
309
|
-
|
|
310
310
|
}
|
|
311
311
|
|
|
312
312
|
.dark,
|
package/docs/DESIGN-AUTHORITY.md
CHANGED
|
@@ -12,17 +12,17 @@ It changes no code by itself. It is the tie-breaker a reviewer points at.
|
|
|
12
12
|
|
|
13
13
|
## The layers, and who owns each
|
|
14
14
|
|
|
15
|
-
| Layer | Authority
|
|
16
|
-
| ------------------------------------------------------------------ |
|
|
17
|
-
| Interaction semantics, keyboard, ARIA | **WAI-ARIA APG**
|
|
18
|
-
| Behaviour primitives | **Radix**
|
|
19
|
-
| Component composition shape | **shadcn**
|
|
20
|
-
| Component taxonomy / grouping | **Ant Design** groups
|
|
21
|
-
| Colour foundation | **SmartHR**
|
|
22
|
-
| **Derived colour — the interaction states hanging off each seed**
|
|
23
|
-
| **Japanese UI convention — density, JP typography, form patterns** | **SmartHR**
|
|
24
|
-
| **Japanese accessibility / public-sector convention** | **デジタル庁 Design System** (Digital Agency)
|
|
25
|
-
| **Spacing, density, type scale, information architecture** | **IBM Carbon**
|
|
15
|
+
| Layer | Authority | Status in this repo |
|
|
16
|
+
| ------------------------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| Interaction semantics, keyboard, ARIA | **WAI-ARIA APG** | already followed — 33 references in `src/` |
|
|
18
|
+
| Behaviour primitives | **Radix** | already the implementation — 193 references |
|
|
19
|
+
| Component composition shape | **shadcn** | already the structural convention — 23 references |
|
|
20
|
+
| Component taxonomy / grouping | **Ant Design** groups | already the catalog shape: `data-entry`, `data-display`, `layout`, `feedback`, `navigation`, `general` — a naming precedent, nothing is installed |
|
|
21
|
+
| Colour foundation | **SmartHR** | already the palette source — `--primary` = SmartHR MAIN `#0071bd`, `--foreground` = TEXT_BLACK, `--border` = BORDER |
|
|
22
|
+
| **Derived colour — the interaction states hanging off each seed** | **Measured contrast (WCAG 2.2 / JIS X 8341-3)** | Authored in `src/tokens/derived.css`; no algorithm derives them. Four contrast suites read that file and hold every value to a threshold — see below |
|
|
23
|
+
| **Japanese UI convention — density, JP typography, form patterns** | **SmartHR** | **NEW — this decision.** Extends SmartHR from "where the colours came from" to the authority for how a JP business screen behaves |
|
|
24
|
+
| **Japanese accessibility / public-sector convention** | **デジタル庁 Design System** (Digital Agency) | **NEW — this decision.** The reference when a JP customer asks which standard a screen meets (JIS X 8341-3) |
|
|
25
|
+
| **Spacing, density, type scale, information architecture** | **IBM Carbon** | **NEW — this decision** |
|
|
26
26
|
|
|
27
27
|
The first five were already true and merely unwritten. The last three are the choices being made
|
|
28
28
|
here. Carbon fills the one layer that had no outside answer at all: page rhythm, table density, form layout,
|
|
@@ -197,10 +197,34 @@ rows or a totals row — which is how a consumer ends up hand-rolling a `<tfoot>
|
|
|
197
197
|
in page CSS.
|
|
198
198
|
|
|
199
199
|
**The rule: where antd names a capability, this library takes antd's name and antd's semantics.**
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
|
|
201
|
+
> **Restated and widened by the repo owner on 2026-09-12, because the rule above was being read as
|
|
202
|
+
> advice rather than as the standard it is: Ant Design IS the standard. A missing capability is
|
|
203
|
+
> ported from antd 100% FIRST — its names, its props, its semantics — and improved afterwards.
|
|
204
|
+
> Not redesigned first, and not half-ported.**
|
|
205
|
+
>
|
|
206
|
+
> Two things forced the restatement, both measured rather than felt:
|
|
207
|
+
>
|
|
208
|
+
> - `Dialog` + `AlertDialog` ship **26** exports with **12 name-for-name pairs and 0 parts unique
|
|
209
|
+
> to `AlertDialog`**, whose entire difference is `role="alertdialog"` plus `isDismissable={false}`
|
|
210
|
+
> — two props the shell already takes. antd has exactly one `Modal`, where danger is `okType`
|
|
211
|
+
> and `Modal.confirm()`. The shape here came from Radix, silently, against this very rule
|
|
212
|
+
> (gh#567). A package-wide sweep over all 272 public exports found this is the ONLY such pair:
|
|
213
|
+
> `Skeleton.Avatar/Button/Input/Image/Node` looks identical in shape but is antd's own naming,
|
|
214
|
+
> so it is compliance, not drift.
|
|
215
|
+
> - Whole families arrived half-ported: Ant Design X without `Conversations`, `Attachments`,
|
|
216
|
+
> `ThoughtChain`, `Welcome` or `Actions` (gh#559); `FloatButton` never at all (gh#558);
|
|
217
|
+
> `Typography` reduced to `Text` + `Heading`, with `Paragraph`, `Link`, `copyable`, `editable`,
|
|
218
|
+
> `mark`, `keyboard` and `italic` simply absent.
|
|
219
|
+
>
|
|
220
|
+
> A deviation from antd is still allowed — the three below are — but it must be WRITTEN DOWN at
|
|
221
|
+
> the point of deviation. An undocumented deviation is a bug, and gh#567 is what that bug costs:
|
|
222
|
+
> a consumer forced to pick between the right ARIA role and a form it needs, and an accessibility
|
|
223
|
+
> decision pushed onto the party least able to make it.
|
|
224
|
+
> The gap is read out of the INSTALLED types (`antd/es/table/interface.d.ts`,
|
|
225
|
+
> `antd/es/table/InternalTable.d.ts` and the `@rc-component/table` interface they extend) — never
|
|
226
|
+
> from memory, because antd's own names move between majors (`fixed: 'left'` is deprecated in favour
|
|
227
|
+
> of `start` inside rc-table itself).
|
|
204
228
|
|
|
205
229
|
> Đọc sau 20.0.0: bản major ấy đã **gỡ `antd` khỏi devDependencies** cùng máy sinh màu của nó, và
|
|
206
230
|
> `check:no-antd-runtime` canh cho nó không quay lại. Câu trên mô tả cách bề mặt prop này ĐƯỢC ĐỌC
|
|
@@ -240,12 +264,12 @@ to trust them had to.
|
|
|
240
264
|
**The authority is now the measurement, not the derivation.** Four suites read `derived.css`
|
|
241
265
|
directly and hold every value in it to a threshold this repo has already committed to:
|
|
242
266
|
|
|
243
|
-
| suite
|
|
244
|
-
|
|
|
267
|
+
| suite | what it holds |
|
|
268
|
+
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
245
269
|
| `src/tokens/__tests__/focus-ring-contrast.test.ts` | the focus mark in both switch positions — ≥3:1 (WCAG 2.2 SC 1.4.11) on every surface a control sits on, and the halo proven to be decoration rather than the indicator |
|
|
246
|
-
| `src/tokens/__tests__/interactive-fill-contrast.test.ts` | an interactive fill must clear **4.5:1** against the label sitting on it
|
|
247
|
-
| `src/tokens/__tests__/destructive-contrast.test.ts` | `--destructive-hover` / `--destructive-active` against the same bar
|
|
248
|
-
| `src/lib/__tests__/theme-tokens-css.test.ts` | the tier is actually loaded, and complete in both themes
|
|
270
|
+
| `src/tokens/__tests__/interactive-fill-contrast.test.ts` | an interactive fill must clear **4.5:1** against the label sitting on it |
|
|
271
|
+
| `src/tokens/__tests__/destructive-contrast.test.ts` | `--destructive-hover` / `--destructive-active` against the same bar |
|
|
272
|
+
| `src/lib/__tests__/theme-tokens-css.test.ts` | the tier is actually loaded, and complete in both themes |
|
|
249
273
|
|
|
250
274
|
The first three also pin each value as a literal, so an edit to `derived.css` alone turns CI red
|
|
251
275
|
rather than quietly retinting the library. **That is a stronger claim than the generator made, not
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godxjp/ui",
|
|
3
|
-
"version": "23.
|
|
4
|
-
"godxUiMcp": "23.
|
|
3
|
+
"version": "23.3.0",
|
|
4
|
+
"godxUiMcp": "23.3.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"sideEffects": false,
|
|
23
|
-
"description": "@godxjp/ui
|
|
23
|
+
"description": "@godxjp/ui \u2014 shared React UI framework (shadcn + Radix + Tailwind v4).",
|
|
24
24
|
"files": [
|
|
25
25
|
"LICENSE",
|
|
26
26
|
"dist",
|
|
@@ -397,7 +397,8 @@
|
|
|
397
397
|
"check:number-input-step-target": "node scripts/check-number-input-step-target.mjs",
|
|
398
398
|
"check:mcp-token-sync": "node scripts/gen-component-tokens.mjs --check",
|
|
399
399
|
"check:email-token-sync": "node scripts/gen-email-tokens.mjs --check",
|
|
400
|
-
"check:focus-ring-paint": "node scripts/check-focus-ring-paint.mjs"
|
|
400
|
+
"check:focus-ring-paint": "node scripts/check-focus-ring-paint.mjs",
|
|
401
|
+
"typecheck:mcp": "node node_modules/typescript-7/bin/tsc --noEmit -p mcp/tsconfig.json"
|
|
401
402
|
},
|
|
402
403
|
"peerDependencies": {
|
|
403
404
|
"@hookform/resolvers": "^5.2.0",
|
|
@@ -412,11 +413,26 @@
|
|
|
412
413
|
"zod": "^4.4.0"
|
|
413
414
|
},
|
|
414
415
|
"peerDependenciesMeta": {
|
|
415
|
-
"
|
|
416
|
+
"@hookform/resolvers": {
|
|
417
|
+
"optional": true
|
|
418
|
+
},
|
|
419
|
+
"@tanstack/react-query": {
|
|
416
420
|
"optional": true
|
|
417
421
|
},
|
|
418
422
|
"playwright": {
|
|
419
423
|
"optional": true
|
|
424
|
+
},
|
|
425
|
+
"react-hook-form": {
|
|
426
|
+
"optional": true
|
|
427
|
+
},
|
|
428
|
+
"react-router-dom": {
|
|
429
|
+
"optional": true
|
|
430
|
+
},
|
|
431
|
+
"recharts": {
|
|
432
|
+
"optional": true
|
|
433
|
+
},
|
|
434
|
+
"zod": {
|
|
435
|
+
"optional": true
|
|
420
436
|
}
|
|
421
437
|
},
|
|
422
438
|
"dependencies": {
|
package/scripts/_agent-setup.mjs
CHANGED
|
@@ -10,8 +10,17 @@
|
|
|
10
10
|
* The guarantee is now structural: a file that exists but cannot be read, parsed, or recognised is
|
|
11
11
|
* NEVER written to. We leave a `.godxjp-ui-suggested` sidecar next to it and say so.
|
|
12
12
|
*/
|
|
13
|
-
import { createHash } from "node:crypto";
|
|
14
|
-
import {
|
|
13
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
+
import {
|
|
15
|
+
chmodSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
statSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from "node:fs";
|
|
15
24
|
import { basename, dirname, join } from "node:path";
|
|
16
25
|
import { fileURLToPath } from "node:url";
|
|
17
26
|
|
|
@@ -205,9 +214,31 @@ function readJson(path) {
|
|
|
205
214
|
* write manufactured the precondition for the overwrite.
|
|
206
215
|
*/
|
|
207
216
|
function writeFileAtomic(path, data) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
217
|
+
// UNIQUE temp name. A fixed `${path}.godxjp-ui-tmp` is a shared mutable file: two installs
|
|
218
|
+
// running at once — a workspace installing packages in parallel is enough — write over each
|
|
219
|
+
// other's temp and `rename` whichever finished last.
|
|
220
|
+
const tmp = `${path}.godxjp-ui-tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
221
|
+
try {
|
|
222
|
+
writeFileSync(tmp, data);
|
|
223
|
+
// `rename` does NOT carry the target's permissions: the new file is born under the process
|
|
224
|
+
// umask. A `.mcp.json` the consumer had chmod 600 would come back 644 after the first sync,
|
|
225
|
+
// silently, because the CONTENT would be right. This happens on every write, not only under
|
|
226
|
+
// contention, which makes it the worse half of the two.
|
|
227
|
+
try {
|
|
228
|
+
chmodSync(tmp, statSync(path).mode & 0o7777);
|
|
229
|
+
} catch {
|
|
230
|
+
// No existing file (a create), or a filesystem that will not report/set the mode. Either
|
|
231
|
+
// way the default is correct and this must not abort the write.
|
|
232
|
+
}
|
|
233
|
+
renameSync(tmp, path);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
try {
|
|
236
|
+
unlinkSync(tmp);
|
|
237
|
+
} catch {
|
|
238
|
+
// Nothing to clean up.
|
|
239
|
+
}
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
211
242
|
}
|
|
212
243
|
|
|
213
244
|
/**
|
|
@@ -131,6 +131,20 @@ những thứ ấy trên PR. **Tới được bước này KHÔNG phải là đ
|
|
|
131
131
|
một câu hỏi, không phải một chướng ngại. Nếu bạn tin cổng ấy sai thì nói ra và
|
|
132
132
|
đưa số đo, đừng lặng lẽ sửa nó.
|
|
133
133
|
|
|
134
|
+
**VÀ ĐỪNG NGỒI CHỜ CI.** Đẩy nhánh, mở PR, rồi đi làm việc khác. Không có vòng lặp
|
|
135
|
+
`until … gh pr checks … sleep` nào cả. CI chạy là việc của CI; nếu cần theo dõi thì
|
|
136
|
+
mở một agent nền, đừng chặn người đang điều phối. Đo được ngày 12/09/2026: một lượt
|
|
137
|
+
ngồi poll bốn shard đã ăn hơn một tiếng đồng hồ của chủ dự án để nhìn một thanh tiến
|
|
138
|
+
trình, trong khi có việc khác đang xếp hàng.
|
|
139
|
+
|
|
140
|
+
**Và full suite thì chạy theo LỊCH, không nằm trong vòng lặp sửa code của ai.** Cùng
|
|
141
|
+
ngày, tôi thêm bốn shard vitest vào làn PR của kho DS và đặt chúng thành required
|
|
142
|
+
check, để bịt một khoảng trống có thật (hai commit vào `main` đỏ qua một làn nhanh
|
|
143
|
+
xanh). Khoảng trống là thật và phép đo trung thực — nhưng nó chỉ định giá **máy**.
|
|
144
|
+
Máy chưa bao giờ là phần đắt. Từ lúc ấy mọi PR phải chờ bốn shard mới merge được.
|
|
145
|
+
Đã hoàn nguyên. Một hàng rào làm người điều phối phải chờ không rẻ hơn một `main`
|
|
146
|
+
đỏ; nó chỉ dời chi phí sang con đường duy nhất không song song hoá được.
|
|
147
|
+
|
|
134
148
|
### Bước 6 — Khép vòng
|
|
135
149
|
|
|
136
150
|
Phát hành → nâng gói ở consumer → **gỡ vá tạm** → **gỡ mọi chú thích "chờ
|
package/scripts/ui-audit.mjs
CHANGED
|
@@ -891,16 +891,47 @@ function staleOwnedRules() {
|
|
|
891
891
|
const target = join(CWD, ".ai", "rules", "godxjp-ui.md");
|
|
892
892
|
if (!existsSync(target)) return null;
|
|
893
893
|
|
|
894
|
-
|
|
894
|
+
// FOUR states, not two. `!stamped || !installed || stamped === installed` collapsed three very
|
|
895
|
+
// different situations into one silence, and only ONE of them is genuinely fine:
|
|
896
|
+
//
|
|
897
|
+
// no rule file, consumer never opted into the agent kit -> silent, and it MUST stay silent.
|
|
898
|
+
// Turning this into a finding would make a UI audit into a tool that nags every consumer to
|
|
899
|
+
// install an agent kit they did not ask for. (Handled by the existsSync above.)
|
|
900
|
+
// rule file present but carrying NO stamp -> compatibility UNKNOWN, say so.
|
|
901
|
+
// installed version unreadable -> compatibility UNKNOWN, say so.
|
|
902
|
+
// stamp != installed -> stale, the original finding.
|
|
903
|
+
const contents = readFileSync(target, "utf8");
|
|
904
|
+
const stamped = /<!-- godxjp-ui:version ([^\s]+) -->/.exec(contents)?.[1];
|
|
905
|
+
|
|
895
906
|
let installed;
|
|
896
907
|
try {
|
|
897
908
|
installed = JSON.parse(
|
|
898
909
|
readFileSync(join(CWD, "node_modules", "@godxjp", "ui", "package.json"), "utf8"),
|
|
899
910
|
).version;
|
|
900
911
|
} catch {
|
|
901
|
-
|
|
912
|
+
installed = undefined;
|
|
902
913
|
}
|
|
903
|
-
|
|
914
|
+
|
|
915
|
+
if (!stamped || !installed) {
|
|
916
|
+
// A file the package OWNS, whose provenance cannot be established. That is not the same as
|
|
917
|
+
// "up to date", and reporting it as such is the whole class of bug this function exists for.
|
|
918
|
+
return {
|
|
919
|
+
file: ".ai/rules/godxjp-ui.md",
|
|
920
|
+
line: 1,
|
|
921
|
+
rule: "owned-rules-unknown",
|
|
922
|
+
severity: "warn",
|
|
923
|
+
message:
|
|
924
|
+
`This file is written by @godxjp/ui, but its version cannot be established ` +
|
|
925
|
+
`(${!stamped ? "the file carries no `<!-- godxjp-ui:version … -->` stamp" : "the installed package version could not be read"}). ` +
|
|
926
|
+
`An agent may be reading guidance from a different major. Refresh it with ` +
|
|
927
|
+
`\`npx @godxjp/ui init-agent\`, or delete the file if this project does not use the agent kit.`,
|
|
928
|
+
replacement: null,
|
|
929
|
+
standard: null,
|
|
930
|
+
snippet: (stamped ?? "(no stamp)") + " vs " + (installed ?? "(package version unreadable)"),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (stamped === installed) return null;
|
|
904
935
|
|
|
905
936
|
return {
|
|
906
937
|
file: ".ai/rules/godxjp-ui.md",
|
|
@@ -1112,11 +1143,37 @@ const warnings = findings.filter((f) => f.severity === "warn");
|
|
|
1112
1143
|
// "✓ No UI-standardization violations found." and exited 0 having read nothing at all.
|
|
1113
1144
|
// …except under `--changed`, where "this branch touched no .tsx" is a clean run, not a
|
|
1114
1145
|
// misconfigured path. Failing there would make the gate unusable on every backend-only commit.
|
|
1115
|
-
if
|
|
1116
|
-
|
|
1146
|
+
// "This branch touched no UI file" is a clean run — but ONLY if there is nothing else to say.
|
|
1147
|
+
//
|
|
1148
|
+
// `staleOwnedRules()` runs before the scan loop and pushes its finding into `findings`, and this
|
|
1149
|
+
// branch used to exit 0 regardless: a consumer whose package-owned rules were four majors out of
|
|
1150
|
+
// date got "✓ no .tsx/.jsx changed" and a clean exit on any commit that happened not to touch a
|
|
1151
|
+
// component. Under `--format json` it was worse — the exit came BEFORE anything was written, so
|
|
1152
|
+
// stdout was EMPTY and a CI step reading it could not tell that from a pass.
|
|
1153
|
+
//
|
|
1154
|
+
// Reproduced: rules stamped 19.6.0 against an installed 23.3.0, one README.md changed →
|
|
1155
|
+
// `✓ no .tsx/.jsx changed`, exit 0, empty JSON, while `ui-audit src --format json` on the same
|
|
1156
|
+
// tree at the same moment reported `owned-rules-stale`.
|
|
1157
|
+
//
|
|
1158
|
+
// Same defect as the `.jsx` one this file just fixed, one screen further down: a gate declaring
|
|
1159
|
+
// itself clean while holding a finding.
|
|
1160
|
+
/** `--changed` legitimately opened no file. NOT the same thing as a misconfigured scan path. */
|
|
1161
|
+
const changedNoFiles = CHANGED && filesScanned === 0;
|
|
1162
|
+
|
|
1163
|
+
if (changedNoFiles && findings.length === 0) {
|
|
1164
|
+
if (asJson) {
|
|
1165
|
+
// ALWAYS emit a valid document on the JSON path. This branch used to exit before writing
|
|
1166
|
+
// anything, so a CI step parsing stdout got an empty string and a zero exit.
|
|
1167
|
+
process.stdout.write(
|
|
1168
|
+
JSON.stringify({ summary: { errors: 0, warnings: 0 }, findings: [] }, null, 2) + "\n",
|
|
1169
|
+
);
|
|
1170
|
+
} else if (!quiet) {
|
|
1171
|
+
console.log("✓ ui-audit --changed: no .tsx/.jsx changed on this branch.");
|
|
1172
|
+
}
|
|
1117
1173
|
process.exit(0);
|
|
1118
1174
|
}
|
|
1119
|
-
|
|
1175
|
+
|
|
1176
|
+
if (filesScanned === 0 && !CHANGED) {
|
|
1120
1177
|
const message =
|
|
1121
1178
|
`ui-audit scanned 0 files — none of [${SCAN_DIRS.join(", ")}] exists (or all were filtered). ` +
|
|
1122
1179
|
`Pass the directories to scan, e.g. \`node scripts/ui-audit.mjs src docs\`. ` +
|
|
@@ -1131,7 +1188,7 @@ if (filesScanned === 0) {
|
|
|
1131
1188
|
process.exitCode = 2;
|
|
1132
1189
|
}
|
|
1133
1190
|
|
|
1134
|
-
if (filesScanned === 0) {
|
|
1191
|
+
if (filesScanned === 0 && !changedNoFiles) {
|
|
1135
1192
|
// already reported above
|
|
1136
1193
|
} else if (asJson) {
|
|
1137
1194
|
process.stdout.write(
|
|
@@ -1161,7 +1218,10 @@ if (filesScanned === 0) {
|
|
|
1161
1218
|
console.log(` ${C.dim}${f.snippet}${C.reset}`);
|
|
1162
1219
|
}
|
|
1163
1220
|
console.log(
|
|
1164
|
-
`\ngodxjp-ui audit: ${C.red}${errors.length} error(s)${C.reset}, ${C.yellow}${warnings.length} warning(s)${C.reset}
|
|
1221
|
+
`\ngodxjp-ui audit: ${C.red}${errors.length} error(s)${C.reset}, ${C.yellow}${warnings.length} warning(s)${C.reset}` +
|
|
1222
|
+
(scannedFiles.length > 0
|
|
1223
|
+
? ` across ${scannedFiles.join(", ")}.`
|
|
1224
|
+
: " — no UI file changed on this branch, but the findings above are not about a file."),
|
|
1165
1225
|
);
|
|
1166
1226
|
if (errors.length === 0 && warnings.length === 0) {
|
|
1167
1227
|
console.log("✓ No UI-standardization violations found.");
|
|
@@ -1169,4 +1229,4 @@ if (filesScanned === 0) {
|
|
|
1169
1229
|
}
|
|
1170
1230
|
|
|
1171
1231
|
// See the note above --rules: exitCode, so a large JSON report drains fully.
|
|
1172
|
-
if (filesScanned > 0) process.exitCode = errors.length > 0 ? 1 : 0;
|
|
1232
|
+
if (filesScanned > 0 || changedNoFiles) process.exitCode = errors.length > 0 ? 1 : 0;
|