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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
/** The approved token ramp: brand red first, then its tints, then the neutral greys. */
|
|
5
|
+
const PALETTE = ["var(--brand-red)", "#efbbc4", "#fae6e9", "#c6ccd2", "#1c1f23"];
|
|
6
|
+
const OTHER_LABEL = "Lainnya";
|
|
7
|
+
const defaultFormat = (value) => value.toLocaleString("id-ID");
|
|
8
|
+
/** One slice every 0.1% apart, with the rounding residual given back to the largest slice. */
|
|
9
|
+
function withPercentages(slices) {
|
|
10
|
+
const total = slices.reduce((sum, slice) => sum + slice.value, 0);
|
|
11
|
+
if (total <= 0)
|
|
12
|
+
return slices.map((slice) => ({ ...slice, pct: 0 }));
|
|
13
|
+
const rounded = slices.map((slice) => Math.round((slice.value / total) * 1000) / 10);
|
|
14
|
+
const residual = Math.round((100 - rounded.reduce((sum, pct) => sum + pct, 0)) * 10) / 10;
|
|
15
|
+
if (residual !== 0) {
|
|
16
|
+
const largest = rounded.indexOf(Math.max(...rounded));
|
|
17
|
+
rounded[largest] = Math.round((rounded[largest] + residual) * 10) / 10;
|
|
18
|
+
}
|
|
19
|
+
return slices.map((slice, index) => ({ ...slice, pct: rounded[index] }));
|
|
20
|
+
}
|
|
21
|
+
/** Keeps the largest slices and folds the rest into one "Lainnya" slice. */
|
|
22
|
+
function withMaxSlices(data, maxSlices) {
|
|
23
|
+
const positive = data.filter((slice) => slice.value > 0);
|
|
24
|
+
if (positive.length <= maxSlices)
|
|
25
|
+
return positive;
|
|
26
|
+
const sorted = [...positive].sort((a, b) => b.value - a.value);
|
|
27
|
+
const kept = sorted.slice(0, maxSlices - 1);
|
|
28
|
+
const rest = sorted.slice(maxSlices - 1);
|
|
29
|
+
return [...kept, { label: OTHER_LABEL, value: rest.reduce((sum, slice) => sum + slice.value, 0), color: PALETTE[3] }];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A pie or donut chart drawn by hand: one SVG path per slice, no dependency.
|
|
33
|
+
* The owner's two charts for Statistik (Jenis kelamin and Anggota Biasa vs Luar
|
|
34
|
+
* Biasa) are this component. Every slice is named in the legend beside its
|
|
35
|
+
* count and its percentage, so colour is never the only carrier, and
|
|
36
|
+
* `fallback="table"` puts the same numbers in a table for a screen reader.
|
|
37
|
+
*/
|
|
38
|
+
export function PieChart({ data, size = 168, donut = false, centerLabel, centerValue, legend = "list", fallback = "none", maxSlices = 5, formatValue = defaultFormat, ariaLabel, className, }) {
|
|
39
|
+
const slices = React.useMemo(() => withPercentages(withMaxSlices(data, maxSlices)), [data, maxSlices]);
|
|
40
|
+
const total = slices.reduce((sum, slice) => sum + slice.value, 0);
|
|
41
|
+
const cx = size / 2;
|
|
42
|
+
const cy = size / 2;
|
|
43
|
+
const rOuter = size / 2 - 1;
|
|
44
|
+
const rInner = donut ? rOuter * 0.58 : 0;
|
|
45
|
+
const polar = (radius, deg) => {
|
|
46
|
+
const radians = ((deg - 90) * Math.PI) / 180;
|
|
47
|
+
return [cx + radius * Math.cos(radians), cy + radius * Math.sin(radians)];
|
|
48
|
+
};
|
|
49
|
+
/** One closed ring segment: outer arc, inner arc, back to the start. */
|
|
50
|
+
const ringSeg = (start, end) => {
|
|
51
|
+
const large = end - start > 180 ? 1 : 0;
|
|
52
|
+
const [x1, y1] = polar(rOuter, start);
|
|
53
|
+
const [x2, y2] = polar(rOuter, end);
|
|
54
|
+
const outer = `M ${x1} ${y1} A ${rOuter} ${rOuter} 0 ${large} 1 ${x2} ${y2}`;
|
|
55
|
+
if (rInner <= 0)
|
|
56
|
+
return `${outer} L ${cx} ${cy} Z`;
|
|
57
|
+
const [x3, y3] = polar(rInner, end);
|
|
58
|
+
const [x4, y4] = polar(rInner, start);
|
|
59
|
+
return `${outer} L ${x3} ${y3} A ${rInner} ${rInner} 0 ${large} 0 ${x4} ${y4} Z`;
|
|
60
|
+
};
|
|
61
|
+
let cursor = 0;
|
|
62
|
+
const arcs = slices.map((slice, index) => {
|
|
63
|
+
const sweep = (slice.pct / 100) * 360;
|
|
64
|
+
const start = cursor;
|
|
65
|
+
const end = cursor + sweep;
|
|
66
|
+
cursor = end;
|
|
67
|
+
// A full circle degenerates into one arc, so it is drawn as two halves.
|
|
68
|
+
const path = sweep >= 359.9 ? `${ringSeg(0, 180)} ${ringSeg(180, 360)}` : ringSeg(start, end);
|
|
69
|
+
return { ...slice, path, color: slice.color ?? PALETTE[index % PALETTE.length] };
|
|
70
|
+
});
|
|
71
|
+
const summary = arcs.length
|
|
72
|
+
? arcs.map((arc) => `${arc.label} ${formatValue(arc.value)} (${arc.pct}%)`).join(", ")
|
|
73
|
+
: "Tidak ada data";
|
|
74
|
+
return (_jsxs("div", { "data-slot": "pie-chart", "data-size": size, className: cn("grid min-w-0 gap-3", className), children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center justify-center gap-4", children: [_jsxs("svg", { role: "img", "aria-label": ariaLabel ?? summary, width: size, height: size, viewBox: `0 0 ${size} ${size}`, className: "shrink-0", children: [arcs.map((arc) => (_jsx("path", { d: arc.path, fill: arc.color, stroke: "var(--card)", strokeWidth: 1, vectorEffect: "non-scaling-stroke", fillRule: "evenodd", children: _jsx("title", { children: `${arc.label}: ${formatValue(arc.value)} (${arc.pct}%)` }) }, arc.label))), donut && (centerValue || centerLabel) ? (_jsxs(_Fragment, { children: [_jsx("text", { x: cx, y: centerLabel ? cy - 2 : cy + 4, textAnchor: "middle", className: "fill-brand-ink text-[18px] font-extrabold", children: centerValue }), centerLabel ? (_jsx("text", { x: cx, y: cy + 14, textAnchor: "middle", className: "fill-brand-muted text-[11px]", children: centerLabel })) : null] })) : null] }), legend === "list" ? (_jsxs("ul", { className: "grid min-w-0 gap-1.5 text-[13px]", children: [arcs.map((arc) => (_jsxs("li", { className: "flex min-w-0 items-center gap-2", children: [_jsx("span", { "aria-hidden": true, className: "size-2.5 shrink-0 rounded-[3px]", style: { background: arc.color } }), _jsx("span", { className: "min-w-0 flex-1 truncate text-brand-ink", children: arc.label }), _jsx("span", { className: "shrink-0 font-semibold text-brand-ink tabular-nums", children: formatValue(arc.value) }), _jsxs("span", { className: "w-11 shrink-0 text-right text-brand-muted tabular-nums", children: [arc.pct, "%"] })] }, arc.label))), arcs.length && (centerLabel || total) ? (_jsxs("li", { className: "mt-0.5 flex items-center gap-2 border-t border-brand-border pt-1.5 text-brand-muted", children: [_jsx("span", { className: "min-w-0 flex-1", children: centerLabel ?? "Total" }), _jsx("span", { className: "shrink-0 font-bold text-brand-ink tabular-nums", children: formatValue(total) }), _jsx("span", { className: "w-11 shrink-0" })] })) : null] })) : null] }), fallback === "table" ? (_jsxs("table", { className: "sr-only", children: [_jsx("caption", { children: ariaLabel ?? summary }), _jsx("tbody", { children: arcs.map((arc) => (_jsxs("tr", { children: [_jsx("th", { scope: "row", children: arc.label }), _jsx("td", { children: formatValue(arc.value) }), _jsxs("td", { children: [arc.pct, "%"] })] }, arc.label))) })] })) : null] }));
|
|
75
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface RevealValueProps {
|
|
3
|
+
/** Shown while the value is hidden, for example "••••••••". */
|
|
4
|
+
masked: React.ReactNode;
|
|
5
|
+
/** Shown after the reveal, for example "PPIJ-2026-000123". */
|
|
6
|
+
clear: React.ReactNode;
|
|
7
|
+
revealed: boolean;
|
|
8
|
+
onReveal: () => void;
|
|
9
|
+
onHide: () => void;
|
|
10
|
+
/** Toggle label while hidden. Default "Tampilkan". */
|
|
11
|
+
showLabel?: string;
|
|
12
|
+
/** Toggle label while revealed. Default "Sembunyikan". */
|
|
13
|
+
hideLabel?: string;
|
|
14
|
+
/** Hides the toggle text; the accessible name stays. */
|
|
15
|
+
iconOnly?: boolean;
|
|
16
|
+
/** Announced in place of the mask glyphs; the reveal itself stays app-side and audited. */
|
|
17
|
+
maskedLabel?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A masked value with the Tampilkan/Sembunyikan toggle. The masked state is
|
|
22
|
+
* announced as hidden instead of spelling out dots, the toggle is a real button,
|
|
23
|
+
* and the revealed value is announced on its own so the rest of the row is not
|
|
24
|
+
* read again. The audited reveal is the caller's callback.
|
|
25
|
+
*/
|
|
26
|
+
export declare function RevealValue({ masked, clear, revealed, onReveal, onHide, showLabel, hideLabel, iconOnly, maskedLabel, className, }: RevealValueProps): React.JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { IconEye, IconEyeOff } from "@tabler/icons-react";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
/**
|
|
5
|
+
* A masked value with the Tampilkan/Sembunyikan toggle. The masked state is
|
|
6
|
+
* announced as hidden instead of spelling out dots, the toggle is a real button,
|
|
7
|
+
* and the revealed value is announced on its own so the rest of the row is not
|
|
8
|
+
* read again. The audited reveal is the caller's callback.
|
|
9
|
+
*/
|
|
10
|
+
export function RevealValue({ masked, clear, revealed, onReveal, onHide, showLabel = "Tampilkan", hideLabel = "Sembunyikan", iconOnly = false, maskedLabel = "Nilai tersembunyi", className, }) {
|
|
11
|
+
return (_jsxs("span", { "data-slot": "reveal-value", className: cn("inline-flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1.5", className), children: [_jsx("span", { "aria-live": "polite", className: cn("min-w-0 break-words text-[13px] font-semibold", revealed ? "text-brand-ink" : "tracking-[.1em] text-brand-muted"), children: revealed ? (clear) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "sr-only", children: maskedLabel }), _jsx("span", { "aria-hidden": true, children: masked })] })) }), _jsxs("button", { type: "button", onClick: revealed ? onHide : onReveal, className: cn("inline-flex shrink-0 items-center gap-1 rounded-full border border-brand-border bg-card px-2.5 py-0.5 text-[11px] font-bold text-brand-red transition-colors hover:bg-brand-red/5", "min-h-8 max-[600px]:min-h-11 max-[600px]:px-3 max-[600px]:text-xs", "focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"), children: [revealed ? _jsx(IconEyeOff, { className: "size-3.5", "aria-hidden": true }) : _jsx(IconEye, { className: "size-3.5", "aria-hidden": true }), _jsx("span", { className: iconOnly ? "sr-only" : undefined, children: revealed ? hideLabel : showLabel })] })] }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface SearchableMultiSelectProps {
|
|
3
|
+
/** Names the control; also the accessible name of the trigger. */
|
|
4
|
+
label: string;
|
|
5
|
+
values: string[];
|
|
6
|
+
options: string[];
|
|
7
|
+
/** Single mode replaces the selection; multiple mode adds and removes. */
|
|
8
|
+
onChange: (values: string[]) => void;
|
|
9
|
+
/** Trigger label when nothing is selected, for example "Semua komsat". */
|
|
10
|
+
allLabel: string;
|
|
11
|
+
/** Hides the caption and drops the trigger to the compact band, for a toolbar. */
|
|
12
|
+
compact?: boolean;
|
|
13
|
+
single?: boolean;
|
|
14
|
+
/** The "all" row that clears the selection. Defaults to `!single`. */
|
|
15
|
+
showAllOption?: boolean;
|
|
16
|
+
searchPlaceholder?: string;
|
|
17
|
+
emptyLabel?: string;
|
|
18
|
+
/** Forces the caption on or off; by default it shows unless `compact`. */
|
|
19
|
+
visibleLabel?: boolean;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One searchable select for every unit and facet picker in the admin: the
|
|
25
|
+
* member directory, the invitation form, the management lists and the
|
|
26
|
+
* verifikasi filters. It merges the three local copies that had drifted.
|
|
27
|
+
*
|
|
28
|
+
* The trigger reads `allLabel`, the single value, or "N dipilih". Search
|
|
29
|
+
* matches with `toLocaleLowerCase("id")`, takes focus when the menu opens and
|
|
30
|
+
* resets when it closes; `Escape`, `Tab` and `ArrowDown` pass through to Radix
|
|
31
|
+
* and every other key is stopped so the highlight does not move. Checkbox items
|
|
32
|
+
* keep the menu open, and the "all" row clears the selection.
|
|
33
|
+
*/
|
|
34
|
+
export declare function SearchableMultiSelect({ label, values, options, onChange, allLabel, compact, single, showAllOption, searchPlaceholder, emptyLabel, visibleLabel, disabled, className, }: SearchableMultiSelectProps): React.JSX.Element;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { IconChevronDown } from "@tabler/icons-react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "./dropdown-menu";
|
|
7
|
+
import { Input } from "./input";
|
|
8
|
+
/**
|
|
9
|
+
* One searchable select for every unit and facet picker in the admin: the
|
|
10
|
+
* member directory, the invitation form, the management lists and the
|
|
11
|
+
* verifikasi filters. It merges the three local copies that had drifted.
|
|
12
|
+
*
|
|
13
|
+
* The trigger reads `allLabel`, the single value, or "N dipilih". Search
|
|
14
|
+
* matches with `toLocaleLowerCase("id")`, takes focus when the menu opens and
|
|
15
|
+
* resets when it closes; `Escape`, `Tab` and `ArrowDown` pass through to Radix
|
|
16
|
+
* and every other key is stopped so the highlight does not move. Checkbox items
|
|
17
|
+
* keep the menu open, and the "all" row clears the selection.
|
|
18
|
+
*/
|
|
19
|
+
export function SearchableMultiSelect({ label, values, options, onChange, allLabel, compact = false, single = false, showAllOption, searchPlaceholder, emptyLabel, visibleLabel, disabled = false, className, }) {
|
|
20
|
+
const [query, setQuery] = React.useState("");
|
|
21
|
+
const searchRef = React.useRef(null);
|
|
22
|
+
const showAll = showAllOption ?? !single;
|
|
23
|
+
const showCaption = visibleLabel ?? !compact;
|
|
24
|
+
const matches = options.filter((option) => option.toLocaleLowerCase("id").includes(query.trim().toLocaleLowerCase("id")));
|
|
25
|
+
const selectedLabel = values.length === 0 ? allLabel : values.length === 1 ? values[0] : `${values.length} dipilih`;
|
|
26
|
+
const placeholder = searchPlaceholder ?? `Cari ${label.toLocaleLowerCase("id")}`;
|
|
27
|
+
return (_jsxs("div", { "data-slot": "searchable-multi-select", "data-compact": compact, className: cn("grid min-w-0 gap-2", className), children: [_jsx("span", { className: cn("text-[13px] font-semibold text-brand-muted", showCaption ? undefined : "sr-only"), children: label }), _jsxs(DropdownMenu, { onOpenChange: (open) => {
|
|
28
|
+
if (open)
|
|
29
|
+
window.requestAnimationFrame(() => searchRef.current?.focus());
|
|
30
|
+
else
|
|
31
|
+
setQuery("");
|
|
32
|
+
}, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", disabled: disabled, "aria-label": `${label}: ${selectedLabel}`, className: "flex min-h-11 w-full min-w-0 justify-between gap-2.5 px-3 text-[13px] min-[600px]:h-8 min-[600px]:min-h-8", children: [_jsx("span", { className: "truncate", children: selectedLabel }), _jsx(IconChevronDown, { "aria-hidden": true, className: "size-4 shrink-0 text-brand-muted" })] }) }), _jsxs(DropdownMenuContent, { align: "start", className: "w-[max(230px,var(--radix-dropdown-menu-trigger-width))] max-w-[min(330px,calc(100vw-32px))] bg-card", children: [_jsx("div", { className: "p-[7px]", children: _jsx(Input, { ref: searchRef, "data-size": "sm", textSize: "compact", value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
|
|
33
|
+
if (!["Escape", "Tab", "ArrowDown"].includes(event.key))
|
|
34
|
+
event.stopPropagation();
|
|
35
|
+
}, placeholder: placeholder, "aria-label": placeholder }) }), _jsxs("div", { className: "max-h-[min(280px,42vh)] overflow-y-auto px-1 pb-1", children: [showAll ? (_jsx(DropdownMenuCheckboxItem, { checked: values.length === 0, onCheckedChange: () => onChange([]), onSelect: (event) => event.preventDefault(), className: "min-h-11 text-[13px] min-[600px]:min-h-[38px]", children: allLabel })) : null, matches.length ? (matches.map((option) => (_jsx(DropdownMenuCheckboxItem, { checked: values.includes(option), onCheckedChange: (checked) => onChange(single ? (checked ? [option] : []) : checked ? [...values, option] : values.filter((value) => value !== option)), onSelect: single ? undefined : (event) => event.preventDefault(), className: "min-h-11 text-[13px] min-[600px]:min-h-[38px]", children: option }, option)))) : (_jsx("p", { className: "px-2.5 py-2.5 text-[13px] text-brand-muted", children: emptyLabel ?? "Tidak ada pilihan yang cocok." }))] })] })] })] }));
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export interface StatStripItem {
|
|
3
|
+
value: React.ReactNode;
|
|
4
|
+
label: string;
|
|
5
|
+
/** One quiet line under the label, for example a period or a source. */
|
|
6
|
+
hint?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface StatStripProps {
|
|
9
|
+
items: StatStripItem[];
|
|
10
|
+
/** Columns from 600px up; the strip wraps to two columns on a phone. Default 3. */
|
|
11
|
+
columns?: number;
|
|
12
|
+
/**
|
|
13
|
+
* `default` is the Statistik totals band: a white card with 22px values and
|
|
14
|
+
* 11px labels. `compact` is the quiet in-card strip behind the map footer:
|
|
15
|
+
* a top hairline with 21px values and 12px labels.
|
|
16
|
+
*/
|
|
17
|
+
size?: "default" | "compact";
|
|
18
|
+
/** Accessible name of the strip. Without it the strip claims no landmark. */
|
|
19
|
+
label?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A value-over-label strip: the Statistik totals band and the map card footer.
|
|
24
|
+
* Presentation only; the page computes and formats every number, and a value
|
|
25
|
+
* that is a percentage always carries its label beside it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function StatStrip({ items, columns, size, label, className }: StatStripProps): React.JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
const columnClass = {
|
|
4
|
+
2: "grid-cols-2",
|
|
5
|
+
3: "grid-cols-3",
|
|
6
|
+
4: "grid-cols-2 min-[600px]:grid-cols-4",
|
|
7
|
+
5: "grid-cols-2 min-[601px]:grid-cols-3 min-[1101px]:grid-cols-5",
|
|
8
|
+
6: "grid-cols-2 min-[601px]:grid-cols-3 min-[1101px]:grid-cols-6",
|
|
9
|
+
};
|
|
10
|
+
/** A wrapped grid keeps a row-end hairline unless the item ends the row. */
|
|
11
|
+
const wrapClass = {
|
|
12
|
+
4: "max-[600px]:[&>div]:border-r-0",
|
|
13
|
+
5: "max-[1100px]:[&>div:nth-child(3n)]:border-r-0 max-[600px]:[&>div]:border-r-0",
|
|
14
|
+
6: "max-[1100px]:[&>div:nth-child(3n)]:border-r-0 max-[600px]:[&>div]:border-r-0",
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* A value-over-label strip: the Statistik totals band and the map card footer.
|
|
18
|
+
* Presentation only; the page computes and formats every number, and a value
|
|
19
|
+
* that is a percentage always carries its label beside it.
|
|
20
|
+
*/
|
|
21
|
+
export function StatStrip({ items, columns = 3, size = "default", label, className }) {
|
|
22
|
+
const compact = size === "compact";
|
|
23
|
+
const grid = cn("grid gap-y-3", columnClass[columns] ?? "grid-cols-3", "max-[600px]:[&>div]:px-2.5", "[&>div]:border-r [&>div]:border-brand-border [&>div:last-child]:border-r-0", wrapClass[columns]);
|
|
24
|
+
return (_jsx("dl", { "data-slot": "stat-strip", "data-size": size, ...(label ? { role: "group", "aria-label": label } : {}), className: cn(grid, compact ? "mt-4 border-t border-brand-border pt-3.5" : "rounded-xl border border-brand-border bg-card px-1.5 py-4", "[&>div]:min-w-0", compact ? "[&>div]:px-[18px] [&>div:first-child]:pl-0 [&>div:last-child]:pr-0" : "[&>div]:px-4 [&>div:first-child]:pl-2.5 [&>div:last-child]:pr-1.5", className), style: columnClass[columns] ? undefined : { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }, children: items.map((item) => (_jsxs("div", { className: "flex min-w-0 flex-col-reverse", children: [_jsxs("dt", { className: cn("text-brand-muted", compact ? "text-xs" : "text-[11px]"), children: [item.label, item.hint ? _jsx("span", { className: "mt-0.5 block text-xs text-brand-muted", children: item.hint }) : null] }), _jsx("dd", { className: cn("font-bold tracking-tight text-brand-ink max-[600px]:text-[18px]", compact ? "text-[21px]" : "text-[22px]"), children: item.value })] }, item.label))) }));
|
|
25
|
+
}
|
|
@@ -11,6 +11,11 @@ export interface StatusCardProps {
|
|
|
11
11
|
tone?: StatusCardTone;
|
|
12
12
|
/** Compact: one row, title and action only (the active and expiring states on Beranda). */
|
|
13
13
|
compact?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Admin density: 14px title, 13px body and a 32px action from 600px up, keeping
|
|
16
|
+
* the 44px action below 600px. The default density is the Beranda scale.
|
|
17
|
+
*/
|
|
18
|
+
density?: "default" | "compact";
|
|
14
19
|
className?: string;
|
|
15
20
|
}
|
|
16
21
|
/**
|
|
@@ -19,6 +24,6 @@ export interface StatusCardProps {
|
|
|
19
24
|
* expiring) use the compact one-row form; states whose body informs
|
|
20
25
|
* (submitted, needs changes, rejected) use the full card. The tone
|
|
21
26
|
* carries urgency: default white, warning amber, danger red, info sky,
|
|
22
|
-
* muted for closed memberships.
|
|
27
|
+
* muted for closed memberships. `density="compact"` is the admin scale.
|
|
23
28
|
*/
|
|
24
|
-
export declare function StatusCard({ icon, title, body, actionLabel, actionHref, onAction, tone, compact, className }: StatusCardProps): React.JSX.Element;
|
|
29
|
+
export declare function StatusCard({ icon, title, body, actionLabel, actionHref, onAction, tone, compact, density, className }: StatusCardProps): React.JSX.Element;
|
|
@@ -15,12 +15,13 @@ const TONES = {
|
|
|
15
15
|
* expiring) use the compact one-row form; states whose body informs
|
|
16
16
|
* (submitted, needs changes, rejected) use the full card. The tone
|
|
17
17
|
* carries urgency: default white, warning amber, danger red, info sky,
|
|
18
|
-
* muted for closed memberships.
|
|
18
|
+
* muted for closed memberships. `density="compact"` is the admin scale.
|
|
19
19
|
*/
|
|
20
|
-
export function StatusCard({ icon, title, body, actionLabel, actionHref, onAction, tone = "default", compact = false, className }) {
|
|
21
|
-
const
|
|
20
|
+
export function StatusCard({ icon, title, body, actionLabel, actionHref, onAction, tone = "default", compact = false, density = "default", className }) {
|
|
21
|
+
const admin = density === "compact";
|
|
22
|
+
const action = actionLabel ? (_jsx(Button, { asChild: Boolean(actionHref), onClick: onAction, className: cn("shrink-0 bg-brand-red text-white hover:bg-brand-red/90", compact ? "min-h-11 px-4" : "min-h-11 px-5", admin && "max-[600px]:min-h-11 text-[13px] min-[600px]:h-8 min-[600px]:min-h-8 min-[600px]:px-3"), children: actionHref ? _jsx("a", { href: actionHref, children: actionLabel }) : _jsx("span", { children: actionLabel }) })) : null;
|
|
22
23
|
if (compact) {
|
|
23
|
-
return (_jsxs("div", { role: "region", "aria-label": title, className: cn("flex min-h-[60px] items-center gap-3 rounded-xl border py-2 pr-2 pl-4", TONES[tone], className), children: [_jsx("span", { className: "shrink-0 [&_svg]:size-6", children: icon }), _jsx("h2", { className: "line-clamp-2 min-w-0 flex-1 text-base leading-snug font-bold", children: title }), action] }));
|
|
24
|
+
return (_jsxs("div", { role: "region", "aria-label": title, className: cn("flex min-h-[60px] items-center gap-3 rounded-xl border py-2 pr-2 pl-4", TONES[tone], admin && "text-[13px]", className), children: [_jsx("span", { className: "shrink-0 [&_svg]:size-6", children: icon }), _jsx("h2", { className: cn("line-clamp-2 min-w-0 flex-1 text-base leading-snug font-bold", admin && "text-sm"), children: title }), action] }));
|
|
24
25
|
}
|
|
25
|
-
return (_jsxs(Card, { className: cn("gap-3 rounded-xl border py-5", TONES[tone], className), children: [_jsxs("div", { className: "flex items-start gap-3 px-5", children: [_jsx("span", { className: "mt-0.5 shrink-0 [&_svg]:size-6", children: icon }), _jsxs("div", { className: "min-w-0", children: [_jsx("h2", { className: "text-lg leading-snug font-bold", children: title }), body && _jsx("p", { className: "mt-1 text-sm", children: body })] })] }), action && _jsx("div", { className: "px-5", children: action })] }));
|
|
26
|
+
return (_jsxs(Card, { className: cn("gap-3 rounded-xl border py-5", TONES[tone], admin && "text-[13px]", className), children: [_jsxs("div", { className: "flex items-start gap-3 px-5", children: [_jsx("span", { className: "mt-0.5 shrink-0 [&_svg]:size-6", children: icon }), _jsxs("div", { className: "min-w-0", children: [_jsx("h2", { className: cn("text-lg leading-snug font-bold", admin && "text-sm"), children: title }), body && _jsx("p", { className: cn("mt-1 text-sm", admin && "text-[13px]"), children: body })] })] }), action && _jsx("div", { className: "px-5", children: action })] }));
|
|
26
27
|
}
|
|
@@ -5,7 +5,13 @@ declare function Tabs({ className, orientation, ...props }: React.ComponentProps
|
|
|
5
5
|
declare const tabsListVariants: (props?: ({
|
|
6
6
|
variant?: "default" | "line" | null | undefined;
|
|
7
7
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
8
|
-
|
|
9
|
-
declare function
|
|
8
|
+
export type TabsAppearance = "default" | "underline" | "segmented";
|
|
9
|
+
declare function TabsList({ className, variant, appearance, ...props }: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants> & {
|
|
10
|
+
appearance?: TabsAppearance;
|
|
11
|
+
}): React.JSX.Element;
|
|
12
|
+
declare function TabsTrigger({ className, count, children, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger> & {
|
|
13
|
+
/** Optional count beside the label; it joins the trigger's accessible name. */
|
|
14
|
+
count?: number;
|
|
15
|
+
}): React.JSX.Element;
|
|
10
16
|
declare function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>): React.JSX.Element;
|
|
11
17
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
package/dist/components/tabs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cva } from "class-variance-authority";
|
|
3
3
|
import { cn } from "../lib/utils";
|
|
4
4
|
import { Tabs as TabsPrimitive } from "radix-ui";
|
|
@@ -16,11 +16,24 @@ const tabsListVariants = cva("group/tabs-list inline-flex w-fit items-center jus
|
|
|
16
16
|
variant: "default",
|
|
17
17
|
},
|
|
18
18
|
});
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* The three tab rows the admin uses: the boxed default, the underlined queue and
|
|
21
|
+
* page tabs, and the segmented mode and side switchers. `default` leaves the
|
|
22
|
+
* list exactly as it shipped.
|
|
23
|
+
*/
|
|
24
|
+
const tabsAppearanceClass = {
|
|
25
|
+
default: "",
|
|
26
|
+
underline: "h-auto w-full justify-start gap-0 overflow-x-auto rounded-none border-b border-brand-border bg-transparent p-0 [scrollbar-width:thin] max-[600px]:[&>[data-slot=tabs-trigger]]:min-h-11 min-[600px]:[&>[data-slot=tabs-trigger]]:min-h-8",
|
|
27
|
+
segmented: "h-auto w-full gap-[3px] rounded-[9px] bg-secondary p-[3px] max-[600px]:[&>[data-slot=tabs-trigger]]:min-h-11 min-[600px]:[&>[data-slot=tabs-trigger]]:min-h-8",
|
|
28
|
+
};
|
|
29
|
+
function TabsList({ className, variant = "default", appearance = "default", ...props }) {
|
|
30
|
+
return (_jsx(TabsPrimitive.List, { "data-slot": "tabs-list", "data-variant": variant, "data-appearance": appearance, className: cn(tabsListVariants({ variant }), tabsAppearanceClass[appearance], className), ...props }));
|
|
21
31
|
}
|
|
22
|
-
|
|
23
|
-
|
|
32
|
+
/** Underline and segmented triggers; the default appearance keeps the shipped trigger. */
|
|
33
|
+
const underlineTrigger = "group-data-[appearance=underline]/tabs-list:h-auto group-data-[appearance=underline]/tabs-list:flex-none group-data-[appearance=underline]/tabs-list:gap-2 group-data-[appearance=underline]/tabs-list:rounded-none group-data-[appearance=underline]/tabs-list:border-b-2 group-data-[appearance=underline]/tabs-list:border-transparent group-data-[appearance=underline]/tabs-list:px-3 group-data-[appearance=underline]/tabs-list:py-0 group-data-[appearance=underline]/tabs-list:text-sm group-data-[appearance=underline]/tabs-list:font-bold group-data-[appearance=underline]/tabs-list:text-brand-muted group-data-[appearance=underline]/tabs-list:after:hidden group-data-[appearance=underline]/tabs-list:hover:text-brand-red group-data-[appearance=underline]/tabs-list:data-[state=active]:border-brand-red group-data-[appearance=underline]/tabs-list:data-[state=active]:bg-transparent group-data-[appearance=underline]/tabs-list:data-[state=active]:text-brand-red";
|
|
34
|
+
const segmentedTrigger = "group-data-[appearance=segmented]/tabs-list:h-auto group-data-[appearance=segmented]/tabs-list:rounded-[7px] group-data-[appearance=segmented]/tabs-list:bg-transparent group-data-[appearance=segmented]/tabs-list:px-3 group-data-[appearance=segmented]/tabs-list:text-[13px] group-data-[appearance=segmented]/tabs-list:font-semibold group-data-[appearance=segmented]/tabs-list:text-brand-muted group-data-[appearance=segmented]/tabs-list:after:hidden group-data-[appearance=segmented]/tabs-list:data-[state=active]:text-brand-ink group-data-[appearance=segmented]/tabs-list:data-[state=active]:shadow-[0_2px_6px_rgba(24,34,43,.08)]";
|
|
35
|
+
function TabsTrigger({ className, count, children, ...props }) {
|
|
36
|
+
return (_jsxs(TabsPrimitive.Trigger, { "data-slot": "tabs-trigger", className: cn("relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent", "data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground", "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100", underlineTrigger, segmentedTrigger, className), ...props, children: [children, count === undefined ? null : (_jsxs(_Fragment, { children: [_jsx("span", { className: "sr-only", children: `, ${count}` }), _jsx("span", { "aria-hidden": true, className: cn("grid min-w-[18px] place-items-center rounded-full px-1.5 text-[11px] leading-[18px] font-bold", count === 0 ? "bg-secondary text-brand-muted" : "bg-brand-red/8 text-brand-red"), children: count })] }))] }));
|
|
24
37
|
}
|
|
25
38
|
function TabsContent({ className, ...props }) {
|
|
26
39
|
return (_jsx(TabsPrimitive.Content, { "data-slot": "tabs-content", className: cn("flex-1 outline-none", className), ...props }));
|
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
export interface TimelineItem {
|
|
3
3
|
title: string;
|
|
4
|
-
|
|
4
|
+
/** Any node, so a long note can be an `ExpandableText`. */
|
|
5
|
+
description?: React.ReactNode;
|
|
5
6
|
/** When it happened or will happen, already formatted by the page. */
|
|
6
7
|
time?: string;
|
|
7
8
|
state: "complete" | "current" | "upcoming";
|
|
8
9
|
}
|
|
9
10
|
export interface TimelineProps {
|
|
10
11
|
items: TimelineItem[];
|
|
12
|
+
/**
|
|
13
|
+
* Show only this many items (the newest, in the given order) and reveal the
|
|
14
|
+
* rest through a labelled toggle. `undefined` renders every item, which is the
|
|
15
|
+
* default behaviour.
|
|
16
|
+
*/
|
|
17
|
+
defaultVisibleCount?: number;
|
|
18
|
+
/** Starts expanded; the component is uncontrolled. Default false. */
|
|
19
|
+
defaultExpanded?: boolean;
|
|
20
|
+
/** Overrides the reveal label; `{n}` is the number of hidden items. */
|
|
21
|
+
expandLabel?: string;
|
|
22
|
+
/** Overrides the collapse label; `{n}` is the visible count. */
|
|
23
|
+
collapseLabel?: string;
|
|
24
|
+
onExpandChange?: (expanded: boolean) => void;
|
|
11
25
|
className?: string;
|
|
12
26
|
}
|
|
13
27
|
/**
|
|
14
|
-
* A vertical history: what happened, in order, with the current point
|
|
15
|
-
*
|
|
16
|
-
* person asks one question: where is my process now.
|
|
28
|
+
* A vertical history: what happened, in order, with the current point marked.
|
|
29
|
+
* Used for profile history, verification status and membership history, where
|
|
30
|
+
* the person asks one question: where is my process now. With
|
|
31
|
+
* `defaultVisibleCount` it shows the newest events and keeps the rest behind a
|
|
32
|
+
* real toggle that reports `aria-expanded` and names how many items it hides.
|
|
17
33
|
*/
|
|
18
|
-
export declare function Timeline({ items, className }: TimelineProps): React.JSX.Element;
|
|
34
|
+
export declare function Timeline({ items, defaultVisibleCount, defaultExpanded, expandLabel, collapseLabel, onExpandChange, className }: TimelineProps): React.JSX.Element;
|
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
2
3
|
import { IconCheck } from "@tabler/icons-react";
|
|
3
4
|
import { cn } from "../lib/utils";
|
|
4
5
|
/**
|
|
5
|
-
* A vertical history: what happened, in order, with the current point
|
|
6
|
-
*
|
|
7
|
-
* person asks one question: where is my process now.
|
|
6
|
+
* A vertical history: what happened, in order, with the current point marked.
|
|
7
|
+
* Used for profile history, verification status and membership history, where
|
|
8
|
+
* the person asks one question: where is my process now. With
|
|
9
|
+
* `defaultVisibleCount` it shows the newest events and keeps the rest behind a
|
|
10
|
+
* real toggle that reports `aria-expanded` and names how many items it hides.
|
|
8
11
|
*/
|
|
9
|
-
export function Timeline({ items, className }) {
|
|
10
|
-
|
|
12
|
+
export function Timeline({ items, defaultVisibleCount, defaultExpanded = false, expandLabel, collapseLabel, onExpandChange, className }) {
|
|
13
|
+
const [expanded, setExpanded] = React.useState(defaultExpanded);
|
|
14
|
+
const limited = defaultVisibleCount !== undefined && items.length > defaultVisibleCount;
|
|
15
|
+
const visible = limited && !expanded ? items.slice(0, defaultVisibleCount) : items;
|
|
16
|
+
const hidden = items.length - visible.length;
|
|
17
|
+
const toggle = () => {
|
|
18
|
+
const next = !expanded;
|
|
19
|
+
setExpanded(next);
|
|
20
|
+
onExpandChange?.(next);
|
|
21
|
+
};
|
|
22
|
+
return (_jsxs(_Fragment, { children: [_jsx("ol", { "data-slot": "timeline", className: cn("grid", className), children: visible.map((item, index) => (_jsxs("li", { className: "relative flex gap-3 pb-6 last:pb-0", children: [index < visible.length - 1 && (_jsx("span", { "aria-hidden": true, className: cn("absolute top-6 left-[11px] h-[calc(100%-24px)] w-px", item.state === "complete" ? "bg-brand-red" : "bg-border") })), _jsxs("span", { className: cn("relative z-10 flex size-6 shrink-0 items-center justify-center rounded-full border", item.state === "complete" && "border-brand-red bg-brand-red text-white", item.state === "current" && "border-brand-red bg-card ring-4 ring-brand-red/15", item.state === "upcoming" && "border-border bg-card"), children: [item.state === "complete" && _jsx(IconCheck, { className: "size-3.5" }), item.state === "current" && _jsx("span", { className: "size-2 rounded-full bg-brand-red" }), item.state === "upcoming" && _jsx("span", { className: "size-2 rounded-full bg-border" })] }), _jsxs("div", { className: "grid min-w-0 gap-0.5 pt-0.5", children: [_jsx("span", { className: cn("text-sm", item.state === "upcoming" ? "text-muted-foreground" : "font-medium text-brand-ink"), children: item.title }), item.description ? _jsx("span", { className: "text-sm text-muted-foreground", children: item.description }) : null, item.time ? _jsx("span", { className: "text-xs text-muted-foreground", children: item.time }) : null] })] }, item.title + index))) }), limited ? (_jsx("button", { "data-slot": "timeline-toggle", type: "button", "aria-expanded": expanded, onClick: toggle, className: "mt-2.5 ml-9 text-[13px] font-bold text-brand-red underline-offset-2 hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none max-[600px]:min-h-11 min-[600px]:min-h-8", children: expanded
|
|
23
|
+
? (collapseLabel ?? `Tampilkan ${defaultVisibleCount} terbaru`)
|
|
24
|
+
: (expandLabel ?? `Lihat selengkapnya (${hidden} peristiwa)`) })) : null] }));
|
|
11
25
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
export type WorkspaceLocale = "id" | "en";
|
|
3
|
+
export interface WorkspaceOption {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** Short label for the collapsed and phone trigger, for example "OKY". */
|
|
7
|
+
short?: string;
|
|
8
|
+
/** Unit kind, shown under the label in the menu, for example "Komsat". */
|
|
9
|
+
kind: string;
|
|
10
|
+
/** Menu mark; defaults to a community glyph. */
|
|
11
|
+
icon?: React.ReactNode;
|
|
12
|
+
}
|
|
13
|
+
export interface WorkspaceSelectorProps {
|
|
14
|
+
value: string;
|
|
15
|
+
options: WorkspaceOption[];
|
|
16
|
+
onChange: (id: string) => void;
|
|
17
|
+
/** Small caption above the trigger value. Defaults to the locale's "RUANG KERJA". */
|
|
18
|
+
label?: string;
|
|
19
|
+
searchPlaceholder?: string;
|
|
20
|
+
/** Sidebar rail state: a 48px square trigger with the menu beside it. */
|
|
21
|
+
collapsed?: boolean;
|
|
22
|
+
/** `sidebar` is the boxed picker; `phone` the compact top-bar trigger. */
|
|
23
|
+
variant: "sidebar" | "phone";
|
|
24
|
+
/** Starts with the menu open; the component is otherwise uncontrolled. */
|
|
25
|
+
defaultOpen?: boolean;
|
|
26
|
+
locale: WorkspaceLocale;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The one searchable workspace picker behind the sidebar picker, its collapsed
|
|
31
|
+
* rail form and the phone top-bar picker. The trigger reports `aria-expanded`
|
|
32
|
+
* and names the current workspace; the menu is a labelled group of `aria-pressed`
|
|
33
|
+
* buttons, the search field takes focus on open and Escape closes the menu and
|
|
34
|
+
* returns focus to the trigger.
|
|
35
|
+
*/
|
|
36
|
+
export declare function WorkspaceSelector({ value, options, onChange, label, searchPlaceholder, collapsed, variant, defaultOpen, locale, className }: WorkspaceSelectorProps): React.JSX.Element;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { IconBuildingCommunity, IconCheck, IconChevronDown } from "@tabler/icons-react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { Input } from "./input";
|
|
6
|
+
const labels = {
|
|
7
|
+
id: { label: "RUANG KERJA", search: "Cari korda atau komsat", trigger: "Ruang kerja", empty: "Tidak ditemukan." },
|
|
8
|
+
en: { label: "WORKSPACE", search: "Search region or unit", trigger: "Workspace", empty: "Nothing found." },
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The one searchable workspace picker behind the sidebar picker, its collapsed
|
|
12
|
+
* rail form and the phone top-bar picker. The trigger reports `aria-expanded`
|
|
13
|
+
* and names the current workspace; the menu is a labelled group of `aria-pressed`
|
|
14
|
+
* buttons, the search field takes focus on open and Escape closes the menu and
|
|
15
|
+
* returns focus to the trigger.
|
|
16
|
+
*/
|
|
17
|
+
export function WorkspaceSelector({ value, options, onChange, label, searchPlaceholder, collapsed = false, variant, defaultOpen = false, locale, className }) {
|
|
18
|
+
const l = labels[locale];
|
|
19
|
+
const [open, setOpen] = React.useState(defaultOpen);
|
|
20
|
+
const [query, setQuery] = React.useState("");
|
|
21
|
+
const triggerRef = React.useRef(null);
|
|
22
|
+
const searchRef = React.useRef(null);
|
|
23
|
+
const wrapRef = React.useRef(null);
|
|
24
|
+
const menuId = React.useId();
|
|
25
|
+
const selected = options.find((option) => option.id === value);
|
|
26
|
+
const current = selected?.label ?? "";
|
|
27
|
+
const triggerText = selected?.short ?? selected?.label ?? "";
|
|
28
|
+
const matches = options.filter((option) => `${option.label} ${option.kind}`.toLocaleLowerCase("id").includes(query.trim().toLocaleLowerCase("id")));
|
|
29
|
+
const close = React.useCallback((restoreFocus = true) => {
|
|
30
|
+
setOpen(false);
|
|
31
|
+
setQuery("");
|
|
32
|
+
if (restoreFocus)
|
|
33
|
+
triggerRef.current?.focus();
|
|
34
|
+
}, []);
|
|
35
|
+
// A pointer outside the picker closes the menu; the trigger and the menu are
|
|
36
|
+
// the only places a click keeps it open.
|
|
37
|
+
React.useEffect(() => {
|
|
38
|
+
if (!open)
|
|
39
|
+
return;
|
|
40
|
+
const onPointerDown = (event) => {
|
|
41
|
+
if (!wrapRef.current?.contains(event.target))
|
|
42
|
+
close(false);
|
|
43
|
+
};
|
|
44
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
45
|
+
return () => document.removeEventListener("pointerdown", onPointerDown);
|
|
46
|
+
}, [open, close]);
|
|
47
|
+
// The search field takes focus as soon as the menu opens.
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (open)
|
|
50
|
+
searchRef.current?.focus();
|
|
51
|
+
}, [open]);
|
|
52
|
+
const menu = open ? (_jsxs("div", { id: menuId, role: "group", "aria-label": label ?? l.label, onKeyDown: (event) => {
|
|
53
|
+
if (event.key === "Escape") {
|
|
54
|
+
event.stopPropagation();
|
|
55
|
+
close();
|
|
56
|
+
}
|
|
57
|
+
}, className: cn("absolute z-40 grid max-h-80 gap-0.5 overflow-y-auto rounded-[11px] border border-brand-border bg-card p-1.5 shadow-lg", variant === "phone" ? "top-[calc(100%+6px)] right-0 min-w-[185px]" : collapsed ? "top-0 left-[calc(100%+10px)] w-[220px]" : "top-[calc(100%+7px)] right-0 left-0"), children: [_jsx(Input, { ref: searchRef, "data-size": "sm", textSize: "compact", value: query, onChange: (event) => setQuery(event.target.value), placeholder: searchPlaceholder ?? l.search, "aria-label": searchPlaceholder ?? l.search, className: "mb-1 w-full" }), matches.length ? (matches.map((option) => (_jsxs("button", { type: "button", "aria-pressed": option.id === value, onClick: () => {
|
|
58
|
+
onChange(option.id);
|
|
59
|
+
close();
|
|
60
|
+
}, className: cn("flex min-h-11 w-full items-center justify-between gap-2 rounded-[7px] px-2.5 py-2 text-left text-[13px] font-bold min-[600px]:min-h-8", option.id === value ? "bg-brand-red/8 text-brand-red" : "text-brand-ink hover:bg-secondary"), children: [_jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block truncate", children: option.label }), _jsx("span", { className: "block text-xs font-medium text-brand-muted", children: option.label === option.kind ? "" : option.kind })] }), option.id === value ? _jsx(IconCheck, { "aria-hidden": true, className: "size-4 shrink-0 text-brand-red" }) : null] }, option.id)))) : (_jsx("p", { className: "px-2.5 py-2 text-[13px] text-brand-muted", children: l.empty }))] })) : null;
|
|
61
|
+
if (variant === "phone") {
|
|
62
|
+
return (_jsxs("div", { ref: wrapRef, "data-slot": "workspace-selector", "data-variant": "phone", className: cn("relative", className), children: [_jsxs("button", { ref: triggerRef, type: "button", "aria-expanded": open, "aria-controls": open ? menuId : undefined, "aria-label": `${labels[locale].trigger}: ${current}`, onClick: () => {
|
|
63
|
+
setOpen((value) => !value);
|
|
64
|
+
setQuery("");
|
|
65
|
+
}, className: "flex min-h-11 items-center gap-1.5 rounded-[7px] border border-brand-border bg-card px-2.5 text-xs font-bold text-brand-ink min-[600px]:min-h-8", children: [_jsx("span", { className: "max-w-[9rem] truncate", children: triggerText }), _jsx(IconChevronDown, { "aria-hidden": true, className: cn("size-4 shrink-0 transition-transform", open && "rotate-180") })] }), menu] }));
|
|
66
|
+
}
|
|
67
|
+
return (_jsxs("div", { ref: wrapRef, "data-slot": "workspace-selector", "data-variant": "sidebar", "data-collapsed": collapsed, className: cn("relative min-w-0", className), children: [collapsed ? (_jsx("button", { ref: triggerRef, type: "button", "aria-expanded": open, "aria-controls": open ? menuId : undefined, "aria-label": `${labels[locale].trigger}: ${current}`, onClick: () => {
|
|
68
|
+
setOpen((value) => !value);
|
|
69
|
+
setQuery("");
|
|
70
|
+
}, className: "mx-auto grid size-12 place-items-center rounded-[11px] border border-brand-border bg-brand-red/8 text-brand-red focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none", children: _jsx("span", { "aria-hidden": true, children: selected?.icon ?? _jsx(IconBuildingCommunity, { className: "size-[18px]" }) }) })) : (_jsxs("button", { ref: triggerRef, type: "button", "aria-expanded": open, "aria-controls": open ? menuId : undefined, "aria-label": `${labels[locale].trigger}: ${current}`, onClick: () => {
|
|
71
|
+
setOpen((value) => !value);
|
|
72
|
+
setQuery("");
|
|
73
|
+
}, className: "flex min-h-12 w-full items-center gap-2.5 rounded-[11px] border border-brand-border bg-brand-bg/60 p-2.5 text-left focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none", children: [_jsx("span", { "aria-hidden": true, className: "grid size-7.5 shrink-0 place-items-center rounded-lg bg-brand-red/8 text-brand-red", children: selected?.icon ?? _jsx(IconBuildingCommunity, { className: "size-[18px]" }) }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("small", { className: "block text-[11px] leading-3.5 font-extrabold tracking-[.12em] text-brand-muted", children: label ?? l.label }), _jsx("strong", { className: "block truncate text-[13px] leading-4 text-brand-ink", children: current })] }), _jsx(IconChevronDown, { "aria-hidden": true, className: cn("size-4 shrink-0 text-brand-muted transition-transform", open && "rotate-180") })] })), menu] }));
|
|
74
|
+
}
|