@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 +1016 -573
- package/index.esm.js +1015 -574
- package/migrations/entry.js.map +1 -0
- package/migrations/utils/jsx-utils.js.map +1 -0
- package/migrations/v2-0-0/breadcrumb-remove-deprecated-props.js.map +1 -0
- package/migrations/v2-0-0/cardheader-onclickclose-to-actions.js.map +1 -0
- package/migrations/v2-0-0/iconbutton-add-required-name.js.map +1 -0
- package/migrations/v2-0-0/kpi-tooltiplabel-to-wrapper.js.map +1 -0
- package/migrations/v2-0-0/notice-tooltiplabel-to-wrapper.js.map +1 -0
- package/migrations/v2-0-0/pagination-add-required-titles.js.map +1 -0
- package/migrations/v2-0-0/tag-add-removetaglabel.js.map +1 -0
- package/migrations/v2-0-0/togglegroup-remove-item-style.js.map +1 -0
- package/migrations/v3-0-0/copyabletext-to-text-copybutton.js.map +1 -0
- package/migrations/v3-0-0/menulist-rename-to-menucontent.js.map +1 -0
- package/package.json +6 -6
- package/src/components/AssetIdentity/IdentityCard.d.ts +173 -0
- package/src/components/AssetIdentity/IdentityCard.variants.d.ts +120 -0
- package/src/components/AssetIdentity/IdentityCardSkeleton.d.ts +95 -0
- package/src/components/AssetIdentity/useIdentityImageSize.d.ts +32 -0
- package/src/components/Indicator/Indicator.d.ts +4 -4
- package/src/components/Indicator/Indicator.variants.d.ts +15 -2
- package/src/index.d.ts +2 -0
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
|
|
3314
|
-
return (jsxRuntime.jsx("div", { className:
|
|
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
|
-
*
|
|
4223
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
4230
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
4733
|
+
* import { CopyButton, Text } from "@trackunit/react-components";
|
|
4235
4734
|
*
|
|
4236
|
-
* const
|
|
4237
|
-
* <div className="flex
|
|
4238
|
-
* <
|
|
4239
|
-
* <
|
|
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 {
|
|
4244
|
-
* @returns {ReactElement}
|
|
4742
|
+
* @param {CopyButtonProps} props - The props for the CopyButton component
|
|
4743
|
+
* @returns {ReactElement} CopyButton component
|
|
4245
4744
|
*/
|
|
4246
|
-
const
|
|
4247
|
-
const
|
|
4248
|
-
const
|
|
4249
|
-
const
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
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] }),
|
|
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-
|
|
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-[
|
|
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;
|