@stonedogcode/style 0.19.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.19.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.",
@@ -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;
package/src/index.ts CHANGED
@@ -210,6 +210,16 @@ export {
210
210
  } from "./components/StyledFieldHelp";
211
211
  export type { StyledFieldHelpProps } from "./components/StyledFieldHelp";
212
212
 
213
+ /**
214
+ * A file dropzone with image previews. Ships no artwork: both glyph slots
215
+ * default to nothing and the host passes its own icon set if it wants one.
216
+ */
217
+ export {
218
+ default as StyledImageUpload,
219
+ StyledImageUpload as ImageUpload,
220
+ } from "./components/StyledImageUpload";
221
+ export type { StyledImageUploadProps } from "./components/StyledImageUpload";
222
+
213
223
  // ---------------------------------------------------------------------------
214
224
  // Components that were blocked on a runtime dependency until NEH-430 gave each
215
225
  // a seam with a working default. None of them adds a dependency; the host
@@ -316,6 +326,18 @@ export { DL_VARIANTS } from "./components/StyledDefinitionList";
316
326
  export { default as StyledSparkLine } from "./components/StyledSparkLine";
317
327
  export type { StyledSparkLineProps } from "./components/StyledSparkLine";
318
328
 
329
+ /**
330
+ * A data table that renders real `<table>` markup rather than a div grid — the
331
+ * table role is what carries row and column position to a screen reader.
332
+ */
333
+ export { default as StyledTable, StyledTable as Table } from "./components/StyledTable";
334
+ export type {
335
+ StyledTableProps,
336
+ StyledTableHeaderProps,
337
+ StyledTableBodyProps,
338
+ ColumnDefinition,
339
+ } from "./components/StyledTable";
340
+
319
341
  // ---------------------------------------------------------------------------
320
342
  // Notifications
321
343
  // ---------------------------------------------------------------------------