@charcuterie/ui 3.25.0 → 3.27.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 +4 -2
- package/dist/Board/Board.d.ts +20 -7
- package/dist/Board/Board.d.ts.map +1 -1
- package/dist/Board/Board.js +16 -10
- package/dist/Board/Board.js.map +1 -1
- package/dist/Board/BoardCard.d.ts +23 -10
- package/dist/Board/BoardCard.d.ts.map +1 -1
- package/dist/Board/BoardCard.js +44 -22
- package/dist/Board/BoardCard.js.map +1 -1
- package/dist/Board/BoardLaneList.d.ts +2 -1
- package/dist/Board/BoardLaneList.d.ts.map +1 -1
- package/dist/Board/BoardLaneList.js +13 -4
- package/dist/Board/BoardLaneList.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js +130 -74
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +135 -85
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
- package/package.json +1 -1
- package/src/Board/Board.mdx +17 -0
- package/src/Board/Board.stories.tsx +88 -0
- package/src/Board/Board.test.tsx +66 -0
- package/src/Board/Board.tsx +34 -8
- package/src/Board/BoardCard.tsx +66 -23
- package/src/Board/BoardLaneList.tsx +35 -5
- package/src/MarkdownEditor/MarkdownEditor.mdx +32 -4
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +108 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +89 -0
- package/src/icons.storyHelpers.tsx +11 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +3 -1
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +31 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +109 -1
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +84 -0
package/package.json
CHANGED
package/src/Board/Board.mdx
CHANGED
|
@@ -11,6 +11,8 @@ Lanes of cards, sized by their **container**, moved by keyboard or by pointer
|
|
|
11
11
|
|
|
12
12
|
<Canvas of={BoardStories.Default} />
|
|
13
13
|
|
|
14
|
+
<Canvas of={BoardStories.CardMenuItems} />
|
|
15
|
+
|
|
14
16
|
## Props
|
|
15
17
|
|
|
16
18
|
<Controls />
|
|
@@ -43,6 +45,18 @@ The narrow panel there is **not a phone**. It is the same board in a sidebar, in
|
|
|
43
45
|
window, or on a display somebody has zoomed to 175% — and a media query cannot tell any of
|
|
44
46
|
those apart from a maximised 4K window.
|
|
45
47
|
|
|
48
|
+
## Workflow columns and project-pool rows
|
|
49
|
+
|
|
50
|
+
The default `laneLayout="columns"` is for workflow stages. At `cq-lg` the lanes sit side by
|
|
51
|
+
side; below it, one selected lane stays on screen and a segmented control names the others.
|
|
52
|
+
|
|
53
|
+
`laneLayout="rows"` is for parallel project pools. Every lane becomes one horizontal band,
|
|
54
|
+
every lane stays visible, and the cards form a responsive grid inside their band. A consumer
|
|
55
|
+
can pass a short `items` array with the true `itemCount`; the remaining count stays explicit
|
|
56
|
+
instead of making one large pool determine the page height. Neither layout scrolls sideways.
|
|
57
|
+
|
|
58
|
+
<Canvas of={BoardStories.RowLayout} />
|
|
59
|
+
|
|
46
60
|
### The three card shapes
|
|
47
61
|
|
|
48
62
|
Straight from the consumer requirement, which is specific because the owner answered *"which
|
|
@@ -84,6 +98,9 @@ The move is one control with two drivers, and they commit through the same `onMo
|
|
|
84
98
|
code path, with `setPointerCapture` so the drag survives the pointer leaving the card.
|
|
85
99
|
Escape abandons it.
|
|
86
100
|
|
|
101
|
+
An item can append actions to that existing menu with `menuItems`. The Board inserts a
|
|
102
|
+
separator after the lane destinations, so a card action does not need a second trigger.
|
|
103
|
+
|
|
87
104
|
<Canvas of={BoardStories.Interactive} />
|
|
88
105
|
|
|
89
106
|
Every move is announced in a `role="status"` region that names the **destination and the
|
|
@@ -404,6 +404,35 @@ const LANES: BoardLane[] = [
|
|
|
404
404
|
},
|
|
405
405
|
]
|
|
406
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Eight parallel project pools, not workflow steps. The repeated
|
|
409
|
+
* source items are cloned with lane-qualified keys so the story can
|
|
410
|
+
* put realistic card density in every row without claiming that one
|
|
411
|
+
* card belongs to eight pools.
|
|
412
|
+
*/
|
|
413
|
+
const POOL_LANES: BoardLane[] = [
|
|
414
|
+
["repair", "Repair source", 83],
|
|
415
|
+
["add", "Add media", 78],
|
|
416
|
+
["replace", "Replace copy", 66],
|
|
417
|
+
["review", "Review retention", 38],
|
|
418
|
+
["find", "Find source", 31],
|
|
419
|
+
["extras", "Specials and films", 18],
|
|
420
|
+
["difficult", "Difficult repair", 14],
|
|
421
|
+
["unfiled", "Not in a lane", 1],
|
|
422
|
+
].map(([key, label, itemCount]) => ({
|
|
423
|
+
itemCount: Number(itemCount),
|
|
424
|
+
items: TODO_ITEMS.slice(
|
|
425
|
+
0,
|
|
426
|
+
Math.min(3, Number(itemCount)),
|
|
427
|
+
).map((item) => ({
|
|
428
|
+
...item,
|
|
429
|
+
key: `${String(key)}-${item.key}`,
|
|
430
|
+
})),
|
|
431
|
+
key: String(key),
|
|
432
|
+
label: String(label),
|
|
433
|
+
onShowMore: () => undefined,
|
|
434
|
+
}))
|
|
435
|
+
|
|
407
436
|
/**
|
|
408
437
|
* A **fixed** inline size, because the three-up layout is a
|
|
409
438
|
* measurement of the board's own box. A story that inherited the
|
|
@@ -446,6 +475,25 @@ type Story = StoryObj<typeof meta>
|
|
|
446
475
|
*/
|
|
447
476
|
export const Default: Story = {}
|
|
448
477
|
|
|
478
|
+
/**
|
|
479
|
+
* Parallel project pools as horizontal bands. Every lane stays on
|
|
480
|
+
* screen; the cards form a responsive grid inside their own lane,
|
|
481
|
+
* and the honest overflow keeps a large pool from making the page
|
|
482
|
+
* endless.
|
|
483
|
+
*/
|
|
484
|
+
export const RowLayout: Story = {
|
|
485
|
+
render: (boardProps) => (
|
|
486
|
+
<Frame inlineSize="72rem">
|
|
487
|
+
<Board
|
|
488
|
+
{...boardProps}
|
|
489
|
+
label="Project pools"
|
|
490
|
+
laneLayout="rows"
|
|
491
|
+
lanes={POOL_LANES}
|
|
492
|
+
/>
|
|
493
|
+
</Frame>
|
|
494
|
+
),
|
|
495
|
+
}
|
|
496
|
+
|
|
449
497
|
/**
|
|
450
498
|
* Read-only against movable, side by side.
|
|
451
499
|
*
|
|
@@ -750,6 +798,46 @@ export const Interactive: Story = {
|
|
|
750
798
|
render: () => <MovableBoard />,
|
|
751
799
|
}
|
|
752
800
|
|
|
801
|
+
/**
|
|
802
|
+
* A consumer can add a card action to the same menu as the lane
|
|
803
|
+
* moves. The separator is supplied by `BoardCard`, so the action
|
|
804
|
+
* does not need a second trigger beside the existing one.
|
|
805
|
+
*/
|
|
806
|
+
export const CardMenuItems: Story = {
|
|
807
|
+
render: () => (
|
|
808
|
+
<Frame inlineSize="72rem">
|
|
809
|
+
<Board
|
|
810
|
+
label="Today, with card actions"
|
|
811
|
+
lanes={[
|
|
812
|
+
{
|
|
813
|
+
items: [
|
|
814
|
+
{
|
|
815
|
+
...TODO_ITEMS[0],
|
|
816
|
+
menuItems: [
|
|
817
|
+
{
|
|
818
|
+
key: "send-to-backlog",
|
|
819
|
+
label: "Send to Backlog",
|
|
820
|
+
onSelect: () => undefined,
|
|
821
|
+
},
|
|
822
|
+
],
|
|
823
|
+
},
|
|
824
|
+
],
|
|
825
|
+
key: "todo",
|
|
826
|
+
label: "Todo",
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
items: IN_PROGRESS_ITEMS.slice(0, 1),
|
|
830
|
+
key: "in-progress",
|
|
831
|
+
label: "In Progress",
|
|
832
|
+
},
|
|
833
|
+
]}
|
|
834
|
+
moveIcon={<MoreIcon />}
|
|
835
|
+
onMove={() => undefined}
|
|
836
|
+
/>
|
|
837
|
+
</Frame>
|
|
838
|
+
),
|
|
839
|
+
}
|
|
840
|
+
|
|
753
841
|
/**
|
|
754
842
|
* Where it actually goes, and what the consumer still owns.
|
|
755
843
|
*
|
package/src/Board/Board.test.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import * as stories from "./Board.stories.tsx"
|
|
|
15
15
|
const {
|
|
16
16
|
AccentEdge,
|
|
17
17
|
AllStates,
|
|
18
|
+
CardMenuItems,
|
|
18
19
|
Default,
|
|
19
20
|
InBoardScreen,
|
|
20
21
|
Interactive,
|
|
@@ -22,6 +23,7 @@ const {
|
|
|
22
23
|
LinkedLaneHeadings,
|
|
23
24
|
MoveHandleByWidth,
|
|
24
25
|
Responsive,
|
|
26
|
+
RowLayout,
|
|
25
27
|
} = composeStories(stories)
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -74,6 +76,30 @@ test("a board wider than cq-lg shows every lane at once", async () => {
|
|
|
74
76
|
).toHaveLength(0)
|
|
75
77
|
})
|
|
76
78
|
|
|
79
|
+
test("the row layout keeps every lane visible and grids the cards inside it", async () => {
|
|
80
|
+
const { canvas } = await mountStory(RowLayout)
|
|
81
|
+
|
|
82
|
+
await expect(canvas.getAllByRole("group")).toHaveLength(8)
|
|
83
|
+
await expect(
|
|
84
|
+
canvas.queryAllByRole("radiogroup"),
|
|
85
|
+
).toHaveLength(0)
|
|
86
|
+
|
|
87
|
+
const repair = expectAgentDrivable(canvas, {
|
|
88
|
+
name: "Repair source",
|
|
89
|
+
role: "group",
|
|
90
|
+
})
|
|
91
|
+
const list = within(repair).getByRole("list")
|
|
92
|
+
const columns = getComputedStyle(list)
|
|
93
|
+
.gridTemplateColumns.split(" ")
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
|
|
96
|
+
await expect(getComputedStyle(list).display).toBe("grid")
|
|
97
|
+
await expect(columns.length).toBeGreaterThan(1)
|
|
98
|
+
await expect(
|
|
99
|
+
within(repair).getByText("+ 80 more in Repair source"),
|
|
100
|
+
).toBeInTheDocument()
|
|
101
|
+
})
|
|
102
|
+
|
|
77
103
|
/**
|
|
78
104
|
* The count is the lane's **real** size, not the number of rows
|
|
79
105
|
* painted. A board that truncates and then reports the truncated
|
|
@@ -408,6 +434,46 @@ test("the board adds no link of its own around a rich title", async () => {
|
|
|
408
434
|
)
|
|
409
435
|
})
|
|
410
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Extra card actions share the existing move menu. A second
|
|
439
|
+
* trigger would make the card look busy and would split related
|
|
440
|
+
* actions across two controls, so the component owns the separator
|
|
441
|
+
* between the lane destinations and the consumer's items.
|
|
442
|
+
*/
|
|
443
|
+
test("a card can append an action to its existing menu", async () => {
|
|
444
|
+
const { body, canvas } = await mountStory(CardMenuItems)
|
|
445
|
+
|
|
446
|
+
const todo = expectAgentDrivable(canvas, {
|
|
447
|
+
name: "Todo",
|
|
448
|
+
role: "group",
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
await userEvent.click(
|
|
452
|
+
within(todo).getByRole("button", {
|
|
453
|
+
name: /^Move Retire the second scheduler/,
|
|
454
|
+
}),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
const menu = await waitFor(() =>
|
|
458
|
+
expectAgentDrivable(body, {
|
|
459
|
+
name: /Move Retire the second scheduler/,
|
|
460
|
+
role: "menu",
|
|
461
|
+
}),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
expect(
|
|
465
|
+
within(menu).getByRole("menuitem", {
|
|
466
|
+
name: "In Progress",
|
|
467
|
+
}),
|
|
468
|
+
).toBeInTheDocument()
|
|
469
|
+
expect(
|
|
470
|
+
within(menu).getByRole("menuitem", {
|
|
471
|
+
name: "Send to Backlog",
|
|
472
|
+
}),
|
|
473
|
+
).toBeInTheDocument()
|
|
474
|
+
expect(menu.querySelector("hr")).not.toBeNull()
|
|
475
|
+
})
|
|
476
|
+
|
|
411
477
|
/**
|
|
412
478
|
* The edge is drawn on the card's OWN box, which is the whole
|
|
413
479
|
* difference between it and the `accentIntent` pill.
|
package/src/Board/Board.tsx
CHANGED
|
@@ -42,6 +42,16 @@ export type BoardProps = {
|
|
|
42
42
|
headingLevel?: 2 | 3 | 4 | 5
|
|
43
43
|
/** The board's accessible name. Required — it is a landmark. */
|
|
44
44
|
label: string
|
|
45
|
+
/**
|
|
46
|
+
* How the lanes themselves are arranged.
|
|
47
|
+
*
|
|
48
|
+
* `columns` is the workflow shape: lanes sit side by side above
|
|
49
|
+
* `cq-lg`, and the Narrow View selects one lane. `rows` is the
|
|
50
|
+
* project-pool shape: every lane is one horizontal band, its cards
|
|
51
|
+
* form a responsive grid inside that band, and every lane stays on
|
|
52
|
+
* screen at every width.
|
|
53
|
+
*/
|
|
54
|
+
laneLayout?: "columns" | "rows"
|
|
45
55
|
lanes: readonly BoardLane[]
|
|
46
56
|
/**
|
|
47
57
|
* What the move handle shows **while the lanes are side by side**.
|
|
@@ -91,9 +101,11 @@ export type BoardProps = {
|
|
|
91
101
|
* So there are **two nested containers**, and they answer two
|
|
92
102
|
* different questions:
|
|
93
103
|
*
|
|
94
|
-
* - The board's own box decides *how
|
|
95
|
-
*
|
|
96
|
-
* control below it.
|
|
104
|
+
* - The board's own box decides *how workflow lanes are shown* —
|
|
105
|
+
* side by side at `cq-lg` and wider, one lane plus a segmented
|
|
106
|
+
* control below it. A project-pool board with
|
|
107
|
+
* `laneLayout="rows"` keeps every lane on screen and grids the
|
|
108
|
+
* cards inside each horizontal band instead.
|
|
97
109
|
* - Each lane's list decides *what shape a card is* — two lines,
|
|
98
110
|
* one line, or its own card. See `BoardCard`.
|
|
99
111
|
*
|
|
@@ -105,9 +117,10 @@ export type BoardProps = {
|
|
|
105
117
|
* *"No horizontal scroll, ever. The board becomes a segmented
|
|
106
118
|
* control, never a pan surface."* A board that pans sideways hides
|
|
107
119
|
* lanes behind a gesture with no affordance, and on a trackpad it
|
|
108
|
-
* fights the browser's own back-navigation. So the narrow
|
|
109
|
-
* shows one lane at a time and names the others in a
|
|
110
|
-
* that says how many cards each holds.
|
|
120
|
+
* fights the browser's own back-navigation. So the narrow workflow
|
|
121
|
+
* layout shows one lane at a time and names the others in a
|
|
122
|
+
* `radiogroup` that says how many cards each holds. A row layout
|
|
123
|
+
* wraps its cards inside each lane and also never pans.
|
|
111
124
|
*
|
|
112
125
|
* ### Moving a card is a first-class operation, and it is not a drag
|
|
113
126
|
*
|
|
@@ -137,6 +150,7 @@ export const Board = ({
|
|
|
137
150
|
className,
|
|
138
151
|
headingLevel = 3,
|
|
139
152
|
label,
|
|
153
|
+
laneLayout = "columns",
|
|
140
154
|
lanes,
|
|
141
155
|
moveIcon,
|
|
142
156
|
narrowLaneKey,
|
|
@@ -249,7 +263,12 @@ export const Board = ({
|
|
|
249
263
|
* board has no stray radiogroup in it.
|
|
250
264
|
*/}
|
|
251
265
|
<SegmentedControl
|
|
252
|
-
className=
|
|
266
|
+
className={toClassName(
|
|
267
|
+
"max-w-full flex-wrap",
|
|
268
|
+
laneLayout === "rows"
|
|
269
|
+
? "hidden"
|
|
270
|
+
: "cq-lg:hidden",
|
|
271
|
+
)}
|
|
253
272
|
items={lanes.map((lane) => ({
|
|
254
273
|
// Stacked rather than `Todo (6)`, because the count is
|
|
255
274
|
// the reason to look: a lane picker that reads
|
|
@@ -276,7 +295,13 @@ export const Board = ({
|
|
|
276
295
|
selectedValue={visibleLaneKey}
|
|
277
296
|
/>
|
|
278
297
|
|
|
279
|
-
<div
|
|
298
|
+
<div
|
|
299
|
+
className={toClassName(
|
|
300
|
+
"flex flex-col gap-4",
|
|
301
|
+
laneLayout === "columns" &&
|
|
302
|
+
"cq-lg:flex-row cq-lg:items-start",
|
|
303
|
+
)}
|
|
304
|
+
>
|
|
280
305
|
{lanes.map((lane) => (
|
|
281
306
|
<BoardLaneList
|
|
282
307
|
headingLevel={headingLevel}
|
|
@@ -290,6 +315,7 @@ export const Board = ({
|
|
|
290
315
|
}
|
|
291
316
|
key={lane.key}
|
|
292
317
|
lane={lane}
|
|
318
|
+
laneLayout={laneLayout}
|
|
293
319
|
moveIcon={moveIcon}
|
|
294
320
|
moveTargets={lanes
|
|
295
321
|
.filter((one) => one.key !== lane.key)
|
package/src/Board/BoardCard.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
FOCUS_RING_CLASS,
|
|
13
13
|
INTENT_SOLID_FILL_CLASS,
|
|
14
14
|
} from "../intentStyles.ts"
|
|
15
|
+
import type { MenuProps } from "../Menu/Menu.tsx"
|
|
15
16
|
import { Menu } from "../Menu/Menu.tsx"
|
|
16
17
|
import { TextLink } from "../TextLink/TextLink.tsx"
|
|
17
18
|
import { toClassName } from "../toClassName.ts"
|
|
@@ -55,6 +56,12 @@ type BoardItemFields = {
|
|
|
55
56
|
title: string
|
|
56
57
|
/** The trailing cluster: an assignee avatar, an elapsed-time chip. */
|
|
57
58
|
trailing?: ReactNode
|
|
59
|
+
/**
|
|
60
|
+
* Extra actions in the card's existing menu, after the lane moves.
|
|
61
|
+
* A separator is added when both sets have entries, so a consumer
|
|
62
|
+
* can add a card action without creating a second trigger.
|
|
63
|
+
*/
|
|
64
|
+
menuItems?: MenuProps["items"]
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/**
|
|
@@ -144,6 +151,12 @@ export type BoardItem = BoardItemAccent &
|
|
|
144
151
|
BoardItemTitleLink
|
|
145
152
|
|
|
146
153
|
export type BoardCardProps = {
|
|
154
|
+
/**
|
|
155
|
+
* A grid card measures its own inline size for its inner layout.
|
|
156
|
+
* A list card inherits the lane's measurement, which is almost
|
|
157
|
+
* the same width and avoids an unnecessary nested container.
|
|
158
|
+
*/
|
|
159
|
+
isInGrid: boolean
|
|
147
160
|
item: BoardItem
|
|
148
161
|
laneLabel: string
|
|
149
162
|
/**
|
|
@@ -175,25 +188,25 @@ export type BoardCardProps = {
|
|
|
175
188
|
* One row on the board — and *row* is only what it is at some
|
|
176
189
|
* widths.
|
|
177
190
|
*
|
|
178
|
-
* ### The three shapes are one component, chosen by
|
|
191
|
+
* ### The three shapes are one component, chosen by available space
|
|
179
192
|
*
|
|
180
193
|
* The requirement this was built against is unusually specific, and
|
|
181
194
|
* it is specific because the owner answered "which row style?" with
|
|
182
195
|
* *"all three, depending"*:
|
|
183
196
|
*
|
|
184
|
-
* | The
|
|
197
|
+
* | The card's available width is | The card is |
|
|
185
198
|
* | --- | --- |
|
|
186
199
|
* | narrower than `cq-sm` (24rem) | **two lines** — title clamped to two, metadata underneath, only the trailing slot beside it |
|
|
187
200
|
* | `cq-sm` to `cq-lg` | **one line** — title ellipsised, metadata right-aligned |
|
|
188
201
|
* | `cq-lg` (48rem) and wider | **a card** — its own border, surface and elevation, instead of a divider in a list |
|
|
189
202
|
*
|
|
190
|
-
* Every one of those is a **container** query
|
|
191
|
-
* list
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* would be a defect.
|
|
203
|
+
* Every one of those is a **container** query, never a media query.
|
|
204
|
+
* A list card reads the lane because it fills that lane. A grid card
|
|
205
|
+
* is its own container because several cards share a wide row. A
|
|
206
|
+
* lane in a three-up board is narrow on a 4K display, and a zoomed
|
|
207
|
+
* browser can report far fewer effective CSS pixels than its window
|
|
208
|
+
* has. Window width is not a proxy for the space this card has. A
|
|
209
|
+
* `@media (max-width: …)` anywhere in this file would be a defect.
|
|
197
210
|
*
|
|
198
211
|
* It also dissolved a bug rather than only satisfying a preference:
|
|
199
212
|
* a *fixed* two-line row collapsed its title to `a…` inside a narrow
|
|
@@ -242,6 +255,7 @@ export type BoardCardProps = {
|
|
|
242
255
|
* answers this question exactly backwards.
|
|
243
256
|
*/
|
|
244
257
|
export const BoardCard = ({
|
|
258
|
+
isInGrid,
|
|
245
259
|
item,
|
|
246
260
|
laneLabel,
|
|
247
261
|
moveIcon,
|
|
@@ -251,6 +265,34 @@ export const BoardCard = ({
|
|
|
251
265
|
}: BoardCardProps): ReactNode => {
|
|
252
266
|
const [isMenuVisible, setIsMenuVisible] = useState(false)
|
|
253
267
|
|
|
268
|
+
const isMovable =
|
|
269
|
+
onMoveToLane != null && moveTargets.length > 0
|
|
270
|
+
const hasMenuItems = (item.menuItems?.length ?? 0) > 0
|
|
271
|
+
const hasMenu =
|
|
272
|
+
onMoveToLane != null && (isMovable || hasMenuItems)
|
|
273
|
+
const menuItems: MenuProps["items"] = [
|
|
274
|
+
...(isMovable
|
|
275
|
+
? moveTargets.map((target) => ({
|
|
276
|
+
key: target.key,
|
|
277
|
+
label: target.label,
|
|
278
|
+
onSelect: () => {
|
|
279
|
+
onMoveToLane?.(target.key)
|
|
280
|
+
},
|
|
281
|
+
}))
|
|
282
|
+
: []),
|
|
283
|
+
...(hasMenuItems && isMovable
|
|
284
|
+
? [
|
|
285
|
+
{
|
|
286
|
+
key: "board-extra-actions",
|
|
287
|
+
type: "separator" as const,
|
|
288
|
+
},
|
|
289
|
+
]
|
|
290
|
+
: []),
|
|
291
|
+
...(item.menuItems ?? []),
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
const menuLabel = isMovable ? "Move" : "More"
|
|
295
|
+
|
|
254
296
|
const titleBlock = (
|
|
255
297
|
// `line-clamp-none` before `truncate`, because `line-clamp-2`
|
|
256
298
|
// sets `display: -webkit-box` and `text-overflow` has nothing to
|
|
@@ -267,6 +309,7 @@ export const BoardCard = ({
|
|
|
267
309
|
<li
|
|
268
310
|
className={toClassName(
|
|
269
311
|
"group relative flex gap-2 border-border-subtle border-b py-2 pe-1 last:border-b-0",
|
|
312
|
+
isInGrid && "@container",
|
|
270
313
|
// At `cq-lg` the list stops being a list of rows: the
|
|
271
314
|
// divider goes and each card grows its own box. The *list*
|
|
272
315
|
// cannot do this to itself — it is the container, and a
|
|
@@ -359,20 +402,14 @@ export const BoardCard = ({
|
|
|
359
402
|
) : null}
|
|
360
403
|
</div>
|
|
361
404
|
|
|
362
|
-
{item.trailing ||
|
|
405
|
+
{item.trailing || hasMenu ? (
|
|
363
406
|
<div className="flex shrink-0 items-center gap-1">
|
|
364
407
|
{item.trailing}
|
|
365
408
|
|
|
366
|
-
{
|
|
409
|
+
{hasMenu ? (
|
|
367
410
|
<Menu
|
|
368
411
|
isVisible={isMenuVisible}
|
|
369
|
-
items={
|
|
370
|
-
key: target.key,
|
|
371
|
-
label: target.label,
|
|
372
|
-
onSelect: () => {
|
|
373
|
-
onMoveToLane(target.key)
|
|
374
|
-
},
|
|
375
|
-
}))}
|
|
412
|
+
items={menuItems}
|
|
376
413
|
onDismiss={() => {
|
|
377
414
|
setIsMenuVisible(false)
|
|
378
415
|
}}
|
|
@@ -409,9 +446,13 @@ export const BoardCard = ({
|
|
|
409
446
|
(isVisible) => !isVisible,
|
|
410
447
|
)
|
|
411
448
|
}}
|
|
412
|
-
onPointerDown={
|
|
413
|
-
|
|
414
|
-
|
|
449
|
+
onPointerDown={
|
|
450
|
+
isMovable
|
|
451
|
+
? (pointerEvent) => {
|
|
452
|
+
onStartDrag?.(pointerEvent)
|
|
453
|
+
}
|
|
454
|
+
: undefined
|
|
455
|
+
}
|
|
415
456
|
size="sm"
|
|
416
457
|
sizing="control"
|
|
417
458
|
>
|
|
@@ -441,7 +482,7 @@ export const BoardCard = ({
|
|
|
441
482
|
"@min-[48rem]/board:hidden",
|
|
442
483
|
)}
|
|
443
484
|
>
|
|
444
|
-
|
|
485
|
+
{menuLabel}
|
|
445
486
|
</span>
|
|
446
487
|
|
|
447
488
|
{/*
|
|
@@ -459,7 +500,9 @@ export const BoardCard = ({
|
|
|
459
500
|
* X" narrow is one control with two names.
|
|
460
501
|
*/}
|
|
461
502
|
<VisuallyHidden>
|
|
462
|
-
{
|
|
503
|
+
{isMovable
|
|
504
|
+
? `Move ${item.title}, currently in ${laneLabel}`
|
|
505
|
+
: `More actions for ${item.title}, currently in ${laneLabel}`}
|
|
463
506
|
</VisuallyHidden>
|
|
464
507
|
</Button>
|
|
465
508
|
}
|
|
@@ -89,6 +89,7 @@ export type BoardLaneListProps = {
|
|
|
89
89
|
headingLevel: 2 | 3 | 4 | 5
|
|
90
90
|
isVisibleWhenNarrow: boolean
|
|
91
91
|
lane: BoardLane
|
|
92
|
+
laneLayout: "columns" | "rows"
|
|
92
93
|
moveIcon?: ReactNode
|
|
93
94
|
moveTargets: readonly { key: string; label: string }[]
|
|
94
95
|
onMoveToLane?: (
|
|
@@ -144,6 +145,7 @@ export const BoardLaneList = ({
|
|
|
144
145
|
headingLevel,
|
|
145
146
|
isVisibleWhenNarrow,
|
|
146
147
|
lane,
|
|
148
|
+
laneLayout,
|
|
147
149
|
moveIcon,
|
|
148
150
|
moveTargets,
|
|
149
151
|
onMoveToLane,
|
|
@@ -189,7 +191,11 @@ export const BoardLaneList = ({
|
|
|
189
191
|
// queries the **board**, not the list below — an element
|
|
190
192
|
// carrying `container-type` still resolves its own container
|
|
191
193
|
// queries against its nearest ancestor container.
|
|
192
|
-
|
|
194
|
+
laneLayout === "rows"
|
|
195
|
+
? "flex"
|
|
196
|
+
: isVisibleWhenNarrow
|
|
197
|
+
? "flex"
|
|
198
|
+
: "hidden cq-lg:flex",
|
|
193
199
|
)}
|
|
194
200
|
>
|
|
195
201
|
{/*
|
|
@@ -201,8 +207,20 @@ export const BoardLaneList = ({
|
|
|
201
207
|
* treatment becomes expressible.
|
|
202
208
|
*/}
|
|
203
209
|
<div className="@container">
|
|
204
|
-
<div
|
|
205
|
-
|
|
210
|
+
<div
|
|
211
|
+
className={toClassName(
|
|
212
|
+
"gap-2 rounded-lg border border-border-subtle px-3 py-2",
|
|
213
|
+
laneLayout === "rows"
|
|
214
|
+
? "grid bg-surface-raised cq-lg:grid-cols-[12rem_minmax(0,1fr)] cq-lg:items-start cq-lg:gap-x-3"
|
|
215
|
+
: "flex flex-col bg-surface-raised cq-lg:border-0 cq-lg:bg-transparent cq-lg:p-0",
|
|
216
|
+
)}
|
|
217
|
+
>
|
|
218
|
+
<header
|
|
219
|
+
className={toClassName(
|
|
220
|
+
"flex items-center gap-2",
|
|
221
|
+
laneLayout === "rows" && "flex-wrap",
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
206
224
|
<Heading
|
|
207
225
|
className="font-semibold text-content-primary text-sm"
|
|
208
226
|
id={headingId}
|
|
@@ -245,7 +263,12 @@ export const BoardLaneList = ({
|
|
|
245
263
|
</header>
|
|
246
264
|
|
|
247
265
|
<ul
|
|
248
|
-
className=
|
|
266
|
+
className={toClassName(
|
|
267
|
+
"relative",
|
|
268
|
+
laneLayout === "rows"
|
|
269
|
+
? "grid grid-cols-[repeat(auto-fit,minmax(min(100%,14rem),1fr))] gap-2"
|
|
270
|
+
: "flex flex-col",
|
|
271
|
+
)}
|
|
249
272
|
ref={registerListElement}
|
|
250
273
|
>
|
|
251
274
|
{lane.items.length === 0 ? (
|
|
@@ -266,6 +289,7 @@ export const BoardLaneList = ({
|
|
|
266
289
|
) : (
|
|
267
290
|
lane.items.map((item, index) => (
|
|
268
291
|
<BoardCard
|
|
292
|
+
isInGrid={laneLayout === "rows"}
|
|
269
293
|
item={item}
|
|
270
294
|
key={item.key}
|
|
271
295
|
laneLabel={lane.label}
|
|
@@ -304,7 +328,13 @@ export const BoardLaneList = ({
|
|
|
304
328
|
</ul>
|
|
305
329
|
|
|
306
330
|
{hiddenCount > 0 ? (
|
|
307
|
-
<p
|
|
331
|
+
<p
|
|
332
|
+
className={toClassName(
|
|
333
|
+
"text-content-muted text-xs",
|
|
334
|
+
laneLayout === "rows" &&
|
|
335
|
+
"cq-lg:col-start-2",
|
|
336
|
+
)}
|
|
337
|
+
>
|
|
308
338
|
{lane.onShowMore ? (
|
|
309
339
|
<Button
|
|
310
340
|
appearance="ghost"
|
|
@@ -114,7 +114,7 @@ A consumer that must swap the whole document — Docket opening a different task
|
|
|
114
114
|
with a `key`. That is one line, and it is honest about the fact that the old undo history
|
|
115
115
|
belonged to the old document.
|
|
116
116
|
|
|
117
|
-
##
|
|
117
|
+
## Put an image at the cursor — paste, drop, or press **Image**
|
|
118
118
|
|
|
119
119
|
```tsx
|
|
120
120
|
<MarkdownEditor
|
|
@@ -137,8 +137,35 @@ during an upload and the insertion point has to move with what they type. It car
|
|
|
137
137
|
counter so two files of the same name do not resolve into each other's slot, and a failed
|
|
138
138
|
upload leaves a document the user can read and fix rather than a dangling widget.
|
|
139
139
|
|
|
140
|
-
Omit `onUploadImage` and
|
|
141
|
-
image on its own, which is the correct
|
|
140
|
+
Omit `onUploadImage` and none of the three is offered — no toolbar button, and a paste left
|
|
141
|
+
to the browser. A `<textarea>` ignores a pasted image on its own, which is the correct
|
|
142
|
+
fallback rather than a broken one.
|
|
143
|
+
|
|
144
|
+
### The **Image** button, and why it is in the toolbar
|
|
145
|
+
|
|
146
|
+
Paste and drop are both mouse-and-keyboard gestures. On a tablet there is nothing to paste
|
|
147
|
+
from and nothing to drag, so wiring `onUploadImage` also puts an **Image** action in the
|
|
148
|
+
toolbar: it opens the platform file picker, which on a phone is where the camera is offered.
|
|
149
|
+
|
|
150
|
+
It is a toolbar action rather than a `FileDropZone` under the box **because of the caret**.
|
|
151
|
+
Pressing anything outside the editor moves focus out of it, so a control beside the editor
|
|
152
|
+
can only append to the end of the document — and an image in prose belongs at the point it
|
|
153
|
+
is explained, not stapled to the bottom. The `<textarea>` keeps its `selectionStart` while
|
|
154
|
+
blurred, so the insertion lands where the user last was.
|
|
155
|
+
|
|
156
|
+
**It is the fourth action, and the position is load-bearing.** `Toolbar` overflows from the
|
|
157
|
+
**end** of the list, so the order in that list is the priority order. Placed last — beside
|
|
158
|
+
Link, where it reads most naturally — Image was the first thing to collapse, hidden behind
|
|
159
|
+
"More actions" at 900px. A control nobody can find does not fix "adding an image is buried";
|
|
160
|
+
it moves the problem. Fourth is visible wherever four actions fit, and it reads as a group:
|
|
161
|
+
emphasis, structure, insert.
|
|
162
|
+
|
|
163
|
+
⚠️ **It still collapses on a genuinely narrow editor, and that is not a bug to route around.**
|
|
164
|
+
Measured on the `Interactive` story: at 900px the bar carries eight actions and Image is one
|
|
165
|
+
of them; at 460px it carries three, and everything after Heading is in the overflow. A
|
|
166
|
+
comment box in a rail is that width. Keep a `FileDropZone` beside such an editor as well —
|
|
167
|
+
appending to the end of the document is the right trade when the alternative is a control
|
|
168
|
+
nobody can reach.
|
|
142
169
|
|
|
143
170
|
## Composition, and the one component that did not fit
|
|
144
171
|
|
|
@@ -152,7 +179,8 @@ component.
|
|
|
152
179
|
`<label>` around a file input covering its whole area — correct for "drop a disc image here",
|
|
153
180
|
and wrong for a text surface, where the drop has to land *at a character offset* and the area
|
|
154
181
|
has to keep taking clicks as caret placement. So the editor handles its own drop, and
|
|
155
|
-
`FileDropZone` stays the right answer for the attachment list beside it
|
|
182
|
+
`FileDropZone` stays the right answer for the attachment list beside it — and for a
|
|
183
|
+
narrow editor whose toolbar has collapsed the Image action away.
|
|
156
184
|
|
|
157
185
|
<Canvas of={MarkdownEditorStories.Responsive} />
|
|
158
186
|
|