@suphark/ui 0.47.0 → 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.
@@ -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 className="flex flex-col gap-2">
195
- {items.map((item) =>
196
- renderItem ? (
197
- <li key={item.id} data-slot="notification-row" className="min-w-0">
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
- function NotificationRow({ item, busy }: { item: NotificationEntry; busy: boolean }) {
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
- /** floating = แถบลอยกลางล่างจอ (ค่าเริ่มต้น) · inline = แถบในหน้า เหนือตาราง */
28
- variant?: "floating" | "inline";
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 rounded-xl border bg-card px-3 py-2 shadow-lg",
62
- variant === "floating" &&
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="ml-auto shrink-0"
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
- /** Badge ของ shadcn (variant outline) + สีตามความหมายของสถานะ */
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 <Badge variant="outline" className={cn(TONE[tone], className)} {...props} />;
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",
@@ -1048,6 +1048,8 @@ export interface SortableRow {
1048
1048
  isDragging: boolean;
1049
1049
  /** รายการนี้ถูกเลือกอยู่ (`multiSelect`) */
1050
1050
  selected: boolean;
1051
+ /** เลือก/ไม่เลือกแถวนี้ (สำหรับช่องติ๊กในแถว) — ไม่ได้เปิด `multiSelect` = ไม่ทำอะไร */
1052
+ toggleSelected: () => void;
1051
1053
  /**
1052
1054
  * กระจายลงแถวเมื่อเปิด `multiSelect`: คลิกพร้อม ⌘/Ctrl/Shift เพื่อเลือก + `data-selected`
1053
1055
  * สำหรับทำสี (`data-[selected]:bg-primary/10`) — ปิด `multiSelect` หรือเป็นแถวกลุ่ม = object ว่าง
@@ -1188,6 +1190,9 @@ export function useSortableRow({
1188
1190
  handleProps,
1189
1191
  isDragging: sortable.isDragging,
1190
1192
  selected: isSelected,
1193
+ toggleSelected: () => {
1194
+ if (selectable) select(id, "toggle");
1195
+ },
1191
1196
  selectProps,
1192
1197
  };
1193
1198
  }
@@ -1240,6 +1245,7 @@ export function DragHandle({
1240
1245
  className,
1241
1246
  disabled,
1242
1247
  hideWhenDisabled = false,
1248
+ reveal = false,
1243
1249
  ...handleProps
1244
1250
  }: {
1245
1251
  label?: string;
@@ -1247,6 +1253,11 @@ export function DragHandle({
1247
1253
  disabled?: boolean;
1248
1254
  /** ปิดการลากแล้วไม่ต้องแสดงที่จับเลย (ค่าเริ่มต้นคือแสดงแบบจาง ๆ ให้รู้ว่ามีแต่ตอนนี้ลากไม่ได้) */
1249
1255
  hideWhenDisabled?: boolean;
1256
+ /**
1257
+ * "สงบจนกว่าจะแตะ": ซ่อนที่จับไว้ โผล่เมื่อชี้แถว / โฟกัสในแถว (คีย์บอร์ด) / แถวถูกเลือก · จอสัมผัส (ไม่มี hover) เห็นตลอด
1258
+ * ต้องใส่ `group/row` ที่แถว (`<TableRow className="group/row">`) · ที่จับยังโฟกัสด้วย Tab ได้เสมอ
1259
+ */
1260
+ reveal?: boolean;
1250
1261
  } & Record<string, unknown>) {
1251
1262
  const state = useContext(SortableStateContext);
1252
1263
  const off = disabled ?? state.disabled;
@@ -1257,7 +1268,12 @@ export function DragHandle({
1257
1268
  size="icon-sm"
1258
1269
  aria-label={label ?? state.handleLabel}
1259
1270
  disabled={off}
1260
- className={cn("cursor-grab touch-none active:cursor-grabbing", className)}
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
+ )}
1261
1277
  data-sortable-handle=""
1262
1278
  {...handleProps}
1263
1279
  >
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";