@stasho/ds 0.6.1 → 0.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stasho/ds",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "stasho design system",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -7,25 +7,28 @@ import {
7
7
  useRef,
8
8
  useState,
9
9
  type HTMLAttributes,
10
+ type ReactNode,
10
11
  } from "react";
11
12
  import { cva, type VariantProps } from "class-variance-authority";
12
13
  import { ArrowUpRight, Copy } from "@phosphor-icons/react";
13
14
  import { cn } from "@ac/lib/cn";
14
15
 
15
- const copyableTextVariants = cva(
16
- "inline-flex items-center font-mono select-none",
17
- {
18
- variants: {
19
- size: {
20
- sm: "text-xs gap-1",
21
- md: "text-sm gap-1.5",
22
- },
16
+ const copyableTextVariants = cva("items-center font-mono select-none", {
17
+ variants: {
18
+ size: {
19
+ sm: "text-xs gap-1",
20
+ md: "text-sm gap-1.5",
23
21
  },
24
- defaultVariants: {
25
- size: "md",
22
+ fluid: {
23
+ true: "flex w-full",
24
+ false: "inline-flex",
26
25
  },
27
26
  },
28
- );
27
+ defaultVariants: {
28
+ size: "md",
29
+ fluid: false,
30
+ },
31
+ });
29
32
 
30
33
  const iconSize: Record<"sm" | "md", string> = {
31
34
  sm: "size-3.5",
@@ -50,6 +53,26 @@ function truncateMiddle(
50
53
  return `${text.slice(0, startChars)}...${text.slice(-endChars)}`;
51
54
  }
52
55
 
56
+ function renderTextContent(
57
+ text: string,
58
+ fluid: boolean,
59
+ startChars: number,
60
+ endChars: number,
61
+ ): ReactNode {
62
+ if (!fluid) return truncateMiddle(text, startChars, endChars);
63
+ if (text.length <= endChars) return text;
64
+ return (
65
+ <>
66
+ <span className="min-w-0 flex-initial overflow-hidden text-ellipsis whitespace-nowrap">
67
+ {text.slice(0, -endChars)}
68
+ </span>
69
+ <span className="flex-none whitespace-nowrap">
70
+ {text.slice(-endChars)}
71
+ </span>
72
+ </>
73
+ );
74
+ }
75
+
53
76
  type CopyableTextProps = Omit<HTMLAttributes<HTMLSpanElement>, "children"> &
54
77
  VariantProps<typeof copyableTextVariants> & {
55
78
  text: string;
@@ -66,6 +89,7 @@ const CopyableText = forwardRef<HTMLSpanElement, CopyableTextProps>(
66
89
  endChars = 4,
67
90
  href,
68
91
  size = "md",
92
+ fluid = false,
69
93
  className,
70
94
  ...rest
71
95
  },
@@ -80,24 +104,30 @@ const CopyableText = forwardRef<HTMLSpanElement, CopyableTextProps>(
80
104
  };
81
105
  }, []);
82
106
 
83
- const handleCopy = useCallback((e: React.MouseEvent) => {
84
- e.stopPropagation();
85
- void navigator.clipboard.writeText(text).then(() => {
86
- setCopied(true);
87
- if (timerRef.current) clearTimeout(timerRef.current);
88
- timerRef.current = setTimeout(() => setCopied(false), 1500);
89
- });
90
- }, [text]);
107
+ const handleCopy = useCallback(
108
+ (e: React.MouseEvent) => {
109
+ e.stopPropagation();
110
+ void navigator.clipboard.writeText(text).then(() => {
111
+ setCopied(true);
112
+ if (timerRef.current) clearTimeout(timerRef.current);
113
+ timerRef.current = setTimeout(() => setCopied(false), 1500);
114
+ });
115
+ },
116
+ [text],
117
+ );
91
118
 
92
119
  const resolvedSize = size ?? "md";
93
120
  const iconCn = iconSize[resolvedSize];
94
121
  const btnCn = buttonSize[resolvedSize];
122
+ const isFluid = fluid ?? false;
123
+ const content = renderTextContent(text, isFluid, startChars, endChars);
124
+ const titleAttr = isFluid ? text : undefined;
95
125
 
96
126
  return (
97
127
  <span
98
128
  ref={ref}
99
129
  className={cn(
100
- copyableTextVariants({ size }),
130
+ copyableTextVariants({ size, fluid }),
101
131
  href && "text-accent-500 dark:text-accent",
102
132
  className,
103
133
  )}
@@ -106,62 +136,69 @@ const CopyableText = forwardRef<HTMLSpanElement, CopyableTextProps>(
106
136
  {href ? (
107
137
  <a
108
138
  href={href}
139
+ title={titleAttr}
109
140
  {...(isExternalUrl(href)
110
141
  ? { target: "_blank", rel: "noopener noreferrer" }
111
142
  : {})}
112
143
  onClick={(e) => e.stopPropagation()}
113
- className="cursor-pointer hover:underline"
144
+ className={cn(
145
+ "cursor-pointer hover:underline",
146
+ isFluid && "flex min-w-0 flex-1 overflow-hidden",
147
+ )}
114
148
  >
115
- {truncateMiddle(text, startChars, endChars)}
149
+ {content}
116
150
  </a>
117
151
  ) : (
118
- <span className="cursor-default">
119
- {truncateMiddle(text, startChars, endChars)}
152
+ <span
153
+ title={titleAttr}
154
+ className={cn("cursor-default", isFluid && "flex min-w-0 flex-1")}
155
+ >
156
+ {content}
120
157
  </span>
121
158
  )}
122
159
 
123
160
  <button
124
- type="button"
125
- onClick={handleCopy}
161
+ type="button"
162
+ onClick={handleCopy}
163
+ className={cn(
164
+ "relative inline-flex items-center justify-center",
165
+ "rounded-none cursor-pointer shrink-0",
166
+ "hover:bg-foreground/10 transition-colors",
167
+ btnCn,
168
+ )}
169
+ aria-label={copied ? "Copied" : "Copy to clipboard"}
170
+ >
171
+ <Copy
172
+ weight="bold"
126
173
  className={cn(
127
- "relative inline-flex items-center justify-center",
128
- "rounded-none cursor-pointer",
129
- "hover:bg-foreground/10 transition-colors",
130
- btnCn,
174
+ iconCn,
175
+ "text-muted-foreground",
176
+ "transition-opacity duration-100",
177
+ "motion-reduce:transition-none",
178
+ copied && "opacity-0",
179
+ )}
180
+ aria-hidden="true"
181
+ />
182
+ <svg
183
+ viewBox="0 0 24 24"
184
+ fill="none"
185
+ stroke="currentColor"
186
+ strokeWidth={3}
187
+ strokeLinecap="round"
188
+ strokeLinejoin="round"
189
+ className={cn(
190
+ iconCn,
191
+ "text-muted-foreground absolute",
192
+ "[stroke-dasharray:20] [stroke-dashoffset:20]",
193
+ "transition-[stroke-dashoffset] duration-300 delay-75 ease-out",
194
+ "motion-reduce:transition-none",
195
+ copied && "[stroke-dashoffset:0]",
131
196
  )}
132
- aria-label={copied ? "Copied" : "Copy to clipboard"}
197
+ aria-hidden="true"
133
198
  >
134
- <Copy
135
- weight="bold"
136
- className={cn(
137
- iconCn,
138
- "text-muted-foreground",
139
- "transition-opacity duration-100",
140
- "motion-reduce:transition-none",
141
- copied && "opacity-0",
142
- )}
143
- aria-hidden="true"
144
- />
145
- <svg
146
- viewBox="0 0 24 24"
147
- fill="none"
148
- stroke="currentColor"
149
- strokeWidth={3}
150
- strokeLinecap="round"
151
- strokeLinejoin="round"
152
- className={cn(
153
- iconCn,
154
- "text-muted-foreground absolute",
155
- "[stroke-dasharray:20] [stroke-dashoffset:20]",
156
- "transition-[stroke-dashoffset] duration-300 delay-75 ease-out",
157
- "motion-reduce:transition-none",
158
- copied && "[stroke-dashoffset:0]",
159
- )}
160
- aria-hidden="true"
161
- >
162
- <polyline points="4 12 9 17 20 6" />
163
- </svg>
164
- </button>
199
+ <polyline points="4 12 9 17 20 6" />
200
+ </svg>
201
+ </button>
165
202
 
166
203
  {href && isExternalUrl(href) ? (
167
204
  <a
@@ -170,18 +207,14 @@ const CopyableText = forwardRef<HTMLSpanElement, CopyableTextProps>(
170
207
  rel="noopener noreferrer"
171
208
  onClick={(e) => e.stopPropagation()}
172
209
  className={cn(
173
- "inline-flex items-center justify-center rounded-none",
210
+ "inline-flex items-center justify-center rounded-none shrink-0",
174
211
  "text-muted-foreground hover:text-foreground",
175
212
  "hover:bg-foreground/10 transition-colors",
176
213
  btnCn,
177
214
  )}
178
215
  aria-label="Open in new tab"
179
216
  >
180
- <ArrowUpRight
181
- weight="bold"
182
- className={iconCn}
183
- aria-hidden="true"
184
- />
217
+ <ArrowUpRight weight="bold" className={iconCn} aria-hidden="true" />
185
218
  </a>
186
219
  ) : null}
187
220
  </span>
@@ -8,6 +8,12 @@ type SortDirection = "asc" | "desc";
8
8
 
9
9
  export type Column<T> = {
10
10
  header: string;
11
+ /**
12
+ * Stable React key for the column. Defaults to `header`. Set this when two
13
+ * columns share a header (e.g. multiple header-less icon/action columns,
14
+ * where `header` is `""`) so cell keys don't collide.
15
+ */
16
+ id?: string;
11
17
  accessor: (row: T) => ReactNode;
12
18
  sortable?: boolean;
13
19
  sortValue?: (row: T) => string | number;
@@ -163,7 +169,7 @@ export function Table<T>({
163
169
  <tr className="bg-muted/50">
164
170
  {columns.map((col, i) => (
165
171
  <th
166
- key={col.header}
172
+ key={col.id ?? col.header}
167
173
  className={cn(
168
174
  "px-4 py-3 font-mono font-normal uppercase tracking-widest text-[11px] text-muted-foreground",
169
175
  alignClass[col.align ?? "left"],
@@ -241,7 +247,7 @@ export function Table<T>({
241
247
  >
242
248
  {columns.map((col) => (
243
249
  <td
244
- key={col.header}
250
+ key={col.id ?? col.header}
245
251
  className={cn(
246
252
  "px-4 py-3 text-sm",
247
253
  alignClass[col.align ?? "left"],