@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonedogcode/style",
3
- "version": "0.17.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 \u2014 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.",
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 \u2014 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.",
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 \u2014 the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
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 \u2014 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.",
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, { useId, useState } from "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
- const [uncontrolled, setUncontrolled] = useState(defaultOpen);
86
-
87
- // Controlled the moment `open` is supplied, and uncontrolled otherwise
88
- // decided per render rather than latched at mount, because a host that
89
- // switches between the two mid-life has a bug we should not paper over by
90
- // silently ignoring the prop.
91
- const isControlled = open !== undefined;
92
- const isOpen = isControlled ? open : uncontrolled;
93
-
94
- const contentId = useId();
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
- type="button"
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 id={contentId} hidden={!isOpen} data-testid={contentTestId}>
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;