wiki-formant 0.89.4 → 0.91.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 CHANGED
@@ -859,14 +859,13 @@ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUs
859
859
 
860
860
  ## Block editor
861
861
 
862
- `wiki-formant/block-editor` is the block list an author edits and the fields of the core types. Three wikis had written it, 500 to 700 lines each, and had drifted: one dropped a stored code language its list did not name, one let a typed count run past the row cap, one named every remove button "Remove", one never marked the block being edited. The behaviour is here once; the look arrives as a kit.
862
+ `wiki-formant/block-editor` is the block list an author edits and the fields of the core types. Three wikis had written it, 500 to 700 lines each, and had drifted: one dropped a stored code language its list did not name, one let a typed count run past the row cap, one named every remove button "Remove", one never marked the block being edited. The behaviour is here once, and so is the chrome: each block is a card with a toolbar (its name, then add below, move, duplicate and delete, shown while the block is in hand and always on a touch screen), and every add menu is two tight columns of names, each type's `hint` its tooltip, drawn by `base.css` on the `--ui-*` tokens. The fields' look arrives as a kit.
863
863
 
864
864
  ```tsx
865
865
  const KIT: BlockEditorKit = {
866
- classNames: { list: 'stack', card: 'block-card', action: 'icon-btn', input: 'input', row: 'row', … },
866
+ classNames: { action: 'icon-btn', input: 'input', row: 'row', stack: 'stack-sm', … },
867
867
  icons: { add: <Plus />, up: <ChevronUp />, down: <ChevronDown />, duplicate: <Copy />, remove: <Trash2 /> },
868
868
  Choice: ToggleGroup, // optional: a <select> otherwise
869
- Menu: PopoverMenu, // optional: an inline panel otherwise
870
869
  };
871
870
  const SCHEMA: BlockSchema<Block> = {
872
871
  meta: type => BLOCK_META[type], create: createBlock, duplicate: duplicateBlock,
@@ -885,7 +884,7 @@ const SCHEMA: BlockSchema<Block> = {
885
884
 
886
885
  The dispatch stays a `switch` over the wiki's own union, so a new type is a compile error there. `CodeTabsFields`, `StatsFields`, `TestimonialFields`, `EventsFields`, `LocationsFields`, `ReferencesFields`, `LinkGridFields`, `BannerFields`, `PageListFields`, `ListFields`, `ColumnsFields` and `InfoboxFields` take the core blocks `wiki-formant/blocks` declares; the two containers hold the same list, offering `nestedTypes`. `Field`, `Choice` and `LimitField` are exported for a wiki's own editors, so its native types wear the same kit. A card is marked `data-selected` while it is being edited and `data-nested` inside a container; a type the build does not know is shown by its raw type and carried through untouched. Keep the kit and the schema stable — a module constant or a `useMemo` — since both reach every card through context, and a fresh one re-renders every card on every keystroke.
887
886
 
888
- An editor that draws each block in place, with a list of its own (miow's), hosts the same fields in `<BlockFieldsKit kit={KIT}>`, without `<BlockEditor>`. Only the two container fields need the full editor, since they hold a block list.
887
+ An editor that draws each block in place, with a list of its own (miow's), hosts the same fields in `<BlockFieldsKit kit={KIT}>`, without `<BlockEditor>`. Only the two container fields need the full editor, since they hold a block list. It wears the same chrome: `<BlockToolbar>` over each block (an editor's own controls go in as `children`, before add below) and `<AddBlockMenu>` at a list's foot, each offering `BlockOption`s.
889
888
 
890
889
  A `testimonial` is `{ quote, author, role?, avatarUrl? }`, drawn by `TestimonialView` as `figure.testimonial`: the words in a `blockquote`, then a `figcaption` holding the photo and a `span` of `strong` (who) and `small` (their role). radix-wiki and miow each had one under its own field names; a site with sized images passes `photo`, the `<img>` attributes (`src`, `srcSet`, `sizes`) it made from `avatarUrl`. They are data, because a server component renders the view.
891
890
 
package/dist/base.css CHANGED
@@ -203,7 +203,7 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
203
203
 
204
204
  /* ---- the look of the package's components ----------------------------------
205
205
  The panels, lists, cards and rails the package's markup produces, drawn once
206
- on eight tokens a site maps from its own palette in `:root`. Four
206
+ on the tokens a site maps from its own palette in `:root`. Four
207
207
  stylesheets had restated sixty of these classes with four vocabularies
208
208
  (`--corner`, `--radius`, `--radius-wing`; `--space`, `--spacing-md`,
209
209
  `--space-3`) and four bodies. A site that must differ overrides the rule;
@@ -212,6 +212,7 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
212
212
  :root {
213
213
  --ui-surface: var(--color-surface-1); a card's background
214
214
  --ui-surface-2: var(--color-surface-2); a raised or hovered one
215
+ --ui-stripe: color-mix(in srgb, var(--color-text) 5%, transparent); every other table row, over its cells
215
216
  --ui-border: var(--color-border);
216
217
  --ui-radius: var(--radius);
217
218
  --ui-pad: 1rem; a panel's padding
@@ -225,6 +226,7 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
225
226
  :root {
226
227
  --ui-surface: color-mix(in srgb, currentColor 6%, transparent);
227
228
  --ui-surface-2: color-mix(in srgb, currentColor 12%, transparent);
229
+ --ui-stripe: color-mix(in srgb, currentColor 5%, transparent);
228
230
  --ui-border: color-mix(in srgb, currentColor 20%, transparent);
229
231
  --ui-radius: 0.5rem;
230
232
  --ui-pad: 1rem;
@@ -252,6 +254,11 @@ html[data-sidebar='closed'] .wiki-sidebar { width: 0; }
252
254
  /* Labels are short and values are the reading: 42% still fits "Published:". */
253
255
  .infobox th { width: 42%; font-weight: 600; color: var(--ui-muted); }
254
256
  .infobox tr:last-child :is(th, td) { border-bottom: 0; }
257
+ /* Every table's body rows alternate, so a row reads across, the infobox's
258
+ included. The stripe is an image laid over the cell rather than a colour, so
259
+ it shows on whatever background a site gives its cells, and under a hover's.
260
+ The chart lays itself out in a table, and is not one. */
261
+ tbody > tr:nth-child(even) > :is(th, td):not(.chart *) { background-image: linear-gradient(var(--ui-stripe), var(--ui-stripe)); }
255
262
  .infobox-facts { margin: 0; }
256
263
  .infobox-series { align-items: center; gap: 0.125rem; padding-bottom: 0.75rem; border-bottom: 1px solid var(--ui-border); text-align: center; font-size: var(--ui-small); color: var(--ui-muted); }
257
264
  .page-with-infobox { gap: 1.5rem; }
@@ -597,11 +604,15 @@ pre:hover > .code-copy-btn,
597
604
  `TimeChart`: the canvas, a loading or empty state over it, and the range and
598
605
  step buttons pinned to its top-left corner. A site gives the buttons and the
599
606
  box their look; pressed is all this marks. */
600
- .chart { position: relative; margin: 0; overflow: hidden; }
607
+ .chart { position: relative; display: flex; flex-direction: column; margin: 0; overflow: hidden; container-type: inline-size; }
601
608
  /* Its own stacking context: the library's canvases stack themselves, and the buttons go over them. */
602
609
  .chart-canvas { position: relative; z-index: 0; height: var(--chart-height, 15rem); }
603
610
  /* The chart lays itself out in a <table>; a site's table styles are not for it. */
604
611
  .chart-canvas :is(table, tr, td) { background: none; border: 0; border-radius: 0; }
612
+ /* It sizes the plot and leaves the axis column to fit its labels. The infobox's
613
+ fixed layout split what was left between the axis and an empty column, and
614
+ cut the axis to half its labels. */
615
+ .chart-canvas table { table-layout: auto; }
605
616
  .chart-status { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; }
606
617
  .chart-controls {
607
618
  position: absolute;
@@ -622,6 +633,12 @@ pre:hover > .code-copy-btn,
622
633
  .chart-controls :is(button, a):is([aria-pressed='true'], [aria-checked='true'], [aria-current]) { opacity: 1; }
623
634
  /* A step the range cannot make stays in its place, and dims. */
624
635
  .chart-controls button:disabled { opacity: 0.3; cursor: default; pointer-events: none; }
636
+ /* A narrow chart has no room over its plot: in the 306px of radix-wiki's
637
+ infobox the buttons ran across the price axis and wrapped onto the line. There
638
+ they take a row of their own above it, still over the loading and empty state. */
639
+ @container (max-width: 30rem) {
640
+ .chart-controls { position: relative; top: auto; left: auto; order: -1; padding: 0.5rem 0.5rem 0; }
641
+ }
625
642
 
626
643
  /* ---- Stats ----------------------------------------------------------------
627
644
  `--share` is a row's value over the largest in its list, 0 to 1. Bars take
@@ -701,3 +718,118 @@ pre:hover > .code-copy-btn,
701
718
  max-width: 100%;
702
719
  min-height: 200px;
703
720
  }
721
+
722
+ /* ---- the block editor (BlockEditor, BlockToolbar, AddBlockMenu) -------------
723
+ Every wiki's editor wears the same chrome: a card per block, its name and its
724
+ controls in a toolbar above its fields, and an add menu of two tight columns,
725
+ names only, with what each type is for as its tooltip. The four had drawn it
726
+ four ways, one a 16-row list a screen tall. A block's controls show while it
727
+ is in hand (hovered, focused, picked, or with its menu open), and always on a
728
+ screen with no hover. The fields inside are the site's kit. */
729
+ .block-list { display: flex; flex-direction: column; gap: 1rem; }
730
+ .block-list[data-nested] { gap: 0.5rem; }
731
+ .block-list__empty { margin: 0; font-size: var(--ui-small); color: var(--ui-muted); }
732
+ .block-card {
733
+ display: flex;
734
+ flex-direction: column;
735
+ gap: 0.75rem;
736
+ min-width: 0;
737
+ padding: var(--ui-pad);
738
+ border: 1px solid var(--ui-border);
739
+ border-radius: var(--ui-radius);
740
+ transition: border-color 150ms, background-color 150ms;
741
+ }
742
+ .block-card:hover { border-color: color-mix(in srgb, var(--ui-muted) 50%, transparent); }
743
+ .block-card[data-nested] { gap: 0.5rem; padding: 0.75rem; }
744
+ .block-card[data-selected] { border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent) 5%, transparent); }
745
+
746
+ .block-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; min-height: 1.75rem; }
747
+ .block-toolbar__label {
748
+ display: inline-flex;
749
+ align-items: center;
750
+ gap: 0.5rem;
751
+ min-width: 0;
752
+ font-size: var(--ui-small);
753
+ font-weight: 500;
754
+ letter-spacing: 0.02em;
755
+ text-transform: uppercase;
756
+ color: var(--ui-muted);
757
+ }
758
+ .block-toolbar__tools, .block-toolbar__actions { display: flex; align-items: center; gap: 0.125rem; }
759
+ .block-toolbar__tools { opacity: 0; transition: opacity 150ms; }
760
+ :is(:hover, :focus-within, [data-selected]) > .block-toolbar > .block-toolbar__tools,
761
+ .block-toolbar__tools:has([aria-expanded='true']) { opacity: 1; }
762
+ @media (hover: none) { .block-toolbar__tools { opacity: 1; } }
763
+ .block-toolbar__button {
764
+ display: inline-grid;
765
+ place-items: center;
766
+ width: 1.75rem;
767
+ height: 1.75rem;
768
+ padding: 0;
769
+ border: 0;
770
+ border-radius: calc(var(--ui-radius) / 2);
771
+ background: none;
772
+ color: var(--ui-muted);
773
+ cursor: pointer;
774
+ }
775
+ .block-toolbar__button:is(:hover, [aria-expanded='true']):not(:disabled) { background: var(--ui-surface-2); color: inherit; }
776
+ .block-toolbar__button:disabled { opacity: 0.3; cursor: default; }
777
+ /* BlockActions puts delete last. */
778
+ .block-toolbar__actions > :last-child:hover { color: var(--ui-down); }
779
+ :is(.block-toolbar, .block-menu) svg { flex: none; width: 1rem; height: 1rem; }
780
+
781
+ .block-menu { position: relative; display: inline-flex; }
782
+ .block-list > .block-menu { align-self: center; }
783
+ .block-add {
784
+ display: inline-flex;
785
+ align-items: center;
786
+ gap: 0.375rem;
787
+ padding: 0.375rem 0.75rem;
788
+ border: 1px dashed var(--ui-border);
789
+ border-radius: var(--ui-radius);
790
+ background: none;
791
+ font: inherit;
792
+ font-size: var(--ui-small);
793
+ color: var(--ui-muted);
794
+ cursor: pointer;
795
+ }
796
+ .block-add:is(:hover, [aria-expanded='true']) { border-color: var(--ui-muted); color: inherit; }
797
+ .block-menu__panel {
798
+ position: absolute;
799
+ top: calc(100% + 0.25rem);
800
+ z-index: 50;
801
+ display: grid;
802
+ grid-template-columns: repeat(2, minmax(0, 1fr));
803
+ width: min(22rem, calc(100vw - 2rem));
804
+ padding: 0.25rem;
805
+ background: var(--ui-surface);
806
+ border: 1px solid var(--ui-border);
807
+ border-radius: var(--ui-radius);
808
+ box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.25);
809
+ }
810
+ .block-menu:not([data-below]) > .block-menu__panel { left: 50%; translate: -50% 0; }
811
+ .block-menu[data-below] > .block-menu__panel { right: 0; }
812
+ /* A phone has no room to hang it off a button deep in a column: it is a sheet
813
+ along the foot of the screen. */
814
+ @media (max-width: 40rem) {
815
+ /* As specific as the two placements above, and after them. */
816
+ .block-menu:is(:not([data-below]), [data-below]) > .block-menu__panel { position: fixed; inset: auto 1rem 1rem; width: auto; translate: none; }
817
+ }
818
+ .block-menu__item {
819
+ display: flex;
820
+ align-items: center;
821
+ gap: 0.5rem;
822
+ min-width: 0;
823
+ padding: 0.375rem 0.5rem;
824
+ border: 0;
825
+ border-radius: calc(var(--ui-radius) / 2);
826
+ background: none;
827
+ font: inherit;
828
+ font-size: var(--ui-small);
829
+ color: inherit;
830
+ text-align: left;
831
+ cursor: pointer;
832
+ }
833
+ .block-menu__item > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
834
+ .block-menu__item svg { color: var(--ui-muted); }
835
+ .block-menu__item:is(:hover, :focus-visible) { background: var(--ui-surface-2); outline: none; }
@@ -1,29 +1,11 @@
1
- import { type ComponentType, type ReactElement, type ReactNode } from 'react';
1
+ import { type ComponentType, type ReactNode } from 'react';
2
+ import { type BlockActionsProps } from './react.js';
2
3
  import { type BannerBlock, type CodeTabsBlock, type ColumnsBlock, type EventsBlock, type LocationsBlock, type InfoboxBlock, type LinkGridBlock, type ReferencesBlock, type StatsBlock, type TestimonialBlock } from './blocks.js';
3
4
  export interface BlockEditorClassNames {
4
- /** A page's block list. */
5
- list?: string;
6
- /** A column's or an infobox's. */
7
- nestedList?: string;
8
- /** The line an empty list shows. */
9
- empty?: string;
10
- /** A block. It carries `data-selected` while it is the one being edited, and `data-nested` inside a container. */
11
- card?: string;
12
- cardHead?: string;
13
- cardLabel?: string;
14
- cardActions?: string;
15
- /** Every icon button: a card's actions, add-below, remove. */
5
+ /** A field's icon button: remove a row, a tab, a column. */
16
6
  action?: string;
17
- /** The Add block button at the foot of a list. */
18
- addBlock?: string;
19
7
  /** The default `Add` button: Add tab, Add link. */
20
8
  add?: string;
21
- /** The default `Menu`'s wrapper and panel. */
22
- menuAnchor?: string;
23
- menu?: string;
24
- menuItem?: string;
25
- menuText?: string;
26
- menuHint?: string;
27
9
  /** A vertical group of fields. */
28
10
  stack?: string;
29
11
  /** A row of inputs ending in a remove button. */
@@ -68,18 +50,6 @@ export interface ChoiceProps {
68
50
  }>;
69
51
  onChange: (value: string) => void;
70
52
  }
71
- /**
72
- * The add-block menu around its trigger. `trigger` is the button the editor
73
- * drew; a popover takes it as its anchor, an inline panel renders it first.
74
- */
75
- export interface MenuProps {
76
- open: boolean;
77
- onOpenChange: (open: boolean) => void;
78
- trigger: ReactElement;
79
- /** The icon in a block's head that adds under it, rather than the list's foot button. */
80
- below: boolean;
81
- children: ReactNode;
82
- }
83
53
  export interface AddProps {
84
54
  onPress: () => void;
85
55
  children: ReactNode;
@@ -89,7 +59,6 @@ export interface BlockEditorKit {
89
59
  icons: BlockEditorIcons;
90
60
  Field?: ComponentType<FieldProps>;
91
61
  Choice?: ComponentType<ChoiceProps>;
92
- Menu?: ComponentType<MenuProps>;
93
62
  Add?: ComponentType<AddProps>;
94
63
  }
95
64
  export type EditableBlock = {
@@ -99,7 +68,7 @@ export type EditableBlock = {
99
68
  export interface BlockMeta {
100
69
  label: string;
101
70
  icon?: ReactNode;
102
- /** The line under the label in the add menu. */
71
+ /** What it is for: the add menu's tooltip on it. */
103
72
  hint?: string;
104
73
  }
105
74
  export interface BlockSchema<B extends EditableBlock> {
@@ -141,6 +110,37 @@ export interface BlockEditorProps<B extends EditableBlock> {
141
110
  * the same list, offering `nestedTypes`.
142
111
  */
143
112
  export declare function BlockEditor<B extends EditableBlock>({ blocks, onChange, schema, kit }: BlockEditorProps<B>): import("react").JSX.Element;
113
+ /** A type an add menu offers. */
114
+ export interface BlockOption {
115
+ type: string;
116
+ label: string;
117
+ icon?: ReactNode;
118
+ /** What it is for: the item's tooltip. */
119
+ hint?: string;
120
+ }
121
+ export interface AddBlockMenuProps {
122
+ options: readonly BlockOption[];
123
+ onPick: (type: string) => void;
124
+ icon: ReactNode;
125
+ /** The icon in a block's toolbar that adds under it, rather than the button at a list's foot. */
126
+ below?: boolean;
127
+ /** The foot button's words. */
128
+ label?: string;
129
+ }
130
+ /** An add button and its menu, which closes on a pick, a click outside it or Escape. Nothing to offer, no button. */
131
+ export declare function AddBlockMenu({ options, onPick, icon, below, label }: AddBlockMenuProps): import("react").JSX.Element | null;
132
+ export interface BlockToolbarProps extends Pick<BlockActionsProps, 'index' | 'total' | 'ops'> {
133
+ label: string;
134
+ icon?: ReactNode;
135
+ icons: BlockEditorIcons;
136
+ /** What add-below offers; without them, no add-below. */
137
+ options?: readonly BlockOption[];
138
+ onAdd?: (type: string) => void;
139
+ /** An editor's own controls, before add-below. */
140
+ children?: ReactNode;
141
+ }
142
+ /** A block's name and its controls: add below, move up and down, duplicate, delete. */
143
+ export declare function BlockToolbar({ label, icon, index, total, ops, icons, options, onAdd, children }: BlockToolbarProps): import("react").JSX.Element;
144
144
  /** A labelled field in the kit's style, for a wiki's own block editors as well as these. */
145
145
  export declare function Field(props: FieldProps): import("react").JSX.Element;
146
146
  /** One of a few values in the kit's style; a `<select>` when the kit has none. */
@@ -7,10 +7,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
7
7
  // drifted the way copies do: one dropped a stored code language that was not
8
8
  // in its list, one let a typed count run past the row cap, one named every
9
9
  // remove button "Remove", one never highlighted the block being edited. The
10
- // behaviour lives here once. The look does not: each wiki draws its inputs,
11
- // toggles and menus with its own design system, so those arrive as a KIT —
12
- // class names, icons, and four optional components — and the markup here is
13
- // bare semantic HTML wearing whatever the kit says.
10
+ // behaviour lives here once, and so does the chrome every editor wears: a
11
+ // block's toolbar and the add menu, drawn by base.css. The fields' look does
12
+ // not: each wiki draws its inputs and toggles with its own design system, so
13
+ // those arrive as a KIT — class names, icons, and three optional components —
14
+ // and the markup here is bare semantic HTML wearing whatever the kit says.
14
15
  //
15
16
  // The dispatch stays with the wiki. `renderBody` is a `switch` over its own
16
17
  // closed union, as S4 of the workspace's STRUCTURE.md requires, so a new block
@@ -21,8 +22,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
21
22
  // what lets a column or an infobox hold a list without being handed the whole
22
23
  // configuration. Keep both stable — a module constant or a `useMemo` — or every
23
24
  // card re-renders on every keystroke.
24
- import { createContext, memo, useCallback, useContext, useMemo, useState, } from 'react';
25
- import { BlockActions, useBlockOperations } from './react.js';
25
+ import { createContext, memo, useCallback, useContext, useMemo, useRef, useState, } from 'react';
26
+ import { BlockActions, useBlockOperations, useClickOutside } from './react.js';
26
27
  import { CODE_LANGS, DEFAULT_LANG, EVENT_DATE, eventBegun, eventEnded, eventWhen, LIST_SOURCES, today, } from './blocks.js';
27
28
  import { BANNER_VARIANTS } from './text.js';
28
29
  import { DAY_NAMES, DAYS, DEFAULT_ZONE, hoursRows, TIME } from './hours.js';
@@ -65,48 +66,55 @@ export function BlockEditor({ blocks, onChange, schema, kit }) {
65
66
  }
66
67
  function BlockList({ blocks, onChange, nested = false }) {
67
68
  const { kit, schema } = useEditorContext();
68
- const c = kit.classNames;
69
69
  const ops = useBlockOperations(blocks, onChange, schema);
70
70
  const base = nested ? schema.nestedTypes : schema.types;
71
71
  // A string, so the offer is recomputed — and every card re-rendered — only
72
72
  // when a single-use type comes or goes, not on every keystroke.
73
73
  const held = (schema.single ?? []).filter(t => blocks.some(b => b.type === t)).join(' ');
74
- const offered = useMemo(() => base.filter(t => !held.split(' ').includes(t)), [base, held]);
74
+ const offered = useMemo(() => base.filter(t => !held.split(' ').includes(t)).map(type => blockOption(schema, type)), [base, held, schema]);
75
75
  const insertNew = useCallback((type) => ops.insert(schema.create(type)), [ops.insert, schema]);
76
- return (_jsxs("div", { className: nested ? c.nestedList : c.list, children: [blocks.length === 0 && _jsx("p", { className: c.empty, children: nested ? 'Empty.' : 'Nothing here yet. Add the first block.' }), blocks.map((block, i) => (_jsx(BlockCard, { block: block, index: i, total: blocks.length, selected: ops.selectedIndex === i, nested: nested, offered: offered, update: ops.update, move: ops.move, duplicate: ops.duplicate, remove: ops.remove, insert: ops.insert, select: ops.setSelectedIndex }, block.id))), _jsx(InsertMenu, { types: offered, onPick: insertNew })] }));
76
+ return (_jsxs("div", { className: "block-list", "data-nested": nested || undefined, children: [blocks.length === 0 && _jsx("p", { className: "block-list__empty", children: nested ? 'Empty.' : 'Nothing here yet. Add the first block.' }), blocks.map((block, i) => (_jsx(BlockCard, { block: block, index: i, total: blocks.length, selected: ops.selectedIndex === i, nested: nested, offered: offered, update: ops.update, move: ops.move, duplicate: ops.duplicate, remove: ops.remove, insert: ops.insert, select: ops.setSelectedIndex }, block.id))), _jsx(AddBlockMenu, { options: offered, onPick: insertNew, icon: kit.icons.add })] }));
77
+ }
78
+ function blockOption(schema, type) {
79
+ const meta = schema.meta(type);
80
+ return { type, label: meta?.label ?? type, icon: meta?.icon, hint: meta?.hint };
77
81
  }
78
82
  // Memoised on the list's ref-stable callbacks, so a keystroke in one block does
79
83
  // not re-render every other block's editor.
80
84
  const BlockCard = memo(function BlockCard({ block, index, total, selected, nested, offered, update, move, duplicate, remove, insert, select }) {
81
85
  const { kit, schema } = useEditorContext();
82
- const c = kit.classNames;
83
86
  const onUpdate = useCallback((next) => update(index, next), [update, index]);
84
87
  const onSelect = useCallback(() => select(index), [select, index]);
88
+ const onAdd = useCallback((type) => insert(schema.create(type), index + 1), [insert, schema, index]);
85
89
  const meta = schema.meta(block.type);
86
90
  const label = meta?.label ?? block.type;
87
91
  const body = meta ? schema.renderBody(block, onUpdate) : null;
88
- return (_jsxs("section", { className: c.card, "data-selected": selected || undefined, "data-nested": nested || undefined, onFocusCapture: onSelect, onClick: onSelect, children: [_jsxs("div", { className: c.cardHead, children: [_jsxs("span", { className: c.cardLabel, children: [meta?.icon, label] }), _jsxs("span", { className: c.cardActions, children: [_jsx(InsertMenu, { below: true, types: offered, onPick: type => insert(schema.create(type), index + 1) }), _jsx(BlockActions, { index: index, total: total, ops: { move, duplicate, remove }, icons: kit.icons, className: c.cardActions, buttonClassName: c.action, blockLabel: label })] })] }), body ?? _jsx("p", { className: c.muted, children: "Kept as it is \u2013 this editor has no fields for it." })] }));
92
+ return (_jsxs("section", { className: "block-card", "data-selected": selected || undefined, "data-nested": nested || undefined, onFocusCapture: onSelect, onClick: onSelect, children: [_jsx(BlockToolbar, { label: label, icon: meta?.icon, index: index, total: total, ops: { move, duplicate, remove }, icons: kit.icons, options: offered, onAdd: onAdd }), body ?? _jsx("p", { className: kit.classNames.muted, children: "Kept as it is \u2013 this editor has no fields for it." })] }));
89
93
  });
90
- function InsertMenu({ types, onPick, below = false }) {
91
- const { kit, schema } = useEditorContext();
92
- const c = kit.classNames;
94
+ /** An add button and its menu, which closes on a pick, a click outside it or Escape. Nothing to offer, no button. */
95
+ export function AddBlockMenu({ options, onPick, icon, below = false, label = 'Add block' }) {
93
96
  const [open, setOpen] = useState(false);
94
- const Menu = kit.Menu ?? InlineMenu;
95
- const toggle = () => setOpen(o => !o);
96
- const trigger = below ? (_jsx("button", { type: "button", className: c.action, title: "Add a block below", "aria-label": "Add a block below", "aria-expanded": open, onClick: toggle, children: kit.icons.add })) : (_jsxs("button", { type: "button", className: c.addBlock, "aria-expanded": open, onClick: toggle, children: [kit.icons.add, "Add block"] }));
97
- return (_jsx(Menu, { open: open, onOpenChange: setOpen, trigger: trigger, below: below, children: types.map(type => {
98
- const meta = schema.meta(type);
99
- return (_jsxs("button", { type: "button", className: c.menuItem, onClick: e => {
100
- // Inside a card, a pick is not also a click on the card.
101
- e.stopPropagation();
102
- onPick(type);
103
- setOpen(false);
104
- }, children: [meta?.icon, _jsxs("span", { className: c.menuText, children: [_jsx("span", { children: meta?.label ?? type }), meta?.hint && _jsx("span", { className: c.menuHint, children: meta.hint })] })] }, type));
105
- }) }));
97
+ const close = useCallback(() => setOpen(false), []);
98
+ const ref = useClickOutside(close);
99
+ const trigger = useRef(null);
100
+ if (options.length === 0)
101
+ return null;
102
+ return (_jsxs("span", { ref: ref, className: "block-menu", "data-below": below || undefined, onKeyDown: e => {
103
+ if (e.key !== 'Escape' || !open)
104
+ return;
105
+ e.stopPropagation();
106
+ close();
107
+ trigger.current?.focus();
108
+ }, children: [_jsxs("button", { ref: trigger, type: "button", className: below ? 'block-toolbar__button' : 'block-add', title: below ? 'Add a block below' : undefined, "aria-label": below ? 'Add a block below' : undefined, "aria-haspopup": "menu", "aria-expanded": open, onClick: () => setOpen(o => !o), children: [icon, !below && label] }), open && (_jsx("span", { className: "block-menu__panel", role: "menu", children: options.map(o => (_jsxs("button", { type: "button", role: "menuitem", className: "block-menu__item", title: o.hint, onClick: e => {
109
+ // Inside a card, a pick is not also a click on the card.
110
+ e.stopPropagation();
111
+ close();
112
+ onPick(o.type);
113
+ }, children: [o.icon, _jsx("span", { children: o.label })] }, o.type))) }))] }));
106
114
  }
107
- function InlineMenu({ open, trigger, children }) {
108
- const c = useEditorContext().kit.classNames;
109
- return (_jsxs("span", { className: c.menuAnchor, children: [trigger, open && _jsx("span", { className: c.menu, children: children })] }));
115
+ /** A block's name and its controls: add below, move up and down, duplicate, delete. */
116
+ export function BlockToolbar({ label, icon, index, total, ops, icons, options, onAdd, children }) {
117
+ return (_jsxs("div", { className: "block-toolbar", children: [_jsxs("span", { className: "block-toolbar__label", children: [icon, label] }), _jsxs("span", { className: "block-toolbar__tools", children: [children, options && onAdd && _jsx(AddBlockMenu, { below: true, options: options, onPick: onAdd, icon: icons.add }), _jsx(BlockActions, { index: index, total: total, ops: ops, icons: icons, className: "block-toolbar__actions", buttonClassName: "block-toolbar__button", blockLabel: label })] })] }));
110
118
  }
111
119
  // ---- the kit's parts, resolved ----------------------------------------------
112
120
  /** A labelled field in the kit's style, for a wiki's own block editors as well as these. */
@@ -254,7 +262,7 @@ function PagePicker({ ids, onChange: set, pages }) {
254
262
  };
255
263
  return (_jsxs("div", { className: c.stack, children: [ids.map((id, i) => {
256
264
  const page = byId.get(id);
257
- return (_jsxs("div", { className: c.row, children: [_jsxs("span", { className: c.muted, children: [i + 1, "."] }), _jsxs("span", { children: [pages ? (page?.title ?? `No such page: ${id}`) : id, page?.detail && _jsxs("span", { className: c.muted, children: [" \u00B7 ", page.detail] })] }), _jsx(BlockActions, { index: i, total: ids.length, ops: ops, icons: { up: kit.icons.up, down: kit.icons.down, remove: kit.icons.remove }, className: c.cardActions, buttonClassName: c.action, blockLabel: "page" })] }, i));
265
+ return (_jsxs("div", { className: c.row, children: [_jsxs("span", { className: c.muted, children: [i + 1, "."] }), _jsxs("span", { children: [pages ? (page?.title ?? `No such page: ${id}`) : id, page?.detail && _jsxs("span", { className: c.muted, children: [" \u00B7 ", page.detail] })] }), _jsx(BlockActions, { index: i, total: ids.length, ops: ops, icons: { up: kit.icons.up, down: kit.icons.down, remove: kit.icons.remove }, className: "block-toolbar__actions", buttonClassName: "block-toolbar__button", blockLabel: "page" })] }, i));
258
266
  }), pages && pages.length === 0 && _jsx("p", { className: c.muted, children: "This wiki has no pages to pick yet." }), !full && pages && unpicked.length > 0 && (_jsx(Field, { label: "Add a page", children: _jsxs("select", { className: c.input, value: "", onChange: e => add(e.target.value), children: [_jsx("option", { value: "", children: "Choose\u2026" }), unpicked.map(p => (_jsx("option", { value: p.id, children: p.detail ? `${p.title} · ${p.detail}` : p.title }, p.id)))] }) })), !full && !pages && (_jsxs("div", { className: c.row, children: [_jsx("input", { type: "text", className: c.input, "aria-label": "Page id", placeholder: "Page id", value: typed, onChange: e => setTyped(e.target.value), onKeyDown: e => {
259
267
  if (e.key !== 'Enter')
260
268
  return;
@@ -301,7 +309,7 @@ const MAX_COLUMNS = 4;
301
309
  export function ColumnsFields({ block, onUpdate }) {
302
310
  const c = useEditorContext().kit.classNames;
303
311
  const columns = block.columns;
304
- return (_jsxs("div", { className: c.stack, children: [_jsxs("div", { className: c.row, children: [_jsx(Choice, { label: "Gap", value: block.gap ?? 'md', options: GAPS, onChange: gap => onUpdate({ ...block, gap: gap }) }), _jsx(Choice, { label: "Align", value: block.align ?? 'start', options: ALIGNS, onChange: align => onUpdate({ ...block, align: align }) })] }), _jsx("div", { className: c.columns, children: columns.map((column, ci) => (_jsxs("div", { className: cx(c.nest, c.stack), children: [_jsxs("div", { className: c.cardHead, children: [_jsxs("span", { className: c.cardLabel, children: ["Column ", ci + 1] }), columns.length > 1 && _jsx(Remove, { label: `Remove column ${ci + 1}`, onPress: () => onUpdate({ ...block, columns: removeAt(columns, ci) }) })] }), _jsx(BlockList, { nested: true, blocks: column.blocks, onChange: blocks => onUpdate({ ...block, columns: replaceAt(columns, ci, { ...column, blocks: blocks }) }) })] }, column.id))) }), columns.length < MAX_COLUMNS && _jsx(Add, { onPress: () => onUpdate({ ...block, columns: [...columns, { id: newId(), blocks: [] }] }), children: "Add column" })] }));
312
+ return (_jsxs("div", { className: c.stack, children: [_jsxs("div", { className: c.row, children: [_jsx(Choice, { label: "Gap", value: block.gap ?? 'md', options: GAPS, onChange: gap => onUpdate({ ...block, gap: gap }) }), _jsx(Choice, { label: "Align", value: block.align ?? 'start', options: ALIGNS, onChange: align => onUpdate({ ...block, align: align }) })] }), _jsx("div", { className: c.columns, children: columns.map((column, ci) => (_jsxs("div", { className: cx(c.nest, c.stack), children: [_jsxs("div", { className: "block-toolbar", children: [_jsxs("span", { className: "block-toolbar__label", children: ["Column ", ci + 1] }), columns.length > 1 && _jsx(Remove, { label: `Remove column ${ci + 1}`, onPress: () => onUpdate({ ...block, columns: removeAt(columns, ci) }) })] }), _jsx(BlockList, { nested: true, blocks: column.blocks, onChange: blocks => onUpdate({ ...block, columns: replaceAt(columns, ci, { ...column, blocks: blocks }) }) })] }, column.id))) }), columns.length < MAX_COLUMNS && _jsx(Add, { onPress: () => onUpdate({ ...block, columns: [...columns, { id: newId(), blocks: [] }] }), children: "Add column" })] }));
305
313
  }
306
314
  /** An infobox's blocks, offering the schema's `nestedTypes`. */
307
315
  export function InfoboxFields({ block, onUpdate }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.89.4",
3
+ "version": "0.91.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",