@stonedogcode/style 0.19.0 → 0.20.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 +1 -1
- package/src/components/StyledImageUpload.tsx +387 -0
- package/src/components/StyledTable.tsx +222 -0
- package/src/components/StyledTooltip.tsx +168 -16
- package/src/index.ts +22 -0
package/package.json
CHANGED
|
@@ -0,0 +1,387 @@
|
|
|
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
|
+
/**
|
|
72
|
+
* Chakra's `Float` — the delete control at the item's top-end corner.
|
|
73
|
+
*
|
|
74
|
+
* The `translate(40%, -40%)` this used to carry is gone (NEH-1117). It centred
|
|
75
|
+
* the control ON the corner, so roughly half of it hung outside the preview it
|
|
76
|
+
* belonged to — measured in Chromium at every viewport, the control's top edge
|
|
77
|
+
* sat at **y = -7.2**, above the top of the page. That is tolerable while the
|
|
78
|
+
* control is 16px and merely untidy; at the 48px hit area below it would put
|
|
79
|
+
* two neighbouring previews' targets into the same few pixels, which is the
|
|
80
|
+
* sharper half of what the issue reports.
|
|
81
|
+
*
|
|
82
|
+
* So the hit area is anchored inside the tile instead, and the visible chip
|
|
83
|
+
* sits in its top-end corner — which lands the chip in very nearly the place
|
|
84
|
+
* it occupied before, without anything overhanging.
|
|
85
|
+
*/
|
|
86
|
+
const PandaFloat = styled("div", {
|
|
87
|
+
base: {
|
|
88
|
+
position: "absolute",
|
|
89
|
+
top: "0",
|
|
90
|
+
right: "0",
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The hit area — and only the hit area.
|
|
96
|
+
*
|
|
97
|
+
* `boxSize="4"` and `layerStyle="fill.solid"` were Chakra props with no Panda
|
|
98
|
+
* equivalent; their effect is reproduced as real styles. What was NOT
|
|
99
|
+
* reproduced was a tap target: `boxSize="4"` is **16x16 CSS px**, a third of
|
|
100
|
+
* this package's stated 48x48 floor, and it came across the extraction
|
|
101
|
+
* unchanged (NEH-1116) because changing it changes HopperGuard's rendering.
|
|
102
|
+
* This is that change.
|
|
103
|
+
*
|
|
104
|
+
* The split is `StyledTag`'s, which solves the same tension: the BUTTON is the
|
|
105
|
+
* target and carries no appearance at all, and the chip inside it is what a
|
|
106
|
+
* reader sees. Sizing the visible circle to 48px instead would put a control
|
|
107
|
+
* half the width of the 96px preview on top of it.
|
|
108
|
+
*
|
|
109
|
+
* `StyledTag` needs negative block margin to stop the target growing its own
|
|
110
|
+
* tag; here the control is absolutely positioned, so it is already out of
|
|
111
|
+
* flow and cannot drag the preview's layout with it whatever size it is. The
|
|
112
|
+
* component test asserts the preview stays 96x96 rather than trusting that.
|
|
113
|
+
*/
|
|
114
|
+
const PandaDeleteTrigger = styled("button", {
|
|
115
|
+
base: {
|
|
116
|
+
display: "inline-flex",
|
|
117
|
+
// Top-end, not centred: the chip keeps the corner position it has always
|
|
118
|
+
// had, and the extra target grows inwards over the preview — which is
|
|
119
|
+
// decorative, and the only direction with room.
|
|
120
|
+
alignItems: "flex-start",
|
|
121
|
+
justifyContent: "flex-end",
|
|
122
|
+
// The house floor, stated rather than left to emerge from whatever glyph
|
|
123
|
+
// a consumer passes — see CLAUDE.md. 48 rather than WCAG 2.5.5 AAA's 44,
|
|
124
|
+
// because the standard is calibrated for the general population and this
|
|
125
|
+
// library's largest consumer serves an often-elderly, sometimes
|
|
126
|
+
// motor-impaired audience.
|
|
127
|
+
minWidth: "48px",
|
|
128
|
+
minHeight: "48px",
|
|
129
|
+
padding: "0",
|
|
130
|
+
background: "transparent",
|
|
131
|
+
border: "none",
|
|
132
|
+
cursor: "pointer",
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What a reader actually sees: the small round chip that used to BE the
|
|
138
|
+
* button. Same size, same tokens, same appearance — it is now a passenger
|
|
139
|
+
* inside a target big enough to hit.
|
|
140
|
+
*
|
|
141
|
+
* The colours are tokens, never literals, so the control follows the host's
|
|
142
|
+
* theme and colour mode.
|
|
143
|
+
*/
|
|
144
|
+
const PandaDeleteChip = styled("span", {
|
|
145
|
+
base: {
|
|
146
|
+
display: "inline-flex",
|
|
147
|
+
alignItems: "center",
|
|
148
|
+
justifyContent: "center",
|
|
149
|
+
width: "4",
|
|
150
|
+
height: "4",
|
|
151
|
+
borderRadius: "full",
|
|
152
|
+
lineHeight: "1",
|
|
153
|
+
bg: "boxBgPrimary",
|
|
154
|
+
color: "textPrimary",
|
|
155
|
+
borderWidth: "1px",
|
|
156
|
+
borderStyle: "solid",
|
|
157
|
+
borderColor: "borderBgPrimary",
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const PandaDropzone = styled("div", {
|
|
162
|
+
base: {
|
|
163
|
+
display: "flex",
|
|
164
|
+
alignItems: "center",
|
|
165
|
+
justifyContent: "center",
|
|
166
|
+
borderWidth: "1px",
|
|
167
|
+
borderStyle: "dashed",
|
|
168
|
+
borderColor: "borderBgPrimary",
|
|
169
|
+
borderRadius: "md",
|
|
170
|
+
padding: "4",
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const PandaDropzoneContent = styled("div", {
|
|
175
|
+
base: {
|
|
176
|
+
display: "flex",
|
|
177
|
+
flexDirection: "column",
|
|
178
|
+
alignItems: "center",
|
|
179
|
+
justifyContent: "center",
|
|
180
|
+
gap: "2",
|
|
181
|
+
textAlign: "center",
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The preview image.
|
|
187
|
+
*
|
|
188
|
+
* A plain `<img>` on purpose: the source is an object URL for a file the user
|
|
189
|
+
* just picked, so there is nothing for a framework image component to optimise
|
|
190
|
+
* — it has no intrinsic dimensions to read, no remote host to whitelist, and no
|
|
191
|
+
* cacheable URL. (In the originating Next.js app this needed a lint
|
|
192
|
+
* suppression; this package has no such rule, so it needs none.)
|
|
193
|
+
*/
|
|
194
|
+
function PreviewImage({ file }: { file: File }) {
|
|
195
|
+
const [src, setSrc] = React.useState<string>();
|
|
196
|
+
/**
|
|
197
|
+
* Creating the object URL in an effect keyed on the File — rather than inline
|
|
198
|
+
* during render — is what stops a blob leaking on every re-render, and the
|
|
199
|
+
* cleanup is what stops one leaking when the preview is removed.
|
|
200
|
+
*/
|
|
201
|
+
React.useEffect(() => {
|
|
202
|
+
const url = URL.createObjectURL(file);
|
|
203
|
+
setSrc(url);
|
|
204
|
+
return () => URL.revokeObjectURL(url);
|
|
205
|
+
}, [file]);
|
|
206
|
+
if (!src) return null;
|
|
207
|
+
return (
|
|
208
|
+
<img
|
|
209
|
+
src={src}
|
|
210
|
+
alt={file.name}
|
|
211
|
+
// Inline rather than a Panda rule: a size a consumer's Panda `include`
|
|
212
|
+
// glob might never extract is a size that silently does not apply.
|
|
213
|
+
style={{
|
|
214
|
+
borderRadius: "8px",
|
|
215
|
+
objectFit: "cover",
|
|
216
|
+
width: "96px",
|
|
217
|
+
height: "96px",
|
|
218
|
+
display: "block",
|
|
219
|
+
margin: "0 auto",
|
|
220
|
+
}}
|
|
221
|
+
/>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface StyledImageUploadProps
|
|
226
|
+
extends Omit<HTMLStyledProps<"div">, "onChange"> {
|
|
227
|
+
/** Controlled-ish initial selection. Currently informational only. */
|
|
228
|
+
value?: File[];
|
|
229
|
+
onChange?: (files: File[]) => void;
|
|
230
|
+
/** `1` keeps the input single-select; above 1 sets `multiple`. */
|
|
231
|
+
maxFiles?: number;
|
|
232
|
+
accept?: string;
|
|
233
|
+
buttonText?: string;
|
|
234
|
+
dropzoneText?: string;
|
|
235
|
+
/** Decorative glyph inside the upload button. `aria-hidden`. */
|
|
236
|
+
fileIcon?: React.ReactNode;
|
|
237
|
+
/** Decorative glyph inside each remove button. `aria-hidden`. */
|
|
238
|
+
removeIcon?: React.ReactNode;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const FileUploadPreviewOnly = ({
|
|
242
|
+
removeIcon,
|
|
243
|
+
}: {
|
|
244
|
+
removeIcon?: React.ReactNode;
|
|
245
|
+
}) => {
|
|
246
|
+
const { acceptedFiles, removeFile } = useFileUploadContext();
|
|
247
|
+
if (acceptedFiles.length === 0) return null;
|
|
248
|
+
return (
|
|
249
|
+
<PandaItemGroup>
|
|
250
|
+
{acceptedFiles.map((file) => (
|
|
251
|
+
<PandaItem p="2" key={file.name}>
|
|
252
|
+
<PreviewImage file={file} />
|
|
253
|
+
<PandaFloat>
|
|
254
|
+
{/*
|
|
255
|
+
The accessible name is on the BUTTON and names the file, so the
|
|
256
|
+
glyph is decorative and hidden. A screen-reader user with three
|
|
257
|
+
previews needs to know which one this removes.
|
|
258
|
+
*/}
|
|
259
|
+
<PandaDeleteTrigger
|
|
260
|
+
type="button"
|
|
261
|
+
aria-label={`Remove ${file.name}`}
|
|
262
|
+
onClick={() => removeFile(file)}
|
|
263
|
+
>
|
|
264
|
+
<PandaDeleteChip aria-hidden="true">{removeIcon}</PandaDeleteChip>
|
|
265
|
+
</PandaDeleteTrigger>
|
|
266
|
+
</PandaFloat>
|
|
267
|
+
</PandaItem>
|
|
268
|
+
))}
|
|
269
|
+
</PandaItemGroup>
|
|
270
|
+
);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const FileUploadDropzoneOnly = ({
|
|
274
|
+
buttonText,
|
|
275
|
+
dropzoneText,
|
|
276
|
+
fileIcon,
|
|
277
|
+
}: {
|
|
278
|
+
buttonText: string;
|
|
279
|
+
dropzoneText: string;
|
|
280
|
+
fileIcon?: React.ReactNode;
|
|
281
|
+
}) => {
|
|
282
|
+
const { openPicker } = useFileUploadContext();
|
|
283
|
+
return (
|
|
284
|
+
<PandaDropzone
|
|
285
|
+
width="100%"
|
|
286
|
+
cursor="pointer"
|
|
287
|
+
onClick={openPicker}
|
|
288
|
+
onDragOver={(e) => e.preventDefault()}
|
|
289
|
+
onDrop={(e) => e.preventDefault()}
|
|
290
|
+
>
|
|
291
|
+
<PandaDropzoneContent cursor="pointer">
|
|
292
|
+
<StyledText cursor="pointer">{dropzoneText}</StyledText>
|
|
293
|
+
{/*
|
|
294
|
+
A real <button>, not the dropzone div with a handler: the control must
|
|
295
|
+
be reachable and operable by KEYBOARD, and a div is neither focusable
|
|
296
|
+
nor Enter/Space-activated. `stopPropagation` keeps the click from also
|
|
297
|
+
reaching the dropzone and opening the picker twice.
|
|
298
|
+
*/}
|
|
299
|
+
<StyledButton
|
|
300
|
+
type="button"
|
|
301
|
+
onClick={(e: React.MouseEvent) => {
|
|
302
|
+
e.stopPropagation();
|
|
303
|
+
openPicker();
|
|
304
|
+
}}
|
|
305
|
+
>
|
|
306
|
+
{buttonText}
|
|
307
|
+
{fileIcon ? <span aria-hidden="true">{fileIcon}</span> : null}
|
|
308
|
+
</StyledButton>
|
|
309
|
+
</PandaDropzoneContent>
|
|
310
|
+
</PandaDropzone>
|
|
311
|
+
);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export function StyledImageUpload({
|
|
315
|
+
onChange,
|
|
316
|
+
maxFiles = 1,
|
|
317
|
+
accept = "image/*",
|
|
318
|
+
buttonText = "Upload Image",
|
|
319
|
+
dropzoneText = "Drag and drop an image or",
|
|
320
|
+
fileIcon,
|
|
321
|
+
removeIcon,
|
|
322
|
+
...vStackProps
|
|
323
|
+
}: StyledImageUploadProps) {
|
|
324
|
+
log.trace("StyledImageUpload rendered");
|
|
325
|
+
const [acceptedFiles, setAcceptedFiles] = React.useState<File[]>([]);
|
|
326
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
327
|
+
|
|
328
|
+
const commit = React.useCallback(
|
|
329
|
+
(files: File[]) => {
|
|
330
|
+
const limited = maxFiles > 0 ? files.slice(0, maxFiles) : files;
|
|
331
|
+
setAcceptedFiles(limited);
|
|
332
|
+
onChange?.(limited);
|
|
333
|
+
},
|
|
334
|
+
[maxFiles, onChange],
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const ctx = React.useMemo<FileUploadContextValue>(
|
|
338
|
+
() => ({
|
|
339
|
+
acceptedFiles,
|
|
340
|
+
openPicker: () => inputRef.current?.click(),
|
|
341
|
+
removeFile: (file) =>
|
|
342
|
+
commit(acceptedFiles.filter((f) => f.name !== file.name)),
|
|
343
|
+
}),
|
|
344
|
+
[acceptedFiles, commit],
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<FileUploadContext.Provider value={ctx}>
|
|
349
|
+
{/*
|
|
350
|
+
Chakra's HiddenInput. `display: none` would DETACH it from the
|
|
351
|
+
accessibility tree; it is visually hidden the standard way instead so it
|
|
352
|
+
stays reachable and stays a real form control.
|
|
353
|
+
*/}
|
|
354
|
+
<input
|
|
355
|
+
ref={inputRef}
|
|
356
|
+
type="file"
|
|
357
|
+
accept={accept}
|
|
358
|
+
multiple={maxFiles > 1}
|
|
359
|
+
onChange={(e) => commit(Array.from(e.target.files ?? []))}
|
|
360
|
+
style={{
|
|
361
|
+
position: "absolute",
|
|
362
|
+
width: "1px",
|
|
363
|
+
height: "1px",
|
|
364
|
+
padding: 0,
|
|
365
|
+
margin: "-1px",
|
|
366
|
+
overflow: "hidden",
|
|
367
|
+
clip: "rect(0 0 0 0)",
|
|
368
|
+
whiteSpace: "nowrap",
|
|
369
|
+
border: 0,
|
|
370
|
+
}}
|
|
371
|
+
/>
|
|
372
|
+
<PandaVStack gap={2} {...vStackProps}>
|
|
373
|
+
{acceptedFiles.length === 0 ? (
|
|
374
|
+
<FileUploadDropzoneOnly
|
|
375
|
+
buttonText={buttonText}
|
|
376
|
+
dropzoneText={dropzoneText}
|
|
377
|
+
fileIcon={fileIcon}
|
|
378
|
+
/>
|
|
379
|
+
) : (
|
|
380
|
+
<FileUploadPreviewOnly removeIcon={removeIcon} />
|
|
381
|
+
)}
|
|
382
|
+
</PandaVStack>
|
|
383
|
+
</FileUploadContext.Provider>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
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;
|
|
@@ -226,6 +226,59 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
226
226
|
*/
|
|
227
227
|
const [focusableAncestor, setFocusableAncestor] = useState<HTMLElement | null>(null);
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Whether the layout effect below has yet decided WHERE the click-mode help
|
|
231
|
+
* control belongs (NEH-965).
|
|
232
|
+
*
|
|
233
|
+
* The control is a real `<button>`, and until the trigger has been measured
|
|
234
|
+
* the component cannot know whether it is standing inside one. Rendering it
|
|
235
|
+
* optimistically and moving it afterwards is not an option: React validates
|
|
236
|
+
* DOM nesting at *render* time, against the React tree, so a single pass
|
|
237
|
+
* with `<button>` inside `<button>` warns and — on a server-rendered host
|
|
238
|
+
* like hopper-web — produces a hydration error, whatever the DOM ends up
|
|
239
|
+
* looking like a moment later.
|
|
240
|
+
*
|
|
241
|
+
* So the first pass renders no control at all and the second one puts it
|
|
242
|
+
* where it belongs. Both effects here are layout effects, so that settles
|
|
243
|
+
* before paint and before hydration compares anything: there is no frame in
|
|
244
|
+
* which a reader could see the control missing, and no server/client
|
|
245
|
+
* mismatch, because the server also renders nothing.
|
|
246
|
+
*/
|
|
247
|
+
const [helpPlacementSettled, setHelpPlacementSettled] = useState(false);
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The element the click-mode help control is rendered into when the trigger
|
|
251
|
+
* sits inside something focusable (NEH-965).
|
|
252
|
+
*
|
|
253
|
+
* `HelpTrigger` renders beside the child, inside the trigger wrapper. When
|
|
254
|
+
* the tooltipped thing is an icon inside an icon button that lands the
|
|
255
|
+
* control *inside* that button:
|
|
256
|
+
*
|
|
257
|
+
* <button aria-label="Expand"> <- the consumer's control
|
|
258
|
+
* <div> <- TooltipTrigger
|
|
259
|
+
* <svg aria-hidden />
|
|
260
|
+
* <button aria-label="More information">?</button> <- invalid
|
|
261
|
+
*
|
|
262
|
+
* `<button>` cannot be a descendant of `<button>`, and this is not a
|
|
263
|
+
* preference anybody opted into: `isClick` is `trigger === "click" ||
|
|
264
|
+
* !canHover`, so it is the DEFAULT rendering on every phone and tablet.
|
|
265
|
+
*
|
|
266
|
+
* Simply not rendering the control would trade invalid HTML for an
|
|
267
|
+
* unreachable explanation — on a device that cannot hover there is no hover,
|
|
268
|
+
* and tapping the button activates it rather than explaining it. So the
|
|
269
|
+
* control moves *out* instead: a span inserted immediately after the
|
|
270
|
+
* focusable ancestor, portalled into. Valid HTML, still visible, still
|
|
271
|
+
* tappable, still in the tab sequence, and it scrolls with the page because
|
|
272
|
+
* it sits in normal flow rather than being positioned over anything.
|
|
273
|
+
*
|
|
274
|
+
* `inline-flex` on the host rather than `display: contents`: contents would
|
|
275
|
+
* let the control participate in the ancestor's parent layout directly, but
|
|
276
|
+
* it was removed from the accessibility tree by browsers this package's
|
|
277
|
+
* audience is still using, and an invisible help control is the bug we are
|
|
278
|
+
* fixing.
|
|
279
|
+
*/
|
|
280
|
+
const [helpHost, setHelpHost] = useState<HTMLElement | null>(null);
|
|
281
|
+
|
|
229
282
|
/**
|
|
230
283
|
* True when something else — a descendant or an ancestor — already puts this
|
|
231
284
|
* trigger's content in the tab sequence. When it does, the trigger must add
|
|
@@ -287,6 +340,10 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
287
340
|
? null
|
|
288
341
|
: node?.parentElement?.closest<HTMLElement>(FOCUSABLE_SELECTOR) ?? null;
|
|
289
342
|
setFocusableAncestor((prev) => (prev === ancestor ? prev : ancestor));
|
|
343
|
+
// Measured — the next render may place the help control (NEH-965). Set
|
|
344
|
+
// before the `!node` bail so a trigger that never mounted a node does not
|
|
345
|
+
// leave click mode permanently without its control.
|
|
346
|
+
setHelpPlacementSettled(true);
|
|
290
347
|
|
|
291
348
|
if (!node) return;
|
|
292
349
|
|
|
@@ -299,9 +356,21 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
299
356
|
.join(" ")
|
|
300
357
|
.replace(/\s+/g, " ")
|
|
301
358
|
.trim();
|
|
359
|
+
// With a focusable ancestor the help control is rendered OUTSIDE it
|
|
360
|
+
// (NEH-965), so the ancestor no longer supplies context by containment and
|
|
361
|
+
// the control has to carry the subject in its own name. "More information"
|
|
362
|
+
// sitting on its own next to a button called "Expand" names nothing —
|
|
363
|
+
// which is the twenty-identical-controls problem NEH-769 fixed for the
|
|
364
|
+
// inline case, reappearing one level up.
|
|
365
|
+
const ancestorText = ancestor
|
|
366
|
+
? (ancestor.getAttribute("aria-label") ?? ancestor.textContent ?? "")
|
|
367
|
+
.replace(/\s+/g, " ")
|
|
368
|
+
.trim()
|
|
369
|
+
: "";
|
|
370
|
+
const subject = ownText || ancestorText;
|
|
302
371
|
// Long enough to distinguish twenty controls, short enough that a screen
|
|
303
372
|
// reader does not read a paragraph before the reader can act on it.
|
|
304
|
-
setSubjectLabel(
|
|
373
|
+
setSubjectLabel(subject.length > 80 ? `${subject.slice(0, 80).trimEnd()}…` : subject);
|
|
305
374
|
|
|
306
375
|
// parentElement, not the node itself: closest() would match our own
|
|
307
376
|
// aria-label once we set one, and the answer would flip every render.
|
|
@@ -361,6 +430,67 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
361
430
|
};
|
|
362
431
|
}, [focusableAncestor, isClick, show, hide]);
|
|
363
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Insert the host for the portalled help control, immediately after the
|
|
435
|
+
* focusable ancestor (NEH-965). A layout effect, so the control is in place
|
|
436
|
+
* before paint.
|
|
437
|
+
*/
|
|
438
|
+
useLayoutEffect(() => {
|
|
439
|
+
if (!isClick || !focusableAncestor || typeof document === "undefined") {
|
|
440
|
+
setHelpHost(null);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const host = document.createElement("span");
|
|
444
|
+
// Named so a consumer reading the DOM can see whose node this is, and so a
|
|
445
|
+
// test can assert the control landed outside the ancestor rather than
|
|
446
|
+
// merely that it exists somewhere.
|
|
447
|
+
host.setAttribute("data-stonedog-tooltip-help-host", "");
|
|
448
|
+
host.style.display = "inline-flex";
|
|
449
|
+
host.style.verticalAlign = "middle";
|
|
450
|
+
focusableAncestor.after(host);
|
|
451
|
+
setHelpHost(host);
|
|
452
|
+
return () => {
|
|
453
|
+
host.remove();
|
|
454
|
+
setHelpHost(null);
|
|
455
|
+
};
|
|
456
|
+
}, [isClick, focusableAncestor]);
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Keep the host beside its ancestor when React MOVES the ancestor.
|
|
460
|
+
*
|
|
461
|
+
* The host is a node this component inserts, not one React renders, so React
|
|
462
|
+
* does not move it with the ancestor: reordering a row of icon buttons
|
|
463
|
+
* leaves every help control behind at its old index. Measured before fixing,
|
|
464
|
+
* two buttons swapped:
|
|
465
|
+
*
|
|
466
|
+
* before BTN(Expand) HOST(Help: Expand) BTN(Collapse) HOST(Help: Collapse)
|
|
467
|
+
* after HOST(Help: Expand) BTN(Collapse) HOST(Help: Collapse) BTN(Expand)
|
|
468
|
+
*
|
|
469
|
+
* — every control now beside the wrong button, which for an accessibility
|
|
470
|
+
* affordance is worse than the nesting it replaced.
|
|
471
|
+
*
|
|
472
|
+
* Re-running the effect above cannot catch this. Its dependency is the
|
|
473
|
+
* ancestor NODE, and a moved node is the same node, so nothing changes and
|
|
474
|
+
* nothing re-runs. Hence a deliberately dependency-free layout effect: it
|
|
475
|
+
* re-asserts the placement on every commit, before paint, and costs two DOM
|
|
476
|
+
* property reads on a path that only exists in click mode inside a focusable
|
|
477
|
+
* ancestor.
|
|
478
|
+
*
|
|
479
|
+
* Scope worth stating rather than implying: this follows a move that
|
|
480
|
+
* RE-RENDERS this component, which is what a list reorder does. A subtree
|
|
481
|
+
* memoised so hard that React moves it without rendering it would not be
|
|
482
|
+
* followed. A `MutationObserver` on the parent would cover that too, and is
|
|
483
|
+
* deliberately not here — its callback is a microtask, so it could not be
|
|
484
|
+
* asserted in the tier that can see this at all, and an untested guard is
|
|
485
|
+
* the thing this package's rules are most emphatic about.
|
|
486
|
+
*/
|
|
487
|
+
useLayoutEffect(() => {
|
|
488
|
+
if (!helpHost || !focusableAncestor) return;
|
|
489
|
+
if (!focusableAncestor.isConnected) return;
|
|
490
|
+
if (helpHost.previousSibling === focusableAncestor) return;
|
|
491
|
+
focusableAncestor.after(helpHost);
|
|
492
|
+
});
|
|
493
|
+
|
|
364
494
|
useLayoutEffect(() => {
|
|
365
495
|
if (visible && triggerRef.current && tooltipRef.current) {
|
|
366
496
|
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
@@ -566,19 +696,40 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
566
696
|
const resolvedHelpLabel =
|
|
567
697
|
helpLabel ?? (subjectLabel ? `Help: ${subjectLabel}` : "More information");
|
|
568
698
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
699
|
+
/**
|
|
700
|
+
* The click-mode control — rendered only once its home is known, see
|
|
701
|
+
* `helpPlacementSettled`.
|
|
702
|
+
*
|
|
703
|
+
* `stopPropagation` is load-bearing rather than defensive. A React portal
|
|
704
|
+
* bubbles its events through the REACT tree, not the DOM tree, so once this
|
|
705
|
+
* control is portalled out of the icon button it is still, as far as React
|
|
706
|
+
* is concerned, inside it — and a press on "?" would fire the button's own
|
|
707
|
+
* `onClick`. That is precisely the collision this fix exists to remove, so
|
|
708
|
+
* it is stopped for the inline case too: pressing "?" asks for an
|
|
709
|
+
* explanation, and must never also do the thing being explained.
|
|
710
|
+
*/
|
|
711
|
+
const helpControl =
|
|
712
|
+
isClick && helpPlacementSettled ? (
|
|
713
|
+
<HelpTrigger
|
|
714
|
+
ref={helpRef}
|
|
715
|
+
type="button"
|
|
716
|
+
side={helpGoesFirst ? "before" : "after"}
|
|
717
|
+
aria-label={resolvedHelpLabel}
|
|
718
|
+
aria-expanded={visible}
|
|
719
|
+
aria-controls={visible ? tooltipId : undefined}
|
|
720
|
+
onMouseDown={(event: React.MouseEvent) => event.stopPropagation()}
|
|
721
|
+
onClick={(event: React.MouseEvent) => {
|
|
722
|
+
event.stopPropagation();
|
|
723
|
+
setVisible((open) => !open);
|
|
724
|
+
}}
|
|
725
|
+
>
|
|
726
|
+
?
|
|
727
|
+
</HelpTrigger>
|
|
728
|
+
) : null;
|
|
729
|
+
|
|
730
|
+
// Inside a focusable ancestor the control is portalled out to `helpHost`;
|
|
731
|
+
// everywhere else it stays where it has always been, beside the child.
|
|
732
|
+
const inlineHelpControl = focusableAncestor ? null : helpControl;
|
|
582
733
|
|
|
583
734
|
return (
|
|
584
735
|
<>
|
|
@@ -628,10 +779,11 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
|
|
|
628
779
|
aria-describedby={!insideFocusable && visible ? tooltipId : undefined}
|
|
629
780
|
{...rest}
|
|
630
781
|
>
|
|
631
|
-
{helpGoesFirst &&
|
|
782
|
+
{helpGoesFirst && inlineHelpControl}
|
|
632
783
|
{children}
|
|
633
|
-
{!helpGoesFirst &&
|
|
784
|
+
{!helpGoesFirst && inlineHelpControl}
|
|
634
785
|
</TooltipTrigger>
|
|
786
|
+
{helpControl && helpHost && createPortal(helpControl, helpHost)}
|
|
635
787
|
{visible && typeof document !== "undefined" &&
|
|
636
788
|
createPortal(
|
|
637
789
|
<TooltipContent
|
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
|
// ---------------------------------------------------------------------------
|