@suphark/ui 0.45.2 → 0.53.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/CHANGELOG.md +79 -1
- package/README.md +2 -0
- package/package.json +2 -2
- package/src/components/app-shell/header-breadcrumbs.tsx +24 -0
- package/src/components/pwa/web-push-row.tsx +126 -0
- package/src/components/shared/date/date-picker.tsx +750 -319
- package/src/components/shared/notification-center.tsx +99 -11
- package/src/components/shared/record-card.tsx +158 -0
- package/src/components/shared/selection-bar.tsx +20 -6
- package/src/components/shared/status-badge.tsx +16 -2
- package/src/components/shared/ui-text.tsx +25 -0
- package/src/components/sortable/sortable-list.tsx +24 -21
- package/src/index.ts +1 -0
- package/src/lib/types.ts +18 -0
- package/src/pwa.ts +2 -1
|
@@ -89,8 +89,14 @@ export interface NotificationEntry {
|
|
|
89
89
|
details?: ReactNode;
|
|
90
90
|
/** ซ้ายของแถบล่าง — เรื่องเฉพาะโดเมน เช่นยอดเงิน (ซ่อนได้) หรือความคืบหน้ารอบ */
|
|
91
91
|
footer?: ReactNode;
|
|
92
|
+
/** `variant="quiet"`: เน้นแถวที่ต้องจัดการก่อน (พื้นสี accent/tone จาง ๆ) — ปกติใช้แถวเดียว */
|
|
93
|
+
highlight?: boolean;
|
|
94
|
+
/** `variant="quiet"`: แถบความคืบหน้า 0–100 ใต้ยอด (`footer`) */
|
|
95
|
+
progress?: number;
|
|
92
96
|
/** ยังไม่ได้อ่าน — จุดหน้าชื่อ + พื้นจาง */
|
|
93
97
|
unread?: boolean;
|
|
98
|
+
/** หัวข้อกลุ่มคั่น (`variant="quiet"`) — แถวติดกันที่ `section` เดียวกันอยู่ใต้หัวเดียว เช่น "ต้องจัดการก่อน" */
|
|
99
|
+
section?: string;
|
|
94
100
|
/** ปุ่มในแถว (ปุ่มที่ `primary` จะเด่นสุด) */
|
|
95
101
|
actions?: ReadonlyArray<NotificationAction>;
|
|
96
102
|
/** คำสั่งรองใน "…" */
|
|
@@ -120,6 +126,11 @@ export interface NotificationCenterProps {
|
|
|
120
126
|
viewAll?: { label?: ReactNode; onSelect: () => void };
|
|
121
127
|
/** กำลังทำคำสั่งลัดอยู่ — ปิดปุ่มทั้งหมดกันกดซ้ำ */
|
|
122
128
|
busy?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* `card` (ค่าเริ่มต้น) = ชิปนับ + การ์ดแยกใบ · `quiet` = "Quiet ledger": ชิปนับเป็นแท็บขีดเส้นใต้ (ตัวเลข tone danger เป็นสีแดง)
|
|
131
|
+
* รายการเป็นแถวเรียบในกรอบเดียว (`RecordCard variant="quiet"`) มีหัวกลุ่มคั่นด้วย `section` — เห็นได้มากกว่าเท่าตัว
|
|
132
|
+
*/
|
|
133
|
+
variant?: "card" | "quiet";
|
|
123
134
|
/**
|
|
124
135
|
* เรนเดอร์แถวเอง (การ์ดของแอป) — ปกติไม่ต้องใช้: แถวมาตรฐานคือ `RecordCard` ตัวเดียวกับที่แอปใช้ทำรายการ
|
|
125
136
|
* อยู่แล้ว · คืน node ที่จะอยู่ใน `<li>`
|
|
@@ -149,9 +160,11 @@ export function NotificationCenter({
|
|
|
149
160
|
empty,
|
|
150
161
|
viewAll,
|
|
151
162
|
busy = false,
|
|
163
|
+
variant = "card",
|
|
152
164
|
renderItem,
|
|
153
165
|
className,
|
|
154
166
|
}: NotificationCenterProps) {
|
|
167
|
+
const quiet = variant === "quiet";
|
|
155
168
|
const text = useUiText();
|
|
156
169
|
const heading = title ?? text.notifications?.title ?? "การแจ้งเตือน";
|
|
157
170
|
|
|
@@ -165,7 +178,7 @@ export function NotificationCenter({
|
|
|
165
178
|
<div className="flex min-w-0 flex-col gap-1.5">
|
|
166
179
|
<Heading className={cn("font-semibold text-sm", headingClassName)}>{heading}</Heading>
|
|
167
180
|
{description && <p className="text-muted-foreground text-xs">{description}</p>}
|
|
168
|
-
{counts && counts.length > 0 && (
|
|
181
|
+
{counts && counts.length > 0 && !quiet && (
|
|
169
182
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
170
183
|
{counts.map((chip) => (
|
|
171
184
|
<CountChip key={chip.key} chip={chip} />
|
|
@@ -181,6 +194,8 @@ export function NotificationCenter({
|
|
|
181
194
|
)}
|
|
182
195
|
</div>
|
|
183
196
|
|
|
197
|
+
{quiet && counts && counts.length > 0 && <QuietTabs counts={counts} />}
|
|
198
|
+
|
|
184
199
|
{items.length === 0 ? (
|
|
185
200
|
(empty ?? (
|
|
186
201
|
<div className="flex flex-col items-center gap-1 py-8 text-center">
|
|
@@ -191,16 +206,31 @@ export function NotificationCenter({
|
|
|
191
206
|
</div>
|
|
192
207
|
))
|
|
193
208
|
) : (
|
|
194
|
-
<ul
|
|
195
|
-
{
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
{renderItem(item, { busy })}
|
|
199
|
-
</li>
|
|
200
|
-
) : (
|
|
201
|
-
<NotificationRow key={item.id} item={item} busy={busy} />
|
|
202
|
-
),
|
|
209
|
+
<ul
|
|
210
|
+
className={cn(
|
|
211
|
+
"flex flex-col",
|
|
212
|
+
quiet ? "overflow-hidden rounded-xl border bg-card" : "gap-2",
|
|
203
213
|
)}
|
|
214
|
+
>
|
|
215
|
+
{items.map((item, index) => (
|
|
216
|
+
<Fragment key={item.id}>
|
|
217
|
+
{quiet && item.section && item.section !== items[index - 1]?.section && (
|
|
218
|
+
<li
|
|
219
|
+
role="presentation"
|
|
220
|
+
className="border-b bg-muted px-3.5 pt-2 pb-1.5 font-semibold text-muted-foreground text-xs not-first:border-t"
|
|
221
|
+
>
|
|
222
|
+
{item.section}
|
|
223
|
+
</li>
|
|
224
|
+
)}
|
|
225
|
+
{renderItem ? (
|
|
226
|
+
<li data-slot="notification-row" className="min-w-0">
|
|
227
|
+
{renderItem(item, { busy })}
|
|
228
|
+
</li>
|
|
229
|
+
) : (
|
|
230
|
+
<NotificationRow item={item} busy={busy} quiet={quiet} />
|
|
231
|
+
)}
|
|
232
|
+
</Fragment>
|
|
233
|
+
))}
|
|
204
234
|
</ul>
|
|
205
235
|
)}
|
|
206
236
|
</section>
|
|
@@ -230,12 +260,70 @@ function CountChip({ chip }: { chip: NotificationChip }) {
|
|
|
230
260
|
);
|
|
231
261
|
}
|
|
232
262
|
|
|
233
|
-
|
|
263
|
+
/** แท็บนับแบบขีดเส้นใต้ (`variant="quiet"`) — ตัวเลขของ tone danger เป็นสีแดงตัวหนา */
|
|
264
|
+
function QuietTabs({ counts }: { counts: ReadonlyArray<NotificationChip> }) {
|
|
265
|
+
return (
|
|
266
|
+
<div className="flex flex-wrap gap-x-5 border-b">
|
|
267
|
+
{counts.map((chip) => {
|
|
268
|
+
const body = (
|
|
269
|
+
<>
|
|
270
|
+
{chip.label}
|
|
271
|
+
<span
|
|
272
|
+
className={cn(
|
|
273
|
+
"ms-1 text-xs tabular-nums",
|
|
274
|
+
isDanger(chip.tone) ? "font-bold text-destructive" : "text-muted-foreground",
|
|
275
|
+
)}
|
|
276
|
+
>
|
|
277
|
+
{chip.count}
|
|
278
|
+
</span>
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
const tab = cn(
|
|
282
|
+
"-mb-px border-b-2 px-0.5 pt-2 pb-2.5 text-sm",
|
|
283
|
+
chip.active
|
|
284
|
+
? "border-primary font-semibold"
|
|
285
|
+
: "border-transparent font-medium text-muted-foreground",
|
|
286
|
+
);
|
|
287
|
+
return chip.onSelect ? (
|
|
288
|
+
<button
|
|
289
|
+
key={chip.key}
|
|
290
|
+
type="button"
|
|
291
|
+
aria-pressed={chip.active ?? false}
|
|
292
|
+
onClick={chip.onSelect}
|
|
293
|
+
className={cn(
|
|
294
|
+
tab,
|
|
295
|
+
"cursor-pointer outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
{body}
|
|
299
|
+
</button>
|
|
300
|
+
) : (
|
|
301
|
+
<span key={chip.key} className={tab}>
|
|
302
|
+
{body}
|
|
303
|
+
</span>
|
|
304
|
+
);
|
|
305
|
+
})}
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function NotificationRow({
|
|
311
|
+
item,
|
|
312
|
+
busy,
|
|
313
|
+
quiet,
|
|
314
|
+
}: {
|
|
315
|
+
item: NotificationEntry;
|
|
316
|
+
busy: boolean;
|
|
317
|
+
quiet: boolean;
|
|
318
|
+
}) {
|
|
234
319
|
const text = useUiText();
|
|
235
320
|
const hasActions = Boolean(item.actions?.length || item.menuActions?.length);
|
|
236
321
|
return (
|
|
237
322
|
<RecordCard
|
|
238
323
|
as="li"
|
|
324
|
+
variant={quiet ? "quiet" : "card"}
|
|
325
|
+
highlight={item.highlight}
|
|
326
|
+
progress={item.progress}
|
|
239
327
|
accent={item.accent ?? item.tone}
|
|
240
328
|
icon={item.icon}
|
|
241
329
|
title={item.title}
|
|
@@ -32,6 +32,16 @@ export function recordAccentColor(accent: RecordAccent | undefined): string | un
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export interface RecordCardProps {
|
|
35
|
+
/**
|
|
36
|
+
* `card` (ค่าเริ่มต้น) = การ์ดมีสี/ขอบ/พื้นไล่สีครบ · `quiet` = แถวเรียบแบบสมุดบัญชี ("Quiet ledger"):
|
|
37
|
+
* ไม่มีกรอบ/พื้นสี เส้นคั่นล่างบาง ๆ สีของ `accent` เหลือแค่ไอคอน · ชื่อ + (status · meta) ซ้าย, `footerSummary` (ยอดเงิน) ขวา,
|
|
38
|
+
* `actions` ท้ายแถว — ยัดได้มากกว่าเท่าตัวในที่เท่ากัน · วางในกรอบ/`<ul>` เดียวกัน (เส้นคั่นล่างของแถวสุดท้ายหายเอง)
|
|
39
|
+
*/
|
|
40
|
+
variant?: "card" | "quiet";
|
|
41
|
+
/** `quiet` เท่านั้น: เน้นแถวที่ต้องจัดการก่อน — พื้นไล่เป็นสีของ `accent` จางมาก (ใช้ตัวเดียวต่อรายการ ไม่ใช่ทุกแถว) */
|
|
42
|
+
highlight?: boolean;
|
|
43
|
+
/** `quiet` เท่านั้น: แถบความคืบหน้า 0–100 ใต้ `footerSummary` (เช่นรอบที่ทำแล้ว 35/60 = 58) */
|
|
44
|
+
progress?: number;
|
|
35
45
|
/** สีประจำการ์ด: ขอบซ้าย 3px + ขอบจาง ๆ + พื้นไล่สีจากซ้าย — tone หรือสี CSS (`var(--type-income)`) */
|
|
36
46
|
accent?: RecordAccent;
|
|
37
47
|
/** ไอคอน — ใส่ในกรอบสี่เหลี่ยมมนตามสี accent ให้เอง (`iconTile={false}` = วางตามที่ส่งมา) */
|
|
@@ -96,10 +106,23 @@ export function RecordCard({
|
|
|
96
106
|
unread = false,
|
|
97
107
|
onSelect,
|
|
98
108
|
as: Root = "article",
|
|
109
|
+
variant = "card",
|
|
110
|
+
highlight = false,
|
|
111
|
+
progress,
|
|
99
112
|
className,
|
|
100
113
|
}: RecordCardProps) {
|
|
101
114
|
const text = useUiText();
|
|
102
115
|
const color = recordAccentColor(accent);
|
|
116
|
+
if (variant === "quiet") {
|
|
117
|
+
return (
|
|
118
|
+
<QuietRecord
|
|
119
|
+
{...{ Root, Title, color, icon, title, titleClassName, status, meta, description }}
|
|
120
|
+
{...{ details, detailsLabel, footerSummary, actions, unread, onSelect, className }}
|
|
121
|
+
{...{ highlight, progress }}
|
|
122
|
+
fallbackDetailsLabel={text.record?.details ?? "ข้อมูลเพิ่มเติม"}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
103
126
|
const small = iconSize === "sm";
|
|
104
127
|
// เยื้อง = ความกว้างไอคอน + gap-3 (44+12 = 56 = ml-14, 36+12 = 48 = ml-12)
|
|
105
128
|
const indent = icon ? (small ? "ml-12 ps-0" : "ml-14 ps-0") : "";
|
|
@@ -203,3 +226,138 @@ export function RecordCard({
|
|
|
203
226
|
</Root>
|
|
204
227
|
);
|
|
205
228
|
}
|
|
229
|
+
|
|
230
|
+
/** variant="quiet": แถวเดียวต่อรายการ — ไอคอน | ชื่อ + (สถานะ · meta) | ยอด | ปุ่ม */
|
|
231
|
+
function QuietRecord({
|
|
232
|
+
Root,
|
|
233
|
+
Title,
|
|
234
|
+
color,
|
|
235
|
+
icon,
|
|
236
|
+
title,
|
|
237
|
+
titleClassName,
|
|
238
|
+
status,
|
|
239
|
+
meta,
|
|
240
|
+
description,
|
|
241
|
+
details,
|
|
242
|
+
detailsLabel,
|
|
243
|
+
fallbackDetailsLabel,
|
|
244
|
+
footerSummary,
|
|
245
|
+
actions,
|
|
246
|
+
unread,
|
|
247
|
+
onSelect,
|
|
248
|
+
highlight,
|
|
249
|
+
progress,
|
|
250
|
+
className,
|
|
251
|
+
}: Pick<
|
|
252
|
+
RecordCardProps,
|
|
253
|
+
| "icon"
|
|
254
|
+
| "title"
|
|
255
|
+
| "titleClassName"
|
|
256
|
+
| "status"
|
|
257
|
+
| "meta"
|
|
258
|
+
| "description"
|
|
259
|
+
| "details"
|
|
260
|
+
| "detailsLabel"
|
|
261
|
+
| "footerSummary"
|
|
262
|
+
| "actions"
|
|
263
|
+
| "unread"
|
|
264
|
+
| "onSelect"
|
|
265
|
+
| "highlight"
|
|
266
|
+
| "progress"
|
|
267
|
+
| "className"
|
|
268
|
+
> & {
|
|
269
|
+
Root: "article" | "li" | "div";
|
|
270
|
+
Title: "h2" | "h3" | "h4" | "p";
|
|
271
|
+
color: string | undefined;
|
|
272
|
+
fallbackDetailsLabel: string;
|
|
273
|
+
}) {
|
|
274
|
+
return (
|
|
275
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: คลิกทั้งแถวเป็นทางลัดของเมาส์ ปุ่มข้างในใช้คีย์บอร์ดได้อยู่แล้ว
|
|
276
|
+
<Root
|
|
277
|
+
data-slot="record-card"
|
|
278
|
+
data-variant="quiet"
|
|
279
|
+
data-unread={unread || undefined}
|
|
280
|
+
onClick={onSelect}
|
|
281
|
+
style={color ? ({ "--record-accent": color } as CSSProperties) : undefined}
|
|
282
|
+
className={cn(
|
|
283
|
+
"min-w-0 border-b bg-transparent px-3.5 py-2.5 text-card-foreground last:border-b-0",
|
|
284
|
+
unread && "bg-muted/40",
|
|
285
|
+
highlight && color && "bg-[color-mix(in_oklab,var(--record-accent)_9%,var(--card))]",
|
|
286
|
+
onSelect && "cursor-pointer transition-colors hover:bg-muted/60",
|
|
287
|
+
className,
|
|
288
|
+
)}
|
|
289
|
+
>
|
|
290
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
|
|
291
|
+
{icon && (
|
|
292
|
+
<div
|
|
293
|
+
data-slot="record-card-icon"
|
|
294
|
+
className={cn(
|
|
295
|
+
"flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-muted [&_svg]:size-[1.125rem]",
|
|
296
|
+
color ? "text-(--record-accent)" : "text-muted-foreground",
|
|
297
|
+
)}
|
|
298
|
+
>
|
|
299
|
+
{icon}
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
<div className="min-w-0 flex-1 basis-48">
|
|
303
|
+
<Title className={cn("truncate font-semibold text-sm leading-snug", titleClassName)}>
|
|
304
|
+
{unread && (
|
|
305
|
+
<span
|
|
306
|
+
className="me-1.5 inline-block size-1.5 rounded-full bg-primary align-middle"
|
|
307
|
+
aria-hidden="true"
|
|
308
|
+
/>
|
|
309
|
+
)}
|
|
310
|
+
{title}
|
|
311
|
+
</Title>
|
|
312
|
+
{(status != null || meta != null) && (
|
|
313
|
+
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-muted-foreground text-xs">
|
|
314
|
+
{status}
|
|
315
|
+
{status != null && meta != null && <span aria-hidden="true">·</span>}
|
|
316
|
+
{meta}
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
{description != null && (
|
|
320
|
+
<p className="mt-0.5 text-muted-foreground text-xs">{description}</p>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
{footerSummary != null && (
|
|
324
|
+
<div className="min-w-24 shrink-0 text-end tabular-nums">
|
|
325
|
+
{footerSummary}
|
|
326
|
+
{progress != null && (
|
|
327
|
+
<div
|
|
328
|
+
role="progressbar"
|
|
329
|
+
aria-valuenow={progress}
|
|
330
|
+
aria-valuemin={0}
|
|
331
|
+
aria-valuemax={100}
|
|
332
|
+
className="mt-1 h-[3px] overflow-hidden rounded-full bg-muted"
|
|
333
|
+
>
|
|
334
|
+
<div
|
|
335
|
+
className="h-full rounded-full bg-info"
|
|
336
|
+
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
{actions != null && (
|
|
343
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: ตัวกั้นคลิกเฉย ๆ ไม่ใช่ตัวควบคุม
|
|
344
|
+
<div
|
|
345
|
+
className="flex shrink-0 items-center gap-1.5"
|
|
346
|
+
onClick={(event: MouseEvent) => event.stopPropagation()}
|
|
347
|
+
onKeyDown={(event: KeyboardEvent) => event.stopPropagation()}
|
|
348
|
+
>
|
|
349
|
+
{actions}
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
{details != null && (
|
|
354
|
+
<section
|
|
355
|
+
aria-label={detailsLabel ?? fallbackDetailsLabel}
|
|
356
|
+
className={cn("mt-2 border-t pt-2", icon && "ms-12")}
|
|
357
|
+
>
|
|
358
|
+
{details}
|
|
359
|
+
</section>
|
|
360
|
+
)}
|
|
361
|
+
</Root>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
@@ -24,8 +24,11 @@ export interface SelectionBarProps {
|
|
|
24
24
|
children?: ReactNode;
|
|
25
25
|
/** ข้อความนับ ใช้ `{count}` เช่น "เลือก {count} รายการ" (ไม่ส่ง = `UiText.selection.selected`) */
|
|
26
26
|
label?: string;
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* floating = แถบลอยกลางล่างจอ (ค่าเริ่มต้น) · inline = แถบในหน้า เหนือตาราง ·
|
|
29
|
+
* pill = แคปซูลสีหลักลอยกลางล่างจอ (กลับสีเข้ม เด่นกว่า floating, ปุ่มโปร่งบนพื้นสีหลัก)
|
|
30
|
+
*/
|
|
31
|
+
variant?: "floating" | "inline" | "pill";
|
|
29
32
|
className?: string;
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -48,6 +51,7 @@ export function SelectionBar({
|
|
|
48
51
|
}: SelectionBarProps) {
|
|
49
52
|
const text = useUiText();
|
|
50
53
|
if (count <= 0) return null;
|
|
54
|
+
const pill = variant === "pill";
|
|
51
55
|
|
|
52
56
|
const template = label ?? text.selection?.selected ?? "เลือก {count} รายการ";
|
|
53
57
|
const message = template.replace("{count}", String(count));
|
|
@@ -58,8 +62,11 @@ export function SelectionBar({
|
|
|
58
62
|
// อ่านออกเสียงจำนวนที่เลือกให้ screen reader โดยไม่ขัดจังหวะ
|
|
59
63
|
aria-live="polite"
|
|
60
64
|
className={cn(
|
|
61
|
-
"flex items-center gap-2
|
|
62
|
-
|
|
65
|
+
"flex items-center gap-2 px-3 py-2 shadow-lg",
|
|
66
|
+
pill
|
|
67
|
+
? "h-11 rounded-full bg-primary py-0 ps-4 pe-2 text-primary-foreground"
|
|
68
|
+
: "rounded-xl border bg-card",
|
|
69
|
+
variant !== "inline" &&
|
|
63
70
|
"-translate-x-1/2 fixed bottom-[calc(1rem+env(safe-area-inset-bottom))] left-1/2 z-40 max-w-[calc(100vw-2rem)]",
|
|
64
71
|
className,
|
|
65
72
|
)}
|
|
@@ -70,7 +77,11 @@ export function SelectionBar({
|
|
|
70
77
|
<Button
|
|
71
78
|
key={action.label}
|
|
72
79
|
size="sm"
|
|
73
|
-
variant={action.destructive ? "destructive" : "outline"}
|
|
80
|
+
variant={pill ? "ghost" : action.destructive ? "destructive" : "outline"}
|
|
81
|
+
className={cn(
|
|
82
|
+
pill && "rounded-full hover:bg-primary-foreground/15 hover:text-primary-foreground",
|
|
83
|
+
pill && action.destructive && "text-primary-foreground",
|
|
84
|
+
)}
|
|
74
85
|
disabled={action.disabled}
|
|
75
86
|
onClick={action.onClick}
|
|
76
87
|
>
|
|
@@ -85,7 +96,10 @@ export function SelectionBar({
|
|
|
85
96
|
size="icon-sm"
|
|
86
97
|
aria-label={text.selection?.clear ?? "ล้างการเลือก"}
|
|
87
98
|
onClick={onClear}
|
|
88
|
-
className=
|
|
99
|
+
className={cn(
|
|
100
|
+
"ml-auto shrink-0",
|
|
101
|
+
pill && "rounded-full hover:bg-primary-foreground/15 hover:text-primary-foreground",
|
|
102
|
+
)}
|
|
89
103
|
>
|
|
90
104
|
<XIcon />
|
|
91
105
|
</Button>
|
|
@@ -12,13 +12,27 @@ const TONE: Record<Tone, string> = {
|
|
|
12
12
|
info: "border-info-border bg-info-subtle text-info-subtle-foreground",
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Badge ของ shadcn (variant outline) + สีตามความหมายของสถานะ
|
|
17
|
+
* อยู่ใน `RecordCard variant="quiet"` (data-variant="quiet") จะกลายเป็น "จุดสี + ข้อความสี" ไม่มีกรอบ/พื้น อัตโนมัติ
|
|
18
|
+
*/
|
|
16
19
|
export function StatusBadge({
|
|
17
20
|
tone = "neutral",
|
|
18
21
|
className,
|
|
19
22
|
...props
|
|
20
23
|
}: Omit<ComponentProps<typeof Badge>, "variant"> & { tone?: Tone }) {
|
|
21
|
-
return
|
|
24
|
+
return (
|
|
25
|
+
<Badge
|
|
26
|
+
variant="outline"
|
|
27
|
+
className={cn(
|
|
28
|
+
TONE[tone],
|
|
29
|
+
"in-data-[variant=quiet]:h-auto in-data-[variant=quiet]:border-transparent in-data-[variant=quiet]:bg-transparent in-data-[variant=quiet]:px-0 in-data-[variant=quiet]:py-0 in-data-[variant=quiet]:font-semibold",
|
|
30
|
+
"in-data-[variant=quiet]:before:size-1.5 in-data-[variant=quiet]:before:shrink-0 in-data-[variant=quiet]:before:rounded-full in-data-[variant=quiet]:before:bg-current in-data-[variant=quiet]:before:content-['']",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
/** สถานะที่ใช้บ่อยในระบบ → tone */
|
|
@@ -204,6 +204,15 @@ export interface UiText {
|
|
|
204
204
|
helpRange: string;
|
|
205
205
|
helpRangeShortcut: string;
|
|
206
206
|
pickEnd: string;
|
|
207
|
+
/** ช่องพิมพ์ (0.49.0) — `{example}` = ตัวอย่างที่พิมพ์ได้, `{count}` = จำนวนวัน */
|
|
208
|
+
cannotRead: string;
|
|
209
|
+
shortcutsLabel: string;
|
|
210
|
+
showAll: string;
|
|
211
|
+
backToCalendar: string;
|
|
212
|
+
openCalendar: string;
|
|
213
|
+
dayCount: string;
|
|
214
|
+
allShortcuts: string;
|
|
215
|
+
tapToFill: string;
|
|
207
216
|
last7Days: string;
|
|
208
217
|
last30Days: string;
|
|
209
218
|
thisMonth: string;
|
|
@@ -401,6 +410,14 @@ export const UI_TEXT_TH: UiText = {
|
|
|
401
410
|
helpRange: "1–5 ก.ค.",
|
|
402
411
|
helpRangeShortcut: "ช่วงจากตัวย่อ (คั่นด้วย .. ~ ถึง to)",
|
|
403
412
|
pickEnd: "เลือกวันสุดท้ายของช่วง",
|
|
413
|
+
cannotRead: "อ่านไม่ออก — ลองพิมพ์ {example}",
|
|
414
|
+
shortcutsLabel: "พิมพ์ลัด",
|
|
415
|
+
showAll: "ทั้งหมด",
|
|
416
|
+
backToCalendar: "ปฏิทิน",
|
|
417
|
+
openCalendar: "เปิดปฏิทิน",
|
|
418
|
+
dayCount: "{count} วัน",
|
|
419
|
+
allShortcuts: "พิมพ์ลัดทั้งหมด",
|
|
420
|
+
tapToFill: "กดเพื่อเติมในช่อง",
|
|
404
421
|
last7Days: "7 วันล่าสุด",
|
|
405
422
|
last30Days: "30 วันล่าสุด",
|
|
406
423
|
thisMonth: "เดือนนี้",
|
|
@@ -604,6 +621,14 @@ export const UI_TEXT_EN: UiText = {
|
|
|
604
621
|
helpRange: "1–5 Jul",
|
|
605
622
|
helpRangeShortcut: "Range from shortcuts (separate with .. ~ to)",
|
|
606
623
|
pickEnd: "Pick the last day of the range",
|
|
624
|
+
cannotRead: "Can't read that — try {example}",
|
|
625
|
+
shortcutsLabel: "Shortcuts",
|
|
626
|
+
showAll: "All",
|
|
627
|
+
backToCalendar: "Calendar",
|
|
628
|
+
openCalendar: "Open calendar",
|
|
629
|
+
dayCount: "{count} days",
|
|
630
|
+
allShortcuts: "All shortcuts",
|
|
631
|
+
tapToFill: "Tap to fill the field",
|
|
607
632
|
last7Days: "Last 7 days",
|
|
608
633
|
last30Days: "Last 30 days",
|
|
609
634
|
thisMonth: "This month",
|
|
@@ -153,8 +153,6 @@ interface SortableState {
|
|
|
153
153
|
selected: ReadonlySet<string>;
|
|
154
154
|
select: (id: string, mode: SelectMode) => void;
|
|
155
155
|
groupOf: (itemId: string) => string | undefined;
|
|
156
|
-
/** แถวรายการอยู่ใน node ของกลุ่มเองไหม (กลุ่มเป็น <tbody> ทั้งก้อน) — ถ้าใช่ เลื่อนตามพ่ออยู่แล้ว */
|
|
157
|
-
insideGroup: (itemId: string, groupId: string) => boolean;
|
|
158
156
|
register: (kind: SortableKind, id: string, node: HTMLElement | null) => void;
|
|
159
157
|
shifts: ShiftStore;
|
|
160
158
|
}
|
|
@@ -173,7 +171,6 @@ const SortableStateContext = createContext<SortableState>({
|
|
|
173
171
|
selected: EMPTY,
|
|
174
172
|
select: () => {},
|
|
175
173
|
groupOf: () => undefined,
|
|
176
|
-
insideGroup: () => false,
|
|
177
174
|
register: () => {},
|
|
178
175
|
shifts: createShiftStore(),
|
|
179
176
|
});
|
|
@@ -399,14 +396,16 @@ export function SortableProvider({
|
|
|
399
396
|
} else map.delete(rowId);
|
|
400
397
|
}, []);
|
|
401
398
|
|
|
402
|
-
/**
|
|
399
|
+
/**
|
|
400
|
+
* กรอบของทั้งก้อน: หัวกลุ่ม + แถวรายการที่แสดงอยู่ (ไม่สนใจ transform ระหว่างลาก)
|
|
401
|
+
* หัวกลุ่มกับแถวรายการต้องเป็นพี่น้องกัน (`<tr>` ใน `<tbody>` เดียวกัน / การ์ดในลิสต์เดียวกัน)
|
|
402
|
+
* ห้ามให้ node ของกลุ่มครอบแถวรายการไว้ ไม่งั้นกรอบนี้นับซ้ำและแถวลูกจะเลื่อนสองชั้น
|
|
403
|
+
*/
|
|
403
404
|
const blockRect = useCallback((groupId: string, header: HTMLElement) => {
|
|
404
405
|
let { top, bottom, left, right } = getClientRect(header, { ignoreTransform: true });
|
|
405
406
|
for (const itemId of latest.current.items[groupId] ?? []) {
|
|
406
407
|
const node = nodes.current.items.get(itemId);
|
|
407
|
-
|
|
408
|
-
// และกรอบของแถวลูกติด transform ของพ่อ (ignoreTransform ไม่ช่วย) → นับซ้ำแล้วก้อนใหญ่เกินจริง
|
|
409
|
-
if (!node?.isConnected || header.contains(node)) continue;
|
|
408
|
+
if (!node?.isConnected) continue;
|
|
410
409
|
const r = getClientRect(node, { ignoreTransform: true });
|
|
411
410
|
top = Math.min(top, r.top);
|
|
412
411
|
bottom = Math.max(bottom, r.bottom);
|
|
@@ -937,11 +936,6 @@ export function SortableProvider({
|
|
|
937
936
|
const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
|
|
938
937
|
|
|
939
938
|
const groupOf = useCallback((itemId: string) => groupOfItem(order, itemId), [order]);
|
|
940
|
-
const insideGroup = useCallback((itemId: string, groupId: string) => {
|
|
941
|
-
const group = nodes.current.groups.get(groupId);
|
|
942
|
-
const item = nodes.current.items.get(itemId);
|
|
943
|
-
return Boolean(group && item && group !== item && group.contains(item));
|
|
944
|
-
}, []);
|
|
945
939
|
const rootDrag =
|
|
946
940
|
activeDrag !== null &&
|
|
947
941
|
(activeDrag.kind === "group" ||
|
|
@@ -968,7 +962,6 @@ export function SortableProvider({
|
|
|
968
962
|
selected,
|
|
969
963
|
select,
|
|
970
964
|
groupOf,
|
|
971
|
-
insideGroup,
|
|
972
965
|
register,
|
|
973
966
|
shifts,
|
|
974
967
|
}),
|
|
@@ -986,7 +979,6 @@ export function SortableProvider({
|
|
|
986
979
|
selected,
|
|
987
980
|
select,
|
|
988
981
|
groupOf,
|
|
989
|
-
insideGroup,
|
|
990
982
|
register,
|
|
991
983
|
shifts,
|
|
992
984
|
],
|
|
@@ -1056,6 +1048,8 @@ export interface SortableRow {
|
|
|
1056
1048
|
isDragging: boolean;
|
|
1057
1049
|
/** รายการนี้ถูกเลือกอยู่ (`multiSelect`) */
|
|
1058
1050
|
selected: boolean;
|
|
1051
|
+
/** เลือก/ไม่เลือกแถวนี้ (สำหรับช่องติ๊กในแถว) — ไม่ได้เปิด `multiSelect` = ไม่ทำอะไร */
|
|
1052
|
+
toggleSelected: () => void;
|
|
1059
1053
|
/**
|
|
1060
1054
|
* กระจายลงแถวเมื่อเปิด `multiSelect`: คลิกพร้อม ⌘/Ctrl/Shift เพื่อเลือก + `data-selected`
|
|
1061
1055
|
* สำหรับทำสี (`data-[selected]:bg-primary/10`) — ปิด `multiSelect` หรือเป็นแถวกลุ่ม = object ว่าง
|
|
@@ -1136,12 +1130,7 @@ export function useSortableRow({
|
|
|
1136
1130
|
? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
|
|
1137
1131
|
{ ...style, opacity: 0.4 }
|
|
1138
1132
|
: { ...style, position: "relative", zIndex: 1, opacity: 0.85 };
|
|
1139
|
-
} else if (
|
|
1140
|
-
type === "item" &&
|
|
1141
|
-
state.rootDrag &&
|
|
1142
|
-
shift &&
|
|
1143
|
-
!(groupId && state.insideGroup(id, groupId))
|
|
1144
|
-
) {
|
|
1133
|
+
} else if (type === "item" && state.rootDrag && shift) {
|
|
1145
1134
|
const own = state.activeGroup !== null && state.activeGroup === groupId;
|
|
1146
1135
|
style = {
|
|
1147
1136
|
transform: shift.transform,
|
|
@@ -1201,6 +1190,9 @@ export function useSortableRow({
|
|
|
1201
1190
|
handleProps,
|
|
1202
1191
|
isDragging: sortable.isDragging,
|
|
1203
1192
|
selected: isSelected,
|
|
1193
|
+
toggleSelected: () => {
|
|
1194
|
+
if (selectable) select(id, "toggle");
|
|
1195
|
+
},
|
|
1204
1196
|
selectProps,
|
|
1205
1197
|
};
|
|
1206
1198
|
}
|
|
@@ -1253,6 +1245,7 @@ export function DragHandle({
|
|
|
1253
1245
|
className,
|
|
1254
1246
|
disabled,
|
|
1255
1247
|
hideWhenDisabled = false,
|
|
1248
|
+
reveal = false,
|
|
1256
1249
|
...handleProps
|
|
1257
1250
|
}: {
|
|
1258
1251
|
label?: string;
|
|
@@ -1260,6 +1253,11 @@ export function DragHandle({
|
|
|
1260
1253
|
disabled?: boolean;
|
|
1261
1254
|
/** ปิดการลากแล้วไม่ต้องแสดงที่จับเลย (ค่าเริ่มต้นคือแสดงแบบจาง ๆ ให้รู้ว่ามีแต่ตอนนี้ลากไม่ได้) */
|
|
1262
1255
|
hideWhenDisabled?: boolean;
|
|
1256
|
+
/**
|
|
1257
|
+
* "สงบจนกว่าจะแตะ": ซ่อนที่จับไว้ โผล่เมื่อชี้แถว / โฟกัสในแถว (คีย์บอร์ด) / แถวถูกเลือก · จอสัมผัส (ไม่มี hover) เห็นตลอด
|
|
1258
|
+
* ต้องใส่ `group/row` ที่แถว (`<TableRow className="group/row">`) · ที่จับยังโฟกัสด้วย Tab ได้เสมอ
|
|
1259
|
+
*/
|
|
1260
|
+
reveal?: boolean;
|
|
1263
1261
|
} & Record<string, unknown>) {
|
|
1264
1262
|
const state = useContext(SortableStateContext);
|
|
1265
1263
|
const off = disabled ?? state.disabled;
|
|
@@ -1270,7 +1268,12 @@ export function DragHandle({
|
|
|
1270
1268
|
size="icon-sm"
|
|
1271
1269
|
aria-label={label ?? state.handleLabel}
|
|
1272
1270
|
disabled={off}
|
|
1273
|
-
className={cn(
|
|
1271
|
+
className={cn(
|
|
1272
|
+
"cursor-grab touch-none active:cursor-grabbing",
|
|
1273
|
+
reveal &&
|
|
1274
|
+
"opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 group-data-[selected]/row:opacity-100 [@media(hover:none)]:opacity-100",
|
|
1275
|
+
className,
|
|
1276
|
+
)}
|
|
1274
1277
|
data-sortable-handle=""
|
|
1275
1278
|
{...handleProps}
|
|
1276
1279
|
>
|
package/src/index.ts
CHANGED
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type helper ที่แอปใช้ตอนห่อ component ของแพ็กเกจ — ไม่มีโค้ดตอนรัน
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `Omit` ที่ทำทีละสมาชิกของ union — props ของ component ที่มี `mode`/variant
|
|
7
|
+
* (เช่น `DatePickerProps` ที่เป็น `DatePickerSingleProps | DatePickerRangeProps`)
|
|
8
|
+
* ถ้าใช้ `Omit` ปกติจะถูกยุบเป็นก้อนเดียว แยกตาม discriminant ไม่ได้อีก (Pi Auth เจอตอนห่อ `DateField`)
|
|
9
|
+
*
|
|
10
|
+
* type DateFieldProps = DistributiveOmit<DatePickerProps, "locale"> & { label: string };
|
|
11
|
+
* // แยกตาม props.mode ได้ตามปกติ ไม่ต้อง cast
|
|
12
|
+
*/
|
|
13
|
+
export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
|
14
|
+
|
|
15
|
+
/** `Pick` ที่ทำทีละสมาชิกของ union (คู่กับ `DistributiveOmit`) */
|
|
16
|
+
export type DistributivePick<T, K extends PropertyKey> = T extends unknown
|
|
17
|
+
? Pick<T, Extract<keyof T, K>>
|
|
18
|
+
: never;
|
package/src/pwa.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @suphark/ui/pwa — ชุด Progressive Web App: ลงทะเบียน/อัปเดต service worker, การ์ดชวนติดตั้ง,
|
|
2
|
+
* @suphark/ui/pwa — ชุด Progressive Web App: ลงทะเบียน/อัปเดต service worker, การ์ดชวนติดตั้ง, ดึงลงเพื่อรีเฟรช, สวิตช์แจ้งเตือน Web Push
|
|
3
3
|
* `PullToRefresh` ใช้ next/navigation จึงแยกจาก barrel หลักเหมือน `@suphark/ui/next`
|
|
4
4
|
*/
|
|
5
5
|
export {
|
|
@@ -22,3 +22,4 @@ export {
|
|
|
22
22
|
} from "./components/pwa/pwa-install-card";
|
|
23
23
|
export { PwaServiceWorkerRegistration } from "./components/pwa/pwa-service-worker-registration";
|
|
24
24
|
export { canReloadNow, watchServiceWorkerUpdates } from "./components/pwa/service-worker-updates";
|
|
25
|
+
export { WebPushRow, type WebPushRowProps } from "./components/pwa/web-push-row";
|