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