ppij-design-system 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/components/account-sheet.d.ts +37 -0
- package/dist/components/account-sheet.js +25 -0
- package/dist/components/admin-drawer.d.ts +7 -1
- package/dist/components/admin-drawer.js +7 -2
- package/dist/components/admin-top-bar.d.ts +47 -0
- package/dist/components/admin-top-bar.js +20 -0
- package/dist/components/bar-chart.d.ts +32 -0
- package/dist/components/bar-chart.js +28 -0
- package/dist/components/callout.d.ts +21 -0
- package/dist/components/callout.js +28 -0
- package/dist/components/distribution-bar.d.ts +25 -0
- package/dist/components/distribution-bar.js +21 -0
- package/dist/components/document-viewer.d.ts +38 -0
- package/dist/components/document-viewer.js +25 -0
- package/dist/components/drawer.js +1 -1
- package/dist/components/expandable-text.d.ts +21 -0
- package/dist/components/expandable-text.js +33 -0
- package/dist/components/fact-list.d.ts +36 -0
- package/dist/components/fact-list.js +29 -0
- package/dist/components/figures-band.d.ts +32 -0
- package/dist/components/figures-band.js +28 -0
- package/dist/components/filter-chips.d.ts +23 -0
- package/dist/components/filter-chips.js +15 -0
- package/dist/components/filter-menu.d.ts +24 -0
- package/dist/components/filter-menu.js +15 -0
- package/dist/components/form-grid.d.ts +32 -0
- package/dist/components/form-grid.js +29 -0
- package/dist/components/identity-chip.d.ts +18 -0
- package/dist/components/identity-chip.js +12 -0
- package/dist/components/input.d.ts +12 -1
- package/dist/components/input.js +2 -2
- package/dist/components/list-row.d.ts +29 -0
- package/dist/components/list-row.js +27 -0
- package/dist/components/map-card.d.ts +45 -0
- package/dist/components/map-card.js +45 -0
- package/dist/components/member-card-block.d.ts +5 -0
- package/dist/components/member-card-block.js +1 -1
- package/dist/components/member-card.css +2 -0
- package/dist/components/panel-card.d.ts +28 -0
- package/dist/components/panel-card.js +25 -0
- package/dist/components/pie-chart.d.ts +34 -0
- package/dist/components/pie-chart.js +75 -0
- package/dist/components/reveal-value.d.ts +26 -0
- package/dist/components/reveal-value.js +12 -0
- package/dist/components/searchable-multi-select.d.ts +34 -0
- package/dist/components/searchable-multi-select.js +36 -0
- package/dist/components/stat-strip.d.ts +27 -0
- package/dist/components/stat-strip.js +25 -0
- package/dist/components/status-card.d.ts +7 -2
- package/dist/components/status-card.js +6 -5
- package/dist/components/tabs.d.ts +8 -2
- package/dist/components/tabs.js +18 -5
- package/dist/components/timeline.d.ts +21 -5
- package/dist/components/timeline.js +20 -6
- package/dist/components/workspace-selector.d.ts +36 -0
- package/dist/components/workspace-selector.js +74 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +21 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# ppij-design-system
|
|
2
|
+
|
|
3
|
+
PPIJ presentation components on React 19, Tailwind CSS v4 and shadcn/ui on
|
|
4
|
+
Radix, with the framework-free token layer beside them. Public, member portal,
|
|
5
|
+
admin, Pemira, Keycloak and email surfaces consume this package; it holds no
|
|
6
|
+
application data logic, no API clients and no secrets.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install ppij-design-system
|
|
10
|
+
npm install github:ppij-dev/ppij-design-system # the released tarball route
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Exports
|
|
14
|
+
|
|
15
|
+
- `ppij-design-system` — the React components from the package root
|
|
16
|
+
- `ppij-design-system/tokens.css` — the token layer, plus Tailwind's own import
|
|
17
|
+
- `ppij-design-system/tokens.json` — the same values, framework free
|
|
18
|
+
|
|
19
|
+
## Consumer setup
|
|
20
|
+
|
|
21
|
+
Two things are the consuming app's:
|
|
22
|
+
|
|
23
|
+
- **Tailwind CSS v4.** `tokens.css` opens with `@import "tailwindcss"`, so
|
|
24
|
+
install `tailwindcss` and `@tailwindcss/vite` (or the equivalent PostCSS
|
|
25
|
+
plugin) and import the token file once. The component classes are Tailwind
|
|
26
|
+
utilities; without the plugin the token import cannot resolve.
|
|
27
|
+
- **The automatic JSX runtime**, as in any React 19 app
|
|
28
|
+
(`@vitejs/plugin-react`, or `esbuild: { jsx: "automatic" }`).
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { AdminTopBar, MapCard, PanelCard, PieChart } from "ppij-design-system";
|
|
32
|
+
import "ppij-design-system/tokens.css";
|
|
33
|
+
import tokens from "ppij-design-system/tokens.json";
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The component guidelines and every component story live in the Storybook Docs
|
|
37
|
+
pages at https://ppij-dev.github.io/ppij-design-system/.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type AccountSheetLocale = "id" | "en";
|
|
3
|
+
export interface AccountSheetItem {
|
|
4
|
+
label: string;
|
|
5
|
+
/** Second line under the label, for example the destination route. */
|
|
6
|
+
sublabel?: string;
|
|
7
|
+
icon: React.ReactNode;
|
|
8
|
+
onSelect: () => void;
|
|
9
|
+
/** `danger` marks the sign-out row. */
|
|
10
|
+
tone?: "default" | "danger";
|
|
11
|
+
}
|
|
12
|
+
export interface AccountSheetProps {
|
|
13
|
+
open: boolean;
|
|
14
|
+
onOpenChange: (open: boolean) => void;
|
|
15
|
+
name: string;
|
|
16
|
+
detail: string;
|
|
17
|
+
initials: string;
|
|
18
|
+
items: AccountSheetItem[];
|
|
19
|
+
/** Slot above the rows, for example the Portal | Admin switch. */
|
|
20
|
+
switchSlot?: React.ReactNode;
|
|
21
|
+
signOutLabel?: string;
|
|
22
|
+
onSignOut?: () => void;
|
|
23
|
+
locale: AccountSheetLocale;
|
|
24
|
+
/** Overrides the built-in close label for this locale. */
|
|
25
|
+
closeLabel?: string;
|
|
26
|
+
/** Accessible name of the dialog. Defaults to the locale's profile label. */
|
|
27
|
+
label?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The account sheet: who is signed in, the context switch, the account rows and
|
|
32
|
+
* sign out, as a bottom sheet on every screen width. Escape, the backdrop and
|
|
33
|
+
* the close button dismiss it; focus starts on the close button and returns to
|
|
34
|
+
* the trigger that opened it; every row is an ordinary button of at least 44px.
|
|
35
|
+
* The rows and their destinations are the caller's.
|
|
36
|
+
*/
|
|
37
|
+
export declare function AccountSheet({ open, onOpenChange, name, detail, initials, items, switchSlot, signOutLabel, onSignOut, locale, closeLabel, label, className, }: AccountSheetProps): React.JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { IconLogout, IconX } from "@tabler/icons-react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { Dialog, DialogContent } from "./dialog";
|
|
6
|
+
const labels = {
|
|
7
|
+
id: { close: "Tutup profil", dialog: "Profil pengguna" },
|
|
8
|
+
en: { close: "Close profile", dialog: "User profile" },
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The account sheet: who is signed in, the context switch, the account rows and
|
|
12
|
+
* sign out, as a bottom sheet on every screen width. Escape, the backdrop and
|
|
13
|
+
* the close button dismiss it; focus starts on the close button and returns to
|
|
14
|
+
* the trigger that opened it; every row is an ordinary button of at least 44px.
|
|
15
|
+
* The rows and their destinations are the caller's.
|
|
16
|
+
*/
|
|
17
|
+
export function AccountSheet({ open, onOpenChange, name, detail, initials, items, switchSlot, signOutLabel, onSignOut, locale, closeLabel, label, className, }) {
|
|
18
|
+
const l = labels[locale];
|
|
19
|
+
const closeRef = React.useRef(null);
|
|
20
|
+
const dialogLabel = label ?? l.dialog;
|
|
21
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { "data-slot": "account-sheet", showCloseButton: false, "aria-label": dialogLabel, onOpenAutoFocus: (event) => {
|
|
22
|
+
event.preventDefault();
|
|
23
|
+
closeRef.current?.focus();
|
|
24
|
+
}, className: cn("bottom-0 top-auto left-1/2 w-[min(420px,calc(100vw-24px))] translate-x-[-50%] translate-y-0 gap-0 overflow-hidden rounded-b-none rounded-t-[14px] border border-b-0 bg-card p-0 pb-[max(10px,env(safe-area-inset-bottom))]", className), children: [_jsx("span", { "aria-hidden": true, className: "mx-auto mt-2.5 h-[5px] w-[38px] rounded-full bg-brand-border" }), _jsxs("div", { className: "relative flex items-center gap-2.5 border-b border-brand-border px-4 pt-2 pb-3 pr-14", children: [_jsx("span", { "aria-hidden": true, className: "grid size-9 shrink-0 place-items-center rounded-full bg-brand-red/8 text-[13px] font-extrabold text-brand-red", children: initials }), _jsxs("span", { className: "min-w-0", children: [_jsx("b", { className: "block truncate text-sm text-brand-ink", children: name }), _jsx("small", { className: "block truncate text-[11.5px] text-brand-muted", children: detail })] }), _jsx("button", { ref: closeRef, type: "button", "aria-label": closeLabel ?? l.close, onClick: () => onOpenChange(false), className: "absolute top-1 right-1 grid size-11 place-items-center rounded-[10px] text-brand-muted transition-colors hover:bg-secondary hover:text-brand-ink focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none", children: _jsx(IconX, { "aria-hidden": true, className: "size-[18px]" }) })] }), switchSlot ? _jsx("div", { className: "grid gap-1.5 px-4 pt-2.5", children: switchSlot }) : null, _jsxs("div", { className: "px-2 pt-1 pb-2.5", children: [items.map((item) => (_jsxs("button", { type: "button", onClick: item.onSelect, className: cn("flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13.5px] font-semibold transition-colors hover:bg-secondary focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none", item.tone === "danger" ? "text-destructive" : "text-brand-ink"), children: [_jsx("span", { "aria-hidden": true, className: cn("shrink-0 [&_svg]:size-4", item.tone === "danger" ? "text-destructive" : "text-brand-muted"), children: item.icon }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate", children: item.label }), item.sublabel ? _jsx("small", { className: "block text-xs font-normal text-brand-muted", children: item.sublabel }) : null] })] }, item.label))), onSignOut && signOutLabel ? (_jsxs("button", { type: "button", onClick: onSignOut, className: "mt-1 flex min-h-11 w-full items-center gap-2.5 rounded-lg border-t border-brand-border px-2.5 py-2 text-left text-[13.5px] font-semibold text-destructive transition-colors hover:bg-secondary focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none", children: [_jsx(IconLogout, { "aria-hidden": true, className: "size-4 shrink-0" }), _jsx("span", { className: "min-w-0 truncate", children: signOutLabel })] })) : null] })] }) }));
|
|
25
|
+
}
|
|
@@ -3,6 +3,8 @@ import * as React from "react";
|
|
|
3
3
|
export type Locale = "id" | "en";
|
|
4
4
|
/** One matchMedia subscription; the only viewport check in the admin drawer. Server render assumes a phone. */
|
|
5
5
|
export declare function useIsDesktop(): boolean;
|
|
6
|
+
/** Width of the right-hand drawer from 900 px: sm 380, md 440 (default), lg 560, each capped to the viewport. Phones always get a full-width bottom sheet. */
|
|
7
|
+
export type AdminDrawerSize = "sm" | "md" | "lg";
|
|
6
8
|
export type AdminDrawerProps = {
|
|
7
9
|
open: boolean;
|
|
8
10
|
onOpenChange: (open: boolean) => void;
|
|
@@ -14,6 +16,8 @@ export type AdminDrawerProps = {
|
|
|
14
16
|
closeLabel?: string;
|
|
15
17
|
/** Overrides the built-in back label for this locale. */
|
|
16
18
|
backLabel?: string;
|
|
19
|
+
/** Compact density uses a 13px body, 14px title, and 12px padding. */
|
|
20
|
+
density?: "compact" | "default";
|
|
17
21
|
/** Two-step body: 1 is the first step (default), 2 is the confirmation step. */
|
|
18
22
|
step?: 1 | 2;
|
|
19
23
|
/** Shown as the "Kembali" button when step is 2. Going back never submits. */
|
|
@@ -24,6 +28,8 @@ export type AdminDrawerProps = {
|
|
|
24
28
|
footer?: React.ReactNode;
|
|
25
29
|
/** Overrides Radix's focus return on close; the caller decides which control gets focus back. */
|
|
26
30
|
onCloseAutoFocus?: (event: Event) => void;
|
|
31
|
+
/** Desktop width. Defaults to "md" (440 px), the previous fixed width. */
|
|
32
|
+
size?: AdminDrawerSize;
|
|
27
33
|
className?: string;
|
|
28
34
|
};
|
|
29
35
|
/**
|
|
@@ -31,4 +37,4 @@ export type AdminDrawerProps = {
|
|
|
31
37
|
* Escape, the backdrop and the close button dismiss it without submitting; Radix returns
|
|
32
38
|
* focus to the element that was focused before the drawer opened.
|
|
33
39
|
*/
|
|
34
|
-
export declare function AdminDrawer({ open, onOpenChange, title, description, locale, closeLabel, backLabel, step, onBack, children, footer, onCloseAutoFocus, className, }: AdminDrawerProps): React.JSX.Element;
|
|
40
|
+
export declare function AdminDrawer({ open, onOpenChange, title, description, locale, closeLabel, backLabel, density, step, onBack, children, footer, onCloseAutoFocus, size, className, }: AdminDrawerProps): React.JSX.Element;
|
|
@@ -18,13 +18,18 @@ function subscribeDesktop(onChange) {
|
|
|
18
18
|
export function useIsDesktop() {
|
|
19
19
|
return React.useSyncExternalStore(subscribeDesktop, () => window.matchMedia(DESKTOP_QUERY).matches, () => false);
|
|
20
20
|
}
|
|
21
|
+
const sizeClass = {
|
|
22
|
+
sm: "data-[vaul-drawer-direction=right]:w-[380px] data-[vaul-drawer-direction=right]:sm:max-w-[min(380px,calc(100vw-48px))]",
|
|
23
|
+
md: "data-[vaul-drawer-direction=right]:w-[440px] data-[vaul-drawer-direction=right]:sm:max-w-[min(440px,calc(100vw-48px))]",
|
|
24
|
+
lg: "data-[vaul-drawer-direction=right]:w-[560px] data-[vaul-drawer-direction=right]:sm:max-w-[min(560px,calc(100vw-48px))]",
|
|
25
|
+
};
|
|
21
26
|
/**
|
|
22
27
|
* Admin form or detail in a drawer: right from 900 px, bottom below.
|
|
23
28
|
* Escape, the backdrop and the close button dismiss it without submitting; Radix returns
|
|
24
29
|
* focus to the element that was focused before the drawer opened.
|
|
25
30
|
*/
|
|
26
|
-
export function AdminDrawer({ open, onOpenChange, title, description, locale, closeLabel, backLabel, step = 1, onBack, children, footer, onCloseAutoFocus, className, }) {
|
|
31
|
+
export function AdminDrawer({ open, onOpenChange, title, description, locale, closeLabel, backLabel, density = "default", step = 1, onBack, children, footer, onCloseAutoFocus, size = "md", className, }) {
|
|
27
32
|
const isDesktop = useIsDesktop();
|
|
28
33
|
const showBack = step === 2 && onBack !== undefined;
|
|
29
|
-
return (_jsx(Drawer, { open: open, onOpenChange: onOpenChange, direction: isDesktop ? "right" : "bottom", children: _jsxs(DrawerContent, { "data-admin-drawer": "", onCloseAutoFocus: onCloseAutoFocus, "data-step": step, className: cn("min-h-0 gap-0 p-0 text-foreground", "data-[vaul-drawer-direction=bottom]:max-h-[85vh]",
|
|
34
|
+
return (_jsx(Drawer, { open: open, onOpenChange: onOpenChange, direction: isDesktop ? "right" : "bottom", children: _jsxs(DrawerContent, { "data-admin-drawer": "", "data-size": size, onCloseAutoFocus: onCloseAutoFocus, "data-step": step, "data-density": density, className: cn("min-h-0 gap-0 p-0 text-foreground", "data-[vaul-drawer-direction=bottom]:max-h-[85vh]", sizeClass[size], density === "compact" && "text-[13px] leading-5", className), children: [_jsxs(DrawerHeader, { "data-vaul-no-drag": "", className: cn("shrink-0 flex-row items-start justify-between gap-3 border-b border-border p-4 text-left", density === "compact" && "p-3"), children: [_jsxs("div", { className: "min-w-0", children: [_jsx(DrawerTitle, { className: density === "compact" ? "text-sm leading-5" : "text-base leading-6", children: title }), description ? _jsx(DrawerDescription, { className: cn("break-words", density === "compact" && "text-xs leading-5"), children: description }) : null] }), _jsx(DrawerClose, { asChild: true, children: _jsx(Button, { type: "button", variant: "ghost", size: density === "compact" && isDesktop ? "icon-sm" : "icon", "aria-label": (closeLabel ?? labels[locale].close), className: "shrink-0 max-[600px]:size-11", children: _jsx(IconX, { className: "size-4" }) }) })] }), _jsx("div", { "data-admin-drawer-body": "", className: cn("min-h-0 flex-1 overflow-y-auto p-4", density === "compact" && "p-3"), children: children }), showBack || footer ? (_jsxs(DrawerFooter, { "data-vaul-no-drag": "", "data-admin-drawer-footer": "", className: cn("shrink-0 flex-row flex-wrap items-center justify-end gap-2 border-t border-border bg-card p-4", density === "compact" && "p-3"), children: [showBack ? (_jsx(Button, { type: "button", variant: "outline", size: density === "compact" ? "sm" : "default", onClick: onBack, className: "mr-auto", children: (backLabel ?? labels[locale].back) })) : null, footer] })) : null] }) }));
|
|
30
35
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type TopBarLocale = "id" | "en";
|
|
3
|
+
export interface TopBarPathItem {
|
|
4
|
+
label: string;
|
|
5
|
+
/** Only the last path segment is a link; the title carries the current page. */
|
|
6
|
+
href?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AdminTopBarIdentity {
|
|
9
|
+
name: string;
|
|
10
|
+
initials: string;
|
|
11
|
+
}
|
|
12
|
+
export interface AdminTopBarProps {
|
|
13
|
+
/** Trail above the current page, for example Admin › Organisasi. */
|
|
14
|
+
path?: TopBarPathItem[];
|
|
15
|
+
/** The current page title; it ends the trail and is reported as the current page. */
|
|
16
|
+
title: string;
|
|
17
|
+
/** Quiet ambient facts (a date, a timezone), hidden below 1100px to keep the bar calm. */
|
|
18
|
+
meta?: React.ReactNode[];
|
|
19
|
+
/** Right-hand controls; the bar raises them to the 44px touch floor below 600px. */
|
|
20
|
+
actions?: React.ReactNode;
|
|
21
|
+
/** Renders the bell. It carries the locale accessible name. */
|
|
22
|
+
onNotificationsClick?: () => void;
|
|
23
|
+
/** Unread count beside the bell; announced with the button's name. */
|
|
24
|
+
notificationCount?: number;
|
|
25
|
+
identity?: AdminTopBarIdentity;
|
|
26
|
+
/** Makes the identity a button that opens the account sheet. */
|
|
27
|
+
onIdentityClick?: () => void;
|
|
28
|
+
/** Phone brand lock-up: the logo, its alt text and the label beside it. */
|
|
29
|
+
brand?: {
|
|
30
|
+
src: string;
|
|
31
|
+
alt: string;
|
|
32
|
+
label: string;
|
|
33
|
+
};
|
|
34
|
+
/** Slot at the very start of the bar, for example a sidebar toggle. */
|
|
35
|
+
leading?: React.ReactNode;
|
|
36
|
+
/** The shell mounts exactly one variant, so only one `<header>` landmark exists. */
|
|
37
|
+
variant: "desktop" | "phone";
|
|
38
|
+
locale: TopBarLocale;
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The admin shell's top bar. Desktop: the breadcrumb trail, quiet facts, the
|
|
43
|
+
* bar's actions and the identity. Phone: the brand, one workspace slot in
|
|
44
|
+
* `leading` and the 44px identity target. The bar owns no route and no session;
|
|
45
|
+
* labels, links and callbacks arrive as props.
|
|
46
|
+
*/
|
|
47
|
+
export declare function AdminTopBar({ path, title, meta, actions, onNotificationsClick, notificationCount, identity, onIdentityClick, brand, leading, variant, locale, className, }: AdminTopBarProps): React.JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { IconBell, IconChevronRight } from "@tabler/icons-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
const labels = {
|
|
5
|
+
id: { breadcrumb: "Jejak halaman", notifications: "Notifikasi", identity: "Profil pengguna", brand: "PPI Jepang" },
|
|
6
|
+
en: { breadcrumb: "Breadcrumb", notifications: "Notifications", identity: "User profile", brand: "PPI Japan" },
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The admin shell's top bar. Desktop: the breadcrumb trail, quiet facts, the
|
|
10
|
+
* bar's actions and the identity. Phone: the brand, one workspace slot in
|
|
11
|
+
* `leading` and the 44px identity target. The bar owns no route and no session;
|
|
12
|
+
* labels, links and callbacks arrive as props.
|
|
13
|
+
*/
|
|
14
|
+
export function AdminTopBar({ path = [], title, meta = [], actions, onNotificationsClick, notificationCount, identity, onIdentityClick, brand, leading, variant, locale, className, }) {
|
|
15
|
+
const l = labels[locale];
|
|
16
|
+
if (variant === "phone") {
|
|
17
|
+
return (_jsxs("header", { "data-slot": "admin-top-bar", "data-variant": "phone", className: cn("sticky top-0 z-25 flex h-15 shrink-0 items-center justify-between border-b border-brand-border bg-card px-4", className), children: [brand ? (_jsxs("span", { className: "flex min-w-0 items-center gap-2 text-[15px] font-bold text-brand-ink", children: [_jsx("img", { src: brand.src, alt: brand.alt, className: "size-[34px] shrink-0 rounded-md border border-brand-border object-contain" }), _jsx("span", { className: "truncate", children: brand.label })] })) : (_jsx("span", { className: "truncate text-[15px] font-bold text-brand-ink", children: title })), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-2", children: [leading, identity ? (_jsx("button", { type: "button", onClick: onIdentityClick, "aria-label": identity.name, "aria-haspopup": onIdentityClick ? "dialog" : undefined, className: "grid size-11 shrink-0 place-items-center rounded-[10px] bg-brand-red/8 text-[13px] font-extrabold text-brand-red focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none", children: identity.initials })) : null] })] }));
|
|
18
|
+
}
|
|
19
|
+
return (_jsxs("header", { "data-slot": "admin-top-bar", "data-variant": "desktop", className: cn("flex h-17 shrink-0 items-center justify-between gap-4 border-b border-brand-border bg-card px-[clamp(24px,3vw,48px)]", className), children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2.5", children: [leading, _jsx("nav", { "aria-label": l.breadcrumb, className: "min-w-0", children: _jsxs("ol", { className: "flex min-w-0 items-center gap-2.5 text-sm text-brand-muted", children: [path.map((item, index) => (_jsxs("li", { className: "flex min-w-0 items-center gap-2.5", children: [item.href ? (_jsx("a", { href: item.href, className: "truncate hover:text-brand-ink", children: item.label })) : (_jsx("span", { className: "truncate", children: item.label })), _jsx(IconChevronRight, { "aria-hidden": true, className: "size-4 shrink-0 text-brand-border" })] }, `${item.label}-${index}`))), _jsx("li", { className: "min-w-0", children: _jsx("strong", { "aria-current": "page", className: "block truncate font-bold text-brand-ink", children: title }) })] }) })] }), _jsxs("div", { className: "flex shrink-0 items-center gap-2.5 text-[13px] text-brand-muted", children: [meta.length ? (_jsx("span", { className: "hidden items-center gap-2.5 min-[1100px]:flex", children: meta.map((item, index) => (_jsx("span", { children: item }, index))) })) : null, actions || onNotificationsClick ? (_jsxs("span", { className: "flex items-center gap-1.5 max-[600px]:[&_[data-slot=button]]:min-h-11 min-[600px]:[&_[data-slot=button]]:min-h-8", children: [actions, onNotificationsClick ? (_jsxs("button", { type: "button", onClick: onNotificationsClick, "aria-label": notificationCount ? `${l.notifications}, ${notificationCount}` : l.notifications, className: "relative grid size-8 place-items-center rounded-md text-brand-muted transition-colors hover:bg-secondary hover:text-brand-ink focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none max-[600px]:size-11", children: [_jsx(IconBell, { "aria-hidden": true, className: "size-5" }), notificationCount ? (_jsx("span", { "aria-hidden": true, className: "absolute -top-0.5 -right-0.5 grid min-w-4.5 place-items-center rounded-full bg-brand-red px-1 text-[11px] leading-4.5 font-bold text-white", children: notificationCount })) : null] })) : null] })) : null, identity ? (onIdentityClick ? (_jsxs("button", { type: "button", onClick: onIdentityClick, "aria-label": identity.name, "aria-haspopup": "dialog", className: "flex items-center gap-2 rounded-full pr-1 pl-0.5 transition-colors hover:bg-secondary focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none max-[600px]:min-h-11 min-[600px]:h-8", children: [_jsx("span", { "aria-hidden": true, className: "grid size-8 place-items-center rounded-full bg-brand-red/8 text-xs font-extrabold text-brand-red", children: identity.initials }), _jsx("span", { className: "hidden max-w-40 truncate font-bold text-brand-ink min-[900px]:block", children: identity.name })] })) : (_jsxs("span", { className: "flex items-center gap-2 max-[600px]:min-h-11 min-[600px]:h-8", children: [_jsx("span", { "aria-hidden": true, className: "grid size-8 place-items-center rounded-full bg-brand-red/8 text-xs font-extrabold text-brand-red", children: identity.initials }), _jsx("span", { className: "hidden max-w-40 truncate font-bold text-brand-ink min-[900px]:block", children: identity.name })] }))) : null] })] }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface BarChartDatum {
|
|
2
|
+
label: string;
|
|
3
|
+
value: number;
|
|
4
|
+
/** Short context for the bar, for example "12 pendaftaran". */
|
|
5
|
+
hint?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface BarChartProps {
|
|
8
|
+
data: BarChartDatum[];
|
|
9
|
+
/** `vertical` is the monthly trend; `horizontal` the facet distribution. */
|
|
10
|
+
orientation?: "vertical" | "horizontal";
|
|
11
|
+
/** Paints the newest bar in brand red, the earlier ones in the tint. */
|
|
12
|
+
highlightLast?: boolean;
|
|
13
|
+
/** Scale ceiling; defaults to the largest value, never below 1. */
|
|
14
|
+
max?: number;
|
|
15
|
+
/** Chart height in pixels for the vertical orientation. Default 150. */
|
|
16
|
+
height?: number;
|
|
17
|
+
formatValue?: (value: number) => string;
|
|
18
|
+
/** Hairlines behind the vertical bars. */
|
|
19
|
+
gridLines?: boolean;
|
|
20
|
+
/** `table` adds a screen-reader `<table>` carrying the same numbers. */
|
|
21
|
+
fallback?: "table" | "none";
|
|
22
|
+
ariaLabel?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The bar chart: a monthly trend read top to bottom, or a facet distribution
|
|
27
|
+
* read left to right. Both orientations are divs on the token palette, so no
|
|
28
|
+
* chart library and no SVG maths is needed. Every bar carries a `title` and its
|
|
29
|
+
* label, a small value keeps a 4px visible bar, and `fallback="table"` gives a
|
|
30
|
+
* screen reader the same numbers.
|
|
31
|
+
*/
|
|
32
|
+
export declare function BarChart({ data, orientation, highlightLast, max, height, formatValue, gridLines, fallback, ariaLabel, className, }: BarChartProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
const defaultFormat = (value) => value.toLocaleString("id-ID");
|
|
4
|
+
/**
|
|
5
|
+
* The bar chart: a monthly trend read top to bottom, or a facet distribution
|
|
6
|
+
* read left to right. Both orientations are divs on the token palette, so no
|
|
7
|
+
* chart library and no SVG maths is needed. Every bar carries a `title` and its
|
|
8
|
+
* label, a small value keeps a 4px visible bar, and `fallback="table"` gives a
|
|
9
|
+
* screen reader the same numbers.
|
|
10
|
+
*/
|
|
11
|
+
export function BarChart({ data, orientation = "vertical", highlightLast = false, max, height = 150, formatValue = defaultFormat, gridLines = false, fallback = "none", ariaLabel, className, }) {
|
|
12
|
+
const ceiling = Math.max(max ?? 0, 1, ...data.map((item) => item.value));
|
|
13
|
+
const summary = data.length
|
|
14
|
+
? data.map((item) => `${item.label} ${formatValue(item.value)}${item.hint ? ` (${item.hint})` : ""}`).join(", ")
|
|
15
|
+
: "Tidak ada data";
|
|
16
|
+
const table = fallback === "table" ? (_jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: ariaLabel ?? summary }), _jsx("tbody", { children: data.map((item) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: item.label }), _jsx("td", { children: formatValue(item.value) }), _jsx("td", { children: item.hint ?? "" })] }, item.label))) })] })) : null;
|
|
17
|
+
if (orientation === "horizontal") {
|
|
18
|
+
return (_jsxs("div", { "data-slot": "bar-chart", "data-orientation": "horizontal", className: cn("grid min-w-0 gap-3", className), children: [_jsx("ol", { role: "img", "aria-label": ariaLabel ?? summary, className: "grid list-none gap-2.5", children: data.map((item) => {
|
|
19
|
+
const pct = ceiling > 0 ? (item.value / ceiling) * 100 : 0;
|
|
20
|
+
return (_jsxs("li", { className: "grid grid-cols-[minmax(96px,150px)_minmax(0,1fr)_52px] items-center gap-2.5 text-[13px]", children: [_jsx("span", { className: "min-w-0 truncate text-brand-ink", children: item.label }), _jsx("span", { className: "h-2.5 min-w-0 overflow-hidden rounded-full bg-secondary", children: _jsx("span", { className: "block h-full rounded-full bg-brand-red", style: { width: `${pct}%` }, title: item.hint ?? `${item.label}: ${formatValue(item.value)}` }) }), _jsx("strong", { className: "text-right font-extrabold tabular-nums text-brand-ink", children: formatValue(item.value) })] }, item.label));
|
|
21
|
+
}) }), table] }));
|
|
22
|
+
}
|
|
23
|
+
return (_jsxs("div", { "data-slot": "bar-chart", "data-orientation": "vertical", className: cn("grid min-w-0 gap-2", className), children: [_jsxs("div", { role: "img", "aria-label": ariaLabel ?? summary, className: "relative flex items-stretch gap-2", style: { height }, children: [gridLines ? (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute inset-0 grid grid-rows-4", children: [0, 1, 2, 3].map((line) => (_jsx("span", { className: "border-t border-dashed border-brand-border" }, line))) })) : null, data.map((item, index) => {
|
|
24
|
+
const pct = ceiling > 0 ? (item.value / ceiling) * 100 : 0;
|
|
25
|
+
const last = index === data.length - 1;
|
|
26
|
+
return (_jsxs("span", { className: "flex min-w-0 flex-1 flex-col justify-end gap-1.5", children: [_jsx("span", { className: "text-center text-[10px] font-semibold text-brand-muted tabular-nums", children: formatValue(item.value) }), _jsx("span", { className: cn("block min-h-1 w-full rounded-t-[3px]", highlightLast && last ? "bg-brand-red" : "bg-[#efbbc4]"), style: { height: `${pct}%` }, title: item.hint ?? `${item.label}: ${formatValue(item.value)}` })] }, item.label));
|
|
27
|
+
})] }), _jsx("div", { "aria-hidden": true, className: "flex gap-2", children: data.map((item) => (_jsx("span", { className: "min-w-0 flex-1 truncate text-center text-[10px] text-brand-muted", children: item.label }, item.label))) }), table] }));
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type CalloutTone = "info" | "success" | "warning" | "danger" | "neutral";
|
|
3
|
+
export interface CalloutProps {
|
|
4
|
+
tone?: CalloutTone;
|
|
5
|
+
/** Leading Tabler icon. The component hides it from assistive technology. */
|
|
6
|
+
icon?: React.ReactNode;
|
|
7
|
+
title?: string;
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
/** Inline control slot, for example the "Lengkapi data" Button. */
|
|
10
|
+
action?: React.ReactNode;
|
|
11
|
+
/** `compact` is the admin drawer note: a 12px body with 10px padding. */
|
|
12
|
+
density?: "default" | "compact";
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* One inline note: a colour step, an optional icon, a title and a body, plus
|
|
17
|
+
* one action. Replaces the flag, claim, read-only, sample-note, audit-note,
|
|
18
|
+
* study-prompt, section-note, drawer-stamp and drawer-note rules. Text contrast
|
|
19
|
+
* is checked per tone: the lightest is 5.7:1 against its own surface.
|
|
20
|
+
*/
|
|
21
|
+
export declare function Callout({ tone, icon, title, children, action, density, className }: CalloutProps): React.JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
/** The prototype's nine inline notes: the same shape, four urgency steps and a quiet neutral. */
|
|
4
|
+
const TONES = {
|
|
5
|
+
info: "bg-[#f0f4fa] text-[#4c627b]",
|
|
6
|
+
success: "bg-[#f1f5f3] text-[#35604a]",
|
|
7
|
+
warning: "bg-[#fff5e6] text-[#7c5014]",
|
|
8
|
+
danger: "border border-[#f0ccd4] bg-[#fdf2f4] text-[#8f1730]",
|
|
9
|
+
neutral: "border border-[#e6eaed] bg-[#f8fafb] text-[#56636d]",
|
|
10
|
+
};
|
|
11
|
+
/** `role="alert"` interrupts, so only the tone that reports a problem claims it. */
|
|
12
|
+
const ROLES = {
|
|
13
|
+
info: "status",
|
|
14
|
+
success: "status",
|
|
15
|
+
warning: undefined,
|
|
16
|
+
danger: "alert",
|
|
17
|
+
neutral: undefined,
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* One inline note: a colour step, an optional icon, a title and a body, plus
|
|
21
|
+
* one action. Replaces the flag, claim, read-only, sample-note, audit-note,
|
|
22
|
+
* study-prompt, section-note, drawer-stamp and drawer-note rules. Text contrast
|
|
23
|
+
* is checked per tone: the lightest is 5.7:1 against its own surface.
|
|
24
|
+
*/
|
|
25
|
+
export function Callout({ tone = "info", icon, title, children, action, density = "default", className }) {
|
|
26
|
+
const compact = density === "compact";
|
|
27
|
+
return (_jsxs("div", { "data-slot": "callout", "data-tone": tone, role: ROLES[tone], className: cn("rounded-lg", compact ? "px-3 py-2.5 text-xs leading-5" : "px-3.5 py-3 text-[13px] leading-5", TONES[tone], className), children: [_jsxs("div", { className: "flex items-start gap-2.5", children: [icon ? (_jsx("span", { "aria-hidden": true, className: "mt-0.5 shrink-0 [&_svg]:size-[18px]", children: icon })) : null, _jsxs("div", { className: "min-w-0", children: [title ? _jsx("strong", { className: cn("block font-bold", compact ? "text-xs" : "text-[13px]"), children: title }) : null, _jsx("div", { className: cn("min-w-0", title && "mt-0.5 text-xs"), children: children })] })] }), action ? (_jsx("div", { className: cn("mt-2.5 flex flex-wrap items-center gap-2", icon && "min-[600px]:pl-7", "max-[600px]:[&_[data-slot=button]]:min-h-11"), children: action })) : null] }));
|
|
28
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface DistributionSegment {
|
|
2
|
+
label: string;
|
|
3
|
+
value: number;
|
|
4
|
+
/** Defaults to the token ramp: brand red first, then its tints, then the neutral greys. */
|
|
5
|
+
color?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DistributionBarProps {
|
|
8
|
+
segments: DistributionSegment[];
|
|
9
|
+
/** `sm` is the 8px figures-band bar, `md` the 12px stacked bar. A taller card bar takes `className="h-[18px]"`. */
|
|
10
|
+
size?: "sm" | "md";
|
|
11
|
+
/** `inline` is the figures-band legend, `list` the Statistik legend with counts. */
|
|
12
|
+
legend?: "list" | "inline" | "none";
|
|
13
|
+
/** Adds a labelled total row to the `list` legend. */
|
|
14
|
+
totalLabel?: string;
|
|
15
|
+
formatValue?: (value: number) => string;
|
|
16
|
+
ariaLabel?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* One stacked bar: the completeness split on the dashboard figures band, the
|
|
21
|
+
* degree composition beside it and the Statistik completeness card. Every
|
|
22
|
+
* segment carries its own `title`, the legend is text rather than colour alone,
|
|
23
|
+
* and the accessible name names every segment with its count.
|
|
24
|
+
*/
|
|
25
|
+
export declare function DistributionBar({ segments, size, legend, totalLabel, formatValue, ariaLabel, className }: DistributionBarProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
const PALETTE = ["var(--brand-red)", "#efbbc4", "#c6ccd2", "#fae6e9", "#1c1f23"];
|
|
4
|
+
const defaultFormat = (value) => value.toLocaleString("id-ID");
|
|
5
|
+
/**
|
|
6
|
+
* One stacked bar: the completeness split on the dashboard figures band, the
|
|
7
|
+
* degree composition beside it and the Statistik completeness card. Every
|
|
8
|
+
* segment carries its own `title`, the legend is text rather than colour alone,
|
|
9
|
+
* and the accessible name names every segment with its count.
|
|
10
|
+
*/
|
|
11
|
+
export function DistributionBar({ segments, size = "sm", legend = "list", totalLabel, formatValue = defaultFormat, ariaLabel, className }) {
|
|
12
|
+
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
|
|
13
|
+
const summary = segments.length
|
|
14
|
+
? segments.map((segment) => `${segment.label} ${formatValue(segment.value)}`).join(", ")
|
|
15
|
+
: "Tidak ada data";
|
|
16
|
+
return (_jsxs("div", { "data-slot": "distribution-bar", "data-size": size, className: cn("grid min-w-0 gap-2", className), children: [_jsx("div", { role: "img", "aria-label": ariaLabel ?? summary, className: cn("flex min-w-0 gap-0.5 overflow-hidden", size === "sm" ? "h-2 rounded-full" : "h-3 rounded-md"), children: segments.map((segment, index) => {
|
|
17
|
+
const pct = total > 0 ? (segment.value / total) * 100 : 0;
|
|
18
|
+
const color = segment.color ?? PALETTE[index % PALETTE.length];
|
|
19
|
+
return pct > 0 ? (_jsx("span", { className: "block h-full min-w-0", style: { width: `${pct}%`, background: color }, title: `${segment.label}: ${formatValue(segment.value)}` }, segment.label)) : null;
|
|
20
|
+
}) }), legend === "inline" ? (_jsx("p", { className: "flex min-w-0 flex-wrap gap-x-3 gap-y-1 text-[11px] text-brand-muted", children: segments.map((segment, index) => (_jsxs("span", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: segment.color ?? PALETTE[index % PALETTE.length] } }), _jsxs("span", { className: "truncate", children: [segment.label, " ", _jsx("strong", { className: "font-bold text-brand-ink tabular-nums", children: formatValue(segment.value) })] })] }, segment.label))) })) : null, legend === "list" ? (_jsxs("ul", { className: "flex min-w-0 flex-wrap gap-x-[18px] gap-y-2 text-xs text-brand-muted", children: [segments.map((segment, index) => (_jsxs("li", { className: "flex min-w-0 items-center gap-1.5", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: segment.color ?? PALETTE[index % PALETTE.length] } }), _jsx("span", { className: "truncate", children: segment.label }), _jsx("strong", { className: "font-bold text-brand-ink tabular-nums", children: formatValue(segment.value) })] }, segment.label))), totalLabel ? (_jsxs("li", { className: "flex min-w-0 items-center gap-1.5 border-t border-brand-border pt-1.5", children: [_jsx("span", { className: "truncate font-semibold text-brand-ink", children: totalLabel }), _jsx("strong", { className: "font-extrabold text-brand-ink tabular-nums", children: formatValue(total) })] })) : null] })) : null] }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { type FactListItem } from "./fact-list";
|
|
3
|
+
export type DocumentViewerLocale = "id" | "en";
|
|
4
|
+
export interface DocumentSide {
|
|
5
|
+
id: string;
|
|
6
|
+
/** Tab label, for example "Sisi depan". */
|
|
7
|
+
label: string;
|
|
8
|
+
kind: "image" | "pdf";
|
|
9
|
+
/** File name in the file row. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Quiet line under the name, for example "Berkas contoh · JPG". */
|
|
12
|
+
meta?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface DocumentViewerProps {
|
|
15
|
+
/** The sides the document has; one or two in practice. */
|
|
16
|
+
sides: DocumentSide[];
|
|
17
|
+
/** The selected side id. */
|
|
18
|
+
value: string;
|
|
19
|
+
onChange: (id: string) => void;
|
|
20
|
+
/** The app's preview stage: the real image, the PDF viewer or a placeholder. */
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
/** Facts under the stage, for example the document's own fields. */
|
|
23
|
+
facts?: FactListItem[];
|
|
24
|
+
loading?: boolean;
|
|
25
|
+
emptyLabel?: string;
|
|
26
|
+
locale: DocumentViewerLocale;
|
|
27
|
+
/** Accessible name of the stage region. */
|
|
28
|
+
label?: string;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The student-card frame: the side switcher, the file row, the app's preview
|
|
33
|
+
* stage and the document's own facts. The file itself and the preview stay
|
|
34
|
+
* app-side; this component owns the frame, the accessible names and the
|
|
35
|
+
* loading and empty states. Side tabs are 44px below 600px and 32px from
|
|
36
|
+
* 600px up, and the facts list is one column on a phone.
|
|
37
|
+
*/
|
|
38
|
+
export declare function DocumentViewer({ sides, value, onChange, children, facts, loading, emptyLabel, locale, label, className }: DocumentViewerProps): React.JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { IconFileTypePdf, IconPhoto } from "@tabler/icons-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { FactList } from "./fact-list";
|
|
5
|
+
import { Skeleton } from "./skeleton";
|
|
6
|
+
const labels = {
|
|
7
|
+
id: { preview: "Pratinjau berkas", image: "Berkas gambar", pdf: "Berkas PDF", empty: "Tidak ada dokumen untuk ditampilkan.", loading: "Memuat pratinjau dokumen" },
|
|
8
|
+
en: { preview: "File preview", image: "Image file", pdf: "PDF file", empty: "No document to show.", loading: "Loading the document preview" },
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The student-card frame: the side switcher, the file row, the app's preview
|
|
12
|
+
* stage and the document's own facts. The file itself and the preview stay
|
|
13
|
+
* app-side; this component owns the frame, the accessible names and the
|
|
14
|
+
* loading and empty states. Side tabs are 44px below 600px and 32px from
|
|
15
|
+
* 600px up, and the facts list is one column on a phone.
|
|
16
|
+
*/
|
|
17
|
+
export function DocumentViewer({ sides, value, onChange, children, facts, loading = false, emptyLabel, locale, label, className }) {
|
|
18
|
+
const l = labels[locale];
|
|
19
|
+
const selected = sides.find((side) => side.id === value) ?? sides[0];
|
|
20
|
+
const stageName = label ?? l.preview;
|
|
21
|
+
if (!sides.length) {
|
|
22
|
+
return (_jsx("div", { "data-slot": "document-viewer", className: cn("grid rounded-[11px] border border-[#dce3e7] bg-card p-5 text-[13px] text-brand-muted", className), children: emptyLabel ?? l.empty }));
|
|
23
|
+
}
|
|
24
|
+
return (_jsxs("div", { "data-slot": "document-viewer", "data-loading": loading, className: cn("grid min-w-0 gap-3", className), children: [sides.length > 1 ? (_jsx("div", { role: "group", "aria-label": stageName, className: "inline-flex w-fit max-w-full gap-1 overflow-x-auto rounded-[9px] border border-[#e4e8eb] bg-card p-[3px]", children: sides.map((side) => (_jsx("button", { type: "button", "aria-pressed": side.id === selected?.id, onClick: () => onChange(side.id), className: cn("min-h-11 shrink-0 rounded-[7px] px-3 text-[13px] font-semibold transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none min-[600px]:min-h-8", side.id === selected?.id ? "bg-secondary text-brand-ink" : "text-brand-muted hover:text-brand-ink"), children: side.label }, side.id))) })) : null, _jsxs("div", { className: "overflow-hidden rounded-[11px] border border-[#dce3e7] bg-card", children: [selected ? (_jsxs("div", { className: "flex items-center gap-3 border-b border-[#e4e9ec] px-4 py-3 text-brand-red", children: [_jsx("span", { role: "img", "aria-label": selected.kind === "pdf" ? l.pdf : l.image, className: "shrink-0", children: selected.kind === "pdf" ? _jsx(IconFileTypePdf, { "aria-hidden": true, className: "size-[22px]" }) : _jsx(IconPhoto, { "aria-hidden": true, className: "size-[22px]" }) }), _jsxs("span", { className: "grid min-w-0 gap-0.5", children: [_jsx("strong", { className: "truncate text-sm text-brand-ink", children: selected.name }), selected.meta ? _jsx("span", { className: "truncate text-xs text-brand-muted", children: selected.meta }) : null] })] })) : null, _jsx("div", { role: "region", "aria-label": stageName, "aria-live": "polite", className: "grid min-h-[220px] place-items-center bg-card p-5", children: loading ? (_jsxs("div", { role: "status", "aria-busy": "true", "aria-label": l.loading, className: "grid w-full max-w-[300px] gap-3", children: [_jsx(Skeleton, { "aria-hidden": "true", className: "h-[178px] w-full rounded-lg" }), _jsx(Skeleton, { "aria-hidden": "true", className: "h-4 w-2/3" }), _jsx(Skeleton, { "aria-hidden": "true", className: "h-4 w-1/3" })] })) : (children) }), facts?.length ? _jsx(FactList, { variant: "twoColumn", items: facts, className: "border-t border-[#e4e9ec] bg-[#fbfcfc] px-4 py-3.5" }) : null] })] }));
|
|
25
|
+
}
|
|
@@ -17,7 +17,7 @@ function DrawerOverlay({ className, ...props }) {
|
|
|
17
17
|
return (_jsx(DrawerPrimitive.Overlay, { "data-slot": "drawer-overlay", className: cn("fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", className), ...props }));
|
|
18
18
|
}
|
|
19
19
|
function DrawerContent({ className, children, ...props }) {
|
|
20
|
-
return (_jsxs(DrawerPortal, { "data-slot": "drawer-portal", children: [_jsx(DrawerOverlay, {}), _jsxs(DrawerPrimitive.Content, { "data-slot": "drawer-content", className: cn("group/drawer-content fixed z-50 flex h-auto flex-col bg-
|
|
20
|
+
return (_jsxs(DrawerPortal, { "data-slot": "drawer-portal", children: [_jsx(DrawerOverlay, {}), _jsxs(DrawerPrimitive.Content, { "data-slot": "drawer-content", className: cn("group/drawer-content fixed z-50 flex h-auto flex-col bg-card", "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b", "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm", "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm", className), ...props, children: [_jsx("div", { className: "mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" }), children] })] }));
|
|
21
21
|
}
|
|
22
22
|
function DrawerHeader({ className, ...props }) {
|
|
23
23
|
return (_jsx("div", { "data-slot": "drawer-header", className: cn("flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left", className), ...props }));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface ExpandableTextProps {
|
|
3
|
+
/** The text to clamp. Only a string: the clamp needs the text itself. */
|
|
4
|
+
children: string;
|
|
5
|
+
/** Lines shown before the toggle appears. Default 3. */
|
|
6
|
+
lines?: number;
|
|
7
|
+
/** Starts expanded; the component is uncontrolled. Default false. */
|
|
8
|
+
defaultExpanded?: boolean;
|
|
9
|
+
/** Toggle label while clamped. Default "Lihat selengkapnya". */
|
|
10
|
+
expandLabel?: string;
|
|
11
|
+
/** Toggle label while expanded. Default "Tampilkan lebih sedikit". */
|
|
12
|
+
collapseLabel?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Long text clamped to a few lines with a Lihat selengkapnya toggle. The clamp
|
|
17
|
+
* is CSS, so the whole text stays in the accessibility tree and stays findable;
|
|
18
|
+
* the toggle only appears when the text actually overflows, and it reports
|
|
19
|
+
* `aria-expanded`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ExpandableText({ children, lines, defaultExpanded, expandLabel, collapseLabel, className }: ExpandableTextProps): React.JSX.Element;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
/**
|
|
5
|
+
* Long text clamped to a few lines with a Lihat selengkapnya toggle. The clamp
|
|
6
|
+
* is CSS, so the whole text stays in the accessibility tree and stays findable;
|
|
7
|
+
* the toggle only appears when the text actually overflows, and it reports
|
|
8
|
+
* `aria-expanded`.
|
|
9
|
+
*/
|
|
10
|
+
export function ExpandableText({ children, lines = 3, defaultExpanded = false, expandLabel = "Lihat selengkapnya", collapseLabel = "Tampilkan lebih sedikit", className }) {
|
|
11
|
+
const textRef = React.useRef(null);
|
|
12
|
+
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
|
13
|
+
const [canExpand, setCanExpand] = React.useState(false);
|
|
14
|
+
// The clamp is applied while collapsed, so overflow is measurable. Expanded
|
|
15
|
+
// text is not re-measured: `canExpand` keeps its last value so the collapse
|
|
16
|
+
// direction stays available.
|
|
17
|
+
React.useEffect(() => {
|
|
18
|
+
const element = textRef.current;
|
|
19
|
+
if (!element)
|
|
20
|
+
return;
|
|
21
|
+
// Only measure while clamped: expanded text cannot overflow its clamp box.
|
|
22
|
+
if (expanded) {
|
|
23
|
+
setCanExpand(true);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const measure = () => setCanExpand(element.scrollHeight > element.clientHeight + 1);
|
|
27
|
+
measure();
|
|
28
|
+
const observer = new ResizeObserver(measure);
|
|
29
|
+
observer.observe(element);
|
|
30
|
+
return () => observer.disconnect();
|
|
31
|
+
}, [children, lines, expanded]);
|
|
32
|
+
return (_jsxs("span", { "data-slot": "expandable-text", className: cn("grid justify-items-start gap-1", className), children: [_jsx("span", { ref: textRef, className: cn("block min-w-0 break-words", !expanded && "overflow-hidden"), style: expanded ? undefined : { display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: lines }, children: children }), canExpand ? (_jsx("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded((value) => !value), className: cn("-ml-px text-left text-[13px] font-bold text-brand-red underline-offset-2 hover:underline", "min-h-8 max-[600px]:min-h-11", "focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"), children: expanded ? collapseLabel : expandLabel })) : null] }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface FactListItem {
|
|
3
|
+
label: string;
|
|
4
|
+
value: React.ReactNode;
|
|
5
|
+
/** Spans the full width in the column variants, for example a long address. */
|
|
6
|
+
span?: boolean;
|
|
7
|
+
/** Quiets the value, for text that is absent or not applicable. */
|
|
8
|
+
muted?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type FactListVariant = "rows" | "inline" | "twoColumn";
|
|
11
|
+
export interface FactListProps {
|
|
12
|
+
items: FactListItem[];
|
|
13
|
+
/**
|
|
14
|
+
* `rows` is the drawer detail: a fixed 96px term column, one fact per row,
|
|
15
|
+
* with a hairline between rows. `twoColumn` stacks term over value in two
|
|
16
|
+
* columns, for review field groups. `inline` puts the value beside its term,
|
|
17
|
+
* two facts per row, for short document facts. Both column variants collapse
|
|
18
|
+
* to one column below 600px; `rows` keeps its term column on a phone.
|
|
19
|
+
*/
|
|
20
|
+
variant?: FactListVariant;
|
|
21
|
+
/** A final emphasised row, for example a coverage total. */
|
|
22
|
+
total?: {
|
|
23
|
+
label: string;
|
|
24
|
+
value: React.ReactNode;
|
|
25
|
+
};
|
|
26
|
+
/** Shown instead of the list when there is nothing to show. */
|
|
27
|
+
emptyLabel?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Term and value rows: the drawer detail, the review field groups, the document
|
|
32
|
+
* facts, the coverage list, the role detail and the invite summary. It renders
|
|
33
|
+
* a real `<dl>`, so every term stays beside its value and a masked value keeps
|
|
34
|
+
* its own label. Presentation only; the page decides what each value says.
|
|
35
|
+
*/
|
|
36
|
+
export declare function FactList({ items, variant, total, emptyLabel, className }: FactListProps): React.JSX.Element | null;
|