@object-ui/components 3.0.2 → 3.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 (55) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/CHANGELOG.md +8 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.js +24701 -22929
  5. package/dist/index.umd.cjs +37 -37
  6. package/dist/src/custom/config-field-renderer.d.ts +21 -0
  7. package/dist/src/custom/config-panel-renderer.d.ts +81 -0
  8. package/dist/src/custom/config-row.d.ts +27 -0
  9. package/dist/src/custom/index.d.ts +5 -0
  10. package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
  11. package/dist/src/custom/navigation-overlay.d.ts +8 -0
  12. package/dist/src/custom/section-header.d.ts +31 -0
  13. package/dist/src/debug/DebugPanel.d.ts +39 -0
  14. package/dist/src/debug/index.d.ts +9 -0
  15. package/dist/src/hooks/use-config-draft.d.ts +46 -0
  16. package/dist/src/index.d.ts +4 -0
  17. package/dist/src/renderers/action/action-bar.d.ts +23 -0
  18. package/dist/src/types/config-panel.d.ts +92 -0
  19. package/dist/src/ui/sheet.d.ts +2 -0
  20. package/dist/src/ui/sidebar.d.ts +4 -0
  21. package/package.json +17 -17
  22. package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
  23. package/src/__tests__/action-bar.test.tsx +172 -0
  24. package/src/__tests__/config-field-renderer.test.tsx +307 -0
  25. package/src/__tests__/config-panel-renderer.test.tsx +580 -0
  26. package/src/__tests__/config-primitives.test.tsx +106 -0
  27. package/src/__tests__/mobile-accessibility.test.tsx +120 -0
  28. package/src/__tests__/navigation-overlay.test.tsx +97 -0
  29. package/src/__tests__/use-config-draft.test.tsx +295 -0
  30. package/src/custom/config-field-renderer.tsx +276 -0
  31. package/src/custom/config-panel-renderer.tsx +306 -0
  32. package/src/custom/config-row.tsx +50 -0
  33. package/src/custom/index.ts +5 -0
  34. package/src/custom/mobile-dialog-content.tsx +67 -0
  35. package/src/custom/navigation-overlay.tsx +42 -4
  36. package/src/custom/section-header.tsx +68 -0
  37. package/src/debug/DebugPanel.tsx +313 -0
  38. package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
  39. package/src/{index.test.ts → debug/index.ts} +2 -7
  40. package/src/hooks/use-config-draft.ts +127 -0
  41. package/src/index.css +4 -0
  42. package/src/index.ts +15 -0
  43. package/src/renderers/action/action-bar.tsx +202 -0
  44. package/src/renderers/action/index.ts +1 -0
  45. package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
  46. package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
  47. package/src/renderers/complex/data-table.tsx +346 -43
  48. package/src/renderers/data-display/breadcrumb.tsx +3 -2
  49. package/src/renderers/form/form.tsx +4 -4
  50. package/src/renderers/navigation/header-bar.tsx +69 -10
  51. package/src/stories/ConfigPanel.stories.tsx +232 -0
  52. package/src/types/config-panel.ts +101 -0
  53. package/src/ui/dialog.tsx +20 -3
  54. package/src/ui/sheet.tsx +6 -3
  55. package/src/ui/sidebar.tsx +93 -9
@@ -8,7 +8,8 @@
8
8
 
9
9
  import React from 'react';
10
10
  import { ComponentRegistry } from '@object-ui/core';
11
- import type { HeaderBarSchema } from '@object-ui/types';
11
+ import type { HeaderBarSchema, BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types';
12
+ import { resolveI18nLabel, SchemaRenderer } from '@object-ui/react';
12
13
  import {
13
14
  SidebarTrigger,
14
15
  Separator,
@@ -17,8 +18,45 @@ import {
17
18
  BreadcrumbItem,
18
19
  BreadcrumbLink,
19
20
  BreadcrumbSeparator,
20
- BreadcrumbPage
21
+ BreadcrumbPage,
22
+ DropdownMenu,
23
+ DropdownMenuTrigger,
24
+ DropdownMenuContent,
25
+ DropdownMenuItem,
26
+ Input,
21
27
  } from '../../ui';
28
+ import { ChevronDown, Search } from 'lucide-react';
29
+
30
+ function BreadcrumbLabel({ crumb, isLast }: { crumb: BreadcrumbItemType; isLast: boolean }) {
31
+ const label = resolveI18nLabel(crumb.label) ?? '';
32
+
33
+ if (crumb.siblings && crumb.siblings.length > 0) {
34
+ return (
35
+ <DropdownMenu>
36
+ <DropdownMenuTrigger className="flex items-center gap-1">
37
+ {isLast ? (
38
+ <span className="font-semibold">{label}</span>
39
+ ) : (
40
+ <span>{label}</span>
41
+ )}
42
+ <ChevronDown className="h-3 w-3" />
43
+ </DropdownMenuTrigger>
44
+ <DropdownMenuContent align="start">
45
+ {crumb.siblings.map((sibling, i) => (
46
+ <DropdownMenuItem key={i} asChild>
47
+ <a href={sibling.href}>{sibling.label}</a>
48
+ </DropdownMenuItem>
49
+ ))}
50
+ </DropdownMenuContent>
51
+ </DropdownMenu>
52
+ );
53
+ }
54
+
55
+ if (isLast) {
56
+ return <BreadcrumbPage>{label}</BreadcrumbPage>;
57
+ }
58
+ return <BreadcrumbLink href={crumb.href || '#'}>{label}</BreadcrumbLink>;
59
+ }
22
60
 
23
61
  ComponentRegistry.register('header-bar',
24
62
  ({ schema }: { schema: HeaderBarSchema }) => (
@@ -27,27 +65,48 @@ ComponentRegistry.register('header-bar',
27
65
  <Separator orientation="vertical" className="mr-2 h-4" />
28
66
  <Breadcrumb>
29
67
  <BreadcrumbList>
30
- {schema.crumbs?.map((crumb: any, idx: number) => (
68
+ {schema.crumbs?.map((crumb: BreadcrumbItemType, idx: number) => (
31
69
  <React.Fragment key={idx}>
32
70
  <BreadcrumbItem>
33
- {idx === schema.crumbs.length - 1 ? (
34
- <BreadcrumbPage>{crumb.label}</BreadcrumbPage>
35
- ) : (
36
- <BreadcrumbLink href={crumb.href || '#'}>{crumb.label}</BreadcrumbLink>
37
- )}
71
+ <BreadcrumbLabel crumb={crumb} isLast={idx === schema.crumbs!.length - 1} />
38
72
  </BreadcrumbItem>
39
- {idx < schema.crumbs.length - 1 && <BreadcrumbSeparator />}
73
+ {idx < schema.crumbs!.length - 1 && <BreadcrumbSeparator />}
40
74
  </React.Fragment>
41
75
  ))}
42
76
  </BreadcrumbList>
43
77
  </Breadcrumb>
78
+
79
+ <div className="ml-auto flex items-center gap-2">
80
+ {schema.search?.enabled && (
81
+ <div className="relative">
82
+ <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
83
+ <Input
84
+ type="search"
85
+ placeholder={schema.search.placeholder}
86
+ className="pl-8 w-[200px] lg:w-[300px]"
87
+ />
88
+ {schema.search.shortcut && (
89
+ <kbd className="pointer-events-none absolute right-2 top-2 hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
90
+ {schema.search.shortcut}
91
+ </kbd>
92
+ )}
93
+ </div>
94
+ )}
95
+ {schema.actions?.map((action, idx) => (
96
+ <SchemaRenderer key={idx} schema={action} />
97
+ ))}
98
+ {schema.rightContent && <SchemaRenderer schema={schema.rightContent} />}
99
+ </div>
44
100
  </header>
45
101
  ),
46
102
  {
47
103
  namespace: 'ui',
48
104
  label: 'Header Bar',
49
105
  inputs: [
50
- { name: 'crumbs', type: 'array', label: 'Breadcrumbs' }
106
+ { name: 'crumbs', type: 'array', label: 'Breadcrumbs' },
107
+ { name: 'search', type: 'object', label: 'Search Configuration' },
108
+ { name: 'actions', type: 'array', label: 'Action Slots' },
109
+ { name: 'rightContent', type: 'object', label: 'Right Content' },
51
110
  ],
52
111
  defaultProps: {
53
112
  crumbs: [
@@ -0,0 +1,232 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import React, { useState } from 'react';
10
+ import type { Meta, StoryObj } from '@storybook/react';
11
+ import { ConfigPanelRenderer } from '../custom/config-panel-renderer';
12
+ import { ConfigFieldRenderer } from '../custom/config-field-renderer';
13
+ import { useConfigDraft } from '../hooks/use-config-draft';
14
+ import type { ConfigPanelSchema, ConfigField } from '../types/config-panel';
15
+
16
+ // ─── ConfigPanelRenderer Stories ─────────────────────────────────────────────
17
+
18
+ const panelMeta = {
19
+ title: 'Config/ConfigPanelRenderer',
20
+ component: ConfigPanelRenderer,
21
+ parameters: {
22
+ layout: 'padded',
23
+ },
24
+ tags: ['autodocs'],
25
+ } satisfies Meta<typeof ConfigPanelRenderer>;
26
+
27
+ export default panelMeta;
28
+ type Story = StoryObj<typeof panelMeta>;
29
+
30
+ // Basic schema example
31
+ const basicSchema: ConfigPanelSchema = {
32
+ breadcrumb: ['Settings', 'General'],
33
+ sections: [
34
+ {
35
+ key: 'basic',
36
+ title: 'Basic',
37
+ fields: [
38
+ { key: 'title', label: 'Title', type: 'input', placeholder: 'Enter title' },
39
+ { key: 'enabled', label: 'Enabled', type: 'switch', defaultValue: true },
40
+ {
41
+ key: 'mode',
42
+ label: 'Mode',
43
+ type: 'select',
44
+ options: [
45
+ { value: 'auto', label: 'Auto' },
46
+ { value: 'manual', label: 'Manual' },
47
+ { value: 'scheduled', label: 'Scheduled' },
48
+ ],
49
+ defaultValue: 'auto',
50
+ },
51
+ ],
52
+ },
53
+ {
54
+ key: 'appearance',
55
+ title: 'Appearance',
56
+ collapsible: true,
57
+ fields: [
58
+ { key: 'theme', label: 'Theme', type: 'select', options: [
59
+ { value: 'light', label: 'Light' },
60
+ { value: 'dark', label: 'Dark' },
61
+ { value: 'system', label: 'System' },
62
+ ], defaultValue: 'system' },
63
+ { key: 'accentColor', label: 'Accent color', type: 'color', defaultValue: '#3b82f6' },
64
+ { key: 'fontSize', label: 'Font size', type: 'slider', min: 10, max: 24, step: 1, defaultValue: 14 },
65
+ ],
66
+ },
67
+ {
68
+ key: 'advanced',
69
+ title: 'Advanced',
70
+ collapsible: true,
71
+ defaultCollapsed: true,
72
+ hint: 'These settings are for power users.',
73
+ fields: [
74
+ { key: 'debug', label: 'Debug mode', type: 'checkbox' },
75
+ { key: 'apiEndpoint', label: 'API endpoint', type: 'input', placeholder: 'https://...' },
76
+ ],
77
+ },
78
+ ],
79
+ };
80
+
81
+ function BasicPanelStory() {
82
+ const source = { title: 'My App', enabled: true, mode: 'auto', theme: 'system', accentColor: '#3b82f6', fontSize: 14, debug: false, apiEndpoint: '' };
83
+ const { draft, isDirty, updateField, discard } = useConfigDraft(source);
84
+ return (
85
+ <div style={{ position: 'relative', height: 600, width: 320, border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden' }}>
86
+ <ConfigPanelRenderer
87
+ open={true}
88
+ onClose={() => alert('Close clicked')}
89
+ schema={basicSchema}
90
+ draft={draft}
91
+ isDirty={isDirty}
92
+ onFieldChange={updateField}
93
+ onSave={() => alert('Saved: ' + JSON.stringify(draft, null, 2))}
94
+ onDiscard={discard}
95
+ />
96
+ </div>
97
+ );
98
+ }
99
+
100
+ export const Basic: Story = {
101
+ render: () => <BasicPanelStory />,
102
+ };
103
+
104
+ // Dashboard-like schema
105
+ const dashboardSchema: ConfigPanelSchema = {
106
+ breadcrumb: ['Dashboard', 'Layout'],
107
+ sections: [
108
+ {
109
+ key: 'layout',
110
+ title: 'Layout',
111
+ fields: [
112
+ { key: 'columns', label: 'Columns', type: 'slider', min: 1, max: 12, step: 1, defaultValue: 3 },
113
+ { key: 'gap', label: 'Gap', type: 'slider', min: 0, max: 16, step: 1, defaultValue: 4 },
114
+ { key: 'rowHeight', label: 'Row height', type: 'input', defaultValue: '120', placeholder: 'px' },
115
+ ],
116
+ },
117
+ {
118
+ key: 'data',
119
+ title: 'Data',
120
+ collapsible: true,
121
+ fields: [
122
+ { key: 'refreshInterval', label: 'Refresh interval', type: 'select', options: [
123
+ { value: '0', label: 'Manual' },
124
+ { value: '30', label: '30s' },
125
+ { value: '60', label: '1 min' },
126
+ { value: '300', label: '5 min' },
127
+ ], defaultValue: '0' },
128
+ { key: 'autoRefresh', label: 'Auto-refresh', type: 'switch', defaultValue: false },
129
+ ],
130
+ },
131
+ {
132
+ key: 'appearance',
133
+ title: 'Appearance',
134
+ collapsible: true,
135
+ defaultCollapsed: true,
136
+ fields: [
137
+ { key: 'showTitle', label: 'Show title', type: 'switch', defaultValue: true },
138
+ { key: 'showDescription', label: 'Show description', type: 'switch', defaultValue: true },
139
+ { key: 'theme', label: 'Theme', type: 'select', options: [
140
+ { value: 'light', label: 'Light' },
141
+ { value: 'dark', label: 'Dark' },
142
+ { value: 'auto', label: 'Auto' },
143
+ ], defaultValue: 'auto' },
144
+ ],
145
+ },
146
+ ],
147
+ };
148
+
149
+ function DashboardPanelStory() {
150
+ const source = { columns: 3, gap: 4, rowHeight: '120', refreshInterval: '0', autoRefresh: false, showTitle: true, showDescription: true, theme: 'auto' };
151
+ const { draft, isDirty, updateField, discard } = useConfigDraft(source);
152
+ return (
153
+ <div style={{ position: 'relative', height: 600, width: 320, border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden' }}>
154
+ <ConfigPanelRenderer
155
+ open={true}
156
+ onClose={() => alert('Close')}
157
+ schema={dashboardSchema}
158
+ draft={draft}
159
+ isDirty={isDirty}
160
+ onFieldChange={updateField}
161
+ onSave={() => alert('Saved!')}
162
+ onDiscard={discard}
163
+ />
164
+ </div>
165
+ );
166
+ }
167
+
168
+ export const DashboardConfig: Story = {
169
+ render: () => <DashboardPanelStory />,
170
+ };
171
+
172
+ // Custom field escape hatch
173
+ const customSchema: ConfigPanelSchema = {
174
+ breadcrumb: ['View', 'Config'],
175
+ sections: [
176
+ {
177
+ key: 'main',
178
+ title: 'General',
179
+ fields: [
180
+ { key: 'name', label: 'View name', type: 'input' },
181
+ {
182
+ key: 'rowHeight',
183
+ label: 'Row height',
184
+ type: 'custom',
185
+ render: (value, onChange) => (
186
+ <div style={{ display: 'flex', gap: 4, padding: '4px 0' }}>
187
+ {['compact', 'medium', 'tall'].map((h) => (
188
+ <button
189
+ key={h}
190
+ onClick={() => onChange(h)}
191
+ style={{
192
+ padding: '2px 8px',
193
+ fontSize: 11,
194
+ borderRadius: 4,
195
+ border: value === h ? '2px solid #3b82f6' : '1px solid #d1d5db',
196
+ background: value === h ? '#eff6ff' : 'transparent',
197
+ cursor: 'pointer',
198
+ }}
199
+ >
200
+ {h}
201
+ </button>
202
+ ))}
203
+ </div>
204
+ ),
205
+ },
206
+ ],
207
+ },
208
+ ],
209
+ };
210
+
211
+ function CustomFieldStory() {
212
+ const source = { name: 'My Grid View', rowHeight: 'medium' };
213
+ const { draft, isDirty, updateField, discard } = useConfigDraft(source);
214
+ return (
215
+ <div style={{ position: 'relative', height: 400, width: 320, border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden' }}>
216
+ <ConfigPanelRenderer
217
+ open={true}
218
+ onClose={() => {}}
219
+ schema={customSchema}
220
+ draft={draft}
221
+ isDirty={isDirty}
222
+ onFieldChange={updateField}
223
+ onSave={() => alert('Saved!')}
224
+ onDiscard={discard}
225
+ />
226
+ </div>
227
+ );
228
+ }
229
+
230
+ export const CustomFieldEscapeHatch: Story = {
231
+ render: () => <CustomFieldStory />,
232
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ /**
10
+ * Schema-driven config panel type definitions.
11
+ *
12
+ * Each concrete panel (View, Dashboard, Page…) provides a ConfigPanelSchema,
13
+ * and ConfigPanelRenderer auto-generates the UI.
14
+ */
15
+
16
+ /** Supported control types for config fields */
17
+ export type ControlType =
18
+ | 'input'
19
+ | 'switch'
20
+ | 'select'
21
+ | 'checkbox'
22
+ | 'slider'
23
+ | 'color'
24
+ | 'field-picker'
25
+ | 'filter'
26
+ | 'sort'
27
+ | 'icon-group'
28
+ | 'summary'
29
+ | 'custom';
30
+
31
+ /** A single field within a config section */
32
+ export interface ConfigField {
33
+ /** Field key in the draft object */
34
+ key: string;
35
+ /** Display label (may be an i18n key) */
36
+ label: string;
37
+ /** Control type determining which widget to render */
38
+ type: ControlType;
39
+ /** Default value for the field */
40
+ defaultValue?: any;
41
+ /** Select/icon-group options */
42
+ options?: Array<{ value: string; label: string; icon?: React.ReactNode }>;
43
+ /** Visibility predicate evaluated against the current draft */
44
+ visibleWhen?: (draft: Record<string, any>) => boolean;
45
+ /** Disabled predicate evaluated against the current draft */
46
+ disabledWhen?: (draft: Record<string, any>) => boolean;
47
+ /** Custom render function for type='custom' */
48
+ render?: (
49
+ value: any,
50
+ onChange: (v: any) => void,
51
+ draft: Record<string, any>,
52
+ ) => React.ReactNode;
53
+ /** Placeholder text for input/select controls */
54
+ placeholder?: string;
55
+ /** Help text displayed below the control */
56
+ helpText?: string;
57
+ /** Minimum value for slider */
58
+ min?: number;
59
+ /** Maximum value for slider */
60
+ max?: number;
61
+ /** Step value for slider */
62
+ step?: number;
63
+ /** Whether the field is disabled */
64
+ disabled?: boolean;
65
+ /** Field definitions for filter/sort sub-editors */
66
+ fields?: Array<{ value: string; label: string; type?: string; options?: Array<{ value: string; label: string }> }>;
67
+ /** Summary display text for type='summary' */
68
+ summaryText?: string;
69
+ /** Click handler for the summary gear/action button (type='summary') */
70
+ onSummaryClick?: () => void;
71
+ }
72
+
73
+ /** A group of related config fields */
74
+ export interface ConfigSection {
75
+ /** Unique section key (used for collapse state tracking) */
76
+ key: string;
77
+ /** Section title (may be an i18n key) */
78
+ title: string;
79
+ /** Hint text displayed below the title */
80
+ hint?: string;
81
+ /** Icon displayed before the section title (e.g. a Lucide icon element) */
82
+ icon?: React.ReactNode;
83
+ /** Whether this section supports collapse/expand */
84
+ collapsible?: boolean;
85
+ /** Default collapsed state */
86
+ defaultCollapsed?: boolean;
87
+ /** Fields belonging to this section */
88
+ fields: ConfigField[];
89
+ /** Nested sub-sections for complex grouping */
90
+ subsections?: ConfigSection[];
91
+ /** Visibility predicate evaluated against the current draft */
92
+ visibleWhen?: (draft: Record<string, any>) => boolean;
93
+ }
94
+
95
+ /** Top-level schema describing an entire config panel */
96
+ export interface ConfigPanelSchema {
97
+ /** Breadcrumb segments displayed in the panel header */
98
+ breadcrumb: string[];
99
+ /** Ordered list of sections */
100
+ sections: ConfigSection[];
101
+ }
package/src/ui/dialog.tsx CHANGED
@@ -46,14 +46,31 @@ const DialogContent = React.forwardRef<
46
46
  <DialogPrimitive.Content
47
47
  ref={ref}
48
48
  className={cn(
49
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
49
+ // Mobile-first: full-screen
50
+ "fixed inset-0 z-50 grid w-full bg-background p-4 shadow-lg duration-200",
51
+ "h-[100dvh]",
52
+ // Desktop (sm+): centered dialog with border + rounded corners
53
+ "sm:inset-auto sm:left-[50%] sm:top-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%]",
54
+ "sm:max-w-lg sm:h-auto sm:max-h-[90vh] sm:rounded-lg sm:border sm:p-6",
55
+ // Animations
56
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
57
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
58
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
59
+ "data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
60
+ "data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
50
61
  className
51
62
  )}
52
63
  {...props}
53
64
  >
54
65
  {children}
55
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
56
- <X className="h-4 w-4" />
66
+ <DialogPrimitive.Close className={cn(
67
+ "absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity",
68
+ "hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
69
+ "disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
70
+ // Mobile touch target ≥ 44×44px (WCAG 2.5.5)
71
+ "min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0 flex items-center justify-center",
72
+ )}>
73
+ <X className="h-5 w-5 sm:h-4 sm:w-4" />
57
74
  <span className="sr-only">Close</span>
58
75
  </DialogPrimitive.Close>
59
76
  </DialogPrimitive.Content>
package/src/ui/sheet.tsx CHANGED
@@ -59,14 +59,17 @@ const sheetVariants = cva(
59
59
 
60
60
  interface SheetContentProps
61
61
  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
62
- VariantProps<typeof sheetVariants> {}
62
+ VariantProps<typeof sheetVariants> {
63
+ /** When true, the backdrop overlay is not rendered (useful for non-modal drawers) */
64
+ hideOverlay?: boolean;
65
+ }
63
66
 
64
67
  const SheetContent = React.forwardRef<
65
68
  React.ElementRef<typeof SheetPrimitive.Content>,
66
69
  SheetContentProps
67
- >(({ side = "right", className, children, ...props }, ref) => (
70
+ >(({ side = "right", className, children, hideOverlay, ...props }, ref) => (
68
71
  <SheetPortal>
69
- <SheetOverlay />
72
+ {!hideOverlay && <SheetOverlay />}
70
73
  <SheetPrimitive.Content
71
74
  ref={ref}
72
75
  className={cn(sheetVariants({ side }), className)}
@@ -39,6 +39,11 @@ const SIDEBAR_WIDTH = "16rem"
39
39
  const SIDEBAR_WIDTH_MOBILE = "18rem"
40
40
  const SIDEBAR_WIDTH_ICON = "3rem"
41
41
  const SIDEBAR_KEYBOARD_SHORTCUT = "b"
42
+ const SIDEBAR_WIDTH_STORAGE_KEY = "sidebar_width"
43
+ const SIDEBAR_MIN_WIDTH = 200 // px
44
+ const SIDEBAR_MAX_WIDTH = 480 // px
45
+ const SIDEBAR_DEFAULT_WIDTH_PX = 256 // 16rem in px
46
+ const SIDEBAR_CLICK_THRESHOLD_PX = 5
42
47
 
43
48
  type SidebarContextProps = {
44
49
  state: "expanded" | "collapsed"
@@ -48,6 +53,10 @@ type SidebarContextProps = {
48
53
  setOpenMobile: (open: boolean) => void
49
54
  isMobile: boolean
50
55
  toggleSidebar: () => void
56
+ /** Current sidebar width in pixels (only used when resizable) */
57
+ sidebarWidth: number | null
58
+ /** Update sidebar width (only used when resizable) */
59
+ setSidebarWidth: (width: number | null) => void
51
60
  }
52
61
 
53
62
  const SidebarContext = React.createContext<SidebarContextProps | null>(null)
@@ -84,6 +93,27 @@ const SidebarProvider = React.forwardRef<
84
93
  const isMobile = useIsMobile()
85
94
  const [openMobile, setOpenMobile] = React.useState(false)
86
95
 
96
+ // Resizable sidebar width state (persisted to localStorage)
97
+ const [sidebarWidth, setSidebarWidthState] = React.useState<number | null>(() => {
98
+ try {
99
+ const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)
100
+ if (!stored) return null
101
+ const value = Number(stored)
102
+ if (isNaN(value)) return null
103
+ return Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, value))
104
+ } catch { return null }
105
+ })
106
+ const setSidebarWidth = React.useCallback((width: number | null) => {
107
+ setSidebarWidthState(width)
108
+ try {
109
+ if (width != null) {
110
+ localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width))
111
+ } else {
112
+ localStorage.removeItem(SIDEBAR_WIDTH_STORAGE_KEY)
113
+ }
114
+ } catch { /* ignore */ }
115
+ }, [])
116
+
87
117
  // This is the internal state of the sidebar.
88
118
  // We use openProp and setOpenProp for control from outside the component.
89
119
  const [_open, _setOpen] = React.useState(defaultOpen)
@@ -139,8 +169,10 @@ const SidebarProvider = React.forwardRef<
139
169
  openMobile,
140
170
  setOpenMobile,
141
171
  toggleSidebar,
172
+ sidebarWidth,
173
+ setSidebarWidth,
142
174
  }),
143
- [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
175
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, sidebarWidth, setSidebarWidth]
144
176
  )
145
177
 
146
178
  return (
@@ -149,7 +181,7 @@ const SidebarProvider = React.forwardRef<
149
181
  <div
150
182
  style={
151
183
  {
152
- "--sidebar-width": SIDEBAR_WIDTH,
184
+ "--sidebar-width": sidebarWidth ? `${sidebarWidth}px` : SIDEBAR_WIDTH,
153
185
  "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
154
186
  ...style,
155
187
  } as React.CSSProperties
@@ -307,7 +339,47 @@ const SidebarRail = React.forwardRef<
307
339
  HTMLButtonElement,
308
340
  React.ComponentProps<"button">
309
341
  >(({ className, ...props }, ref) => {
310
- const { toggleSidebar } = useSidebar()
342
+ const { toggleSidebar, setSidebarWidth } = useSidebar()
343
+ const dragging = React.useRef(false)
344
+ const startX = React.useRef(0)
345
+ const startWidth = React.useRef(0)
346
+
347
+ const handlePointerDown = React.useCallback((e: React.PointerEvent) => {
348
+ // Only initiate resize on left mouse button
349
+ if (e.button !== 0) return
350
+ e.preventDefault()
351
+ dragging.current = true
352
+ startX.current = e.clientX
353
+
354
+ // Get the current sidebar width from computed CSS variable
355
+ const wrapper = (e.target as HTMLElement).closest('[style*="--sidebar-width"]') as HTMLElement | null
356
+ startWidth.current = wrapper
357
+ ? parseInt(getComputedStyle(wrapper).getPropertyValue('--sidebar-width')) || SIDEBAR_DEFAULT_WIDTH_PX
358
+ : SIDEBAR_DEFAULT_WIDTH_PX
359
+
360
+ const onPointerMove = (ev: PointerEvent) => {
361
+ if (!dragging.current) return
362
+ const delta = ev.clientX - startX.current
363
+ const newWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, startWidth.current + delta))
364
+ setSidebarWidth(newWidth)
365
+ }
366
+
367
+ const onPointerUp = () => {
368
+ dragging.current = false
369
+ document.removeEventListener('pointermove', onPointerMove)
370
+ document.removeEventListener('pointerup', onPointerUp)
371
+ }
372
+
373
+ document.addEventListener('pointermove', onPointerMove)
374
+ document.addEventListener('pointerup', onPointerUp)
375
+ }, [setSidebarWidth])
376
+
377
+ const handleClick = React.useCallback((e: React.MouseEvent) => {
378
+ // Only toggle on click (not at end of drag)
379
+ if (Math.abs(e.clientX - startX.current) < SIDEBAR_CLICK_THRESHOLD_PX) {
380
+ toggleSidebar()
381
+ }
382
+ }, [toggleSidebar])
311
383
 
312
384
  return (
313
385
  <button
@@ -315,11 +387,13 @@ const SidebarRail = React.forwardRef<
315
387
  data-sidebar="rail"
316
388
  aria-label="Toggle Sidebar"
317
389
  tabIndex={-1}
318
- onClick={toggleSidebar}
319
- title="Toggle Sidebar"
390
+ onPointerDown={handlePointerDown}
391
+ onClick={handleClick}
392
+ onDoubleClick={() => setSidebarWidth(null)}
393
+ title="Drag to resize, click to toggle, double-click to reset"
320
394
  className={cn(
321
395
  "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
322
- "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
396
+ "[[data-side=left]_&]:cursor-col-resize [[data-side=right]_&]:cursor-col-resize",
323
397
  "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
324
398
  "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
325
399
  "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
@@ -340,7 +414,7 @@ const SidebarInset = React.forwardRef<
340
414
  <main
341
415
  ref={ref}
342
416
  className={cn(
343
- "relative flex w-full flex-1 flex-col bg-background",
417
+ "relative flex min-w-0 w-full flex-1 flex-col bg-background overflow-hidden",
344
418
  "md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
345
419
  className
346
420
  )}
@@ -457,7 +531,7 @@ const SidebarGroupLabel = React.forwardRef<
457
531
  ref={ref}
458
532
  data-sidebar="group-label"
459
533
  className={cn(
460
- "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
534
+ "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0 border-t border-border/30 pt-3 mt-2 first:border-t-0 first:pt-0 first:mt-0",
461
535
  "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
462
536
  className
463
537
  )}
@@ -530,7 +604,17 @@ const SidebarMenuItem = React.forwardRef<
530
604
  SidebarMenuItem.displayName = "SidebarMenuItem"
531
605
 
532
606
  const sidebarMenuButtonVariants = cva(
533
- "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
607
+ [
608
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding,color,background-color] duration-150",
609
+ "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground",
610
+ "disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50",
611
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground",
612
+ "data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground",
613
+ "group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
614
+ // Active indicator bar (3px left accent)
615
+ "relative data-[active=true]:before:absolute data-[active=true]:before:left-0 data-[active=true]:before:top-1/2 data-[active=true]:before:-translate-y-1/2",
616
+ "data-[active=true]:before:h-4 data-[active=true]:before:w-[3px] data-[active=true]:before:rounded-full data-[active=true]:before:bg-primary",
617
+ ].join(" "),
534
618
  {
535
619
  variants: {
536
620
  variant: {