@surrealdb/ui 1.2.11 → 1.2.13
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/.claude/launch.json +17 -0
- package/.oxfmtrc.json +27 -0
- package/.oxlintrc.json +23 -0
- package/.zed/settings.json +34 -34
- package/AGENTS.md +21 -19
- package/README.md +15 -10
- package/dist/assets/245b1ccaa4927818.svg +3 -0
- package/dist/assets/333e1fe6e67ca4a2.svg +10 -0
- package/dist/assets/4b0e10c655814886.svg +21 -0
- package/dist/assets/9932da008ea5dd32.svg +3 -0
- package/dist/assets/a372168d5789daa2.svg +4 -0
- package/dist/assets/b140e44ee4ae7e35.svg +5 -0
- package/dist/assets/e028075817d928e4.svg +3 -0
- package/dist/assets/ee1065ad5a8f7f14.svg +6 -0
- package/dist/ui.css +1 -1
- package/dist/ui.d.ts +579 -1
- package/dist/ui.js +11553 -8988
- package/dist/ui.js.map +1 -1
- package/doc/code-style.md +264 -0
- package/doc/stacker-plan.md +219 -0
- package/package.json +119 -115
- package/res/_mixins.scss +15 -11
- package/tests/_setup/e2e-helpers.tsx +85 -85
- package/tests/_setup/portable-stories.ts +11 -10
- package/tests/e2e/MarkdownEditor/content-blocks.test.tsx +140 -139
- package/tests/e2e/MarkdownEditor/edits.test.tsx +101 -100
- package/tests/e2e/MarkdownEditor/heading-fold.test.tsx +35 -34
- package/tests/e2e/MarkdownEditor/hybrid-widgets.test.tsx +178 -177
- package/tests/e2e/MarkdownEditor/image-on-image.test.tsx +50 -49
- package/tests/e2e/MarkdownEditor/jsx-block-content.test.tsx +195 -194
- package/tests/e2e/MarkdownEditor/jsx-highlight.test.tsx +47 -46
- package/tests/e2e/MarkdownEditor/jsx-inline-badges.test.tsx +35 -34
- package/tests/e2e/MarkdownEditor/jsx-inline-click.test.tsx +39 -38
- package/tests/e2e/MarkdownEditor/jsx-selection.test.tsx +28 -27
- package/tests/e2e/MarkdownEditor/link-placeholder.test.tsx +49 -48
- package/tests/e2e/MarkdownEditor/media-align.test.tsx +40 -39
- package/tests/e2e/MarkdownEditor/media-edit.test.tsx +49 -48
- package/tests/e2e/MarkdownEditor/media-sizing.test.tsx +108 -107
- package/tests/e2e/MarkdownEditor/modes.test.tsx +79 -78
- package/tests/e2e/MarkdownEditor/onimage-parity.test.tsx +29 -28
- package/tests/e2e/MarkdownEditor/regressions.test.tsx +194 -193
- package/tests/e2e/MarkdownEditor/slash-commands.test.tsx +81 -80
- package/tests/e2e/MarkdownEditor/table-click.test.tsx +36 -35
- package/tests/e2e/MarkdownEditor/table-controls.test.tsx +44 -43
- package/tests/e2e/MarkdownEditor/table-format.test.tsx +28 -27
- package/tests/e2e/MarkdownEditor/undo-redo.test.tsx +22 -21
- package/tests/e2e/MarkdownViewer/parity.test.tsx +144 -143
- package/tests/e2e/MarkdownViewer/render.test.tsx +20 -19
- package/tests/e2e/Stacker/stacker.test.tsx +304 -0
- package/tests/unit/Editor/helpers.test.ts +26 -26
- package/tests/unit/MarkdownConformance/error-isolation.test.tsx +26 -25
- package/tests/unit/MarkdownConformance/indentation.test.tsx +20 -19
- package/tests/unit/MarkdownConformance/inline-code-fuzz.test.tsx +255 -0
- package/tests/unit/MarkdownConformance/jsx-attributes.test.tsx +39 -38
- package/tests/unit/MarkdownConformance/jsx-fuzz.test.tsx +122 -122
- package/tests/unit/MarkdownConformance/links.test.tsx +78 -54
- package/tests/unit/MarkdownConformance/render-helpers.tsx +60 -60
- package/tests/unit/MarkdownEditor/code-info.test.ts +58 -58
- package/tests/unit/MarkdownEditor/decorations.test.ts +423 -421
- package/tests/unit/MarkdownEditor/editor-ready.test.ts +22 -22
- package/tests/unit/MarkdownEditor/html-descriptors.test.ts +70 -70
- package/tests/unit/MarkdownEditor/indented-fence.test.ts +80 -80
- package/tests/unit/MarkdownEditor/jsx-attr-scan.test.ts +87 -87
- package/tests/unit/MarkdownEditor/jsx-block-widget.test.ts +51 -51
- package/tests/unit/MarkdownEditor/jsx-tag-grammar.test.ts +63 -63
- package/tests/unit/MarkdownEditor/list-indent.test.ts +67 -67
- package/tests/unit/MarkdownEditor/slash-commands.test.ts +150 -150
- package/tests/unit/MarkdownEditor/table-format.test.ts +67 -67
- package/tests/unit/MarkdownEditor/table.test.ts +96 -96
- package/tests/unit/MarkdownEditor/triggers.test.ts +167 -167
- package/tests/unit/MarkdownEditor/widget-store.test.ts +90 -90
- package/tests/unit/MarkdownViewer/callout.test.tsx +42 -42
- package/tests/unit/MarkdownViewer/code-highlight.test.tsx +41 -41
- package/tests/unit/MarkdownViewer/code-title.test.tsx +45 -45
- package/tests/unit/MarkdownViewer/features.test.tsx +129 -129
- package/tests/unit/MarkdownViewer/headings.test.tsx +28 -28
- package/tests/unit/MarkdownViewer/indented-jsx-children.test.tsx +53 -53
- package/tests/unit/MarkdownViewer/jsx-block-nesting.test.tsx +137 -137
- package/tests/unit/MarkdownViewer/jsx.test.tsx +188 -188
- package/tests/unit/MarkdownViewer/list-bullets.test.tsx +31 -31
- package/tests/unit/MarkdownViewer/list-code.test.tsx +45 -45
- package/tests/unit/MarkdownViewer/preserve-newlines.test.tsx +109 -109
- package/tests/unit/MarkdownViewer/read-time.test.ts +13 -13
- package/tests/unit/MarkdownViewer/renderers.test.tsx +75 -75
- package/tests/unit/MarkdownViewer/runnable.test.tsx +51 -51
- package/tests/unit/MarkdownViewer/ssr.test.tsx +56 -55
- package/tests/unit/MarkdownViewer/syntax-highlighting.test.tsx +43 -42
- package/tests/unit/MarkdownViewer/tabs-fenced-code.test.tsx +120 -120
- package/tests/unit/MarkdownViewer/tabs.test.tsx +83 -82
- package/tests/unit/Stacker/drag.test.ts +21 -0
- package/tests/unit/Stacker/helpers.test.ts +105 -0
- package/tests/unit/Stacker/reducer.test.ts +107 -0
- package/tools/duplicate-vector-mappings.json +141 -141
- package/tools/glass-export-overrides.json +6 -6
- package/tools/icon-parser.ts +41 -40
- package/tools/integration-gradient-exports.json +55 -55
- package/tools/picto-compare.ts +32 -32
- package/tools/picto-merge-urls.ts +19 -19
- package/tools/picto-sync.ts +543 -543
- package/tools/picto-verify-gradients.ts +50 -50
- package/tools/vector-export-nodes.json +975 -975
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Code Style
|
|
2
|
+
|
|
3
|
+
This document describes how code is written in the SurrealDB UI Kit. It is
|
|
4
|
+
derived from analysing the existing source under `src/`, which is the
|
|
5
|
+
**leading** authority: when in doubt, match the surrounding code. The goal is
|
|
6
|
+
that anyone can write new code indistinguishable from what is already there.
|
|
7
|
+
|
|
8
|
+
Formatting (tabs, 100-column width, one-attribute-per-line JSX, import ordering)
|
|
9
|
+
is enforced by oxfmt. This document covers the conventions the formatter and
|
|
10
|
+
linter do **not** enforce: structure, naming, comments, and idiom.
|
|
11
|
+
|
|
12
|
+
## Files and extensions
|
|
13
|
+
|
|
14
|
+
- **`.tsx` everywhere in `/src`** — including files
|
|
15
|
+
that contain no JSX (for example `api/helpers.tsx`, `api/keys.tsx`,
|
|
16
|
+
`api/publisher.tsx`). The framework packages use `.tsx` as the default source
|
|
17
|
+
extension.
|
|
18
|
+
- **Component folders** are `PascalCase` and contain an `index.tsx` plus, when
|
|
19
|
+
needed, a `style.module.scss`. Sub-parts of a component live beside it as
|
|
20
|
+
lowercase files (e.g. `section.tsx`, `view.tsx`, `list.tsx`) or in nested
|
|
21
|
+
`PascalCase` folders.
|
|
22
|
+
- **Hook and utility files** are `kebab-case`: `use-stable.tsx`,
|
|
23
|
+
`use-registry.tsx`, `make-panel-type.tsx`, `clone.ts`.
|
|
24
|
+
- **SCSS modules** are always named `style.module.scss` and imported as
|
|
25
|
+
`import classes from "./style.module.scss"`.
|
|
26
|
+
|
|
27
|
+
## Components
|
|
28
|
+
|
|
29
|
+
**Always define components as typed `const` arrow functions, never as `function`
|
|
30
|
+
declarations.** This is the dominant convention across `core`.
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
export interface EntryButtonProps
|
|
34
|
+
extends ButtonProps, ElementProps<"button">, RefAttributes<HTMLButtonElement> {}
|
|
35
|
+
|
|
36
|
+
export const EntryButton: FC<...> = ({ children, className, ...other }) => {
|
|
37
|
+
return (
|
|
38
|
+
<Button
|
|
39
|
+
{...other}
|
|
40
|
+
className={clsx(classes.root, className)}
|
|
41
|
+
>
|
|
42
|
+
{children}
|
|
43
|
+
</Button>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Conventions:
|
|
49
|
+
|
|
50
|
+
- Type the component with `FC` (or `FC<Props>`) imported from `react`, and
|
|
51
|
+
destructure props in the parameter list.
|
|
52
|
+
- Name the props interface `<Component>Props`. Extend Mantine prop types
|
|
53
|
+
(`ButtonProps`, `BoxProps`, `ElementProps<"...">`), `PropsWithChildren`, and
|
|
54
|
+
`RefAttributes<...>` where relevant, rather than re-declaring common props.
|
|
55
|
+
- **Named exports** are the norm; avoid default exports.
|
|
56
|
+
- When a component needs an inner implementation (for example to sit behind a
|
|
57
|
+
`<Suspense>` or memo boundary), the exported component is a thin wrapper and the
|
|
58
|
+
implementation is a private `const XxxInner: FC<...>` in the same file:
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
export const WorkspaceMixin: FC<WorkspaceMixinProps> = memo((props) => (
|
|
62
|
+
<Suspense>
|
|
63
|
+
<WorkspaceMixinInner {...props} />
|
|
64
|
+
</Suspense>
|
|
65
|
+
));
|
|
66
|
+
|
|
67
|
+
const WorkspaceMixinInner: FC<WorkspaceMixinProps> = ({ depotKey, name, weight }) => {
|
|
68
|
+
// ...
|
|
69
|
+
};
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- Boolean props read as flags: `with*` (`withBackground`, `withDebug`), `is*`
|
|
73
|
+
(`isActive`, `isFocused`), or `show*` (`showResults`, `showSidebar`).
|
|
74
|
+
- JSX attributes are placed one-per-line (oxfmt `singleAttributePerLine: true`);
|
|
75
|
+
do not fight the formatter by collapsing them.
|
|
76
|
+
|
|
77
|
+
## Event handlers
|
|
78
|
+
|
|
79
|
+
Interactive handlers are named `handle<Event>` and wrapped in `useStable` so they
|
|
80
|
+
keep a stable identity across renders:
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
const handleSidebarToggle = useStable(() => {
|
|
84
|
+
setShowSidebar(!showSidebar);
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`useStable` (from `src/hooks`) is preferred over `useCallback` for
|
|
89
|
+
callbacks whose dependencies would otherwise churn; it is registered with Biome
|
|
90
|
+
as a stable-result hook, so it does not need a dependency array.
|
|
91
|
+
|
|
92
|
+
## Hooks
|
|
93
|
+
|
|
94
|
+
- Custom hooks are `use<Thing>` and live in `src/hooks/` (core-wide) or beside the
|
|
95
|
+
feature they serve.
|
|
96
|
+
- **Context hooks** read a context with `use(SomeContext)` and throw a typed
|
|
97
|
+
error when used outside their provider, rather than returning `null`:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
export function useTabs(): TabsContext {
|
|
101
|
+
const ctx = use(TabsContext);
|
|
102
|
+
|
|
103
|
+
if (!ctx) {
|
|
104
|
+
throw new SurrealistError("Component must be wrapped in a <Workspace> component");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return ctx;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- A context, its `use*` accessor, and its `Provider` component typically share a
|
|
112
|
+
file. The context value interface and the context object often share the same
|
|
113
|
+
name (`interface TabsContext` and `const TabsContext = createContext(...)`).
|
|
114
|
+
- Utility hooks are exceptions to the arrow-component rule and are written as
|
|
115
|
+
`export function` (e.g. `useStable`, `useRegistry`, `useTabs`). Components use
|
|
116
|
+
`const` + `FC`; standalone functions and hooks use `function`.
|
|
117
|
+
|
|
118
|
+
## Functions and utilities
|
|
119
|
+
|
|
120
|
+
- Standalone, non-component functions use `export function foo(...)` with explicit
|
|
121
|
+
return types where they are part of a public surface.
|
|
122
|
+
- Pure helpers live in utility modules and are small and
|
|
123
|
+
single-purpose (`generateId`, `delegate`, `dedent`, `extractError`, `bail`,
|
|
124
|
+
`sanitizeId`).
|
|
125
|
+
- Prefer early returns and guard clauses over deep nesting.
|
|
126
|
+
|
|
127
|
+
## Types and interfaces
|
|
128
|
+
|
|
129
|
+
- Use `interface` for object shapes, component props, and context values; use
|
|
130
|
+
`type` for unions, aliases, mapped/conditional types, and function-type
|
|
131
|
+
aliases.
|
|
132
|
+
- Union-of-literals is the norm for enumerations
|
|
133
|
+
(`type Platform = "darwin" | "windows" | "linux"`,
|
|
134
|
+
`type UpdateType = "metadata" | "state"`).
|
|
135
|
+
- Discriminated unions use a `kind` field, with `is*` type guards:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
export type ProviderEntry = FolderEntry | ModelEntry;
|
|
139
|
+
|
|
140
|
+
export const isFolder = (item: ProviderEntry): item is FolderEntry => item.kind === "folder";
|
|
141
|
+
export const isModel = (item: ProviderEntry): item is ModelEntry => item.kind === "model";
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- Generic type parameters are single uppercase letters (`T`, `K`, `A`, `S`, `U`),
|
|
145
|
+
matching the panel/registry/depot generics.
|
|
146
|
+
- Deliberate exceptions to a lint rule use an inline
|
|
147
|
+
`// oxlint-disable-next-line <rule>: <reason>` comment, always with a trailing
|
|
148
|
+
reason. The Biome-owned `useExhaustiveDependencies` rule is the exception — it
|
|
149
|
+
still reads `// biome-ignore lint/correctness/useExhaustiveDependencies` comments.
|
|
150
|
+
|
|
151
|
+
## Naming
|
|
152
|
+
|
|
153
|
+
- **Variables, functions, props, methods:** `camelCase`.
|
|
154
|
+
- **Components, classes, interfaces, types:** `PascalCase`.
|
|
155
|
+
- **Module-level constants:** `SCREAMING_SNAKE_CASE` — e.g. `LOG_TAG`,
|
|
156
|
+
`CONFIG_CACHE`, `DEFAULT_PANELS`, `WORKSPACE_PROVIDER`, `THEME_DATA_RETRIEVAL`,
|
|
157
|
+
`OPACITY_STRONG`, `ENTITY_TYPES`, `FullSync`. (A small number of local
|
|
158
|
+
singletons like `FullSync` use `PascalCase`; module-wide exported constants use
|
|
159
|
+
`SCREAMING_SNAKE_CASE`.)
|
|
160
|
+
- **Refs:** suffix with `Ref` (`onChangeInner`, `modelEntryCacheRef`,
|
|
161
|
+
`collectRef`).
|
|
162
|
+
- **Design tokens:** colours are `THEME_*` (`THEME_DATA_RETRIEVAL`,
|
|
163
|
+
`THEME_SCHEMA`, `THEME_ADMIN`, ...); opacities are `OPACITY_*`
|
|
164
|
+
(`OPACITY_SUBTLE`, `OPACITY_MEDIUM`, `OPACITY_STRONG`).
|
|
165
|
+
- **Icons and pictos:** `icon<Name>` (`iconQuery`, `iconCog`, `iconNamespace`)
|
|
166
|
+
and `picto<Name>` (`pictoSurrealist`), imported from the core UI layer.
|
|
167
|
+
- **Event names** use a namespaced, double-colon convention on the IPC/electron
|
|
168
|
+
boundary (`window::maximize`, `window::toggle-fullscreen`).
|
|
169
|
+
- **SCSS class names** are `kebab-case` in the stylesheet and accessed as
|
|
170
|
+
`camelCase` via the `classes` object (`.save-box` → `classes.saveBox`).
|
|
171
|
+
|
|
172
|
+
## Comments
|
|
173
|
+
|
|
174
|
+
Two distinct comment styles are used, and both matter:
|
|
175
|
+
|
|
176
|
+
1. **Multi-line JSDoc block comments (`/** ... */`)** document exported functions, classes,
|
|
177
|
+
interfaces, class members, and interface members. They describe intent, and
|
|
178
|
+
use `@param`/`@returns`/`@example` where helpful. Nearly every public method
|
|
179
|
+
on `Depot`, `Panel`, `ModelProvider`, and the hooks carries one:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
/**
|
|
183
|
+
* Get a value from the storage backend.
|
|
184
|
+
*
|
|
185
|
+
* @param key - The key of the value to get.
|
|
186
|
+
* @returns A promise that resolves with the value.
|
|
187
|
+
*/
|
|
188
|
+
async get<K extends keyof T>(key: K): Promise<T[K] | null> { ... }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
2. **Short single-line comments (`// ...`)** explain _why_ a block of logic exists
|
|
192
|
+
or mark the steps of a longer procedure. They sit directly above the code they
|
|
193
|
+
describe and read as terse imperative phrases:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// Focus or replace the model if it is already open
|
|
197
|
+
for (const key of currentKeys) {
|
|
198
|
+
// Focus the existing model
|
|
199
|
+
if (models.includes(uriString)) { ... }
|
|
200
|
+
|
|
201
|
+
// Replace an existing model
|
|
202
|
+
if (replaceUriString && models.includes(replaceUriString)) { ... }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Create a new region
|
|
206
|
+
const newKey = generateId();
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Do not** restate the code; explain the reason behind logic or explain the use case to a public facing audience.
|
|
210
|
+
Longer procedural functions (like tab open/close logic) are essentially outlined by these step-comments.
|
|
211
|
+
|
|
212
|
+
**Do not** make any references to the planning phases or phrase comments from a first person perspective.
|
|
213
|
+
|
|
214
|
+
## Whitespace and vertical rhythm
|
|
215
|
+
|
|
216
|
+
Blank lines are used deliberately to separate **logical units**, not
|
|
217
|
+
arbitrarily. The consistent pattern is:
|
|
218
|
+
|
|
219
|
+
- One blank line between the imports block and the first declaration.
|
|
220
|
+
- One blank line separating each logical step inside a function — typically a
|
|
221
|
+
step introduced by a `// ...` comment is preceded by a blank line:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
const newSchema = { ...schema };
|
|
225
|
+
|
|
226
|
+
// Synchronize root schema
|
|
227
|
+
if (options.root) {
|
|
228
|
+
...
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Synchronize namespace schema
|
|
232
|
+
if (surreal.namespace && options.namespace) {
|
|
233
|
+
...
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
- One blank line between members of a class/interface, and between grouped
|
|
238
|
+
declarations.
|
|
239
|
+
- Related declarations are grouped together with no blank lines between them,
|
|
240
|
+
then separated from the next group by a single blank line. For example, a block
|
|
241
|
+
of `useLocalDepotState` calls is grouped, then a blank line precedes the refs:
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
const [regionKeys, setRegionKeys] = useLocalDepotState(depot, "regionKeys", [], true);
|
|
245
|
+
const [regionModels, setRegionModels] = useLocalDepotState(depot, "regionModels", {}, true);
|
|
246
|
+
const [regionActives, setRegionActives] = useLocalDepotState(depot, "regionActives", {});
|
|
247
|
+
|
|
248
|
+
const modelEntryCacheRef = useRef(new Map<string, ModelEntry>());
|
|
249
|
+
const providers = modelProviders.mapping();
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Never use more than one consecutive blank line.
|
|
253
|
+
|
|
254
|
+
## Imports and exports
|
|
255
|
+
|
|
256
|
+
- oxfmt organises imports automatically (`sortImports`); do not hand-sort them.
|
|
257
|
+
Imports are grouped — external packages, then internal, then local/relative —
|
|
258
|
+
with a blank line between groups, and sorted within each group.
|
|
259
|
+
- Use **named exports**. Barrel `index.tsx` files re-export a package's public
|
|
260
|
+
surface with `export *` and named re-exports, grouped by area with a short
|
|
261
|
+
comment header per group (see `core/src/index.tsx`, whose sections are labelled
|
|
262
|
+
`// API exports`, `// Foundation exports`, `// Hooks exports`, etc.).
|
|
263
|
+
- Import type-only symbols with `import type { ... }` (`verbatimModuleSyntax` is
|
|
264
|
+
on, so this is required for types).
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Stacker v2 — Responsive modal/drawer overlay manager
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
Rewrite `src/lib/stacker` **from scratch** (replacing the previous attempt entirely). Stacker becomes a single imperative API for spawning overlays that render as **modals on desktop and drawers on mobile**, chosen automatically per stack, exposed as `<StackerProvider>` + `useStacker()` (Mantine modals-manager style). It must be built on Mantine's lower-level compound primitives (`Modal.Root`/`Drawer.Root`/`ModalBase*`) rather than the opinionated `<Modal>`/`<Drawer>`.
|
|
6
|
+
|
|
7
|
+
Key behaviors (authoritative requirements):
|
|
8
|
+
|
|
9
|
+
- **Single `open()`** receives one content + chrome props; kind chosen live from the viewport (mobile → drawer), forceable via optional `variant` on the **first** stack item. Later items' `variant` hints are ignored for rendering. Resizing remounts the matching host immediately.
|
|
10
|
+
- **Nested stack** like Mantine Modal Manager: closing the top reveals the previous.
|
|
11
|
+
- **Modals**: only ever ONE modal rendered; pushes/pops animate a content swap (scale/fade). ONE scrim (`<Overlay>`) that darkens/fades only — never scales the page.
|
|
12
|
+
- **Drawers**: lower drawers stay mounted & peek out behind the top one (shadcn nested look), their **content faded to opacity 0** (sheet surface stays visible). ONE scrim + the page content scales down slightly (fixed amount, no per-depth compounding).
|
|
13
|
+
- `title` + `subtitle` (under title); close button pinned top-right even when title/subtitle wrap.
|
|
14
|
+
- `side` hint (drawer-only, **default "right"** per user), `draggable` hint (drawer-only, vaul-style velocity drag-to-dismiss).
|
|
15
|
+
- Mobile breakpoint: **`sm` = 48em/768px** (per user), overridable via provider `mobileQuery` prop.
|
|
16
|
+
- Storybook stories + unit + e2e tests.
|
|
17
|
+
|
|
18
|
+
## Verified Mantine 9.2.2 facts that drive the design
|
|
19
|
+
|
|
20
|
+
1. `Transition` never animates on initial mount (`useDidUpdate`) → every Root mounts `opened=false` and flips true in a mount effect (two-phase mount).
|
|
21
|
+
2. In the `entered` state, Transition keeps its `in` styles **inline on `Modal.Content`/`Drawer.Content` permanently** → our depth/drag/swap transforms must live on inner elements we own, never on Mantine's Content.
|
|
22
|
+
3. Every mounted `ModalBase` registers its own window-capture Escape listener gated only by its own `closeOnEscape` → with N drawer roots mounted, set `closeOnEscape` **only on the top** root.
|
|
23
|
+
4. Theme `defaultProps` do NOT reach compound roots (keys are `ModalRoot`/`DrawerRoot`) → replicate house defaults explicitly (modal: `size "lg"`, `radius "sm"`, `centered`, `padding "xl"`). Theme `Overlay` (opacity .25, blur 4) and `CloseButton` (surreal, custom icon) defaults DO apply — free consistency.
|
|
24
|
+
5. `ModalBase*` parts (`ModalBaseTitle/Body/...`) are exported from `@mantine/core` root and share one context with both Modal and Drawer → one shared header/body chrome component works in both paths with correct `aria-labelledby`/`aria-describedby`.
|
|
25
|
+
6. `onExitTransitionEnd`/`onEnterTransitionEnd` are first-class Root props → use for REMOVE dispatch.
|
|
26
|
+
7. `Drawer.Content` has `overflow-y: auto` and `data-hidden` means `opacity: 0 !important` on the whole content → neutralize Content (transparent, `overflow: visible`), never use `__hidden`; scrolling moves into our `.body`.
|
|
27
|
+
8. `RemoveScroll` is refcounted → per-root `lockScroll` default is safe with N mounted roots.
|
|
28
|
+
9. Drawer `position` auto-picks slide transition (top→slide-down, bottom→slide-up, left→slide-right, right→slide-left); positioning is CSS-vars, not data attributes.
|
|
29
|
+
10. `useDrag` (@mantine/hooks) provides movement/velocity/direction with document-level listeners → synthetic PointerEvents work for e2e drag tests.
|
|
30
|
+
11. `useMediaQuery(query, initial, { getInitialValueInEffect: false })` reads matchMedia synchronously.
|
|
31
|
+
12. Repo: `isolatedDeclarations` (annotate all exports), SCSS modules `dashesOnly`, `clsx` from `@src/helpers/clsx`, `generateRandomId` from `@src/helpers/id`, `useStable` from `src/hooks/use-stable.tsx`, `src/index.ts` already re-exports `./lib/stacker`.
|
|
32
|
+
|
|
33
|
+
## File layout — `src/lib/stacker/` (delete all 11 existing files)
|
|
34
|
+
|
|
35
|
+
| File | Responsibility |
|
|
36
|
+
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
37
|
+
| `index.ts` | Public re-exports (components, hook, types, `stackerReducer`, pure helpers for tests) |
|
|
38
|
+
| `types.ts` | `StackerKind`, `StackerSide`, `StackerSettings`, `StackerItem`, `StackerContextValue` |
|
|
39
|
+
| `config.ts` | Constants: `STACKER_MOBILE_QUERY = "(max-width: 48em)"`, z base 200, durations (root 200ms, swap 220ms, drawer 300ms, page 400ms), easing `cubic-bezier(0.32, 0.72, 0, 1)`, `DRAWER_PEEK_PX 12`, `DRAWER_DEPTH_SCALE 0.04`, `PAGE_SCALE 0.98`, `PAGE_RADIUS 12` |
|
|
40
|
+
| `helpers.ts` | Pure: `resolveKind(variant, isMobile)`, `resolveLiveKind(items, isMobile)`, `drawerDepthStyle(side, depth)`, `topOpened(items)` |
|
|
41
|
+
| `reducer.ts` | `stackerReducer` — OPEN/CLOSE/CLOSE_ALL/REMOVE/UPDATE (items only; no stored kind) |
|
|
42
|
+
| `context.ts` | `StackerContext`, `useStacker()` (throws outside provider) |
|
|
43
|
+
| `provider.tsx` | `StackerProvider`: reducer, `useMediaQuery`, stable callbacks, page-scale shell, mounts one host by kind |
|
|
44
|
+
| `modal-host.tsx` | The single `Modal.Root` tree; REMOVE on root exit |
|
|
45
|
+
| `modal-swap.tsx` | Content cross-swap state machine (current/leaving panes, push/pop direction, cleanup, reduced motion) |
|
|
46
|
+
| `drawer-host.tsx` | Shared scrim (Portal + Transition + `<Overlay fixed>`) + one `Drawer.Root` per item |
|
|
47
|
+
| `drawer-sheet.tsx` | Per-item inner sheet: depth transform, content fade, drag wiring, handle |
|
|
48
|
+
| `chrome.tsx` | `StackerHeader` (title/subtitle/close, `live` vs inert mode) + `StackerBody` |
|
|
49
|
+
| `use-drag-dismiss.ts` | Port of existing hook nearly as-is (`position`→`side`); pure `shouldDismiss` kept |
|
|
50
|
+
| `style.module.scss` | Page shell, sheet, content fade, handle, header, swap panes/keyframes, reduced-motion |
|
|
51
|
+
| `AGENTS.md` | Rewritten module doc |
|
|
52
|
+
|
|
53
|
+
### Public API
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
type StackerKind = "modal" | "drawer";
|
|
57
|
+
type StackerSide = "left" | "right" | "top" | "bottom";
|
|
58
|
+
|
|
59
|
+
interface StackerSettings {
|
|
60
|
+
id?: string; // default generateRandomId()
|
|
61
|
+
title?: ReactNode;
|
|
62
|
+
subtitle?: ReactNode; // under title
|
|
63
|
+
children?: ReactNode; // THE one content
|
|
64
|
+
variant?: StackerKind; // first-overlay hint only
|
|
65
|
+
side?: StackerSide; // drawer-only, DEFAULT "right"
|
|
66
|
+
draggable?: boolean; // drawer-only, default false
|
|
67
|
+
size?: MantineSize | (string & {}) | number; // modal default "lg", drawer "md"
|
|
68
|
+
padding?: MantineSpacing; // default "xl"
|
|
69
|
+
withCloseButton?: boolean; // default true
|
|
70
|
+
closeOnEscape?: boolean; // default true
|
|
71
|
+
closeOnClickOutside?: boolean; // default true
|
|
72
|
+
onClose?: () => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
useStacker(): { open(settings?): string; close(id?): void; closeAll(): void;
|
|
76
|
+
update(id, patch): void; stack: StackerItem[]; kind: StackerKind | null }
|
|
77
|
+
|
|
78
|
+
<StackerProvider mobileQuery?: string /* default "(max-width: 48em)" */ zIndex?: number>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## State & reducer
|
|
82
|
+
|
|
83
|
+
`{ items: StackerItem[] /* bottom→top; opened=false = playing exit */ }`
|
|
84
|
+
|
|
85
|
+
- **OPEN** `{item}`: existing id → no-op if open, **revive** (flip opened, merge settings) if closing; else append.
|
|
86
|
+
- **CLOSE**: flip `opened=false` (idempotent, same-ref no-op).
|
|
87
|
+
- **CLOSE_ALL**: flip all.
|
|
88
|
+
- **REMOVE**: filter id.
|
|
89
|
+
- **UPDATE**: shallow-merge patch.
|
|
90
|
+
|
|
91
|
+
Provider `open()` (stable via `useStable`, state via ref): dispatches `{ item }` only. Live kind is `resolveLiveKind(items, isMobile)` = first item's `variant` ?? viewport.
|
|
92
|
+
Provider `close(id?)`: target = id or top opened item; skip if already closing (onClose fires once); fire `onClose`; **non-top modal → immediate REMOVE** (it isn't rendered, no animation); else CLOSE.
|
|
93
|
+
|
|
94
|
+
Edge cases: double-close = no-op; reopen mid-exit = revive (Transition reverses); viewport change mid-stack remounts the matching host with the same items.
|
|
95
|
+
|
|
96
|
+
## Modal path — ONE `Modal.Root` for the whole stack
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
<Modal.Root opened={entered && anyOpen} onClose={() => close()}
|
|
100
|
+
closeOnEscape={top.closeOnEscape} closeOnClickOutside={top.closeOnClickOutside}
|
|
101
|
+
trapFocus returnFocus lockScroll centered radius="sm"
|
|
102
|
+
padding={top.padding ?? "xl"} size={top.size ?? "lg"}
|
|
103
|
+
transitionProps={{ transition: "pop", duration: 200, timingFunction: EASING }}
|
|
104
|
+
onExitTransitionEnd={removeAllClosedItems} zIndex={zIndex}>
|
|
105
|
+
<Modal.Overlay data-stacker-scrim /> ← THE single scrim (darken/fade only)
|
|
106
|
+
<Modal.Content data-stacker="modal">
|
|
107
|
+
<ModalSwap top={lastItemRegardlessOfOpened} onLeaveEnd={removeIfStillClosed}>
|
|
108
|
+
pane: <StackerHeader live /> + <StackerBody />
|
|
109
|
+
</ModalSwap>
|
|
110
|
+
</Modal.Content>
|
|
111
|
+
</Modal.Root>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- Escape + overlay click funnel through `onClose` → pops top. Native Mantine focus trap/scroll lock/portal.
|
|
115
|
+
- When the LAST item pops, content persists (swap keyed on last item, not last _open_) while the Root plays exit; `onExitTransitionEnd` REMOVEs → kind reset.
|
|
116
|
+
|
|
117
|
+
### Swap mechanism (`modal-swap.tsx`)
|
|
118
|
+
|
|
119
|
+
- Derived-state-in-render: when top id ≠ current id, `setLeaving({item: current, direction})`, `setCurrent(top)` during render (no flash frame). Direction: old current still in items → "push", else "pop".
|
|
120
|
+
- Layout: `.swap { display:grid }`, `.pane { grid-area: 1/1; min-width: 0 }` (panes overlap; no height animation in v1).
|
|
121
|
+
- CSS keyframes 220ms, fill-mode both, direction-aware deck: push = old scales back to 0.94 + fades, new enters from 1.06; pop = reversed.
|
|
122
|
+
- Leaving pane is `inert aria-hidden` with `live={false}` chrome (plain markup — avoids duplicate `ModalBaseTitle` aria ids).
|
|
123
|
+
- Cleanup: `onAnimationEnd` → `onLeaveEnd(id)` → provider REMOVEs only if item still present with `opened === false`; `setTimeout(duration+100)` fallback. Reduced motion (`useReducedMotion`): skip leaving pane entirely, instant swap.
|
|
124
|
+
- Focus: entering pane `tabIndex={-1}`, focused (preventScroll) when `document.activeElement` collapsed to body.
|
|
125
|
+
|
|
126
|
+
## Drawer path — one `Drawer.Root` per item + standalone scrim
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
<Portal>
|
|
130
|
+
<Transition mounted={anyOpen} transition="fade" duration={200}>
|
|
131
|
+
{(styles) => <Overlay fixed data-stacker-scrim style={styles} zIndex={zIndex}
|
|
132
|
+
onClick={() => topAllowsClickOutside && close()} />}
|
|
133
|
+
</Transition>
|
|
134
|
+
</Portal>
|
|
135
|
+
{items.map((item, index) =>
|
|
136
|
+
<Drawer.Root opened={entered && item.opened} onClose={() => close(item.id)}
|
|
137
|
+
position={side /* default "right" */} size={size ?? "md"} padding={padding ?? "xl"}
|
|
138
|
+
closeOnEscape={isTop && closeOnEscape} ← CRITICAL (fact 3)
|
|
139
|
+
closeOnClickOutside={false} ← scrim owns click-outside
|
|
140
|
+
trapFocus={isTop} returnFocus lockScroll
|
|
141
|
+
transitionProps={{ duration: 300, timingFunction: EASING }} ← slide-* auto per position
|
|
142
|
+
onExitTransitionEnd={() => remove(item.id)} zIndex={zIndex + 1 + index}>
|
|
143
|
+
<Drawer.Content className={contentReset} data-stacker="drawer" data-covered={depth > 0}>
|
|
144
|
+
<div className={sheet} data-side data-covered style={depthOrDragTransform}>
|
|
145
|
+
{draggable && <div className={grip} ref={isTop ? dragRef : undefined}
|
|
146
|
+
style={{ touchAction: isTop ? "none" : undefined }}><div className={handle}/></div>}
|
|
147
|
+
<StackerHeader live className={fadeItem} />
|
|
148
|
+
<StackerBody className={fadeItem} />
|
|
149
|
+
</div>
|
|
150
|
+
</Drawer.Content>
|
|
151
|
+
</Drawer.Root>)}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
- **Standalone scrim** (not `Drawer.Overlay` in root[0]) because a mid-stack/bottom REMOVE must not tear down the scrim. z: scrim at base, roots at base+1+index.
|
|
155
|
+
- **Depth visuals** (`drawerDepthStyle(side, depth)`, depth = count of opened items above): transform-origin at the **free edge**, translate away from anchor + scale, net peek = depth × 12px:
|
|
156
|
+
- right: `translateX(-12px·depth) scale(1 − 0.04·depth)`, origin `0% 50%`
|
|
157
|
+
- left: `translateX(+…)`, origin `100% 50%`; bottom: `translateY(-…)`, origin `50% 0%`; top: `translateY(+…)`, origin `50% 100%`
|
|
158
|
+
- Mantine's slide transform lives on Content (outer), ours on `.sheet` (inner) — they compose, never conflict (fact 2). `.sheet { transition: transform .3s EASING }`.
|
|
159
|
+
- **Content fade**: `.sheet[data-covered] :is(.header, .body) { opacity: 0 }` — surface + handle stay visible. Covered content gets `pointer-events: none` so clicks on peeking sheets fall through to the scrim.
|
|
160
|
+
- **Content neutralization**: `.contentReset { background: transparent; box-shadow: none; overflow: visible }`; scrolling in `.body { flex:1; min-height:0; overflow-y:auto }`; per-side radius on `.sheet` (round free corners only).
|
|
161
|
+
- **Drag**: reuse ported `use-drag-dismiss` (flick ≥ 0.5 px/ms toward edge OR past 50% of extent → dismiss); enabled only for top sheet with `draggable`; `transition: none` while dragging; spring-back via sheet transition. Drag and depth transforms never coexist (only depth-0 drags).
|
|
162
|
+
|
|
163
|
+
## Page scale (drawers only)
|
|
164
|
+
|
|
165
|
+
Provider wraps children: `<Box className={page} data-pushed={kind === "drawer" && anyOpen}>`. `.page[data-pushed] { transform: scale(0.98); border-radius: 12px; overflow: hidden }`, 0.4s EASING, `transform-origin: top center`, no transform when idle (keeps `position: fixed` descendants working), fixed amount regardless of depth, reduced-motion disables it. Never applied for modals.
|
|
166
|
+
|
|
167
|
+
## Header (`chrome.tsx`)
|
|
168
|
+
|
|
169
|
+
Flex row `align-items: flex-start`: left column `.titles { flex:1; min-width:0 }` with `ModalBaseTitle` (live) or plain div (inert) + subtitle `<Text c="dimmed" size="sm">`; right `CloseButton` `.close { flex-shrink:0; margin-inline-start:auto }` → pinned top-right regardless of wrapping. Padding via `--mb-padding`. NOT using `ModalBaseHeader` (sticky/min-height/opaque bg don't fit sheets). Body = `ModalBaseBody` (live) for `aria-describedby`.
|
|
170
|
+
|
|
171
|
+
## Stories (`src/stories/primitives/Stacker.stories.tsx`, CSF3, NO play functions)
|
|
172
|
+
|
|
173
|
+
1. `Default` — auto kind, nested push buttons, title/subtitle demo
|
|
174
|
+
2. `MobileEmulated` — `mobileQuery="(min-width: 0px)"` + explanatory banner
|
|
175
|
+
3. `ForcedModal` / `ForcedDrawer`
|
|
176
|
+
4. `ModalDeck` — recursive push/pop (+ `update` title button)
|
|
177
|
+
5. `StackedDrawers` — nested right drawers, peek + content fade
|
|
178
|
+
6. `DrawerSides` — 4 side buttons
|
|
179
|
+
7. `DraggableDrawer` — `draggable`, `size "60vh"`
|
|
180
|
+
8. `CoercedStack` — drawer first, then `variant:"modal"` push renders as drawer
|
|
181
|
+
9. `DismissControl` — closeOnEscape/closeOnClickOutside/withCloseButton false combos
|
|
182
|
+
10. `LongHeader` — wrapping title+subtitle fixture
|
|
183
|
+
11. `Lifecycle` — visible onClose counters + closeAll
|
|
184
|
+
|
|
185
|
+
## Tests
|
|
186
|
+
|
|
187
|
+
**Unit** (`tests/unit/Stacker/`): `reducer.test.ts` (open order/kind lock incl. all-closing stack/revive/idempotent close/remove resets kind/close-all/update), `helpers.test.ts` (`resolveKind` matrix; `drawerDepthStyle` 4 sides × depths), `drag.test.ts` (keep existing 4 `shouldDismiss` cases).
|
|
188
|
+
|
|
189
|
+
**E2E** (`tests/e2e/Stacker/stacker.test.tsx`, composeStories + mountStory, selectors via `data-stacker*`):
|
|
190
|
+
|
|
191
|
+
- E1 nothing mounts on render; E2 desktop auto → modal, page scale 1; E3 `MobileEmulated` → drawer + page scale < 0.99; E4 forced drawer on desktop; E5 kind coercion (2 sheets, 0 modals); E6 modal push keeps exactly ONE dialog + ONE scrim, old pane leaves DOM; E7 pop reveals previous, scrim stays mounted; E8 drawer peek geometry (lower sheet offset ≈ 12px) + lower body computed opacity 0; E9 page scale not compounding + returns to 1 after closeAll; E10 escape/scrim-click pop only top + respect opt-outs + focus returns into revealed drawer; E11 close button pinned with wrapping header; E12 drag-dismiss via synthetic PointerEvents (past 50% closes; 30px springs back); E13 onClose fires once, closeAll empties, kind resets after stack empties.
|
|
192
|
+
- Delete stale `tests/e2e/Stacker/__screenshots__/`.
|
|
193
|
+
|
|
194
|
+
## Implementation sequence
|
|
195
|
+
|
|
196
|
+
1. `types/config/helpers/reducer` → unit tests green
|
|
197
|
+
2. `context` + `provider` (callbacks, kind decision, page shell)
|
|
198
|
+
3. Drawer path (`drawer-host`, `drawer-sheet`, `chrome`, SCSS, `use-drag-dismiss` port)
|
|
199
|
+
4. Modal path (`modal-host`, `modal-swap`, keyframes)
|
|
200
|
+
5. Stories rewrite; manual Storybook pass
|
|
201
|
+
6. E2E rewrite; delete stale screenshots
|
|
202
|
+
7. `AGENTS.md`; verify `src/index.ts` re-export
|
|
203
|
+
|
|
204
|
+
## Verification
|
|
205
|
+
|
|
206
|
+
- `npx tsc --noEmit` clean (mind `isolatedDeclarations`)
|
|
207
|
+
- `npm run qc` (oxlint + biome exhaustive-deps + oxfmt) clean
|
|
208
|
+
- `npx vitest run --project=unit tests/unit/Stacker --project=e2e tests/e2e/Stacker --project=storybook src/stories/primitives/Stacker.stories.tsx` all green
|
|
209
|
+
- Live check in Storybook via preview server (spawn on a free port; user's own runs on 6006): no auto-open on load; modal path (single dialog, swap animation, scrim persists, page never scales); drawer path (right default, peek + content fade, page scales once, drag-dismiss); `npm run build` rolls up types
|
|
210
|
+
|
|
211
|
+
## Key risks (mitigations built into design)
|
|
212
|
+
|
|
213
|
+
- Transition inline styles on Content → transforms only on inner elements (2)
|
|
214
|
+
- No initial-mount animation → two-phase mount everywhere (1)
|
|
215
|
+
- Escape multi-close across drawer roots → closeOnEscape top-only (3)
|
|
216
|
+
- `data-hidden`/`__hidden` hides sheet surface → never used; fade header/body instead (7)
|
|
217
|
+
- Duplicate ARIA ids in swap → inert `live={false}` leaving pane
|
|
218
|
+
- Focus tug-of-war on drawer pop → e2e asserts; fallback = delay trapFocus flip one frame or manual focus restore
|
|
219
|
+
- Page transform breaks fixed descendants while drawers open → inherent vaul trade-off, no transform when idle, documented
|