@structuralists/scaffolding 0.0.1 → 0.1.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.
Files changed (70) hide show
  1. package/.github/workflows/publish.yml +66 -0
  2. package/.storybook/main.ts +1 -1
  3. package/.storybook/preview.tsx +5 -1
  4. package/CLAUDE.md +25 -0
  5. package/README.md +79 -0
  6. package/bun.lock +211 -202
  7. package/eslint.config.mjs +85 -84
  8. package/package.json +21 -20
  9. package/roadmap.md +27 -0
  10. package/src/components/Chat/ChatComposer/ChatComposer.stories.tsx +1 -1
  11. package/src/components/Chat/ChatMessage/ChatMessage.stories.tsx +1 -1
  12. package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +1 -1
  13. package/src/components/Chat/ChatShell/ChatShell.stories.tsx +1 -1
  14. package/src/components/Chat/PillCombobox/PillCombobox.stories.tsx +1 -1
  15. package/src/components/Content/Badge/Badge.stories.tsx +1 -1
  16. package/src/components/Content/Card/Card.stories.tsx +1 -1
  17. package/src/components/Content/EditableMarkdown/EditableMarkdown.stories.tsx +1 -1
  18. package/src/components/Content/Heading/Heading.stories.tsx +1 -1
  19. package/src/components/Content/Link/Link.stories.tsx +1 -1
  20. package/src/components/Content/List/List.stories.tsx +1 -1
  21. package/src/components/Content/LoadingContainer/LoadingContainer.stories.tsx +1 -1
  22. package/src/components/Content/Markdown/Markdown.stories.tsx +1 -1
  23. package/src/components/Content/Menu/Menu.stories.tsx +1 -1
  24. package/src/components/Content/Text/Text.stories.tsx +1 -1
  25. package/src/components/Forms/Button/Button.stories.tsx +1 -1
  26. package/src/components/Forms/Field/Field.stories.tsx +1 -1
  27. package/src/components/Forms/IconButton/IconButton.stories.tsx +1 -1
  28. package/src/components/Forms/Input/Input.stories.tsx +1 -1
  29. package/src/components/Forms/Select/MultiSelect/MultiSelect.stories.tsx +1 -1
  30. package/src/components/Forms/Select/SingleSelect/SingleSelect.stories.tsx +1 -1
  31. package/src/components/Forms/Textarea/Textarea.stories.tsx +1 -1
  32. package/src/components/Json/Json/Json.stories.tsx +1 -1
  33. package/src/components/Json/JsonTable/JsonTable.stories.tsx +1 -1
  34. package/src/components/Layout/Bar/Bar.stories.tsx +1 -1
  35. package/src/components/Layout/Debug/Debug.stories.tsx +1 -1
  36. package/src/components/Layout/Divider/Divider.stories.tsx +1 -1
  37. package/src/components/Layout/Grid/Grid.stories.tsx +1 -1
  38. package/src/components/Layout/Panels/Panels.stories.tsx +47 -1
  39. package/src/components/Layout/Panels/index.tsx +17 -1
  40. package/src/components/Layout/Panels/types.ts +5 -0
  41. package/src/components/Layout/Stack/Stack.stories.tsx +1 -1
  42. package/src/components/Modals/ConfirmModal/ConfirmModal.stories.tsx +1 -1
  43. package/src/components/Modals/LargeModal/LargeModal.stories.tsx +1 -1
  44. package/src/components/Modals/MediumModal/MediumModal.stories.tsx +1 -1
  45. package/src/components/Modals/MediumModal/MediumModal.test.tsx +11 -11
  46. package/src/components/Navigation/TabBar/TabBar.stories.tsx +1 -1
  47. package/src/components/Navigation/VerticalNav/VerticalNav.stories.tsx +1 -1
  48. package/src/components/Overlays/Popover/Popover.stories.tsx +1 -1
  49. package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +1 -1
  50. package/src/components/Primitives/EmptyValue/EmptyValue.stories.tsx +1 -1
  51. package/src/components/Primitives/LinedStack/LinedStack.stories.tsx +1 -1
  52. package/src/components/Primitives/LongText/LongText.stories.tsx +1 -1
  53. package/src/components/Primitives/Num/Num.stories.tsx +1 -1
  54. package/src/components/Primitives/Percent/Percent.stories.tsx +1 -1
  55. package/src/components/Primitives/RelativeTime/RelativeTime.stories.tsx +1 -1
  56. package/src/components/Tables/BigTable/BigTable.stories.tsx +1 -1
  57. package/src/components/Tables/QuickTable/QuickTable.stories.tsx +1 -1
  58. package/src/forms/CLAUDE.md +144 -0
  59. package/src/forms/path/path.ts +50 -0
  60. package/src/forms/path/types.test-d.ts +175 -0
  61. package/src/forms/path/types.ts +35 -0
  62. package/src/forms/useFormState/types.ts +13 -0
  63. package/src/forms/useFormState/useFormState.ts +14 -0
  64. package/src/forms/validations/types.test-d.ts +26 -0
  65. package/src/forms/validations/types.ts +15 -0
  66. package/src/hooks/useClickOutside/index.ts +57 -0
  67. package/src/hooks/useStableCallback/index.ts +36 -0
  68. package/src/index.ts +2 -0
  69. package/src/storybook/Composition.stories.tsx +1 -1
  70. package/src/storybook/_StoryUtils.stories.tsx +1 -1
package/eslint.config.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
2
+ import storybook from "eslint-plugin-storybook";
3
+
1
4
  import boundaries from 'eslint-plugin-boundaries';
2
5
  import tsParser from '@typescript-eslint/parser';
3
6
 
@@ -17,90 +20,88 @@ import tsParser from '@typescript-eslint/parser';
17
20
  // `!(Modals|Chat)` is an extglob negation that excludes umbrella sections from
18
21
  // the depth-3 patterns, so files inside Modals/Chat fall through to the
19
22
  // depth-2 patterns and resolve to the umbrella as one primitive.
20
- export default [
21
- {
22
- files: ['src/**/*.{ts,tsx}'],
23
- plugins: { boundaries },
24
- languageOptions: {
25
- parser: tsParser,
26
- parserOptions: { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true } },
23
+ export default [{
24
+ files: ['src/**/*.{ts,tsx}'],
25
+ plugins: { boundaries },
26
+ languageOptions: {
27
+ parser: tsParser,
28
+ parserOptions: { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true } },
29
+ },
30
+ settings: {
31
+ 'import/resolver': {
32
+ node: { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
27
33
  },
28
- settings: {
29
- 'import/resolver': {
30
- node: { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
34
+ 'boundaries/include': ['src/**/*'],
35
+ 'boundaries/elements': [
36
+ // Plain-section component primitives (depth-3 under src/components)
37
+ {
38
+ type: 'primitive-entry',
39
+ pattern: 'src/components/!(Modals|Chat)/*/index.{ts,tsx}',
40
+ mode: 'full',
41
+ capture: ['_section', 'name'],
31
42
  },
32
- 'boundaries/include': ['src/**/*'],
33
- 'boundaries/elements': [
34
- // Plain-section component primitives (depth-3 under src/components)
35
- {
36
- type: 'primitive-entry',
37
- pattern: 'src/components/!(Modals|Chat)/*/index.{ts,tsx}',
38
- mode: 'full',
39
- capture: ['_section', 'name'],
40
- },
41
- {
42
- type: 'primitive-dogfood',
43
- pattern: 'src/components/!(Modals|Chat)/*/**/*.{stories,test}.{ts,tsx}',
44
- mode: 'full',
45
- capture: ['_section', 'name'],
46
- },
47
- {
48
- type: 'primitive',
49
- pattern: 'src/components/!(Modals|Chat)/*',
50
- mode: 'folder',
51
- capture: ['_section', 'name'],
52
- },
53
- // Umbrella-section primitives (depth-2): Modals, Chat
54
- {
55
- type: 'primitive-entry',
56
- pattern: 'src/components/*/index.{ts,tsx}',
57
- mode: 'full',
58
- capture: ['name'],
59
- },
60
- {
61
- type: 'primitive-dogfood',
62
- pattern: 'src/components/*/**/*.{stories,test}.{ts,tsx}',
63
- mode: 'full',
64
- capture: ['name'],
65
- },
66
- {
67
- type: 'primitive',
68
- pattern: 'src/components/*',
69
- mode: 'folder',
70
- capture: ['name'],
71
- },
72
- ],
73
- },
74
- rules: {
75
- 'boundaries/no-unknown': 'off',
76
- 'boundaries/no-unknown-files': 'off',
77
- 'boundaries/dependencies': [
78
- 'error',
79
- {
80
- default: 'allow',
81
- rules: [
82
- // External imports into a primitive must go through its barrel.
83
- // Importers that aren't in the same primitive can't reach internals.
84
- {
85
- from: [
86
- { type: 'primitive', captured: { name: '!{{ to.captured.name }}' } },
87
- { type: 'primitive-entry', captured: { name: '!{{ to.captured.name }}' } },
88
- { type: 'primitive-dogfood', captured: { name: '!{{ to.captured.name }}' } },
89
- ],
90
- disallow: [{ to: { type: 'primitive' } }],
91
- message: "External imports must go through the folder's index barrel.",
92
- },
93
- // Inside the same primitive, files must not import their own barrel
94
- // (cycle risk). Reach the neighbor directly.
95
- {
96
- from: [{ type: 'primitive', captured: { name: '{{ to.captured.name }}' } }],
97
- disallow: [{ to: { type: 'primitive-entry' } }],
98
- message:
99
- "Files inside a primitive folder must not import their own index barrel; import the neighbor directly.",
100
- },
101
- ],
102
- },
103
- ],
104
- },
43
+ {
44
+ type: 'primitive-dogfood',
45
+ pattern: 'src/components/!(Modals|Chat)/*/**/*.{stories,test}.{ts,tsx}',
46
+ mode: 'full',
47
+ capture: ['_section', 'name'],
48
+ },
49
+ {
50
+ type: 'primitive',
51
+ pattern: 'src/components/!(Modals|Chat)/*',
52
+ mode: 'folder',
53
+ capture: ['_section', 'name'],
54
+ },
55
+ // Umbrella-section primitives (depth-2): Modals, Chat
56
+ {
57
+ type: 'primitive-entry',
58
+ pattern: 'src/components/*/index.{ts,tsx}',
59
+ mode: 'full',
60
+ capture: ['name'],
61
+ },
62
+ {
63
+ type: 'primitive-dogfood',
64
+ pattern: 'src/components/*/**/*.{stories,test}.{ts,tsx}',
65
+ mode: 'full',
66
+ capture: ['name'],
67
+ },
68
+ {
69
+ type: 'primitive',
70
+ pattern: 'src/components/*',
71
+ mode: 'folder',
72
+ capture: ['name'],
73
+ },
74
+ ],
75
+ },
76
+ rules: {
77
+ 'boundaries/no-unknown': 'off',
78
+ 'boundaries/no-unknown-files': 'off',
79
+ 'boundaries/dependencies': [
80
+ 'error',
81
+ {
82
+ default: 'allow',
83
+ rules: [
84
+ // External imports into a primitive must go through its barrel.
85
+ // Importers that aren't in the same primitive can't reach internals.
86
+ {
87
+ from: [
88
+ { type: 'primitive', captured: { name: '!{{ to.captured.name }}' } },
89
+ { type: 'primitive-entry', captured: { name: '!{{ to.captured.name }}' } },
90
+ { type: 'primitive-dogfood', captured: { name: '!{{ to.captured.name }}' } },
91
+ ],
92
+ disallow: [{ to: { type: 'primitive' } }],
93
+ message: "External imports must go through the folder's index barrel.",
94
+ },
95
+ // Inside the same primitive, files must not import their own barrel
96
+ // (cycle risk). Reach the neighbor directly.
97
+ {
98
+ from: [{ type: 'primitive', captured: { name: '{{ to.captured.name }}' } }],
99
+ disallow: [{ to: { type: 'primitive-entry' } }],
100
+ message:
101
+ "Files inside a primitive folder must not import their own index barrel; import the neighbor directly.",
102
+ },
103
+ ],
104
+ },
105
+ ],
105
106
  },
106
- ];
107
+ }, ...storybook.configs["flat/recommended"]];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -19,32 +19,33 @@
19
19
  "lint": "eslint src"
20
20
  },
21
21
  "dependencies": {
22
- "@uiw/react-json-view": "^2.0.0-alpha.41",
22
+ "@uiw/react-json-view": "^2.0.0-alpha.42",
23
23
  "react-markdown": "^10.1.0"
24
24
  },
25
25
  "peerDependencies": {
26
- "react": "^19.0.0",
27
- "react-dom": "^19.0.0",
26
+ "react": "^19.2.5",
27
+ "react-dom": "^19.2.5",
28
28
  "react-router": "^7.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@happy-dom/global-registrator": "^15.0.0",
32
- "@storybook/addon-essentials": "^8.6.0",
33
- "@storybook/addon-storysource": "^8.6.0",
34
- "@storybook/react": "^8.6.0",
35
- "@storybook/react-vite": "^8.6.0",
36
- "@testing-library/dom": "^10.0.0",
37
- "@testing-library/react": "^16.0.0",
31
+ "@happy-dom/global-registrator": "^20.9.0",
32
+ "@storybook/addon-docs": "^10.3.6",
33
+ "@storybook/react": "^10.3.6",
34
+ "@storybook/react-vite": "^10.3.6",
35
+ "@testing-library/dom": "^10.4.1",
36
+ "@testing-library/react": "^16.3.2",
38
37
  "@types/bun": "latest",
39
- "@types/react": "^19.0.0",
40
- "@types/react-dom": "^19.0.0",
41
- "@typescript-eslint/parser": "^8.58.2",
42
- "@vitejs/plugin-react": "^4.0.0",
43
- "eslint": "^10.2.1",
38
+ "@types/react": "^19.2.14",
39
+ "@types/react-dom": "^19.2.3",
40
+ "@typescript-eslint/parser": "^8.59.1",
41
+ "@vitejs/plugin-react": "^6.0.1",
42
+ "eslint": "^10.3.0",
44
43
  "eslint-plugin-boundaries": "^6.0.2",
45
- "react-router": "^7.0.0",
46
- "storybook": "^8.6.0",
47
- "typescript": "^5.0.0",
48
- "vite": "^6.0.0"
44
+ "eslint-plugin-storybook": "10.3.6",
45
+ "react-router": "^7.14.2",
46
+ "storybook": "^10.3.6",
47
+ "typescript": "^6.0.3",
48
+ "vite": "^8.0.10",
49
+ "vitest": "^4.1.5"
49
50
  }
50
51
  }
package/roadmap.md ADDED
@@ -0,0 +1,27 @@
1
+ # Roadmap
2
+
3
+ ## Soon
4
+
5
+ ### Investigate `@storybook/addon-vitest`
6
+
7
+ Storybook 10's flagship test workflow: stories become tests automatically. Each `*.stories.tsx` is run as a Vitest test in a real browser via Playwright — every story is at minimum a smoke test, and any story with a `play` function becomes a full interaction test. Optional `addon-a11y` integration can fail builds on accessibility violations.
8
+
9
+ **Why it's appealing:**
10
+
11
+ - ~50+ component smoke tests for free, in real browser environments — no test code to write.
12
+ - Real layout, focus, and dialog semantics (catches things happy-dom misses).
13
+ - A11y gating in CI without standing up a separate framework.
14
+
15
+ **What to weigh:**
16
+
17
+ - Adds Vitest + Playwright to the dep tree (~200MB of browser binaries on install). CI install time goes up.
18
+ - Two test runners coexisting: keep `bun:test` for unit tests (utils, modal) and add Vitest for stories. The `test` script would invoke both.
19
+ - The current `MediumModal.test.tsx` and similar are arguably better expressed as story interaction tests once this lands.
20
+
21
+ **Rough plan if we go:**
22
+
23
+ 1. `bun add -d @storybook/addon-vitest vitest @vitest/browser playwright @storybook/test`
24
+ 2. Run `bunx playwright install chromium` (and add it to the CI workflow).
25
+ 3. Add a `vitest.config.ts` that pulls in `@storybook/addon-vitest/vitest-plugin`.
26
+ 4. Register the addon in `.storybook/main.ts`.
27
+ 5. Add `test:stories` script; decide whether to fold into `test` or run as a separate gate in the workflow.
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { ChatComposer } from './index';
3
3
 
4
4
  const meta: Meta<typeof ChatComposer> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { ChatMessage } from './index';
3
3
  import { Stack } from '../../Layout/Stack';
4
4
  import { Badge } from '../../Content/Badge';
@@ -1,5 +1,5 @@
1
1
  import { useState, type ComponentType } from 'react';
2
- import type { Meta, StoryObj } from '@storybook/react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
  import { ChatRecipientsHeader } from './index';
4
4
  import type { PillComboboxOption } from '../PillCombobox/types';
5
5
  import { Stack } from '../../Layout/Stack';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useState } from 'react';
3
3
  import { ChatShell } from './index';
4
4
  import { ChatMessage } from '../ChatMessage';
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import type { Meta, StoryObj } from '@storybook/react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
  import { PillCombobox, type PillComboboxOption } from './index';
4
4
 
5
5
  const meta: Meta<typeof PillCombobox> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Badge } from './index';
3
3
  import { Stack } from '../../Layout/Stack';
4
4
 
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Card } from './index';
3
3
  import { Heading } from '../Heading';
4
4
  import { Stack } from '../../Layout/Stack';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useState } from 'react';
3
3
  import { EditableMarkdown } from './index';
4
4
 
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Heading } from './index';
3
3
 
4
4
  const meta: Meta<typeof Heading> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Link } from './index';
3
3
 
4
4
  const meta: Meta<typeof Link> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { List, ListItem } from './index';
3
3
 
4
4
  const meta: Meta<typeof List> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useState } from 'react';
3
3
  import { LoadingContainer } from './index';
4
4
  import { Toggle } from '../../../storybook';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Markdown } from './index';
3
3
 
4
4
  const sample = `# Heading 1
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Menu } from './index';
3
3
  import type { MenuItem } from './types';
4
4
 
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Text } from './index';
3
3
 
4
4
  const meta: Meta<typeof Text> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Button } from './index';
3
3
  import { Stack } from '../../Layout/Stack';
4
4
 
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Field } from './index';
3
3
  import { Input } from '../Input';
4
4
 
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { IconButton } from './index';
3
3
  import type { IconButtonVariant, IconButtonSize } from './types';
4
4
 
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Input } from './index';
3
3
 
4
4
  const meta: Meta<typeof Input> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useState } from 'react';
3
3
  import { MultiSelect } from './index';
4
4
  import { Field } from '../../Field';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useState } from 'react';
3
3
  import { SingleSelect } from './index';
4
4
  import { Button } from '../../Button';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Textarea } from './index';
3
3
 
4
4
  const meta: Meta<typeof Textarea> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Json } from './index';
3
3
 
4
4
  const meta: Meta<typeof Json> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { JsonTable } from './index';
3
3
 
4
4
  const meta: Meta<typeof JsonTable> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Bar } from './index';
3
3
  import { Button } from '../../Forms/Button';
4
4
  import { Debug } from '../Debug';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Debug } from './index';
3
3
  import { Stack } from '../Stack';
4
4
  import { Text } from '../../Content/Text';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Divider } from './index';
3
3
  import { Stack } from '../Stack';
4
4
  import { Text } from '../../Content/Text';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Grid } from './index';
3
3
  import { Card } from '../../Content/Card';
4
4
  import { Text } from '../../Content/Text';
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import type { Meta, StoryObj } from '@storybook/react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
  import { Panels } from './index';
4
4
  import { Bar } from '../Bar';
5
5
  import { Button } from '../../Forms/Button';
@@ -155,6 +155,52 @@ export const RightOverlay: Story = {
155
155
  },
156
156
  };
157
157
 
158
+ // Same shape as RightOverlay, but passes `onRightOverlayDismiss` so a
159
+ // pointerdown anywhere outside the overlay clears the selection. The
160
+ // parent still owns the open state — the callback just signals intent.
161
+ export const RightOverlayDismissOnOutsideClick: Story = {
162
+ render: () => {
163
+ const Demo = () => {
164
+ const [selected, setSelected] = useState<string | null>(null);
165
+
166
+ return (
167
+ <Panels
168
+ header={<Bar title="Workbench" />}
169
+ footer={<Bar title="Click outside the panel to dismiss." />}
170
+ leftSidebar={slot(<LeftSidebarContent />, 200)}
171
+ onRightOverlayDismiss={() => setSelected(null)}
172
+ rightOverlay={
173
+ selected
174
+ ? slot(
175
+ <Stack gap={3}>
176
+ <Heading level={3}>{selected}</Heading>
177
+ <Text isMuted size="small">
178
+ Click anywhere outside this panel to dismiss it.
179
+ </Text>
180
+ </Stack>,
181
+ )
182
+ : null
183
+ }
184
+ >
185
+ {slot(
186
+ <Stack gap={3}>
187
+ <Heading level={2}>Items</Heading>
188
+ {['Alpha', 'Beta', 'Gamma'].map((name) => (
189
+ <Button key={name} onClick={() => setSelected(name)}>
190
+ Open {name}
191
+ </Button>
192
+ ))}
193
+ <Lorem paragraphs={20} as={Text} />
194
+ </Stack>,
195
+ )}
196
+ </Panels>
197
+ );
198
+ };
199
+
200
+ return <Demo />;
201
+ },
202
+ };
203
+
158
204
  // The overlay's content is itself a Panels — header/footer stay pinned
159
205
  // while the body scrolls. The header carries the title and an X close
160
206
  // button; the outer layout is intentionally minimal (no sidebars) to
@@ -1,6 +1,12 @@
1
- import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
1
+ import {
2
+ useEffect,
3
+ useState,
4
+ type CSSProperties,
5
+ type ReactNode,
6
+ } from 'react';
2
7
  import { backgroundStyle } from '../../../tokens';
3
8
  import { cx } from '../../../utils';
9
+ import { useClickOutside } from '../../../hooks/useClickOutside';
4
10
  import type { PanelsProps } from './types';
5
11
  import styles from './styles.module.css';
6
12
 
@@ -16,6 +22,7 @@ export const Panels = (props: PanelsProps) => {
16
22
  leftSidebar,
17
23
  rightSidebar,
18
24
  rightOverlay,
25
+ onRightOverlayDismiss,
19
26
  leftSidebarWidth,
20
27
  rightSidebarWidth,
21
28
  rightOverlayWidth,
@@ -46,6 +53,14 @@ export const Panels = (props: PanelsProps) => {
46
53
  const [renderedOverlay, setRenderedOverlay] = useState<ReactNode>(rightOverlay ?? null);
47
54
  const [isOverlayClosing, setIsOverlayClosing] = useState(false);
48
55
 
56
+ // Outside-click dismissal. Gated to the open state so the close
57
+ // animation window — where `rightOverlay` is null but `renderedOverlay`
58
+ // is still mounted — doesn't fire a second dismissal.
59
+ const { ref: overlayRef } = useClickOutside<HTMLDivElement>({
60
+ onOutside: onRightOverlayDismiss,
61
+ enabled: rightOverlay != null,
62
+ });
63
+
49
64
  useEffect(() => {
50
65
  if (rightOverlay != null) {
51
66
  setRenderedOverlay(rightOverlay);
@@ -97,6 +112,7 @@ export const Panels = (props: PanelsProps) => {
97
112
  )}
98
113
  {renderedOverlay != null && (
99
114
  <div
115
+ ref={overlayRef}
100
116
  className={cx(styles.rightOverlay, isOverlayClosing && styles.closing)}
101
117
  style={rightOverlayStyle}
102
118
  >
@@ -18,6 +18,11 @@ export type PanelsProps = {
18
18
  * Parent owns the open state and renders its own close affordance —
19
19
  * there is no backdrop scrim or built-in dismissal. */
20
20
  rightOverlay?: ReactNode;
21
+ /** Optional outside-click dismissal for `rightOverlay`. When provided,
22
+ * a pointerdown anywhere outside the overlay element invokes this
23
+ * callback; the parent is responsible for clearing `rightOverlay` in
24
+ * response. Omit to disable outside-click dismissal entirely. */
25
+ onRightOverlayDismiss?: () => void;
21
26
  /** Fixed width for the left sidebar. Numbers → px, strings pass through
22
27
  * (`"22rem"`, `"25%"`, etc). Defaults to content-sized. */
23
28
  leftSidebarWidth?: number | string;
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Stack } from './index';
3
3
  import { Button } from '../../Forms/Button';
4
4
 
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { ConfirmModal } from './index';
3
3
  import { Toggle } from '../../../storybook';
4
4
 
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { LargeModal } from './index';
3
3
  import { Button } from '../../Forms/Button';
4
4
  import { Stack } from '../../Layout/Stack';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { MediumModal } from './index';
3
3
  import { Button } from '../../Forms/Button';
4
4
  import { Field } from '../../Forms/Field';