@godxjp/ui 31.1.0 → 31.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/agent/START-HERE.md +6 -6
- package/agent/components/AuthExpiryProvider.json +40 -0
- package/agent/components/DataState.json +3 -1
- package/agent/components/DataTable.json +1 -1
- package/agent/components/InfiniteQueryState.json +1 -1
- package/agent/components/ListRow.json +1 -0
- package/agent/components-index.json +5 -0
- package/agent/components.json +46 -3
- package/agent/index.json +7 -7
- package/agent/llms.txt +7 -7
- package/agent/patterns/async-data-state.json +1 -1
- package/agent/patterns.json +1 -1
- package/agent/tokens.json +6 -0
- package/dist/components/admin/index.d.ts +2 -0
- package/dist/components/admin/index.js +2 -0
- package/dist/components/data-display/data-table.js +2 -1
- package/dist/components/feedback/alert.d.ts +4 -0
- package/dist/components/feedback/alert.js +29 -8
- package/dist/components/feedback/auth-expiry.d.ts +30 -0
- package/dist/components/feedback/auth-expiry.js +64 -0
- package/dist/components/feedback/index.d.ts +2 -0
- package/dist/components/feedback/index.js +2 -0
- package/dist/components/query/data-state.js +9 -0
- package/dist/components/query/index.d.ts +2 -0
- package/dist/components/query/index.js +2 -0
- package/dist/components/query/infinite-query-state.js +9 -0
- package/dist/contracts/measurement.json +1 -1
- package/dist/i18n/messages/en.json +3 -0
- package/dist/i18n/messages/ja.json +3 -0
- package/dist/i18n/messages/vi.json +3 -0
- package/dist/props/components/feedback.prop.d.ts +13 -0
- package/dist/props/components/index.d.ts +1 -1
- package/dist/props/registry.d.ts +10 -1
- package/dist/props/registry.js +12 -0
- package/dist/props/vocabulary/data.prop.d.ts +8 -0
- package/dist/styles/alert-layout.css +4 -0
- package/dist/styles/layers.json +35 -1
- package/dist/styles/table-layout.css +6 -2
- package/dist/tokens/components/feedback.css +2 -0
- package/docs/FRAME-COVERAGE-REPORT.md +4 -2
- package/docs/data-display/data-table/examples/selectable-inbox.tsx +113 -0
- package/docs/data-display/data-table/index.md +2 -0
- package/docs/feedback/auth-expiry.tsx +104 -0
- package/docs/i18n/messages/en.json +6 -0
- package/docs/i18n/messages/ja.json +6 -0
- package/docs/i18n/messages/vi.json +6 -0
- package/docs/query/data-state.tsx +53 -5
- package/package.json +2 -2
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Avatar,
|
|
5
|
+
AvatarFallback,
|
|
6
|
+
Card,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardHeader,
|
|
9
|
+
CardTitle,
|
|
10
|
+
DataTable,
|
|
11
|
+
ListRow,
|
|
12
|
+
type ColumnDef,
|
|
13
|
+
} from "@godxjp/ui/data-display";
|
|
14
|
+
import { PageContainer } from "@godxjp/ui/layout";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A selectable inbox — `ListRow` hosted in a DataTable column (gh#1016).
|
|
18
|
+
*
|
|
19
|
+
* DataTable is the only primitive that selects rows; ListRow is the canonical entity row. The
|
|
20
|
+
* column is marked `flush: true`, so its body cells drop their own padding and the ListRow owns
|
|
21
|
+
* the inset: the avatar sits 16px from the checkbox (the same step as any other column, not 32px),
|
|
22
|
+
* the row is ListRow's own height, and an unread row's band spans the whole table row — the
|
|
23
|
+
* checkbox cell included. The header cell keeps its padding, so its label sits on the row's inset.
|
|
24
|
+
*/
|
|
25
|
+
type Message = {
|
|
26
|
+
id: string;
|
|
27
|
+
from: string;
|
|
28
|
+
initials: string;
|
|
29
|
+
subject: string;
|
|
30
|
+
read: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MESSAGES: Message[] = [
|
|
34
|
+
{
|
|
35
|
+
id: "m1",
|
|
36
|
+
from: "Aiko Tanaka",
|
|
37
|
+
initials: "AT",
|
|
38
|
+
subject: "April invoice is ready",
|
|
39
|
+
read: false,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "m2",
|
|
43
|
+
from: "Minh Nguyen",
|
|
44
|
+
initials: "MN",
|
|
45
|
+
subject: "Release notes for 4.2",
|
|
46
|
+
read: false,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "m3",
|
|
50
|
+
from: "Kenji Sato",
|
|
51
|
+
initials: "KS",
|
|
52
|
+
subject: "Meeting minutes",
|
|
53
|
+
read: true,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "m4",
|
|
57
|
+
from: "Lan Pham",
|
|
58
|
+
initials: "LP",
|
|
59
|
+
subject: "Access request approved",
|
|
60
|
+
read: true,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const columns: ColumnDef<Message>[] = [
|
|
65
|
+
{
|
|
66
|
+
key: "message",
|
|
67
|
+
header: "Message",
|
|
68
|
+
// The cell's CONTENT owns the inset — without it the cell padding stacks on the row's.
|
|
69
|
+
flush: true,
|
|
70
|
+
render: (m) => (
|
|
71
|
+
<ListRow
|
|
72
|
+
asChild
|
|
73
|
+
density="compact"
|
|
74
|
+
unread={!m.read}
|
|
75
|
+
leading={
|
|
76
|
+
<Avatar size="sm">
|
|
77
|
+
<AvatarFallback>{m.initials}</AvatarFallback>
|
|
78
|
+
</Avatar>
|
|
79
|
+
}
|
|
80
|
+
title={m.from}
|
|
81
|
+
description={m.subject}
|
|
82
|
+
>
|
|
83
|
+
<a href={`#${m.id}`} />
|
|
84
|
+
</ListRow>
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export default function SelectableInboxDemo() {
|
|
90
|
+
const [selected, setSelected] = useState<Set<string>>(new Set(["m2"]));
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<PageContainer title="Inbox" subtitle="DataTable selectable · ListRow in a flush column">
|
|
94
|
+
<Card>
|
|
95
|
+
<CardHeader>
|
|
96
|
+
<CardTitle>Messages</CardTitle>
|
|
97
|
+
</CardHeader>
|
|
98
|
+
<CardContent flush>
|
|
99
|
+
<DataTable
|
|
100
|
+
data={MESSAGES}
|
|
101
|
+
columns={columns}
|
|
102
|
+
getRowId={(m) => m.id}
|
|
103
|
+
getRowLabel={(m) => m.subject}
|
|
104
|
+
selectable
|
|
105
|
+
selected={selected}
|
|
106
|
+
onSelectChange={setSelected}
|
|
107
|
+
density="compact"
|
|
108
|
+
/>
|
|
109
|
+
</CardContent>
|
|
110
|
+
</Card>
|
|
111
|
+
</PageContainer>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -3,3 +3,5 @@
|
|
|
3
3
|
The admin list primitive — sticky header, sorting, bulk selection, density toggle, cursor pagination, and a built-in empty/loading state. Never wrap it in a `data.length === 0` guard; the empty state renders itself. See Examples for a full list-page screen.
|
|
4
4
|
|
|
5
5
|
See the "Approval queue" example.
|
|
6
|
+
|
|
7
|
+
See the "Selectable inbox" example for a `ListRow` hosted in a selectable DataTable: mark that column `flush: true` so the cell drops its own padding and the ListRow owns the inset (gh#1016).
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { QueryClient, QueryClientProvider, useMutation, useQuery } from "@tanstack/react-query";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardDescription,
|
|
8
|
+
CardHeader,
|
|
9
|
+
CardTitle,
|
|
10
|
+
DataTable,
|
|
11
|
+
type ColumnDef,
|
|
12
|
+
} from "@godxjp/ui/data-display";
|
|
13
|
+
import { SkeletonTable } from "@godxjp/ui/feedback";
|
|
14
|
+
import { Button, Text } from "@godxjp/ui/general";
|
|
15
|
+
import { useTranslation } from "@godxjp/ui/i18n";
|
|
16
|
+
import { Flex, PageContainer } from "@godxjp/ui/layout";
|
|
17
|
+
import { AlertMutationFeedback, AuthExpiryProvider, DataState } from "@godxjp/ui/query";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* AuthExpiryProvider · the app root owns an expired session (gh#1022). Every DataState /
|
|
21
|
+
* InfiniteQueryState / AlertMutationFeedback / AlertQueryError underneath calls `onAuthExpired`
|
|
22
|
+
* automatically, once for any number of simultaneous 401s, and shows a neutral pending state
|
|
23
|
+
* instead of an error alert. A real app redirects to its IdP here:
|
|
24
|
+
* window.location.assign(`/auth/login?return_to=${encodeURIComponent(location.href)}`)
|
|
25
|
+
*/
|
|
26
|
+
const queryClient = new QueryClient({
|
|
27
|
+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
type Member = { id: string; name: string };
|
|
31
|
+
|
|
32
|
+
const expired = () => Object.assign(new Error("Access token expired"), { status: 401 });
|
|
33
|
+
|
|
34
|
+
function MembersCard() {
|
|
35
|
+
const { t } = useTranslation();
|
|
36
|
+
const columns: ColumnDef<Member>[] = [
|
|
37
|
+
{ key: "id", header: "ID" },
|
|
38
|
+
{ key: "name", header: t("authExpiryDocs.nameColumn") },
|
|
39
|
+
];
|
|
40
|
+
const query = useQuery<Member[]>({
|
|
41
|
+
queryKey: ["auth-expiry-members"],
|
|
42
|
+
queryFn: async () => {
|
|
43
|
+
throw expired();
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
return (
|
|
47
|
+
<Card>
|
|
48
|
+
<CardHeader>
|
|
49
|
+
<CardTitle level={2}>{t("authExpiryDocs.membersTitle")}</CardTitle>
|
|
50
|
+
<CardDescription>A 401 keeps the skeleton and announces the redirect.</CardDescription>
|
|
51
|
+
</CardHeader>
|
|
52
|
+
<CardContent flush>
|
|
53
|
+
<DataState query={query} skeleton={<SkeletonTable rows={3} columns={2} />}>
|
|
54
|
+
{(d) => <DataTable data={d} columns={columns} getRowId={(r) => r.id} />}
|
|
55
|
+
</DataState>
|
|
56
|
+
</CardContent>
|
|
57
|
+
</Card>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function InviteCard() {
|
|
62
|
+
const { t } = useTranslation();
|
|
63
|
+
const mutation = useMutation({
|
|
64
|
+
mutationFn: async () => {
|
|
65
|
+
throw expired();
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
return (
|
|
69
|
+
<Card>
|
|
70
|
+
<CardHeader>
|
|
71
|
+
<CardTitle level={2}>{t("authExpiryDocs.inviteTitle")}</CardTitle>
|
|
72
|
+
<CardDescription>A 401 on submit shows a muted status line, not an alert.</CardDescription>
|
|
73
|
+
</CardHeader>
|
|
74
|
+
<CardContent>
|
|
75
|
+
<Flex direction="col" gap="md" align="start">
|
|
76
|
+
<Button onClick={() => mutation.mutate()}>{t("authExpiryDocs.send")}</Button>
|
|
77
|
+
<AlertMutationFeedback mutation={mutation} />
|
|
78
|
+
</Flex>
|
|
79
|
+
</CardContent>
|
|
80
|
+
</Card>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default function Demo() {
|
|
85
|
+
const [calls, setCalls] = useState(0);
|
|
86
|
+
return (
|
|
87
|
+
<QueryClientProvider client={queryClient}>
|
|
88
|
+
<AuthExpiryProvider onAuthExpired={() => setCalls((n) => n + 1)}>
|
|
89
|
+
<PageContainer
|
|
90
|
+
title="AuthExpiryProvider"
|
|
91
|
+
subtitle="Expired session handled once at the app root, never as a page error"
|
|
92
|
+
>
|
|
93
|
+
<Flex direction="col" gap="lg">
|
|
94
|
+
<Text size="sm" tone="muted">
|
|
95
|
+
onAuthExpired calls: {calls}
|
|
96
|
+
</Text>
|
|
97
|
+
<MembersCard />
|
|
98
|
+
<InviteCard />
|
|
99
|
+
</Flex>
|
|
100
|
+
</PageContainer>
|
|
101
|
+
</AuthExpiryProvider>
|
|
102
|
+
</QueryClientProvider>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -1438,5 +1438,11 @@
|
|
|
1438
1438
|
"sample": "Mẫu",
|
|
1439
1439
|
"date": "Tháng 9/2026 · ngày dùng cho demo"
|
|
1440
1440
|
}
|
|
1441
|
+
},
|
|
1442
|
+
"authExpiryDocs": {
|
|
1443
|
+
"membersTitle": "Thành viên",
|
|
1444
|
+
"nameColumn": "Họ tên",
|
|
1445
|
+
"inviteTitle": "Gửi lời mời",
|
|
1446
|
+
"send": "Gửi"
|
|
1441
1447
|
}
|
|
1442
1448
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -12,8 +13,9 @@ import {
|
|
|
12
13
|
type ColumnDef,
|
|
13
14
|
} from "@godxjp/ui/data-display";
|
|
14
15
|
import { SkeletonTable } from "@godxjp/ui/feedback";
|
|
16
|
+
import { Text } from "@godxjp/ui/general";
|
|
15
17
|
import { Flex, PageContainer } from "@godxjp/ui/layout";
|
|
16
|
-
import { DataState } from "@godxjp/ui/query";
|
|
18
|
+
import { AuthExpiryProvider, DataState } from "@godxjp/ui/query";
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* DataState · drives skeleton / error / empty / success for ONE useQuery block.
|
|
@@ -99,8 +101,40 @@ function PrerequisiteBlock() {
|
|
|
99
101
|
);
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
function ExpiredSessionQuery({ queryKey }: { queryKey: string }) {
|
|
105
|
+
const query = useQuery<Invoice[]>({
|
|
106
|
+
queryKey: [queryKey],
|
|
107
|
+
queryFn: async () => {
|
|
108
|
+
throw Object.assign(new Error("Access token expired"), { status: 401 });
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
return (
|
|
112
|
+
<DataState query={query} skeleton={<SkeletonTable rows={3} />}>
|
|
113
|
+
{(d) => <DataTable data={d} columns={columns} getRowId={(r) => r.id} />}
|
|
114
|
+
</DataState>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function AuthExpiryBlock() {
|
|
119
|
+
// The app root owns session expiry (gh#1022). A real app redirects to the IdP here:
|
|
120
|
+
// window.location.assign(`/auth/login?return_to=${encodeURIComponent(location.href)}`)
|
|
121
|
+
// Two queries 401 at once — the handler still runs exactly once, and no alert is painted.
|
|
122
|
+
const [calls, setCalls] = useState(0);
|
|
123
|
+
return (
|
|
124
|
+
<AuthExpiryProvider onAuthExpired={() => setCalls((n) => n + 1)}>
|
|
125
|
+
<Flex direction="col" gap="md">
|
|
126
|
+
<Text size="sm" tone="muted">
|
|
127
|
+
onAuthExpired calls: {calls}
|
|
128
|
+
</Text>
|
|
129
|
+
<ExpiredSessionQuery queryKey="ds-auth-expiry-a" />
|
|
130
|
+
<ExpiredSessionQuery queryKey="ds-auth-expiry-b" />
|
|
131
|
+
</Flex>
|
|
132
|
+
</AuthExpiryProvider>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
102
136
|
function AuthErrorBlock() {
|
|
103
|
-
//
|
|
137
|
+
// Without an AuthExpiryProvider: the proportionate fallback — a neutral sign-in prompt.
|
|
104
138
|
const query = useQuery<Invoice[]>({
|
|
105
139
|
queryKey: ["ds-auth-error"],
|
|
106
140
|
queryFn: async () => {
|
|
@@ -175,10 +209,24 @@ export default function Demo() {
|
|
|
175
209
|
|
|
176
210
|
<Card>
|
|
177
211
|
<CardHeader>
|
|
178
|
-
<CardTitle level={2}>
|
|
212
|
+
<CardTitle level={2}>Session expired · AuthExpiryProvider (recommended)</CardTitle>
|
|
213
|
+
<CardDescription>
|
|
214
|
+
Mount `AuthExpiryProvider` once at the app root. A 401 calls `onAuthExpired`
|
|
215
|
+
automatically, once for any number of simultaneous 401s, while each DataState keeps
|
|
216
|
+
its skeleton and announces the redirect to sign-in through a live region.
|
|
217
|
+
</CardDescription>
|
|
218
|
+
</CardHeader>
|
|
219
|
+
<CardContent>
|
|
220
|
+
<AuthExpiryBlock />
|
|
221
|
+
</CardContent>
|
|
222
|
+
</Card>
|
|
223
|
+
|
|
224
|
+
<Card>
|
|
225
|
+
<CardHeader>
|
|
226
|
+
<CardTitle level={2}>Session expired · fallback without a provider</CardTitle>
|
|
179
227
|
<CardDescription>
|
|
180
|
-
|
|
181
|
-
|
|
228
|
+
Without a provider a 401 renders a neutral, width-capped sign-in prompt whose button
|
|
229
|
+
calls `onAuthError`. It never offers a blind retry or shows the raw token message.
|
|
182
230
|
</CardDescription>
|
|
183
231
|
</CardHeader>
|
|
184
232
|
<CardContent>
|