@robr0/design-system 0.15.0 → 0.16.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 +3 -3
- package/behaviors/focusable.d.ts +26 -0
- package/behaviors/focusable.js +18 -0
- package/behaviors/useFocusScope.d.ts +38 -0
- package/behaviors/useFocusScope.js +82 -0
- package/behaviors/useLayer.d.ts +43 -0
- package/behaviors/useLayer.js +55 -0
- package/behaviors/useMounted.d.ts +7 -0
- package/behaviors/useMounted.js +14 -0
- package/behaviors/useScrollLock.d.ts +55 -0
- package/behaviors/useScrollLock.js +59 -0
- package/components/AgentPlan/AgentPlan.css +12 -3
- package/components/AiButton/AiButton.css +327 -0
- package/components/AiButton/AiButton.d.ts +42 -0
- package/components/AiButton/AiButton.js +140 -16
- package/components/AlertDialog/AlertDialog.js +13 -58
- package/components/Button/Button.css +3 -0
- package/components/Combobox/Combobox.js +2 -1
- package/components/CommandPalette/CommandPalette.css +10 -7
- package/components/CommandPalette/CommandPalette.js +9 -58
- package/components/Composer/Composer.css +54 -0
- package/components/Composer/Composer.d.ts +21 -4
- package/components/Composer/Composer.js +7 -0
- package/components/Dialog/Dialog.js +9 -62
- package/components/Drawer/Drawer.js +9 -62
- package/components/Dropdown/Dropdown.js +2 -1
- package/components/DropdownMenu/DropdownMenu.js +11 -3
- package/components/ModelPicker/ModelPicker.js +2 -1
- package/components/Popover/Popover.js +3 -0
- package/components/SegmentedControl/SegmentedControl.css +4 -2
- package/components/SourceTrail/SourceTrail.css +2 -2
- package/components/TimePicker/TimePicker.js +2 -1
- package/components/ToolCall/ToolCall.css +9 -1
- package/components/Tooltip/Tooltip.js +6 -0
- package/components/registry.json +2 -2
- package/components/registry.json.d.ts +2 -2
- package/components/registry.json.js +1 -1
- package/package.json +5 -1
- package/tokens/motion.d.ts +10 -4
- package/tokens/motion.js +7 -1
- package/tokens/registry.json +6 -0
- package/tokens/registry.json.d.ts +6 -0
- package/tokens/registry.json.js +1 -1
- package/tokens/tokens-dark.css +11 -0
- package/tokens/tokens-light.css +13 -0
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
line-height: var(--font-paragraph-em-line-height);
|
|
39
39
|
letter-spacing: var(--font-paragraph-em-letter-spacing);
|
|
40
40
|
text-align: center;
|
|
41
|
+
/* A pill's label never wraps — a squeezed layout shortens around the
|
|
42
|
+
button, it doesn't fold the silhouette into two lines. */
|
|
43
|
+
white-space: nowrap;
|
|
41
44
|
|
|
42
45
|
/* The field is transparent by design: the ring and glow are the button */
|
|
43
46
|
background: transparent;
|
|
@@ -161,3 +164,327 @@
|
|
|
161
164
|
animation: none;
|
|
162
165
|
}
|
|
163
166
|
}
|
|
167
|
+
|
|
168
|
+
/* ============================================
|
|
169
|
+
THE SUMMARY PANEL
|
|
170
|
+
AiButton's TLDR surface: a frosted glass card
|
|
171
|
+
summoned above (or below) the button. The
|
|
172
|
+
button and panel share one hover zone — the
|
|
173
|
+
host — and the AI gradient sweeps once around
|
|
174
|
+
the panel's border as it opens, then fades to
|
|
175
|
+
the ordinary container border.
|
|
176
|
+
============================================ */
|
|
177
|
+
|
|
178
|
+
.ds-ai-button-host {
|
|
179
|
+
position: relative;
|
|
180
|
+
display: inline-flex;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* The button acknowledges the panel's arrival with one gentle pulse. */
|
|
184
|
+
.ds-ai-button-host--open .ds-ai-button {
|
|
185
|
+
animation: ds-ai-button-acknowledge var(--motion-duration-deliberate) var(--motion-ease-spring) both;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@keyframes ds-ai-button-acknowledge {
|
|
189
|
+
from {
|
|
190
|
+
transform: scale(1);
|
|
191
|
+
}
|
|
192
|
+
50% {
|
|
193
|
+
transform: scale(1.05);
|
|
194
|
+
}
|
|
195
|
+
to {
|
|
196
|
+
transform: scale(1);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.ds-ai-button__summary {
|
|
201
|
+
position: absolute;
|
|
202
|
+
right: 0;
|
|
203
|
+
z-index: 1;
|
|
204
|
+
/* Component-local lengths, chosen by eye like the button's glow: a
|
|
205
|
+
readable measure, capped so a phone viewport keeps its margins. */
|
|
206
|
+
--ds-ai-summary-width: 340px;
|
|
207
|
+
--ds-ai-summary-viewport-margin: 40px;
|
|
208
|
+
width: min(var(--ds-ai-summary-width), calc(100vw - var(--ds-ai-summary-viewport-margin)));
|
|
209
|
+
|
|
210
|
+
display: flex;
|
|
211
|
+
flex-direction: column;
|
|
212
|
+
gap: var(--gap-sm);
|
|
213
|
+
|
|
214
|
+
padding: var(--padding-lg);
|
|
215
|
+
border-radius: var(--radius-xl);
|
|
216
|
+
/* ds-allow(color): the light glass token reads solid on a floating card;
|
|
217
|
+
thinned so the page shows through the frost */
|
|
218
|
+
background: color-mix(in srgb, var(--color-bg-glass) 62%, transparent);
|
|
219
|
+
/* The frost: heavier than the 24px glass pairing so the blur reads
|
|
220
|
+
unmistakably over imagery — a component-local effect length, chosen by
|
|
221
|
+
eye. Standard property only: the build pipeline prefixes it, and a
|
|
222
|
+
hand-written -webkit- duplicate makes its merging drop the declaration. */
|
|
223
|
+
--ds-ai-summary-frost: 36px;
|
|
224
|
+
backdrop-filter: blur(var(--ds-ai-summary-frost)) saturate(1.5);
|
|
225
|
+
border: var(--border-xs) solid var(--color-bg-container-border);
|
|
226
|
+
box-shadow: var(--shadow-floating);
|
|
227
|
+
|
|
228
|
+
/* Mounted but resting: the closed state mirrors the pop's `from`, so
|
|
229
|
+
leaving transitions back down the same path the entrance sprang up.
|
|
230
|
+
visibility keeps the hidden panel out of the accessibility tree and
|
|
231
|
+
the tab order, the way HoverCard hides. */
|
|
232
|
+
visibility: hidden;
|
|
233
|
+
opacity: 0;
|
|
234
|
+
transform: translateY(10px) scale(0.9);
|
|
235
|
+
transition:
|
|
236
|
+
opacity var(--motion-duration-fast) var(--motion-ease-standard),
|
|
237
|
+
transform var(--motion-duration-fast) var(--motion-ease-standard),
|
|
238
|
+
visibility var(--motion-duration-fast) var(--motion-ease-standard);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.ds-ai-button-host--open .ds-ai-button__summary {
|
|
242
|
+
visibility: visible;
|
|
243
|
+
opacity: 1;
|
|
244
|
+
transform: none;
|
|
245
|
+
animation: ds-ai-summary-pop var(--motion-duration-deliberate) var(--motion-ease-entrance) both;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* Dark glass is already translucent; thinning it further puts body text
|
|
249
|
+
straight onto whatever bright imagery sits beneath. */
|
|
250
|
+
[data-theme='dark'] .ds-ai-button__summary {
|
|
251
|
+
background: var(--color-bg-glass);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* Placement: the panel grows out of the button, so its transform origin
|
|
255
|
+
points back at the button's side of the gap. */
|
|
256
|
+
.ds-ai-button-host--top .ds-ai-button__summary {
|
|
257
|
+
bottom: calc(100% + var(--gap-sm));
|
|
258
|
+
transform-origin: calc(100% - 60px) calc(100% + 40px);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.ds-ai-button-host--bottom .ds-ai-button__summary {
|
|
262
|
+
top: calc(100% + var(--gap-sm));
|
|
263
|
+
transform-origin: calc(100% - 60px) -40px;
|
|
264
|
+
transform: translateY(-10px) scale(0.9);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.ds-ai-button-host--bottom.ds-ai-button-host--open .ds-ai-button__summary {
|
|
268
|
+
transform: none;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
@keyframes ds-ai-summary-pop {
|
|
272
|
+
from {
|
|
273
|
+
opacity: 0;
|
|
274
|
+
transform: translateY(10px) scale(0.9);
|
|
275
|
+
}
|
|
276
|
+
to {
|
|
277
|
+
opacity: 1;
|
|
278
|
+
transform: translateY(0) scale(1);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.ds-ai-button-host--bottom.ds-ai-button-host--open .ds-ai-button__summary {
|
|
283
|
+
animation-name: ds-ai-summary-pop-down;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@keyframes ds-ai-summary-pop-down {
|
|
287
|
+
from {
|
|
288
|
+
opacity: 0;
|
|
289
|
+
transform: translateY(-10px) scale(0.9);
|
|
290
|
+
}
|
|
291
|
+
to {
|
|
292
|
+
opacity: 1;
|
|
293
|
+
transform: translateY(0) scale(1);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* The opening signature: the AI gradient sweeps once around the whole
|
|
298
|
+
border — the button's masked conic ring, run as a single swoosh — then
|
|
299
|
+
fades away, leaving the regular border. The rotation animates a
|
|
300
|
+
registered custom property; without @property support the ring still
|
|
301
|
+
fades in and out, just without turning. */
|
|
302
|
+
@property --ds-ai-summary-ring-angle {
|
|
303
|
+
syntax: '<angle>';
|
|
304
|
+
initial-value: 0deg;
|
|
305
|
+
inherits: false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.ds-ai-button__summary::before {
|
|
309
|
+
content: '';
|
|
310
|
+
position: absolute;
|
|
311
|
+
inset: calc(-1 * var(--border-xs));
|
|
312
|
+
border-radius: var(--radius-xl);
|
|
313
|
+
padding: var(--border-md);
|
|
314
|
+
background: conic-gradient(
|
|
315
|
+
from var(--ds-ai-summary-ring-angle),
|
|
316
|
+
var(--color-ai-gradient-start),
|
|
317
|
+
var(--color-ai-gradient-mid),
|
|
318
|
+
var(--color-ai-gradient-end),
|
|
319
|
+
var(--color-ai-gradient-mid),
|
|
320
|
+
var(--color-ai-gradient-start)
|
|
321
|
+
);
|
|
322
|
+
mask:
|
|
323
|
+
linear-gradient(#fff 0 0) content-box,
|
|
324
|
+
linear-gradient(#fff 0 0);
|
|
325
|
+
mask-composite: exclude;
|
|
326
|
+
pointer-events: none;
|
|
327
|
+
opacity: 0;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.ds-ai-button-host--open .ds-ai-button__summary::before {
|
|
331
|
+
/* ds-allow(motion): one-shot signature sweep timed to the panel's
|
|
332
|
+
entrance, tuned by eye — it runs once, then the border is ordinary */
|
|
333
|
+
animation: ds-ai-summary-ring 1500ms var(--motion-ease-standard) both;
|
|
334
|
+
animation-delay: 100ms;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
@keyframes ds-ai-summary-ring {
|
|
338
|
+
from {
|
|
339
|
+
--ds-ai-summary-ring-angle: 0deg;
|
|
340
|
+
opacity: 0;
|
|
341
|
+
}
|
|
342
|
+
12% {
|
|
343
|
+
opacity: 1;
|
|
344
|
+
}
|
|
345
|
+
65% {
|
|
346
|
+
opacity: 1;
|
|
347
|
+
}
|
|
348
|
+
to {
|
|
349
|
+
--ds-ai-summary-ring-angle: 360deg;
|
|
350
|
+
opacity: 0;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/* The content settles a beat behind the surface — each row fades up in
|
|
355
|
+
sequence, so the panel and its contents arrive as one gesture. */
|
|
356
|
+
.ds-ai-button-host--open .ds-ai-button__summary-meta,
|
|
357
|
+
.ds-ai-button-host--open .ds-ai-button__summary-title,
|
|
358
|
+
.ds-ai-button-host--open .ds-ai-button__summary-text,
|
|
359
|
+
.ds-ai-button-host--open .ds-ai-button__summary-actions {
|
|
360
|
+
animation: ds-ai-summary-rise var(--motion-duration-slow) var(--motion-ease-entrance) both;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/* ds-allow(motion): stagger offsets between rows, tuned by eye — the paced
|
|
364
|
+
durations themselves are the motion tokens above */
|
|
365
|
+
.ds-ai-button-host--open .ds-ai-button__summary-meta { animation-delay: 80ms; }
|
|
366
|
+
.ds-ai-button-host--open .ds-ai-button__summary-title { animation-delay: 130ms; }
|
|
367
|
+
.ds-ai-button-host--open .ds-ai-button__summary-text { animation-delay: 180ms; }
|
|
368
|
+
.ds-ai-button-host--open .ds-ai-button__summary-actions { animation-delay: 230ms; }
|
|
369
|
+
|
|
370
|
+
@keyframes ds-ai-summary-rise {
|
|
371
|
+
from {
|
|
372
|
+
opacity: 0;
|
|
373
|
+
transform: translateY(6px);
|
|
374
|
+
}
|
|
375
|
+
to {
|
|
376
|
+
opacity: 1;
|
|
377
|
+
transform: translateY(0);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.ds-ai-button__summary-dismiss {
|
|
382
|
+
position: absolute;
|
|
383
|
+
top: var(--gap-sm);
|
|
384
|
+
right: var(--gap-sm);
|
|
385
|
+
display: inline-flex;
|
|
386
|
+
align-items: center;
|
|
387
|
+
justify-content: center;
|
|
388
|
+
/* Icon-button hit target — a component-local size, chosen by eye. */
|
|
389
|
+
--ds-ai-summary-dismiss-size: 28px;
|
|
390
|
+
width: var(--ds-ai-summary-dismiss-size);
|
|
391
|
+
height: var(--ds-ai-summary-dismiss-size);
|
|
392
|
+
border: none;
|
|
393
|
+
border-radius: var(--radius-full);
|
|
394
|
+
background: transparent;
|
|
395
|
+
color: var(--color-text-secondary);
|
|
396
|
+
cursor: pointer;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
.ds-ai-button__summary-dismiss:hover {
|
|
400
|
+
background: var(--color-bg-container-secondary);
|
|
401
|
+
color: var(--color-text-primary);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.ds-ai-button__summary-dismiss .material-symbols-rounded {
|
|
405
|
+
font-size: var(--icon-size-sm);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.ds-ai-button__summary-meta {
|
|
409
|
+
display: flex;
|
|
410
|
+
align-items: baseline;
|
|
411
|
+
gap: var(--gap-sm);
|
|
412
|
+
/* Keeps the row clear of the dismiss button in the corner. */
|
|
413
|
+
padding-right: var(--gap-xl);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.ds-ai-button__summary-overline {
|
|
417
|
+
font-family: var(--font-overline-family);
|
|
418
|
+
font-size: var(--font-overline-size);
|
|
419
|
+
font-weight: var(--font-overline-weight);
|
|
420
|
+
line-height: var(--font-overline-line-height);
|
|
421
|
+
letter-spacing: var(--font-overline-letter-spacing);
|
|
422
|
+
text-transform: uppercase;
|
|
423
|
+
color: var(--color-text-secondary);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.ds-ai-button__summary-caption {
|
|
427
|
+
font-family: var(--font-family-primary);
|
|
428
|
+
font-size: var(--font-caption-size);
|
|
429
|
+
font-weight: var(--font-caption-weight);
|
|
430
|
+
line-height: var(--font-caption-line-height);
|
|
431
|
+
color: var(--color-text-tertiary);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.ds-ai-button__summary-title {
|
|
435
|
+
/* Long titles wrap short of the dismiss corner. */
|
|
436
|
+
padding-right: var(--gap-lg);
|
|
437
|
+
font-family: var(--font-family-primary);
|
|
438
|
+
font-size: var(--font-title-body-size);
|
|
439
|
+
font-weight: var(--font-title-body-weight);
|
|
440
|
+
line-height: var(--font-title-body-line-height);
|
|
441
|
+
color: var(--color-text-primary);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.ds-ai-button__summary-text {
|
|
445
|
+
display: block;
|
|
446
|
+
font-family: var(--font-family-primary);
|
|
447
|
+
font-size: var(--font-paragraph-size);
|
|
448
|
+
font-weight: var(--font-paragraph-weight);
|
|
449
|
+
line-height: var(--font-paragraph-line-height);
|
|
450
|
+
color: var(--color-text-secondary);
|
|
451
|
+
/* Seats the ghost/reveal stack below. */
|
|
452
|
+
position: relative;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* During the type-out, an invisible ghost of the finished text sizes the
|
|
456
|
+
container and the stream paints over it — the panel takes its final
|
|
457
|
+
height the moment "generation" completes, and a short summary reserves
|
|
458
|
+
nothing it won't use. */
|
|
459
|
+
.ds-ai-button__summary-text-ghost {
|
|
460
|
+
visibility: hidden;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.ds-ai-button__summary-text-reveal {
|
|
464
|
+
position: absolute;
|
|
465
|
+
inset: 0;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.ds-ai-button__summary-actions {
|
|
469
|
+
display: flex;
|
|
470
|
+
flex-direction: column;
|
|
471
|
+
align-items: flex-start;
|
|
472
|
+
gap: var(--gap-sm);
|
|
473
|
+
margin-top: var(--gap-xs);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
@media (prefers-reduced-motion: reduce) {
|
|
477
|
+
.ds-ai-button-host--open .ds-ai-button,
|
|
478
|
+
.ds-ai-button-host--open .ds-ai-button__summary,
|
|
479
|
+
.ds-ai-button-host--open .ds-ai-button__summary::before,
|
|
480
|
+
.ds-ai-button-host--open .ds-ai-button__summary-meta,
|
|
481
|
+
.ds-ai-button-host--open .ds-ai-button__summary-title,
|
|
482
|
+
.ds-ai-button-host--open .ds-ai-button__summary-text,
|
|
483
|
+
.ds-ai-button-host--open .ds-ai-button__summary-actions {
|
|
484
|
+
animation: none;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.ds-ai-button__summary {
|
|
488
|
+
transition: none;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { default as React } from 'react';
|
|
2
|
+
import { PromptSuggestion } from '../PromptSuggestions/PromptSuggestions';
|
|
3
|
+
/** The content of AiButton's summary panel. */
|
|
4
|
+
export interface AiButtonSummary {
|
|
5
|
+
/** Overline label above the title. */
|
|
6
|
+
overline?: string;
|
|
7
|
+
/** Title line — usually the page or subject being summarized. */
|
|
8
|
+
title: string;
|
|
9
|
+
/** Small caption beside the overline, e.g. a reading time. */
|
|
10
|
+
caption?: string;
|
|
11
|
+
/** The pre-written summary the panel reveals. */
|
|
12
|
+
text: string;
|
|
13
|
+
/** Prompt chips under the summary; a tap reports through `onSummarySuggestion`. */
|
|
14
|
+
suggestions?: PromptSuggestion[];
|
|
15
|
+
}
|
|
2
16
|
/** Props owned by AiButton itself — everything else falls through to the DOM node. */
|
|
3
17
|
type AiButtonOwnProps = {
|
|
4
18
|
/** Button text content */
|
|
@@ -15,6 +29,28 @@ type AiButtonOwnProps = {
|
|
|
15
29
|
target?: string;
|
|
16
30
|
/** Optional rel attribute for links */
|
|
17
31
|
rel?: string;
|
|
32
|
+
/**
|
|
33
|
+
* A TLDR panel the button can summon: overline, title, a pre-written
|
|
34
|
+
* summary revealed through a skeleton beat and a typed stream (once per
|
|
35
|
+
* mount — later openings show it instantly, as if cached), and optional
|
|
36
|
+
* prompt chips. Hovering the button opens it after the standard hover
|
|
37
|
+
* delay; the pointer keeps it alive anywhere over the button or panel,
|
|
38
|
+
* and it hides a grace period after leaving both. Omit for the plain
|
|
39
|
+
* button.
|
|
40
|
+
*/
|
|
41
|
+
summary?: AiButtonSummary;
|
|
42
|
+
/** Which side of the button the summary panel opens on. */
|
|
43
|
+
summaryPlacement?: 'top' | 'bottom';
|
|
44
|
+
/**
|
|
45
|
+
* Holds the summary panel open regardless of hover — for callers that
|
|
46
|
+
* reveal it on their own signal (scroll depth, dwell). Hover mechanics
|
|
47
|
+
* still run when it flips back off.
|
|
48
|
+
*/
|
|
49
|
+
summaryPinned?: boolean;
|
|
50
|
+
/** Fires when the panel's dismiss button is pressed. The panel closes itself either way; hovering away and back re-summons it. */
|
|
51
|
+
onSummaryDismiss?: () => void;
|
|
52
|
+
/** Fires with the tapped summary suggestion's `id`. */
|
|
53
|
+
onSummarySuggestion?: (id: string) => void;
|
|
18
54
|
/** Additional CSS classes */
|
|
19
55
|
className?: string;
|
|
20
56
|
};
|
|
@@ -28,6 +64,12 @@ export interface AiButtonProps extends AiButtonOwnProps, Omit<React.ComponentPro
|
|
|
28
64
|
* and this ring is reserved for AI surfaces so neither affordance dilutes
|
|
29
65
|
* the other.
|
|
30
66
|
*
|
|
67
|
+
* With `summary`, the button can also summon a TLDR panel — a glass card
|
|
68
|
+
* that opens above (or below) it on hover or when pinned, fakes a one-time
|
|
69
|
+
* generation beat (skeleton, then a typed reveal), and offers prompt chips.
|
|
70
|
+
* The panel and button share one hover zone, and the gradient sweeps once
|
|
71
|
+
* around the panel's border as it opens.
|
|
72
|
+
*
|
|
31
73
|
* Renders a `<button>`, or an `<a>` when `href` is supplied. Forwards a ref
|
|
32
74
|
* to whichever element it renders, and spreads unrecognised props onto it.
|
|
33
75
|
* The rotation pauses under `prefers-reduced-motion`; the ring and glow stay.
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs, Fragment } from "react/jsx-runtime";
|
|
3
|
-
import React from "react";
|
|
3
|
+
import React, { useState, useRef, useEffect } from "react";
|
|
4
|
+
import { PromptSuggestions } from "../PromptSuggestions/PromptSuggestions.js";
|
|
5
|
+
import { Skeleton } from "../Skeleton/Skeleton.js";
|
|
6
|
+
import { StreamingText } from "../StreamingText/StreamingText.js";
|
|
7
|
+
import { MOTION_SUMMARY_THINK_MS, MOTION_SUMMARY_REVEAL_MS, MOTION_HOVER_EXIT_GRACE_MS, MOTION_HOVER_SHOW_DELAY_MS } from "../../tokens/motion.js";
|
|
4
8
|
import "./AiButton.css";
|
|
5
9
|
import "../../fonts/material-symbols.css";
|
|
6
10
|
const AiButton = React.forwardRef(
|
|
@@ -12,10 +16,67 @@ const AiButton = React.forwardRef(
|
|
|
12
16
|
href,
|
|
13
17
|
target,
|
|
14
18
|
rel,
|
|
19
|
+
summary,
|
|
20
|
+
summaryPlacement = "top",
|
|
21
|
+
summaryPinned = false,
|
|
22
|
+
onSummaryDismiss,
|
|
23
|
+
onSummarySuggestion,
|
|
15
24
|
className = "",
|
|
16
25
|
...rest
|
|
17
26
|
}, ref) => {
|
|
18
27
|
const baseClass = "ds-ai-button";
|
|
28
|
+
const [hoverOpen, setHoverOpen] = useState(false);
|
|
29
|
+
const [dismissed, setDismissed] = useState(false);
|
|
30
|
+
const [phase, setPhase] = useState("skeleton");
|
|
31
|
+
const generated = useRef(false);
|
|
32
|
+
const showTimer = useRef(null);
|
|
33
|
+
const hideTimer = useRef(null);
|
|
34
|
+
const panelOpen = Boolean(summary) && !disabled && !dismissed && (summaryPinned || hoverOpen);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!panelOpen) return;
|
|
37
|
+
if (generated.current) {
|
|
38
|
+
setPhase("cached");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
setPhase("skeleton");
|
|
42
|
+
const t = window.setTimeout(() => {
|
|
43
|
+
generated.current = true;
|
|
44
|
+
setPhase("typing");
|
|
45
|
+
}, MOTION_SUMMARY_THINK_MS);
|
|
46
|
+
return () => window.clearTimeout(t);
|
|
47
|
+
}, [panelOpen]);
|
|
48
|
+
useEffect(
|
|
49
|
+
() => () => {
|
|
50
|
+
if (showTimer.current) window.clearTimeout(showTimer.current);
|
|
51
|
+
if (hideTimer.current) window.clearTimeout(hideTimer.current);
|
|
52
|
+
},
|
|
53
|
+
[]
|
|
54
|
+
);
|
|
55
|
+
const hostEnter = () => {
|
|
56
|
+
if (hideTimer.current) {
|
|
57
|
+
window.clearTimeout(hideTimer.current);
|
|
58
|
+
hideTimer.current = null;
|
|
59
|
+
}
|
|
60
|
+
if (!hoverOpen && !showTimer.current) {
|
|
61
|
+
showTimer.current = window.setTimeout(() => {
|
|
62
|
+
showTimer.current = null;
|
|
63
|
+
setHoverOpen(true);
|
|
64
|
+
}, MOTION_HOVER_SHOW_DELAY_MS);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const hostLeave = () => {
|
|
68
|
+
setDismissed(false);
|
|
69
|
+
if (showTimer.current) {
|
|
70
|
+
window.clearTimeout(showTimer.current);
|
|
71
|
+
showTimer.current = null;
|
|
72
|
+
}
|
|
73
|
+
if (hoverOpen && !hideTimer.current) {
|
|
74
|
+
hideTimer.current = window.setTimeout(() => {
|
|
75
|
+
hideTimer.current = null;
|
|
76
|
+
setHoverOpen(false);
|
|
77
|
+
}, MOTION_HOVER_EXIT_GRACE_MS);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
19
80
|
const classes = [
|
|
20
81
|
baseClass,
|
|
21
82
|
`${baseClass}--${size}`,
|
|
@@ -26,21 +87,18 @@ const AiButton = React.forwardRef(
|
|
|
26
87
|
icon && (typeof icon === "string" ? /* @__PURE__ */ jsx("span", { className: `${baseClass}__icon material-symbols-rounded`, "aria-hidden": "true", children: icon }) : /* @__PURE__ */ jsx("span", { className: `${baseClass}__icon`, "aria-hidden": "true", children: icon })),
|
|
27
88
|
/* @__PURE__ */ jsx("span", { className: `${baseClass}__text`, children: label })
|
|
28
89
|
] });
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
return /* @__PURE__ */ jsx(
|
|
90
|
+
const control = href && !disabled ? /* @__PURE__ */ jsx(
|
|
91
|
+
"a",
|
|
92
|
+
{
|
|
93
|
+
...rest,
|
|
94
|
+
ref,
|
|
95
|
+
className: classes,
|
|
96
|
+
href,
|
|
97
|
+
target,
|
|
98
|
+
rel,
|
|
99
|
+
children: content
|
|
100
|
+
}
|
|
101
|
+
) : /* @__PURE__ */ jsx(
|
|
44
102
|
"button",
|
|
45
103
|
{
|
|
46
104
|
...rest,
|
|
@@ -51,6 +109,72 @@ const AiButton = React.forwardRef(
|
|
|
51
109
|
children: content
|
|
52
110
|
}
|
|
53
111
|
);
|
|
112
|
+
if (!summary) return control;
|
|
113
|
+
const panelClass = `${baseClass}__summary`;
|
|
114
|
+
return /* @__PURE__ */ jsxs(
|
|
115
|
+
"div",
|
|
116
|
+
{
|
|
117
|
+
className: [
|
|
118
|
+
`${baseClass}-host`,
|
|
119
|
+
`${baseClass}-host--${summaryPlacement}`,
|
|
120
|
+
panelOpen && `${baseClass}-host--open`
|
|
121
|
+
].filter(Boolean).join(" "),
|
|
122
|
+
onMouseEnter: hostEnter,
|
|
123
|
+
onMouseLeave: hostLeave,
|
|
124
|
+
children: [
|
|
125
|
+
control,
|
|
126
|
+
/* @__PURE__ */ jsxs("aside", { className: panelClass, "aria-label": `Summary: ${summary.title}`, children: [
|
|
127
|
+
/* @__PURE__ */ jsx(
|
|
128
|
+
"button",
|
|
129
|
+
{
|
|
130
|
+
type: "button",
|
|
131
|
+
className: `${panelClass}-dismiss`,
|
|
132
|
+
"aria-label": "Dismiss summary",
|
|
133
|
+
onClick: () => {
|
|
134
|
+
setDismissed(true);
|
|
135
|
+
setHoverOpen(false);
|
|
136
|
+
onSummaryDismiss?.();
|
|
137
|
+
},
|
|
138
|
+
children: /* @__PURE__ */ jsx("span", { className: "material-symbols-rounded", "aria-hidden": "true", children: "close" })
|
|
139
|
+
}
|
|
140
|
+
),
|
|
141
|
+
/* @__PURE__ */ jsxs("span", { className: `${panelClass}-meta`, children: [
|
|
142
|
+
/* @__PURE__ */ jsx("span", { className: `${panelClass}-overline`, children: summary.overline ?? "AI summary" }),
|
|
143
|
+
summary.caption && /* @__PURE__ */ jsx("span", { className: `${panelClass}-caption`, children: summary.caption })
|
|
144
|
+
] }),
|
|
145
|
+
/* @__PURE__ */ jsx("span", { className: `${panelClass}-title`, children: summary.title }),
|
|
146
|
+
/* @__PURE__ */ jsx("span", { className: `${panelClass}-text`, children: phase === "skeleton" ? /* @__PURE__ */ jsx(Skeleton, { variant: "text", lines: 3 }) : phase === "typing" ? (
|
|
147
|
+
/* An invisible ghost of the finished text sizes the
|
|
148
|
+
container, so the type-out never jumps the panel and a
|
|
149
|
+
short summary leaves no reserved space behind it. */
|
|
150
|
+
/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
151
|
+
/* @__PURE__ */ jsx("span", { className: `${panelClass}-text-ghost`, "aria-hidden": "true", children: summary.text }),
|
|
152
|
+
/* @__PURE__ */ jsx("span", { className: `${panelClass}-text-reveal`, children: /* @__PURE__ */ jsx(
|
|
153
|
+
StreamingText,
|
|
154
|
+
{
|
|
155
|
+
text: summary.text,
|
|
156
|
+
streaming: false,
|
|
157
|
+
cursor: true,
|
|
158
|
+
drainMs: MOTION_SUMMARY_REVEAL_MS,
|
|
159
|
+
onRevealComplete: () => setPhase("cached")
|
|
160
|
+
}
|
|
161
|
+
) })
|
|
162
|
+
] })
|
|
163
|
+
) : summary.text }),
|
|
164
|
+
summary.suggestions && summary.suggestions.length > 0 && phase === "cached" && /* @__PURE__ */ jsx("span", { className: `${panelClass}-actions`, children: /* @__PURE__ */ jsx(
|
|
165
|
+
PromptSuggestions,
|
|
166
|
+
{
|
|
167
|
+
layout: "stack",
|
|
168
|
+
size: "compact",
|
|
169
|
+
ariaLabel: "Suggested prompts",
|
|
170
|
+
suggestions: summary.suggestions,
|
|
171
|
+
onValueChange: onSummarySuggestion
|
|
172
|
+
}
|
|
173
|
+
) })
|
|
174
|
+
] })
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
);
|
|
54
178
|
}
|
|
55
179
|
);
|
|
56
180
|
AiButton.displayName = "AiButton";
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsxs, jsx } from "react/jsx-runtime";
|
|
3
|
-
import { useRef, useId,
|
|
3
|
+
import { useRef, useId, useCallback } from "react";
|
|
4
4
|
import ReactDOM from "react-dom";
|
|
5
5
|
import { Button } from "../Button/Button.js";
|
|
6
6
|
import "./AlertDialog.css";
|
|
7
7
|
import "../../fonts/material-symbols.css";
|
|
8
|
-
|
|
9
|
-
};
|
|
10
|
-
|
|
8
|
+
import { useMounted } from "../../behaviors/useMounted.js";
|
|
9
|
+
import { useLayer } from "../../behaviors/useLayer.js";
|
|
10
|
+
import { useFocusScope } from "../../behaviors/useFocusScope.js";
|
|
11
|
+
import { useScrollLock } from "../../behaviors/useScrollLock.js";
|
|
11
12
|
const AlertDialog = ({
|
|
12
13
|
open,
|
|
13
14
|
onOpenChange,
|
|
@@ -21,11 +22,10 @@ const AlertDialog = ({
|
|
|
21
22
|
className = ""
|
|
22
23
|
}) => {
|
|
23
24
|
const panelRef = useRef(null);
|
|
24
|
-
const previousFocusRef = useRef(null);
|
|
25
25
|
const titleId = useId();
|
|
26
26
|
const descId = useId();
|
|
27
27
|
const baseClass = "ds-alert-dialog";
|
|
28
|
-
const mounted =
|
|
28
|
+
const mounted = useMounted();
|
|
29
29
|
const handleCancel = useCallback(() => {
|
|
30
30
|
onCancel?.();
|
|
31
31
|
onOpenChange(false);
|
|
@@ -34,58 +34,12 @@ const AlertDialog = ({
|
|
|
34
34
|
onConfirm?.();
|
|
35
35
|
onOpenChange(false);
|
|
36
36
|
}, [onConfirm, onOpenChange]);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (!open) return;
|
|
44
|
-
const panel = panelRef.current;
|
|
45
|
-
if (panel) {
|
|
46
|
-
const cancelBtn = panel.querySelector(
|
|
47
|
-
`.${baseClass}__actions button:first-child`
|
|
48
|
-
);
|
|
49
|
-
cancelBtn?.focus();
|
|
50
|
-
}
|
|
51
|
-
const handleKeyDown = (e) => {
|
|
52
|
-
if (e.key === "Escape") {
|
|
53
|
-
handleCancel();
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
if (e.key === "Tab" && panel) {
|
|
57
|
-
const focusable = panel.querySelectorAll(FOCUSABLE);
|
|
58
|
-
if (focusable.length === 0) return;
|
|
59
|
-
const first = focusable[0];
|
|
60
|
-
const last = focusable[focusable.length - 1];
|
|
61
|
-
if (e.shiftKey && document.activeElement === first) {
|
|
62
|
-
e.preventDefault();
|
|
63
|
-
last.focus();
|
|
64
|
-
} else if (!e.shiftKey && document.activeElement === last) {
|
|
65
|
-
e.preventDefault();
|
|
66
|
-
first.focus();
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
};
|
|
70
|
-
document.addEventListener("keydown", handleKeyDown);
|
|
71
|
-
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
72
|
-
}, [open, handleCancel]);
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
if (!open && previousFocusRef.current) {
|
|
75
|
-
previousFocusRef.current.focus();
|
|
76
|
-
previousFocusRef.current = null;
|
|
77
|
-
}
|
|
78
|
-
}, [open]);
|
|
79
|
-
useEffect(() => {
|
|
80
|
-
if (open) {
|
|
81
|
-
document.body.style.overflow = "hidden";
|
|
82
|
-
} else {
|
|
83
|
-
document.body.style.overflow = "";
|
|
84
|
-
}
|
|
85
|
-
return () => {
|
|
86
|
-
document.body.style.overflow = "";
|
|
87
|
-
};
|
|
88
|
-
}, [open]);
|
|
37
|
+
useLayer({ open, onDismiss: handleCancel });
|
|
38
|
+
useFocusScope(panelRef, {
|
|
39
|
+
active: open,
|
|
40
|
+
initialFocus: () => panelRef.current?.querySelector(`.${baseClass}__actions button:first-child`) ?? null
|
|
41
|
+
});
|
|
42
|
+
useScrollLock(open);
|
|
89
43
|
const variantClass = variant !== "default" ? `${baseClass}--${variant}` : "";
|
|
90
44
|
const openClass = open ? `${baseClass}--open` : "";
|
|
91
45
|
const containerClasses = [baseClass, variantClass, openClass, className].filter(Boolean).join(" ");
|
|
@@ -100,6 +54,7 @@ const AlertDialog = ({
|
|
|
100
54
|
"aria-modal": "true",
|
|
101
55
|
"aria-labelledby": titleId,
|
|
102
56
|
"aria-describedby": description ? descId : void 0,
|
|
57
|
+
tabIndex: -1,
|
|
103
58
|
children: [
|
|
104
59
|
/* @__PURE__ */ jsxs("div", { className: `${baseClass}__header`, children: [
|
|
105
60
|
/* @__PURE__ */ jsx(
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
line-height: var(--font-paragraph-em-line-height);
|
|
24
24
|
letter-spacing: var(--font-paragraph-em-letter-spacing);
|
|
25
25
|
text-align: center;
|
|
26
|
+
/* A pill's label never wraps — a squeezed layout shortens around the
|
|
27
|
+
button, it doesn't fold the silhouette into two lines. */
|
|
28
|
+
white-space: nowrap;
|
|
26
29
|
|
|
27
30
|
/* Transitions */
|
|
28
31
|
transition: background-color var(--motion-duration-base) var(--motion-ease-standard), border-color var(--motion-duration-base) var(--motion-ease-standard), opacity var(--motion-duration-base) var(--motion-ease-standard);
|