@trackunit/react-components 3.1.6 → 3.2.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/index.cjs.js CHANGED
@@ -2810,6 +2810,912 @@ const getIconName = (color) => {
2810
2810
  }
2811
2811
  };
2812
2812
 
2813
+ const cvaCard = cssClassVarianceUtilities.cva({
2814
+ base: [
2815
+ "flex",
2816
+ "border",
2817
+ "flex-col",
2818
+ "relative",
2819
+ "bg-white",
2820
+ "component-card-shadow",
2821
+ "component-card-border",
2822
+ "transition-colors",
2823
+ "ease-in-out",
2824
+ "overflow-clip", //otherwise rounded corners are not respected (clip is preferable to hidden since it doesn't create a scroll clipping box. )
2825
+ ],
2826
+ variants: {
2827
+ clickable: {
2828
+ false: "",
2829
+ true: "cursor-pointer hover:bg-neutral-50",
2830
+ },
2831
+ fullHeight: {
2832
+ false: "",
2833
+ true: "h-full",
2834
+ },
2835
+ },
2836
+ defaultVariants: {
2837
+ clickable: false,
2838
+ fullHeight: false,
2839
+ },
2840
+ });
2841
+ const cvaCardFooterContainerRoot = cssClassVarianceUtilities.cva({
2842
+ base: ["flex", "border-t", "border-neutral-200", "justify-end", "flex-row"],
2843
+ variants: {
2844
+ border: {
2845
+ default: "",
2846
+ borderless: "border-t-0",
2847
+ },
2848
+ padding: {
2849
+ none: "p-0",
2850
+ default: "p-4",
2851
+ },
2852
+ },
2853
+ defaultVariants: {
2854
+ border: "default",
2855
+ padding: "default",
2856
+ },
2857
+ });
2858
+ const cvaCardFooterContainer = cssClassVarianceUtilities.cva({
2859
+ base: ["justify-self-end", "grid", "auto-cols-max", "grid-flow-col", "gap-2"],
2860
+ });
2861
+ const cvaCardHeader = cssClassVarianceUtilities.cva({ base: ["flex", "justify-between", "flex-1"] });
2862
+ const cvaCardHeaderHeadingContainer = cssClassVarianceUtilities.cva({ base: ["flex", "gap-4", "items-center"] });
2863
+ const cvaCardHeaderContainer = cssClassVarianceUtilities.cva({
2864
+ base: ["flex", "border-b", "border-neutral-200"],
2865
+ variants: {
2866
+ border: {
2867
+ default: "",
2868
+ borderless: "border-b-0",
2869
+ },
2870
+ padding: {
2871
+ none: "p-0",
2872
+ default: "p-4",
2873
+ },
2874
+ },
2875
+ defaultVariants: {
2876
+ border: "default",
2877
+ padding: "default",
2878
+ },
2879
+ });
2880
+ const cvaCardBodyContainer = cssClassVarianceUtilities.cva({
2881
+ base: ["flex", "flex-grow", "overflow-auto"],
2882
+ variants: {
2883
+ direction: {
2884
+ row: "flex-row",
2885
+ "row-reverse": "flex-row-reverse",
2886
+ column: "flex-col",
2887
+ "column-reverse": "flex-col-reverse",
2888
+ },
2889
+ padding: {
2890
+ none: "p-0",
2891
+ default: "p-4",
2892
+ },
2893
+ gap: {
2894
+ none: "gap-0",
2895
+ default: "gap-4",
2896
+ },
2897
+ },
2898
+ defaultVariants: {
2899
+ direction: "column",
2900
+ padding: "default",
2901
+ gap: "default",
2902
+ },
2903
+ });
2904
+
2905
+ const ROLE_CARD = "region";
2906
+ /**
2907
+ * The Card component is a container for UI elements that groups related content together with a visual boundary.
2908
+ * To get the most out of the Card, use it in combination with the CardHeader, CardBody and CardFooter.
2909
+ *
2910
+ * ### When to use
2911
+ * Use cards to group related information and actions, such as in dashboards, list items, or content previews.
2912
+ *
2913
+ * ### When not to use
2914
+ * Do not use cards for layout purposes only. Use standard containers or grid layouts instead.
2915
+ *
2916
+ * @example Basic card
2917
+ * ```tsx
2918
+ * import { Card, CardHeader, CardBody } from "@trackunit/react-components";
2919
+ *
2920
+ * const MyComponent = () => {
2921
+ * return (
2922
+ * <Card>
2923
+ * <CardHeader title="Asset Details" />
2924
+ * <CardBody>
2925
+ * <p>This card contains information about the asset.</p>
2926
+ * </CardBody>
2927
+ * </Card>
2928
+ * );
2929
+ * };
2930
+ * ```
2931
+ * @example Clickable card with full content
2932
+ * ```tsx
2933
+ * import { Card, CardHeader, CardBody, CardFooter, Button } from "@trackunit/react-components";
2934
+ *
2935
+ * const AssetCard = () => {
2936
+ * return (
2937
+ * <Card onClick={() => console.log("Card clicked")}>
2938
+ * <CardHeader title="Excavator #1234" subtitle="Last updated: 2 hours ago" />
2939
+ * <CardBody>
2940
+ * <p>Operating hours: 1,234</p>
2941
+ * <p>Status: Active</p>
2942
+ * </CardBody>
2943
+ * <CardFooter>
2944
+ * <Button variant="secondary" size="small">View Details</Button>
2945
+ * </CardFooter>
2946
+ * </Card>
2947
+ * );
2948
+ * };
2949
+ * ```
2950
+ * @param {CardProps} props - The props for the Card component
2951
+ * @returns {ReactElement} Card component
2952
+ */
2953
+ const Card = ({ children, onClick, fullHeight = false, onMouseEnter, onMouseLeave, className, "data-testid": dataTestId, ref, ...rest }) => {
2954
+ return (jsxRuntime.jsx("div", { className: cvaCard({ fullHeight, clickable: Boolean(onClick), className }), "data-card": true, "data-testid": dataTestId, onClick: onClick, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, ref: ref, role: ROLE_CARD, ...rest, children: children }));
2955
+ };
2956
+ Card.displayName = "Card";
2957
+
2958
+ const cvaDetailsList = cssClassVarianceUtilities.cva({
2959
+ base: ["flex", "w-full", "min-w-0", "items-center", "truncate", "text-xs", "text-neutral-600", "font-medium", "pt-0"],
2960
+ variants: {
2961
+ hasLink: {
2962
+ true: "pl-1",
2963
+ },
2964
+ },
2965
+ defaultVariants: {
2966
+ hasLink: false,
2967
+ },
2968
+ });
2969
+ const cvaDetailsListItem = cssClassVarianceUtilities.cva({ base: ["last:truncate"] });
2970
+
2971
+ /**
2972
+ * DetailsList renders a one-line list of text values separated by slash icons.
2973
+ * It is used to display compact metadata such as model, serial number, or location in a single row.
2974
+ *
2975
+ * ### When to use
2976
+ * Use DetailsList to display a compact, horizontal list of metadata values (e.g., in a card subtitle or list item description).
2977
+ *
2978
+ * ### When not to use
2979
+ * Do not use DetailsList for key-value pairs — use a standard layout with labels. Do not use for long text — values should be short strings.
2980
+ *
2981
+ * @example Displaying asset metadata
2982
+ * ```tsx
2983
+ * import { DetailsList } from "@trackunit/react-components";
2984
+ *
2985
+ * const AssetMeta = () => (
2986
+ * <DetailsList details={["Excavator", "CAT 320", "SN-00142"]} />
2987
+ * );
2988
+ * ```
2989
+ * @example DetailsList inside a linked context
2990
+ * ```tsx
2991
+ * import { DetailsList } from "@trackunit/react-components";
2992
+ *
2993
+ * const LinkedAssetMeta = () => (
2994
+ * <a href="/assets/123">
2995
+ * <DetailsList details={["Site: Oslo", "Group: Heavy"]} hasLink />
2996
+ * </a>
2997
+ * );
2998
+ * ```
2999
+ * @param {DetailsListProps} props - The props for the DetailsList component
3000
+ * @returns {ReactElement} DetailsList component
3001
+ */
3002
+ const DetailsList = ({ details, className, "data-testid": dataTestId, hasLink = false, style, ref, }) => {
3003
+ return (jsxRuntime.jsx("div", { className: cvaDetailsList({ className, hasLink }), "data-testid": dataTestId, ref: ref, style: style, children: details.map((value, index, array) => (jsxRuntime.jsxs(react.Fragment, { children: [jsxRuntime.jsx("span", { className: cvaDetailsListItem({ className }), children: value }), index < array.length - 1 && (jsxRuntime.jsx("div", { className: "mx-0.5 flex items-center", children: jsxRuntime.jsx(Icon, { className: "w-4 text-neutral-300", color: "neutral", name: "Slash", size: "small" }) }))] }, index))) }));
3004
+ };
3005
+
3006
+ /**
3007
+ * Applies standardized interaction-related styles to an element.
3008
+ *
3009
+ * Prefer using the "auto" settings — only resort to manual control if necessary!
3010
+ * - selected: "auto" will apply styling when it contains a `checked` input aka. `:has(:checked)`.
3011
+ * - focused: "auto" will apply styling when the element or it's children has focus aka. `:focus-within`.
3012
+ *
3013
+ * To be used for elements that do not have typical interactive roles and behaviors,
3014
+ * such as divs, table rows, cards etc.
3015
+ *
3016
+ * To not be used for buttons, links, or other elements that have their own intrinsic interactive roles.
3017
+ *
3018
+ * @returns tailwind class names on the basis on the provided props.
3019
+ */
3020
+ const cvaInteractableItem = cssClassVarianceUtilities.cva({
3021
+ base: ["transition-colors", "duration-100", "ease-in-out"],
3022
+ variants: {
3023
+ cursor: {
3024
+ pointer: "cursor-pointer",
3025
+ auto: "cursor-auto",
3026
+ help: "cursor-help",
3027
+ "zoom-in": "cursor-zoom-in",
3028
+ "zoom-out": "cursor-zoom-out",
3029
+ default: "cursor-default",
3030
+ "not-allowed": "cursor-not-allowed",
3031
+ },
3032
+ selection: {
3033
+ selected: ["bg-primary-50", "hover:bg-primary-100", "focus-within:bg-primary-50"],
3034
+ unselected: "",
3035
+ auto: [
3036
+ "hover:bg-neutral-100",
3037
+ "has-[:checked]:bg-primary-50",
3038
+ "hover:has-[:checked]:bg-primary-50",
3039
+ "focus-within:has-[:checked]:bg-primary-50",
3040
+ ],
3041
+ },
3042
+ disabled: {
3043
+ // Remember to also disable the element or set tabIndex=-1 to prevent actual interaction/focus!
3044
+ true: ["pointer-events-none", "!bg-neutral-100", "!cursor-not-allowed"],
3045
+ false: "", // use compoundVariants instead
3046
+ },
3047
+ focus: {
3048
+ focused: ["bg-neutral-600/5"],
3049
+ unfocused: "",
3050
+ auto: "[&:has(:focus-visible)]:outline-native",
3051
+ },
3052
+ },
3053
+ compoundVariants: [
3054
+ {
3055
+ disabled: false,
3056
+ selection: "unselected",
3057
+ className: ["hover:bg-neutral-600/5", "focus-within:bg-neutral-600/5"],
3058
+ },
3059
+ ],
3060
+ defaultVariants: {
3061
+ cursor: "auto",
3062
+ selection: "auto",
3063
+ disabled: false,
3064
+ focus: "auto",
3065
+ },
3066
+ });
3067
+
3068
+ // The card layout intentionally mirrors the compact identity strip
3069
+ // vocabulary (image + title + subtitle + status + connectivity + chevron)
3070
+ // used across the app. Class definitions are colocated with the component
3071
+ // so it can evolve independently — if a similar layout emerges elsewhere,
3072
+ // promote the shared parts deliberately rather than re-plumbing here.
3073
+ // Horizontal row: main cluster (image/thumbnail + text) + optional chevron.
3074
+ // `gap-2` (8px) is the space between connectivity (the right edge of the
3075
+ // text stack) and the chevron. Image-to-metadata spacing lives on
3076
+ // `cvaCardMain` so tightening this gap does not pull the thumbnail closer
3077
+ // to the text. `px-4` insets the chevron 16px from the card's right edge
3078
+ // (see `cvaCardChevron`).
3079
+ const cvaCardContentRow = cssClassVarianceUtilities.cva({
3080
+ base: ["flex", "gap-2", "px-4", "py-3", "w-full", "min-w-0", "overflow-hidden"],
3081
+ });
3082
+ // Image/thumbnail + text stack. `gap-3` (12px) is the media-to-metadata
3083
+ // gutter — independent of the chevron gap on the parent row. When the text
3084
+ // stack renders a single row (title only — no subtitle or status), swap
3085
+ // `items-end` → `items-center` so the title sits centered against the
3086
+ // taller-than-text image slot rather than clinging to its bottom edge.
3087
+ // Multi-row cards keep `items-end` so the image and text share a bottom
3088
+ // edge — same alignment as the full `AssetPageHeader`.
3089
+ const cvaCardMain = cssClassVarianceUtilities.cva({
3090
+ base: ["flex", "gap-3", "min-w-0", "flex-1", "overflow-hidden"],
3091
+ variants: {
3092
+ singleLine: {
3093
+ true: "items-center",
3094
+ false: "items-end",
3095
+ },
3096
+ },
3097
+ defaultVariants: {
3098
+ singleLine: false,
3099
+ },
3100
+ });
3101
+ // Asset image slot: `rounded-lg` for the same corner treatment as
3102
+ // `AssetPageHeader`. Height and width are supplied inline from a JS
3103
+ // measurement of the text stack so the image always spans exactly the
3104
+ // rendered lines of text at a 14:9 aspect ratio (168/108 — the `lg`
3105
+ // header image). `aspect-[14/9]` is a fallback for the first render
3106
+ // before the measurement resolves.
3107
+ const cvaCardImage = cssClassVarianceUtilities.cva({
3108
+ base: [
3109
+ "shrink-0",
3110
+ "flex",
3111
+ "items-center",
3112
+ "justify-center",
3113
+ "overflow-hidden",
3114
+ "rounded-lg",
3115
+ "bg-neutral-100",
3116
+ "aspect-[14/9]",
3117
+ ],
3118
+ });
3119
+ // Icon-thumbnail slot: the square, colored badge alternative to the 14:9
3120
+ // asset image. Sized at `h-11 w-11` (44px) — matches the loaded card's
3121
+ // minimum image height (see `IDENTITY_MIN_IMAGE_HEIGHT_PX` in
3122
+ // `useIdentityImageSize.ts`) so the badge and the image slot occupy the
3123
+ // same footprint on title-only / title + subtitle cards. Deliberately
3124
+ // larger than `ListItem`'s 32-square thumbnail: the identity card is a
3125
+ // more prominent surface and needs a beefier badge to read as balanced
3126
+ // next to the text. `rounded-md` keeps the same corner treatment as
3127
+ // `ListItem` so the "colored badge for site / customer / user" vocabulary
3128
+ // still reads as one thing across the two components. `self-center`
3129
+ // overrides `cvaCardMain`'s `items-end` for multi-row cards so the
3130
+ // badge stays vertically balanced against the text stack regardless of
3131
+ // its height (mirrors the chevron slot's alignment strategy).
3132
+ const cvaCardThumbnail = cssClassVarianceUtilities.cva({
3133
+ base: [
3134
+ "shrink-0",
3135
+ "self-center",
3136
+ "flex",
3137
+ "h-11",
3138
+ "w-11",
3139
+ "items-center",
3140
+ "justify-center",
3141
+ "overflow-hidden",
3142
+ "rounded-md",
3143
+ ],
3144
+ });
3145
+ // Outer column: separates the "identity" group (title + connectivity +
3146
+ // subtitle) from the status-indicator row with a `gap-2` so the indicators
3147
+ // sit visually distinct from the naming block — same gap as the full
3148
+ // `AssetPageHeader` title-block → status row.
3149
+ const cvaCardTextStack = cssClassVarianceUtilities.cva({
3150
+ base: ["flex", "min-w-0", "flex-1", "flex-col", "gap-2", "overflow-hidden"],
3151
+ });
3152
+ // Identity group: keeps the title row and subtitle tight (`gap-1`) so they
3153
+ // read as one naming block; the parent `cvaCardTextStack` adds the larger
3154
+ // `gap-2` between this group and the status-indicator row below.
3155
+ // Title-only cards (`singleLine`) still reserve the two-row height
3156
+ // (`min-h-11` = 44px, matching `IDENTITY_MIN_IMAGE_HEIGHT_PX`) so the
3157
+ // card does not collapse when subtitle / status are omitted.
3158
+ // `justify-center` keeps the title (and optional connectivity) vertically
3159
+ // centered in that reserved space — same optical alignment as before the
3160
+ // min-height was added.
3161
+ const cvaCardIdentityGroup = cssClassVarianceUtilities.cva({
3162
+ base: ["flex", "min-w-0", "flex-col", "gap-1", "overflow-hidden"],
3163
+ variants: {
3164
+ singleLine: {
3165
+ true: ["min-h-11", "justify-center"],
3166
+ false: "",
3167
+ },
3168
+ },
3169
+ defaultVariants: {
3170
+ singleLine: false,
3171
+ },
3172
+ });
3173
+ // Top row inside the identity group: title on the left, connectivity slot
3174
+ // on the right. Isolating connectivity to this row means the subtitle and
3175
+ // status rows below get the full text-stack width.
3176
+ const cvaCardTitleRow = cssClassVarianceUtilities.cva({
3177
+ base: ["flex", "items-center", "gap-3", "min-w-0"],
3178
+ });
3179
+ // Left cluster inside the title row: wraps the title so it consumes the
3180
+ // available flex-1 space, leaving the right-side connectivity slot pinned to
3181
+ // the right edge. `min-w-0` lets the truncating title inside shrink.
3182
+ const cvaCardTitleCluster = cssClassVarianceUtilities.cva({
3183
+ base: ["flex", "min-w-0", "flex-1", "items-center", "gap-2"],
3184
+ });
3185
+ // Title typography. `leading-6` locks the line-height so it matches the
3186
+ // natural text-stack height that drives the image measurement (see
3187
+ // `useIdentityImageSize`).
3188
+ const cvaCardTitle = cssClassVarianceUtilities.cva({
3189
+ base: ["min-w-0", "flex-1", "truncate", "text-base", "font-semibold", "leading-6", "text-neutral-900"],
3190
+ });
3191
+ const cvaCardStatusRow = cssClassVarianceUtilities.cva({
3192
+ base: [
3193
+ "flex",
3194
+ "flex-nowrap",
3195
+ "items-center",
3196
+ "gap-2",
3197
+ "overflow-hidden",
3198
+ // Guard against wrapping *inside* an indicator: multi-word labels
3199
+ // (notably `RentalIndicator`'s "In Repair") would otherwise wrap,
3200
+ // inflate the measured text-stack height, and kick off a feedback loop
3201
+ // where the image steals more width. Same treatment as `AssetPageHeader`.
3202
+ "whitespace-nowrap",
3203
+ ],
3204
+ });
3205
+ // Skeleton row for `statusIndicators` — two empty chip placeholders sized to
3206
+ // match the loaded criticality + activity indicators.
3207
+ const cvaCardStatusSkeletonRow = cssClassVarianceUtilities.cva({
3208
+ base: ["flex", "flex-nowrap", "items-center", "gap-2", "whitespace-nowrap"],
3209
+ });
3210
+ const cvaCardConnectivity = cssClassVarianceUtilities.cva({
3211
+ base: [
3212
+ "shrink-0",
3213
+ "flex",
3214
+ "items-center",
3215
+ // `gap-1` keeps the GSM + GPS last-seen chips as a tight pair. Each
3216
+ // chip should be a single child (icon + label wrapped together) so the
3217
+ // gap sits between chips, not between an icon and its label.
3218
+ "gap-2",
3219
+ // Match the header's muted last-seen treatment so a plain "MapPin +
3220
+ // relative time" chip sits at the same weight/size/color. Consumers
3221
+ // that pass richer elements (e.g. Highlight with its own colored
3222
+ // label) still win via more specific styles on the child.
3223
+ "text-sm",
3224
+ "font-medium",
3225
+ "leading-5",
3226
+ "text-neutral-500",
3227
+ ],
3228
+ });
3229
+ // Right-side chevron slot (shown only when the card is clickable).
3230
+ // `self-center` keeps the chevron vertically centered against the main
3231
+ // cluster regardless of the stack's height. No extra padding of its own:
3232
+ // the parent row's `gap-2` provides the 8px gap from connectivity / the
3233
+ // text stack, and the row's `px-4` places the chevron 16px inset from the
3234
+ // card's right edge — matching the image inset on the left.
3235
+ const cvaCardChevron = cssClassVarianceUtilities.cva({
3236
+ base: ["shrink-0", "self-center", "flex", "items-center", "text-neutral-500"],
3237
+ });
3238
+
3239
+ // Minimum asset-image height (in px). Matches the natural text-stack height
3240
+ // of a title + subtitle row (h3 `leading-6` + `gap-1` + `text-xs`
3241
+ // line-height) so a title-only identity never collapses the image to the
3242
+ // title's line height.
3243
+ const IDENTITY_MIN_IMAGE_HEIGHT_PX = 44;
3244
+ // Matches `AssetPageHeader`'s `lg` image (168×108).
3245
+ const IDENTITY_IMAGE_ASPECT_RATIO = 168 / 108;
3246
+ /**
3247
+ * Measures the rendered text-stack height and returns matching image
3248
+ * dimensions at a 14:9 (168/108) aspect ratio, clamped to the identity
3249
+ * minimum so title-only identities keep a reasonable image footprint. Kept
3250
+ * as a shared hook so the loaded `IdentityCard` and its `IdentityCardSkeleton`
3251
+ * cannot drift out of sync — the skeleton → loaded transition would jump
3252
+ * if the two measurements diverged.
3253
+ *
3254
+ * Measured in `useLayoutEffect` so the image slot is sized before the
3255
+ * browser paints. `useMeasure` is the usual choice here, but it observes
3256
+ * in `useEffect` (after paint), which would flash the unsized
3257
+ * `aspect-[14/9]` fallback for a frame.
3258
+ *
3259
+ * Attach `textStackRef` to the text-stack container; subsequent layout
3260
+ * changes (title text, subtitle wrap, status row toggling on/off)
3261
+ * re-measure automatically.
3262
+ *
3263
+ * @param enabled - When false, the hook is inert (no measurement). Use this
3264
+ * to gate on `showImage` / `hasImage`.
3265
+ */
3266
+ const useIdentityImageSize = (enabled) => {
3267
+ const [element, setElement] = react.useState(null);
3268
+ const [imageSize, setImageSize] = react.useState(null);
3269
+ const textStackRef = react.useCallback(node => {
3270
+ setElement(node);
3271
+ }, []);
3272
+ react.useLayoutEffect(() => {
3273
+ if (!enabled || element === null) {
3274
+ return;
3275
+ }
3276
+ const measure = (height) => {
3277
+ if (height > 0) {
3278
+ const clamped = Math.max(height, IDENTITY_MIN_IMAGE_HEIGHT_PX);
3279
+ setImageSize({ height: clamped, width: Math.round(clamped * IDENTITY_IMAGE_ASPECT_RATIO) });
3280
+ }
3281
+ };
3282
+ measure(element.offsetHeight);
3283
+ const observer = new ResizeObserver(entries => {
3284
+ const entry = entries[0];
3285
+ if (entry) {
3286
+ measure(entry.contentRect.height);
3287
+ }
3288
+ });
3289
+ observer.observe(element);
3290
+ return () => {
3291
+ observer.disconnect();
3292
+ };
3293
+ }, [enabled, element]);
3294
+ return react.useMemo(() => ({ imageSize, textStackRef }), [imageSize, textStackRef]);
3295
+ };
3296
+
3297
+ /**
3298
+ * IdentityCard is the entity-agnostic, `Card`-wrapped identity component.
3299
+ * It renders a slot-based identity strip (14:9 image, title + subtitle,
3300
+ * status indicators, connectivity) inside a bordered surface —
3301
+ * the compact list-row / drawer-row shape used to *reference* a related
3302
+ * entity (an asset, site, customer, user, …) from inside a surface whose
3303
+ * real focus is something else (an alert, service ticket, inspection, …).
3304
+ *
3305
+ * When `onClick` is supplied, the whole card becomes clickable and a
3306
+ * `ChevronRight` icon is rendered on the right edge to signal navigation.
3307
+ * Clickable cards are meant to route the user to the referenced entity's
3308
+ * own surface — pair the handler with the app's navigation hook
3309
+ * (`useNavigateInHost` from `@trackunit/react-core-hooks`) rather than
3310
+ * treating it as an in-place action. Clickable cards are keyboard-operable
3311
+ * out of the box: they receive `role="button"`, join the tab order, and
3312
+ * activate on Enter / Space with a visible focus ring.
3313
+ *
3314
+ * The card stays presentational: it owns layout only; domain hydration
3315
+ * (indicator wiring, translations, last-seen formatting,
3316
+ * click targets) lives at the call-site. The API stays lean so the
3317
+ * inline-reference shape doesn't compete with the surrounding surface's
3318
+ * real focus.
3319
+ *
3320
+ * For the loading placeholder, use the paired `IdentityCardSkeleton`
3321
+ * component — it renders the same layout with per-slot skeleton blocks.
3322
+ *
3323
+ * ### When to use
3324
+ * Use `IdentityCard` when the surface you're building is really **about
3325
+ * something else** (an alert, a service ticket, an inspection, a scheduled
3326
+ * job) and needs to *mention* a related entity in its content, usually
3327
+ * with a click-through to that entity's own page.
3328
+ *
3329
+ * ### When not to use
3330
+ * Do not use `IdentityCard` as the header of a surface whose subject
3331
+ * **is** the entity (an asset's side panel, an asset overview card, a
3332
+ * page-level asset header). The card is a reference that lives *inside*
3333
+ * someone else's content.
3334
+ *
3335
+ * @example Clickable asset reference inside a service ticket panel
3336
+ * ```tsx
3337
+ * import { IdentityCard, Highlight } from "@trackunit/react-components";
3338
+ * import { useNavigateInHost } from "@trackunit/react-core-hooks";
3339
+ *
3340
+ * const ServicedAssetRow = ({ assetId }: { assetId: string }) => {
3341
+ * const { gotoAssetHome } = useNavigateInHost();
3342
+ * return (
3343
+ * <IdentityCard
3344
+ * title="Trackunit-AWP343"
3345
+ * subtitle={["Trackunit", "Scissorlift", "GS4390"]}
3346
+ * image={<OEMModelImage brandName="Trackunit" model="GS4390" />}
3347
+ * statusIndicators={statusChips}
3348
+ * connectivity={
3349
+ * <>
3350
+ * <Highlight color="clear" iconName="ChartBar" size="small">
3351
+ * 2h
3352
+ * </Highlight>
3353
+ * <Highlight color="danger" iconName="MapPin" size="small">
3354
+ * 2y
3355
+ * </Highlight>
3356
+ * </>
3357
+ * }
3358
+ * onClick={() => gotoAssetHome(assetId)}
3359
+ * />
3360
+ * );
3361
+ * };
3362
+ * ```
3363
+ * @example Non-clickable (chevron omitted)
3364
+ * ```tsx
3365
+ * <IdentityCard
3366
+ * image={<OEMModelImage brandName="Trackunit" model="GS4390" />}
3367
+ * title="Trackunit-AWP343"
3368
+ * subtitle={["Trackunit", "Scissorlift", "GS4390"]}
3369
+ * />
3370
+ * ```
3371
+ * @example Non-asset entity (site) with an icon thumbnail
3372
+ * ```tsx
3373
+ * import { IdentityCard, Icon } from "@trackunit/react-components";
3374
+ *
3375
+ * <IdentityCard
3376
+ * title="Riverside Job Site"
3377
+ * subtitle={["Riverside Street 12, Copenhagen, Denmark"]}
3378
+ * thumbnail={<Icon name="ConstructionCone" />}
3379
+ * thumbnailColor="site_construction_site-500"
3380
+ * thumbnailBackground="site_construction_site-100"
3381
+ * onClick={() => gotoSiteHome(siteId)}
3382
+ * />
3383
+ * ```
3384
+ * @param {IdentityCardProps} props - The props for the IdentityCard component
3385
+ * @returns {ReactElement} IdentityCard component
3386
+ */
3387
+ const IdentityCard = ({ title, subtitle, image, thumbnail, thumbnailColor = "info-600", thumbnailBackground = "info-100", statusIndicators, connectivity, onClick, className, "data-testid": dataTestId, style, ref, }) => {
3388
+ const hasSubtitleRow = subtitle !== undefined && subtitle.length > 0;
3389
+ const showImage = image !== null && image !== undefined;
3390
+ // `image` and `thumbnail` are mutually exclusive slots — the 14:9 asset
3391
+ // photo and the 44-square icon badge share the same left-side position
3392
+ // and would fight for layout otherwise. When both are supplied, prefer
3393
+ // `image` (documented on the props) so a call-site adding a photo
3394
+ // doesn't silently regress to a badge.
3395
+ const showThumbnail = !showImage && thumbnail !== undefined;
3396
+ const hasConnectivitySlot = connectivity !== null && connectivity !== undefined;
3397
+ const hasStatusSlot = statusIndicators !== null && statusIndicators !== undefined;
3398
+ const isClickable = onClick !== undefined;
3399
+ // Single-line layout kicks in when the text stack renders the title row
3400
+ // only — no subtitle or status row. The `connectivity` slot lives on the
3401
+ // title row itself, so it doesn't add a line and doesn't disqualify the
3402
+ // single-line state.
3403
+ const isSingleLine = !hasSubtitleRow && !hasStatusSlot;
3404
+ // Match the image height to the rendered text stack so the asset image
3405
+ // always spans exactly the lines of text at a 14:9 (168/108) aspect
3406
+ // ratio. The measurement lives in a shared hook so `IdentityCardSkeleton`
3407
+ // stays in sync — the skeleton → loaded transition jumps otherwise.
3408
+ const { imageSize, textStackRef } = useIdentityImageSize(showImage);
3409
+ const imageSlotStyle = showImage && imageSize !== null ? { height: imageSize.height, width: imageSize.width } : undefined;
3410
+ // Keyboard activation for the clickable branch. `Card` renders a `div`
3411
+ // with `role="region"` by default, so on its own it can't be reached with
3412
+ // Tab and Enter/Space wouldn't fire `onClick`. When we're clickable, opt
3413
+ // into button semantics (focusable, activatable) and dispatch a real
3414
+ // click on Enter/Space — the native `.click()` fires our own `onClick`
3415
+ // handler with a genuine `MouseEvent`, so we don't have to fabricate one
3416
+ // from a keyboard event.
3417
+ const handleKeyDown = (event) => {
3418
+ if (event.repeat) {
3419
+ return;
3420
+ }
3421
+ if (event.key !== "Enter" && event.key !== " ") {
3422
+ return;
3423
+ }
3424
+ event.preventDefault();
3425
+ event.currentTarget.click();
3426
+ };
3427
+ const clickableProps = isClickable
3428
+ ? {
3429
+ role: "button",
3430
+ tabIndex: 0,
3431
+ onKeyDown: handleKeyDown,
3432
+ }
3433
+ : {};
3434
+ return (jsxRuntime.jsx(Card, { className: isClickable
3435
+ ? cvaInteractableItem({
3436
+ cursor: "pointer",
3437
+ className: ["focus-visible:outline-native", className],
3438
+ })
3439
+ : className, "data-testid": dataTestId, onClick: onClick, ref: ref, style: style, ...clickableProps, children: jsxRuntime.jsxs("div", { className: cvaCardContentRow(), children: [jsxRuntime.jsxs("div", { className: cvaCardMain({ singleLine: isSingleLine }), children: [showImage ? (jsxRuntime.jsx("div", { className: cvaCardImage(), "data-testid": dataTestId ? `${dataTestId}-image` : undefined, style: imageSlotStyle, children: image })) : null, showThumbnail ? (jsxRuntime.jsx("div", { className: cvaCardThumbnail({
3440
+ className: `text-${thumbnailColor} bg-${thumbnailBackground}`,
3441
+ }), "data-testid": dataTestId ? `${dataTestId}-thumbnail` : undefined, children: thumbnail })) : null, jsxRuntime.jsxs("div", { className: cvaCardTextStack(), ref: textStackRef, children: [jsxRuntime.jsxs("div", { className: cvaCardIdentityGroup({ singleLine: isSingleLine }), "data-testid": dataTestId ? `${dataTestId}-identity-group` : undefined, children: [jsxRuntime.jsxs("div", { className: cvaCardTitleRow(), children: [jsxRuntime.jsx("div", { className: cvaCardTitleCluster(), children: jsxRuntime.jsx("h3", { className: cvaCardTitle(), "data-testid": dataTestId ? `${dataTestId}-title` : undefined,
3442
+ // Inline text-wrap defeats the unlayered global
3443
+ // `h1,h2,h3,h4 { text-wrap: balance }` rule from
3444
+ // `@trackunit/css-core` — otherwise a wrapping title
3445
+ // fights the `truncate` on the same element and the
3446
+ // ellipsis never appears.
3447
+ style: { textWrap: "nowrap" }, title: title, children: title }) }), hasConnectivitySlot ? (jsxRuntime.jsx("div", { className: cvaCardConnectivity(), "data-testid": dataTestId ? `${dataTestId}-connectivity` : undefined, children: connectivity })) : null] }), subtitle !== undefined && subtitle.length > 0 ? (jsxRuntime.jsx(DetailsList, { "data-testid": dataTestId ? `${dataTestId}-subtitle` : undefined, details: subtitle })) : null] }), hasStatusSlot ? (jsxRuntime.jsx("div", { className: cvaCardStatusRow(), "data-testid": dataTestId ? `${dataTestId}-status` : undefined, children: statusIndicators })) : null] })] }), isClickable ? (jsxRuntime.jsx("div", { className: cvaCardChevron(), "data-testid": dataTestId ? `${dataTestId}-chevron` : undefined, children: jsxRuntime.jsx(Icon, { name: "ChevronRight", size: "medium" }) })) : null] }) }));
3448
+ };
3449
+
3450
+ /**
3451
+ * Merges inline styles where `base` defines component defaults and `override` (typically from props) wins on conflicts.
3452
+ */
3453
+ function mergeDomStyles(base, override) {
3454
+ if (base === undefined && override === undefined) {
3455
+ return undefined;
3456
+ }
3457
+ if (base === undefined) {
3458
+ return override;
3459
+ }
3460
+ if (override === undefined) {
3461
+ return base;
3462
+ }
3463
+ return { ...base, ...override };
3464
+ }
3465
+
3466
+ const cvaSkeleton = cssClassVarianceUtilities.cva({
3467
+ base: [
3468
+ "relative",
3469
+ "overflow-hidden",
3470
+ "rounded-lg",
3471
+ // Gradient background
3472
+ "bg-linear-to-r",
3473
+ "from-neutral-200/80",
3474
+ "via-neutral-300/60",
3475
+ "to-neutral-200/80",
3476
+ // Pulse animation
3477
+ "animate-pulse",
3478
+ // Shimmer overlay
3479
+ "before:absolute",
3480
+ "before:inset-0",
3481
+ "before:bg-linear-to-r",
3482
+ "before:from-transparent",
3483
+ "before:via-white/50",
3484
+ "before:to-transparent",
3485
+ "before:opacity-0",
3486
+ "before:animate-pulse",
3487
+ // Smooth transitions for accessibility
3488
+ "transition-all",
3489
+ "duration-300",
3490
+ "ease-in-out",
3491
+ ],
3492
+ variants: {
3493
+ textSize: {
3494
+ "text-xs": ["my-[calc((var(--line-height-xs)-var(--font-size-xs)*0.7)/2)]"],
3495
+ "text-sm": ["my-[calc((var(--line-height-sm)-var(--font-size-sm)*0.7)/2)]"],
3496
+ "text-base": ["my-[calc((var(--line-height-base)-var(--font-size-base)*0.7)/2)]"],
3497
+ "text-lg": ["my-[calc((var(--line-height-lg)-var(--font-size-lg)*0.7)/2)]"],
3498
+ "text-xl": ["my-[calc((var(--line-height-xl)-var(--font-size-xl)*0.7)/2)]"],
3499
+ "text-2xl": ["my-[calc((var(--line-height-2xl)-var(--font-size-2xl)*0.7)/2)]"],
3500
+ "text-3xl": ["my-[calc((var(--line-height-3xl)-var(--font-size-3xl)*0.7)/2)]"],
3501
+ "text-4xl": ["my-[calc((var(--line-height-4xl)-var(--font-size-4xl)*0.7)/2)]"],
3502
+ "text-5xl": ["my-[calc((var(--line-height-5xl)-var(--font-size-5xl)*0.7)/2)]"],
3503
+ "text-6xl": ["my-[calc((var(--line-height-6xl)-var(--font-size-6xl)*0.7)/2)]"],
3504
+ "text-7xl": ["my-[calc((var(--line-height-7xl)-var(--font-size-7xl)*0.7)/2)]"],
3505
+ "text-8xl": ["my-[calc((var(--line-height-8xl)-var(--font-size-8xl)*0.7)/2)]"],
3506
+ "text-9xl": ["my-[calc((var(--line-height-9xl)-var(--font-size-9xl)*0.7)/2)]"],
3507
+ },
3508
+ },
3509
+ });
3510
+
3511
+ /**
3512
+ * SkeletonBlock renders a single animated placeholder block for shape-based elements (images, icons, buttons, avatars)
3513
+ * before data is loaded. It uses exact height and width values to match the element it replaces.
3514
+ *
3515
+ * ### When to use
3516
+ * Use SkeletonBlock for loading placeholders of shape-based UI elements: images, icons, badges, buttons, avatars, thumbnails.
3517
+ *
3518
+ * ### When not to use
3519
+ * For text content, use `SkeletonLabel` which accounts for text line-height margins.
3520
+ * For multiple text lines, use `SkeletonLines`.
3521
+ *
3522
+ * @example Skeleton block for an avatar and button
3523
+ * ```tsx
3524
+ * import { SkeletonBlock } from "@trackunit/react-components";
3525
+ *
3526
+ * const AvatarSkeleton = () => (
3527
+ * <div className="flex items-center gap-3">
3528
+ * <SkeletonBlock height={40} width={40} className="rounded-full" />
3529
+ * <SkeletonBlock height={32} width={120} className="rounded-md" />
3530
+ * </div>
3531
+ * );
3532
+ * ```
3533
+ * @param {SkeletonBlockProps} props - The props for the SkeletonBlock component
3534
+ * @returns {ReactElement} SkeletonBlock component
3535
+ */
3536
+ const SkeletonBlock = react.memo((props) => {
3537
+ const { width = "100%", height = 16, flexibleWidth = false, className, "data-testid": dataTestId, children, style, ref, } = props;
3538
+ const widthValue = typeof width === "number" ? `${width}px` : width;
3539
+ const heightValue = typeof height === "number" ? `${height}px` : height;
3540
+ return (jsxRuntime.jsx("div", { "aria-label": "Loading", className: cvaSkeleton({ className }), "data-testid": dataTestId, ref: ref, role: "status", style: mergeDomStyles({
3541
+ width: flexibleWidth ? "100%" : widthValue,
3542
+ maxWidth: flexibleWidth ? widthValue : undefined,
3543
+ height: heightValue,
3544
+ }, style), children: children }));
3545
+ });
3546
+ SkeletonBlock.displayName = "SkeletonBlock";
3547
+
3548
+ const VALID_SIZE_KEYS = [
3549
+ "xs",
3550
+ "sm",
3551
+ "base",
3552
+ "lg",
3553
+ "xl",
3554
+ "2xl",
3555
+ "3xl",
3556
+ "4xl",
3557
+ "5xl",
3558
+ "6xl",
3559
+ "7xl",
3560
+ "8xl",
3561
+ "9xl",
3562
+ ];
3563
+ /**
3564
+ * Extract the size key from a text size string (e.g., "text-base" → "base").
3565
+ *
3566
+ * @param value - The text size string to parse
3567
+ * @returns {fontSizeKeys | null} The extracted size key or null if invalid
3568
+ */
3569
+ const extractSizeKey = (value) => {
3570
+ if (!value.startsWith("text-")) {
3571
+ return null;
3572
+ }
3573
+ const sizeKey = value.replace("text-", "");
3574
+ return VALID_SIZE_KEYS.find(key => key === sizeKey) ?? null;
3575
+ };
3576
+ /**
3577
+ * Calculate the height value based on the height prop and variant.
3578
+ *
3579
+ * @param height - The height value (number, CSS length, or text size key)
3580
+ * @param variant - The skeleton variant ("text" or "block")
3581
+ * @returns {string} The calculated CSS height value
3582
+ */
3583
+ const getHeightValue = (height, variant) => {
3584
+ if (typeof height === "number") {
3585
+ return `${height}px`;
3586
+ }
3587
+ const sizeKey = extractSizeKey(height);
3588
+ if (sizeKey) {
3589
+ // Text variant: use font-size × 0.7 to approximate cap-height
3590
+ // Block variant: use full line-height (for when text size keys are passed to block variant)
3591
+ return `calc(var(--font-size-${sizeKey}) * 0.7)` ;
3592
+ }
3593
+ return height;
3594
+ };
3595
+ /**
3596
+ * Type-safe helper to construct a CSSLength from a number and unit.
3597
+ * Uses a generic to preserve the specific unit type in the return type.
3598
+ */
3599
+ const makeCSSLength = (value, unit) => {
3600
+ return `${value}${unit}`;
3601
+ };
3602
+ /**
3603
+ * Generates a random CSS length value.
3604
+ *
3605
+ * Note: For React components, prefer `useRandomCSSLengths` hook to ensure stable values across re-renders.
3606
+ *
3607
+ * @param params - The parameters object
3608
+ * @param params.min - Minimum numeric value
3609
+ * @param params.max - Maximum numeric value
3610
+ * @param params.unit - CSS unit (defaults to "%")
3611
+ * @returns {CSSLength} A CSS length string (e.g., "65%", "120px", "4rem")
3612
+ */
3613
+ const getRandomCSSLength = ({ min, max, unit = "%", }) => {
3614
+ const randomValue = Math.floor(Math.random() * (max - min + 1)) + min;
3615
+ return makeCSSLength(randomValue, unit);
3616
+ };
3617
+ /**
3618
+ * React hook that generates stable random CSS length values for skeleton loading components.
3619
+ *
3620
+ * Values are memoized and only regenerate when count, min, max, or unit changes.
3621
+ *
3622
+ * @param params - The parameters object
3623
+ * @param params.count - Number of random lengths to generate
3624
+ * @param params.min - Minimum numeric value
3625
+ * @param params.max - Maximum numeric value
3626
+ * @param params.unit - CSS unit (defaults to "%")
3627
+ * @returns {Array<CSSLength>} Array of CSS length strings (e.g., ["65%", "42%", "78%"])
3628
+ */
3629
+ const useRandomCSSLengths = ({ count, min, max, unit = "%", }) => {
3630
+ return react.useMemo(() => Array.from({ length: count }, () => getRandomCSSLength({ min, max, unit })), [count, min, max, unit]);
3631
+ };
3632
+
3633
+ /**
3634
+ * SkeletonLabel renders a single animated placeholder line for text content. It uses text-size keys (text-xs, text-sm, text-base, etc.)
3635
+ * to match the visual cap-height of actual text, with appropriate vertical margins to maintain line-height alignment.
3636
+ *
3637
+ * ### When to use
3638
+ * Use SkeletonLabel as a loading placeholder for single text lines: labels, titles, descriptions, values.
3639
+ *
3640
+ * ### When not to use
3641
+ * For multiple text lines, use `SkeletonLines`.
3642
+ * For shape-based elements (images, icons, avatars), use `SkeletonBlock`.
3643
+ *
3644
+ * @example Skeleton label for a title and description
3645
+ * ```tsx
3646
+ * import { SkeletonLabel } from "@trackunit/react-components";
3647
+ *
3648
+ * const TextSkeleton = () => (
3649
+ * <div className="flex flex-col gap-1">
3650
+ * <SkeletonLabel textSize="text-lg" width={200} />
3651
+ * <SkeletonLabel textSize="text-sm" width="80%" />
3652
+ * </div>
3653
+ * );
3654
+ * ```
3655
+ * @param {SkeletonLabelProps} props - The props for the SkeletonLabel component
3656
+ * @returns {ReactElement} SkeletonLabel component
3657
+ */
3658
+ const SkeletonLabel = react.memo((props) => {
3659
+ const { width = "100%", textSize = "text-base", flexibleWidth = true, className, "data-testid": dataTestId, children, style, ref, } = props;
3660
+ const widthValue = typeof width === "number" ? `${width}px` : width;
3661
+ const heightValue = getHeightValue(textSize);
3662
+ return (jsxRuntime.jsx("div", { "aria-label": "Loading", className: cvaSkeleton({ textSize, className }), "data-testid": dataTestId, ref: ref, role: "status", style: mergeDomStyles({
3663
+ width: flexibleWidth ? "100%" : widthValue,
3664
+ maxWidth: flexibleWidth ? widthValue : undefined,
3665
+ height: heightValue,
3666
+ }, style), children: children }));
3667
+ });
3668
+ SkeletonLabel.displayName = "SkeletonLabel";
3669
+
3670
+ /**
3671
+ * IdentityCardSkeleton is the loading placeholder paired with
3672
+ * `IdentityCard`. It renders per-slot skeleton blocks (title +
3673
+ * optional subtitle / status indicators / image / thumbnail / connectivity)
3674
+ * inside the same `Card` shell so the skeleton → loaded
3675
+ * transition is visually stable. When `hasChevron` is on, the real
3676
+ * `ChevronRight` is shown — that icon does not depend on loaded data.
3677
+ *
3678
+ * The slot toggles (`hasImage`, `hasThumbnail`, `hasSubtitle`,
3679
+ * `hasStatusIndicators`, `hasConnectivity`, `hasChevron`) are the skeleton
3680
+ * counterparts to the loaded `IdentityCard`'s slot props / `onClick` —
3681
+ * flip on the slots you'll render once the data resolves.
3682
+ *
3683
+ * ### When to use
3684
+ * Use `IdentityCardSkeleton` in place of `IdentityCard` while
3685
+ * the referenced entity's data is being fetched.
3686
+ *
3687
+ * ### When not to use
3688
+ * Do not use for generic text loading — use `SkeletonLabel` /
3689
+ * `SkeletonBlock` directly. Do not use to represent partial data (some
3690
+ * fields loaded, others not); render `IdentityCard` with the fields
3691
+ * you have and omit the rest.
3692
+ *
3693
+ * @example Compact card (title + subtitle)
3694
+ * ```tsx
3695
+ * import { IdentityCardSkeleton } from "@trackunit/react-components";
3696
+ *
3697
+ * const Loading = () => <IdentityCardSkeleton />;
3698
+ * ```
3699
+ * @example Clickable card with image + status + connectivity
3700
+ * ```tsx
3701
+ * <IdentityCardSkeleton hasImage hasStatusIndicators hasConnectivity hasChevron />
3702
+ * ```
3703
+ * @param {IdentityCardSkeletonProps} props - The props for the IdentityCardSkeleton component
3704
+ * @returns {ReactElement} IdentityCardSkeleton component
3705
+ */
3706
+ const IdentityCardSkeleton = ({ hasImage = false, hasThumbnail = false, hasSubtitle = true, hasStatusIndicators = false, hasConnectivity = false, hasChevron = false, className, "data-testid": dataTestId, style, ref, }) => {
3707
+ // Mirror the loaded card's slot precedence: `hasImage` wins over
3708
+ // `hasThumbnail` so the skeleton → loaded transition doesn't jump if a
3709
+ // call-site mis-toggles both.
3710
+ const showThumbnail = hasThumbnail && !hasImage;
3711
+ const { imageSize, textStackRef } = useIdentityImageSize(hasImage);
3712
+ const imageSlotStyle = hasImage && imageSize !== null ? { height: imageSize.height, width: imageSize.width } : undefined;
3713
+ // Mirror the loaded `IdentityCard` single-line rule so the skeleton →
3714
+ // loaded transition keeps the same vertical alignment against the image.
3715
+ const isSingleLine = !hasSubtitle && !hasStatusIndicators;
3716
+ return (jsxRuntime.jsx(Card, { className: className, "data-testid": dataTestId, ref: ref, style: style, children: jsxRuntime.jsxs("div", { className: cvaCardContentRow(), children: [jsxRuntime.jsxs("div", { className: cvaCardMain({ singleLine: isSingleLine }), children: [hasImage ? (jsxRuntime.jsx("div", { className: cvaCardImage(), "data-testid": dataTestId ? `${dataTestId}-image` : undefined, style: imageSlotStyle, children: jsxRuntime.jsx(SkeletonBlock, { "data-testid": dataTestId ? `${dataTestId}-image-skeleton` : undefined, height: "100%", width: "100%" }) })) : null, showThumbnail ? (jsxRuntime.jsx("div", { className: cvaCardThumbnail(), "data-testid": dataTestId ? `${dataTestId}-thumbnail` : undefined, children: jsxRuntime.jsx(SkeletonBlock, { "data-testid": dataTestId ? `${dataTestId}-thumbnail-skeleton` : undefined, height: "100%", width: "100%" }) })) : null, jsxRuntime.jsxs("div", { className: cvaCardTextStack(), ref: textStackRef, children: [jsxRuntime.jsxs("div", { className: cvaCardIdentityGroup({ singleLine: isSingleLine }), "data-testid": dataTestId ? `${dataTestId}-identity-group` : undefined, children: [jsxRuntime.jsxs("div", { className: cvaCardTitleRow(), children: [jsxRuntime.jsx("div", { className: cvaCardTitleCluster(), children: jsxRuntime.jsx(SkeletonLabel, { "data-testid": dataTestId ? `${dataTestId}-title-skeleton` : undefined, textSize: "text-base", width: 140 }) }), hasConnectivity ? (jsxRuntime.jsx(SkeletonBlock, { "data-testid": dataTestId ? `${dataTestId}-connectivity-skeleton` : undefined, height: 14, width: 50 })) : null] }), hasSubtitle ? (jsxRuntime.jsx(SkeletonLabel, { "data-testid": dataTestId ? `${dataTestId}-subtitle-skeleton` : undefined, textSize: "text-xs", width: 180 })) : null] }), hasStatusIndicators ? (jsxRuntime.jsxs("div", { className: cvaCardStatusSkeletonRow(), "data-testid": dataTestId ? `${dataTestId}-status-skeleton` : undefined, children: [jsxRuntime.jsx(SkeletonBlock, { height: 14, width: 42 }), jsxRuntime.jsx(SkeletonBlock, { height: 14, width: 64 })] })) : null] })] }), hasChevron ? (jsxRuntime.jsx("div", { className: cvaCardChevron(), "data-testid": dataTestId ? `${dataTestId}-chevron` : undefined, children: jsxRuntime.jsx(Icon, { name: "ChevronRight", size: "medium" }) })) : null] }) }));
3717
+ };
3718
+
2813
3719
  const cvaBadge = cssClassVarianceUtilities.cva({
2814
3720
  base: [
2815
3721
  "box-border",
@@ -3163,157 +4069,12 @@ const Breadcrumb = ({ className, "data-testid": dataTestId, breadcrumbItems, onC
3163
4069
  * return <StarButton starred={starred} onClick={() => setStarred(s => !s)} />;
3164
4070
  * };
3165
4071
  * ```
3166
- * @param {StarButtonProps} props - The props for the StarButton component
3167
- * @returns {ReactElement} StarButton component
3168
- */
3169
- const StarButton = ({ starred, onClick, className, "data-testid": dataTestId, style, ref, }) => {
3170
- return (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId ?? "starred-filter", onClick: onClick, ref: ref, style: style, children: jsxRuntime.jsx(Icon, { color: starred ? "primary" : "neutral", name: "Star", size: "medium" }) }));
3171
- };
3172
-
3173
- const cvaCard = cssClassVarianceUtilities.cva({
3174
- base: [
3175
- "flex",
3176
- "border",
3177
- "flex-col",
3178
- "relative",
3179
- "bg-white",
3180
- "component-card-shadow",
3181
- "component-card-border",
3182
- "transition-colors",
3183
- "ease-in-out",
3184
- "overflow-clip", //otherwise rounded corners are not respected (clip is preferable to hidden since it doesn't create a scroll clipping box. )
3185
- ],
3186
- variants: {
3187
- clickable: {
3188
- false: "",
3189
- true: "cursor-pointer hover:bg-neutral-50",
3190
- },
3191
- fullHeight: {
3192
- false: "",
3193
- true: "h-full",
3194
- },
3195
- },
3196
- defaultVariants: {
3197
- clickable: false,
3198
- fullHeight: false,
3199
- },
3200
- });
3201
- const cvaCardFooterContainerRoot = cssClassVarianceUtilities.cva({
3202
- base: ["flex", "border-t", "border-neutral-200", "justify-end", "flex-row"],
3203
- variants: {
3204
- border: {
3205
- default: "",
3206
- borderless: "border-t-0",
3207
- },
3208
- padding: {
3209
- none: "p-0",
3210
- default: "p-4",
3211
- },
3212
- },
3213
- defaultVariants: {
3214
- border: "default",
3215
- padding: "default",
3216
- },
3217
- });
3218
- const cvaCardFooterContainer = cssClassVarianceUtilities.cva({
3219
- base: ["justify-self-end", "grid", "auto-cols-max", "grid-flow-col", "gap-2"],
3220
- });
3221
- const cvaCardHeader = cssClassVarianceUtilities.cva({ base: ["flex", "justify-between", "flex-1"] });
3222
- const cvaCardHeaderHeadingContainer = cssClassVarianceUtilities.cva({ base: ["flex", "gap-4", "items-center"] });
3223
- const cvaCardHeaderContainer = cssClassVarianceUtilities.cva({
3224
- base: ["flex", "border-b", "border-neutral-200"],
3225
- variants: {
3226
- border: {
3227
- default: "",
3228
- borderless: "border-b-0",
3229
- },
3230
- padding: {
3231
- none: "p-0",
3232
- default: "p-4",
3233
- },
3234
- },
3235
- defaultVariants: {
3236
- border: "default",
3237
- padding: "default",
3238
- },
3239
- });
3240
- const cvaCardBodyContainer = cssClassVarianceUtilities.cva({
3241
- base: ["flex", "flex-grow", "overflow-auto"],
3242
- variants: {
3243
- direction: {
3244
- row: "flex-row",
3245
- "row-reverse": "flex-row-reverse",
3246
- column: "flex-col",
3247
- "column-reverse": "flex-col-reverse",
3248
- },
3249
- padding: {
3250
- none: "p-0",
3251
- default: "p-4",
3252
- },
3253
- gap: {
3254
- none: "gap-0",
3255
- default: "gap-4",
3256
- },
3257
- },
3258
- defaultVariants: {
3259
- direction: "column",
3260
- padding: "default",
3261
- gap: "default",
3262
- },
3263
- });
3264
-
3265
- const ROLE_CARD = "region";
3266
- /**
3267
- * The Card component is a container for UI elements that groups related content together with a visual boundary.
3268
- * To get the most out of the Card, use it in combination with the CardHeader, CardBody and CardFooter.
3269
- *
3270
- * ### When to use
3271
- * Use cards to group related information and actions, such as in dashboards, list items, or content previews.
3272
- *
3273
- * ### When not to use
3274
- * Do not use cards for layout purposes only. Use standard containers or grid layouts instead.
3275
- *
3276
- * @example Basic card
3277
- * ```tsx
3278
- * import { Card, CardHeader, CardBody } from "@trackunit/react-components";
3279
- *
3280
- * const MyComponent = () => {
3281
- * return (
3282
- * <Card>
3283
- * <CardHeader title="Asset Details" />
3284
- * <CardBody>
3285
- * <p>This card contains information about the asset.</p>
3286
- * </CardBody>
3287
- * </Card>
3288
- * );
3289
- * };
3290
- * ```
3291
- * @example Clickable card with full content
3292
- * ```tsx
3293
- * import { Card, CardHeader, CardBody, CardFooter, Button } from "@trackunit/react-components";
3294
- *
3295
- * const AssetCard = () => {
3296
- * return (
3297
- * <Card onClick={() => console.log("Card clicked")}>
3298
- * <CardHeader title="Excavator #1234" subtitle="Last updated: 2 hours ago" />
3299
- * <CardBody>
3300
- * <p>Operating hours: 1,234</p>
3301
- * <p>Status: Active</p>
3302
- * </CardBody>
3303
- * <CardFooter>
3304
- * <Button variant="secondary" size="small">View Details</Button>
3305
- * </CardFooter>
3306
- * </Card>
3307
- * );
3308
- * };
3309
- * ```
3310
- * @param {CardProps} props - The props for the Card component
3311
- * @returns {ReactElement} Card component
4072
+ * @param {StarButtonProps} props - The props for the StarButton component
4073
+ * @returns {ReactElement} StarButton component
3312
4074
  */
3313
- const Card = ({ children, onClick, fullHeight = false, onMouseEnter, onMouseLeave, className, "data-testid": dataTestId, ref, ...rest }) => {
3314
- return (jsxRuntime.jsx("div", { className: cvaCard({ fullHeight, clickable: Boolean(onClick), className }), "data-card": true, "data-testid": dataTestId, onClick: onClick, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, ref: ref, role: ROLE_CARD, ...rest, children: children }));
4075
+ const StarButton = ({ starred, onClick, className, "data-testid": dataTestId, style, ref, }) => {
4076
+ return (jsxRuntime.jsx("div", { className: className, "data-testid": dataTestId ?? "starred-filter", onClick: onClick, ref: ref, style: style, children: jsxRuntime.jsx(Icon, { color: starred ? "primary" : "neutral", name: "Star", size: "medium" }) }));
3315
4077
  };
3316
- Card.displayName = "Card";
3317
4078
 
3318
4079
  /**
3319
4080
  * CardBody is the main content area of a Card. It provides consistent padding and gap between child elements.
@@ -3919,341 +4680,111 @@ const useCopyToClipboard = () => {
3919
4680
  textArea.readOnly = false;
3920
4681
  const range = document.createRange();
3921
4682
  range.selectNodeContents(textArea);
3922
- const selection = window.getSelection();
3923
- if (selection) {
3924
- selection.removeAllRanges();
3925
- selection.addRange(range);
3926
- }
3927
- textArea.setSelectionRange(0, 999999);
3928
- }
3929
- else {
3930
- textArea.select();
3931
- }
3932
- const successful = document.execCommand("copy");
3933
- document.body.removeChild(textArea);
3934
- if (successful) {
3935
- setIsCopied(true);
3936
- }
3937
- else {
3938
- throw new Error("Copy command was unsuccessful");
3939
- }
3940
- }
3941
- catch (error) {
3942
- setIsCopied(false);
3943
- // Re-throw with more context for debugging
3944
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
3945
- throw new Error(`Copy failed: ${errorMessage}`);
3946
- }
3947
- }, []);
3948
- return react.useMemo(() => [isCopied, copyToClipboard], [isCopied, copyToClipboard]);
3949
- };
3950
-
3951
- /**
3952
- * CopyButton is a standalone icon button that copies a value to the clipboard when clicked,
3953
- * showing brief tooltip feedback. The copied value is not itself the clickable/displayed
3954
- * element, so the button can be placed next to plain (non-clickable) text.
3955
- *
3956
- * ### When to use
3957
- * - Icon-only (default): the copyable value is displayed as plain text (e.g. in a table cell or
3958
- * row) and only a dedicated icon should trigger the copy, keeping the rest of the row clickable.
3959
- * - Labelled (`label` set): copying is the primary action of a panel or section — e.g. an invite
3960
- * link — and deserves a full button that is itself the click target.
3961
- *
3962
- * ### When not to use
3963
- * Do not hand-roll copy affordances (custom icons, click-to-copy text) — this component is the
3964
- * one copy control; pick icon-only or labelled mode instead.
3965
- *
3966
- * ### Empty values
3967
- * CopyButton renders nothing when `value` is empty, so consumers never present a copy control
3968
- * that has no meaningful result.
3969
- *
3970
- * @example Icon button next to plain text
3971
- * ```tsx
3972
- * import { CopyButton, Text } from "@trackunit/react-components";
3973
- *
3974
- * const ShortCode = ({ code }: { code: string }) => (
3975
- * <div className="flex items-center gap-0.5">
3976
- * <Text type="span">{code}</Text>
3977
- * <CopyButton value={code} />
3978
- * </div>
3979
- * );
3980
- * ```
3981
- * @param {CopyButtonProps} props - The props for the CopyButton component
3982
- * @returns {ReactElement} CopyButton component
3983
- */
3984
- const CopyButton = ({ value, copyLabel, copiedLabel, size = "small", label, variant = "ghost-neutral", className, style, "data-testid": dataTestId, onCopy, ref, }) => {
3985
- const [t] = useTranslation();
3986
- const [showCopied, setShowCopied] = react.useState(false);
3987
- const [, copyToClipboard] = useCopyToClipboard();
3988
- const resetTimerRef = react.useRef(undefined);
3989
- react.useEffect(() => () => clearTimeout(resetTimerRef.current), []);
3990
- // Localized defaults, so call sites don't each re-declare "Copy"/"Copied!"
3991
- // in their own translation files; the props stay as context-specific overrides.
3992
- const resolvedCopyLabel = copyLabel ?? t("copyButton.copy");
3993
- const resolvedCopiedLabel = copiedLabel ?? t("copyButton.copied");
3994
- if (!value) {
3995
- return null;
3996
- }
3997
- const handleCopy = (e) => {
3998
- e.stopPropagation();
3999
- void copyToClipboard(value)
4000
- // Rejections (both clipboard mechanisms failed) are intentionally
4001
- // swallowed; the icon simply doesn't flip. A throwing onCopy is a
4002
- // consumer bug and surfaces as an unhandled rejection instead.
4003
- .then(() => {
4004
- // Flip the icon to a checkmark for a moment, then revert. The timer
4005
- // owns the copied state so the confirmation survives the pointer
4006
- // leaving the button, and runs even if onCopy below throws.
4007
- setShowCopied(true);
4008
- clearTimeout(resetTimerRef.current);
4009
- resetTimerRef.current = setTimeout(() => setShowCopied(false), COPIED_FEEDBACK_DURATION_MS);
4010
- onCopy?.();
4011
- }, () => undefined);
4012
- };
4013
- const icon = (jsxRuntime.jsx(Icon, { ariaHidden: true, name: showCopied ? "Check" : "Square2Stack", size: size === "large" ? "medium" : "small" }));
4014
- if (label) {
4015
- // Labelled mode: a regular (non-square) button whose prefix icon carries
4016
- // the copied confirmation. `extraSmall` is icon-only, so it collapses to small.
4017
- // The visible label stays the accessible name (WCAG 2.5.3 Label in Name);
4018
- // since the copied feedback is otherwise visual-only, it is announced
4019
- // through the polite live region rendered next to the button.
4020
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Tooltip, { delayed: !showCopied, label: showCopied ? resolvedCopiedLabel : resolvedCopyLabel, placement: "top", children: jsxRuntime.jsx(Button, { className: className, "data-testid": dataTestId, onClick: handleCopy, prefix: icon, ref: ref, size: toButtonSize(size), style: style, variant: variant, children: label }) }), jsxRuntime.jsx("span", { className: "sr-only", role: "status", children: showCopied ? resolvedCopiedLabel : undefined })] }));
4021
- }
4022
- return (jsxRuntime.jsx(Tooltip, { delayed: !showCopied, label: showCopied ? resolvedCopiedLabel : resolvedCopyLabel, placement: "top", children: jsxRuntime.jsx(Button, { ariaLabel: showCopied ? resolvedCopiedLabel : resolvedCopyLabel, className: cvaIconButton({ size, className }), "data-testid": dataTestId, onClick: handleCopy, prefix: icon, ref: ref, size: toButtonSize(size), square: true, style: style, variant: variant }) }));
4023
- };
4024
- CopyButton.displayName = "CopyButton";
4025
- const COPIED_FEEDBACK_DURATION_MS = 2000;
4026
-
4027
- const cvaDetailsList = cssClassVarianceUtilities.cva({
4028
- base: ["flex", "w-full", "min-w-0", "items-center", "truncate", "text-xs", "text-neutral-600", "font-medium", "pt-0"],
4029
- variants: {
4030
- hasLink: {
4031
- true: "pl-1",
4032
- },
4033
- },
4034
- defaultVariants: {
4035
- hasLink: false,
4036
- },
4037
- });
4038
- const cvaDetailsListItem = cssClassVarianceUtilities.cva({ base: ["last:truncate"] });
4039
-
4040
- /**
4041
- * DetailsList renders a one-line list of text values separated by slash icons.
4042
- * It is used to display compact metadata such as model, serial number, or location in a single row.
4043
- *
4044
- * ### When to use
4045
- * Use DetailsList to display a compact, horizontal list of metadata values (e.g., in a card subtitle or list item description).
4046
- *
4047
- * ### When not to use
4048
- * Do not use DetailsList for key-value pairs — use a standard layout with labels. Do not use for long text — values should be short strings.
4049
- *
4050
- * @example Displaying asset metadata
4051
- * ```tsx
4052
- * import { DetailsList } from "@trackunit/react-components";
4053
- *
4054
- * const AssetMeta = () => (
4055
- * <DetailsList details={["Excavator", "CAT 320", "SN-00142"]} />
4056
- * );
4057
- * ```
4058
- * @example DetailsList inside a linked context
4059
- * ```tsx
4060
- * import { DetailsList } from "@trackunit/react-components";
4061
- *
4062
- * const LinkedAssetMeta = () => (
4063
- * <a href="/assets/123">
4064
- * <DetailsList details={["Site: Oslo", "Group: Heavy"]} hasLink />
4065
- * </a>
4066
- * );
4067
- * ```
4068
- * @param {DetailsListProps} props - The props for the DetailsList component
4069
- * @returns {ReactElement} DetailsList component
4070
- */
4071
- const DetailsList = ({ details, className, "data-testid": dataTestId, hasLink = false, style, ref, }) => {
4072
- return (jsxRuntime.jsx("div", { className: cvaDetailsList({ className, hasLink }), "data-testid": dataTestId, ref: ref, style: style, children: details.map((value, index, array) => (jsxRuntime.jsxs(react.Fragment, { children: [jsxRuntime.jsx("span", { className: cvaDetailsListItem({ className }), children: value }), index < array.length - 1 && (jsxRuntime.jsx("div", { className: "mx-0.5 flex items-center", children: jsxRuntime.jsx(Icon, { className: "w-4 text-neutral-300", color: "neutral", name: "Slash", size: "small" }) }))] }, index))) }));
4073
- };
4074
-
4075
- /**
4076
- * Merges inline styles where `base` defines component defaults and `override` (typically from props) wins on conflicts.
4077
- */
4078
- function mergeDomStyles(base, override) {
4079
- if (base === undefined && override === undefined) {
4080
- return undefined;
4081
- }
4082
- if (base === undefined) {
4083
- return override;
4084
- }
4085
- if (override === undefined) {
4086
- return base;
4087
- }
4088
- return { ...base, ...override };
4089
- }
4090
-
4091
- const VALID_SIZE_KEYS = [
4092
- "xs",
4093
- "sm",
4094
- "base",
4095
- "lg",
4096
- "xl",
4097
- "2xl",
4098
- "3xl",
4099
- "4xl",
4100
- "5xl",
4101
- "6xl",
4102
- "7xl",
4103
- "8xl",
4104
- "9xl",
4105
- ];
4106
- /**
4107
- * Extract the size key from a text size string (e.g., "text-base" → "base").
4108
- *
4109
- * @param value - The text size string to parse
4110
- * @returns {fontSizeKeys | null} The extracted size key or null if invalid
4111
- */
4112
- const extractSizeKey = (value) => {
4113
- if (!value.startsWith("text-")) {
4114
- return null;
4115
- }
4116
- const sizeKey = value.replace("text-", "");
4117
- return VALID_SIZE_KEYS.find(key => key === sizeKey) ?? null;
4118
- };
4119
- /**
4120
- * Calculate the height value based on the height prop and variant.
4121
- *
4122
- * @param height - The height value (number, CSS length, or text size key)
4123
- * @param variant - The skeleton variant ("text" or "block")
4124
- * @returns {string} The calculated CSS height value
4125
- */
4126
- const getHeightValue = (height, variant) => {
4127
- if (typeof height === "number") {
4128
- return `${height}px`;
4129
- }
4130
- const sizeKey = extractSizeKey(height);
4131
- if (sizeKey) {
4132
- // Text variant: use font-size × 0.7 to approximate cap-height
4133
- // Block variant: use full line-height (for when text size keys are passed to block variant)
4134
- return `calc(var(--font-size-${sizeKey}) * 0.7)` ;
4135
- }
4136
- return height;
4137
- };
4138
- /**
4139
- * Type-safe helper to construct a CSSLength from a number and unit.
4140
- * Uses a generic to preserve the specific unit type in the return type.
4141
- */
4142
- const makeCSSLength = (value, unit) => {
4143
- return `${value}${unit}`;
4144
- };
4145
- /**
4146
- * Generates a random CSS length value.
4147
- *
4148
- * Note: For React components, prefer `useRandomCSSLengths` hook to ensure stable values across re-renders.
4149
- *
4150
- * @param params - The parameters object
4151
- * @param params.min - Minimum numeric value
4152
- * @param params.max - Maximum numeric value
4153
- * @param params.unit - CSS unit (defaults to "%")
4154
- * @returns {CSSLength} A CSS length string (e.g., "65%", "120px", "4rem")
4155
- */
4156
- const getRandomCSSLength = ({ min, max, unit = "%", }) => {
4157
- const randomValue = Math.floor(Math.random() * (max - min + 1)) + min;
4158
- return makeCSSLength(randomValue, unit);
4159
- };
4160
- /**
4161
- * React hook that generates stable random CSS length values for skeleton loading components.
4162
- *
4163
- * Values are memoized and only regenerate when count, min, max, or unit changes.
4164
- *
4165
- * @param params - The parameters object
4166
- * @param params.count - Number of random lengths to generate
4167
- * @param params.min - Minimum numeric value
4168
- * @param params.max - Maximum numeric value
4169
- * @param params.unit - CSS unit (defaults to "%")
4170
- * @returns {Array<CSSLength>} Array of CSS length strings (e.g., ["65%", "42%", "78%"])
4171
- */
4172
- const useRandomCSSLengths = ({ count, min, max, unit = "%", }) => {
4173
- return react.useMemo(() => Array.from({ length: count }, () => getRandomCSSLength({ min, max, unit })), [count, min, max, unit]);
4683
+ const selection = window.getSelection();
4684
+ if (selection) {
4685
+ selection.removeAllRanges();
4686
+ selection.addRange(range);
4687
+ }
4688
+ textArea.setSelectionRange(0, 999999);
4689
+ }
4690
+ else {
4691
+ textArea.select();
4692
+ }
4693
+ const successful = document.execCommand("copy");
4694
+ document.body.removeChild(textArea);
4695
+ if (successful) {
4696
+ setIsCopied(true);
4697
+ }
4698
+ else {
4699
+ throw new Error("Copy command was unsuccessful");
4700
+ }
4701
+ }
4702
+ catch (error) {
4703
+ setIsCopied(false);
4704
+ // Re-throw with more context for debugging
4705
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
4706
+ throw new Error(`Copy failed: ${errorMessage}`);
4707
+ }
4708
+ }, []);
4709
+ return react.useMemo(() => [isCopied, copyToClipboard], [isCopied, copyToClipboard]);
4174
4710
  };
4175
4711
 
4176
- const cvaSkeleton = cssClassVarianceUtilities.cva({
4177
- base: [
4178
- "relative",
4179
- "overflow-hidden",
4180
- "rounded-lg",
4181
- // Gradient background
4182
- "bg-linear-to-r",
4183
- "from-neutral-200/80",
4184
- "via-neutral-300/60",
4185
- "to-neutral-200/80",
4186
- // Pulse animation
4187
- "animate-pulse",
4188
- // Shimmer overlay
4189
- "before:absolute",
4190
- "before:inset-0",
4191
- "before:bg-linear-to-r",
4192
- "before:from-transparent",
4193
- "before:via-white/50",
4194
- "before:to-transparent",
4195
- "before:opacity-0",
4196
- "before:animate-pulse",
4197
- // Smooth transitions for accessibility
4198
- "transition-all",
4199
- "duration-300",
4200
- "ease-in-out",
4201
- ],
4202
- variants: {
4203
- textSize: {
4204
- "text-xs": ["my-[calc((var(--line-height-xs)-var(--font-size-xs)*0.7)/2)]"],
4205
- "text-sm": ["my-[calc((var(--line-height-sm)-var(--font-size-sm)*0.7)/2)]"],
4206
- "text-base": ["my-[calc((var(--line-height-base)-var(--font-size-base)*0.7)/2)]"],
4207
- "text-lg": ["my-[calc((var(--line-height-lg)-var(--font-size-lg)*0.7)/2)]"],
4208
- "text-xl": ["my-[calc((var(--line-height-xl)-var(--font-size-xl)*0.7)/2)]"],
4209
- "text-2xl": ["my-[calc((var(--line-height-2xl)-var(--font-size-2xl)*0.7)/2)]"],
4210
- "text-3xl": ["my-[calc((var(--line-height-3xl)-var(--font-size-3xl)*0.7)/2)]"],
4211
- "text-4xl": ["my-[calc((var(--line-height-4xl)-var(--font-size-4xl)*0.7)/2)]"],
4212
- "text-5xl": ["my-[calc((var(--line-height-5xl)-var(--font-size-5xl)*0.7)/2)]"],
4213
- "text-6xl": ["my-[calc((var(--line-height-6xl)-var(--font-size-6xl)*0.7)/2)]"],
4214
- "text-7xl": ["my-[calc((var(--line-height-7xl)-var(--font-size-7xl)*0.7)/2)]"],
4215
- "text-8xl": ["my-[calc((var(--line-height-8xl)-var(--font-size-8xl)*0.7)/2)]"],
4216
- "text-9xl": ["my-[calc((var(--line-height-9xl)-var(--font-size-9xl)*0.7)/2)]"],
4217
- },
4218
- },
4219
- });
4220
-
4221
4712
  /**
4222
- * SkeletonLabel renders a single animated placeholder line for text content. It uses text-size keys (text-xs, text-sm, text-base, etc.)
4223
- * to match the visual cap-height of actual text, with appropriate vertical margins to maintain line-height alignment.
4713
+ * CopyButton is a standalone icon button that copies a value to the clipboard when clicked,
4714
+ * showing brief tooltip feedback. The copied value is not itself the clickable/displayed
4715
+ * element, so the button can be placed next to plain (non-clickable) text.
4224
4716
  *
4225
4717
  * ### When to use
4226
- * Use SkeletonLabel as a loading placeholder for single text lines: labels, titles, descriptions, values.
4718
+ * - Icon-only (default): the copyable value is displayed as plain text (e.g. in a table cell or
4719
+ * row) and only a dedicated icon should trigger the copy, keeping the rest of the row clickable.
4720
+ * - Labelled (`label` set): copying is the primary action of a panel or section — e.g. an invite
4721
+ * link — and deserves a full button that is itself the click target.
4227
4722
  *
4228
4723
  * ### When not to use
4229
- * For multiple text lines, use `SkeletonLines`.
4230
- * For shape-based elements (images, icons, avatars), use `SkeletonBlock`.
4724
+ * Do not hand-roll copy affordances (custom icons, click-to-copy text) — this component is the
4725
+ * one copy control; pick icon-only or labelled mode instead.
4231
4726
  *
4232
- * @example Skeleton label for a title and description
4727
+ * ### Empty values
4728
+ * CopyButton renders nothing when `value` is empty, so consumers never present a copy control
4729
+ * that has no meaningful result.
4730
+ *
4731
+ * @example Icon button next to plain text
4233
4732
  * ```tsx
4234
- * import { SkeletonLabel } from "@trackunit/react-components";
4733
+ * import { CopyButton, Text } from "@trackunit/react-components";
4235
4734
  *
4236
- * const TextSkeleton = () => (
4237
- * <div className="flex flex-col gap-1">
4238
- * <SkeletonLabel textSize="text-lg" width={200} />
4239
- * <SkeletonLabel textSize="text-sm" width="80%" />
4735
+ * const ShortCode = ({ code }: { code: string }) => (
4736
+ * <div className="flex items-center gap-0.5">
4737
+ * <Text type="span">{code}</Text>
4738
+ * <CopyButton value={code} />
4240
4739
  * </div>
4241
4740
  * );
4242
4741
  * ```
4243
- * @param {SkeletonLabelProps} props - The props for the SkeletonLabel component
4244
- * @returns {ReactElement} SkeletonLabel component
4742
+ * @param {CopyButtonProps} props - The props for the CopyButton component
4743
+ * @returns {ReactElement} CopyButton component
4245
4744
  */
4246
- const SkeletonLabel = react.memo((props) => {
4247
- const { width = "100%", textSize = "text-base", flexibleWidth = true, className, "data-testid": dataTestId, children, style, ref, } = props;
4248
- const widthValue = typeof width === "number" ? `${width}px` : width;
4249
- const heightValue = getHeightValue(textSize);
4250
- return (jsxRuntime.jsx("div", { "aria-label": "Loading", className: cvaSkeleton({ textSize, className }), "data-testid": dataTestId, ref: ref, role: "status", style: mergeDomStyles({
4251
- width: flexibleWidth ? "100%" : widthValue,
4252
- maxWidth: flexibleWidth ? widthValue : undefined,
4253
- height: heightValue,
4254
- }, style), children: children }));
4255
- });
4256
- SkeletonLabel.displayName = "SkeletonLabel";
4745
+ const CopyButton = ({ value, copyLabel, copiedLabel, size = "small", label, variant = "ghost-neutral", className, style, "data-testid": dataTestId, onCopy, ref, }) => {
4746
+ const [t] = useTranslation();
4747
+ const [showCopied, setShowCopied] = react.useState(false);
4748
+ const [, copyToClipboard] = useCopyToClipboard();
4749
+ const resetTimerRef = react.useRef(undefined);
4750
+ react.useEffect(() => () => clearTimeout(resetTimerRef.current), []);
4751
+ // Localized defaults, so call sites don't each re-declare "Copy"/"Copied!"
4752
+ // in their own translation files; the props stay as context-specific overrides.
4753
+ const resolvedCopyLabel = copyLabel ?? t("copyButton.copy");
4754
+ const resolvedCopiedLabel = copiedLabel ?? t("copyButton.copied");
4755
+ if (!value) {
4756
+ return null;
4757
+ }
4758
+ const handleCopy = (e) => {
4759
+ e.stopPropagation();
4760
+ void copyToClipboard(value)
4761
+ // Rejections (both clipboard mechanisms failed) are intentionally
4762
+ // swallowed; the icon simply doesn't flip. A throwing onCopy is a
4763
+ // consumer bug and surfaces as an unhandled rejection instead.
4764
+ .then(() => {
4765
+ // Flip the icon to a checkmark for a moment, then revert. The timer
4766
+ // owns the copied state so the confirmation survives the pointer
4767
+ // leaving the button, and runs even if onCopy below throws.
4768
+ setShowCopied(true);
4769
+ clearTimeout(resetTimerRef.current);
4770
+ resetTimerRef.current = setTimeout(() => setShowCopied(false), COPIED_FEEDBACK_DURATION_MS);
4771
+ onCopy?.();
4772
+ }, () => undefined);
4773
+ };
4774
+ const buttonSize = toButtonSize(size);
4775
+ const icon = (jsxRuntime.jsx(Icon, { ariaHidden: true, name: showCopied ? "Check" : "Square2Stack", size: buttonSize === "large" ? "medium" : "small" }));
4776
+ if (label) {
4777
+ // Labelled mode: a regular (non-square) button whose prefix icon carries
4778
+ // the copied confirmation. `extraSmall` is icon-only, so it collapses to small.
4779
+ // The visible label stays the accessible name (WCAG 2.5.3 Label in Name);
4780
+ // since the copied feedback is otherwise visual-only, it is announced
4781
+ // through the polite live region rendered next to the button.
4782
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Tooltip, { delayed: !showCopied, label: showCopied ? resolvedCopiedLabel : resolvedCopyLabel, placement: "top", children: jsxRuntime.jsx(Button, { className: className, "data-testid": dataTestId, onClick: handleCopy, prefix: icon, ref: ref, size: buttonSize, style: style, variant: variant, children: label }) }), jsxRuntime.jsx("span", { className: "sr-only", role: "status", children: showCopied ? resolvedCopiedLabel : undefined })] }));
4783
+ }
4784
+ return (jsxRuntime.jsx(Tooltip, { delayed: !showCopied, label: showCopied ? resolvedCopiedLabel : resolvedCopyLabel, placement: "top", children: jsxRuntime.jsx(Button, { ariaLabel: showCopied ? resolvedCopiedLabel : resolvedCopyLabel, className: cvaIconButton({ size, className }), "data-testid": dataTestId, onClick: handleCopy, prefix: icon, ref: ref, size: buttonSize, square: true, style: style, variant: variant }) }));
4785
+ };
4786
+ CopyButton.displayName = "CopyButton";
4787
+ const COPIED_FEEDBACK_DURATION_MS = 2000;
4257
4788
 
4258
4789
  const cvaContainerStyles = cssClassVarianceUtilities.cva({
4259
4790
  base: [
@@ -5851,8 +6382,11 @@ const HorizontalOverflowScroller = ({ className, "data-testid": dataTestId, chil
5851
6382
  const cvaIndicator = cssClassVarianceUtilities.cva({ base: ["inline-flex", "items-center"] });
5852
6383
  const cvaIndicatorIcon = cssClassVarianceUtilities.cva({ base: ["mr-2"] });
5853
6384
  const cvaIndicatorLabel = cssClassVarianceUtilities.cva({
5854
- base: ["capitalize"],
5855
6385
  variants: {
6386
+ content: {
6387
+ text: "capitalize",
6388
+ custom: null,
6389
+ },
5856
6390
  size: {
5857
6391
  small: "text-xs",
5858
6392
  medium: "text-sm",
@@ -5867,6 +6401,7 @@ const cvaIndicatorLabel = cssClassVarianceUtilities.cva({
5867
6401
  },
5868
6402
  },
5869
6403
  defaultVariants: {
6404
+ content: "text",
5870
6405
  size: "medium",
5871
6406
  weight: "medium",
5872
6407
  background: "visible",
@@ -6016,71 +6551,14 @@ const cvaIndicatorIconBackground = cssClassVarianceUtilities.cva({
6016
6551
  * @returns {ReactElement} Indicator component
6017
6552
  */
6018
6553
  const Indicator = ({ "data-testid": dataTestId, icon, label, color = "unknown", withBackground = true, withLabel = true, ping = false, size = "medium", weight = "normal", className, ref, ...rest }) => {
6019
- return (jsxRuntime.jsx(Tooltip, { disabled: withLabel, label: label, placement: "bottom", children: jsxRuntime.jsxs("div", { "aria-label": label, className: cvaIndicator({ className }), "data-testid": dataTestId, ref: ref, ...rest, children: [jsxRuntime.jsxs("div", { className: cvaIndicatorIconBackground({ color, background: withBackground ? "visible" : "hidden" }), "data-testid": dataTestId ? `${dataTestId}-background` : "indicator-background", children: [ping ? (jsxRuntime.jsx("div", { className: cvaIndicatorPing({ color }), "data-testid": dataTestId ? `${dataTestId}-ping` : "indicator-ping" })) : null, icon] }), label && withLabel ? (jsxRuntime.jsx("div", { className: cvaIndicatorLabel({ size, weight, background: withBackground ? "visible" : "hidden" }), "data-testid": dataTestId ? `${dataTestId}-label` : undefined, children: label })) : null] }) }));
6554
+ return (jsxRuntime.jsx(Tooltip, { disabled: withLabel, label: label, placement: "bottom", children: jsxRuntime.jsxs("div", { "aria-label": typeof label === "string" ? label : undefined, className: cvaIndicator({ className }), "data-testid": dataTestId, ref: ref, ...rest, children: [jsxRuntime.jsxs("div", { className: cvaIndicatorIconBackground({ color, background: withBackground ? "visible" : "hidden" }), "data-testid": dataTestId ? `${dataTestId}-background` : "indicator-background", children: [ping ? (jsxRuntime.jsx("div", { className: cvaIndicatorPing({ color }), "data-testid": dataTestId ? `${dataTestId}-ping` : "indicator-ping" })) : null, icon] }), withLabel && typeof label !== "boolean" && label !== null && label !== undefined && label !== "" ? (jsxRuntime.jsx("div", { className: cvaIndicatorLabel({
6555
+ content: typeof label === "string" ? "text" : "custom",
6556
+ size,
6557
+ weight,
6558
+ background: withBackground ? "visible" : "hidden",
6559
+ }), "data-testid": dataTestId ? `${dataTestId}-label` : undefined, children: label })) : null] }) }));
6020
6560
  };
6021
6561
 
6022
- /**
6023
- * Applies standardized interaction-related styles to an element.
6024
- *
6025
- * Prefer using the "auto" settings — only resort to manual control if necessary!
6026
- * - selected: "auto" will apply styling when it contains a `checked` input aka. `:has(:checked)`.
6027
- * - focused: "auto" will apply styling when the element or it's children has focus aka. `:focus-within`.
6028
- *
6029
- * To be used for elements that do not have typical interactive roles and behaviors,
6030
- * such as divs, table rows, cards etc.
6031
- *
6032
- * To not be used for buttons, links, or other elements that have their own intrinsic interactive roles.
6033
- *
6034
- * @returns tailwind class names on the basis on the provided props.
6035
- */
6036
- const cvaInteractableItem = cssClassVarianceUtilities.cva({
6037
- base: ["transition-colors", "duration-100", "ease-in-out"],
6038
- variants: {
6039
- cursor: {
6040
- pointer: "cursor-pointer",
6041
- auto: "cursor-auto",
6042
- help: "cursor-help",
6043
- "zoom-in": "cursor-zoom-in",
6044
- "zoom-out": "cursor-zoom-out",
6045
- default: "cursor-default",
6046
- "not-allowed": "cursor-not-allowed",
6047
- },
6048
- selection: {
6049
- selected: ["bg-primary-50", "hover:bg-primary-100", "focus-within:bg-primary-50"],
6050
- unselected: "",
6051
- auto: [
6052
- "hover:bg-neutral-100",
6053
- "has-[:checked]:bg-primary-50",
6054
- "hover:has-[:checked]:bg-primary-50",
6055
- "focus-within:has-[:checked]:bg-primary-50",
6056
- ],
6057
- },
6058
- disabled: {
6059
- // Remember to also disable the element or set tabIndex=-1 to prevent actual interaction/focus!
6060
- true: ["pointer-events-none", "!bg-neutral-100", "!cursor-not-allowed"],
6061
- false: "", // use compoundVariants instead
6062
- },
6063
- focus: {
6064
- focused: ["bg-neutral-600/5"],
6065
- unfocused: "",
6066
- auto: "[&:has(:focus-visible)]:outline-native",
6067
- },
6068
- },
6069
- compoundVariants: [
6070
- {
6071
- disabled: false,
6072
- selection: "unselected",
6073
- className: ["hover:bg-neutral-600/5", "focus-within:bg-neutral-600/5"],
6074
- },
6075
- ],
6076
- defaultVariants: {
6077
- cursor: "auto",
6078
- selection: "auto",
6079
- disabled: false,
6080
- focus: "auto",
6081
- },
6082
- });
6083
-
6084
6562
  const cvaKPI = cssClassVarianceUtilities.cva({
6085
6563
  base: ["flex", "flex-col"],
6086
6564
  variants: {
@@ -6458,43 +6936,6 @@ const KPICard = ({ isActive = false, onClick, className, "data-testid": dataTest
6458
6936
  }), "data-testid": dataTestId ? dataTestId : undefined, onClick: onClick, ref: ref, children: jsxRuntime.jsx(Tooltip, { "data-testid": dataTestId ? `${dataTestId}-tooltip` : undefined, disabled: tooltipLabel === undefined || tooltipLabel === "", label: tooltipLabel, placement: "bottom", children: jsxRuntime.jsxs(CardBody, { className: cvaKPICardBody(), gap: "none", padding: "none", children: [jsxRuntime.jsxs("div", { className: cvaKPICardHeader(), children: [jsxRuntime.jsx(KPI, { ...rest, className: "p-0", "data-testid": dataTestId ? `${dataTestId}-kpi` : undefined, unit: unit }), iconName ? (jsxRuntime.jsx("div", { className: cvaKPIIconContainer({ iconColor }), children: jsxRuntime.jsx(Icon, { name: iconName, size: "small", type: "solid" }) })) : null] }), trends !== undefined && trends.length > 0 ? (jsxRuntime.jsx(TrendIndicators, { "data-testid": dataTestId ? `${dataTestId}-trend-indicators` : undefined, trends: trends })) : null, valueBar !== undefined ? (jsxRuntime.jsx(ValueBar, { className: "h-2", "data-testid": dataTestId ? `${dataTestId}-value-bar` : undefined, ...valueBar })) : null, notice !== undefined ? (jsxRuntime.jsxs("div", { className: "flex items-center gap-1 truncate", "data-testid": dataTestId ? `${dataTestId}-notice` : undefined, children: [notice.iconName ? (jsxRuntime.jsx(Icon, { color: notice.iconColor, "data-testid": dataTestId ? `${dataTestId}-notice-icon` : undefined, name: notice.iconName, size: "small" })) : null, jsxRuntime.jsx(Text, { className: "truncate text-neutral-900", "data-testid": dataTestId ? `${dataTestId}-notice-label` : undefined, size: "small", children: notice.label })] })) : null, children] }) }) }));
6459
6937
  };
6460
6938
 
6461
- /**
6462
- * SkeletonBlock renders a single animated placeholder block for shape-based elements (images, icons, buttons, avatars)
6463
- * before data is loaded. It uses exact height and width values to match the element it replaces.
6464
- *
6465
- * ### When to use
6466
- * Use SkeletonBlock for loading placeholders of shape-based UI elements: images, icons, badges, buttons, avatars, thumbnails.
6467
- *
6468
- * ### When not to use
6469
- * For text content, use `SkeletonLabel` which accounts for text line-height margins.
6470
- * For multiple text lines, use `SkeletonLines`.
6471
- *
6472
- * @example Skeleton block for an avatar and button
6473
- * ```tsx
6474
- * import { SkeletonBlock } from "@trackunit/react-components";
6475
- *
6476
- * const AvatarSkeleton = () => (
6477
- * <div className="flex items-center gap-3">
6478
- * <SkeletonBlock height={40} width={40} className="rounded-full" />
6479
- * <SkeletonBlock height={32} width={120} className="rounded-md" />
6480
- * </div>
6481
- * );
6482
- * ```
6483
- * @param {SkeletonBlockProps} props - The props for the SkeletonBlock component
6484
- * @returns {ReactElement} SkeletonBlock component
6485
- */
6486
- const SkeletonBlock = react.memo((props) => {
6487
- const { width = "100%", height = 16, flexibleWidth = false, className, "data-testid": dataTestId, children, style, ref, } = props;
6488
- const widthValue = typeof width === "number" ? `${width}px` : width;
6489
- const heightValue = typeof height === "number" ? `${height}px` : height;
6490
- return (jsxRuntime.jsx("div", { "aria-label": "Loading", className: cvaSkeleton({ className }), "data-testid": dataTestId, ref: ref, role: "status", style: mergeDomStyles({
6491
- width: flexibleWidth ? "100%" : widthValue,
6492
- maxWidth: flexibleWidth ? widthValue : undefined,
6493
- height: heightValue,
6494
- }, style), children: children }));
6495
- });
6496
- SkeletonBlock.displayName = "SkeletonBlock";
6497
-
6498
6939
  /**
6499
6940
  * KPICardSkeleton is a loading placeholder that mimics the layout of a KPICard.
6500
6941
  * It renders skeleton lines for the KPI title/value, and optional icon, trends, value bar, and notice slots.
@@ -6528,7 +6969,7 @@ const cvaLabeledValue = cssClassVarianceUtilities.cva({
6528
6969
  base: ["flex", "text-sm"],
6529
6970
  variants: {
6530
6971
  orientation: {
6531
- horizontal: ["flex-row", "items-start", "gap-6"],
6972
+ horizontal: ["flex-row", "items-start", "gap-2"],
6532
6973
  vertical: ["flex-col"],
6533
6974
  },
6534
6975
  },
@@ -6540,7 +6981,7 @@ const cvaLabeledValueLabel = cssClassVarianceUtilities.cva({
6540
6981
  base: ["font-medium", "text-neutral-500"],
6541
6982
  variants: {
6542
6983
  orientation: {
6543
- horizontal: ["min-w-[40%]", "max-w-[40%]"],
6984
+ horizontal: ["min-w-[30%]", "max-w-[30%]"],
6544
6985
  vertical: ["mb-1"],
6545
6986
  },
6546
6987
  },
@@ -15059,6 +15500,8 @@ exports.Highlight = Highlight;
15059
15500
  exports.HorizontalOverflowScroller = HorizontalOverflowScroller;
15060
15501
  exports.Icon = Icon;
15061
15502
  exports.IconButton = IconButton;
15503
+ exports.IdentityCard = IdentityCard;
15504
+ exports.IdentityCardSkeleton = IdentityCardSkeleton;
15062
15505
  exports.Indicator = Indicator;
15063
15506
  exports.KPI = KPI;
15064
15507
  exports.KPICard = KPICard;