@protolabsai/ui 0.19.1 → 0.21.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/dist/plugin-kit.css +260 -0
- package/package.json +1 -1
- package/src/Empty.stories.tsx +32 -0
- package/src/Grid.stories.tsx +37 -0
- package/src/TabBar.stories.tsx +74 -0
- package/src/app-shell.tsx +127 -54
- package/src/layout.tsx +40 -1
- package/src/navigation.tsx +119 -0
- package/src/primitives.tsx +31 -3
- package/src/styles/app-shell.css +50 -0
- package/src/styles/layout.css +43 -0
- package/src/styles/navigation.css +131 -0
- package/src/styles/primitives.css +36 -0
package/dist/plugin-kit.css
CHANGED
|
@@ -336,6 +336,42 @@ a:hover {
|
|
|
336
336
|
padding: 1rem 0;
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
/* Slotted form (icon/title/description/action) — centered scaffold. */
|
|
340
|
+
.pl-empty--slotted {
|
|
341
|
+
display: flex;
|
|
342
|
+
flex-direction: column;
|
|
343
|
+
align-items: center;
|
|
344
|
+
justify-content: center;
|
|
345
|
+
gap: var(--pl-space-2);
|
|
346
|
+
padding: var(--pl-space-6) var(--pl-space-4);
|
|
347
|
+
text-align: center;
|
|
348
|
+
}
|
|
349
|
+
.pl-empty__icon {
|
|
350
|
+
display: inline-flex;
|
|
351
|
+
margin-bottom: var(--pl-space-1);
|
|
352
|
+
color: var(--pl-color-fg-subtle);
|
|
353
|
+
}
|
|
354
|
+
.pl-empty__icon svg {
|
|
355
|
+
width: 28px;
|
|
356
|
+
height: 28px;
|
|
357
|
+
}
|
|
358
|
+
.pl-empty__title {
|
|
359
|
+
font-family: var(--pl-font-sans);
|
|
360
|
+
font-size: 14px;
|
|
361
|
+
font-weight: var(--pl-font-weight-medium);
|
|
362
|
+
color: var(--pl-color-fg);
|
|
363
|
+
}
|
|
364
|
+
.pl-empty__desc {
|
|
365
|
+
max-width: 44ch;
|
|
366
|
+
font-family: var(--pl-font-sans);
|
|
367
|
+
font-size: 13px;
|
|
368
|
+
line-height: 1.6;
|
|
369
|
+
color: var(--pl-color-fg-muted);
|
|
370
|
+
}
|
|
371
|
+
.pl-empty__action {
|
|
372
|
+
margin-top: var(--pl-space-2);
|
|
373
|
+
}
|
|
374
|
+
|
|
339
375
|
/* ── divider ── */
|
|
340
376
|
.pl-divider {
|
|
341
377
|
border: 0;
|
|
@@ -572,6 +608,49 @@ a:hover {
|
|
|
572
608
|
}
|
|
573
609
|
}
|
|
574
610
|
|
|
611
|
+
/* ── Grid (responsive card grid) ── */
|
|
612
|
+
.pl-grid {
|
|
613
|
+
display: grid;
|
|
614
|
+
}
|
|
615
|
+
.pl-grid--gap-sm {
|
|
616
|
+
gap: var(--pl-space-2);
|
|
617
|
+
}
|
|
618
|
+
.pl-grid--gap-md {
|
|
619
|
+
gap: var(--pl-space-4);
|
|
620
|
+
}
|
|
621
|
+
.pl-grid--gap-lg {
|
|
622
|
+
gap: var(--pl-space-6);
|
|
623
|
+
}
|
|
624
|
+
/* auto-fill: as many columns of >= --pl-grid-min as fit */
|
|
625
|
+
.pl-grid--auto {
|
|
626
|
+
grid-template-columns: repeat(auto-fill, minmax(var(--pl-grid-min, 14rem), 1fr));
|
|
627
|
+
}
|
|
628
|
+
/* fixed/responsive column count — each breakpoint falls back to the nearest
|
|
629
|
+
smaller value that was set, so `cols={{ base:1, md:2, xl:3 }}` cascades cleanly */
|
|
630
|
+
.pl-grid--cols {
|
|
631
|
+
grid-template-columns: repeat(var(--pl-grid-cols, 1), minmax(0, 1fr));
|
|
632
|
+
}
|
|
633
|
+
@media (min-width: 640px) {
|
|
634
|
+
.pl-grid--cols {
|
|
635
|
+
grid-template-columns: repeat(var(--pl-grid-cols-sm, var(--pl-grid-cols, 1)), minmax(0, 1fr));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
@media (min-width: 768px) {
|
|
639
|
+
.pl-grid--cols {
|
|
640
|
+
grid-template-columns: repeat(var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1))), minmax(0, 1fr));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
@media (min-width: 1024px) {
|
|
644
|
+
.pl-grid--cols {
|
|
645
|
+
grid-template-columns: repeat(var(--pl-grid-cols-lg, var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1)))), minmax(0, 1fr));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
@media (min-width: 1280px) {
|
|
649
|
+
.pl-grid--cols {
|
|
650
|
+
grid-template-columns: repeat(var(--pl-grid-cols-xl, var(--pl-grid-cols-lg, var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1))))), minmax(0, 1fr));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
575
654
|
/* ── row (label | body [| status]) ── */
|
|
576
655
|
.pl-row {
|
|
577
656
|
display: grid;
|
|
@@ -1282,6 +1361,137 @@ a.pl-changelog__version:hover {
|
|
|
1282
1361
|
color: var(--pl-color-fg-muted);
|
|
1283
1362
|
}
|
|
1284
1363
|
|
|
1364
|
+
/* ── TabBar (browser-style session tabs) ────────────────────────────────────── */
|
|
1365
|
+
/* On a narrow window session tabs scroll horizontally (browser/VS Code behaviour) —
|
|
1366
|
+
they do NOT collapse to a <select> like Tabs, because a select can't host the
|
|
1367
|
+
per-tab close / inline rename / add-new affordances that are the point of TabBar. */
|
|
1368
|
+
.pl-tabbar {
|
|
1369
|
+
display: flex;
|
|
1370
|
+
align-items: stretch;
|
|
1371
|
+
gap: 2px;
|
|
1372
|
+
overflow-x: auto;
|
|
1373
|
+
scrollbar-width: thin;
|
|
1374
|
+
border-bottom: var(--pl-border-width) solid var(--pl-color-border);
|
|
1375
|
+
}
|
|
1376
|
+
.pl-tabbar__tab {
|
|
1377
|
+
display: inline-flex;
|
|
1378
|
+
align-items: center;
|
|
1379
|
+
gap: 7px;
|
|
1380
|
+
flex: 0 1 auto;
|
|
1381
|
+
min-width: 92px;
|
|
1382
|
+
max-width: 220px;
|
|
1383
|
+
padding: 7px 10px 7px 12px;
|
|
1384
|
+
background: none;
|
|
1385
|
+
border: none;
|
|
1386
|
+
border-bottom: 2px solid transparent;
|
|
1387
|
+
color: var(--pl-color-fg-muted);
|
|
1388
|
+
font-family: var(--pl-font-sans);
|
|
1389
|
+
font-size: 13px;
|
|
1390
|
+
cursor: pointer;
|
|
1391
|
+
border-radius: var(--pl-radius) var(--pl-radius) 0 0;
|
|
1392
|
+
transition:
|
|
1393
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
1394
|
+
color var(--pl-motion-fast) var(--pl-motion-ease);
|
|
1395
|
+
}
|
|
1396
|
+
.pl-tabbar__tab:hover {
|
|
1397
|
+
background: var(--pl-color-bg-hover);
|
|
1398
|
+
color: var(--pl-color-fg);
|
|
1399
|
+
}
|
|
1400
|
+
.pl-tabbar__tab:focus-visible {
|
|
1401
|
+
outline: 2px solid var(--pl-color-accent);
|
|
1402
|
+
outline-offset: -2px;
|
|
1403
|
+
}
|
|
1404
|
+
.pl-tabbar__tab--active {
|
|
1405
|
+
color: var(--pl-color-fg);
|
|
1406
|
+
background: var(--pl-color-bg-subtle);
|
|
1407
|
+
border-bottom-color: var(--pl-color-accent);
|
|
1408
|
+
}
|
|
1409
|
+
.pl-tabbar__icon {
|
|
1410
|
+
display: inline-flex;
|
|
1411
|
+
align-items: center;
|
|
1412
|
+
}
|
|
1413
|
+
.pl-tabbar__icon svg {
|
|
1414
|
+
width: 15px;
|
|
1415
|
+
height: 15px;
|
|
1416
|
+
}
|
|
1417
|
+
.pl-tabbar__label {
|
|
1418
|
+
min-width: 0;
|
|
1419
|
+
overflow: hidden;
|
|
1420
|
+
text-overflow: ellipsis;
|
|
1421
|
+
white-space: nowrap;
|
|
1422
|
+
}
|
|
1423
|
+
.pl-tabbar__edit {
|
|
1424
|
+
min-width: 60px;
|
|
1425
|
+
max-width: 140px;
|
|
1426
|
+
padding: 1px 4px;
|
|
1427
|
+
font: inherit;
|
|
1428
|
+
font-size: 13px;
|
|
1429
|
+
color: var(--pl-color-fg);
|
|
1430
|
+
background: var(--pl-color-bg-inset);
|
|
1431
|
+
border: var(--pl-border-width) solid var(--pl-color-accent);
|
|
1432
|
+
border-radius: calc(var(--pl-radius) - 2px);
|
|
1433
|
+
outline: none;
|
|
1434
|
+
}
|
|
1435
|
+
.pl-tabbar__badge {
|
|
1436
|
+
display: inline-flex;
|
|
1437
|
+
align-items: center;
|
|
1438
|
+
justify-content: center;
|
|
1439
|
+
min-width: 16px;
|
|
1440
|
+
height: 16px;
|
|
1441
|
+
padding: 0 5px;
|
|
1442
|
+
font-family: var(--pl-font-mono);
|
|
1443
|
+
font-size: 10px;
|
|
1444
|
+
line-height: 1;
|
|
1445
|
+
color: var(--pl-color-fg-muted);
|
|
1446
|
+
background: var(--pl-color-bg-subtle);
|
|
1447
|
+
border: var(--pl-border-width) solid var(--pl-color-border);
|
|
1448
|
+
border-radius: 999px;
|
|
1449
|
+
}
|
|
1450
|
+
.pl-tabbar__close {
|
|
1451
|
+
display: inline-flex;
|
|
1452
|
+
align-items: center;
|
|
1453
|
+
justify-content: center;
|
|
1454
|
+
width: 18px;
|
|
1455
|
+
height: 18px;
|
|
1456
|
+
margin-right: -3px;
|
|
1457
|
+
padding: 0;
|
|
1458
|
+
color: var(--pl-color-fg-subtle);
|
|
1459
|
+
background: none;
|
|
1460
|
+
border: none;
|
|
1461
|
+
border-radius: var(--pl-radius);
|
|
1462
|
+
cursor: pointer;
|
|
1463
|
+
opacity: 0.7;
|
|
1464
|
+
transition:
|
|
1465
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
1466
|
+
color var(--pl-motion-fast) var(--pl-motion-ease),
|
|
1467
|
+
opacity var(--pl-motion-fast) var(--pl-motion-ease);
|
|
1468
|
+
}
|
|
1469
|
+
.pl-tabbar__close:hover {
|
|
1470
|
+
color: var(--pl-color-fg);
|
|
1471
|
+
background: var(--pl-color-bg-hover);
|
|
1472
|
+
opacity: 1;
|
|
1473
|
+
}
|
|
1474
|
+
.pl-tabbar__add {
|
|
1475
|
+
display: inline-flex;
|
|
1476
|
+
align-items: center;
|
|
1477
|
+
justify-content: center;
|
|
1478
|
+
width: 28px;
|
|
1479
|
+
flex-shrink: 0;
|
|
1480
|
+
margin-left: 2px;
|
|
1481
|
+
color: var(--pl-color-fg-muted);
|
|
1482
|
+
background: none;
|
|
1483
|
+
border: none;
|
|
1484
|
+
cursor: pointer;
|
|
1485
|
+
border-radius: var(--pl-radius);
|
|
1486
|
+
transition:
|
|
1487
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
1488
|
+
color var(--pl-motion-fast) var(--pl-motion-ease);
|
|
1489
|
+
}
|
|
1490
|
+
.pl-tabbar__add:hover {
|
|
1491
|
+
color: var(--pl-color-fg);
|
|
1492
|
+
background: var(--pl-color-bg-hover);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1285
1495
|
/* ── ui component: forms.css ───────────────────────────────────────────────── */
|
|
1286
1496
|
/* @protolabsai/ui — forms styles (over @protolabsai/design --pl-* tokens). */
|
|
1287
1497
|
|
|
@@ -2515,6 +2725,56 @@ a.pl-changelog__version:hover {
|
|
|
2515
2725
|
outline: none;
|
|
2516
2726
|
}
|
|
2517
2727
|
|
|
2728
|
+
/* Reopen handle — the grab strip a collapsed side leaves at its edge. Drag it in to
|
|
2729
|
+
size the panel open (VS Code style), or tap/Enter to pop it to its min width. */
|
|
2730
|
+
.pl-appshell__reopen {
|
|
2731
|
+
position: relative;
|
|
2732
|
+
flex: 0 0 6px;
|
|
2733
|
+
align-self: stretch;
|
|
2734
|
+
padding: 0;
|
|
2735
|
+
background: transparent;
|
|
2736
|
+
border: none;
|
|
2737
|
+
cursor: col-resize;
|
|
2738
|
+
touch-action: none;
|
|
2739
|
+
transition: background var(--pl-motion-fast) var(--pl-motion-ease);
|
|
2740
|
+
}
|
|
2741
|
+
.pl-appshell__reopen--right {
|
|
2742
|
+
border-left: var(--pl-border-width) solid var(--pl-color-border);
|
|
2743
|
+
}
|
|
2744
|
+
.pl-appshell__reopen--left {
|
|
2745
|
+
border-right: var(--pl-border-width) solid var(--pl-color-border);
|
|
2746
|
+
}
|
|
2747
|
+
.pl-appshell__reopen:hover,
|
|
2748
|
+
.pl-appshell__reopen:focus-visible {
|
|
2749
|
+
background: var(--pl-color-bg-hover);
|
|
2750
|
+
outline: none;
|
|
2751
|
+
}
|
|
2752
|
+
/* a centered grip that surfaces on hover/focus so the affordance is discoverable */
|
|
2753
|
+
.pl-appshell__reopen::after {
|
|
2754
|
+
content: "";
|
|
2755
|
+
position: absolute;
|
|
2756
|
+
top: 50%;
|
|
2757
|
+
left: 50%;
|
|
2758
|
+
width: 2px;
|
|
2759
|
+
height: 24px;
|
|
2760
|
+
transform: translate(-50%, -50%);
|
|
2761
|
+
border-radius: 2px;
|
|
2762
|
+
background: var(--pl-color-border-strong);
|
|
2763
|
+
opacity: 0;
|
|
2764
|
+
transition: opacity var(--pl-motion-fast) var(--pl-motion-ease);
|
|
2765
|
+
}
|
|
2766
|
+
.pl-appshell__reopen:hover::after,
|
|
2767
|
+
.pl-appshell__reopen:focus-visible::after {
|
|
2768
|
+
opacity: 1;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
/* While a divider/reopen gesture is live, kill text selection + force the resize
|
|
2772
|
+
cursor everywhere (the pointer roams the whole window during the drag). */
|
|
2773
|
+
.pl-appshell-frame--dragging {
|
|
2774
|
+
cursor: col-resize;
|
|
2775
|
+
user-select: none;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2518
2778
|
.pl-appshell--mobile {
|
|
2519
2779
|
flex-direction: column;
|
|
2520
2780
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Empty, Button } from "./primitives";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof Empty> = { title: "Components/Primitives/Empty" };
|
|
5
|
+
export default meta;
|
|
6
|
+
type Story = StoryObj<typeof Empty>;
|
|
7
|
+
|
|
8
|
+
const Inbox = () => (
|
|
9
|
+
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
|
10
|
+
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
|
11
|
+
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
|
12
|
+
</svg>
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
/** Slotted: icon + title + description + action — the standard scaffold. */
|
|
16
|
+
export const Slotted: Story = {
|
|
17
|
+
render: () => (
|
|
18
|
+
<div style={{ maxWidth: 420, border: "1px solid var(--pl-color-border)", borderRadius: 8 }}>
|
|
19
|
+
<Empty
|
|
20
|
+
icon={<Inbox />}
|
|
21
|
+
title="No agents yet"
|
|
22
|
+
description="Discover protoAgents on your network to add them as delegates."
|
|
23
|
+
action={<Button>Discover</Button>}
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Bare form still works unchanged (back-compat). */
|
|
30
|
+
export const Bare: Story = {
|
|
31
|
+
render: () => <Empty>no open contracts</Empty>,
|
|
32
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Grid } from "./layout";
|
|
3
|
+
import { Card } from "./primitives";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Grid> = { title: "Components/Layout/Grid" };
|
|
6
|
+
export default meta;
|
|
7
|
+
type Story = StoryObj<typeof Grid>;
|
|
8
|
+
|
|
9
|
+
const cards = (n: number) =>
|
|
10
|
+
Array.from({ length: n }, (_, i) => (
|
|
11
|
+
<Card key={i} style={{ padding: 16, fontFamily: "var(--pl-font-mono)", fontSize: 13, color: "var(--pl-color-fg-muted)" }}>
|
|
12
|
+
card {i + 1}
|
|
13
|
+
</Card>
|
|
14
|
+
));
|
|
15
|
+
|
|
16
|
+
/** `min` — auto-fill: as many columns of ≥ the floor as fit. Resize the canvas. */
|
|
17
|
+
export const AutoFill: Story = {
|
|
18
|
+
render: () => <Grid min="14rem" gap="md">{cards(8)}</Grid>,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** `cols` — responsive count, cascading by breakpoint (1 → 2 at md → 3 at xl). */
|
|
22
|
+
export const ResponsiveCols: Story = {
|
|
23
|
+
render: () => (
|
|
24
|
+
<Grid cols={{ base: 1, md: 2, xl: 3 }} gap="md">
|
|
25
|
+
{cards(6)}
|
|
26
|
+
</Grid>
|
|
27
|
+
),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** `cols` — a fixed count. */
|
|
31
|
+
export const FixedCols: Story = {
|
|
32
|
+
render: () => (
|
|
33
|
+
<Grid cols={4} gap="lg">
|
|
34
|
+
{cards(8)}
|
|
35
|
+
</Grid>
|
|
36
|
+
),
|
|
37
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { TabBar } from "./navigation";
|
|
4
|
+
import type { TabBarItem } from "./navigation";
|
|
5
|
+
|
|
6
|
+
const meta: Meta = { title: "Components/Navigation/TabBar" };
|
|
7
|
+
export default meta;
|
|
8
|
+
type Story = StoryObj;
|
|
9
|
+
|
|
10
|
+
/** Full browser-style session tabs: close ✕, double-click to rename, "+" to add.
|
|
11
|
+
* Drop any callback to hide its affordance. On a narrow canvas the strip scrolls
|
|
12
|
+
* (it does NOT collapse to a select — that can't carry close/rename/add). */
|
|
13
|
+
export const Sessions: Story = {
|
|
14
|
+
render: () => {
|
|
15
|
+
function Demo() {
|
|
16
|
+
const [tabs, setTabs] = useState<TabBarItem[]>([
|
|
17
|
+
{ id: "a", label: "research" },
|
|
18
|
+
{ id: "b", label: "draft", badge: 3 },
|
|
19
|
+
{ id: "c", label: "review" },
|
|
20
|
+
]);
|
|
21
|
+
const [active, setActive] = useState("a");
|
|
22
|
+
let seq = tabs.length;
|
|
23
|
+
return (
|
|
24
|
+
<div style={{ maxWidth: 520 }}>
|
|
25
|
+
<TabBar
|
|
26
|
+
ariaLabel="Sessions"
|
|
27
|
+
items={tabs}
|
|
28
|
+
activeId={active}
|
|
29
|
+
onSelect={setActive}
|
|
30
|
+
onClose={(id) => {
|
|
31
|
+
setTabs((ts) => {
|
|
32
|
+
const next = ts.filter((t) => t.id !== id);
|
|
33
|
+
if (id === active && next[0]) setActive(next[0].id);
|
|
34
|
+
return next;
|
|
35
|
+
});
|
|
36
|
+
}}
|
|
37
|
+
onRename={(id, label) => setTabs((ts) => ts.map((t) => (t.id === id ? { ...t, label } : t)))}
|
|
38
|
+
onAdd={() => {
|
|
39
|
+
const id = `s${++seq}`;
|
|
40
|
+
setTabs((ts) => [...ts, { id, label: `session ${seq}` }]);
|
|
41
|
+
setActive(id);
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
<p style={{ fontFamily: "var(--pl-font-mono)", fontSize: 12, color: "var(--pl-color-fg-muted)" }}>
|
|
45
|
+
active: {active} · double-click a tab to rename
|
|
46
|
+
</p>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return <Demo />;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Read-only: no callbacks beyond select → degrades to a plain strip. */
|
|
55
|
+
export const SelectOnly: Story = {
|
|
56
|
+
render: () => {
|
|
57
|
+
function Demo() {
|
|
58
|
+
const [active, setActive] = useState("a");
|
|
59
|
+
return (
|
|
60
|
+
<TabBar
|
|
61
|
+
ariaLabel="Views"
|
|
62
|
+
items={[
|
|
63
|
+
{ id: "a", label: "overview" },
|
|
64
|
+
{ id: "b", label: "activity" },
|
|
65
|
+
{ id: "c", label: "settings" },
|
|
66
|
+
]}
|
|
67
|
+
activeId={active}
|
|
68
|
+
onSelect={setActive}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return <Demo />;
|
|
73
|
+
},
|
|
74
|
+
};
|
package/src/app-shell.tsx
CHANGED
|
@@ -253,6 +253,11 @@ function useIsMobile(breakpoint: number) {
|
|
|
253
253
|
|
|
254
254
|
type RailOrder = { left: string[]; right: string[] };
|
|
255
255
|
|
|
256
|
+
// An active divider/reopen gesture. `move`/`up` are the window listeners for this
|
|
257
|
+
// gesture (stored so they can be detached) — see the drag block in AppShell.
|
|
258
|
+
type DragKind = "divider" | "reopen-right" | "reopen-left";
|
|
259
|
+
type DragSession = { kind: DragKind; move: (e: PointerEvent) => void; up: (e: PointerEvent) => void };
|
|
260
|
+
|
|
256
261
|
export type AppShellProps = {
|
|
257
262
|
leftItems: RailItem[];
|
|
258
263
|
rightItems: RailItem[];
|
|
@@ -331,70 +336,107 @@ export function AppShell({
|
|
|
331
336
|
}: AppShellProps) {
|
|
332
337
|
const isMobile = useIsMobile(mobileBreakpoint);
|
|
333
338
|
|
|
334
|
-
// ──
|
|
335
|
-
// One divider, zero-sum: the right column is width-controlled and the left
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
|
|
339
|
+
// ── divider + collapse/reopen drag ──
|
|
340
|
+
// One divider, zero-sum: the right column is width-controlled and the left flexes,
|
|
341
|
+
// so dragging grows one column as it shrinks the other. The gesture runs on WINDOW
|
|
342
|
+
// listeners so it survives the layout change when a side closes — collapse is only
|
|
343
|
+
// COMMITTED on pointer-up (drag back out before releasing to recover an accidental
|
|
344
|
+
// close), the panel can shrink past its min toward the edge, and a collapsed side
|
|
345
|
+
// exposes a reopen handle you can drag (or tap) back open. Collapse fires once the
|
|
346
|
+
// side is dragged below half its min — you have to bring it well in, not just nudge.
|
|
347
|
+
const COLLAPSE_RIGHT = Math.round(minRightWidth * 0.5);
|
|
348
|
+
const COLLAPSE_LEFT = Math.round(minLeftWidth * 0.5);
|
|
349
|
+
const REOPEN_TAP = 6; // movement under this on a reopen handle counts as a tap (open at min)
|
|
340
350
|
const leftColRef = useRef<HTMLElement | null>(null);
|
|
341
|
-
const drag = useRef<
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
351
|
+
const drag = useRef<DragSession | null>(null);
|
|
352
|
+
const [dragging, setDragging] = useState(false);
|
|
353
|
+
|
|
354
|
+
/** rightWidth snapped back into the valid open range (left keeps its min). */
|
|
355
|
+
const clampOpen = useCallback(
|
|
356
|
+
(w: number, spanW: number) => {
|
|
357
|
+
const upper = Math.max(minRightWidth, Math.min(maxRightWidth, spanW - minLeftWidth));
|
|
358
|
+
return Math.min(upper, Math.max(minRightWidth, w));
|
|
347
359
|
},
|
|
348
|
-
[minRightWidth, maxRightWidth, minLeftWidth
|
|
360
|
+
[minRightWidth, maxRightWidth, minLeftWidth],
|
|
349
361
|
);
|
|
350
|
-
|
|
351
|
-
|
|
362
|
+
|
|
363
|
+
const endGesture = useCallback(() => {
|
|
364
|
+
const d = drag.current;
|
|
365
|
+
if (d) {
|
|
366
|
+
window.removeEventListener("pointermove", d.move);
|
|
367
|
+
window.removeEventListener("pointerup", d.up);
|
|
368
|
+
window.removeEventListener("pointercancel", d.up);
|
|
369
|
+
}
|
|
370
|
+
drag.current = null;
|
|
371
|
+
setDragging(false);
|
|
372
|
+
}, []);
|
|
373
|
+
|
|
374
|
+
const beginDrag = useCallback(
|
|
375
|
+
(kind: DragKind, e: ReactPointerEvent<HTMLElement>) => {
|
|
352
376
|
e.preventDefault();
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (!d) return;
|
|
362
|
-
const raw = d.startW + (d.startX - e.clientX);
|
|
363
|
-
const predictedLeft = d.startLeftW - (raw - d.startW);
|
|
364
|
-
const endDrag = () => {
|
|
365
|
-
drag.current = null;
|
|
366
|
-
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
367
|
-
};
|
|
368
|
-
// Overdrag toward the right rail → snap the right column closed.
|
|
369
|
-
if (onCollapse && raw < minRightWidth - OVERDRAG) {
|
|
370
|
-
endDrag();
|
|
371
|
-
onCollapse(true);
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
// Overdrag toward the left rail → snap the left column closed.
|
|
375
|
-
if (onLeftCollapse && predictedLeft < minLeftWidth - OVERDRAG) {
|
|
376
|
-
endDrag();
|
|
377
|
-
onLeftCollapse(true);
|
|
378
|
-
return;
|
|
377
|
+
const leftW = leftColRef.current?.clientWidth ?? 0;
|
|
378
|
+
// Reopen reveals the side first so it can grow from the edge under the pointer.
|
|
379
|
+
const startW = kind === "reopen-right" ? 0 : rightWidth;
|
|
380
|
+
if (kind === "reopen-right") {
|
|
381
|
+
onCollapse?.(false);
|
|
382
|
+
onRightWidthChange(0);
|
|
383
|
+
} else if (kind === "reopen-left") {
|
|
384
|
+
onLeftCollapse?.(false);
|
|
379
385
|
}
|
|
380
|
-
|
|
386
|
+
const startX = e.clientX;
|
|
387
|
+
// The two columns share a fixed span during the gesture (zero-sum divider).
|
|
388
|
+
const spanW = startW + leftW;
|
|
389
|
+
const rightFromPointer = (clientX: number) =>
|
|
390
|
+
Math.max(0, Math.min(spanW || maxRightWidth, startW + (startX - clientX)));
|
|
391
|
+
|
|
392
|
+
const move = (ev: PointerEvent) => {
|
|
393
|
+
if (kind === "reopen-left") return; // left is the flex column — reopening is binary
|
|
394
|
+
onRightWidthChange(rightFromPointer(ev.clientX));
|
|
395
|
+
};
|
|
396
|
+
const up = (ev: PointerEvent) => {
|
|
397
|
+
if (kind === "reopen-left") {
|
|
398
|
+
endGesture();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const moved = Math.abs(ev.clientX - startX);
|
|
402
|
+
const raw = rightFromPointer(ev.clientX);
|
|
403
|
+
const span = spanW || raw + minLeftWidth;
|
|
404
|
+
const leftAtRaw = span - raw;
|
|
405
|
+
if (kind === "reopen-right" && moved < REOPEN_TAP) {
|
|
406
|
+
onCollapse?.(false); // a tap on the reopen handle → just open at min
|
|
407
|
+
onRightWidthChange(clampOpen(minRightWidth, span));
|
|
408
|
+
} else if (onCollapse && raw < COLLAPSE_RIGHT) {
|
|
409
|
+
onCollapse(true);
|
|
410
|
+
onRightWidthChange(clampOpen(startW || minRightWidth, span)); // sane width for next open
|
|
411
|
+
} else if (onLeftCollapse && spanW > 0 && leftAtRaw < COLLAPSE_LEFT) {
|
|
412
|
+
onLeftCollapse(true);
|
|
413
|
+
onRightWidthChange(clampOpen(startW || minRightWidth, span)); // restore right for when left reopens
|
|
414
|
+
} else {
|
|
415
|
+
onCollapse?.(false);
|
|
416
|
+
onRightWidthChange(clampOpen(raw, span));
|
|
417
|
+
}
|
|
418
|
+
endGesture();
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
drag.current = { kind, move, up };
|
|
422
|
+
window.addEventListener("pointermove", move);
|
|
423
|
+
window.addEventListener("pointerup", up);
|
|
424
|
+
window.addEventListener("pointercancel", up);
|
|
425
|
+
setDragging(true);
|
|
381
426
|
},
|
|
382
|
-
[minRightWidth, minLeftWidth, onCollapse, onLeftCollapse,
|
|
427
|
+
[rightWidth, minRightWidth, minLeftWidth, maxRightWidth, onRightWidthChange, onCollapse, onLeftCollapse, clampOpen, endGesture, COLLAPSE_RIGHT, COLLAPSE_LEFT],
|
|
383
428
|
);
|
|
384
|
-
|
|
385
|
-
drag.current = null;
|
|
386
|
-
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
387
|
-
}, []);
|
|
429
|
+
|
|
388
430
|
const onKeyDown = useCallback(
|
|
389
431
|
(e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
390
432
|
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
391
433
|
e.preventDefault();
|
|
392
434
|
const step = e.shiftKey ? 48 : 16;
|
|
393
|
-
const
|
|
435
|
+
const spanW = rightWidth + (leftColRef.current?.clientWidth ?? minLeftWidth);
|
|
394
436
|
// ArrowLeft grows the right column (shrinks the left); ArrowRight reverses.
|
|
395
|
-
|
|
437
|
+
onRightWidthChange(clampOpen(rightWidth + (e.key === "ArrowLeft" ? step : -step), spanW));
|
|
396
438
|
},
|
|
397
|
-
[rightWidth,
|
|
439
|
+
[rightWidth, onRightWidthChange, clampOpen],
|
|
398
440
|
);
|
|
399
441
|
|
|
400
442
|
// ── rail drag-and-drop (only when onRailReorder is provided) ──
|
|
@@ -493,11 +535,30 @@ export function AppShell({
|
|
|
493
535
|
const ctxLeft = onRailContextMenu ? (e: ReactMouseEvent, id: string) => onRailContextMenu("left", e, id) : undefined;
|
|
494
536
|
const ctxRight = onRailContextMenu ? (e: ReactMouseEvent, id: string) => onRailContextMenu("right", e, id) : undefined;
|
|
495
537
|
|
|
538
|
+
// Collapsed-but-present sides expose a reopen handle (drag in to size it open, or
|
|
539
|
+
// tap to pop it to min) — VS Code style.
|
|
540
|
+
const canReopenRight = !showRight && rightItems.length > 0 && showLeft;
|
|
541
|
+
const canReopenLeft = leftCollapsed && showRight;
|
|
542
|
+
|
|
496
543
|
const renderShell = (leftRail: ReactNode, rightRail: ReactNode) => (
|
|
497
|
-
<div className={cx("pl-appshell-frame", className)}>
|
|
544
|
+
<div className={cx("pl-appshell-frame", dragging && "pl-appshell-frame--dragging", className)}>
|
|
498
545
|
{header != null && <div className="pl-appshell__header">{header}</div>}
|
|
499
546
|
<div className="pl-appshell">
|
|
500
547
|
{leftRail}
|
|
548
|
+
{canReopenLeft && (
|
|
549
|
+
<button
|
|
550
|
+
type="button"
|
|
551
|
+
className="pl-appshell__reopen pl-appshell__reopen--left"
|
|
552
|
+
aria-label="Open left panel"
|
|
553
|
+
onPointerDown={(e) => beginDrag("reopen-left", e)}
|
|
554
|
+
onKeyDown={(e) => {
|
|
555
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
556
|
+
e.preventDefault();
|
|
557
|
+
onLeftCollapse?.(false);
|
|
558
|
+
}
|
|
559
|
+
}}
|
|
560
|
+
/>
|
|
561
|
+
)}
|
|
501
562
|
{showLeft && (
|
|
502
563
|
<main ref={leftColRef} className="pl-appshell__col pl-appshell__col--left">
|
|
503
564
|
{leftContent}
|
|
@@ -514,9 +575,7 @@ export function AppShell({
|
|
|
514
575
|
aria-valuemax={maxRightWidth}
|
|
515
576
|
aria-valuetext={`Right panel ${rightWidth}px`}
|
|
516
577
|
tabIndex={0}
|
|
517
|
-
onPointerDown={
|
|
518
|
-
onPointerMove={onPointerMove}
|
|
519
|
-
onPointerUp={onPointerUp}
|
|
578
|
+
onPointerDown={(e) => beginDrag("divider", e)}
|
|
520
579
|
onKeyDown={onKeyDown}
|
|
521
580
|
onDoubleClick={() => onCollapse?.(true)}
|
|
522
581
|
/>
|
|
@@ -529,6 +588,20 @@ export function AppShell({
|
|
|
529
588
|
{rightContent}
|
|
530
589
|
</aside>
|
|
531
590
|
)}
|
|
591
|
+
{canReopenRight && (
|
|
592
|
+
<button
|
|
593
|
+
type="button"
|
|
594
|
+
className="pl-appshell__reopen pl-appshell__reopen--right"
|
|
595
|
+
aria-label="Open right panel"
|
|
596
|
+
onPointerDown={(e) => beginDrag("reopen-right", e)}
|
|
597
|
+
onKeyDown={(e) => {
|
|
598
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
599
|
+
e.preventDefault();
|
|
600
|
+
onCollapse?.(false);
|
|
601
|
+
}
|
|
602
|
+
}}
|
|
603
|
+
/>
|
|
604
|
+
)}
|
|
532
605
|
{rightRail}
|
|
533
606
|
</div>
|
|
534
607
|
{utilityBar != null && <div className="pl-appshell__utility">{utilityBar}</div>}
|
package/src/layout.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HTMLAttributes, ReactNode } from "react";
|
|
1
|
+
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
|
|
2
2
|
import { cx } from "./internal";
|
|
3
3
|
|
|
4
4
|
export function Stat({ value, label }: { value: ReactNode; label: ReactNode }) {
|
|
@@ -23,6 +23,45 @@ export function Stats({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
|
23
23
|
return <div className={cx("pl-stats", className)} {...rest} />;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
type GridCols = number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number };
|
|
27
|
+
|
|
28
|
+
/** Responsive card grid — the generic `repeat(auto-fill, minmax())` every surface
|
|
29
|
+
* was re-rolling. Pass `min` (auto-fill floor — packs in as many columns as fit)
|
|
30
|
+
* OR `cols` (a fixed count, or a per-breakpoint map). `gap` is on the token scale. */
|
|
31
|
+
export function Grid({
|
|
32
|
+
min,
|
|
33
|
+
cols,
|
|
34
|
+
gap = "md",
|
|
35
|
+
className,
|
|
36
|
+
style,
|
|
37
|
+
...rest
|
|
38
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
39
|
+
/** Auto-fill column floor, e.g. "14rem". Columns wrap to fit the container. */
|
|
40
|
+
min?: string;
|
|
41
|
+
/** Fixed column count, or per-breakpoint `{ base, sm, md, lg, xl }`. Ignored if `min` is set. */
|
|
42
|
+
cols?: GridCols;
|
|
43
|
+
gap?: "sm" | "md" | "lg";
|
|
44
|
+
}) {
|
|
45
|
+
const vars: Record<string, string> = {};
|
|
46
|
+
if (min) {
|
|
47
|
+
vars["--pl-grid-min"] = min;
|
|
48
|
+
} else if (cols != null) {
|
|
49
|
+
const c = typeof cols === "number" ? { base: cols } : cols;
|
|
50
|
+
if (c.base != null) vars["--pl-grid-cols"] = String(c.base);
|
|
51
|
+
if (c.sm != null) vars["--pl-grid-cols-sm"] = String(c.sm);
|
|
52
|
+
if (c.md != null) vars["--pl-grid-cols-md"] = String(c.md);
|
|
53
|
+
if (c.lg != null) vars["--pl-grid-cols-lg"] = String(c.lg);
|
|
54
|
+
if (c.xl != null) vars["--pl-grid-cols-xl"] = String(c.xl);
|
|
55
|
+
}
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={cx("pl-grid", min ? "pl-grid--auto" : "pl-grid--cols", `pl-grid--gap-${gap}`, className)}
|
|
59
|
+
style={{ ...vars, ...style } as CSSProperties}
|
|
60
|
+
{...rest}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
26
65
|
export type RowProps = {
|
|
27
66
|
/** Left mono label / layer. */
|
|
28
67
|
label: string;
|
package/src/navigation.tsx
CHANGED
|
@@ -80,6 +80,125 @@ export function Tabs({
|
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
export type TabBarItem = {
|
|
84
|
+
id: string;
|
|
85
|
+
label: string;
|
|
86
|
+
icon?: ReactNode;
|
|
87
|
+
badge?: ReactNode;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Browser-style session tabs: closable, inline-renamable, with an add-new "+".
|
|
91
|
+
* Every interaction callback is optional — absence hides its affordance, so with
|
|
92
|
+
* just `items`/`activeId`/`onSelect` it degrades to a plain tab strip. Pass
|
|
93
|
+
* `onClose` for per-tab ✕, `onRename` to enable double-click-to-edit, `onAdd` for
|
|
94
|
+
* the trailing "+". */
|
|
95
|
+
export function TabBar({
|
|
96
|
+
items,
|
|
97
|
+
activeId,
|
|
98
|
+
onSelect,
|
|
99
|
+
onClose,
|
|
100
|
+
onRename,
|
|
101
|
+
onAdd,
|
|
102
|
+
addLabel = "New tab",
|
|
103
|
+
ariaLabel,
|
|
104
|
+
}: {
|
|
105
|
+
items: TabBarItem[];
|
|
106
|
+
activeId: string;
|
|
107
|
+
onSelect: (id: string) => void;
|
|
108
|
+
onClose?: (id: string) => void;
|
|
109
|
+
onRename?: (id: string, label: string) => void;
|
|
110
|
+
onAdd?: () => void;
|
|
111
|
+
addLabel?: string;
|
|
112
|
+
ariaLabel?: string;
|
|
113
|
+
}) {
|
|
114
|
+
const [editing, setEditing] = useState<string | null>(null);
|
|
115
|
+
const [draft, setDraft] = useState("");
|
|
116
|
+
const startEdit = (t: TabBarItem) => {
|
|
117
|
+
if (!onRename) return;
|
|
118
|
+
setEditing(t.id);
|
|
119
|
+
setDraft(t.label);
|
|
120
|
+
};
|
|
121
|
+
const commit = () => {
|
|
122
|
+
if (editing != null && onRename) {
|
|
123
|
+
const v = draft.trim();
|
|
124
|
+
if (v) onRename(editing, v);
|
|
125
|
+
}
|
|
126
|
+
setEditing(null);
|
|
127
|
+
};
|
|
128
|
+
return (
|
|
129
|
+
<div className="pl-tabbar" role="tablist" aria-label={ariaLabel}>
|
|
130
|
+
{items.map((t) => (
|
|
131
|
+
<div
|
|
132
|
+
key={t.id}
|
|
133
|
+
role="tab"
|
|
134
|
+
tabIndex={0}
|
|
135
|
+
aria-selected={t.id === activeId}
|
|
136
|
+
className={cx("pl-tabbar__tab", t.id === activeId && "pl-tabbar__tab--active")}
|
|
137
|
+
onClick={() => editing !== t.id && onSelect(t.id)}
|
|
138
|
+
onDoubleClick={() => startEdit(t)}
|
|
139
|
+
onKeyDown={(e) => {
|
|
140
|
+
if (editing === t.id) return;
|
|
141
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
onSelect(t.id);
|
|
144
|
+
}
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
{t.icon != null && (
|
|
148
|
+
<span className="pl-tabbar__icon" aria-hidden>
|
|
149
|
+
{t.icon}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
{editing === t.id ? (
|
|
153
|
+
<input
|
|
154
|
+
className="pl-tabbar__edit"
|
|
155
|
+
autoFocus
|
|
156
|
+
value={draft}
|
|
157
|
+
aria-label="Rename tab"
|
|
158
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
159
|
+
onClick={(e) => e.stopPropagation()}
|
|
160
|
+
onBlur={commit}
|
|
161
|
+
onKeyDown={(e) => {
|
|
162
|
+
if (e.key === "Enter") {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
commit();
|
|
165
|
+
} else if (e.key === "Escape") {
|
|
166
|
+
setEditing(null);
|
|
167
|
+
}
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
) : (
|
|
171
|
+
<span className="pl-tabbar__label">{t.label}</span>
|
|
172
|
+
)}
|
|
173
|
+
{t.badge != null && <span className="pl-tabbar__badge">{t.badge}</span>}
|
|
174
|
+
{onClose && editing !== t.id && (
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className="pl-tabbar__close"
|
|
178
|
+
aria-label={`Close ${t.label}`}
|
|
179
|
+
onClick={(e) => {
|
|
180
|
+
e.stopPropagation();
|
|
181
|
+
onClose(t.id);
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
|
|
185
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
186
|
+
</svg>
|
|
187
|
+
</button>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
))}
|
|
191
|
+
{onAdd && (
|
|
192
|
+
<button type="button" className="pl-tabbar__add" aria-label={addLabel} title={addLabel} onClick={onAdd}>
|
|
193
|
+
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
|
194
|
+
<path d="M12 5v14M5 12h14" />
|
|
195
|
+
</svg>
|
|
196
|
+
</button>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
83
202
|
/** A horizontal kanban board. Wrap BoardColumn children. */
|
|
84
203
|
export function Board({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
85
204
|
return <div className={cx("pl-board", className)} {...rest} />;
|
package/src/primitives.tsx
CHANGED
|
@@ -40,9 +40,37 @@ export function Eyebrow({ children }: { children: ReactNode }) {
|
|
|
40
40
|
return <div className="pl-eyebrow">{children}</div>;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
/** Empty state. Bare `<Empty>body</Empty>` still works; pass any of the slots
|
|
44
|
+
* (icon / title / description / action) for the standard centered scaffold so
|
|
45
|
+
* surfaces stop re-composing it around the wrapper. */
|
|
46
|
+
export function Empty({
|
|
47
|
+
icon,
|
|
48
|
+
title,
|
|
49
|
+
description,
|
|
50
|
+
action,
|
|
51
|
+
className,
|
|
52
|
+
children,
|
|
53
|
+
...rest
|
|
54
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
55
|
+
icon?: ReactNode;
|
|
56
|
+
title?: ReactNode;
|
|
57
|
+
description?: ReactNode;
|
|
58
|
+
action?: ReactNode;
|
|
59
|
+
}) {
|
|
60
|
+
const slotted = icon != null || title != null || description != null || action != null;
|
|
61
|
+
return (
|
|
62
|
+
<div className={cx("pl-empty", slotted && "pl-empty--slotted", className)} {...rest}>
|
|
63
|
+
{icon != null && (
|
|
64
|
+
<div className="pl-empty__icon" aria-hidden>
|
|
65
|
+
{icon}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
{title != null && <div className="pl-empty__title">{title}</div>}
|
|
69
|
+
{description != null && <div className="pl-empty__desc">{description}</div>}
|
|
70
|
+
{children}
|
|
71
|
+
{action != null && <div className="pl-empty__action">{action}</div>}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
46
74
|
}
|
|
47
75
|
|
|
48
76
|
/** Hairline rule. */
|
package/src/styles/app-shell.css
CHANGED
|
@@ -244,6 +244,56 @@
|
|
|
244
244
|
outline: none;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/* Reopen handle — the grab strip a collapsed side leaves at its edge. Drag it in to
|
|
248
|
+
size the panel open (VS Code style), or tap/Enter to pop it to its min width. */
|
|
249
|
+
.pl-appshell__reopen {
|
|
250
|
+
position: relative;
|
|
251
|
+
flex: 0 0 6px;
|
|
252
|
+
align-self: stretch;
|
|
253
|
+
padding: 0;
|
|
254
|
+
background: transparent;
|
|
255
|
+
border: none;
|
|
256
|
+
cursor: col-resize;
|
|
257
|
+
touch-action: none;
|
|
258
|
+
transition: background var(--pl-motion-fast) var(--pl-motion-ease);
|
|
259
|
+
}
|
|
260
|
+
.pl-appshell__reopen--right {
|
|
261
|
+
border-left: var(--pl-border-width) solid var(--pl-color-border);
|
|
262
|
+
}
|
|
263
|
+
.pl-appshell__reopen--left {
|
|
264
|
+
border-right: var(--pl-border-width) solid var(--pl-color-border);
|
|
265
|
+
}
|
|
266
|
+
.pl-appshell__reopen:hover,
|
|
267
|
+
.pl-appshell__reopen:focus-visible {
|
|
268
|
+
background: var(--pl-color-bg-hover);
|
|
269
|
+
outline: none;
|
|
270
|
+
}
|
|
271
|
+
/* a centered grip that surfaces on hover/focus so the affordance is discoverable */
|
|
272
|
+
.pl-appshell__reopen::after {
|
|
273
|
+
content: "";
|
|
274
|
+
position: absolute;
|
|
275
|
+
top: 50%;
|
|
276
|
+
left: 50%;
|
|
277
|
+
width: 2px;
|
|
278
|
+
height: 24px;
|
|
279
|
+
transform: translate(-50%, -50%);
|
|
280
|
+
border-radius: 2px;
|
|
281
|
+
background: var(--pl-color-border-strong);
|
|
282
|
+
opacity: 0;
|
|
283
|
+
transition: opacity var(--pl-motion-fast) var(--pl-motion-ease);
|
|
284
|
+
}
|
|
285
|
+
.pl-appshell__reopen:hover::after,
|
|
286
|
+
.pl-appshell__reopen:focus-visible::after {
|
|
287
|
+
opacity: 1;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* While a divider/reopen gesture is live, kill text selection + force the resize
|
|
291
|
+
cursor everywhere (the pointer roams the whole window during the drag). */
|
|
292
|
+
.pl-appshell-frame--dragging {
|
|
293
|
+
cursor: col-resize;
|
|
294
|
+
user-select: none;
|
|
295
|
+
}
|
|
296
|
+
|
|
247
297
|
.pl-appshell--mobile {
|
|
248
298
|
flex-direction: column;
|
|
249
299
|
}
|
package/src/styles/layout.css
CHANGED
|
@@ -39,6 +39,49 @@
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/* ── Grid (responsive card grid) ── */
|
|
43
|
+
.pl-grid {
|
|
44
|
+
display: grid;
|
|
45
|
+
}
|
|
46
|
+
.pl-grid--gap-sm {
|
|
47
|
+
gap: var(--pl-space-2);
|
|
48
|
+
}
|
|
49
|
+
.pl-grid--gap-md {
|
|
50
|
+
gap: var(--pl-space-4);
|
|
51
|
+
}
|
|
52
|
+
.pl-grid--gap-lg {
|
|
53
|
+
gap: var(--pl-space-6);
|
|
54
|
+
}
|
|
55
|
+
/* auto-fill: as many columns of >= --pl-grid-min as fit */
|
|
56
|
+
.pl-grid--auto {
|
|
57
|
+
grid-template-columns: repeat(auto-fill, minmax(var(--pl-grid-min, 14rem), 1fr));
|
|
58
|
+
}
|
|
59
|
+
/* fixed/responsive column count — each breakpoint falls back to the nearest
|
|
60
|
+
smaller value that was set, so `cols={{ base:1, md:2, xl:3 }}` cascades cleanly */
|
|
61
|
+
.pl-grid--cols {
|
|
62
|
+
grid-template-columns: repeat(var(--pl-grid-cols, 1), minmax(0, 1fr));
|
|
63
|
+
}
|
|
64
|
+
@media (min-width: 640px) {
|
|
65
|
+
.pl-grid--cols {
|
|
66
|
+
grid-template-columns: repeat(var(--pl-grid-cols-sm, var(--pl-grid-cols, 1)), minmax(0, 1fr));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
@media (min-width: 768px) {
|
|
70
|
+
.pl-grid--cols {
|
|
71
|
+
grid-template-columns: repeat(var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1))), minmax(0, 1fr));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
@media (min-width: 1024px) {
|
|
75
|
+
.pl-grid--cols {
|
|
76
|
+
grid-template-columns: repeat(var(--pl-grid-cols-lg, var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1)))), minmax(0, 1fr));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
@media (min-width: 1280px) {
|
|
80
|
+
.pl-grid--cols {
|
|
81
|
+
grid-template-columns: repeat(var(--pl-grid-cols-xl, var(--pl-grid-cols-lg, var(--pl-grid-cols-md, var(--pl-grid-cols-sm, var(--pl-grid-cols, 1))))), minmax(0, 1fr));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
42
85
|
/* ── row (label | body [| status]) ── */
|
|
43
86
|
.pl-row {
|
|
44
87
|
display: grid;
|
|
@@ -255,3 +255,134 @@
|
|
|
255
255
|
line-height: 1.55;
|
|
256
256
|
color: var(--pl-color-fg-muted);
|
|
257
257
|
}
|
|
258
|
+
|
|
259
|
+
/* ── TabBar (browser-style session tabs) ────────────────────────────────────── */
|
|
260
|
+
/* On a narrow window session tabs scroll horizontally (browser/VS Code behaviour) —
|
|
261
|
+
they do NOT collapse to a <select> like Tabs, because a select can't host the
|
|
262
|
+
per-tab close / inline rename / add-new affordances that are the point of TabBar. */
|
|
263
|
+
.pl-tabbar {
|
|
264
|
+
display: flex;
|
|
265
|
+
align-items: stretch;
|
|
266
|
+
gap: 2px;
|
|
267
|
+
overflow-x: auto;
|
|
268
|
+
scrollbar-width: thin;
|
|
269
|
+
border-bottom: var(--pl-border-width) solid var(--pl-color-border);
|
|
270
|
+
}
|
|
271
|
+
.pl-tabbar__tab {
|
|
272
|
+
display: inline-flex;
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: 7px;
|
|
275
|
+
flex: 0 1 auto;
|
|
276
|
+
min-width: 92px;
|
|
277
|
+
max-width: 220px;
|
|
278
|
+
padding: 7px 10px 7px 12px;
|
|
279
|
+
background: none;
|
|
280
|
+
border: none;
|
|
281
|
+
border-bottom: 2px solid transparent;
|
|
282
|
+
color: var(--pl-color-fg-muted);
|
|
283
|
+
font-family: var(--pl-font-sans);
|
|
284
|
+
font-size: 13px;
|
|
285
|
+
cursor: pointer;
|
|
286
|
+
border-radius: var(--pl-radius) var(--pl-radius) 0 0;
|
|
287
|
+
transition:
|
|
288
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
289
|
+
color var(--pl-motion-fast) var(--pl-motion-ease);
|
|
290
|
+
}
|
|
291
|
+
.pl-tabbar__tab:hover {
|
|
292
|
+
background: var(--pl-color-bg-hover);
|
|
293
|
+
color: var(--pl-color-fg);
|
|
294
|
+
}
|
|
295
|
+
.pl-tabbar__tab:focus-visible {
|
|
296
|
+
outline: 2px solid var(--pl-color-accent);
|
|
297
|
+
outline-offset: -2px;
|
|
298
|
+
}
|
|
299
|
+
.pl-tabbar__tab--active {
|
|
300
|
+
color: var(--pl-color-fg);
|
|
301
|
+
background: var(--pl-color-bg-subtle);
|
|
302
|
+
border-bottom-color: var(--pl-color-accent);
|
|
303
|
+
}
|
|
304
|
+
.pl-tabbar__icon {
|
|
305
|
+
display: inline-flex;
|
|
306
|
+
align-items: center;
|
|
307
|
+
}
|
|
308
|
+
.pl-tabbar__icon svg {
|
|
309
|
+
width: 15px;
|
|
310
|
+
height: 15px;
|
|
311
|
+
}
|
|
312
|
+
.pl-tabbar__label {
|
|
313
|
+
min-width: 0;
|
|
314
|
+
overflow: hidden;
|
|
315
|
+
text-overflow: ellipsis;
|
|
316
|
+
white-space: nowrap;
|
|
317
|
+
}
|
|
318
|
+
.pl-tabbar__edit {
|
|
319
|
+
min-width: 60px;
|
|
320
|
+
max-width: 140px;
|
|
321
|
+
padding: 1px 4px;
|
|
322
|
+
font: inherit;
|
|
323
|
+
font-size: 13px;
|
|
324
|
+
color: var(--pl-color-fg);
|
|
325
|
+
background: var(--pl-color-bg-inset);
|
|
326
|
+
border: var(--pl-border-width) solid var(--pl-color-accent);
|
|
327
|
+
border-radius: calc(var(--pl-radius) - 2px);
|
|
328
|
+
outline: none;
|
|
329
|
+
}
|
|
330
|
+
.pl-tabbar__badge {
|
|
331
|
+
display: inline-flex;
|
|
332
|
+
align-items: center;
|
|
333
|
+
justify-content: center;
|
|
334
|
+
min-width: 16px;
|
|
335
|
+
height: 16px;
|
|
336
|
+
padding: 0 5px;
|
|
337
|
+
font-family: var(--pl-font-mono);
|
|
338
|
+
font-size: 10px;
|
|
339
|
+
line-height: 1;
|
|
340
|
+
color: var(--pl-color-fg-muted);
|
|
341
|
+
background: var(--pl-color-bg-subtle);
|
|
342
|
+
border: var(--pl-border-width) solid var(--pl-color-border);
|
|
343
|
+
border-radius: 999px;
|
|
344
|
+
}
|
|
345
|
+
.pl-tabbar__close {
|
|
346
|
+
display: inline-flex;
|
|
347
|
+
align-items: center;
|
|
348
|
+
justify-content: center;
|
|
349
|
+
width: 18px;
|
|
350
|
+
height: 18px;
|
|
351
|
+
margin-right: -3px;
|
|
352
|
+
padding: 0;
|
|
353
|
+
color: var(--pl-color-fg-subtle);
|
|
354
|
+
background: none;
|
|
355
|
+
border: none;
|
|
356
|
+
border-radius: var(--pl-radius);
|
|
357
|
+
cursor: pointer;
|
|
358
|
+
opacity: 0.7;
|
|
359
|
+
transition:
|
|
360
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
361
|
+
color var(--pl-motion-fast) var(--pl-motion-ease),
|
|
362
|
+
opacity var(--pl-motion-fast) var(--pl-motion-ease);
|
|
363
|
+
}
|
|
364
|
+
.pl-tabbar__close:hover {
|
|
365
|
+
color: var(--pl-color-fg);
|
|
366
|
+
background: var(--pl-color-bg-hover);
|
|
367
|
+
opacity: 1;
|
|
368
|
+
}
|
|
369
|
+
.pl-tabbar__add {
|
|
370
|
+
display: inline-flex;
|
|
371
|
+
align-items: center;
|
|
372
|
+
justify-content: center;
|
|
373
|
+
width: 28px;
|
|
374
|
+
flex-shrink: 0;
|
|
375
|
+
margin-left: 2px;
|
|
376
|
+
color: var(--pl-color-fg-muted);
|
|
377
|
+
background: none;
|
|
378
|
+
border: none;
|
|
379
|
+
cursor: pointer;
|
|
380
|
+
border-radius: var(--pl-radius);
|
|
381
|
+
transition:
|
|
382
|
+
background var(--pl-motion-fast) var(--pl-motion-ease),
|
|
383
|
+
color var(--pl-motion-fast) var(--pl-motion-ease);
|
|
384
|
+
}
|
|
385
|
+
.pl-tabbar__add:hover {
|
|
386
|
+
color: var(--pl-color-fg);
|
|
387
|
+
background: var(--pl-color-bg-hover);
|
|
388
|
+
}
|
|
@@ -99,6 +99,42 @@
|
|
|
99
99
|
padding: 1rem 0;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/* Slotted form (icon/title/description/action) — centered scaffold. */
|
|
103
|
+
.pl-empty--slotted {
|
|
104
|
+
display: flex;
|
|
105
|
+
flex-direction: column;
|
|
106
|
+
align-items: center;
|
|
107
|
+
justify-content: center;
|
|
108
|
+
gap: var(--pl-space-2);
|
|
109
|
+
padding: var(--pl-space-6) var(--pl-space-4);
|
|
110
|
+
text-align: center;
|
|
111
|
+
}
|
|
112
|
+
.pl-empty__icon {
|
|
113
|
+
display: inline-flex;
|
|
114
|
+
margin-bottom: var(--pl-space-1);
|
|
115
|
+
color: var(--pl-color-fg-subtle);
|
|
116
|
+
}
|
|
117
|
+
.pl-empty__icon svg {
|
|
118
|
+
width: 28px;
|
|
119
|
+
height: 28px;
|
|
120
|
+
}
|
|
121
|
+
.pl-empty__title {
|
|
122
|
+
font-family: var(--pl-font-sans);
|
|
123
|
+
font-size: 14px;
|
|
124
|
+
font-weight: var(--pl-font-weight-medium);
|
|
125
|
+
color: var(--pl-color-fg);
|
|
126
|
+
}
|
|
127
|
+
.pl-empty__desc {
|
|
128
|
+
max-width: 44ch;
|
|
129
|
+
font-family: var(--pl-font-sans);
|
|
130
|
+
font-size: 13px;
|
|
131
|
+
line-height: 1.6;
|
|
132
|
+
color: var(--pl-color-fg-muted);
|
|
133
|
+
}
|
|
134
|
+
.pl-empty__action {
|
|
135
|
+
margin-top: var(--pl-space-2);
|
|
136
|
+
}
|
|
137
|
+
|
|
102
138
|
/* ── divider ── */
|
|
103
139
|
.pl-divider {
|
|
104
140
|
border: 0;
|