@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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useId, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The mechanics of a disclosure, with no markup attached.
|
|
7
|
+
*
|
|
8
|
+
* ## Why this exists as a hook and not only as a component
|
|
9
|
+
*
|
|
10
|
+
* `StyledCollapsible` renders its own `<button>` with the trigger inside it.
|
|
11
|
+
* That is the right default and it is the wrong shape for a host whose control
|
|
12
|
+
* is *already* a button — an icon button with a tooltip, say, sitting in a
|
|
13
|
+
* header row opposite a title. Handed such a control as `trigger`, the
|
|
14
|
+
* component would wrap one `<button>` in another: invalid HTML that React warns
|
|
15
|
+
* will break hydration, and one affordance split into two, with the accessible
|
|
16
|
+
* name on the inner element and `aria-expanded` on the outer one. A screen
|
|
17
|
+
* reader then announces a button that says nothing, containing a button that
|
|
18
|
+
* says nothing about its state.
|
|
19
|
+
*
|
|
20
|
+
* The alternative — the host hand-rolling `useState`, a `useId`, the two ARIA
|
|
21
|
+
* attributes and the `hidden` decision — is how one product ends up with two
|
|
22
|
+
* disclosures that disagree, which is exactly what NEH-1100 records happening.
|
|
23
|
+
*
|
|
24
|
+
* So the mechanics live here, and both the component below and any host
|
|
25
|
+
* composition are built on the same three lines. There is one implementation of
|
|
26
|
+
* *a disclosure*; there are as many arrangements of it as there are layouts.
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const { open, triggerProps, contentProps } = useDisclosure();
|
|
30
|
+
*
|
|
31
|
+
* <header>
|
|
32
|
+
* <h2>Vitals</h2>
|
|
33
|
+
* <MyIconButton {...triggerProps} aria-label={open ? "Hide" : "Show"} />
|
|
34
|
+
* </header>
|
|
35
|
+
* <section {...contentProps}>…</section>
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* ## `hidden`, never unmounted
|
|
39
|
+
*
|
|
40
|
+
* `contentProps.hidden` is the whole opinion this hook carries, and it is not
|
|
41
|
+
* negotiable by a prop. Unmounting collapsed content looks tidier and discards
|
|
42
|
+
* focus, scroll position and anything part-typed — so a mis-press destroys work
|
|
43
|
+
* rather than merely hiding it. `hidden` also keeps the region addressable by
|
|
44
|
+
* `aria-controls` at all times, which is what lets `aria-expanded` mean
|
|
45
|
+
* anything: a control that claims to expand something must point at something
|
|
46
|
+
* that exists while it is collapsed.
|
|
47
|
+
*
|
|
48
|
+
* A host that genuinely wants unmounting can render `{open && …}` itself. It
|
|
49
|
+
* should then know it is giving that up.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export interface UseDisclosureOptions {
|
|
53
|
+
/** Controlled. Omit to let the hook own the state. */
|
|
54
|
+
open?: boolean | undefined;
|
|
55
|
+
/** Initial state when uncontrolled. Default `false`. */
|
|
56
|
+
defaultOpen?: boolean | undefined;
|
|
57
|
+
onOpenChange?: ((next: boolean) => void) | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The id linking trigger to content. Generated when omitted.
|
|
60
|
+
*
|
|
61
|
+
* Supply one only when something outside this pair must reference the region
|
|
62
|
+
* by id; two disclosures given the same id will produce two triggers pointing
|
|
63
|
+
* at one region.
|
|
64
|
+
*/
|
|
65
|
+
id?: string | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Spread onto the ONE element that is the control. It must be a `<button>`. */
|
|
69
|
+
export interface DisclosureTriggerProps {
|
|
70
|
+
type: "button";
|
|
71
|
+
"aria-expanded": boolean;
|
|
72
|
+
"aria-controls": string;
|
|
73
|
+
onClick: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Spread onto the region the control shows and hides. */
|
|
77
|
+
export interface DisclosureContentProps {
|
|
78
|
+
id: string;
|
|
79
|
+
hidden: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Disclosure {
|
|
83
|
+
open: boolean;
|
|
84
|
+
toggle: () => void;
|
|
85
|
+
setOpen: (next: boolean) => void;
|
|
86
|
+
triggerProps: DisclosureTriggerProps;
|
|
87
|
+
contentProps: DisclosureContentProps;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function useDisclosure(options: UseDisclosureOptions = {}): Disclosure {
|
|
91
|
+
const { open: controlled, defaultOpen = false, onOpenChange, id } = options;
|
|
92
|
+
|
|
93
|
+
const [uncontrolled, setUncontrolled] = useState(defaultOpen);
|
|
94
|
+
|
|
95
|
+
// Controlled the moment `open` is supplied, and uncontrolled otherwise —
|
|
96
|
+
// decided per render rather than latched at mount, because a host that
|
|
97
|
+
// switches between the two mid-life has a bug we should not paper over by
|
|
98
|
+
// silently ignoring the prop.
|
|
99
|
+
const isControlled = controlled !== undefined;
|
|
100
|
+
const open = isControlled ? controlled : uncontrolled;
|
|
101
|
+
|
|
102
|
+
const generatedId = useId();
|
|
103
|
+
const contentId = id ?? generatedId;
|
|
104
|
+
|
|
105
|
+
const setOpen = useCallback(
|
|
106
|
+
(next: boolean) => {
|
|
107
|
+
// The internal state moves even when controlled. If the host ignores the
|
|
108
|
+
// callback the control would otherwise appear dead to the pointer, and a
|
|
109
|
+
// control that does nothing when pressed is indistinguishable from a
|
|
110
|
+
// broken one.
|
|
111
|
+
if (!isControlled) setUncontrolled(next);
|
|
112
|
+
onOpenChange?.(next);
|
|
113
|
+
},
|
|
114
|
+
[isControlled, onOpenChange],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const toggle = useCallback(() => setOpen(!open), [setOpen, open]);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
open,
|
|
121
|
+
toggle,
|
|
122
|
+
setOpen,
|
|
123
|
+
triggerProps: {
|
|
124
|
+
// `type="button"` because the commonest place a disclosure lives is
|
|
125
|
+
// inside a form, where an untyped button submits it. The symptom is a
|
|
126
|
+
// page reload on the first press of a "show more" control.
|
|
127
|
+
type: "button",
|
|
128
|
+
"aria-expanded": open,
|
|
129
|
+
"aria-controls": contentId,
|
|
130
|
+
onClick: toggle,
|
|
131
|
+
},
|
|
132
|
+
contentProps: {
|
|
133
|
+
id: contentId,
|
|
134
|
+
hidden: !open,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export default useDisclosure;
|
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
|
|
@@ -315,3 +325,39 @@ export { DL_VARIANTS } from "./components/StyledDefinitionList";
|
|
|
315
325
|
|
|
316
326
|
export { default as StyledSparkLine } from "./components/StyledSparkLine";
|
|
317
327
|
export type { StyledSparkLineProps } from "./components/StyledSparkLine";
|
|
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
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Notifications
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
export { default as StyledToaster, StyledToaster as Toaster } from "./components/StyledToaster";
|
|
345
|
+
export type { StyledToasterProps } from "./components/StyledToaster";
|
|
346
|
+
|
|
347
|
+
export { createToaster, DEFAULT_DURATIONS } from "./components/toaster-store";
|
|
348
|
+
export type {
|
|
349
|
+
Toast,
|
|
350
|
+
ToastAction,
|
|
351
|
+
ToastOptions,
|
|
352
|
+
ToastType,
|
|
353
|
+
ToasterStore,
|
|
354
|
+
ToasterStoreOptions,
|
|
355
|
+
} from "./components/toaster-store";
|
|
356
|
+
|
|
357
|
+
export { default as useDisclosure } from "./components/useDisclosure";
|
|
358
|
+
export type {
|
|
359
|
+
Disclosure,
|
|
360
|
+
DisclosureContentProps,
|
|
361
|
+
DisclosureTriggerProps,
|
|
362
|
+
UseDisclosureOptions,
|
|
363
|
+
} from "./components/useDisclosure";
|
package/src/preset/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { stackRecipe } from "./recipes/stack";
|
|
|
27
27
|
import { stripedRecipe } from "./recipes/striped";
|
|
28
28
|
import { tagRecipe } from "./recipes/tag";
|
|
29
29
|
import { textRecipe } from "./recipes/text";
|
|
30
|
+
import { toastRecipe } from "./recipes/toast";
|
|
30
31
|
import { tooltipRecipe } from "./recipes/tooltip";
|
|
31
32
|
|
|
32
33
|
import {
|
|
@@ -56,8 +57,9 @@ export interface StonedogStylePresetOptions {
|
|
|
56
57
|
/**
|
|
57
58
|
* Every recipe, keyed by the name it is exported under in `styled-system/recipes`.
|
|
58
59
|
*
|
|
59
|
-
*
|
|
60
|
-
* `inputRadioRootRecipe`) are slot recipes declared with
|
|
60
|
+
* Six of these (`alertRecipe`, `listRecipe`, `menuRecipe`, `inputBoolRecipe`,
|
|
61
|
+
* `inputRadioRootRecipe`, `toastRecipe`) are slot recipes declared with
|
|
62
|
+
* `defineSlotRecipe`.
|
|
61
63
|
* Panda accepts them here rather than under `slotRecipes` and generates them
|
|
62
64
|
* correctly — verified against HopperGuard's own generated output. Moving them
|
|
63
65
|
* to `slotRecipes` would be more "correct" by the docs and would change the
|
|
@@ -87,6 +89,7 @@ const recipes = {
|
|
|
87
89
|
stripedRecipe,
|
|
88
90
|
tagRecipe,
|
|
89
91
|
textRecipe,
|
|
92
|
+
toastRecipe,
|
|
90
93
|
tooltipRecipe,
|
|
91
94
|
};
|
|
92
95
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { defineSlotRecipe } from "@pandacss/dev";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A transient message: the region it lives in, and the card itself.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from HopperGuard, where the card was a `cva` in the component file
|
|
7
|
+
* and the region was a nine-cell CSS grid of which one cell was ever used. Both
|
|
8
|
+
* are here now, for the reason every recipe is: a `cva` inside a component is
|
|
9
|
+
* invisible to a consumer's Panda run unless that consumer globs this package's
|
|
10
|
+
* source, whereas a recipe is emitted from config with no parsing at all.
|
|
11
|
+
*
|
|
12
|
+
* ## The accent is a border, not a background
|
|
13
|
+
*
|
|
14
|
+
* Status is carried by a 4px bar down the leading edge rather than by tinting
|
|
15
|
+
* the whole card. Two reasons, and the second is the load-bearing one:
|
|
16
|
+
*
|
|
17
|
+
* - A toast sits over arbitrary page content, so it needs an opaque surface of
|
|
18
|
+
* its own to stay readable. A tint would fight that.
|
|
19
|
+
* - **Colour is never the only cue.** The accent says the same thing as the
|
|
20
|
+
* glyph the component renders beside the message, so a reader who cannot
|
|
21
|
+
* distinguish the hues loses nothing — WCAG 1.4.1, Level A. Deleting the
|
|
22
|
+
* glyph "because the colour already says it" is the regression this note
|
|
23
|
+
* exists to stop.
|
|
24
|
+
*
|
|
25
|
+
* `borderInlineStart` rather than `borderLeft`: in a right-to-left document the
|
|
26
|
+
* accent belongs on the right, and the logical property is what moves it there.
|
|
27
|
+
*/
|
|
28
|
+
export const toastRecipe = defineSlotRecipe({
|
|
29
|
+
className: "toast",
|
|
30
|
+
description: "A transient message and the region that stacks them",
|
|
31
|
+
slots: [
|
|
32
|
+
"region",
|
|
33
|
+
"root",
|
|
34
|
+
"indicator",
|
|
35
|
+
"content",
|
|
36
|
+
"title",
|
|
37
|
+
"description",
|
|
38
|
+
"action",
|
|
39
|
+
"close",
|
|
40
|
+
],
|
|
41
|
+
base: {
|
|
42
|
+
region: {
|
|
43
|
+
position: "fixed",
|
|
44
|
+
// Anchored to one corner rather than laid out in a grid of nine cells:
|
|
45
|
+
// the extracted version declared all nine and rendered into exactly one,
|
|
46
|
+
// so eight of them were markup nothing could ever reach.
|
|
47
|
+
insetBlockEnd: "4",
|
|
48
|
+
insetInlineEnd: "4",
|
|
49
|
+
display: "flex",
|
|
50
|
+
flexDirection: "column",
|
|
51
|
+
alignItems: "flex-end",
|
|
52
|
+
gap: "3",
|
|
53
|
+
// The region spans far enough to stack wide toasts but must not swallow
|
|
54
|
+
// clicks meant for the page beneath it — `none` here, `auto` on each
|
|
55
|
+
// card. Without this pairing a dismissed-but-still-animating toast
|
|
56
|
+
// leaves an invisible plate over the corner of the app.
|
|
57
|
+
pointerEvents: "none",
|
|
58
|
+
maxWidth: "calc(100vw - {spacing.8})",
|
|
59
|
+
zIndex: "toast",
|
|
60
|
+
},
|
|
61
|
+
root: {
|
|
62
|
+
pointerEvents: "auto",
|
|
63
|
+
display: "flex",
|
|
64
|
+
alignItems: "center",
|
|
65
|
+
gap: "4",
|
|
66
|
+
borderRadius: "md",
|
|
67
|
+
boxShadow: "lg",
|
|
68
|
+
paddingInline: "4",
|
|
69
|
+
paddingBlock: "3",
|
|
70
|
+
minWidth: { base: "320px", lg: "600px" },
|
|
71
|
+
maxWidth: { base: "400px", lg: "700px" },
|
|
72
|
+
fontSize: "md",
|
|
73
|
+
// Stated, not inherited: a themed typeface otherwise reaches the page and
|
|
74
|
+
// stops at the edge of the component (NEH-289).
|
|
75
|
+
fontFamily: "body",
|
|
76
|
+
borderWidth: "1px",
|
|
77
|
+
borderStyle: "solid",
|
|
78
|
+
// `borderBgPrimary`, not the `borderSubtle` this was first written with:
|
|
79
|
+
// that token belongs to HopperGuard's vocabulary, not this package's, and
|
|
80
|
+
// Panda passes an unknown token through as a literal — the card would
|
|
81
|
+
// have rendered with `border-color: borderSubtle`, which the browser
|
|
82
|
+
// discards, so the toast would have had no border at all and nothing
|
|
83
|
+
// would have said so. The package's own token-contract test caught it.
|
|
84
|
+
borderColor: "borderBgPrimary",
|
|
85
|
+
backgroundColor: "boxBgPrimary",
|
|
86
|
+
color: "textPrimary",
|
|
87
|
+
transition: "opacity 200ms ease, transform 200ms ease",
|
|
88
|
+
// `data-state` rather than a class: the renderer flips one attribute and
|
|
89
|
+
// the same rule drives both directions, so there is no window in which a
|
|
90
|
+
// toast has neither state.
|
|
91
|
+
"&[data-state='closed']": {
|
|
92
|
+
opacity: "0",
|
|
93
|
+
transform: "translateY(0.5rem)",
|
|
94
|
+
},
|
|
95
|
+
"&[data-state='open']": {
|
|
96
|
+
opacity: "1",
|
|
97
|
+
transform: "translateY(0)",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
indicator: {
|
|
101
|
+
flexShrink: 0,
|
|
102
|
+
display: "flex",
|
|
103
|
+
alignItems: "center",
|
|
104
|
+
justifyContent: "center",
|
|
105
|
+
lineHeight: "1",
|
|
106
|
+
fontSize: "lg",
|
|
107
|
+
},
|
|
108
|
+
content: {
|
|
109
|
+
flex: "1",
|
|
110
|
+
minWidth: "0",
|
|
111
|
+
display: "flex",
|
|
112
|
+
flexDirection: "column",
|
|
113
|
+
gap: "1",
|
|
114
|
+
},
|
|
115
|
+
title: {
|
|
116
|
+
fontWeight: "bold",
|
|
117
|
+
},
|
|
118
|
+
description: {
|
|
119
|
+
display: "block",
|
|
120
|
+
},
|
|
121
|
+
action: {
|
|
122
|
+
flexShrink: 0,
|
|
123
|
+
},
|
|
124
|
+
close: {
|
|
125
|
+
flexShrink: 0,
|
|
126
|
+
display: "inline-flex",
|
|
127
|
+
alignItems: "center",
|
|
128
|
+
justifyContent: "center",
|
|
129
|
+
// 48px, matching every other interactive floor in this package. A close
|
|
130
|
+
// control is the one thing on a toast a person is *aiming* at, often
|
|
131
|
+
// while it is animating, so it is the last place to shave a target down
|
|
132
|
+
// to the size of its glyph.
|
|
133
|
+
minWidth: "48px",
|
|
134
|
+
minHeight: "48px",
|
|
135
|
+
borderRadius: "md",
|
|
136
|
+
borderWidth: "1px",
|
|
137
|
+
borderStyle: "solid",
|
|
138
|
+
borderColor: "transparent",
|
|
139
|
+
background: "transparent",
|
|
140
|
+
color: "inherit",
|
|
141
|
+
cursor: "pointer",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
variants: {
|
|
145
|
+
/**
|
|
146
|
+
* The status accent. `default` deliberately has none — a toast that means
|
|
147
|
+
* nothing in particular should not borrow a colour that means something.
|
|
148
|
+
*/
|
|
149
|
+
type: {
|
|
150
|
+
success: { root: { borderInlineStartWidth: "4px", borderInlineStartColor: "borderSuccess" } },
|
|
151
|
+
error: { root: { borderInlineStartWidth: "4px", borderInlineStartColor: "borderError" } },
|
|
152
|
+
warning: { root: { borderInlineStartWidth: "4px", borderInlineStartColor: "borderWarning" } },
|
|
153
|
+
info: { root: { borderInlineStartWidth: "4px", borderInlineStartColor: "borderBgAccent" } },
|
|
154
|
+
loading: { root: { borderInlineStartWidth: "4px", borderInlineStartColor: "borderBgAccent" } },
|
|
155
|
+
default: {},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
defaultVariants: {
|
|
159
|
+
type: "default",
|
|
160
|
+
},
|
|
161
|
+
});
|