@tristan2828/ui-foundation 3.13.0 → 3.15.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/conventions/docs/add-an-entity.md +7 -2
- package/conventions/docs/cell-patterns.md +262 -2
- package/conventions/docs/design-language.md +23 -4
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +218 -15
- package/dist/components/app/rich-text-editor.d.ts +8 -0
- package/dist/components/app/stage-circle.d.ts +20 -0
- package/dist/components/app/stage-circle.js +30 -0
- package/dist/components/ui/badge.d.ts +1 -1
- package/dist/components/ui/popover.d.ts +1 -1
- package/dist/components/ui/popover.js +6 -2
- package/dist/components/ui/toggle.d.ts +8 -0
- package/dist/components/ui/toggle.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/link-href.d.ts +2 -0
- package/dist/lib/link-href.js +22 -0
- package/dist/lib/markdown-merge.js +8 -7
- package/package.json +1 -1
|
@@ -168,7 +168,10 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
168
168
|
`src/routes/<entity>/<entity>-schema.ts` (zod schema mirroring
|
|
169
169
|
`<Entity>Create`/`<Entity>Update`, plus the form ↔ wire conversion
|
|
170
170
|
functions — see `widgetToFormValues`/`formValuesToWidgetCreate` for
|
|
171
|
-
the shape
|
|
171
|
+
the shape. A Markdown long text is checked with `refine`, never
|
|
172
|
+
trimmed, like Widget's `description`: a trim rewrites text nobody
|
|
173
|
+
touched, a leading indent that makes a code block or the text's
|
|
174
|
+
own ending)
|
|
172
175
|
- `src/routes/widgets/widget-format.ts` →
|
|
173
176
|
`src/routes/<entity>/<entity>-format.ts` (the lookups and formatters
|
|
174
177
|
both the columns and the view render values with: badge variant maps,
|
|
@@ -194,7 +197,9 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
194
197
|
the plan edits in place** (`Edit in place`), shared by the form
|
|
195
198
|
(through a `Controller`) and the view (through `editInPlace`), and
|
|
196
199
|
the Markdown long text's `RichTextEditor` (on the form whether or
|
|
197
|
-
not it edits in place)
|
|
200
|
+
not it edits in place), given a `placeholder` and `maxLength` (the
|
|
201
|
+
schema's limit, one constant both read, like Widget's
|
|
202
|
+
`DESCRIPTION_MAX_LENGTH`). Every other field's control stays inline in
|
|
198
203
|
the form, as `widget-form.tsx`'s Category, Tags and Available From
|
|
199
204
|
do. No `Edit in place` line and no Markdown long text: no fields
|
|
200
205
|
file.
|
|
@@ -10,7 +10,12 @@ alternatives ([`column-options.md`](column-options.md)), not designed in the abs
|
|
|
10
10
|
Patterns 12 (boolean), 15 (yes/no flipped in the row), 16 (title linking
|
|
11
11
|
to its view), 17 (quick action on the view) and 18 (editable value) are
|
|
12
12
|
the template's own references: Widget's In Stock and Name columns and its
|
|
13
|
-
view render them, and their specs pin them.
|
|
13
|
+
view render them, and their specs pin them. Pattern 19 (stage circle)
|
|
14
|
+
and the multi-value variant of 4, the dependency list in 10 and the
|
|
15
|
+
pressed icon in 15 were each built for a real app's screen first; the
|
|
16
|
+
package's Storybook renders them (`app/StageCircle`,
|
|
17
|
+
`patterns/CellPatterns`), and its checks pin their accessibility and
|
|
18
|
+
contrast in both themes. The other patterns from 11 on are
|
|
14
19
|
marked **Unproven**: written ahead of a real
|
|
15
20
|
column, so no app has tested them on real data yet. Prefer a proven
|
|
16
21
|
pattern when one fits. When you use an unproven one, say so in the
|
|
@@ -109,6 +114,63 @@ narrow.
|
|
|
109
114
|
cell, absent in an isolated Storybook story.
|
|
110
115
|
- Encode the value in the glyph's **shape** (solid / half / slashed /
|
|
111
116
|
dashed), not its colour.
|
|
117
|
+
- **Give the trigger a focus ring**
|
|
118
|
+
(`rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50`):
|
|
119
|
+
it's focusable, and a span has none of its own.
|
|
120
|
+
|
|
121
|
+
### Several values: the icons side by side, every name in one tooltip
|
|
122
|
+
|
|
123
|
+
A multi value (several categories on one record) where each value has
|
|
124
|
+
its own icon and colour slot, and the column is narrow. The package's
|
|
125
|
+
Storybook renders it (`patterns/CellPatterns`, "Icons with one tooltip").
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
// values: the row's categories, `undefined` for one still loading.
|
|
129
|
+
const names = values.map((value) => value?.name ?? '…').join(', ')
|
|
130
|
+
if (values.length === 0) return emptyCell
|
|
131
|
+
|
|
132
|
+
<Tooltip>
|
|
133
|
+
<TooltipTrigger
|
|
134
|
+
render={
|
|
135
|
+
<span role="img" aria-label={names} tabIndex={0}
|
|
136
|
+
className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50" />
|
|
137
|
+
}
|
|
138
|
+
>
|
|
139
|
+
{values.map((value, index) => {
|
|
140
|
+
if (!value) return <span key={index} className="text-muted-foreground">…</span>
|
|
141
|
+
const Icon = CATEGORY_ICON[value.icon]
|
|
142
|
+
return <Icon key={value.id} className={`size-4 shrink-0 ${CATEGORY_COLOR[value.color]}`} />
|
|
143
|
+
})}
|
|
144
|
+
</TooltipTrigger>
|
|
145
|
+
<TooltipContent>{names}</TooltipContent>
|
|
146
|
+
</Tooltip>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- **One tooltip on the whole group**, listing every name in the record's
|
|
150
|
+
order ("Home, Finance"), not one per icon: one tab stop per cell, and
|
|
151
|
+
the names read as one value.
|
|
152
|
+
- **The trigger is the `role="img"` span**, named by that same list. The
|
|
153
|
+
icons inside are presentational (children of an `img`), so they need no
|
|
154
|
+
`aria-hidden` of their own. Read-only: never a button.
|
|
155
|
+
- **Each icon tinted with its value's colour slot**
|
|
156
|
+
([`design-language.md`](design-language.md) "Categorical colour"), the
|
|
157
|
+
icon's shape telling values apart in greyscale.
|
|
158
|
+
- **A value still loading shows "…"**, in the cell and in the names,
|
|
159
|
+
never a bare id.
|
|
160
|
+
- **Where there's room** (the record's view), each icon sits beside its
|
|
161
|
+
name instead (pattern 2 per value, in a `flex flex-wrap gap-x-3 gap-y-1`
|
|
162
|
+
list), with no tooltip.
|
|
163
|
+
|
|
164
|
+
**Testing note.** With several tooltips on a page, one that's closing
|
|
165
|
+
stays in the DOM for a moment beside the one opening. A spec that reads
|
|
166
|
+
the open tooltip must select `[data-slot=tooltip-content][data-open]`;
|
|
167
|
+
the bare `[data-slot=tooltip-content]` can match the closing one, and
|
|
168
|
+
reads the wrong text:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
await page.getByRole('img', { name: 'Home, Finance', exact: true }).focus()
|
|
172
|
+
await expect(page.locator('[data-slot=tooltip-content][data-open]')).toHaveText('Home, Finance')
|
|
173
|
+
```
|
|
112
174
|
|
|
113
175
|
## 5. Enum → tone-mapped badge
|
|
114
176
|
|
|
@@ -116,6 +178,11 @@ A many-valued enum that collapses to good / neutral / bad. **The grouping
|
|
|
116
178
|
is the decision, not the style** — see
|
|
117
179
|
[`design-language.md`](design-language.md#mapping-an-enum-to-tones).
|
|
118
180
|
|
|
181
|
+
An **ordered** status (a lifecycle every record moves through toward
|
|
182
|
+
*done*) reads better as a stage circle beside the word (pattern 19);
|
|
183
|
+
the tone badge stays right for a status with no order, or one whose end
|
|
184
|
+
isn't *done*.
|
|
185
|
+
|
|
119
186
|
Keep a separate short-label map when the API strings are full sentences
|
|
120
187
|
(`"Yes – Full Crossplay"` → `Full`): read once in a dropdown they're right,
|
|
121
188
|
read down 25 rows under a column header they're noise.
|
|
@@ -232,6 +299,40 @@ A one-to-many value (`★ 4.7 (3)` opening a per-person breakdown).
|
|
|
232
299
|
can't be sorted server-side without a backend change
|
|
233
300
|
(`enableSorting: false`).
|
|
234
301
|
|
|
302
|
+
### A second example: what a row depends on
|
|
303
|
+
|
|
304
|
+
The records a row waits on (parts it needs, a date that has to pass,
|
|
305
|
+
other records that have to finish), summarised in the cell, listed in
|
|
306
|
+
the popover. The package's Storybook renders it
|
|
307
|
+
(`patterns/CellPatterns`, "Dependency list").
|
|
308
|
+
|
|
309
|
+
- **The cell: a computed summary.** "Ready" when nothing holds the row,
|
|
310
|
+
else the count of what does ("1 hold", "2 holds"). Compute it where the
|
|
311
|
+
data is: the backend, when the column sorts or filters on it.
|
|
312
|
+
- **The popover: every related record, grouped by kind**, each kind
|
|
313
|
+
under a small heading (`type-caption text-muted-foreground`) in its own
|
|
314
|
+
labelled group, in a fixed order. Each record keeps its kind's glyph
|
|
315
|
+
(`aria-hidden`, beside the name), so the kinds stay apart in
|
|
316
|
+
greyscale.
|
|
317
|
+
- **Held reads dark, with its reason; clear reads muted.** A held record
|
|
318
|
+
is `text-foreground` with why after its name ("Bulb: unavailable",
|
|
319
|
+
"Delivery day: hasn't happened", or the blocking record's status);
|
|
320
|
+
a clear one is `text-muted-foreground` with a visually hidden ": clear",
|
|
321
|
+
so a screen reader hears the state the colour shows. No tone: a hold
|
|
322
|
+
is the normal state of waiting, not an error, and the count already
|
|
323
|
+
says how many.
|
|
324
|
+
- **Name the popup**: `<PopoverContent aria-label={`What ${name} depends on`}>`.
|
|
325
|
+
It's a `dialog`, and axe fails one with no name.
|
|
326
|
+
- **A row that links nothing has nothing to open**: render the summary as
|
|
327
|
+
plain text (`<span>Ready</span>`), not a button. A button that opens an
|
|
328
|
+
empty popover is a dead end.
|
|
329
|
+
- **The trigger reads as the value**, with a dotted underline
|
|
330
|
+
(`underline decoration-dotted underline-offset-4`) and a focus ring,
|
|
331
|
+
so it looks openable without becoming a column of buttons.
|
|
332
|
+
- The trigger's name starts with the summary it shows
|
|
333
|
+
(`aria-label={`${summary}: what ${name} depends on`}`), so it's still
|
|
334
|
+
found by its visible text, and says which row.
|
|
335
|
+
|
|
235
336
|
## 11. Number or currency — *Unproven*
|
|
236
337
|
|
|
237
338
|
A count, a quantity, a price.
|
|
@@ -358,6 +459,91 @@ now", "happened"). Widget's In Stock column is the reference
|
|
|
358
459
|
- **Cost:** one click changes data, with no confirmation. Only for values
|
|
359
460
|
that are cheap to flip back; never for anything destructive.
|
|
360
461
|
|
|
462
|
+
### A variant: a pressed icon inside another value's cell
|
|
463
|
+
|
|
464
|
+
A yes/no that belongs to another value rather than having a column of
|
|
465
|
+
its own: a "focus" flag that only exists while the status is "doing", a
|
|
466
|
+
"pinned" mark on an open item. It's a small icon button inside the owning
|
|
467
|
+
value's cell, beside its word. The package's Storybook renders it
|
|
468
|
+
(`patterns/CellPatterns`, "Pressed icon in a cell").
|
|
469
|
+
|
|
470
|
+
**Pick it over the Switch** when the yes/no only means something while
|
|
471
|
+
another value allows it, or when a Switch and its words would crowd that
|
|
472
|
+
value's cell. A yes/no that stands on its own keeps its own column and
|
|
473
|
+
the Switch.
|
|
474
|
+
|
|
475
|
+
```tsx
|
|
476
|
+
// Off: faint. On: the destructive text tone, filled. The primitive's
|
|
477
|
+
// pressed background is dropped: the icon carries the value.
|
|
478
|
+
const FLAG_TOGGLE_CLASS =
|
|
479
|
+
'text-muted-foreground/80 hover:text-muted-foreground aria-pressed:bg-transparent ' +
|
|
480
|
+
'aria-pressed:text-destructive-text'
|
|
481
|
+
|
|
482
|
+
// <entity>-focus-toggle.tsx, rendered inside the status cell
|
|
483
|
+
export function FocusToggle({ item }: { item: Item }) {
|
|
484
|
+
const save = useSaveItemField(item.id) // useRecordUpdate, like Widget's useSaveWidgetField
|
|
485
|
+
return (
|
|
486
|
+
<>
|
|
487
|
+
<Toggle size="icon-xs" pressed={item.focus} aria-label={`Focus: ${item.name}`}
|
|
488
|
+
className={FLAG_TOGGLE_CLASS} onPressedChange={(focus) => save.mutate({ focus })}>
|
|
489
|
+
<FlagIcon className="group-aria-pressed/toggle:fill-current" />
|
|
490
|
+
</Toggle>
|
|
491
|
+
<span className="inline-flex size-3.5 shrink-0">
|
|
492
|
+
{save.isPending && <Spinner aria-label="Saving" className="size-3.5 text-muted-foreground" />}
|
|
493
|
+
</span>
|
|
494
|
+
</>
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The status cell: the word stays, the toggle only while the status allows it.
|
|
499
|
+
<span className="inline-flex items-center gap-1.5">
|
|
500
|
+
<StageCircle {...STATUS_STAGE[item.status]} />
|
|
501
|
+
{STATUS_LABEL[item.status]}
|
|
502
|
+
{item.status === 'doing' && <FocusToggle item={item} />}
|
|
503
|
+
</span>
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
- **`Toggle` from the package** (`/ui/toggle`, shadcn's, on Base UI), at
|
|
507
|
+
`size="icon-xs"`: a 24px target, the smallest WCAG 2.2 allows, which
|
|
508
|
+
keeps the row's height. It's `aria-pressed` by itself.
|
|
509
|
+
- **Named for the row, not the state**: `Focus: <record name>`. The
|
|
510
|
+
pressed state is announced on its own; a name that changes with it
|
|
511
|
+
("Unfocus") reads twice.
|
|
512
|
+
- **The icon carries the value.** Filled when on
|
|
513
|
+
(`group-aria-pressed/toggle:fill-current`), outline when off, so it
|
|
514
|
+
reads in greyscale. Pick a glyph with an area to fill (`FlagIcon`,
|
|
515
|
+
`StarIcon`, `PinIcon`), not a line.
|
|
516
|
+
- **Faint, but never below 3:1.** Off is `text-muted-foreground/80`, the
|
|
517
|
+
faintest step that still clears WCAG's 3:1 non-text minimum in light
|
|
518
|
+
mode (3.2:1; dark is 5.2:1). `/70` measures 2.7:1, and a control a
|
|
519
|
+
reader can't see fails them. Hover lifts it to full
|
|
520
|
+
`muted-foreground`. axe doesn't measure icons, so the package's
|
|
521
|
+
Storybook glyph check does.
|
|
522
|
+
- **Tone: `destructive-text`, read as "flagged", not "error".** A flag
|
|
523
|
+
claims attention ahead of the rest of the row, and the destructive
|
|
524
|
+
tone is the one with that pull. The other tones are taken where it
|
|
525
|
+
sits: `info` is the in-progress status it belongs to (an info flag
|
|
526
|
+
beside an info stage disappears), `success` reads as done, and
|
|
527
|
+
`warning` says something may be wrong. It stays honest because it's
|
|
528
|
+
rare (a flag nearly every row carries is the "about a third" limit
|
|
529
|
+
broken) and because no word beside it says error. If a flag is common,
|
|
530
|
+
or does mean "something's wrong", it's an enum with a verdict
|
|
531
|
+
(pattern 5) instead.
|
|
532
|
+
- **It saves on its own, through `useRecordUpdate`**, like the Switch: the
|
|
533
|
+
new value shows at once, and a refusal (a cap the server enforces)
|
|
534
|
+
puts it back with the toast giving the server's reason. The hook does
|
|
535
|
+
both; add nothing.
|
|
536
|
+
- **The "Saving" slot is always there**, after the toggle, so nothing
|
|
537
|
+
moves while it saves, and nothing is disabled.
|
|
538
|
+
- **Only rendered while the owning value allows it.** When the status
|
|
539
|
+
moves on, the toggle goes; what the server does with the flag then (it
|
|
540
|
+
clears it, or keeps it for next time) is the plan's rule, and shows
|
|
541
|
+
through the record it returns.
|
|
542
|
+
- **On the record's view** it's the same component beside the owning
|
|
543
|
+
value in the header, as a quick action (pattern 17): the plan names it
|
|
544
|
+
under `Quick actions`. There its name is the field's label alone
|
|
545
|
+
(`Focus`), like the view's other controls.
|
|
546
|
+
|
|
361
547
|
## 16. Title linking to the record's view
|
|
362
548
|
|
|
363
549
|
The column that names a record (its name or title), when the entity has a
|
|
@@ -415,6 +601,7 @@ type or pick from a list (text, a status) edits in place instead
|
|
|
415
601
|
| The plan's field | The control | Accessible name |
|
|
416
602
|
|---|---|---|
|
|
417
603
|
| `yes/no`, in the header or a field row | A `Switch` (`size="sm"`) with the value in words beside it, `aria-hidden` (pattern 15) | The field's label: `In stock` |
|
|
604
|
+
| A `yes/no` that belongs to another value (pattern 15's variant), beside that value in the header | The same pressed icon `Toggle` as in the table's cell | The field's label: `Focus` |
|
|
418
605
|
| A sub-record list's `yes/no` item field | Each item a `Checkbox` inside a `<label>` with the item's text, so the text ticks it too; the list in one `role="group"` named for the list, described by the done-count | `Done: <item text>` (a visually hidden `Done: ` inside the label) |
|
|
419
606
|
|
|
420
607
|
```tsx
|
|
@@ -549,7 +736,12 @@ const price = editInPlace({
|
|
|
549
736
|
- **Long text written as Markdown edits as rich text** (`RichTextEditor`):
|
|
550
737
|
formatted where it's read, saved as Markdown with every block the person
|
|
551
738
|
didn't touch kept exactly as written. On the form too, so it's one
|
|
552
|
-
control.
|
|
739
|
+
control. Give it a `placeholder` and the schema's limit as `maxLength`
|
|
740
|
+
(a count shows from 80% of it, in the destructive text tone once over).
|
|
741
|
+
Its keys: Ctrl/Cmd+K adds or edits a link (web and email addresses
|
|
742
|
+
only), Ctrl/Cmd+Shift+Enter ticks the task item the caret is in, and
|
|
743
|
+
Markdown pasted as plain text arrives formatted. Ctrl/Cmd+Enter stays
|
|
744
|
+
the save.
|
|
553
745
|
- **The save is the single-field save** (`useRecordUpdate` with
|
|
554
746
|
`optimistic: false, toastOnError: false`, the entity's
|
|
555
747
|
`useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
|
|
@@ -558,3 +750,71 @@ const price = editInPlace({
|
|
|
558
750
|
- **Cost:** a value that looks like text but edits on click can surprise.
|
|
559
751
|
Keep it to the fields the plan names, and leave the form for everything
|
|
560
752
|
else.
|
|
753
|
+
|
|
754
|
+
## 19. Stage circle for an ordered status
|
|
755
|
+
|
|
756
|
+
A status that's an ordered lifecycle (each record moves through the same
|
|
757
|
+
stages, in the same order, toward one that means *done*), with one or
|
|
758
|
+
more exit states off the side. A small circle beside the word fills as
|
|
759
|
+
the record moves on. The package ships it: `StageCircle`, rendered with
|
|
760
|
+
every stage and tone in its Storybook (`app/StageCircle`).
|
|
761
|
+
|
|
762
|
+
| Stage (`stage`) | Drawn as | Say it for |
|
|
763
|
+
|---|---|---|
|
|
764
|
+
| `0` | a dashed outline | the first stage: not started |
|
|
765
|
+
| `1`, `2`, `3` | the outline with a quarter, a half, three quarters filled | a stage under way, in order |
|
|
766
|
+
| `'complete'` | a solid circle with a tick | the final stage: done |
|
|
767
|
+
| `'exit'` | the outline, struck through | off the lifecycle: dropped, cancelled |
|
|
768
|
+
|
|
769
|
+
```tsx
|
|
770
|
+
import { StageCircle, type StageCircleStage, type StageCircleTone } from '@tristan2828/ui-foundation'
|
|
771
|
+
|
|
772
|
+
// <entity>-format.ts: typed by the enum, so a new value can't go unmapped.
|
|
773
|
+
const STATUS_STAGE: Record<ItemStatus, { stage: StageCircleStage; tone?: StageCircleTone }> = {
|
|
774
|
+
idea: { stage: 0 },
|
|
775
|
+
considering: { stage: 1 },
|
|
776
|
+
doing: { stage: 2, tone: 'info' },
|
|
777
|
+
done: { stage: 'complete', tone: 'success' },
|
|
778
|
+
dropped: { stage: 'exit' },
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
cell: ({ getValue }) => {
|
|
782
|
+
const status = getValue() as ItemStatus
|
|
783
|
+
return (
|
|
784
|
+
<span className="inline-flex items-center gap-1.5">
|
|
785
|
+
<StageCircle {...STATUS_STAGE[status]} />
|
|
786
|
+
{STATUS_LABEL[status]}
|
|
787
|
+
</span>
|
|
788
|
+
)
|
|
789
|
+
}
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
- **The word stays**, in plain `text-foreground`. The circle is
|
|
793
|
+
`aria-hidden`; the word is what's read, and what a reader who doesn't
|
|
794
|
+
know the shapes yet goes by.
|
|
795
|
+
- **The shape carries the stage, in greyscale.** Dashed, filling, solid,
|
|
796
|
+
struck through: the stage reads from the shape alone, and the tone only
|
|
797
|
+
reinforces it.
|
|
798
|
+
- **Tone it like pattern 5:** the stage in progress is `info` ("notice
|
|
799
|
+
this"), complete is `success`, and the rest stay `muted` (the default).
|
|
800
|
+
The tones are the `*-text` shades, so the circle clears 3:1 on the page
|
|
801
|
+
in both themes, measured by the package's glyph check. An exit is
|
|
802
|
+
muted: dropped is an end, not a failure. Give it `destructive` only if
|
|
803
|
+
the exit really is bad news (failed, rejected).
|
|
804
|
+
- **Map the stages in order**, the first `0`, the final `'complete'`, the
|
|
805
|
+
ones between `1`–`3` in order. Two middle stages fill a quarter and a
|
|
806
|
+
half; three, up to three quarters. More than three stages between
|
|
807
|
+
first and done is more than a fill can tell apart: use the tone badge.
|
|
808
|
+
- **Stage circle or tone badge (pattern 5)?** The circle is for an
|
|
809
|
+
*ordered* status: every record goes the same way, and "how far along
|
|
810
|
+
toward done" is the question. A status with no order (`active` /
|
|
811
|
+
`paused` / `blocked`), or one whose end isn't *done*, keeps pattern 5.
|
|
812
|
+
Widget's Status is the second kind: `draft` → `active` → `archived` is
|
|
813
|
+
an order in time, but `archived` is retirement, not completion, and its
|
|
814
|
+
good state is the middle one. A tick on `archived` would say "done"
|
|
815
|
+
where nothing was finished, so it stays a tone badge.
|
|
816
|
+
- **On the record's view** it's the same markup in the header, beside the
|
|
817
|
+
title; editing the status in place (pattern 18) wraps it in
|
|
818
|
+
`<EditableValue>` like a badge.
|
|
819
|
+
- **Cost:** a reader learns the shapes once. Until then the word does the
|
|
820
|
+
work, which is why it stays.
|
|
@@ -82,6 +82,24 @@ Two rules that keep this honest:
|
|
|
82
82
|
greyscale screenshot and a printed page all lose it. The label text
|
|
83
83
|
usually carries the meaning already; where a cell is a glyph rather than
|
|
84
84
|
a word, vary the *shape* (filled / half / slashed) too, not just the hue.
|
|
85
|
+
The package's `StageCircle` does this for an ordered status: dashed,
|
|
86
|
+
filling, solid with a tick, struck through
|
|
87
|
+
([`cell-patterns.md`](cell-patterns.md) pattern 19).
|
|
88
|
+
|
|
89
|
+
## Glyphs and icons: 3:1
|
|
90
|
+
|
|
91
|
+
An icon that carries meaning on its own (a stage circle, a category's
|
|
92
|
+
icon, a pressed flag) must clear WCAG's **3:1 non-text minimum** against
|
|
93
|
+
the background behind it, in both themes. axe measures text only, so the
|
|
94
|
+
package's Storybook has a glyph check of its own: every `<svg>` in its
|
|
95
|
+
design-language stories, its ink (with any opacity modifier painted over
|
|
96
|
+
its background) against what's behind it. Every `*-text` tone, every
|
|
97
|
+
category slot and `muted-foreground` clear it as they are. An opacity
|
|
98
|
+
modifier is where it goes wrong: `text-muted-foreground/80` is the
|
|
99
|
+
faintest step that still clears it in light mode (3.2:1), and `/70`
|
|
100
|
+
doesn't (2.7:1). A deliberately faint icon that carries nothing on its
|
|
101
|
+
own (an "empty" star beside a rating's filled ones, pattern 9) may go
|
|
102
|
+
lower; say so in a comment.
|
|
85
103
|
|
|
86
104
|
## Categorical colour
|
|
87
105
|
|
|
@@ -119,10 +137,11 @@ Four things about them:
|
|
|
119
137
|
`text-foreground`, so readability never depends on the hue. A slot used
|
|
120
138
|
as a background needs its own contrast check, which nothing here does
|
|
121
139
|
for you.
|
|
122
|
-
- **
|
|
123
|
-
non-text minimum in both themes (
|
|
124
|
-
|
|
125
|
-
|
|
140
|
+
- **The glyph check re-measures them.** All eight clear the 3:1
|
|
141
|
+
non-text minimum in both themes (recorded in `theme.css`). axe checks
|
|
142
|
+
text contrast, not icon contrast, so the package's Storybook glyph check
|
|
143
|
+
draws an icon in each slot (`patterns/CellPatterns`, "Icons with one
|
|
144
|
+
tooltip") and fails a change that takes one below 3:1.
|
|
126
145
|
|
|
127
146
|
If you find yourself wanting a ninth, that is usually a sign the column
|
|
128
147
|
should be showing a shape or a label rather than more colours.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { RichTextEditorProps } from './rich-text-editor.js';
|
|
2
|
-
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
2
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly, autoFocus, placeholder, maxLength, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }: RichTextEditorProps): import("react").JSX.Element;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// The rich-text editor behind RichTextEditor, loaded only when one opens
|
|
3
3
|
// (rich-text-editor.tsx imports this lazily): Milkdown, a ProseMirror
|
|
4
4
|
// editor whose Markdown goes in and out through remark, the same parser
|
|
@@ -15,15 +15,28 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
15
15
|
// - Headings render from <h3>, like <Markdown>: the page owns <h1> and
|
|
16
16
|
// <h2>. A task-list item renders a real checkbox that toggles it.
|
|
17
17
|
// - Edited blocks are written in the document's own bullet and rule marks.
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// - Markdown pasted as plain text arrives formatted (still no HTML), and a
|
|
19
|
+
// copy out of the editor is Markdown.
|
|
20
|
+
// - Ctrl/Cmd+K adds or edits a link (a box at the caret: web and email
|
|
21
|
+
// addresses only); Ctrl/Cmd+Shift+Enter ticks the task item the caret is
|
|
22
|
+
// in. Ctrl/Cmd+Enter stays the field's save.
|
|
23
|
+
// - A placeholder in an empty document, and a count of the Markdown's
|
|
24
|
+
// length as it nears `maxLength`.
|
|
25
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
26
|
+
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, parserCtx, remarkStringifyOptionsCtx, rootCtx, schemaCtx, serializerCtx, } from '@milkdown/kit/core';
|
|
20
27
|
import { history } from '@milkdown/kit/plugin/history';
|
|
21
28
|
import { commands, inputRules, keymap, markInputRules, plugins, remarkPreserveEmptyLinePlugin, schema, } from '@milkdown/kit/preset/commonmark';
|
|
22
29
|
import { gfm } from '@milkdown/kit/preset/gfm';
|
|
23
30
|
import { Fragment, Slice } from '@milkdown/kit/prose/model';
|
|
24
31
|
import { Plugin } from '@milkdown/kit/prose/state';
|
|
32
|
+
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
|
|
25
33
|
import { $prose, getMarkdown } from '@milkdown/kit/utils';
|
|
26
34
|
import { cn } from 'cn';
|
|
35
|
+
import { Button } from '../ui/button.js';
|
|
36
|
+
import { Field, FieldError, FieldLabel } from '../ui/field.js';
|
|
37
|
+
import { Input } from '../ui/input.js';
|
|
38
|
+
import { Popover, PopoverContent } from '../ui/popover.js';
|
|
39
|
+
import { linkHref } from '../../lib/link-href.js';
|
|
27
40
|
import { MarkdownMergeError, markdownStyle, mergeMarkdown } from '../../lib/markdown-merge.js';
|
|
28
41
|
// The commonmark preset, minus the plugin that writes `<br />`. A $remark
|
|
29
42
|
// plugin is a pair (its options and the plugin), spread into `plugins`.
|
|
@@ -102,14 +115,84 @@ function taskItemView(node, view, getPos) {
|
|
|
102
115
|
ignoreMutation: (mutation) => mutation.target === box,
|
|
103
116
|
};
|
|
104
117
|
}
|
|
105
|
-
|
|
118
|
+
// The task-list item the caret is in, ticked or unticked. False when the
|
|
119
|
+
// caret isn't in one.
|
|
120
|
+
function toggleTaskItem(view) {
|
|
121
|
+
const { $from } = view.state.selection;
|
|
122
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
123
|
+
const node = $from.node(depth);
|
|
124
|
+
if (node.type.name === 'list_item' && node.attrs.checked != null) {
|
|
125
|
+
view.dispatch(view.state.tr.setNodeMarkup($from.before(depth), undefined, { ...node.attrs, checked: !node.attrs.checked }));
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
function linkTarget(state) {
|
|
132
|
+
const link = state.schema.marks.link;
|
|
133
|
+
const { from, to, empty, $from } = state.selection;
|
|
134
|
+
if (!empty) {
|
|
135
|
+
let href = '';
|
|
136
|
+
state.doc.nodesBetween(from, to, (node) => {
|
|
137
|
+
const mark = link.isInSet(node.marks);
|
|
138
|
+
if (mark && !href)
|
|
139
|
+
href = String(mark.attrs.href);
|
|
140
|
+
});
|
|
141
|
+
return { from, to, href };
|
|
142
|
+
}
|
|
143
|
+
const mark = link.isInSet($from.marks());
|
|
144
|
+
if (!mark)
|
|
145
|
+
return { from, to, href: '' };
|
|
146
|
+
const start = $from.start();
|
|
147
|
+
let target = { from, to, href: String(mark.attrs.href) };
|
|
148
|
+
let runFrom = -1;
|
|
149
|
+
$from.parent.forEach((child, offset) => {
|
|
150
|
+
const childFrom = start + offset;
|
|
151
|
+
const childTo = childFrom + child.nodeSize;
|
|
152
|
+
if (!mark.isInSet(child.marks)) {
|
|
153
|
+
runFrom = -1;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (runFrom < 0)
|
|
157
|
+
runFrom = childFrom;
|
|
158
|
+
if (runFrom <= from && from <= childTo)
|
|
159
|
+
target = { from: runFrom, to: childTo, href: target.href };
|
|
160
|
+
});
|
|
161
|
+
return target;
|
|
162
|
+
}
|
|
163
|
+
// Sets (or, with `href` empty, removes) the link on the target. With
|
|
164
|
+
// nothing selected and no link there, the address goes in as the link's
|
|
165
|
+
// own text.
|
|
166
|
+
function applyLink(view, target, href) {
|
|
167
|
+
const link = view.state.schema.marks.link;
|
|
168
|
+
const tr = view.state.tr;
|
|
169
|
+
if (target.from === target.to) {
|
|
170
|
+
if (!href)
|
|
171
|
+
return;
|
|
172
|
+
tr.insertText(href, target.from).addMark(target.from, target.from + href.length, link.create({ href }));
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
tr.removeMark(target.from, target.to, link);
|
|
176
|
+
if (href)
|
|
177
|
+
tr.addMark(target.from, target.to, link.create({ href }));
|
|
178
|
+
}
|
|
179
|
+
view.dispatch(tr.scrollIntoView());
|
|
180
|
+
}
|
|
181
|
+
export default function MilkdownEditor({ id, defaultValue, onChange, onProblem, readOnly = false, autoFocus = false, placeholder, maxLength, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, 'aria-describedby': ariaDescribedBy, 'aria-invalid': ariaInvalid, }) {
|
|
106
182
|
const rootRef = useRef(null);
|
|
107
183
|
const editorRef = useRef(null);
|
|
184
|
+
const [linkBox, setLinkBox] = useState(null);
|
|
185
|
+
const [length, setLength] = useState(defaultValue.length);
|
|
108
186
|
// The latest props, for the editor's callbacks, which are set up once.
|
|
109
|
-
const latest = useRef({ onChange, onProblem, readOnly });
|
|
187
|
+
const latest = useRef({ onChange, onProblem, readOnly, placeholder });
|
|
110
188
|
useEffect(() => {
|
|
111
|
-
latest.current = { onChange, onProblem, readOnly };
|
|
189
|
+
latest.current = { onChange, onProblem, readOnly, placeholder };
|
|
112
190
|
});
|
|
191
|
+
const ids = useId();
|
|
192
|
+
const counterId = `${ids}-count`;
|
|
193
|
+
// The count shows from 80% of the limit: below that it's noise.
|
|
194
|
+
const showCount = maxLength !== undefined && length >= maxLength * 0.8;
|
|
195
|
+
const describedBy = [ariaDescribedBy, showCount ? counterId : undefined].filter(Boolean).join(' ');
|
|
113
196
|
const attributes = {
|
|
114
197
|
'aria-multiline': 'true',
|
|
115
198
|
'aria-readonly': String(readOnly),
|
|
@@ -121,10 +204,12 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
121
204
|
attributes['aria-label'] = ariaLabel;
|
|
122
205
|
if (ariaLabelledBy)
|
|
123
206
|
attributes['aria-labelledby'] = ariaLabelledBy;
|
|
124
|
-
if (
|
|
125
|
-
attributes['aria-describedby'] =
|
|
207
|
+
if (describedBy)
|
|
208
|
+
attributes['aria-describedby'] = describedBy;
|
|
126
209
|
if (ariaInvalid)
|
|
127
210
|
attributes['aria-invalid'] = 'true';
|
|
211
|
+
if (placeholder)
|
|
212
|
+
attributes['aria-placeholder'] = placeholder;
|
|
128
213
|
const attributesKey = JSON.stringify(attributes);
|
|
129
214
|
// The editor is made once, from the Markdown it opened with.
|
|
130
215
|
useEffect(() => {
|
|
@@ -143,6 +228,7 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
143
228
|
const merged = mergeMarkdown(original, baseline, current);
|
|
144
229
|
latest.current.onProblem?.(null);
|
|
145
230
|
latest.current.onChange(merged);
|
|
231
|
+
setLength(merged.length);
|
|
146
232
|
}
|
|
147
233
|
catch (error) {
|
|
148
234
|
if (!(error instanceof MarkdownMergeError))
|
|
@@ -161,6 +247,80 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
161
247
|
transformPasted: (slice) => new Slice(htmlToText(slice.content), slice.openStart, slice.openEnd),
|
|
162
248
|
},
|
|
163
249
|
}));
|
|
250
|
+
// Opens the link box at the start of what the link applies to.
|
|
251
|
+
const openLink = (view) => {
|
|
252
|
+
if (!view.editable)
|
|
253
|
+
return;
|
|
254
|
+
const target = linkTarget(view.state);
|
|
255
|
+
const point = view.coordsAtPos(target.from);
|
|
256
|
+
setLinkBox({
|
|
257
|
+
...target,
|
|
258
|
+
anchor: {
|
|
259
|
+
getBoundingClientRect: () => new DOMRect(point.left, point.top, 0, point.bottom - point.top),
|
|
260
|
+
contextElement: view.dom,
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
// Paste, copy, the editor's own keys and the placeholder.
|
|
265
|
+
const input = $prose((ctx) => new Plugin({
|
|
266
|
+
props: {
|
|
267
|
+
// Plain text is read as Markdown, so `## Notes` or `**bold**`
|
|
268
|
+
// copied from another app arrives formatted; raw HTML in it
|
|
269
|
+
// still arrives as text. A paste with HTML of its own (from a
|
|
270
|
+
// web page) is ProseMirror's, through transformPasted above.
|
|
271
|
+
handlePaste: (view, event) => {
|
|
272
|
+
const data = event.clipboardData;
|
|
273
|
+
if (!data || !view.editable || data.getData('text/html'))
|
|
274
|
+
return false;
|
|
275
|
+
const text = data.getData('text/plain');
|
|
276
|
+
if (!text || view.state.selection.$from.parent.type.spec.code)
|
|
277
|
+
return false;
|
|
278
|
+
const parsed = ctx.get(parserCtx)(text);
|
|
279
|
+
if (!parsed || typeof parsed === 'string')
|
|
280
|
+
return false;
|
|
281
|
+
// Text with no Markdown in it (one plain paragraph) goes in
|
|
282
|
+
// exactly as typed, spaces at its ends kept.
|
|
283
|
+
const only = parsed.childCount === 1 ? parsed.firstChild : null;
|
|
284
|
+
if (only?.type.name === 'paragraph' && only.childCount === 1 && only.textContent === text.trim()) {
|
|
285
|
+
view.dispatch(view.state.tr.insertText(text).scrollIntoView());
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
view.dispatch(view.state.tr.replaceSelection(Slice.maxOpen(htmlToText(parsed.content))).scrollIntoView());
|
|
289
|
+
return true;
|
|
290
|
+
},
|
|
291
|
+
// A copy is Markdown, so it pastes into a plain text field
|
|
292
|
+
// (or another Markdown editor) as written.
|
|
293
|
+
clipboardTextSerializer: (slice) => {
|
|
294
|
+
const doc = slice.content.firstChild?.isBlock
|
|
295
|
+
? ctx.get(schemaCtx).topNodeType.createAndFill(undefined, slice.content)
|
|
296
|
+
: null;
|
|
297
|
+
return doc ? ctx.get(serializerCtx)(doc) : slice.content.textBetween(0, slice.content.size, '\n\n');
|
|
298
|
+
},
|
|
299
|
+
handleKeyDown: (view, event) => {
|
|
300
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
301
|
+
if (mod && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
|
|
302
|
+
event.preventDefault();
|
|
303
|
+
openLink(view);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
// Ctrl/Cmd+Shift+Enter in a task item ticks it, and goes no
|
|
307
|
+
// further. Anywhere else it reaches the field, which saves.
|
|
308
|
+
if (mod && event.shiftKey && event.key === 'Enter' && view.editable && toggleTaskItem(view)) {
|
|
309
|
+
event.preventDefault();
|
|
310
|
+
event.stopPropagation();
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
},
|
|
315
|
+
decorations: (state) => {
|
|
316
|
+
const text = latest.current.placeholder;
|
|
317
|
+
const only = state.doc.childCount === 1 ? state.doc.firstChild : null;
|
|
318
|
+
if (!text || !only || only.type.name !== 'paragraph' || only.content.size > 0)
|
|
319
|
+
return null;
|
|
320
|
+
return DecorationSet.create(state.doc, [Decoration.node(0, only.nodeSize, { 'data-placeholder': text })]);
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
}));
|
|
164
324
|
const make = Editor.make()
|
|
165
325
|
.config((ctx) => {
|
|
166
326
|
ctx.set(rootCtx, root);
|
|
@@ -179,7 +339,8 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
179
339
|
.use(COMMONMARK)
|
|
180
340
|
.use(gfm)
|
|
181
341
|
.use(history)
|
|
182
|
-
.use(changes)
|
|
342
|
+
.use(changes)
|
|
343
|
+
.use(input);
|
|
183
344
|
void make.create().then((editor) => {
|
|
184
345
|
if (cancelled) {
|
|
185
346
|
void editor.destroy();
|
|
@@ -212,10 +373,52 @@ export default function MilkdownEditor({ id, defaultValue, onChange, onProblem,
|
|
|
212
373
|
view.setProps({ editable: () => !readOnly, attributes: JSON.parse(attributesKey) });
|
|
213
374
|
});
|
|
214
375
|
}, [readOnly, attributesKey]);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
376
|
+
const closeLink = () => {
|
|
377
|
+
setLinkBox(null);
|
|
378
|
+
editorRef.current?.action((ctx) => ctx.get(editorViewCtx).focus());
|
|
379
|
+
};
|
|
380
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { ref: rootRef, "data-slot": "rich-text-editor", className: cn('min-h-24 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-2 type-body text-foreground break-words transition-colors', 'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', 'has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:ring-destructive/20', 'has-[[aria-readonly=true]]:text-muted-foreground',
|
|
381
|
+
// The same type roles <Markdown> uses, so text reads the same
|
|
382
|
+
// being edited as being read.
|
|
383
|
+
'[&_.ProseMirror]:flex [&_.ProseMirror]:min-h-20 [&_.ProseMirror]:flex-col [&_.ProseMirror]:gap-3', '[&_h3]:type-section-title [&_h4]:type-section-title [&_h5]:type-label [&_h6]:type-label', '[&_ul]:flex [&_ul]:list-disc [&_ul]:flex-col [&_ul]:gap-1 [&_ul]:pl-6', '[&_ol]:flex [&_ol]:list-decimal [&_ol]:flex-col [&_ol]:gap-1 [&_ol]:pl-6', '[&_li[data-item-type=task]]:-ml-6 [&_li[data-item-type=task]]:flex [&_li[data-item-type=task]]:list-none [&_li[data-item-type=task]]:items-start [&_li[data-item-type=task]]:gap-2', '[&_li[data-item-type=task]>input]:mt-1 [&_li[data-item-type=task]>input]:size-4 [&_li[data-item-type=task]>input]:accent-primary', '[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4', '[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground', '[&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:font-mono', '[&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-muted [&_:not(pre)>code]:px-1 [&_:not(pre)>code]:font-mono', '[&_hr]:border-border', '[&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:type-label',
|
|
384
|
+
// Raw HTML from the text: shown as its characters, never as markup.
|
|
385
|
+
'[&_span[data-type=html]]:rounded [&_span[data-type=html]]:bg-muted [&_span[data-type=html]]:px-1 [&_span[data-type=html]]:font-mono [&_span[data-type=html]]:text-foreground',
|
|
386
|
+
// The placeholder, in an empty document only (a decoration).
|
|
387
|
+
'[&_p[data-placeholder]]:before:pointer-events-none [&_p[data-placeholder]]:before:float-left [&_p[data-placeholder]]:before:h-0 [&_p[data-placeholder]]:before:text-muted-foreground [&_p[data-placeholder]]:before:content-[attr(data-placeholder)]', className) }), showCount && (_jsx("p", { id: counterId, "data-slot": "rich-text-editor-count", className: cn('text-right type-caption tabular-nums', length > maxLength ? 'text-destructive-text' : 'text-muted-foreground'), children: length > maxLength
|
|
388
|
+
? `${(length - maxLength).toLocaleString()} characters over the limit of ${maxLength.toLocaleString()}`
|
|
389
|
+
: `${length.toLocaleString()} of ${maxLength.toLocaleString()} characters` })), _jsx(Popover, { open: linkBox !== null, onOpenChange: (open) => !open && closeLink(), children: linkBox && (_jsx(LinkForm, { box: linkBox, onApply: (href) => {
|
|
390
|
+
const editor = editorRef.current;
|
|
391
|
+
editor?.action((ctx) => applyLink(ctx.get(editorViewCtx), linkBox, href));
|
|
392
|
+
closeLink();
|
|
393
|
+
} })) })] }));
|
|
394
|
+
}
|
|
395
|
+
// The link box: the address, Apply, and Remove link for an existing one.
|
|
396
|
+
// Enter (Ctrl/Cmd+Enter too) applies the link and stays its own: editing
|
|
397
|
+
// in place listens for Ctrl/Cmd+Enter through the portal, and would save
|
|
398
|
+
// the field without it. Esc is the popover's: it closes the box only.
|
|
399
|
+
function LinkForm({ box, onApply }) {
|
|
400
|
+
const [typed, setTyped] = useState(box.href);
|
|
401
|
+
const [error, setError] = useState(null);
|
|
402
|
+
const inputRef = useRef(null);
|
|
403
|
+
const inputId = useId();
|
|
404
|
+
const errorId = `${inputId}-error`;
|
|
405
|
+
const apply = () => {
|
|
406
|
+
const href = linkHref(typed);
|
|
407
|
+
if (href === null) {
|
|
408
|
+
setError('Enter a web or email address, like https://example.com.');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
onApply(href);
|
|
412
|
+
};
|
|
413
|
+
const onKeyDown = (event) => {
|
|
414
|
+
if (event.key === 'Enter' && event.target === inputRef.current) {
|
|
415
|
+
event.preventDefault();
|
|
416
|
+
event.stopPropagation();
|
|
417
|
+
apply();
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
return (_jsxs(PopoverContent, { anchor: box.anchor, align: "start", "aria-label": box.href ? 'Edit link' : 'Add link', initialFocus: inputRef, finalFocus: false, className: "w-80", onKeyDown: onKeyDown, children: [_jsxs(Field, { "data-invalid": error ? true : undefined, children: [_jsx(FieldLabel, { htmlFor: inputId, children: "Link address" }), _jsx(Input, { ref: inputRef, id: inputId, value: typed, placeholder: "https://", autoComplete: "off", "aria-invalid": error ? true : undefined, "aria-describedby": error ? errorId : undefined, onChange: (event) => {
|
|
421
|
+
setTyped(event.target.value);
|
|
422
|
+
setError(null);
|
|
423
|
+
} }), error && _jsx(FieldError, { id: errorId, children: error })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { size: "sm", onClick: apply, children: "Apply" }), box.href && (_jsx(Button, { size: "sm", variant: "outline", onClick: () => onApply(''), children: "Remove link" }))] })] }));
|
|
221
424
|
}
|
|
@@ -10,6 +10,14 @@ export type RichTextEditorProps = {
|
|
|
10
10
|
/** Shown but not editable (while a save is in flight). */
|
|
11
11
|
readOnly?: boolean;
|
|
12
12
|
autoFocus?: boolean;
|
|
13
|
+
/** Shown while the document is empty (and given to screen readers as `aria-placeholder`). */
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The field's limit on the saved Markdown's length. From 80% of it, a
|
|
17
|
+
* count shows under the editor, in the destructive text tone once over.
|
|
18
|
+
* Typing isn't stopped: the form's schema still refuses a save over it.
|
|
19
|
+
*/
|
|
20
|
+
maxLength?: number;
|
|
13
21
|
className?: string;
|
|
14
22
|
/** The field's label. A rich-text area must have one: this, or `aria-labelledby`. */
|
|
15
23
|
'aria-label'?: string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the record is: `0` the first stage (a dashed outline), `1`–`3` a
|
|
3
|
+
* stage under way (a quarter, a half, three quarters filled), `'complete'`
|
|
4
|
+
* the final stage (solid, with a tick), `'exit'` off the lifecycle (struck
|
|
5
|
+
* through: dropped, cancelled).
|
|
6
|
+
*/
|
|
7
|
+
export type StageCircleStage = 0 | 1 | 2 | 3 | 'complete' | 'exit';
|
|
8
|
+
/**
|
|
9
|
+
* The tone, as text on the page (`*-text` tokens). `muted` is the default:
|
|
10
|
+
* colour only the stages that mean something (in progress `info`,
|
|
11
|
+
* complete `success`), like a tone-mapped badge (cell pattern 5).
|
|
12
|
+
*/
|
|
13
|
+
export type StageCircleTone = 'muted' | 'info' | 'success' | 'warning' | 'destructive';
|
|
14
|
+
export interface StageCircleProps {
|
|
15
|
+
stage: StageCircleStage;
|
|
16
|
+
tone?: StageCircleTone;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
/** The glyph only: put the status word beside it. Hidden from screen readers, which read the word. */
|
|
20
|
+
export declare function StageCircle({ stage, tone, className }: StageCircleProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A stage circle (cell pattern 19): where a record is in an ordered
|
|
3
|
+
// lifecycle, as a small circle that fills as it moves on. It sits beside
|
|
4
|
+
// the status word, which stays: the shape carries the stage in greyscale
|
|
5
|
+
// and the tone only reinforces it.
|
|
6
|
+
//
|
|
7
|
+
// Lucide has no part-filled circle, so it's drawn here, in currentColor
|
|
8
|
+
// (16×16 viewBox, the ring and fill in the tone, the tick cut out of the
|
|
9
|
+
// solid circle in the page's background).
|
|
10
|
+
import { cn } from 'cn';
|
|
11
|
+
const TONE_CLASS = {
|
|
12
|
+
muted: 'text-muted-foreground',
|
|
13
|
+
info: 'text-info-text',
|
|
14
|
+
success: 'text-success-text',
|
|
15
|
+
warning: 'text-warning-text',
|
|
16
|
+
destructive: 'text-destructive-text',
|
|
17
|
+
};
|
|
18
|
+
// The filled part of a stage under way: a pie from twelve o'clock,
|
|
19
|
+
// clockwise, inside the ring with a gap so it reads as filling, not as a
|
|
20
|
+
// thicker ring.
|
|
21
|
+
const PIE = {
|
|
22
|
+
1: 'M8 8V4.5A3.5 3.5 0 0 1 11.5 8Z',
|
|
23
|
+
2: 'M8 8V4.5A3.5 3.5 0 0 1 8 11.5Z',
|
|
24
|
+
3: 'M8 8V4.5A3.5 3.5 0 1 1 4.5 8Z',
|
|
25
|
+
};
|
|
26
|
+
const RING = { cx: 8, cy: 8, r: 6, strokeWidth: 1.5, className: 'fill-none stroke-current' };
|
|
27
|
+
/** The glyph only: put the status word beside it. Hidden from screen readers, which read the word. */
|
|
28
|
+
export function StageCircle({ stage, tone = 'muted', className }) {
|
|
29
|
+
return (_jsxs("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", "data-slot": "stage-circle", "data-stage": stage, className: cn('size-4 shrink-0', TONE_CLASS[tone], className), children: [stage === 0 && _jsx("circle", { ...RING, strokeDasharray: "2.4 2.3" }), (stage === 1 || stage === 2 || stage === 3) && (_jsxs(_Fragment, { children: [_jsx("circle", { ...RING }), _jsx("path", { d: PIE[stage], className: "fill-current" })] })), stage === 'complete' && (_jsxs(_Fragment, { children: [_jsx("circle", { cx: "8", cy: "8", r: "7", className: "fill-current" }), _jsx("path", { d: "M5 8.2 7.1 10.2 11 6", className: "fill-none stroke-background", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" })] })), stage === 'exit' && (_jsxs(_Fragment, { children: [_jsx("circle", { ...RING }), _jsx("path", { d: "M4 12 12 4", className: "fill-none stroke-current", strokeWidth: "1.5", strokeLinecap: "round" })] }))] }));
|
|
30
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useRender } from "@base-ui/react/use-render";
|
|
2
2
|
import { type VariantProps } from "class-variance-authority";
|
|
3
3
|
declare const badgeVariants: (props?: ({
|
|
4
|
-
variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "
|
|
4
|
+
variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "info" | "warning" | "outline-success" | "outline-warning" | "outline-info" | "outline-destructive" | "tinted-success" | "tinted-warning" | "tinted-info" | "tinted-destructive" | null | undefined;
|
|
5
5
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
6
6
|
declare function Badge({ className, variant, render, ...props }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
|
|
7
7
|
export { Badge, badgeVariants };
|
|
@@ -2,7 +2,7 @@ import * as React from "react";
|
|
|
2
2
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
|
|
3
3
|
declare function Popover({ ...props }: PopoverPrimitive.Root.Props): React.JSX.Element;
|
|
4
4
|
declare function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props): React.JSX.Element;
|
|
5
|
-
declare function PopoverContent({ className, align, alignOffset, side, sideOffset, ...props }: PopoverPrimitive.Popup.Props & Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">): React.JSX.Element;
|
|
5
|
+
declare function PopoverContent({ className, align, alignOffset, side, sideOffset, anchor, ...props }: PopoverPrimitive.Popup.Props & Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "anchor">): React.JSX.Element;
|
|
6
6
|
declare function PopoverHeader({ className, ...props }: React.ComponentProps<"div">): React.JSX.Element;
|
|
7
7
|
declare function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props): React.JSX.Element;
|
|
8
8
|
declare function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props): React.JSX.Element;
|
|
@@ -8,8 +8,12 @@ function Popover({ ...props }) {
|
|
|
8
8
|
function PopoverTrigger({ ...props }) {
|
|
9
9
|
return _jsx(PopoverPrimitive.Trigger, { "data-slot": "popover-trigger", ...props });
|
|
10
10
|
}
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// `anchor` is this repo's patch to the upstream primitive: a popover with
|
|
12
|
+
// no trigger of its own, placed at a point (the rich-text editor's link
|
|
13
|
+
// box, at the caret). A local patch: re-running `shadcn add popover` here
|
|
14
|
+
// would overwrite it. Diff against upstream before taking a new version.
|
|
15
|
+
function PopoverContent({ className, align = "center", alignOffset = 0, side = "bottom", sideOffset = 4, anchor, ...props }) {
|
|
16
|
+
return (_jsx(PopoverPrimitive.Portal, { children: _jsx(PopoverPrimitive.Positioner, { anchor: anchor, align: align, alignOffset: alignOffset, side: side, sideOffset: sideOffset, className: "isolate z-50", children: _jsx(PopoverPrimitive.Popup, { "data-slot": "popover-content", className: cn("z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props }) }) }));
|
|
13
17
|
}
|
|
14
18
|
function PopoverHeader({ className, ...props }) {
|
|
15
19
|
return (_jsx("div", { "data-slot": "popover-header", className: cn("flex flex-col gap-0.5 text-sm", className), ...props }));
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
|
|
2
|
+
import { type VariantProps } from "class-variance-authority";
|
|
3
|
+
declare const toggleVariants: (props?: ({
|
|
4
|
+
variant?: "default" | "outline" | null | undefined;
|
|
5
|
+
size?: "default" | "sm" | "lg" | "icon-xs" | "icon-sm" | null | undefined;
|
|
6
|
+
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
7
|
+
declare function Toggle({ className, variant, size, ...props }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>): import("react").JSX.Element;
|
|
8
|
+
export { Toggle, toggleVariants };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
|
|
3
|
+
import { cva } from "class-variance-authority";
|
|
4
|
+
import { cn } from "cn";
|
|
5
|
+
// The `icon-xs`/`icon-sm` sizes are this repo's patch to the upstream
|
|
6
|
+
// primitive, matching Button's: a square toggle holding one icon, like the
|
|
7
|
+
// pressed flag inside a table cell (cell pattern 15). A local patch:
|
|
8
|
+
// re-running `shadcn add toggle` here would overwrite it. Diff against
|
|
9
|
+
// upstream before taking a new version.
|
|
10
|
+
const toggleVariants = cva("group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", {
|
|
11
|
+
variants: {
|
|
12
|
+
variant: {
|
|
13
|
+
default: "bg-transparent",
|
|
14
|
+
outline: "border border-input bg-transparent hover:bg-muted",
|
|
15
|
+
},
|
|
16
|
+
size: {
|
|
17
|
+
default: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
18
|
+
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
19
|
+
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
20
|
+
// 24px: the smallest target WCAG 2.2 allows (2.5.8).
|
|
21
|
+
"icon-xs": "size-6 min-w-6 rounded-[min(var(--radius-md),10px)] [&_svg:not([class*='size-'])]:size-3.5",
|
|
22
|
+
"icon-sm": "size-7 min-w-7 rounded-[min(var(--radius-md),12px)]",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
defaultVariants: {
|
|
26
|
+
variant: "default",
|
|
27
|
+
size: "default",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
function Toggle({ className, variant = "default", size = "default", ...props }) {
|
|
31
|
+
return (_jsx(TogglePrimitive, { "data-slot": "toggle", className: cn(toggleVariants({ variant, size, className })), ...props }));
|
|
32
|
+
}
|
|
33
|
+
export { Toggle, toggleVariants };
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { ListEditor } from './components/app/list-editor.js';
|
|
|
14
14
|
export { PasswordInput } from './components/app/password-input.js';
|
|
15
15
|
export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-text-editor.js';
|
|
16
16
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
17
|
+
export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
|
|
17
18
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
18
19
|
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
|
19
20
|
export { RegisterRoute } from './routes/register.js';
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export { ListEditor } from './components/app/list-editor.js';
|
|
|
19
19
|
export { PasswordInput } from './components/app/password-input.js';
|
|
20
20
|
export { RichTextEditor } from './components/app/rich-text-editor.js';
|
|
21
21
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
22
|
+
export { StageCircle, } from './components/app/stage-circle.js';
|
|
22
23
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
23
24
|
// Screens outside the app shell
|
|
24
25
|
export { LoginRoute } from './routes/login.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The address a link in the rich-text editor points at, from what the
|
|
2
|
+
// person typed. Web and email addresses only: a bare `example.com` becomes
|
|
3
|
+
// `https://example.com` and a bare `name@example.com` a `mailto:`; a path
|
|
4
|
+
// on this site (`/x`) or a heading (`#x`) is kept. Any other scheme
|
|
5
|
+
// (`javascript:`, `data:`, `file:`) is refused, so a link can't run code
|
|
6
|
+
// or reach a local file.
|
|
7
|
+
/** The `href` to save, `''` to remove the link, or `null` when it's refused. */
|
|
8
|
+
export function linkHref(typed) {
|
|
9
|
+
const value = typed.trim();
|
|
10
|
+
if (value === '')
|
|
11
|
+
return '';
|
|
12
|
+
if (/^(https?:\/\/|mailto:|tel:)/i.test(value))
|
|
13
|
+
return value;
|
|
14
|
+
// A scheme of its own (`javascript:`), not a port (`example.com:8080`).
|
|
15
|
+
if (/^[a-z][a-z0-9+.-]*:(?!\d)/i.test(value))
|
|
16
|
+
return null;
|
|
17
|
+
if (value.startsWith('/') || value.startsWith('#'))
|
|
18
|
+
return value;
|
|
19
|
+
if (/^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/.test(value))
|
|
20
|
+
return `mailto:${value}`;
|
|
21
|
+
return `https://${value}`;
|
|
22
|
+
}
|
|
@@ -169,13 +169,14 @@ export function mergeMarkdown(original, baseline, current) {
|
|
|
169
169
|
}
|
|
170
170
|
out += 'original' in item ? originals[item.original].source : item.written.source;
|
|
171
171
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
172
|
+
// The document ends the way the original ended, whichever block is last
|
|
173
|
+
// now: an edit to the last block adds no newline the person didn't type.
|
|
174
|
+
// Text written from nothing ends the way the editor ends it.
|
|
175
|
+
const ending = originals.length > 0
|
|
176
|
+
? original.slice(originals[originals.length - 1].end)
|
|
177
|
+
: current.slice(edited.at(-1)?.end ?? current.length);
|
|
178
|
+
if (items.length > 0)
|
|
179
|
+
out += ending;
|
|
179
180
|
const saved = blocks(out)
|
|
180
181
|
.filter((block) => block.type !== 'definition')
|
|
181
182
|
.map((block) => block.key);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.15.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|