@charcuterie/ui 3.23.0 → 3.25.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 +18 -0
- package/dist/Board/Board.d.ts +6 -3
- package/dist/Board/Board.d.ts.map +1 -1
- package/dist/Board/Board.js +19 -1
- package/dist/Board/Board.js.map +1 -1
- package/dist/Board/BoardCard.d.ts +42 -7
- package/dist/Board/BoardCard.d.ts.map +1 -1
- package/dist/Board/BoardCard.js +54 -8
- package/dist/Board/BoardCard.js.map +1 -1
- package/dist/DeploymentUpdate/useDeploymentUpdate.d.ts +31 -0
- package/dist/DeploymentUpdate/useDeploymentUpdate.d.ts.map +1 -0
- package/dist/DeploymentUpdate/useDeploymentUpdate.js +94 -0
- package/dist/DeploymentUpdate/useDeploymentUpdate.js.map +1 -0
- package/dist/Nav/Nav.d.ts +167 -0
- package/dist/Nav/Nav.d.ts.map +1 -0
- package/dist/Nav/Nav.js +165 -0
- package/dist/Nav/Nav.js.map +1 -0
- package/dist/Nav/NavLink.d.ts +43 -0
- package/dist/Nav/NavLink.d.ts.map +1 -0
- package/dist/Nav/NavLink.js +111 -0
- package/dist/Nav/NavLink.js.map +1 -0
- package/dist/Nav/navItems.d.ts +67 -0
- package/dist/Nav/navItems.d.ts.map +1 -0
- package/dist/Nav/navItems.js +104 -0
- package/dist/Nav/navItems.js.map +1 -0
- package/dist/Nav/useNavLayout.d.ts +86 -0
- package/dist/Nav/useNavLayout.d.ts.map +1 -0
- package/dist/Nav/useNavLayout.js +114 -0
- package/dist/Nav/useNavLayout.js.map +1 -0
- package/dist/NavBar/NavBar.d.ts +37 -111
- package/dist/NavBar/NavBar.d.ts.map +1 -1
- package/dist/NavBar/NavBar.js +30 -110
- package/dist/NavBar/NavBar.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Board/Board.mdx +25 -2
- package/src/Board/Board.stories.tsx +52 -1
- package/src/Board/Board.test.tsx +82 -0
- package/src/Board/Board.tsx +24 -4
- package/src/Board/BoardCard.tsx +103 -21
- package/src/Board/boardMoveHandleContainer.test.ts +54 -0
- package/src/DeploymentUpdate/useDeploymentUpdate.ts +143 -0
- package/src/Nav/Nav.mdx +169 -0
- package/src/Nav/Nav.stories.tsx +402 -0
- package/src/Nav/Nav.test.tsx +338 -0
- package/src/Nav/Nav.tsx +406 -0
- package/src/Nav/NavLink.tsx +220 -0
- package/src/Nav/navItems.test.ts +163 -0
- package/src/Nav/navItems.ts +175 -0
- package/src/Nav/useNavLayout.ts +197 -0
- package/src/NavBar/NavBar.mdx +26 -0
- package/src/NavBar/NavBar.tsx +54 -240
- package/src/index.ts +29 -1
- package/src/sourceRules.test.ts +13 -1
- package/dist/NavBar/NavBarLink.d.ts +0 -43
- package/dist/NavBar/NavBarLink.d.ts.map +0 -1
- package/dist/NavBar/NavBarLink.js +0 -67
- package/dist/NavBar/NavBarLink.js.map +0 -1
- package/dist/NavBar/navBarCurrent.d.ts +0 -37
- package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
- package/dist/NavBar/navBarCurrent.js +0 -48
- package/dist/NavBar/navBarCurrent.js.map +0 -1
- package/src/NavBar/NavBarLink.tsx +0 -139
- package/src/NavBar/navBarCurrent.test.ts +0 -81
- package/src/NavBar/navBarCurrent.ts +0 -64
|
@@ -477,7 +477,7 @@ export const AllVariants: Story = {
|
|
|
477
477
|
|
|
478
478
|
<StoryCell
|
|
479
479
|
align="stretch"
|
|
480
|
-
label="movable, with the app's own `moveIcon` — ~
|
|
480
|
+
label="movable, with the app's own `moveIcon` — ~35px a row cheaper"
|
|
481
481
|
>
|
|
482
482
|
<Board
|
|
483
483
|
{...boardProps}
|
|
@@ -898,3 +898,54 @@ export const MarkdownTitles: Story = {
|
|
|
898
898
|
</Frame>
|
|
899
899
|
),
|
|
900
900
|
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* **The handle wears the gesture that can actually succeed**, and
|
|
904
|
+
* the two panels below are the same board at two widths.
|
|
905
|
+
*
|
|
906
|
+
* Wide, the lanes are side by side: there is somewhere to drop, so
|
|
907
|
+
* the handle is the app's `moveIcon` — a grip, which is what teaches
|
|
908
|
+
* a drag. Narrow, there is one lane and a segmented control: nothing
|
|
909
|
+
* on screen to drop onto, so the handle says **Move**, which is the
|
|
910
|
+
* gesture that works there. Pressing it opens the same menu at both
|
|
911
|
+
* widths, and the accessible name is the same sentence at both.
|
|
912
|
+
*
|
|
913
|
+
* QueuePilot is why this is not a preference. It shipped `≡` in a
|
|
914
|
+
* one-lane board and the owner could not move anybody — *"There's
|
|
915
|
+
* no right-click or anything. How do I move these?"* — and the
|
|
916
|
+
* first fix took the glyph away at every width, which broke the
|
|
917
|
+
* half that had been right.
|
|
918
|
+
*/
|
|
919
|
+
export const MoveHandleByWidth: Story = {
|
|
920
|
+
render: (boardProps) => (
|
|
921
|
+
<div className="flex flex-col gap-8">
|
|
922
|
+
<StoryCell
|
|
923
|
+
align="stretch"
|
|
924
|
+
label="past --cq-lg — three lanes, so the handle is a grip"
|
|
925
|
+
>
|
|
926
|
+
<Frame inlineSize="72rem">
|
|
927
|
+
<Board
|
|
928
|
+
{...boardProps}
|
|
929
|
+
label="Today, wide enough to drag"
|
|
930
|
+
moveIcon={<MoreIcon />}
|
|
931
|
+
onMove={() => undefined}
|
|
932
|
+
/>
|
|
933
|
+
</Frame>
|
|
934
|
+
</StoryCell>
|
|
935
|
+
|
|
936
|
+
<StoryCell
|
|
937
|
+
align="stretch"
|
|
938
|
+
label="under --cq-lg — one lane, so the handle says Move"
|
|
939
|
+
>
|
|
940
|
+
<Frame inlineSize="34rem">
|
|
941
|
+
<Board
|
|
942
|
+
{...boardProps}
|
|
943
|
+
label="Today, one lane at a time"
|
|
944
|
+
moveIcon={<MoreIcon />}
|
|
945
|
+
onMove={() => undefined}
|
|
946
|
+
/>
|
|
947
|
+
</Frame>
|
|
948
|
+
</StoryCell>
|
|
949
|
+
</div>
|
|
950
|
+
),
|
|
951
|
+
}
|
package/src/Board/Board.test.tsx
CHANGED
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
Interactive,
|
|
21
21
|
MarkdownTitles,
|
|
22
22
|
LinkedLaneHeadings,
|
|
23
|
+
MoveHandleByWidth,
|
|
23
24
|
Responsive,
|
|
24
25
|
} = composeStories(stories)
|
|
25
26
|
|
|
@@ -467,3 +468,84 @@ test("draws a project's colour on the card's own edge", async () => {
|
|
|
467
468
|
}),
|
|
468
469
|
)
|
|
469
470
|
})
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The handle wears the gesture that can succeed, and nothing else.
|
|
474
|
+
*
|
|
475
|
+
* This is the bug QueuePilot shipped twice, from both directions. A
|
|
476
|
+
* `≡` grip in a one-lane board taught a drag with nowhere to land —
|
|
477
|
+
* *"There's no right-click or anything. How do I move these?"* — and
|
|
478
|
+
* taking the grip away at every width broke the wide board, where
|
|
479
|
+
* dragging is exactly what a person reaches for.
|
|
480
|
+
*
|
|
481
|
+
* Both affordances are in the DOM at both widths and CSS decides,
|
|
482
|
+
* so this asserts on what is PAINTED. A container query is the only
|
|
483
|
+
* honest way to ask: the two boards below sit in a 72rem frame and a
|
|
484
|
+
* 24rem frame inside one window that never moves.
|
|
485
|
+
*/
|
|
486
|
+
test("a wide board's handle is the app's grip, not the word", async () => {
|
|
487
|
+
const { canvas } = await mountStory(MoveHandleByWidth)
|
|
488
|
+
|
|
489
|
+
const handle = within(
|
|
490
|
+
canvas.getByRole("region", {
|
|
491
|
+
name: "Today, wide enough to drag",
|
|
492
|
+
}),
|
|
493
|
+
).getByRole("button", {
|
|
494
|
+
name: "Move Retire the second scheduler and fold its jobs into the broker, currently in Todo",
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
await expect(
|
|
498
|
+
within(handle).getByText("Move"),
|
|
499
|
+
).not.toBeVisible()
|
|
500
|
+
|
|
501
|
+
await expect(handle.querySelector("svg")).toBeVisible()
|
|
502
|
+
|
|
503
|
+
// And it is still the SQUARE icon button it was before the word
|
|
504
|
+
// joined it in the DOM. `sizing` is a prop and cannot be two
|
|
505
|
+
// values, so the button is `control`-sized underneath and takes
|
|
506
|
+
// `ICON_CONTROL_SIZE_CLASS`'s width and `px-0` back at this width.
|
|
507
|
+
// Getting that wrong costs every card in every lane ~20px of
|
|
508
|
+
// title, silently — this board renders pixel-identically to the
|
|
509
|
+
// one before the change, and that is the assertion.
|
|
510
|
+
await expect(handle.offsetWidth).toBe(handle.offsetHeight)
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
test("a one-lane board's handle says Move, whatever the app passed", async () => {
|
|
514
|
+
const { canvas } = await mountStory(MoveHandleByWidth)
|
|
515
|
+
|
|
516
|
+
const handle = within(
|
|
517
|
+
canvas.getByRole("region", {
|
|
518
|
+
name: "Today, one lane at a time",
|
|
519
|
+
}),
|
|
520
|
+
).getByRole("button", {
|
|
521
|
+
name: "Move Retire the second scheduler and fold its jobs into the broker, currently in Todo",
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
await expect(
|
|
525
|
+
within(handle).getByText("Move"),
|
|
526
|
+
).toBeVisible()
|
|
527
|
+
|
|
528
|
+
await expect(
|
|
529
|
+
handle.querySelector("svg"),
|
|
530
|
+
).not.toBeVisible()
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The name is the same sentence at both widths, and that is the
|
|
535
|
+
* point of putting the whole of it in a `VisuallyHidden` while both
|
|
536
|
+
* visible affordances are `aria-hidden`.
|
|
537
|
+
*
|
|
538
|
+
* Left to the visible text, the handle would be called "Move X"
|
|
539
|
+
* wide and "Move Move X" narrow — one control with two names, one
|
|
540
|
+
* `getByRole` query that works in one layout and not the other, and
|
|
541
|
+
* a screen-reader user hearing the word twice.
|
|
542
|
+
*/
|
|
543
|
+
test("the handle has one name at both widths", async () => {
|
|
544
|
+
const { canvas } = await mountStory(MoveHandleByWidth)
|
|
545
|
+
|
|
546
|
+
await expect(
|
|
547
|
+
canvas.getAllByRole("button", {
|
|
548
|
+
name: "Move Retire the second scheduler and fold its jobs into the broker, currently in Todo",
|
|
549
|
+
}),
|
|
550
|
+
).toHaveLength(2)
|
|
551
|
+
})
|
package/src/Board/Board.tsx
CHANGED
|
@@ -44,12 +44,15 @@ export type BoardProps = {
|
|
|
44
44
|
label: string
|
|
45
45
|
lanes: readonly BoardLane[]
|
|
46
46
|
/**
|
|
47
|
-
* What the move handle shows
|
|
48
|
-
*
|
|
47
|
+
* What the move handle shows **while the lanes are side by side**.
|
|
48
|
+
* Below `cq-lg` the handle says "Move" whatever this is, because
|
|
49
|
+
* there is nowhere to drag to — see `BoardCard`.
|
|
50
|
+
*
|
|
51
|
+
* The library ships no icons and no symbol glyphs in a default —
|
|
49
52
|
* they render as nothing where the font lacks them, which
|
|
50
53
|
* includes this sandbox's chromium, the kiosk image and the
|
|
51
54
|
* ePaper build — so the default is a word and an app that owns a
|
|
52
|
-
* glyph set passes one. On a dense board it is worth ~
|
|
55
|
+
* glyph set passes one. On a dense board it is worth ~35px a row.
|
|
53
56
|
*/
|
|
54
57
|
moveIcon?: ReactNode
|
|
55
58
|
/**
|
|
@@ -216,7 +219,24 @@ export const Board = ({
|
|
|
216
219
|
return (
|
|
217
220
|
<section
|
|
218
221
|
aria-label={label}
|
|
219
|
-
|
|
222
|
+
/*
|
|
223
|
+
* `@container/board`, and the NAME is what a card's move
|
|
224
|
+
* handle needs.
|
|
225
|
+
*
|
|
226
|
+
* An unnamed container query matches the nearest ancestor
|
|
227
|
+
* container, and between this box and a card there is a
|
|
228
|
+
* second one — the lane's, one level down, which decides what
|
|
229
|
+
* shape a card is. So a `cq-lg:` written inside `BoardCard`
|
|
230
|
+
* asks about the LANE, and the answer is inverted against the
|
|
231
|
+
* question the handle has to ask: in a three-up board a lane
|
|
232
|
+
* is ~280px, and in the Narrow View the single lane is the
|
|
233
|
+
* whole board. Naming this one lets the handle query past the
|
|
234
|
+
* lane to the box that actually decides how many lanes are on
|
|
235
|
+
* screen. Naming changes nothing else — a named container
|
|
236
|
+
* still answers an unnamed query, which is why the segmented
|
|
237
|
+
* control and the lanes below keep their plain `cq-lg:`.
|
|
238
|
+
*/
|
|
239
|
+
className={toClassName("@container/board", className)}
|
|
220
240
|
>
|
|
221
241
|
<div className="flex flex-col gap-3">
|
|
222
242
|
{/*
|
package/src/Board/BoardCard.tsx
CHANGED
|
@@ -147,13 +147,16 @@ export type BoardCardProps = {
|
|
|
147
147
|
item: BoardItem
|
|
148
148
|
laneLabel: string
|
|
149
149
|
/**
|
|
150
|
-
* Shown on the move handle
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
150
|
+
* Shown on the move handle **while the lanes are side by side**,
|
|
151
|
+
* instead of the word "Move". Below the board's `cq-lg` the word
|
|
152
|
+
* comes back whatever this is — see the handle section below.
|
|
153
|
+
*
|
|
154
|
+
* The library ships no icons, so the default is words — a `⋮`
|
|
155
|
+
* renders as nothing where the font lacks it, which includes the
|
|
156
|
+
* kiosk image and the ePaper build. An app that already owns a
|
|
157
|
+
* glyph set passes one and buys back the ~35px per row that a
|
|
158
|
+
* word costs, which in a 500px lane is several characters of
|
|
159
|
+
* title.
|
|
157
160
|
*/
|
|
158
161
|
moveIcon?: ReactNode
|
|
159
162
|
/** The lanes this card can be sent to — never its own. */
|
|
@@ -205,6 +208,38 @@ export type BoardCardProps = {
|
|
|
205
208
|
* on screen at all. Dragging from it moves the card directly. Both
|
|
206
209
|
* commit through the same callback, so there is no second code path
|
|
207
210
|
* to keep honest and neither is a fallback for the other.
|
|
211
|
+
*
|
|
212
|
+
* ### What it WEARS depends on whether a drag can succeed
|
|
213
|
+
*
|
|
214
|
+
* The two drivers are not equally available at every width, so the
|
|
215
|
+
* handle does not claim they are:
|
|
216
|
+
*
|
|
217
|
+
* | The board is | The handle shows | Because |
|
|
218
|
+
* | --- | --- | --- |
|
|
219
|
+
* | `cq-lg` and wider — lanes side by side | the app's `moveIcon` | the other lanes are on screen, so a drag has somewhere to land, and a grip glyph teaches that gesture |
|
|
220
|
+
* | narrower — one lane and a segmented control | the word **Move** | there is nothing on screen to drop onto, so the only gesture the glyph teaches is the one that cannot work |
|
|
221
|
+
*
|
|
222
|
+
* That table is a bug report. QueuePilot shipped a `≡` handle in a
|
|
223
|
+
* one-lane board and the owner could not move anybody: *"I can't
|
|
224
|
+
* seem to drag 'n drop the names from Everyone Else anywhere else.
|
|
225
|
+
* There's no right-click or anything. How do I move these?"* The
|
|
226
|
+
* first fix took the glyph away everywhere, which was the wrong
|
|
227
|
+
* half — *"I think the drag handles were fine, but now you have it
|
|
228
|
+
* in a 3-column mode, so dragging would work, but it has this
|
|
229
|
+
* 'move' button instead."*
|
|
230
|
+
*
|
|
231
|
+
* Both are in the DOM and CSS picks; neither is rendered from a
|
|
232
|
+
* measurement. A `ResizeObserver` would get the first paint wrong,
|
|
233
|
+
* and a kiosk Pi shows that. Both are `aria-hidden`, and the
|
|
234
|
+
* handle's whole accessible name comes from the `VisuallyHidden`
|
|
235
|
+
* beside them — so the name is the same sentence at every width,
|
|
236
|
+
* which is what keeps one `getByRole` query working in both
|
|
237
|
+
* layouts.
|
|
238
|
+
*
|
|
239
|
+
* The query is against the **board's** container, named `board`,
|
|
240
|
+
* not the lane's. A lane in a three-up board is narrow and the
|
|
241
|
+
* Narrow View's single lane is wide, so the nearer container
|
|
242
|
+
* answers this question exactly backwards.
|
|
208
243
|
*/
|
|
209
244
|
export const BoardCard = ({
|
|
210
245
|
item,
|
|
@@ -346,12 +381,29 @@ export const BoardCard = ({
|
|
|
346
381
|
<Button
|
|
347
382
|
appearance="ghost"
|
|
348
383
|
intent="neutral"
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
384
|
+
className={toClassName(
|
|
385
|
+
// `touch-none` so a touch drag is a drag
|
|
386
|
+
// rather than a page scroll. Only the handle
|
|
387
|
+
// needs it; the rest of the gesture is
|
|
388
|
+
// covered by `preventDefault` on the
|
|
389
|
+
// captured `pointermove`.
|
|
390
|
+
"touch-none opacity-70 group-hover:opacity-100",
|
|
391
|
+
// `sizing` is a prop and cannot be two
|
|
392
|
+
// things at once, so the button stays
|
|
393
|
+
// `control`-sized — which is what the word
|
|
394
|
+
// needs — and takes `icon` sizing back at
|
|
395
|
+
// the width where the glyph is what is
|
|
396
|
+
// showing. These are the two utilities
|
|
397
|
+
// `ICON_CONTROL_SIZE_CLASS.sm` is, minus the
|
|
398
|
+
// height `CONTROL_SIZE_CLASS.sm` already
|
|
399
|
+
// set. Deterministic rather than a
|
|
400
|
+
// specificity coin-flip: Tailwind orders a
|
|
401
|
+
// variant after the utility it varies, which
|
|
402
|
+
// is the same thing the lane's own
|
|
403
|
+
// `cq-lg:p-0` relies on.
|
|
404
|
+
moveIcon != null &&
|
|
405
|
+
"@min-[48rem]/board:w-(--control-height-sm) @min-[48rem]/board:px-0",
|
|
406
|
+
)}
|
|
355
407
|
onClick={() => {
|
|
356
408
|
setIsMenuVisible(
|
|
357
409
|
(isVisible) => !isVisible,
|
|
@@ -361,23 +413,53 @@ export const BoardCard = ({
|
|
|
361
413
|
onStartDrag?.(pointerEvent)
|
|
362
414
|
}}
|
|
363
415
|
size="sm"
|
|
364
|
-
sizing=
|
|
416
|
+
sizing="control"
|
|
365
417
|
>
|
|
366
|
-
{moveIcon
|
|
418
|
+
{moveIcon == null ? null : (
|
|
419
|
+
// `hidden` first, so a browser with no
|
|
420
|
+
// container-query support — and every test
|
|
421
|
+
// renderer that does not lay out — keeps the
|
|
422
|
+
// word, which is the affordance that works
|
|
423
|
+
// everywhere.
|
|
424
|
+
//
|
|
425
|
+
// `contents` rather than `inline`: the glyph
|
|
426
|
+
// stays a direct flex child of the button,
|
|
427
|
+
// so it centres the way it did when it was
|
|
428
|
+
// the button's only content.
|
|
429
|
+
<span
|
|
430
|
+
aria-hidden="true"
|
|
431
|
+
className="hidden @min-[48rem]/board:contents"
|
|
432
|
+
>
|
|
433
|
+
{moveIcon}
|
|
434
|
+
</span>
|
|
435
|
+
)}
|
|
436
|
+
|
|
437
|
+
<span
|
|
438
|
+
aria-hidden="true"
|
|
439
|
+
className={toClassName(
|
|
440
|
+
moveIcon != null &&
|
|
441
|
+
"@min-[48rem]/board:hidden",
|
|
442
|
+
)}
|
|
443
|
+
>
|
|
444
|
+
Move
|
|
445
|
+
</span>
|
|
367
446
|
|
|
368
447
|
{/*
|
|
369
448
|
* Thirty controls all named "Move" is thirty
|
|
370
449
|
* indistinguishable buttons to a screen reader
|
|
371
450
|
* and to `getByRole("button", { name })`, so
|
|
372
451
|
* the accessible name says *which* card and
|
|
373
|
-
* where it currently is.
|
|
374
|
-
*
|
|
375
|
-
*
|
|
452
|
+
* where it currently is.
|
|
453
|
+
*
|
|
454
|
+
* The whole name lives here, and both visible
|
|
455
|
+
* affordances are `aria-hidden`, because which
|
|
456
|
+
* of them is painted is a CSS answer that the
|
|
457
|
+
* accessible name must not depend on — a
|
|
458
|
+
* handle called "Move X" wide and "Move Move
|
|
459
|
+
* X" narrow is one control with two names.
|
|
376
460
|
*/}
|
|
377
461
|
<VisuallyHidden>
|
|
378
|
-
{
|
|
379
|
-
? `Move ${item.title}, currently in ${laneLabel}`
|
|
380
|
-
: ` ${item.title}, currently in ${laneLabel}`}
|
|
462
|
+
{`Move ${item.title}, currently in ${laneLabel}`}
|
|
381
463
|
</VisuallyHidden>
|
|
382
464
|
</Button>
|
|
383
465
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
import { containerQuery } from "@charcuterie/tokens/src/scales.ts"
|
|
5
|
+
import { expect, test } from "vitest"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The move handle swaps its glyph for the word "Move" at the width
|
|
9
|
+
* where the lanes stop being side by side — and it has to be the
|
|
10
|
+
* SAME width the lanes themselves use, or there is a band where the
|
|
11
|
+
* board is one lane and the handle still teaches a drag.
|
|
12
|
+
*
|
|
13
|
+
* Two literals cannot be shared: a container query's threshold is
|
|
14
|
+
* resolved before custom properties exist, so
|
|
15
|
+
* `@container (min-inline-size: var(--cq-lg))` is invalid CSS and
|
|
16
|
+
* `48rem` has to be written out. The lanes get theirs from the
|
|
17
|
+
* generated `cq-lg` variant; the handle writes Tailwind's own
|
|
18
|
+
* `@min-[…]/board:`, because that is the only form that can name a
|
|
19
|
+
* container. This test is what keeps the two from drifting.
|
|
20
|
+
*/
|
|
21
|
+
const directory = import.meta.dirname
|
|
22
|
+
|
|
23
|
+
test("the handle's own breakpoint is the scale's `cq-lg`, spelled out", async () => {
|
|
24
|
+
const source = await readFile(
|
|
25
|
+
join(directory, "BoardCard.tsx"),
|
|
26
|
+
"utf8",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const thresholds = [
|
|
30
|
+
...source.matchAll(/@min-\[([^\]]+)\]\/board:/g),
|
|
31
|
+
].map((match) => match[1])
|
|
32
|
+
|
|
33
|
+
// The mutation guard: a refactor that renames the variant would
|
|
34
|
+
// otherwise turn the assertion below into `[] === []`.
|
|
35
|
+
expect(thresholds.length).toBeGreaterThan(0)
|
|
36
|
+
|
|
37
|
+
expect([...new Set(thresholds)]).toEqual([
|
|
38
|
+
containerQuery.lg,
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The other half. A named query with no container of that name
|
|
44
|
+
* never matches anything — no error, no warning, and the handle
|
|
45
|
+
* silently keeps whichever affordance the base classes gave it.
|
|
46
|
+
*/
|
|
47
|
+
test("the board declares the `board` container the handle queries", async () => {
|
|
48
|
+
const source = await readFile(
|
|
49
|
+
join(directory, "Board.tsx"),
|
|
50
|
+
"utf8",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
expect(source).toContain('"@container/board"')
|
|
54
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
} from "react"
|
|
7
|
+
|
|
8
|
+
/** Matches `@charcuterie/server`'s default build-marker URL. */
|
|
9
|
+
export const DEFAULT_DEPLOYMENT_PATH =
|
|
10
|
+
"/__charcuterie/deployment"
|
|
11
|
+
|
|
12
|
+
/** Matches `@charcuterie/server`'s default SSE URL. */
|
|
13
|
+
export const DEFAULT_DEPLOYMENT_EVENTS_PATH =
|
|
14
|
+
"/__charcuterie/deployment/events"
|
|
15
|
+
|
|
16
|
+
export type UseDeploymentUpdateOptions = {
|
|
17
|
+
deploymentPath?: string
|
|
18
|
+
deploymentEventsPath?: string
|
|
19
|
+
/** `0` disables polling. SSE is normally enough. */
|
|
20
|
+
pollIntervalMs?: number
|
|
21
|
+
/** Disable when an app calls `checkForUpdate` after its own SSE reconnects. */
|
|
22
|
+
isEventSourceEnabled?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DeploymentUpdate = {
|
|
26
|
+
isUpdateAvailable: boolean
|
|
27
|
+
/** Revalidate the marker now. Safe to call after an app SSE reconnects. */
|
|
28
|
+
checkForUpdate: () => Promise<boolean>
|
|
29
|
+
/** Reload after the user has saved or finished their current work. */
|
|
30
|
+
reload: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const getBuildId = (value: unknown): string | undefined => {
|
|
34
|
+
if (
|
|
35
|
+
typeof value === "object" &&
|
|
36
|
+
value !== null &&
|
|
37
|
+
"buildId" in value &&
|
|
38
|
+
typeof value.buildId === "string"
|
|
39
|
+
) {
|
|
40
|
+
return value.buildId
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Detects a replacement static SPA without taking control away from
|
|
47
|
+
* the app. It never reloads by itself: a tab can contain unsaved
|
|
48
|
+
* form state, so the app shows its own accessible update action and
|
|
49
|
+
* calls `reload` only when the user is ready.
|
|
50
|
+
*
|
|
51
|
+
* `createStaticHandler` supplies both endpoints by default. Existing
|
|
52
|
+
* app SSE can keep its one connection: set `isEventSourceEnabled` to
|
|
53
|
+
* `false`, then call `checkForUpdate` in its post-reconnect callback.
|
|
54
|
+
*/
|
|
55
|
+
export const useDeploymentUpdate = ({
|
|
56
|
+
deploymentEventsPath = DEFAULT_DEPLOYMENT_EVENTS_PATH,
|
|
57
|
+
deploymentPath = DEFAULT_DEPLOYMENT_PATH,
|
|
58
|
+
isEventSourceEnabled = true,
|
|
59
|
+
pollIntervalMs = 0,
|
|
60
|
+
}: UseDeploymentUpdateOptions = {}): DeploymentUpdate => {
|
|
61
|
+
const buildIdRef = useRef<string | undefined>(undefined)
|
|
62
|
+
const [isUpdateAvailable, setIsUpdateAvailable] =
|
|
63
|
+
useState(false)
|
|
64
|
+
|
|
65
|
+
const acceptBuildId = useCallback(
|
|
66
|
+
(buildId: string): boolean => {
|
|
67
|
+
if (buildIdRef.current === undefined) {
|
|
68
|
+
buildIdRef.current = buildId
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
if (buildIdRef.current === buildId) {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
setIsUpdateAvailable(true)
|
|
75
|
+
return true
|
|
76
|
+
},
|
|
77
|
+
[],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const checkForUpdate =
|
|
81
|
+
useCallback(async (): Promise<boolean> => {
|
|
82
|
+
const response = await globalThis.fetch(
|
|
83
|
+
deploymentPath,
|
|
84
|
+
{
|
|
85
|
+
cache: "no-store",
|
|
86
|
+
headers: { Accept: "application/json" },
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
const buildId = getBuildId(await response.json())
|
|
93
|
+
return buildId === undefined
|
|
94
|
+
? false
|
|
95
|
+
: acceptBuildId(buildId)
|
|
96
|
+
}, [acceptBuildId, deploymentPath])
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
void checkForUpdate().catch(() => undefined)
|
|
100
|
+
}, [checkForUpdate])
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (
|
|
104
|
+
!isEventSourceEnabled ||
|
|
105
|
+
typeof EventSource === "undefined"
|
|
106
|
+
) {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const source = new EventSource(deploymentEventsPath)
|
|
110
|
+
const onDeployment = (event: MessageEvent<string>) => {
|
|
111
|
+
try {
|
|
112
|
+
const buildId = getBuildId(JSON.parse(event.data))
|
|
113
|
+
if (buildId !== undefined) {
|
|
114
|
+
acceptBuildId(buildId)
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// A malformed marker is not a reason to interrupt the tab.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
source.addEventListener("deployment", onDeployment)
|
|
121
|
+
return () => source.close()
|
|
122
|
+
}, [
|
|
123
|
+
acceptBuildId,
|
|
124
|
+
deploymentEventsPath,
|
|
125
|
+
isEventSourceEnabled,
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (pollIntervalMs <= 0) {
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
const interval = globalThis.setInterval(() => {
|
|
133
|
+
void checkForUpdate().catch(() => undefined)
|
|
134
|
+
}, pollIntervalMs)
|
|
135
|
+
return () => globalThis.clearInterval(interval)
|
|
136
|
+
}, [checkForUpdate, pollIntervalMs])
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
checkForUpdate,
|
|
140
|
+
isUpdateAvailable,
|
|
141
|
+
reload: () => globalThis.location.reload(),
|
|
142
|
+
}
|
|
143
|
+
}
|