@stonedogcode/style 0.17.0 → 0.20.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/package.json +5 -5
- package/src/components/StyledCollapsible.tsx +14 -27
- package/src/components/StyledImageUpload.tsx +335 -0
- package/src/components/StyledTable.tsx +222 -0
- package/src/components/StyledToaster.tsx +325 -0
- package/src/components/toaster-store.ts +334 -0
- package/src/components/useDisclosure.ts +139 -0
- package/src/index.ts +46 -0
- package/src/preset/index.ts +5 -2
- package/src/preset/recipes/toast.ts +161 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stonedogcode/style",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "A Panda CSS design system: a themeable Panda preset plus the React components built on it.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "StoneDogCode L.L.C.",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/stonedog-code/stonedog-style.git"
|
|
10
10
|
},
|
|
11
|
-
"//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer
|
|
11
|
+
"//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer — which reads as a missing package rather than as a private one. It was not needed while the name was unscoped (those default to public), so it is new as of the scope migration and it is the single thing most likely to be forgotten in one.",
|
|
12
12
|
"publishConfig": {
|
|
13
13
|
"access": "public"
|
|
14
14
|
},
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"./preset": "./src/preset/index.ts",
|
|
21
21
|
"./package.json": "./package.json"
|
|
22
22
|
},
|
|
23
|
-
"//files": "The package ships SOURCE, so `src` has to go out whole
|
|
23
|
+
"//files": "The package ships SOURCE, so `src` has to go out whole — but only the part a consumer imports. Tests, Playwright component tests and their harnesses were 57 of 130 entries and never resolvable from any export, so every one of them was a file a consumer's Panda run had to be told to skip. `src/__tests__/published-package.test.ts` asserts this listing rather than this array: npm's own resolution of these negations is the only thing that decides what ships.",
|
|
24
24
|
"files": [
|
|
25
25
|
"src",
|
|
26
26
|
"!src/**/__tests__/**",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"pretype-check": "npm run panda:prepare",
|
|
39
39
|
"type-check": "tsc --noEmit",
|
|
40
40
|
"lint": "eslint . --ext ts,tsx",
|
|
41
|
-
"//pretest": "panda:build, not panda:prepare
|
|
41
|
+
"//pretest": "panda:build, not panda:prepare — the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
|
|
42
42
|
"pretest": "npm run panda:build",
|
|
43
43
|
"test": "jest",
|
|
44
44
|
"pretest:ct": "npm run panda:build",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"test:ct:ui": "playwright test --config playwright-ct.config.ts --ui",
|
|
47
47
|
"test:coverage": "jest --coverage",
|
|
48
48
|
"gate": "npm run type-check && npm run lint && npm run test",
|
|
49
|
-
"//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively
|
|
49
|
+
"//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively — npm prompts for the 2FA one-time password and the login flow needs a browser. It refuses a checkout that is detached, dirty, or behind origin/main: a submodule sits detached at the consumer's gitlink by default, and publishing from one commit behind ships a tarball missing the very thing you are publishing for while looking like a success (it did, on 2026-08-04, without TitleLogo.tsx). Runs the gate, prints the tarball listing, then proves the result by installing from the registry into a temp directory.",
|
|
50
50
|
"publish:stonedog-style": "bash scripts/publish-package.sh",
|
|
51
51
|
"version:bump:patch": "npm version patch --no-git-tag-version",
|
|
52
52
|
"version:bump:minor": "npm version minor --no-git-tag-version",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React
|
|
3
|
+
import React from "react";
|
|
4
4
|
import { styled } from "styled-system/jsx";
|
|
5
|
+
import { useDisclosure } from "./useDisclosure";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* A disclosure: a trigger that shows and hides a region.
|
|
@@ -82,41 +83,27 @@ const StyledCollapsible: React.FC<StyledCollapsibleProps> = ({
|
|
|
82
83
|
triggerTestId,
|
|
83
84
|
contentTestId,
|
|
84
85
|
}) => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const toggle = () => {
|
|
97
|
-
const next = !isOpen;
|
|
98
|
-
// The internal state moves even when controlled. If the host ignores the
|
|
99
|
-
// callback the component would otherwise appear dead to the pointer, and a
|
|
100
|
-
// control that does nothing when pressed is indistinguishable from a broken
|
|
101
|
-
// one — for this audience, the reader concludes the app is broken, not that
|
|
102
|
-
// they misread the affordance.
|
|
103
|
-
if (!isControlled) setUncontrolled(next);
|
|
104
|
-
onOpenChange?.(next);
|
|
105
|
-
};
|
|
86
|
+
// The mechanics live in `useDisclosure`, not here. This component is one
|
|
87
|
+
// ARRANGEMENT of a disclosure — a button with the trigger inside it, above
|
|
88
|
+
// the region — and a host whose control is already a button composes the
|
|
89
|
+
// same hook into its own layout instead of nesting one button in another
|
|
90
|
+
// (NEH-1100). Two arrangements, one implementation.
|
|
91
|
+
const { triggerProps, contentProps } = useDisclosure({
|
|
92
|
+
open,
|
|
93
|
+
defaultOpen,
|
|
94
|
+
onOpenChange,
|
|
95
|
+
});
|
|
106
96
|
|
|
107
97
|
return (
|
|
108
98
|
<>
|
|
109
99
|
<CollapsibleTrigger
|
|
110
|
-
|
|
111
|
-
onClick={toggle}
|
|
112
|
-
aria-expanded={isOpen}
|
|
113
|
-
aria-controls={contentId}
|
|
100
|
+
{...triggerProps}
|
|
114
101
|
aria-label={ariaLabel}
|
|
115
102
|
data-testid={triggerTestId}
|
|
116
103
|
>
|
|
117
104
|
{trigger}
|
|
118
105
|
</CollapsibleTrigger>
|
|
119
|
-
<div
|
|
106
|
+
<div {...contentProps} data-testid={contentTestId}>
|
|
120
107
|
{children}
|
|
121
108
|
</div>
|
|
122
109
|
</>
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { styled, type HTMLStyledProps } from "styled-system/jsx";
|
|
5
|
+
import { log } from "../config/logger";
|
|
6
|
+
import StyledButton from "./StyledButton";
|
|
7
|
+
import StyledText from "./StyledText";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A file dropzone with image previews.
|
|
11
|
+
*
|
|
12
|
+
* Extracted from HopperGuard, where it replaced Chakra's `FileUpload` compound
|
|
13
|
+
* component. Chakra supplied Root/HiddenInput/Dropzone/Trigger/ItemGroup/Item/
|
|
14
|
+
* ItemPreviewImage/ItemDeleteTrigger plus `useFileUploadContext`; all of it is
|
|
15
|
+
* reconstructed here on a local context, because the pieces share state (the
|
|
16
|
+
* accepted files) and cannot be swapped one at a time.
|
|
17
|
+
*
|
|
18
|
+
* ## What must not be lost
|
|
19
|
+
*
|
|
20
|
+
* These are pinned by the originating app's e2e spec, and each one is easy to
|
|
21
|
+
* break while the component still looks right:
|
|
22
|
+
*
|
|
23
|
+
* - a real `<input type="file">` stays ATTACHED to the DOM
|
|
24
|
+
* - the dropzone is reachable and operable BY KEYBOARD — so the trigger is a
|
|
25
|
+
* real `<button>`, never a div with a click handler
|
|
26
|
+
* - the dropzone announces itself IN WORDS, not by icon alone
|
|
27
|
+
* - selecting a file does not tear the widget down
|
|
28
|
+
*
|
|
29
|
+
* ## Icons
|
|
30
|
+
*
|
|
31
|
+
* This package ships no artwork by policy (see `StyledAlert`), so both glyph
|
|
32
|
+
* slots default to nothing and the words carry the meaning on their own. Pass
|
|
33
|
+
* your own nodes to restore an icon set — HopperGuard's facade passes
|
|
34
|
+
* `react-icons`' `LuFileImage` and `LuX`, which is what it rendered before the
|
|
35
|
+
* extraction.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
interface FileUploadContextValue {
|
|
39
|
+
acceptedFiles: File[];
|
|
40
|
+
openPicker: () => void;
|
|
41
|
+
removeFile: (file: File) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FileUploadContext = React.createContext<FileUploadContextValue | null>(
|
|
45
|
+
null,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
function useFileUploadContext(): FileUploadContextValue {
|
|
49
|
+
const ctx = React.useContext(FileUploadContext);
|
|
50
|
+
if (!ctx) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
"useFileUploadContext must be used inside StyledImageUpload",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return ctx;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PandaVStack = styled("div", {
|
|
59
|
+
base: { display: "flex", flexDirection: "column", alignItems: "center" },
|
|
60
|
+
});
|
|
61
|
+
const PandaItemGroup = styled("div", {
|
|
62
|
+
base: {
|
|
63
|
+
display: "flex",
|
|
64
|
+
flexWrap: "wrap",
|
|
65
|
+
gap: "2",
|
|
66
|
+
justifyContent: "center",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const PandaItem = styled("div", { base: { position: "relative" } });
|
|
70
|
+
|
|
71
|
+
/** Chakra's `Float` — the delete control at the item's top-end corner. */
|
|
72
|
+
const PandaFloat = styled("div", {
|
|
73
|
+
base: {
|
|
74
|
+
position: "absolute",
|
|
75
|
+
top: "0",
|
|
76
|
+
right: "0",
|
|
77
|
+
transform: "translate(40%, -40%)",
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `boxSize="4"` and `layerStyle="fill.solid"` were Chakra props with no Panda
|
|
83
|
+
* equivalent — they do not exist on a `styled("button")` and the type-check
|
|
84
|
+
* rejects both. Their effect is reproduced as real styles.
|
|
85
|
+
*
|
|
86
|
+
* The colours are tokens, never literals, so the control follows the host's
|
|
87
|
+
* theme and colour mode.
|
|
88
|
+
*/
|
|
89
|
+
const PandaDeleteTrigger = styled("button", {
|
|
90
|
+
base: {
|
|
91
|
+
display: "inline-flex",
|
|
92
|
+
alignItems: "center",
|
|
93
|
+
justifyContent: "center",
|
|
94
|
+
width: "4",
|
|
95
|
+
height: "4",
|
|
96
|
+
borderRadius: "full",
|
|
97
|
+
cursor: "pointer",
|
|
98
|
+
lineHeight: "1",
|
|
99
|
+
bg: "boxBgPrimary",
|
|
100
|
+
color: "textPrimary",
|
|
101
|
+
borderWidth: "1px",
|
|
102
|
+
borderStyle: "solid",
|
|
103
|
+
borderColor: "borderBgPrimary",
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const PandaDropzone = styled("div", {
|
|
108
|
+
base: {
|
|
109
|
+
display: "flex",
|
|
110
|
+
alignItems: "center",
|
|
111
|
+
justifyContent: "center",
|
|
112
|
+
borderWidth: "1px",
|
|
113
|
+
borderStyle: "dashed",
|
|
114
|
+
borderColor: "borderBgPrimary",
|
|
115
|
+
borderRadius: "md",
|
|
116
|
+
padding: "4",
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const PandaDropzoneContent = styled("div", {
|
|
121
|
+
base: {
|
|
122
|
+
display: "flex",
|
|
123
|
+
flexDirection: "column",
|
|
124
|
+
alignItems: "center",
|
|
125
|
+
justifyContent: "center",
|
|
126
|
+
gap: "2",
|
|
127
|
+
textAlign: "center",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The preview image.
|
|
133
|
+
*
|
|
134
|
+
* A plain `<img>` on purpose: the source is an object URL for a file the user
|
|
135
|
+
* just picked, so there is nothing for a framework image component to optimise
|
|
136
|
+
* — it has no intrinsic dimensions to read, no remote host to whitelist, and no
|
|
137
|
+
* cacheable URL. (In the originating Next.js app this needed a lint
|
|
138
|
+
* suppression; this package has no such rule, so it needs none.)
|
|
139
|
+
*/
|
|
140
|
+
function PreviewImage({ file }: { file: File }) {
|
|
141
|
+
const [src, setSrc] = React.useState<string>();
|
|
142
|
+
/**
|
|
143
|
+
* Creating the object URL in an effect keyed on the File — rather than inline
|
|
144
|
+
* during render — is what stops a blob leaking on every re-render, and the
|
|
145
|
+
* cleanup is what stops one leaking when the preview is removed.
|
|
146
|
+
*/
|
|
147
|
+
React.useEffect(() => {
|
|
148
|
+
const url = URL.createObjectURL(file);
|
|
149
|
+
setSrc(url);
|
|
150
|
+
return () => URL.revokeObjectURL(url);
|
|
151
|
+
}, [file]);
|
|
152
|
+
if (!src) return null;
|
|
153
|
+
return (
|
|
154
|
+
<img
|
|
155
|
+
src={src}
|
|
156
|
+
alt={file.name}
|
|
157
|
+
// Inline rather than a Panda rule: a size a consumer's Panda `include`
|
|
158
|
+
// glob might never extract is a size that silently does not apply.
|
|
159
|
+
style={{
|
|
160
|
+
borderRadius: "8px",
|
|
161
|
+
objectFit: "cover",
|
|
162
|
+
width: "96px",
|
|
163
|
+
height: "96px",
|
|
164
|
+
display: "block",
|
|
165
|
+
margin: "0 auto",
|
|
166
|
+
}}
|
|
167
|
+
/>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface StyledImageUploadProps
|
|
172
|
+
extends Omit<HTMLStyledProps<"div">, "onChange"> {
|
|
173
|
+
/** Controlled-ish initial selection. Currently informational only. */
|
|
174
|
+
value?: File[];
|
|
175
|
+
onChange?: (files: File[]) => void;
|
|
176
|
+
/** `1` keeps the input single-select; above 1 sets `multiple`. */
|
|
177
|
+
maxFiles?: number;
|
|
178
|
+
accept?: string;
|
|
179
|
+
buttonText?: string;
|
|
180
|
+
dropzoneText?: string;
|
|
181
|
+
/** Decorative glyph inside the upload button. `aria-hidden`. */
|
|
182
|
+
fileIcon?: React.ReactNode;
|
|
183
|
+
/** Decorative glyph inside each remove button. `aria-hidden`. */
|
|
184
|
+
removeIcon?: React.ReactNode;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const FileUploadPreviewOnly = ({
|
|
188
|
+
removeIcon,
|
|
189
|
+
}: {
|
|
190
|
+
removeIcon?: React.ReactNode;
|
|
191
|
+
}) => {
|
|
192
|
+
const { acceptedFiles, removeFile } = useFileUploadContext();
|
|
193
|
+
if (acceptedFiles.length === 0) return null;
|
|
194
|
+
return (
|
|
195
|
+
<PandaItemGroup>
|
|
196
|
+
{acceptedFiles.map((file) => (
|
|
197
|
+
<PandaItem p="2" key={file.name}>
|
|
198
|
+
<PreviewImage file={file} />
|
|
199
|
+
<PandaFloat>
|
|
200
|
+
{/*
|
|
201
|
+
The accessible name is on the BUTTON and names the file, so the
|
|
202
|
+
glyph is decorative and hidden. A screen-reader user with three
|
|
203
|
+
previews needs to know which one this removes.
|
|
204
|
+
*/}
|
|
205
|
+
<PandaDeleteTrigger
|
|
206
|
+
type="button"
|
|
207
|
+
aria-label={`Remove ${file.name}`}
|
|
208
|
+
onClick={() => removeFile(file)}
|
|
209
|
+
>
|
|
210
|
+
{removeIcon ? (
|
|
211
|
+
<span aria-hidden="true">{removeIcon}</span>
|
|
212
|
+
) : null}
|
|
213
|
+
</PandaDeleteTrigger>
|
|
214
|
+
</PandaFloat>
|
|
215
|
+
</PandaItem>
|
|
216
|
+
))}
|
|
217
|
+
</PandaItemGroup>
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const FileUploadDropzoneOnly = ({
|
|
222
|
+
buttonText,
|
|
223
|
+
dropzoneText,
|
|
224
|
+
fileIcon,
|
|
225
|
+
}: {
|
|
226
|
+
buttonText: string;
|
|
227
|
+
dropzoneText: string;
|
|
228
|
+
fileIcon?: React.ReactNode;
|
|
229
|
+
}) => {
|
|
230
|
+
const { openPicker } = useFileUploadContext();
|
|
231
|
+
return (
|
|
232
|
+
<PandaDropzone
|
|
233
|
+
width="100%"
|
|
234
|
+
cursor="pointer"
|
|
235
|
+
onClick={openPicker}
|
|
236
|
+
onDragOver={(e) => e.preventDefault()}
|
|
237
|
+
onDrop={(e) => e.preventDefault()}
|
|
238
|
+
>
|
|
239
|
+
<PandaDropzoneContent cursor="pointer">
|
|
240
|
+
<StyledText cursor="pointer">{dropzoneText}</StyledText>
|
|
241
|
+
{/*
|
|
242
|
+
A real <button>, not the dropzone div with a handler: the control must
|
|
243
|
+
be reachable and operable by KEYBOARD, and a div is neither focusable
|
|
244
|
+
nor Enter/Space-activated. `stopPropagation` keeps the click from also
|
|
245
|
+
reaching the dropzone and opening the picker twice.
|
|
246
|
+
*/}
|
|
247
|
+
<StyledButton
|
|
248
|
+
type="button"
|
|
249
|
+
onClick={(e: React.MouseEvent) => {
|
|
250
|
+
e.stopPropagation();
|
|
251
|
+
openPicker();
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{buttonText}
|
|
255
|
+
{fileIcon ? <span aria-hidden="true">{fileIcon}</span> : null}
|
|
256
|
+
</StyledButton>
|
|
257
|
+
</PandaDropzoneContent>
|
|
258
|
+
</PandaDropzone>
|
|
259
|
+
);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export function StyledImageUpload({
|
|
263
|
+
onChange,
|
|
264
|
+
maxFiles = 1,
|
|
265
|
+
accept = "image/*",
|
|
266
|
+
buttonText = "Upload Image",
|
|
267
|
+
dropzoneText = "Drag and drop an image or",
|
|
268
|
+
fileIcon,
|
|
269
|
+
removeIcon,
|
|
270
|
+
...vStackProps
|
|
271
|
+
}: StyledImageUploadProps) {
|
|
272
|
+
log.trace("StyledImageUpload rendered");
|
|
273
|
+
const [acceptedFiles, setAcceptedFiles] = React.useState<File[]>([]);
|
|
274
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
275
|
+
|
|
276
|
+
const commit = React.useCallback(
|
|
277
|
+
(files: File[]) => {
|
|
278
|
+
const limited = maxFiles > 0 ? files.slice(0, maxFiles) : files;
|
|
279
|
+
setAcceptedFiles(limited);
|
|
280
|
+
onChange?.(limited);
|
|
281
|
+
},
|
|
282
|
+
[maxFiles, onChange],
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const ctx = React.useMemo<FileUploadContextValue>(
|
|
286
|
+
() => ({
|
|
287
|
+
acceptedFiles,
|
|
288
|
+
openPicker: () => inputRef.current?.click(),
|
|
289
|
+
removeFile: (file) =>
|
|
290
|
+
commit(acceptedFiles.filter((f) => f.name !== file.name)),
|
|
291
|
+
}),
|
|
292
|
+
[acceptedFiles, commit],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<FileUploadContext.Provider value={ctx}>
|
|
297
|
+
{/*
|
|
298
|
+
Chakra's HiddenInput. `display: none` would DETACH it from the
|
|
299
|
+
accessibility tree; it is visually hidden the standard way instead so it
|
|
300
|
+
stays reachable and stays a real form control.
|
|
301
|
+
*/}
|
|
302
|
+
<input
|
|
303
|
+
ref={inputRef}
|
|
304
|
+
type="file"
|
|
305
|
+
accept={accept}
|
|
306
|
+
multiple={maxFiles > 1}
|
|
307
|
+
onChange={(e) => commit(Array.from(e.target.files ?? []))}
|
|
308
|
+
style={{
|
|
309
|
+
position: "absolute",
|
|
310
|
+
width: "1px",
|
|
311
|
+
height: "1px",
|
|
312
|
+
padding: 0,
|
|
313
|
+
margin: "-1px",
|
|
314
|
+
overflow: "hidden",
|
|
315
|
+
clip: "rect(0 0 0 0)",
|
|
316
|
+
whiteSpace: "nowrap",
|
|
317
|
+
border: 0,
|
|
318
|
+
}}
|
|
319
|
+
/>
|
|
320
|
+
<PandaVStack gap={2} {...vStackProps}>
|
|
321
|
+
{acceptedFiles.length === 0 ? (
|
|
322
|
+
<FileUploadDropzoneOnly
|
|
323
|
+
buttonText={buttonText}
|
|
324
|
+
dropzoneText={dropzoneText}
|
|
325
|
+
fileIcon={fileIcon}
|
|
326
|
+
/>
|
|
327
|
+
) : (
|
|
328
|
+
<FileUploadPreviewOnly removeIcon={removeIcon} />
|
|
329
|
+
)}
|
|
330
|
+
</PandaVStack>
|
|
331
|
+
</FileUploadContext.Provider>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export default StyledImageUpload;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { styled } from "styled-system/jsx";
|
|
5
|
+
import { log } from "../config/logger";
|
|
6
|
+
import StyledBox from "./StyledBox";
|
|
7
|
+
import StyledScrollbar from "./StyledScrollbar";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A data table that renders real table elements.
|
|
11
|
+
*
|
|
12
|
+
* Extracted from HopperGuard, where it began as Chakra's `Table.*` compound
|
|
13
|
+
* component. Chakra rendered real `<table>` markup and the app's e2e spec
|
|
14
|
+
* asserts `toHaveRole("table")`, so every part here is pinned to the semantic
|
|
15
|
+
* element Chakra produced rather than to a styled `<div>`. A div grid is
|
|
16
|
+
* pixel-identical and an accessibility regression: screen readers announce row
|
|
17
|
+
* and column position from the table role, and lose it entirely on divs.
|
|
18
|
+
*
|
|
19
|
+
* The compound shape is deliberate. `Header` takes a column list because that
|
|
20
|
+
* is the shape every consumer already had; the rest are thin passthroughs so a
|
|
21
|
+
* caller can drop to raw rows and cells whenever the list shape does not fit.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A column for `StyledTable.Header`. Extra props reach the `<th>`. */
|
|
25
|
+
export interface ColumnDefinition
|
|
26
|
+
extends React.ComponentProps<typeof PandaTableColumnHeader> {
|
|
27
|
+
key: string;
|
|
28
|
+
label: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PandaTableRoot = styled("table", {
|
|
32
|
+
base: {
|
|
33
|
+
borderCollapse: "collapse",
|
|
34
|
+
textAlign: "start",
|
|
35
|
+
verticalAlign: "top",
|
|
36
|
+
// Digits share a column width, so numeric cells line up down the table.
|
|
37
|
+
fontVariantNumeric: "lining-nums tabular-nums",
|
|
38
|
+
// MEASURED off the originating Chakra build with getComputedStyle, not
|
|
39
|
+
// derived from tokens: font 14px / line-height 20px, cells 12px on every
|
|
40
|
+
// side, a 1px rule under each cell. Reasoning from the token scale instead
|
|
41
|
+
// (fontSize md, py 2) put the table 51px too tall — the font, not the
|
|
42
|
+
// padding, drove the difference.
|
|
43
|
+
fontSize: "14px",
|
|
44
|
+
lineHeight: "20px",
|
|
45
|
+
},
|
|
46
|
+
variants: {
|
|
47
|
+
/**
|
|
48
|
+
* `size` was a Chakra recipe prop. Without a variant declared here it would
|
|
49
|
+
* fall through to the DOM as an invalid `size` attribute on `<table>`.
|
|
50
|
+
*
|
|
51
|
+
* Only `md` is measured against the original — it is what the visual
|
|
52
|
+
* baseline captured. `sm` and `lg` are proportional and UNVERIFIED; treat
|
|
53
|
+
* them as a reasonable scale rather than as a reproduction of anything.
|
|
54
|
+
*/
|
|
55
|
+
size: {
|
|
56
|
+
sm: { "& th, & td": { padding: "8px" } },
|
|
57
|
+
md: { "& th, & td": { padding: "12px" } },
|
|
58
|
+
lg: { "& th, & td": { padding: "16px" } },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
defaultVariants: { size: "md" },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const PandaTableHeader = styled("thead");
|
|
65
|
+
const PandaTableBody = styled("tbody");
|
|
66
|
+
const PandaTableFooter = styled("tfoot");
|
|
67
|
+
const PandaTableRow = styled("tr");
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The 1px rule under each cell.
|
|
71
|
+
*
|
|
72
|
+
* Chakra's own CSS drew this. `border="sm"` on the cell emits NOTHING — an
|
|
73
|
+
* empty computed `border` shorthand on a real `<td>` — so it is restored
|
|
74
|
+
* explicitly.
|
|
75
|
+
*
|
|
76
|
+
* `borderBgPrimary` is the token, never a palette literal: a hardcoded
|
|
77
|
+
* `neutral.200` is invisible to the host's theme and does not follow the colour
|
|
78
|
+
* mode, which is the whole reason this package refuses literal colours.
|
|
79
|
+
*
|
|
80
|
+
* ## Longhands, and why the obvious shorthand is wrong
|
|
81
|
+
*
|
|
82
|
+
* The originating app wrote `borderBottom: "1px solid"` alongside
|
|
83
|
+
* `borderColor: <token>`. That looks equivalent and is not: `border-bottom` is
|
|
84
|
+
* a SHORTHAND, so it also sets `border-bottom-color`, and omitting the colour
|
|
85
|
+
* resets it to its initial value — `currentColor`. Whichever declaration Panda
|
|
86
|
+
* emits second wins, so the cell rule painted the text colour (black on a light
|
|
87
|
+
* theme) instead of the token, on every table.
|
|
88
|
+
*
|
|
89
|
+
* That is a false-pass waiting to happen, because black IS a real colour: a
|
|
90
|
+
* test asserting only "the border resolved to something" goes green on it.
|
|
91
|
+
* `StyledTable.ct.tsx` re-points the custom property and asserts the border
|
|
92
|
+
* follows, which is the assertion that actually distinguishes the two.
|
|
93
|
+
*/
|
|
94
|
+
const CELL_RULE = {
|
|
95
|
+
borderBottomWidth: "1px",
|
|
96
|
+
borderBottomStyle: "solid",
|
|
97
|
+
borderBottomColor: "borderBgPrimary",
|
|
98
|
+
} as const;
|
|
99
|
+
|
|
100
|
+
const PandaTableColumnHeader = styled("th", {
|
|
101
|
+
base: { fontWeight: "medium", textAlign: "start", ...CELL_RULE },
|
|
102
|
+
});
|
|
103
|
+
const PandaTableCell = styled("td", { base: { ...CELL_RULE } });
|
|
104
|
+
const PandaTableCaption = styled("caption");
|
|
105
|
+
|
|
106
|
+
export type StyledTableProps = React.ComponentProps<typeof PandaTableRoot>;
|
|
107
|
+
|
|
108
|
+
const StyledTable: React.FC<StyledTableProps> = ({ children, ...props }) => {
|
|
109
|
+
log.trace("StyledTable rendered");
|
|
110
|
+
return (
|
|
111
|
+
// `overflow: hidden` on the outer box clips the scroll container's corners
|
|
112
|
+
// to the box radius; the scrollbar inside is what actually scrolls.
|
|
113
|
+
<StyledBox
|
|
114
|
+
overflow="hidden"
|
|
115
|
+
data-testid="styled-table-container"
|
|
116
|
+
border={0}
|
|
117
|
+
py={0}
|
|
118
|
+
px={0}
|
|
119
|
+
>
|
|
120
|
+
<StyledScrollbar p={0} data-testid="styled-table-scrollbar" border={0}>
|
|
121
|
+
<PandaTableRoot {...props} data-testid="styled-table-root">
|
|
122
|
+
{children}
|
|
123
|
+
</PandaTableRoot>
|
|
124
|
+
</StyledScrollbar>
|
|
125
|
+
</StyledBox>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export interface StyledTableHeaderProps {
|
|
130
|
+
columns: ColumnDefinition[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const StyledHeader: React.FC<StyledTableHeaderProps> = ({ columns }) => (
|
|
134
|
+
<PandaTableHeader>
|
|
135
|
+
<PandaTableRow>
|
|
136
|
+
{columns.map(({ key, label, ...rest }) => (
|
|
137
|
+
<PandaTableColumnHeader key={key} {...rest}>
|
|
138
|
+
{label}
|
|
139
|
+
</PandaTableColumnHeader>
|
|
140
|
+
))}
|
|
141
|
+
</PandaTableRow>
|
|
142
|
+
</PandaTableHeader>
|
|
143
|
+
);
|
|
144
|
+
StyledHeader.displayName = "StyledTable.Header";
|
|
145
|
+
|
|
146
|
+
export interface StyledTableBodyProps {
|
|
147
|
+
children: React.ReactNode;
|
|
148
|
+
header?: React.ReactNode;
|
|
149
|
+
footer?: React.ReactNode;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `header` and `footer` render as siblings, not children — `<thead>` and
|
|
154
|
+
* `<tfoot>` are invalid inside `<tbody>`, and nesting them there is silently
|
|
155
|
+
* reparented by the browser rather than reported.
|
|
156
|
+
*/
|
|
157
|
+
const StyledBody: React.FC<StyledTableBodyProps> = ({
|
|
158
|
+
children,
|
|
159
|
+
header,
|
|
160
|
+
footer,
|
|
161
|
+
}) => (
|
|
162
|
+
<>
|
|
163
|
+
{header}
|
|
164
|
+
<PandaTableBody>{children}</PandaTableBody>
|
|
165
|
+
{footer}
|
|
166
|
+
</>
|
|
167
|
+
);
|
|
168
|
+
StyledBody.displayName = "StyledTable.Body";
|
|
169
|
+
|
|
170
|
+
type StyledTableFooterProps = React.ComponentProps<typeof PandaTableFooter>;
|
|
171
|
+
const StyledFooter: React.FC<StyledTableFooterProps> = (props) => (
|
|
172
|
+
<PandaTableFooter {...props} />
|
|
173
|
+
);
|
|
174
|
+
StyledFooter.displayName = "StyledTable.Footer";
|
|
175
|
+
|
|
176
|
+
type StyledTableRowProps = React.ComponentProps<typeof PandaTableRow>;
|
|
177
|
+
const StyledRow: React.FC<StyledTableRowProps> = (props) => (
|
|
178
|
+
<PandaTableRow {...props} />
|
|
179
|
+
);
|
|
180
|
+
StyledRow.displayName = "StyledTable.Row";
|
|
181
|
+
|
|
182
|
+
type StyledColumnHeaderProps = React.ComponentProps<
|
|
183
|
+
typeof PandaTableColumnHeader
|
|
184
|
+
>;
|
|
185
|
+
const StyledColumnHeader: React.FC<StyledColumnHeaderProps> = (props) => (
|
|
186
|
+
<PandaTableColumnHeader {...props} />
|
|
187
|
+
);
|
|
188
|
+
StyledColumnHeader.displayName = "StyledTable.ColumnHeader";
|
|
189
|
+
|
|
190
|
+
type StyledTableCellProps = React.ComponentProps<typeof PandaTableCell>;
|
|
191
|
+
const StyledCell: React.FC<StyledTableCellProps> = (props) => (
|
|
192
|
+
<PandaTableCell {...props} />
|
|
193
|
+
);
|
|
194
|
+
StyledCell.displayName = "StyledTable.Cell";
|
|
195
|
+
|
|
196
|
+
type StyledTableCaptionProps = React.ComponentProps<typeof PandaTableCaption>;
|
|
197
|
+
const StyledCaption: React.FC<StyledTableCaptionProps> = (props) => (
|
|
198
|
+
<PandaTableCaption {...props} />
|
|
199
|
+
);
|
|
200
|
+
StyledCaption.displayName = "StyledTable.Caption";
|
|
201
|
+
|
|
202
|
+
interface StyledTableComponent extends React.FC<StyledTableProps> {
|
|
203
|
+
Header: typeof StyledHeader;
|
|
204
|
+
Body: typeof StyledBody;
|
|
205
|
+
Footer: typeof StyledFooter;
|
|
206
|
+
Row: typeof StyledRow;
|
|
207
|
+
ColumnHeader: typeof StyledColumnHeader;
|
|
208
|
+
Cell: typeof StyledCell;
|
|
209
|
+
Caption: typeof StyledCaption;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const StyledTableExport = StyledTable as StyledTableComponent;
|
|
213
|
+
StyledTableExport.Header = StyledHeader;
|
|
214
|
+
StyledTableExport.Body = StyledBody;
|
|
215
|
+
StyledTableExport.Footer = StyledFooter;
|
|
216
|
+
StyledTableExport.Row = StyledRow;
|
|
217
|
+
StyledTableExport.ColumnHeader = StyledColumnHeader;
|
|
218
|
+
StyledTableExport.Cell = StyledCell;
|
|
219
|
+
StyledTableExport.Caption = StyledCaption;
|
|
220
|
+
|
|
221
|
+
export { StyledTableExport as StyledTable };
|
|
222
|
+
export default StyledTableExport;
|