wings-plugin-ui 0.1.1 → 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 +5 -0
- package/package.json +2 -2
- package/plugin-ui.css +237 -0
- package/ui.d.ts +59 -0
- package/ui.js +170 -0
package/README.md
CHANGED
|
@@ -68,6 +68,10 @@ message.
|
|
|
68
68
|
| `Stats` | `items: { label, value, tone? }[]` | Small figures side by side, label over value |
|
|
69
69
|
| `Properties` | `items: { label, value }[]` | Label and value pairs |
|
|
70
70
|
| `List`, `ListItem`, `ListButton` | `ordered` and `compact` on `List`, `align` on the rows | A list of rows; a `ListButton` in a `ListItem` makes the row a button |
|
|
71
|
+
| `Page`, `Toolbar` | `Page`: `toolbar`, `flush`; `Toolbar`: `title`, `actions`, `children` | A page that fills a tab: a toolbar row (title, then filters in `children`, `actions` on the right) over a body that scrolls. `flush` drops the body's padding and scrolling for a body that lays itself out. Show it with `showTab(<Page />, { fill: true })` |
|
|
72
|
+
| `ListDetail` | `list`, `detail`, `empty`, `listWidth` (px, 280), `listLabel`, `detailLabel`, `onBack`, `backLabel` | A list pane and a detail pane side by side; `empty` shows until there is a `detail`. Under 560px wide it shows one pane, the list or the detail, with a back button when `onBack` is given |
|
|
73
|
+
| `Board`, `BoardColumn` | `Board`: `label`; `BoardColumn`: `title`, `count`, `actions` | Columns that scroll sideways, each with a header, a count and a scrolling stack of cards (use `Card`). The board is a focusable region, so the arrow keys scroll it |
|
|
74
|
+
| `Split` | `start`, `end`, `direction` (`horizontal`, `vertical`), `defaultSize` (px, 280), `min` (120), `max`, `onResize`, `label` | Two panes with a draggable handle, a `separator` with `aria-valuenow` in px. Arrow keys (Shift for bigger steps), Home and End resize it. A drag only changes a CSS variable and `onResize(size)` runs once when it ends, so save it with `wings.setTabState()` and pass it back as `defaultSize` |
|
|
71
75
|
| `Dot`, `Spinner` | `tone`, `size`, `label`; `Dot` also `color` | A status dot or a spinner, announced only with a `label`. A dot's `color` comes from your data, like a work item type's `#cc293d` |
|
|
72
76
|
| `Pill`, `Tag` | `tone` | A filled pill, or an outlined tag like a branch |
|
|
73
77
|
| `Loading`, `Empty` | `Empty`: `title`, `description` | Before the first data, and nothing to show |
|
|
@@ -147,6 +151,7 @@ rather than styles.
|
|
|
147
151
|
`w-shell` with `data-place` (`sidebar`, `popover`, `view`, `tab`, `settings`) and `data-fill`; `w-row`,
|
|
148
152
|
`w-stack` with `data-gap`, `data-align`, `data-justify`, `data-wrap`, `data-grow`; `w-text` with `data-tone`,
|
|
149
153
|
`data-size`, `data-weight`, `data-mono`, `data-numeric`, `data-truncate`, `data-clamp`, `data-grow`;
|
|
154
|
+
`w-page`, `w-page-body` with `data-flush`; `w-toolbar`, `w-toolbar-title`, `w-toolbar-main`, `w-toolbar-actions`; `w-list-detail` with `data-has-detail` and `--w-list-width`, `w-list-detail-list`, `w-list-detail-detail`, `w-list-detail-body`, `w-list-detail-back`; `w-board`, `w-board-column`, `w-board-column-header`, `w-board-column-title`, `w-board-column-count`, `w-board-column-actions`, `w-board-column-body`; `w-split` with `data-direction`, `data-dragging` and `--w-split-size`, `w-split-pane`, `w-split-handle`;
|
|
150
155
|
`w-title` with `data-size`; `w-heading`, `w-heading-row`; `w-card` with `data-size`; `w-gauge`, `w-gauge-label`, `w-gauge-value`,
|
|
151
156
|
`w-gauge-note`, `w-meter` with `data-tone`, `w-meter-fill`; `w-stats`, `w-stat-label`, `w-stat-value`;
|
|
152
157
|
`w-props`, `w-prop`, `w-prop-label`, `w-prop-value`; `w-list` with `data-density`, `w-list-item`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wings-plugin-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "React components and the stylesheet for Wings plugin pages, on Base UI like the Wings app's shadcn/ui components.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"react": ">=19",
|
|
35
35
|
"react-dom": ">=19",
|
|
36
|
-
"wings-plugin-sdk": "^0.6.0"
|
|
36
|
+
"wings-plugin-sdk": "^0.6.0 || ^0.7.0 || ^0.8.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/react": "^19.1.8",
|
package/plugin-ui.css
CHANGED
|
@@ -357,6 +357,243 @@ textarea::placeholder {
|
|
|
357
357
|
font-size: 12px;
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
/* Pages that fill a tab: a toolbar over a body, list and detail, a board, a split. */
|
|
361
|
+
.w-page {
|
|
362
|
+
display: flex;
|
|
363
|
+
flex: 1;
|
|
364
|
+
flex-direction: column;
|
|
365
|
+
min-width: 0;
|
|
366
|
+
min-height: 0;
|
|
367
|
+
}
|
|
368
|
+
.w-page-body {
|
|
369
|
+
display: flex;
|
|
370
|
+
flex: 1;
|
|
371
|
+
flex-direction: column;
|
|
372
|
+
gap: 10px;
|
|
373
|
+
min-width: 0;
|
|
374
|
+
min-height: 0;
|
|
375
|
+
padding: 14px;
|
|
376
|
+
overflow: auto;
|
|
377
|
+
}
|
|
378
|
+
.w-page-body[data-flush] {
|
|
379
|
+
gap: 0;
|
|
380
|
+
padding: 0;
|
|
381
|
+
overflow: hidden;
|
|
382
|
+
}
|
|
383
|
+
.w-page-body[data-flush] > :is(.w-list-detail, .w-split, .w-board) {
|
|
384
|
+
flex: 1;
|
|
385
|
+
}
|
|
386
|
+
.w-toolbar {
|
|
387
|
+
display: flex;
|
|
388
|
+
flex: none;
|
|
389
|
+
align-items: center;
|
|
390
|
+
gap: 10px;
|
|
391
|
+
min-width: 0;
|
|
392
|
+
padding: 8px 14px;
|
|
393
|
+
border-bottom: 1px solid var(--wings-hairline);
|
|
394
|
+
}
|
|
395
|
+
.w-toolbar-title {
|
|
396
|
+
flex: none;
|
|
397
|
+
max-width: 40%;
|
|
398
|
+
}
|
|
399
|
+
.w-toolbar-main {
|
|
400
|
+
display: flex;
|
|
401
|
+
flex: 1;
|
|
402
|
+
align-items: center;
|
|
403
|
+
gap: 8px;
|
|
404
|
+
min-width: 0;
|
|
405
|
+
}
|
|
406
|
+
.w-toolbar-actions {
|
|
407
|
+
display: flex;
|
|
408
|
+
flex: none;
|
|
409
|
+
align-items: center;
|
|
410
|
+
gap: 6px;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* List and detail side by side; one pane when the page is narrower than 560px. */
|
|
414
|
+
.w-list-detail {
|
|
415
|
+
display: flex;
|
|
416
|
+
min-width: 0;
|
|
417
|
+
min-height: 0;
|
|
418
|
+
container-type: inline-size;
|
|
419
|
+
}
|
|
420
|
+
.w-list-detail-list {
|
|
421
|
+
flex: none;
|
|
422
|
+
width: var(--w-list-width, 280px);
|
|
423
|
+
min-width: 0;
|
|
424
|
+
padding: 8px;
|
|
425
|
+
overflow-y: auto;
|
|
426
|
+
border-right: 1px solid var(--wings-hairline);
|
|
427
|
+
}
|
|
428
|
+
.w-list-detail-detail {
|
|
429
|
+
display: flex;
|
|
430
|
+
flex: 1;
|
|
431
|
+
flex-direction: column;
|
|
432
|
+
min-width: 0;
|
|
433
|
+
overflow: hidden;
|
|
434
|
+
}
|
|
435
|
+
.w-list-detail-body {
|
|
436
|
+
display: flex;
|
|
437
|
+
flex: 1;
|
|
438
|
+
flex-direction: column;
|
|
439
|
+
min-height: 0;
|
|
440
|
+
padding: 14px;
|
|
441
|
+
overflow-y: auto;
|
|
442
|
+
}
|
|
443
|
+
.w-list-detail-body > .w-empty {
|
|
444
|
+
flex: 1;
|
|
445
|
+
}
|
|
446
|
+
.w-list-detail-back {
|
|
447
|
+
display: none;
|
|
448
|
+
align-self: flex-start;
|
|
449
|
+
margin: 8px 8px 0;
|
|
450
|
+
}
|
|
451
|
+
@container (max-width: 560px) {
|
|
452
|
+
.w-list-detail-list {
|
|
453
|
+
flex: 1;
|
|
454
|
+
width: auto;
|
|
455
|
+
border-right: 0;
|
|
456
|
+
}
|
|
457
|
+
.w-list-detail[data-has-detail] > .w-list-detail-list,
|
|
458
|
+
.w-list-detail:not([data-has-detail]) > .w-list-detail-detail {
|
|
459
|
+
display: none;
|
|
460
|
+
}
|
|
461
|
+
.w-list-detail-back {
|
|
462
|
+
display: inline-flex;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* A board: columns that scroll sideways, each with a header and its cards. */
|
|
467
|
+
.w-board {
|
|
468
|
+
display: flex;
|
|
469
|
+
align-items: flex-start;
|
|
470
|
+
gap: 10px;
|
|
471
|
+
min-width: 0;
|
|
472
|
+
min-height: 0;
|
|
473
|
+
padding: 14px;
|
|
474
|
+
overflow-x: auto;
|
|
475
|
+
}
|
|
476
|
+
.w-board-column {
|
|
477
|
+
display: flex;
|
|
478
|
+
flex: none;
|
|
479
|
+
flex-direction: column;
|
|
480
|
+
align-self: stretch;
|
|
481
|
+
width: 272px;
|
|
482
|
+
min-height: 0;
|
|
483
|
+
padding: 8px;
|
|
484
|
+
border: 1px solid var(--wings-hairline);
|
|
485
|
+
border-radius: var(--wings-radius);
|
|
486
|
+
background: var(--wings-surface);
|
|
487
|
+
}
|
|
488
|
+
.w-board-column-header {
|
|
489
|
+
display: flex;
|
|
490
|
+
flex: none;
|
|
491
|
+
align-items: center;
|
|
492
|
+
gap: 6px;
|
|
493
|
+
min-width: 0;
|
|
494
|
+
padding: 2px 4px 8px;
|
|
495
|
+
}
|
|
496
|
+
.w-board-column-title {
|
|
497
|
+
flex: 1;
|
|
498
|
+
min-width: 0;
|
|
499
|
+
overflow: hidden;
|
|
500
|
+
font-size: 12px;
|
|
501
|
+
font-weight: 600;
|
|
502
|
+
text-overflow: ellipsis;
|
|
503
|
+
white-space: nowrap;
|
|
504
|
+
}
|
|
505
|
+
.w-board-column-count {
|
|
506
|
+
font-variant-numeric: tabular-nums;
|
|
507
|
+
}
|
|
508
|
+
.w-board-column-actions {
|
|
509
|
+
display: flex;
|
|
510
|
+
align-items: center;
|
|
511
|
+
gap: 4px;
|
|
512
|
+
}
|
|
513
|
+
.w-board-column-body {
|
|
514
|
+
display: flex;
|
|
515
|
+
flex: 1;
|
|
516
|
+
flex-direction: column;
|
|
517
|
+
gap: 8px;
|
|
518
|
+
min-height: 0;
|
|
519
|
+
overflow-y: auto;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/* A split: --w-split-size is the start pane's size, changed while you drag the handle. */
|
|
523
|
+
.w-split {
|
|
524
|
+
display: flex;
|
|
525
|
+
min-width: 0;
|
|
526
|
+
min-height: 0;
|
|
527
|
+
}
|
|
528
|
+
.w-split[data-direction="vertical"] {
|
|
529
|
+
flex-direction: column;
|
|
530
|
+
}
|
|
531
|
+
.w-split[data-dragging] {
|
|
532
|
+
user-select: none;
|
|
533
|
+
}
|
|
534
|
+
.w-split-pane {
|
|
535
|
+
display: flex;
|
|
536
|
+
flex-direction: column;
|
|
537
|
+
min-width: 0;
|
|
538
|
+
min-height: 0;
|
|
539
|
+
overflow: auto;
|
|
540
|
+
}
|
|
541
|
+
.w-split-pane:first-child {
|
|
542
|
+
flex: none;
|
|
543
|
+
}
|
|
544
|
+
.w-split[data-direction="horizontal"] > .w-split-pane:first-child {
|
|
545
|
+
width: var(--w-split-size);
|
|
546
|
+
}
|
|
547
|
+
.w-split[data-direction="vertical"] > .w-split-pane:first-child {
|
|
548
|
+
height: var(--w-split-size);
|
|
549
|
+
}
|
|
550
|
+
.w-split-pane:last-child {
|
|
551
|
+
flex: 1;
|
|
552
|
+
}
|
|
553
|
+
.w-split-handle {
|
|
554
|
+
position: relative;
|
|
555
|
+
flex: none;
|
|
556
|
+
touch-action: none;
|
|
557
|
+
outline: none;
|
|
558
|
+
}
|
|
559
|
+
.w-split[data-direction="horizontal"] > .w-split-handle {
|
|
560
|
+
width: 9px;
|
|
561
|
+
margin: 0 -4px;
|
|
562
|
+
cursor: col-resize;
|
|
563
|
+
z-index: 1;
|
|
564
|
+
}
|
|
565
|
+
.w-split[data-direction="vertical"] > .w-split-handle {
|
|
566
|
+
height: 9px;
|
|
567
|
+
margin: -4px 0;
|
|
568
|
+
cursor: row-resize;
|
|
569
|
+
z-index: 1;
|
|
570
|
+
}
|
|
571
|
+
.w-split-handle::before {
|
|
572
|
+
content: "";
|
|
573
|
+
position: absolute;
|
|
574
|
+
background: var(--wings-hairline);
|
|
575
|
+
transition: background-color 150ms;
|
|
576
|
+
}
|
|
577
|
+
.w-split[data-direction="horizontal"] > .w-split-handle::before {
|
|
578
|
+
inset: 0 4px;
|
|
579
|
+
}
|
|
580
|
+
.w-split[data-direction="vertical"] > .w-split-handle::before {
|
|
581
|
+
inset: 4px 0;
|
|
582
|
+
}
|
|
583
|
+
.w-split-handle:hover::before,
|
|
584
|
+
.w-split-handle[data-dragging]::before {
|
|
585
|
+
background: var(--wings-hairline-strong);
|
|
586
|
+
}
|
|
587
|
+
.w-split-handle:focus-visible::before {
|
|
588
|
+
background: var(--wings-ring);
|
|
589
|
+
}
|
|
590
|
+
.w-split[data-direction="horizontal"] > .w-split-handle:focus-visible::before {
|
|
591
|
+
inset: 0 3px;
|
|
592
|
+
}
|
|
593
|
+
.w-split[data-direction="vertical"] > .w-split-handle:focus-visible::before {
|
|
594
|
+
inset: 3px 0;
|
|
595
|
+
}
|
|
596
|
+
|
|
360
597
|
/* A labelled bar, and the bar on its own. --w-value is how full it is. */
|
|
361
598
|
.w-gauge {
|
|
362
599
|
display: grid;
|
package/ui.d.ts
CHANGED
|
@@ -78,6 +78,65 @@ export function Title(props: Props<"h1"> & { as?: "h1" | "h2" | "h3"; size?: "la
|
|
|
78
78
|
/** A card. `compact` for a summary line. As a `form` it holds a form; as a `ul` or `ol`, a list of `li`. `as` types the props like `Row`'s. */
|
|
79
79
|
export function Card<E extends CardAs = "div">(props: Own<E, { as?: E; compact?: boolean }>): ReactElement;
|
|
80
80
|
|
|
81
|
+
// ---------- pages ----------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A page that fills a tab: a `toolbar`, usually a `Toolbar`, over a body that scrolls. `flush` takes the body's padding
|
|
85
|
+
* and scrolling away, for a body that lays itself out, like a `ListDetail`, a `Split` or a `Board`. Show it with
|
|
86
|
+
* `showTab(<Page />, { fill: true })`.
|
|
87
|
+
*/
|
|
88
|
+
export function Page(props: Props<"div"> & { toolbar?: ReactNode; flush?: boolean }): ReactElement;
|
|
89
|
+
/** A page's top row: a `title` (cut to one line) on the left, `children` like filters or a search box after it, and `actions` on the right. */
|
|
90
|
+
export function Toolbar(props: Omit<Props<"header">, "title"> & { title?: ReactNode; actions?: ReactNode }): ReactElement;
|
|
91
|
+
/**
|
|
92
|
+
* A `list` pane and a `detail` pane side by side. Without a `detail` the detail pane shows `empty`. When the page is
|
|
93
|
+
* narrower than 560px it shows one pane: the list, or the detail once there is one, with a back button if `onBack`
|
|
94
|
+
* is given. Each pane is a `section`; name them with `listLabel` and `detailLabel`.
|
|
95
|
+
*/
|
|
96
|
+
export function ListDetail(
|
|
97
|
+
props: Omit<Props<"div">, "children"> & {
|
|
98
|
+
list: ReactNode;
|
|
99
|
+
detail?: ReactNode;
|
|
100
|
+
/** What the detail pane shows without a `detail`: an `Empty` saying "Nothing selected" by default. */
|
|
101
|
+
empty?: ReactNode;
|
|
102
|
+
/** The list's width in px, 280 by default. */
|
|
103
|
+
listWidth?: number;
|
|
104
|
+
listLabel?: string;
|
|
105
|
+
detailLabel?: string;
|
|
106
|
+
/** Shows a back button above the detail when the page is too narrow for both panes. */
|
|
107
|
+
onBack?: () => void;
|
|
108
|
+
/** The back button's text, "Back" by default. */
|
|
109
|
+
backLabel?: string;
|
|
110
|
+
},
|
|
111
|
+
): ReactElement;
|
|
112
|
+
/** Columns side by side that scroll sideways, each a `BoardColumn`. A region named `label`, that takes keyboard focus so the arrow keys scroll it. */
|
|
113
|
+
export function Board(props: Omit<Props<"div">, "role"> & { label: string }): ReactElement;
|
|
114
|
+
/** A column of a `Board`: a `title` with the `count` of its cards and `actions`, over `children`, usually `Card`s, that scroll. */
|
|
115
|
+
export function BoardColumn(props: Omit<Props<"section">, "title"> & { title: ReactNode; count?: number; actions?: ReactNode }): ReactElement;
|
|
116
|
+
/**
|
|
117
|
+
* Two panes, `start` and `end`, with a handle between them. Drag the handle, or focus it and press the arrow keys
|
|
118
|
+
* (Shift for bigger steps), Home or End. A drag changes a CSS variable only and commits on release, so nothing renders
|
|
119
|
+
* per frame. The handle is a `separator` with `aria-valuenow` in px.
|
|
120
|
+
*/
|
|
121
|
+
export function Split(
|
|
122
|
+
props: Omit<Props<"div">, "children"> & {
|
|
123
|
+
start: ReactNode;
|
|
124
|
+
end: ReactNode;
|
|
125
|
+
/** "horizontal" (default) puts the panes side by side, "vertical" one over the other. */
|
|
126
|
+
direction?: "horizontal" | "vertical";
|
|
127
|
+
/** The start pane's size in px at first, 280 by default. Read from `wings.tabState()` to restore a saved one. */
|
|
128
|
+
defaultSize?: number;
|
|
129
|
+
/** The smallest either pane gets, in px, 120 by default. */
|
|
130
|
+
min?: number;
|
|
131
|
+
/** The biggest the start pane gets, in px. */
|
|
132
|
+
max?: number;
|
|
133
|
+
/** Gets the start pane's size in px when a drag or a key press ends, to save it with `wings.setTabState()`. */
|
|
134
|
+
onResize?: (size: number) => void;
|
|
135
|
+
/** Names the handle: "Resize panes" by default. */
|
|
136
|
+
label?: string;
|
|
137
|
+
},
|
|
138
|
+
): ReactElement;
|
|
139
|
+
|
|
81
140
|
// ---------- figures ----------
|
|
82
141
|
|
|
83
142
|
/** A bar from 0 to 100, with the meter role. */
|
package/ui.js
CHANGED
|
@@ -111,6 +111,176 @@ export function Card({ as = "div", compact, className, ...rest }) {
|
|
|
111
111
|
return h(as, { ...rest, className: cx("w-card", className), "data-size": compact ? "compact" : undefined });
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// ---------- pages ----------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A page that fills a tab: a `toolbar` (usually a `Toolbar`) over a body that scrolls. `flush` takes the body's padding
|
|
118
|
+
* and scrolling away, for a body that lays itself out, like a `ListDetail`, a `Split` or a `Board`. Show it with
|
|
119
|
+
* `showTab(<Page />, { fill: true })`.
|
|
120
|
+
*/
|
|
121
|
+
export function Page({ toolbar, flush, children, className, ...rest }) {
|
|
122
|
+
return h("div", { ...rest, className: cx("w-page", className) }, toolbar, h("div", { className: "w-page-body", "data-flush": flag(flush) }, children));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A page's top row: a `title` on the left, `children` like filters or a search box after it, and `actions` on the right. */
|
|
126
|
+
export function Toolbar({ title, actions, children, className, ...rest }) {
|
|
127
|
+
return h(
|
|
128
|
+
"header",
|
|
129
|
+
{ ...rest, className: cx("w-toolbar", className) },
|
|
130
|
+
title === undefined ? null : h(Title, { truncate: true, className: "w-toolbar-title" }, title),
|
|
131
|
+
h("div", { className: "w-toolbar-main" }, children),
|
|
132
|
+
actions ? h("div", { className: "w-toolbar-actions" }, actions) : null,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A `list` pane and a `detail` pane side by side. Without a `detail` the detail pane shows `empty`. Narrower than the
|
|
138
|
+
* page can fit both, it shows one: the list, or the detail once there is one, with a back button if `onBack` is given.
|
|
139
|
+
* `listWidth` is the list's width in px.
|
|
140
|
+
*/
|
|
141
|
+
export function ListDetail({ list, detail, empty = h(Empty, { title: "Nothing selected" }), listWidth = 280, listLabel, detailLabel, onBack, backLabel = "Back", className, style, ...rest }) {
|
|
142
|
+
const has = detail !== undefined && detail !== null && detail !== false;
|
|
143
|
+
return h(
|
|
144
|
+
"div",
|
|
145
|
+
// The width is data, not a style: plugin-ui.css sizes the list from it.
|
|
146
|
+
{ ...rest, className: cx("w-list-detail", className), style: { ...style, "--w-list-width": `${listWidth}px` }, "data-has-detail": flag(has) },
|
|
147
|
+
h("section", { className: "w-list-detail-list", "aria-label": listLabel }, list),
|
|
148
|
+
h(
|
|
149
|
+
"section",
|
|
150
|
+
{ className: "w-list-detail-detail", "aria-label": detailLabel },
|
|
151
|
+
has && onBack ? h(Button, { variant: "ghost", size: "sm", className: "w-list-detail-back", onClick: onBack }, backLabel) : null,
|
|
152
|
+
h("div", { className: "w-list-detail-body" }, has ? detail : empty),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Columns side by side that scroll sideways, each a `BoardColumn`. `label` names the scrolling region. */
|
|
158
|
+
export function Board({ label, className, ...rest }) {
|
|
159
|
+
// A scrolling region has to take focus, or a keyboard can't scroll it.
|
|
160
|
+
return h("div", { tabIndex: 0, ...rest, role: "region", "aria-label": label, className: cx("w-board", className) });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A column of a `Board`: a `title` with the `count` of cards and `actions`, over `children`, usually `Card`s, that scroll. */
|
|
164
|
+
export function BoardColumn({ title, count, actions, children, className, ...rest }) {
|
|
165
|
+
const id = useId();
|
|
166
|
+
return h(
|
|
167
|
+
"section",
|
|
168
|
+
{ ...rest, className: cx("w-board-column", className), "aria-labelledby": id },
|
|
169
|
+
h(
|
|
170
|
+
"header",
|
|
171
|
+
{ className: "w-board-column-header" },
|
|
172
|
+
h("h2", { id, className: "w-board-column-title" }, title),
|
|
173
|
+
count === undefined ? null : h(Badge, { variant: "secondary", className: "w-board-column-count" }, count),
|
|
174
|
+
actions ? h("div", { className: "w-board-column-actions" }, actions) : null,
|
|
175
|
+
),
|
|
176
|
+
h("div", { className: "w-board-column-body" }, children),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const SPLIT_STEP = 16;
|
|
181
|
+
const SPLIT_STEP_LARGE = 64;
|
|
182
|
+
const SPLIT_HANDLE = 9;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Two panes, `start` and `end`, with a handle between them. Drag it or use the arrow keys, Home and End. While you drag
|
|
186
|
+
* only a CSS variable changes, so nothing renders per frame; `onResize` gets the start pane's size in px once, when
|
|
187
|
+
* the drag or the key press ends. `direction` horizontal puts the panes side by side, vertical one over the other.
|
|
188
|
+
*/
|
|
189
|
+
export function Split({ start, end, direction = "horizontal", defaultSize = 280, min = 120, max, onResize, label = "Resize panes", className, style, ...rest }) {
|
|
190
|
+
const id = useId();
|
|
191
|
+
const box = useRef(null);
|
|
192
|
+
const handle = useRef(null);
|
|
193
|
+
const drag = useRef(null);
|
|
194
|
+
const [size, setSize] = useState(defaultSize);
|
|
195
|
+
const vertical = direction === "vertical";
|
|
196
|
+
|
|
197
|
+
// The start pane can't be smaller than `min`, bigger than `max`, or leave the end pane less than `min`.
|
|
198
|
+
const limits = () => {
|
|
199
|
+
const rect = box.current?.getBoundingClientRect();
|
|
200
|
+
const total = rect ? (vertical ? rect.height : rect.width) : 0;
|
|
201
|
+
const room = total > 0 ? total - SPLIT_HANDLE - min : Infinity;
|
|
202
|
+
return [min, Math.max(min, Math.min(max ?? Infinity, room))];
|
|
203
|
+
};
|
|
204
|
+
const clamp = (value) => {
|
|
205
|
+
const [low, high] = limits();
|
|
206
|
+
return Math.round(Math.min(high, Math.max(low, value)));
|
|
207
|
+
};
|
|
208
|
+
const syncMax = () => {
|
|
209
|
+
const [, high] = limits();
|
|
210
|
+
if (Number.isFinite(high)) handle.current?.setAttribute("aria-valuemax", String(Math.round(high)));
|
|
211
|
+
};
|
|
212
|
+
// Dragging writes the DOM, not state: a render per pointer move would re-render the whole page.
|
|
213
|
+
const preview = (next) => {
|
|
214
|
+
box.current.style.setProperty("--w-split-size", `${next}px`);
|
|
215
|
+
handle.current.setAttribute("aria-valuenow", String(next));
|
|
216
|
+
};
|
|
217
|
+
const commit = (next) => {
|
|
218
|
+
setSize(next);
|
|
219
|
+
if (next !== size) onResize?.(next);
|
|
220
|
+
};
|
|
221
|
+
const endDrag = (e, keep) => {
|
|
222
|
+
if (!drag.current) return;
|
|
223
|
+
const { value } = drag.current;
|
|
224
|
+
drag.current = null;
|
|
225
|
+
handle.current.removeAttribute("data-dragging");
|
|
226
|
+
box.current.removeAttribute("data-dragging");
|
|
227
|
+
if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
|
228
|
+
if (keep) commit(value);
|
|
229
|
+
else preview(size);
|
|
230
|
+
};
|
|
231
|
+
useLayoutEffect(syncMax);
|
|
232
|
+
|
|
233
|
+
const onPointerDown = (e) => {
|
|
234
|
+
if (e.button !== 0) return;
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
237
|
+
syncMax();
|
|
238
|
+
drag.current = { from: vertical ? e.clientY : e.clientX, size, value: size };
|
|
239
|
+
handle.current.setAttribute("data-dragging", "");
|
|
240
|
+
box.current.setAttribute("data-dragging", "");
|
|
241
|
+
};
|
|
242
|
+
const onPointerMove = (e) => {
|
|
243
|
+
const d = drag.current;
|
|
244
|
+
if (!d) return;
|
|
245
|
+
d.value = clamp(d.size + (vertical ? e.clientY : e.clientX) - d.from);
|
|
246
|
+
preview(d.value);
|
|
247
|
+
};
|
|
248
|
+
const onKeyDown = (e) => {
|
|
249
|
+
const step = e.shiftKey ? SPLIT_STEP_LARGE : SPLIT_STEP;
|
|
250
|
+
const [low, high] = limits();
|
|
251
|
+
const [less, more] = vertical ? ["ArrowUp", "ArrowDown"] : ["ArrowLeft", "ArrowRight"];
|
|
252
|
+
const next = e.key === less ? size - step : e.key === more ? size + step : e.key === "Home" ? low : e.key === "End" ? high : null;
|
|
253
|
+
if (next === null || !Number.isFinite(next)) return;
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
commit(clamp(next));
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return h(
|
|
259
|
+
"div",
|
|
260
|
+
// The size is data, not a style: plugin-ui.css sizes the start pane from it.
|
|
261
|
+
{ ...rest, ref: box, className: cx("w-split", className), style: { ...style, "--w-split-size": `${size}px` }, "data-direction": direction },
|
|
262
|
+
h("div", { id, className: "w-split-pane" }, start),
|
|
263
|
+
h("div", {
|
|
264
|
+
ref: handle,
|
|
265
|
+
className: "w-split-handle",
|
|
266
|
+
role: "separator",
|
|
267
|
+
tabIndex: 0,
|
|
268
|
+
"aria-label": label,
|
|
269
|
+
"aria-orientation": vertical ? "horizontal" : "vertical",
|
|
270
|
+
"aria-controls": id,
|
|
271
|
+
"aria-valuemin": min,
|
|
272
|
+
"aria-valuemax": max,
|
|
273
|
+
"aria-valuenow": size,
|
|
274
|
+
onPointerDown,
|
|
275
|
+
onPointerMove,
|
|
276
|
+
onPointerUp: (e) => endDrag(e, true),
|
|
277
|
+
onPointerCancel: (e) => endDrag(e, false),
|
|
278
|
+
onKeyDown,
|
|
279
|
+
}),
|
|
280
|
+
h("div", { className: "w-split-pane" }, end),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
114
284
|
// ---------- figures ----------
|
|
115
285
|
|
|
116
286
|
const levelTone = (percent, levels) => (!levels ? undefined : percent >= levels[1] ? "danger" : percent >= levels[0] ? "warn" : undefined);
|