@softize/opus 18.1.1 → 18.3.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.
@@ -164,7 +164,10 @@ export function PageShell({
164
164
  <PageShellContext.Provider value>
165
165
  <PageNavigationTargetContext.Provider value={navigationTarget}>
166
166
  <PageActionsTarget target={actionsTarget}>
167
- <div data-slot="page-shell-content" className="min-h-0 flex-1">
167
+ <div
168
+ data-slot="page-shell-content"
169
+ className="min-h-0 min-w-0 flex-1 overflow-auto [&>*]:min-w-[64rem]"
170
+ >
168
171
  {children}
169
172
  </div>
170
173
  </PageActionsTarget>
@@ -202,6 +205,8 @@ interface PageBaseProps extends Omit<
202
205
  > {
203
206
  /** Classes do container. Default: centralizado com `max-w-7xl` (80rem). */
204
207
  className?: string;
208
+ /** `fill` limita a página à altura do shell para que regiões internas assumam a rolagem. */
209
+ layout?: "flow" | "fill";
205
210
  }
206
211
 
207
212
  interface PageShorthandProps extends PageBaseProps {
@@ -252,8 +257,7 @@ function countPageBodyHeadings(node: ReactNode): {
252
257
  total.content +
253
258
  (child.type === Content && props.level === 1 ? 1 : 0) +
254
259
  nested.content,
255
- state:
256
- total.state + nested.state,
260
+ state: total.state + nested.state,
257
261
  };
258
262
  },
259
263
  { content: 0, state: 0 },
@@ -275,6 +279,7 @@ function hasOpaquePageBodyContent(node: ReactNode): boolean {
275
279
  export function Page({
276
280
  title,
277
281
  actions,
282
+ layout = "flow",
278
283
  className,
279
284
  children,
280
285
  ...props
@@ -372,7 +377,8 @@ export function Page({
372
377
  (total, intro) =>
373
378
  total + countDirectChildrenOfType(intro as ReactElement, PageTitle),
374
379
  0,
375
- ) + bodyHeadingSources.content;
380
+ ) +
381
+ bodyHeadingSources.content;
376
382
  const headingSources =
377
383
  bodyHeadingSources.state > 0
378
384
  ? bodyHeadingSources.state + bodyHeadingSources.content
@@ -429,9 +435,12 @@ export function Page({
429
435
  <PageHeadingPresenceProvider register={registerHeading}>
430
436
  <main
431
437
  data-slot="page"
438
+ data-layout={layout}
432
439
  className={cn(
433
- "min-w-0 flex-1",
440
+ "min-w-[64rem] flex-1",
434
441
  insideShell && "min-h-full",
442
+ layout === "fill" &&
443
+ "flex h-full min-h-0 flex-col overflow-hidden",
435
444
  integralState && "flex min-h-full flex-col",
436
445
  )}
437
446
  {...props}
@@ -439,6 +448,8 @@ export function Page({
439
448
  <div
440
449
  className={cn(
441
450
  "mx-auto max-w-7xl space-y-6 px-8 py-8",
451
+ layout === "fill" &&
452
+ "flex h-full min-h-0 w-full flex-1 flex-col",
442
453
  integralState &&
443
454
  "flex min-h-full w-full flex-1 flex-col space-y-0",
444
455
  className,
@@ -1,13 +1,15 @@
1
1
  import {
2
2
  useCallback,
3
+ useEffect,
3
4
  useState,
4
5
  type ReactElement,
5
6
  type ReactNode,
6
7
  } from "react";
7
8
  import { createPortal } from "react-dom";
8
- import { ArrowLeft, Braces } from "lucide-react";
9
+ import { ArrowLeft, Braces, MessageCircle } from "lucide-react";
9
10
  import type {
10
11
  PresentationActionRegistry,
12
+ PresentationActionBody,
11
13
  PresentationBindingContext,
12
14
  PresentationCommand,
13
15
  PresentationDiagnostic,
@@ -19,6 +21,7 @@ import type {
19
21
  import {
20
22
  applyPresentationEffects,
21
23
  createPresentationInspectionSnapshot,
24
+ isPresentationActionBody,
22
25
  openPresentation,
23
26
  resolvePresentationBindings,
24
27
  } from "../../../core/presentation.ts";
@@ -75,9 +78,11 @@ import {
75
78
  ContentHeader,
76
79
  ContentTitle,
77
80
  } from "./content-header.tsx";
81
+ import { SurfaceAssistant } from "./surface-assistant.tsx";
78
82
 
79
83
  interface PresentationFrameProps {
80
84
  surface: PresentationSurface;
85
+ layout?: "flow" | "fill";
81
86
  title: ReactNode;
82
87
  navigation?: ReactNode;
83
88
  headerActions?: ReactNode;
@@ -90,6 +95,11 @@ interface PresentationFrameProps {
90
95
  className?: string;
91
96
  bodyClassName?: string;
92
97
  listBody?: boolean;
98
+ contentOwnsHeading?: boolean;
99
+ assistantOpen?: boolean;
100
+ assistantLabel?: string;
101
+ assistantAction?: ReactNode;
102
+ assistantPanel?: ReactNode;
93
103
  }
94
104
 
95
105
  const bodyClassName =
@@ -112,6 +122,7 @@ function PresentationActions({
112
122
  /** A mesma Presentation em Page, Dialog ou Drawer; a superfície não redefine o recurso. */
113
123
  function PresentationFrame({
114
124
  surface,
125
+ layout = "flow",
115
126
  title,
116
127
  navigation,
117
128
  headerActions,
@@ -124,6 +135,11 @@ function PresentationFrame({
124
135
  className,
125
136
  bodyClassName: bodyClassNameProp,
126
137
  listBody = false,
138
+ contentOwnsHeading = false,
139
+ assistantOpen = false,
140
+ assistantLabel,
141
+ assistantAction,
142
+ assistantPanel,
127
143
  }: PresentationFrameProps): ReactElement {
128
144
  const insidePageShell = useInsidePageShell();
129
145
  const [footerTarget, setFooterTarget] = useState<HTMLElement | null>(null);
@@ -148,11 +164,29 @@ function PresentationFrame({
148
164
  const body = (
149
165
  <div
150
166
  data-slot="presentation-body"
151
- className={cn(bodyClassName, bodyClassNameProp)}
167
+ className={cn(
168
+ bodyClassName,
169
+ layout === "fill" && "flex min-h-0 flex-1 flex-col",
170
+ bodyClassNameProp,
171
+ )}
152
172
  >
153
173
  {typeof children === "function" ? children(footerTarget) : children}
154
174
  </div>
155
175
  );
176
+ const withAssistant = (content: ReactElement): ReactElement =>
177
+ assistantLabel === undefined || assistantPanel === undefined ? (
178
+ content
179
+ ) : (
180
+ <SurfaceAssistant
181
+ open={assistantOpen}
182
+ assistantLabel={assistantLabel}
183
+ assistant={assistantPanel}
184
+ className="min-h-0 flex-1"
185
+ contentClassName="flex min-h-0 flex-col"
186
+ >
187
+ {content}
188
+ </SurfaceAssistant>
189
+ );
156
190
 
157
191
  if (surface === "page") {
158
192
  if (listBody) {
@@ -170,14 +204,26 @@ function PresentationFrame({
170
204
  </Content>
171
205
  );
172
206
  return (
173
- <Page className={cn("flex min-h-full flex-col", className)}>
174
- {navigation === undefined ? null : (
207
+ <Page
208
+ layout={layout}
209
+ className={cn("flex min-h-full flex-col", className)}
210
+ >
211
+ {navigation === undefined && assistantAction === undefined ? null : (
175
212
  <PageHeader>
176
- <PageNavigation>{navigation}</PageNavigation>
213
+ {navigation === undefined ? null : (
214
+ <PageNavigation>{navigation}</PageNavigation>
215
+ )}
216
+ {assistantAction === undefined ? null : (
217
+ <PageActions>{assistantAction}</PageActions>
218
+ )}
177
219
  </PageHeader>
178
220
  )}
179
- <PageBody className="min-h-0 flex-1 overflow-y-auto">
180
- {content}
221
+ <PageBody className="min-h-0 flex-1 overflow-hidden">
222
+ {withAssistant(
223
+ <div className="h-full overflow-x-hidden overflow-y-auto">
224
+ {content}
225
+ </div>,
226
+ )}
181
227
  </PageBody>
182
228
  {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
183
229
  </Page>
@@ -185,29 +231,52 @@ function PresentationFrame({
185
231
  }
186
232
  if (insidePageShell) {
187
233
  return (
188
- <Page className={cn("flex min-h-full flex-col", className)}>
189
- {navigation === undefined && headerActions === undefined ? null : (
234
+ <Page
235
+ layout={layout}
236
+ className={cn("flex min-h-full flex-col", className)}
237
+ >
238
+ {navigation === undefined &&
239
+ headerActions === undefined &&
240
+ assistantAction === undefined ? null : (
190
241
  <PageHeader>
191
242
  {navigation === undefined ? null : (
192
243
  <PageNavigation>{navigation}</PageNavigation>
193
244
  )}
194
- {headerActions === undefined ? null : (
245
+ {headerActions === undefined &&
246
+ assistantAction === undefined ? null : (
195
247
  <PageActions>
196
- <PresentationActions>{headerActions}</PresentationActions>
248
+ <PresentationActions>
249
+ {headerActions}
250
+ {assistantAction}
251
+ </PresentationActions>
197
252
  </PageActions>
198
253
  )}
199
254
  </PageHeader>
200
255
  )}
201
- <PageIntro>
202
- <PageTitle>{title}</PageTitle>
203
- </PageIntro>
204
- <PageBody className="min-h-0 flex-1 overflow-y-auto">{body}</PageBody>
256
+ <PageBody className="min-h-0 flex-1 overflow-hidden">
257
+ {withAssistant(
258
+ <div
259
+ className={cn(
260
+ "flex h-full min-h-0 flex-col overflow-x-hidden",
261
+ layout === "fill" ? "overflow-y-hidden" : "overflow-y-auto",
262
+ )}
263
+ >
264
+ {contentOwnsHeading ? null : (
265
+ <PageIntro>
266
+ <PageTitle>{title}</PageTitle>
267
+ </PageIntro>
268
+ )}
269
+ {body}
270
+ </div>,
271
+ )}
272
+ </PageBody>
205
273
  {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
206
274
  </Page>
207
275
  );
208
276
  }
209
277
  return (
210
278
  <Page
279
+ layout={layout}
211
280
  className={cn(
212
281
  "flex min-h-full max-w-none flex-col space-y-0 px-0 py-0",
213
282
  className,
@@ -218,14 +287,22 @@ function PresentationFrame({
218
287
  <PageNavigation>{navigation}</PageNavigation>
219
288
  )}
220
289
  <PageTitle className="text-lg leading-none">{title}</PageTitle>
221
- {headerActions === undefined ? null : (
290
+ {headerActions === undefined &&
291
+ assistantAction === undefined ? null : (
222
292
  <PageActions>
223
- <PresentationActions>{headerActions}</PresentationActions>
293
+ <PresentationActions>
294
+ {headerActions}
295
+ {assistantAction}
296
+ </PresentationActions>
224
297
  </PageActions>
225
298
  )}
226
299
  </PageHeader>
227
- <PageBody className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
228
- {body}
300
+ <PageBody className="min-h-0 flex-1 overflow-hidden">
301
+ {withAssistant(
302
+ <div className="h-full overflow-x-hidden overflow-y-auto px-8 py-8">
303
+ {body}
304
+ </div>,
305
+ )}
229
306
  </PageBody>
230
307
  {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
231
308
  </Page>
@@ -242,7 +319,10 @@ function PresentationFrame({
242
319
  }}
243
320
  >
244
321
  <DialogContent
245
- className={className}
322
+ className={cn(
323
+ assistantOpen ? "h-[min(48rem,85vh)] max-w-5xl" : undefined,
324
+ className,
325
+ )}
246
326
  aria-describedby={undefined}
247
327
  closeDisabled={blocked}
248
328
  onEscapeKeyDown={(event) => {
@@ -252,17 +332,23 @@ function PresentationFrame({
252
332
  if (blocked) event.preventDefault();
253
333
  }}
254
334
  >
255
- <DialogHeader>
256
- {navigation}
257
- <DialogTitle className="truncate">{title}</DialogTitle>
258
- {headerActions === undefined ? null : (
259
- <PresentationActions>{headerActions}</PresentationActions>
260
- )}
261
- </DialogHeader>
262
- <DialogBody>{body}</DialogBody>
263
- {hasFooter ? (
264
- <DialogFooter>{renderFooterActions()}</DialogFooter>
265
- ) : null}
335
+ <div className="flex h-full min-h-0 flex-col">
336
+ <DialogHeader>
337
+ {navigation}
338
+ <DialogTitle className="truncate">{title}</DialogTitle>
339
+ {headerActions === undefined &&
340
+ assistantAction === undefined ? null : (
341
+ <PresentationActions>
342
+ {headerActions}
343
+ {assistantAction}
344
+ </PresentationActions>
345
+ )}
346
+ </DialogHeader>
347
+ {withAssistant(<DialogBody>{body}</DialogBody>)}
348
+ {hasFooter ? (
349
+ <DialogFooter>{renderFooterActions()}</DialogFooter>
350
+ ) : null}
351
+ </div>
266
352
  </DialogContent>
267
353
  </Dialog>
268
354
  );
@@ -277,7 +363,12 @@ function PresentationFrame({
277
363
  }}
278
364
  >
279
365
  <DrawerContent
280
- className={className}
366
+ className={cn(
367
+ assistantOpen
368
+ ? "w-[min(80rem,calc(100%-2rem))] max-w-none"
369
+ : undefined,
370
+ className,
371
+ )}
281
372
  aria-describedby={undefined}
282
373
  closeDisabled={blocked}
283
374
  onEscapeKeyDown={(event) => {
@@ -287,22 +378,33 @@ function PresentationFrame({
287
378
  if (blocked) event.preventDefault();
288
379
  }}
289
380
  >
290
- <DrawerHeader>
291
- {navigation}
292
- <DrawerTitle className="truncate">{title}</DrawerTitle>
293
- {headerActions === undefined ? null : (
294
- <PresentationActions>{headerActions}</PresentationActions>
295
- )}
296
- </DrawerHeader>
297
- <DrawerBody>{body}</DrawerBody>
298
- {hasFooter ? (
299
- <DrawerFooter>{renderFooterActions()}</DrawerFooter>
300
- ) : null}
381
+ <div className="flex h-full min-h-0 flex-col">
382
+ <DrawerHeader>
383
+ {navigation}
384
+ <DrawerTitle className="truncate">{title}</DrawerTitle>
385
+ {headerActions === undefined &&
386
+ assistantAction === undefined ? null : (
387
+ <PresentationActions>
388
+ {headerActions}
389
+ {assistantAction}
390
+ </PresentationActions>
391
+ )}
392
+ </DrawerHeader>
393
+ {withAssistant(<DrawerBody>{body}</DrawerBody>)}
394
+ {hasFooter ? (
395
+ <DrawerFooter>{renderFooterActions()}</DrawerFooter>
396
+ ) : null}
397
+ </div>
301
398
  </DrawerContent>
302
399
  </Drawer>
303
400
  );
304
401
  }
305
402
 
403
+ export interface PresentationAssistantRuntime {
404
+ icon?: ReactNode;
405
+ render: (controls: { close: () => void }) => ReactNode;
406
+ }
407
+
306
408
  export interface PresentationProps {
307
409
  /** Definição estática registrada e publicada no manifest. */
308
410
  definition: PresentationDefinition;
@@ -325,6 +427,12 @@ export interface PresentationProps {
325
427
  onOpenChange?: (open: boolean) => void;
326
428
  className?: string;
327
429
  bodyClassName?: string;
430
+ /** `fill` limita a página ao shell e delega a rolagem ao conteúdo React. */
431
+ layout?: "flow" | "fill";
432
+ /** Conteúdo React da boundary declarada por `body.component`. */
433
+ children?: ReactNode;
434
+ /** Painel runtime; a página fecha sobre os dados do recurso que ela carregou. */
435
+ assistant?: PresentationAssistantRuntime;
328
436
  }
329
437
 
330
438
  function actionLabel(action: { name: string; label?: unknown }): string {
@@ -394,6 +502,9 @@ export function Presentation({
394
502
  onOpenChange,
395
503
  className,
396
504
  bodyClassName: bodyClassNameProp,
505
+ layout = "flow",
506
+ children,
507
+ assistant,
397
508
  }: PresentationProps): ReactElement {
398
509
  if (definition.id !== invocation.presentationId) {
399
510
  throw new Error(
@@ -404,16 +515,17 @@ export function Presentation({
404
515
  ReadonlySet<PresentationCommand>
405
516
  >(() => new Set());
406
517
  const [bodyLoading, setBodyLoading] = useState(false);
518
+ const [assistantOpen, setAssistantOpen] = useState(false);
519
+ useEffect(() => {
520
+ setAssistantOpen(false);
521
+ }, [definition.id, invocation.surface]);
407
522
  const context: PresentationBindingContext = {
408
523
  ...bindingContext,
409
524
  route: bindingContext?.route ?? invocation.input,
410
525
  };
411
526
 
412
527
  const applyEffects = useCallback(
413
- (
414
- effects: PresentationDefinition["body"]["onSuccess"],
415
- result?: unknown,
416
- ): void => {
528
+ (effects: PresentationActionBody["onSuccess"], result?: unknown): void => {
417
529
  const next = applyPresentationEffects(
418
530
  invocation,
419
531
  effects,
@@ -541,23 +653,51 @@ export function Presentation({
541
653
  );
542
654
  };
543
655
 
544
- const bodyAction = actions[definition.body.action];
545
- if (bodyAction === undefined) {
546
- throw new Error(`Action “${definition.body.action}” não registrada.`);
656
+ const actionBody = isPresentationActionBody(definition.body)
657
+ ? definition.body
658
+ : null;
659
+ const bodyAction = actionBody === null ? null : actions[actionBody.action];
660
+ if (actionBody !== null && bodyAction === undefined) {
661
+ throw new Error(`Action “${actionBody.action}” não registrada.`);
662
+ }
663
+ if (actionBody === null && children === undefined) {
664
+ throw new Error(
665
+ `A Presentation “${definition.id}” declara body.component e exige children.`,
666
+ );
667
+ }
668
+ if (actionBody !== null && children !== undefined) {
669
+ throw new Error(
670
+ `A Presentation “${definition.id}” usa body.action e não aceita children.`,
671
+ );
547
672
  }
548
- const bodyInput = resolvePresentationBindings(
549
- definition.body.input,
550
- context,
551
- ) as Record<string, unknown>;
673
+ if (definition.assistant !== undefined && assistant === undefined) {
674
+ throw new Error(
675
+ `A Presentation “${definition.id}” declara assistência e exige a prop assistant.`,
676
+ );
677
+ }
678
+ if (definition.assistant === undefined && assistant !== undefined) {
679
+ throw new Error(
680
+ `A Presentation “${definition.id}” não declara assistência e não aceita a prop assistant.`,
681
+ );
682
+ }
683
+ const bodyInput =
684
+ actionBody === null
685
+ ? {}
686
+ : (resolvePresentationBindings(actionBody.input, context) as Record<
687
+ string,
688
+ unknown
689
+ >);
552
690
  const renderBody = (footerTarget: HTMLElement | null): ReactNode => {
691
+ if (actionBody === null) return children;
692
+ if (bodyAction === null) return null;
553
693
  if (bodyAction.kind === "form") {
554
694
  return (
555
695
  <ActionForm
556
696
  action={bodyAction as FormContract<Record<string, unknown>, unknown>}
557
697
  defaultValues={bodyInput}
558
- submitLabel={definition.body.submitLabel}
698
+ submitLabel={actionBody.submitLabel}
559
699
  onCancel={invocation.surface === "page" ? undefined : closeSurface}
560
- onSuccess={(data) => applyEffects(definition.body.onSuccess, data)}
700
+ onSuccess={(data) => applyEffects(actionBody.onSuccess, data)}
561
701
  disabled={hasSurfaceBlock}
562
702
  onLoadingChange={setBodyLoading}
563
703
  footer={(actions) =>
@@ -578,14 +718,14 @@ export function Presentation({
578
718
  input={bodyInput}
579
719
  state={listState}
580
720
  onStateChange={onListStateChange}
581
- {...(definition.body.open === undefined
721
+ {...(actionBody.open === undefined
582
722
  ? {}
583
723
  : {
584
724
  onRowClick: (item: Record<string, unknown>) =>
585
725
  openTarget(
586
- definition.body.open!.presentation,
587
- definition.body.open!.surface,
588
- definition.body.open!.input,
726
+ actionBody.open!.presentation,
727
+ actionBody.open!.surface,
728
+ actionBody.open!.input,
589
729
  { ...context, item },
590
730
  ),
591
731
  })}
@@ -593,7 +733,7 @@ export function Presentation({
593
733
  );
594
734
  }
595
735
  if (bodyAction.kind === "view") {
596
- const fields = definition.body.fields ?? [];
736
+ const fields = actionBody.fields ?? [];
597
737
  return (
598
738
  <ActionView
599
739
  action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
@@ -641,10 +781,27 @@ export function Presentation({
641
781
  <ArrowLeft aria-hidden />
642
782
  </Button>
643
783
  );
784
+ const assistantAction =
785
+ definition.assistant === undefined || assistantOpen ? undefined : (
786
+ <Button
787
+ size="icon"
788
+ variant="ghost"
789
+ aria-label={definition.assistant.triggerLabel}
790
+ title={definition.assistant.triggerLabel}
791
+ onClick={() => setAssistantOpen(true)}
792
+ >
793
+ {assistant?.icon ?? <MessageCircle aria-hidden />}
794
+ </Button>
795
+ );
796
+ const assistantPanel =
797
+ definition.assistant === undefined || assistant === undefined
798
+ ? undefined
799
+ : assistant.render({ close: () => setAssistantOpen(false) });
644
800
 
645
801
  return (
646
802
  <PresentationFrame
647
803
  surface={invocation.surface}
804
+ layout={layout}
648
805
  title={definition.title}
649
806
  navigation={navigation}
650
807
  headerActions={headerActions.length === 0 ? undefined : headerActions}
@@ -659,9 +816,17 @@ export function Presentation({
659
816
  }}
660
817
  className={className}
661
818
  bodyClassName={bodyClassNameProp}
662
- hasBodyFooter={bodyAction.kind === "form"}
819
+ hasBodyFooter={bodyAction?.kind === "form"}
663
820
  blocked={surfaceBlocked}
664
- listBody={bodyAction.kind === "list"}
821
+ listBody={bodyAction?.kind === "list"}
822
+ contentOwnsHeading={
823
+ !isPresentationActionBody(definition.body) &&
824
+ definition.body.heading === "content"
825
+ }
826
+ assistantOpen={assistantOpen}
827
+ assistantLabel={definition.assistant?.triggerLabel}
828
+ assistantAction={assistantAction}
829
+ assistantPanel={assistantPanel}
665
830
  >
666
831
  {renderBody}
667
832
  </PresentationFrame>
@@ -48,6 +48,8 @@ export interface SplitProps {
48
48
  /** Chamado ao concluir uma mudança de layout; pode persistir o resultado em localStorage. */
49
49
  onLayoutChanged?: (layout: SplitLayout, meta: SplitLayoutChange) => void
50
50
  className?: string
51
+ /** Identificador semântico opcional para testes e composição de patterns. */
52
+ 'data-slot'?: string
51
53
  children: ReactNode
52
54
  }
53
55
 
@@ -86,6 +88,7 @@ export function Split({
86
88
  defaultLayout,
87
89
  onLayoutChanged,
88
90
  className,
91
+ 'data-slot': dataSlot = 'split',
89
92
  children,
90
93
  }: SplitProps): React.ReactElement {
91
94
  const panes = panesOf(children)
@@ -93,7 +96,7 @@ export function Split({
93
96
 
94
97
  if (!resizable) {
95
98
  return (
96
- <div id={id === undefined ? undefined : String(id)} data-slot="split" data-direction={direction} className={cn('flex min-h-0 min-w-0 flex-1', vertical && 'flex-col', className)}>
99
+ <div id={id === undefined ? undefined : String(id)} data-slot={dataSlot} data-direction={direction} className={cn('flex min-h-0 min-w-0 flex-1', vertical && 'flex-col', className)}>
97
100
  {panes}
98
101
  </div>
99
102
  )
@@ -111,7 +114,7 @@ export function Split({
111
114
  orientation={direction}
112
115
  defaultLayout={defaultLayout}
113
116
  onLayoutChanged={onLayoutChanged}
114
- data-slot="split"
117
+ data-slot={dataSlot}
115
118
  className={cn('min-h-0 min-w-0 flex-1', className)}
116
119
  >
117
120
  {panes.flatMap((pane, index) => {
@@ -0,0 +1,74 @@
1
+ import type { ReactNode } from "react";
2
+ import { cn } from "../../lib/cn.ts";
3
+ import { Pane, type PaneSize, Split } from "./split.tsx";
4
+
5
+ export interface SurfaceAssistantProps {
6
+ /** Controla somente a presença visual do painel; o conteúdo pertence ao consumidor. */
7
+ open: boolean;
8
+ /** Nome acessível da região complementar. */
9
+ assistantLabel: string;
10
+ /** Conteúdo principal que permanece visível à esquerda. */
11
+ children: ReactNode;
12
+ /** Painel fornecido pela superfície que conhece o contexto do recurso. */
13
+ assistant: ReactNode;
14
+ initialSize?: PaneSize;
15
+ minSize?: PaneSize;
16
+ maxSize?: PaneSize;
17
+ className?: string;
18
+ contentClassName?: string;
19
+ assistantClassName?: string;
20
+ }
21
+
22
+ /**
23
+ * Mantém conteúdo e assistência contextual na mesma superfície. O host decide abertura,
24
+ * trigger e dados; o pattern preserva a relação espacial e o redimensionamento.
25
+ */
26
+ export function SurfaceAssistant({
27
+ open,
28
+ assistantLabel,
29
+ children,
30
+ assistant,
31
+ initialSize = "28rem",
32
+ minSize = "20rem",
33
+ maxSize = "40rem",
34
+ className,
35
+ contentClassName,
36
+ assistantClassName,
37
+ }: SurfaceAssistantProps): React.ReactElement {
38
+ return (
39
+ <Split
40
+ resizable
41
+ data-slot="surface-assistant"
42
+ className={cn("h-full", className)}
43
+ >
44
+ <Pane
45
+ key="surface-content"
46
+ id="surface-content"
47
+ grow
48
+ inset="none"
49
+ className={cn("h-full", contentClassName)}
50
+ >
51
+ {children}
52
+ </Pane>
53
+ {open ? (
54
+ <Pane
55
+ key="surface-assistant"
56
+ id="surface-assistant"
57
+ initialSize={initialSize}
58
+ minSize={minSize}
59
+ maxSize={maxSize}
60
+ inset="none"
61
+ className={cn("h-full bg-background", assistantClassName)}
62
+ >
63
+ <aside
64
+ data-slot="surface-assistant-panel"
65
+ aria-label={assistantLabel}
66
+ className="h-full min-w-0 overflow-hidden"
67
+ >
68
+ {assistant}
69
+ </aside>
70
+ </Pane>
71
+ ) : null}
72
+ </Split>
73
+ );
74
+ }