@designa8/vibedesign-stripe-deepseek-2-176b 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @designa8/vibedesign-stripe-deepseek-2-176b
2
2
 
3
- Complete design system generated by Atoms Design, version **0.1.2**.
3
+ Complete design system generated by Atoms Design, version **0.2.0**.
4
4
  Tokens, atoms, molecules, organisms, and templates — import from the package root;
5
5
  never hardcode visual values in consumer code.
6
6
 
package/dist/styles.css CHANGED
@@ -19,7 +19,6 @@
19
19
  --container-3xl: 48rem;
20
20
  --container-4xl: 56rem;
21
21
  --container-5xl: 64rem;
22
- --container-6xl: 72rem;
23
22
  --font-weight-normal: 400;
24
23
  --tracking-wide: 0.025em;
25
24
  --radius-xs: 4px;
@@ -162,6 +161,8 @@
162
161
  --container-dialog: 420px;
163
162
  --container-sidebar: 240px;
164
163
  --container-sidebar-rail: 72px;
164
+ --container-palette: 640px;
165
+ --container-dialog-md: 560px;
165
166
  --grid-template-columns-system: repeat(12, minmax(0, 1fr));
166
167
  }
167
168
  }
@@ -818,6 +819,9 @@
818
819
  .max-h-64 {
819
820
  max-height: calc(var(--spacing) * 64);
820
821
  }
822
+ .max-h-full {
823
+ max-height: 100%;
824
+ }
821
825
  .min-h-0 {
822
826
  min-height: 0;
823
827
  }
@@ -926,21 +930,24 @@
926
930
  .max-w-5xl {
927
931
  max-width: var(--container-5xl);
928
932
  }
929
- .max-w-6xl {
930
- max-width: var(--container-6xl);
931
- }
932
933
  .max-w-16 {
933
934
  max-width: calc(var(--spacing) * 16);
934
935
  }
935
936
  .max-w-dialog {
936
937
  max-width: var(--container-dialog);
937
938
  }
939
+ .max-w-dialog-md {
940
+ max-width: var(--container-dialog-md);
941
+ }
938
942
  .max-w-full {
939
943
  max-width: 100%;
940
944
  }
941
945
  .max-w-md {
942
946
  max-width: var(--spacing-md);
943
947
  }
948
+ .max-w-palette {
949
+ max-width: var(--container-palette);
950
+ }
944
951
  .max-w-prose {
945
952
  max-width: 65ch;
946
953
  }
@@ -1608,6 +1615,12 @@
1608
1615
  .bg-semantic-error-soft {
1609
1616
  background-color: var(--color-semantic-error-soft);
1610
1617
  }
1618
+ .bg-semantic-error\/60 {
1619
+ background-color: color-mix(in srgb, #EA2261 60%, transparent);
1620
+ @supports (color: color-mix(in lab, red, red)) {
1621
+ background-color: color-mix(in oklab, var(--color-semantic-error) 60%, transparent);
1622
+ }
1623
+ }
1611
1624
  .bg-semantic-info-soft {
1612
1625
  background-color: var(--color-semantic-info-soft);
1613
1626
  }
@@ -2663,6 +2676,16 @@
2663
2676
  }
2664
2677
  }
2665
2678
  }
2679
+ .hover\:bg-semantic-error\/60 {
2680
+ &:hover {
2681
+ @media (hover: hover) {
2682
+ background-color: color-mix(in srgb, #EA2261 60%, transparent);
2683
+ @supports (color: color-mix(in lab, red, red)) {
2684
+ background-color: color-mix(in oklab, var(--color-semantic-error) 60%, transparent);
2685
+ }
2686
+ }
2687
+ }
2688
+ }
2666
2689
  .hover\:bg-surface-soft {
2667
2690
  &:hover {
2668
2691
  @media (hover: hover) {
@@ -2733,6 +2756,13 @@
2733
2756
  }
2734
2757
  }
2735
2758
  }
2759
+ .hover\:text-semantic-error {
2760
+ &:hover {
2761
+ @media (hover: hover) {
2762
+ color: var(--color-semantic-error);
2763
+ }
2764
+ }
2765
+ }
2736
2766
  .hover\:underline {
2737
2767
  &:hover {
2738
2768
  @media (hover: hover) {
@@ -2787,6 +2817,11 @@
2787
2817
  color: var(--color-primary);
2788
2818
  }
2789
2819
  }
2820
+ .focus-visible\:text-semantic-error {
2821
+ &:focus-visible {
2822
+ color: var(--color-semantic-error);
2823
+ }
2824
+ }
2790
2825
  .focus-visible\:ring-0 {
2791
2826
  &:focus-visible {
2792
2827
  --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
@@ -2913,6 +2948,11 @@
2913
2948
  border-color: var(--color-primary-active);
2914
2949
  }
2915
2950
  }
2951
+ .active\:border-semantic-error {
2952
+ &:active {
2953
+ border-color: var(--color-semantic-error);
2954
+ }
2955
+ }
2916
2956
  .active\:border-transparent {
2917
2957
  &:active {
2918
2958
  border-color: transparent;
@@ -2944,6 +2984,11 @@
2944
2984
  }
2945
2985
  }
2946
2986
  }
2987
+ .active\:bg-semantic-error {
2988
+ &:active {
2989
+ background-color: var(--color-semantic-error);
2990
+ }
2991
+ }
2947
2992
  .active\:bg-surface-soft {
2948
2993
  &:active {
2949
2994
  background-color: var(--color-surface-soft);
@@ -3039,6 +3084,14 @@
3039
3084
  }
3040
3085
  }
3041
3086
  }
3087
+ .disabled\:bg-semantic-error\/60 {
3088
+ &:disabled {
3089
+ background-color: color-mix(in srgb, #EA2261 60%, transparent);
3090
+ @supports (color: color-mix(in lab, red, red)) {
3091
+ background-color: color-mix(in oklab, var(--color-semantic-error) 60%, transparent);
3092
+ }
3093
+ }
3094
+ }
3042
3095
  .disabled\:bg-surface-soft {
3043
3096
  &:disabled {
3044
3097
  background-color: var(--color-surface-soft);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@designa8/vibedesign-stripe-deepseek-2-176b",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Complete design system \"My Project stripe-deepseek\" generated by Atoms Design.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",
@@ -3,6 +3,8 @@ import * as React from "react";
3
3
  type ButtonVariant =
4
4
  | "primary"
5
5
  | "primary-soft"
6
+ | "danger"
7
+ | "danger-soft"
6
8
  | "secondary"
7
9
  | "outline"
8
10
  | "outline-soft"
@@ -88,6 +90,21 @@ const variantStyles: Record<ButtonVariant, string> = {
88
90
  "focus-visible:bg-canvas focus-visible:text-primary focus-visible:border-primary focus-visible:ring-focus-blue/40",
89
91
  "disabled:bg-primary-disabled disabled:text-muted disabled:border-transparent",
90
92
  ),
93
+ /**
94
+ * The destructive twin of `primary`: the same filled geometry on the error
95
+ * hue, for the action that removes money, deletes a record, or ends access.
96
+ * Hover inverts to the error outline exactly like the brand fill, so the
97
+ * paired footer keeps one press grammar whichever tone the commit wears.
98
+ */
99
+ danger: cn(
100
+ focusRing,
101
+ "justify-center rounded-xl active:scale-[0.98]",
102
+ "border-2 bg-semantic-error text-on-primary border-transparent",
103
+ "hover:bg-canvas hover:text-semantic-error hover:border-semantic-error",
104
+ "active:bg-semantic-error active:text-on-primary active:border-semantic-error",
105
+ "focus-visible:bg-canvas focus-visible:text-semantic-error focus-visible:border-semantic-error focus-visible:ring-focus-blue/40",
106
+ "disabled:bg-primary-disabled disabled:text-muted disabled:border-transparent",
107
+ ),
91
108
  /**
92
109
  * The same filled pill at reduced strength: the action is committed and the
93
110
  * request is in flight, so the control keeps its size, its label, and the
@@ -102,6 +119,19 @@ const variantStyles: Record<ButtonVariant, string> = {
102
119
  "focus-visible:ring-focus-blue/40",
103
120
  "disabled:pointer-events-none disabled:border-transparent disabled:bg-primary/60 disabled:text-on-primary",
104
121
  ),
122
+ /**
123
+ * The dimmed destructive twin, used while a destructive request is in
124
+ * flight: it holds the pill's size and label, keeps the error hue, and pins
125
+ * the fill so a working control can never be hovered brighter.
126
+ */
127
+ "danger-soft": cn(
128
+ focusRing,
129
+ "justify-center rounded-xl active:scale-[0.98]",
130
+ "border-transparent bg-semantic-error/60 text-on-primary",
131
+ "hover:bg-semantic-error/60 hover:text-on-primary hover:border-transparent",
132
+ "focus-visible:ring-focus-blue/40",
133
+ "disabled:pointer-events-none disabled:border-transparent disabled:bg-semantic-error/60 disabled:text-on-primary",
134
+ ),
105
135
  // Persistent outline treatment.
106
136
  secondary: cn(
107
137
  focusRing,
@@ -14,16 +14,36 @@ const REASONS: DropdownOption[] = [
14
14
  { value: "other", label: "Other" },
15
15
  ];
16
16
 
17
+ /** Everything the focus trap may move to, in DOM order. */
18
+ const FOCUSABLE =
19
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
20
+
21
+ /** The money line of the default refund body, kept out of the props default so
22
+ * a caller that drops the reason control can also drop the amount. */
23
+ const DEFAULT_AMOUNT = "$89.00";
24
+
17
25
  /**
18
26
  * The dismiss layer is pinned inline rather than with a utility: the Button's
19
27
  * own geometry pins `position: relative`, and a style declaration is the only
20
28
  * thing that reliably wins over it without a second set of position classes.
29
+ * Inside a host panel it is pinned to that panel instead of the viewport, so the
30
+ * dimming stops at the panel's edge.
21
31
  */
22
- const SCRIM_STYLE: React.CSSProperties = {
23
- position: "fixed",
32
+ const scrimStyle = (contained: boolean): React.CSSProperties => ({
33
+ position: contained ? "absolute" : "fixed",
24
34
  inset: 0,
25
35
  background: "color-mix(in srgb, var(--color-scrim) 50%, transparent)",
26
- };
36
+ });
37
+
38
+ const sizeStyles = {
39
+ sm: "max-w-dialog",
40
+ md: "max-w-dialog-md",
41
+ lg: "max-w-palette",
42
+ } as const;
43
+
44
+ export type ModalSize = keyof typeof sizeStyles;
45
+ export type ModalConfirmTone = "primary" | "danger";
46
+ export type ModalPlacement = "center" | "top";
27
47
 
28
48
  export interface ModalProps
29
49
  extends Omit<
@@ -40,7 +60,34 @@ export interface ModalProps
40
60
  title?: string;
41
61
  /** Supporting line beneath the heading. */
42
62
  description?: string;
43
- /** Money value under the description, rendered in tabular figures. */
63
+ /**
64
+ * Replaces the whole default body (amount + reason). The body scrolls between
65
+ * the pinned header and the pinned footer, so a long form never pushes the
66
+ * actions out of the viewport.
67
+ */
68
+ children?: React.ReactNode;
69
+ /**
70
+ * Replaces the default Cancel / Confirm pair. Pass `null` to drop the footer
71
+ * entirely — a command palette has nowhere to put actions.
72
+ */
73
+ footer?: React.ReactNode | null;
74
+ /** Panel width: `sm` dialog (420), `md` form (560), `lg` palette (640). */
75
+ size?: ModalSize;
76
+ /**
77
+ * `center` is the transactional dialog. `top` anchors the panel near the top
78
+ * of the viewport, which is where a command palette belongs.
79
+ */
80
+ placement?: ModalPlacement;
81
+ /**
82
+ * Drops the header row. A palette makes its search input the header, so the
83
+ * title stays the accessible name without a visible heading.
84
+ */
85
+ hideHeader?: boolean;
86
+ /**
87
+ * Money value under the description, rendered in tabular figures. It is part
88
+ * of the default refund body only: omitted, the default layout still shows
89
+ * the payment total unless the reason control is dropped as well.
90
+ */
44
91
  amount?: string;
45
92
  /** Label above the reason control. */
46
93
  reasonLabel?: string;
@@ -57,8 +104,14 @@ export interface ModalProps
57
104
  * It thickens that one stroke and never adds a second box.
58
105
  */
59
106
  reasonFocused?: boolean;
107
+ /** Drops the reason control from the default body, leaving the amount. */
108
+ hideReason?: boolean;
60
109
  /** In-flight state: dims the reason field and swaps the confirm action. */
61
110
  confirming?: boolean;
111
+ /** Tone of the committing action; `danger` paints it on the error hue. */
112
+ confirmTone?: ModalConfirmTone;
113
+ /** Holds the committing action closed until the form is valid. */
114
+ confirmDisabled?: boolean;
62
115
  /** Label of the dismissive action. */
63
116
  cancelLabel?: string;
64
117
  /** Label of the committing action. */
@@ -69,6 +122,12 @@ export interface ModalProps
69
122
  onCancel?: () => void;
70
123
  /** Fires when the committing action is taken. */
71
124
  onConfirm?: () => void;
125
+ /**
126
+ * Portals the dialog into this element and pins it there with `absolute`
127
+ * positioning instead of covering the viewport. Page scroll stays unlocked,
128
+ * which is what a demo of the dialog inside a panel needs.
129
+ */
130
+ container?: HTMLElement | null;
72
131
  /**
73
132
  * Renders the panel in place as a static board tile instead of portalling it
74
133
  * over a scrim. Documentation boards use it to show several states at once;
@@ -78,16 +137,19 @@ export interface ModalProps
78
137
  }
79
138
 
80
139
  /**
81
- * A 420px refund dialog. It is the modal organism for an irreversible money
82
- * action: heading and dismiss glyph on one line, a muted instruction, the
140
+ * The system dialog. Called with no children it is still the refund
141
+ * confirmation: heading and dismiss glyph on one line, a muted instruction, the
83
142
  * amount in tabular figures, one reason select, and a paired footer that reads
84
- * dismiss-then-commit from left to right.
143
+ * dismiss-then-commit. Called with children it is any dialog — a dispute form,
144
+ * a payment-link wizard, an invite, a ⌘K palette — keeping the same pinned
145
+ * header, scrolling body, and pinned footer.
85
146
  *
86
- * The overlay never scrolls. The scrim is its own fixed layer so it covers the
87
- * viewport at every scroll position; the panel is capped to the viewport, keeps
88
- * its header and footer pinned, and scrolls only its body. The reason select
89
- * portals its listbox to `document.body` at `--z-popover`, above this dialog's
90
- * `--z-modal`, so opening it can never grow the panel or paint underneath it.
147
+ * The overlay never scrolls. The scrim is its own layer so it covers the whole
148
+ * surface at every scroll position; the panel is capped to the viewport, keeps
149
+ * its header and footer pinned, and scrolls only its body. Any select inside
150
+ * the body portals its listbox to `document.body` at `--z-popover`, above this
151
+ * dialog's `--z-modal`, so opening it can never grow the panel or paint
152
+ * underneath it.
91
153
  */
92
154
  export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
93
155
  (
@@ -97,19 +159,28 @@ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
97
159
  onOpenChange,
98
160
  title = "Refund payment",
99
161
  description = "Refund this payment to the customer.",
100
- amount = "$89.00",
162
+ children,
163
+ footer,
164
+ size = "sm",
165
+ placement = "center",
166
+ hideHeader = false,
167
+ amount,
101
168
  reasonLabel = "Reason",
102
169
  reasons = REASONS,
103
170
  reason,
104
171
  defaultReason = REASONS[0].value,
105
172
  onReasonChange,
106
173
  reasonFocused = false,
174
+ hideReason = false,
107
175
  confirming,
176
+ confirmTone = "primary",
177
+ confirmDisabled = false,
108
178
  cancelLabel = "Cancel",
109
179
  confirmLabel = "Confirm refund",
110
180
  confirmingLabel = "Confirming...",
111
181
  onCancel,
112
182
  onConfirm,
183
+ container,
113
184
  inline = false,
114
185
  className,
115
186
  id,
@@ -122,6 +193,7 @@ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
122
193
  const titleId = `${rootId}-title`;
123
194
  const descriptionId = `${rootId}-description`;
124
195
 
196
+ const panelRef = React.useRef<HTMLDivElement | null>(null);
125
197
  const closeRef = React.useRef<HTMLButtonElement | null>(null);
126
198
  const restoreFocusRef = React.useRef<HTMLElement | null>(null);
127
199
 
@@ -134,6 +206,30 @@ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
134
206
  const isConfirming =
135
207
  confirming !== undefined ? confirming : internalConfirming;
136
208
  const state = isOpen ? "open" : "closed";
209
+ const contained = Boolean(container);
210
+ /* The refund body owns the default money line; a caller that strips the
211
+ reason control has replaced that layout and owns the amount itself. */
212
+ const showDefaultBody = children === undefined;
213
+ const resolvedAmount =
214
+ amount !== undefined
215
+ ? amount
216
+ : showDefaultBody && !hideReason
217
+ ? DEFAULT_AMOUNT
218
+ : undefined;
219
+ const showFooter = footer !== null;
220
+ const hasCustomFooter = footer !== undefined;
221
+
222
+ const assignPanelRef = React.useCallback(
223
+ (node: HTMLDivElement | null) => {
224
+ panelRef.current = node;
225
+ if (typeof ref === "function") {
226
+ ref(node);
227
+ } else if (ref) {
228
+ (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
229
+ }
230
+ },
231
+ [ref],
232
+ );
137
233
 
138
234
  const setOpenState = React.useCallback(
139
235
  (next: boolean) => {
@@ -157,14 +253,21 @@ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
157
253
  }
158
254
  }, [isOpen]);
159
255
 
160
- /* Remember where focus came from, then move it inside the panel. */
256
+ /* Remember where focus came from, then move it inside the panel. The close
257
+ gutter is the usual first stop; a header-less palette focuses its input. */
161
258
  React.useEffect(() => {
162
259
  if (!isOpen || inline) {
163
260
  return;
164
261
  }
165
262
  restoreFocusRef.current = document.activeElement as HTMLElement | null;
166
263
  const frame = window.requestAnimationFrame(() => {
167
- closeRef.current?.focus({ preventScroll: true });
264
+ const panel = panelRef.current;
265
+ const target =
266
+ closeRef.current ??
267
+ panel?.querySelector<HTMLElement>(FOCUSABLE) ??
268
+ panel ??
269
+ null;
270
+ target?.focus({ preventScroll: true });
168
271
  });
169
272
  return () => window.cancelAnimationFrame(frame);
170
273
  }, [isOpen, inline]);
@@ -183,6 +286,55 @@ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
183
286
  return () => document.removeEventListener("keydown", handleKeyDown);
184
287
  }, [isOpen, inline, close]);
185
288
 
289
+ /* A viewport dialog holds the page still. A dialog inside a panel does not. */
290
+ React.useEffect(() => {
291
+ if (!isOpen || inline || contained) {
292
+ return;
293
+ }
294
+ const previous = document.body.style.overflow;
295
+ document.body.style.overflow = "hidden";
296
+ return () => {
297
+ document.body.style.overflow = previous;
298
+ };
299
+ }, [isOpen, inline, contained]);
300
+
301
+ /**
302
+ * Tab cycles inside the panel rather than escaping to the page behind it,
303
+ * and wraps at both ends. Anything the component portalled to `body` — a
304
+ * select's listbox — is deliberately outside this loop.
305
+ */
306
+ const trapFocus = (event: React.KeyboardEvent<HTMLDivElement>) => {
307
+ if (event.key !== "Tab" || inline) {
308
+ return;
309
+ }
310
+ const root = event.currentTarget;
311
+ const focusables = Array.from(
312
+ root.querySelectorAll<HTMLElement>(FOCUSABLE),
313
+ ).filter((element) => element.getClientRects().length > 0);
314
+ if (focusables.length === 0) {
315
+ event.preventDefault();
316
+ root.focus({ preventScroll: true });
317
+ return;
318
+ }
319
+ const first = focusables[0];
320
+ const last = focusables[focusables.length - 1];
321
+ const active = document.activeElement as HTMLElement | null;
322
+ const inside = active ? root.contains(active) : false;
323
+
324
+ if (!inside) {
325
+ event.preventDefault();
326
+ (event.shiftKey ? last : first).focus();
327
+ return;
328
+ }
329
+ if (!event.shiftKey && active === last) {
330
+ event.preventDefault();
331
+ first.focus();
332
+ } else if (event.shiftKey && active === first) {
333
+ event.preventDefault();
334
+ last.focus();
335
+ }
336
+ };
337
+
186
338
  const chooseReason = React.useCallback(
187
339
  (value: string) => {
188
340
  if (reason === undefined) {
@@ -203,17 +355,36 @@ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
203
355
  onConfirm?.();
204
356
  };
205
357
 
358
+ const confirmVariant =
359
+ confirmTone === "danger"
360
+ ? isConfirming
361
+ ? "danger-soft"
362
+ : "danger"
363
+ : isConfirming
364
+ ? "primary-soft"
365
+ : "primary";
366
+
206
367
  const panel = (
207
368
  <div
208
- ref={ref}
369
+ ref={assignPanelRef}
209
370
  role="dialog"
210
- aria-modal={inline ? undefined : true}
211
- aria-labelledby={titleId}
212
- aria-describedby={descriptionId}
371
+ aria-modal={inline || contained ? undefined : true}
372
+ aria-labelledby={hideHeader ? undefined : titleId}
373
+ aria-label={hideHeader ? title : undefined}
374
+ aria-describedby={
375
+ hideHeader || !showDefaultBody ? undefined : descriptionId
376
+ }
213
377
  data-state={state}
378
+ tabIndex={-1}
379
+ onKeyDown={trapFocus}
214
380
  className={cn(
215
- "relative flex w-full max-w-dialog flex-col overflow-hidden",
216
- "max-h-[calc(100dvh-2rem)]",
381
+ "relative flex w-full flex-col overflow-hidden",
382
+ sizeStyles[size],
383
+ contained
384
+ ? "max-h-full"
385
+ : placement === "top"
386
+ ? "max-h-[calc(88dvh-1rem)]"
387
+ : "max-h-[calc(100dvh-2rem)]",
217
388
  "rounded-lg border border-hairline bg-surface shadow-overlay",
218
389
  "transition duration-200 ease-out",
219
390
  "data-[state=open]:translate-y-0 data-[state=open]:scale-100 data-[state=open]:opacity-100",
@@ -222,79 +393,101 @@ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
222
393
  )}
223
394
  {...rest}
224
395
  >
225
- <div className="flex shrink-0 items-start justify-between gap-lg px-xl pt-xl">
226
- <h2
227
- id={titleId}
228
- className="text-display-md font-weight-body-md text-ink"
229
- >
230
- {title}
231
- </h2>
232
- <Button
233
- ref={closeRef}
234
- variant="bare"
235
- size="icon-sm"
236
- aria-label="Close dialog"
237
- onClick={close}
238
- className="-mr-xs -mt-xs text-ink hover:text-muted"
239
- leadingIcon={<Icon name="x" size={18} strokeWidth={1.75} />}
240
- />
241
- </div>
242
-
243
- <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-xl pt-sm">
244
- <p id={descriptionId} className="text-body-md text-body">
245
- {description}
246
- </p>
247
-
248
- <p
249
- className="mt-lg text-display-lg text-ink [font-feature-settings:'tnum']"
250
- >
251
- {amount}
252
- </p>
253
-
254
- <div className="mt-lg">
255
- <Dropdown
256
- label={reasonLabel}
257
- labelVariant="plain"
258
- tone="soft"
259
- options={reasons}
260
- value={selectedReason}
261
- onValueChange={chooseReason}
262
- disabled={isConfirming}
263
- forceFocus={reasonFocused}
264
- ariaLabel={`${reasonLabel} for this refund`}
396
+ {hideHeader ? null : (
397
+ <div className="flex shrink-0 items-start justify-between gap-lg px-xl pt-xl">
398
+ <h2
399
+ id={titleId}
400
+ className="text-display-md font-weight-body-md text-ink"
401
+ >
402
+ {title}
403
+ </h2>
404
+ <Button
405
+ ref={closeRef}
406
+ variant="bare"
407
+ size="icon-sm"
408
+ aria-label="Close dialog"
409
+ onClick={close}
410
+ className="-mr-xs -mt-xs text-ink hover:text-muted"
411
+ leadingIcon={<Icon name="x" size={18} strokeWidth={1.75} />}
265
412
  />
266
413
  </div>
267
- </div>
414
+ )}
268
415
 
269
- <div className="grid shrink-0 grid-cols-5 gap-md px-xl pb-xl pt-lg">
270
- <Button
271
- variant="outline-soft"
272
- size="lg"
273
- fullWidth
274
- onClick={handleCancel}
275
- className="col-span-2"
276
- >
277
- {cancelLabel}
278
- </Button>
279
- <Button
280
- variant={isConfirming ? "primary-soft" : "primary"}
281
- size="lg"
282
- fullWidth
283
- disabled={isConfirming}
284
- onClick={handleConfirm}
285
- className="col-span-3"
286
- leadingIcon={
287
- isConfirming ? (
288
- <span
289
- aria-hidden="true"
290
- className="size-4 shrink-0 animate-spin rounded-full border-2 border-on-primary/70 border-t-transparent"
291
- />
292
- ) : undefined
293
- }
294
- >
295
- {isConfirming ? confirmingLabel : confirmLabel}
296
- </Button>
416
+ <div
417
+ className={cn(
418
+ "min-h-0 flex-1 overflow-y-auto overscroll-contain px-xl",
419
+ hideHeader ? "pt-lg" : "pt-sm",
420
+ showFooter ? "" : "pb-xl",
421
+ )}
422
+ >
423
+ {showDefaultBody ? (
424
+ <>
425
+ <p id={descriptionId} className="text-body-md text-body">
426
+ {description}
427
+ </p>
428
+
429
+ {resolvedAmount === undefined ? null : (
430
+ <p className="mt-lg text-display-lg text-ink [font-feature-settings:'tnum']">
431
+ {resolvedAmount}
432
+ </p>
433
+ )}
434
+
435
+ {hideReason ? null : (
436
+ <div className="mt-lg">
437
+ <Dropdown
438
+ label={reasonLabel}
439
+ labelVariant="plain"
440
+ tone="soft"
441
+ options={reasons}
442
+ value={selectedReason}
443
+ onValueChange={chooseReason}
444
+ disabled={isConfirming}
445
+ forceFocus={reasonFocused}
446
+ ariaLabel={`${reasonLabel} for this refund`}
447
+ />
448
+ </div>
449
+ )}
450
+ </>
451
+ ) : (
452
+ children
453
+ )}
297
454
  </div>
455
+
456
+ {showFooter ? (
457
+ hasCustomFooter ? (
458
+ <div className="shrink-0 px-xl pb-xl pt-lg">{footer}</div>
459
+ ) : (
460
+ <div className="grid shrink-0 grid-cols-5 gap-md px-xl pb-xl pt-lg">
461
+ <Button
462
+ variant="outline-soft"
463
+ size="lg"
464
+ fullWidth
465
+ onClick={handleCancel}
466
+ className="col-span-2"
467
+ >
468
+ {cancelLabel}
469
+ </Button>
470
+ <Button
471
+ variant={confirmVariant}
472
+ size="lg"
473
+ fullWidth
474
+ disabled={isConfirming || confirmDisabled}
475
+ onClick={handleConfirm}
476
+ className="col-span-3"
477
+ leadingIcon={
478
+ isConfirming ? (
479
+ <span
480
+ aria-hidden="true"
481
+ className="size-4 shrink-0 animate-spin rounded-full border-2 border-on-primary/70 border-t-transparent"
482
+ />
483
+ ) : undefined
484
+ }
485
+ >
486
+ {isConfirming ? confirmingLabel : confirmLabel}
487
+ </Button>
488
+ </div>
489
+ )
490
+ ) : null}
298
491
  </div>
299
492
  );
300
493
 
@@ -311,13 +504,17 @@ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
311
504
  data-state={state}
312
505
  inert={isOpen ? undefined : true}
313
506
  className={cn(
314
- "fixed inset-0 flex items-center justify-center p-base",
507
+ "flex justify-center",
508
+ contained ? "absolute inset-0" : "fixed inset-0",
509
+ placement === "top"
510
+ ? "items-start px-base pt-[12dvh]"
511
+ : "items-center p-base",
315
512
  "transition duration-200 ease-out",
316
513
  "data-[state=closed]:pointer-events-none data-[state=closed]:invisible",
317
514
  )}
318
515
  style={{ zIndex: "var(--z-modal, 1200)" }}
319
516
  >
320
- {/* Its own fixed layer, so the scrim covers the viewport. */}
517
+ {/* Its own layer, so the scrim covers the whole surface. */}
321
518
  <Button
322
519
  type="button"
323
520
  variant="bare"
@@ -326,7 +523,7 @@ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
326
523
  aria-label="Close dialog"
327
524
  data-state={state}
328
525
  onClick={close}
329
- style={SCRIM_STYLE}
526
+ style={scrimStyle(contained)}
330
527
  className={cn(
331
528
  "transition-opacity duration-200 ease-out",
332
529
  "data-[state=open]:opacity-100 data-[state=closed]:opacity-0",
@@ -335,7 +532,7 @@ export const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
335
532
 
336
533
  {panel}
337
534
  </div>,
338
- document.body,
535
+ container ?? document.body,
339
536
  );
340
537
  },
341
538
  );
@@ -1,2 +1,7 @@
1
1
  export { Modal } from "./Modal";
2
- export type { ModalProps } from "./Modal";
2
+ export type {
3
+ ModalProps,
4
+ ModalSize,
5
+ ModalConfirmTone,
6
+ ModalPlacement,
7
+ } from "./Modal";
@@ -1,97 +1,179 @@
1
1
  import * as React from "react";
2
- import { Button } from "../Button";
3
2
  import { Modal } from "./Modal";
3
+ import { Button } from "../Button";
4
+ import { Input } from "../Input";
5
+ import { Icon } from "../Icon";
4
6
 
5
- const BOARDS = [
6
- {
7
- id: "default",
8
- label: "DEFAULT",
9
- blurb: "Refund dialog with amount and reason.",
10
- },
11
- {
12
- id: "focus",
13
- label: "FOCUS",
14
- blurb: "Focused reason select control.",
15
- },
16
- {
17
- id: "confirming",
18
- label: "CONFIRMING",
19
- blurb: "Confirming state after action.",
20
- },
21
- ] as const;
7
+ const COMMANDS = [
8
+ { icon: "search", label: "Open payment search", hint: "G then P" },
9
+ { icon: "credit-card", label: "Refund a payment", hint: "R" },
10
+ { icon: "user", label: "Jump to a customer", hint: "C" },
11
+ { icon: "file", label: "Open dispute evidence", hint: "D" },
12
+ ];
22
13
 
23
- /**
24
- * Every board renders the real dialog in place, so the amount, the reason
25
- * select, and the paired footer are the same markup in each state. The tiles
26
- * take the dialog's own width, so a panel is never squeezed below 420px —
27
- * they simply wrap when the canvas is narrower than two of them.
28
- *
29
- * The last section triggers the portalled dialog: it opens over a scrim and
30
- * closes on Escape, on the dismiss glyph, or on Cancel.
31
- */
32
14
  export default function ModalPreview() {
33
- const [open, setOpen] = React.useState(false);
34
- const [reason, setReason] = React.useState("customer-request");
35
- const [confirming, setConfirming] = React.useState(false);
15
+ const [refundOpen, setRefundOpen] = React.useState(false);
16
+ const [dangerOpen, setDangerOpen] = React.useState(false);
17
+ const [formOpen, setFormOpen] = React.useState(false);
18
+ const [paletteOpen, setPaletteOpen] = React.useState(false);
19
+ const [containedOpen, setContainedOpen] = React.useState(false);
20
+ const [host, setHost] = React.useState<HTMLDivElement | null>(null);
21
+ const [amount, setAmount] = React.useState("24.00");
22
+ const [query, setQuery] = React.useState("");
36
23
 
37
- return (
38
- <div className="w-full max-w-6xl mx-auto p-lg space-y-xl">
39
- <div className="flex w-full flex-col gap-sm">
40
- <h2 className="text-display-md text-ink">Modal / Dialog</h2>
41
- <p className="text-body-md text-body">
42
- 420px dialog for refunding a payment, with amount, reason, and actions.
43
- </p>
44
- </div>
45
-
46
- <div className="flex w-full flex-wrap items-start gap-xl">
47
- {BOARDS.map((board) => (
48
- <div
49
- key={board.id}
50
- className="flex w-full max-w-dialog flex-col gap-md"
51
- >
52
- <div className="flex w-full flex-col gap-xs">
53
- <div className="flex w-full">
54
- <span className="rounded-full bg-primary-subdued px-md py-xxs text-micro-cap text-primary-on-subdued">
55
- {board.label}
56
- </span>
57
- </div>
58
- <span className="text-body-md text-body">{board.blurb}</span>
59
- </div>
24
+ const results = COMMANDS.filter((command) =>
25
+ command.label.toLowerCase().includes(query.trim().toLowerCase()),
26
+ );
60
27
 
61
- <Modal
62
- inline
63
- reasonFocused={board.id === "focus"}
64
- confirming={board.id === "confirming"}
65
- />
66
- </div>
67
- ))}
28
+ return (
29
+ <div className="w-full max-w-3xl mx-auto space-y-lg p-lg">
30
+ <div className="flex flex-wrap items-center gap-sm">
31
+ <Button variant="primary" size="md" onClick={() => setRefundOpen(true)}>
32
+ Refund payment
33
+ </Button>
34
+ <Button
35
+ variant="outline-soft"
36
+ size="md"
37
+ onClick={() => setDangerOpen(true)}
38
+ >
39
+ Cancel a payout
40
+ </Button>
41
+ <Button
42
+ variant="outline-soft"
43
+ size="md"
44
+ onClick={() => setFormOpen(true)}
45
+ >
46
+ Edit refund amount
47
+ </Button>
48
+ <Button
49
+ variant="secondary"
50
+ size="md"
51
+ onClick={() => setPaletteOpen(true)}
52
+ >
53
+ Search commands
54
+ </Button>
68
55
  </div>
69
56
 
70
- <div className="flex w-full flex-wrap items-center justify-between gap-md rounded-xl border border-hairline bg-surface-soft p-lg">
71
- <p className="text-body-md text-body">
72
- Open the live dialog to try the motion and the portalled reason list.
57
+ <div
58
+ ref={setHost}
59
+ className="relative overflow-hidden rounded-lg border border-hairline bg-canvas p-lg"
60
+ >
61
+ <p className="text-body-sm text-ink">Dialog hosted inside a panel</p>
62
+ <p className="mt-xs text-caption text-muted">
63
+ Portalled into this card and positioned absolutely, so page scroll
64
+ stays unlocked and the dimming stops at the panel edge.
73
65
  </p>
74
66
  <Button
67
+ variant="outline-soft"
75
68
  size="sm"
76
- variant="primary"
77
- onClick={() => {
78
- setConfirming(false);
79
- setOpen(true);
80
- }}
69
+ className="mt-md"
70
+ onClick={() => setContainedOpen(true)}
81
71
  >
82
- Open refund dialog
72
+ Invite teammate
83
73
  </Button>
74
+
75
+ <Modal
76
+ open={containedOpen}
77
+ onOpenChange={setContainedOpen}
78
+ container={host}
79
+ title="Invite teammate"
80
+ description="They get access to payments for this workspace."
81
+ hideReason
82
+ cancelLabel="Not now"
83
+ confirmLabel="Send invite"
84
+ />
84
85
  </div>
85
86
 
87
+ {/* The dialog with no children is still the refund confirmation. */}
86
88
  <Modal
87
- open={open}
88
- onOpenChange={setOpen}
89
- reason={reason}
90
- onReasonChange={setReason}
91
- confirming={confirming}
92
- onConfirm={() => setConfirming(true)}
93
- onCancel={() => setOpen(false)}
89
+ open={refundOpen}
90
+ onOpenChange={setRefundOpen}
91
+ defaultReason="customer-request"
94
92
  />
93
+
94
+ {/* Destructive commit on the error hue. */}
95
+ <Modal
96
+ open={dangerOpen}
97
+ onOpenChange={setDangerOpen}
98
+ title="Cancel this payout"
99
+ description="The payout to your bank is cancelled and the balance returns to your account."
100
+ amount="$1,240.00"
101
+ hideReason
102
+ confirmTone="danger"
103
+ confirmLabel="Cancel payout"
104
+ cancelLabel="Keep payout"
105
+ />
106
+
107
+ {/* Custom body, default footer, held closed until the field has a value. */}
108
+ <Modal
109
+ open={formOpen}
110
+ onOpenChange={setFormOpen}
111
+ size="md"
112
+ title="Edit refund amount"
113
+ description="The customer sees the new amount within one business day."
114
+ confirmLabel="Save amount"
115
+ confirmDisabled={amount.trim().length === 0}
116
+ >
117
+ <div className="space-y-md pt-sm">
118
+ <Input
119
+ label="Refund amount"
120
+ labelVariant="plain"
121
+ value={amount}
122
+ onChange={(event) => setAmount(event.target.value)}
123
+ helper="Up to $89.00 can be returned on this payment."
124
+ />
125
+ <p className="text-caption text-muted">
126
+ Refunds are final and cannot be reversed from this panel.
127
+ </p>
128
+ </div>
129
+ </Modal>
130
+
131
+ {/* Command palette: top placement, wide panel, no header, no footer. */}
132
+ <Modal
133
+ open={paletteOpen}
134
+ onOpenChange={setPaletteOpen}
135
+ size="lg"
136
+ placement="top"
137
+ hideHeader
138
+ footer={null}
139
+ title="Search commands"
140
+ >
141
+ <div className="space-y-sm">
142
+ <Input
143
+ ariaLabel="Search commands"
144
+ placeholder="Search commands…"
145
+ value={query}
146
+ onChange={(event) => setQuery(event.target.value)}
147
+ leading={<Icon name="search" size={16} />}
148
+ chrome="none"
149
+ />
150
+ <ul>
151
+ {results.length === 0 ? (
152
+ <li className="px-md py-sm text-body-sm text-muted">
153
+ No commands match “{query}”.
154
+ </li>
155
+ ) : (
156
+ results.map((command) => (
157
+ <li key={command.label}>
158
+ <Button
159
+ variant="row"
160
+ size="row"
161
+ onClick={() => setPaletteOpen(false)}
162
+ leadingIcon={<Icon name={command.icon} size={16} />}
163
+ trailingIcon={
164
+ <span className="text-caption text-muted-soft">
165
+ {command.hint}
166
+ </span>
167
+ }
168
+ >
169
+ {command.label}
170
+ </Button>
171
+ </li>
172
+ ))
173
+ )}
174
+ </ul>
175
+ </div>
176
+ </Modal>
95
177
  </div>
96
178
  );
97
179
  }
package/src/tokens.css CHANGED
@@ -216,6 +216,7 @@
216
216
  --container-page: 1240px;
217
217
  --container-palette: 640px;
218
218
  --container-auth: 400px;
219
+ --container-dialog-md: 560px;
219
220
  --grid-template-columns-system: repeat(12, minmax(0, 1fr));
220
221
  --default-font-family: var(--font-body);
221
222
  }