Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
eb56dd9
Fix layout blur dropping trailing columns (P1 layout-blur-drops-columns)
bnomei Jun 27, 2026
053ac18
Guard layout draft sync while field is focused (P1 layout-draft-lost-…
bnomei Jun 27, 2026
4e9865e
Coerce singleton layout row object into an array (P1 non-array-layout…
bnomei Jun 27, 2026
8d2e11d
Always allow removing the last layout row (P2 last-layout-row-not-rem…
bnomei Jun 27, 2026
ea2510e
Use deterministic synthetic column ids in admin (P2 synthetic-column-…
bnomei Jun 27, 2026
1ae999a
Guard normalizeLayoutRow against null/primitive rows (P2 normalize-la…
bnomei Jun 27, 2026
ce18b7c
Scan all columns for span in isLayoutBuilderRow (P2 is-layout-builder…
bnomei Jun 27, 2026
118bede
Coerce singleton column.blocks object into an array (P1 non-array-col…
bnomei Jun 27, 2026
57fdb0d
Guard normalizeLayoutColumn against null/primitive columns (P2 normal…
bnomei Jun 27, 2026
91526f1
Add row-aware grid allocation to stop false wrapping (P2 span-roundin…
bnomei Jun 27, 2026
1aca06b
Cover primitive columns[0] in isLayoutBuilderRow tests (P2 is-layout-…
bnomei Jun 27, 2026
fe58c1d
Stop en default from shadowing later fallback override (P2 bento-mess…
bnomei Jun 27, 2026
a74c55d
De-duplicate row/column ids to prevent duplicate React keys (P2 react…
bnomei Jun 27, 2026
005ce2a
Fix frontend layout span allocation docs
bnomei Jun 27, 2026
0d587d5
comments
bnomei Jun 27, 2026
6b2de16
comments
bnomei Jun 27, 2026
ac99170
Delete .devana directory
bnomei Jun 27, 2026
3f30266
Handle near-one layout span totals
bnomei Jun 29, 2026
0950f32
Format checked PR files
bnomei Jun 29, 2026
d6073f5
Update emdash blocks dependency
bnomei Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,27 +132,31 @@ Render layout rows as a 12-column CSS grid. The package exports helpers for the
common data preparation and span calculation:

```ts
import { spanToGridColumns, visibleLayoutRows } from "@bnomei/emdash-bento";
import { layoutGridSpans, visibleLayoutRows } from "@bnomei/emdash-bento";
import { visibleBlocks } from "@bnomei/emdash-blocks";

const rows = visibleLayoutRows(entry.layouts);
```

```astro
{
rows.map((row) => (
<section class="layout" data-layout-id={row.id}>
{row.columns.map((column) => (
<div
class="column"
style={`--span: ${spanToGridColumns(column.span)}`}
data-column-id={column.id}
>
{visibleBlocks(column.blocks).map((block) => renderBlock(block))}
</div>
))}
</section>
))
rows.map((row) => {
const gridSpans = layoutGridSpans(row.columns.map((column) => column.span));

return (
<section class="layout" data-layout-id={row.id}>
{row.columns.map((column, columnIndex) => (
<div
class="column"
style={`--span: ${gridSpans[columnIndex] ?? 12}`}
data-column-id={column.id}
>
{visibleBlocks(column.blocks).map((block) => renderBlock(block))}
</div>
))}
</section>
);
})
}
```

Expand All @@ -175,7 +179,7 @@ happen before values are saved into EmDash.
- ESM entry: `@bnomei/emdash-bento`.
- Admin entry: `@bnomei/emdash-bento/admin`.
- Type declarations are included from `dist/`.
- Peer dependencies: `@bnomei/emdash-blocks` `^0.2.0`, `emdash`
- Peer dependencies: `@bnomei/emdash-blocks` `^0.2.1`, `emdash`
`>=0.17.0`, `react` `^18.0.0 || ^19.0.0`, `react-dom`
`^18.0.0 || ^19.0.0`, `@cloudflare/kumo` `^2.5.0`, and
`@phosphor-icons/react` `^2.1.10`.
Expand Down
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.3",
"@bnomei/emdash-blocks": "^0.2.0",
"@bnomei/emdash-blocks": "^0.2.1",
"@cloudflare/kumo": "^2.5.2",
"@phosphor-icons/react": "^2.1.10",
"@types/react": "^19.2.17",
Expand All @@ -70,7 +70,7 @@
"vite-plus": "^0.1.24"
},
"peerDependencies": {
"@bnomei/emdash-blocks": "^0.2.0",
"@bnomei/emdash-blocks": "^0.2.1",
"@cloudflare/kumo": "^2.5.0",
"@phosphor-icons/react": "^2.1.10",
"emdash": ">=0.17.0",
Expand Down
8 changes: 8 additions & 0 deletions src/admin-locale.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
/**
* Active EmDash admin locale for the bento widget.
*
* Reads the `emdash-locale` cookie in the browser and resyncs on focus so
* widget copy tracks the admin language switcher without a full reload.
*/
import { useEffect, useState } from "react";
import { DEFAULT_LOCALE, normalizeLocale } from "./i18n";

const LOCALE_COOKIE_NAME = "emdash-locale";

/** Current admin locale from the `emdash-locale` cookie, or `fallback` when absent. */
export function readAdminLocale(fallback = DEFAULT_LOCALE): string {
const normalizedFallback = normalizeLocale(fallback);

Expand All @@ -23,6 +30,7 @@ export function readAdminLocale(fallback = DEFAULT_LOCALE): string {
}
}

/** React hook that tracks the admin locale cookie across focus and periodic resync. */
export function useAdminLocale(fallback = DEFAULT_LOCALE): string {
const [locale, setLocale] = useState(() => readAdminLocale(fallback));

Expand Down
106 changes: 73 additions & 33 deletions src/admin.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* EmDash admin field widget for editing bento layout JSON.
*
* Renders rows and columns on a 12-column preview grid, embeds block builders
* per column, and persists plain JSON. Empty fields stay `[]` until the editor
* adds the first row; normalization tolerates legacy singleton rows and blocks.
*/
import { Button, Input, MenuBar, Select } from "@cloudflare/kumo";
import {
ArrowDownIcon,
Expand All @@ -7,17 +14,19 @@ import {
PlusIcon,
TrashIcon,
} from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ChangeEvent } from "react";
import { BlocksField } from "@bnomei/emdash-blocks/admin";
import type { BlockBuilderBlock, BlockBuilderValue } from "@bnomei/emdash-blocks";
import { useAdminLocale } from "./admin-locale";
import { asBlocksArray, isLayoutBuilderRow } from "./render";
import { bentoMessage, formatBentoMessage, localizedString, type BentoI18nConfig } from "./i18n";
import {
DEFAULT_LAYOUT_PATTERN,
columnsToLayout,
layoutColumns as buildLayoutColumns,
layoutColumnsPreservingExisting,
layoutGridSpans,
layoutSpans,
normalizeLayoutPattern,
spanToGridColumns,
Expand Down Expand Up @@ -193,7 +202,7 @@ function normalizeBlock(value: unknown, index: number): BlockBuilderBlock {
}

function normalizeBlocks(value: unknown): BlockBuilderValue {
return Array.isArray(value) ? value.map((item, index) => normalizeBlock(item, index)) : [];
return asBlocksArray(value).map((item, index) => normalizeBlock(item, index));
}

function normalizeColumn(
Expand Down Expand Up @@ -228,8 +237,8 @@ function normalizeRow(value: unknown, rowIndex: number): LayoutBuilderRow {
const columns = layoutColumnsPreservingExisting(
layoutPattern,
existingColumns,
(_index, span) => ({
id: randomId("column"),
(columnIndex, span) => ({
id: `layout-${rowIndex + 1}-column-${columnIndex + 1}`,
span,
blocks: [],
}),
Expand All @@ -243,8 +252,31 @@ function normalizeRow(value: unknown, rowIndex: number): LayoutBuilderRow {
};
}

// Stored ids can collide with positional ids used as React keys; dedupe deterministically.
function uniqueId(id: string, index: number, seen: Set<string>): string {
let candidate = id;
let attempt = 1;
while (seen.has(candidate)) {
candidate = `${id}-${index + 1}-${attempt}`;
attempt += 1;
}
seen.add(candidate);
return candidate;
}

function asLayouts(value: unknown): LayoutBuilderValue {
return Array.isArray(value) ? value.map((item, index) => normalizeRow(item, index)) : [];
const rows = Array.isArray(value) ? value : isLayoutBuilderRow(value) ? [value] : [];
const seenRowIds = new Set<string>();
return rows.map((item, index) => {
const row = normalizeRow(item, index);
const id = uniqueId(row.id, index, seenRowIds);
const seenColumnIds = new Set<string>();
const columns = row.columns.map((column, columnIndex) => {
const columnId = uniqueId(column.id, columnIndex, seenColumnIds);
return columnId === column.id ? column : { ...column, id: columnId };
});
return { ...row, id, columns };
});
}

function compactControlWidth(values: string[], min = 8, max = 42) {
Expand Down Expand Up @@ -294,8 +326,11 @@ function LayoutPatternField({
onCommit: (value: string) => void;
}) {
const [draft, setDraft] = useState(value);
const isFocused = useRef(false);

useEffect(() => {
// Structural row edits change `value` while the editor is typing; skip draft reset until blur.
if (isFocused.current) return;
setDraft(value);
}, [value]);

Expand All @@ -309,7 +344,11 @@ function LayoutPatternField({
style={layoutPatternInputStyle}
value={draft}
onChange={(event: ChangeEvent<HTMLInputElement>) => setDraft(event.currentTarget.value)}
onFocus={() => {
isFocused.current = true;
}}
onBlur={() => {
isFocused.current = false;
const nextLayout = normalizeLayoutPattern(draft, fallbackLayout ?? "");
setDraft(nextLayout);
onCommit(nextLayout);
Expand Down Expand Up @@ -340,6 +379,7 @@ function BlocksMiniEditor({
);
}

/** Primary `bento:layouts` field widget: row/column editor with nested block fields. */
export function LayoutsField({
value,
onChange,
Expand Down Expand Up @@ -408,37 +448,35 @@ export function LayoutsField({
</div>
) : null}
{layouts.map((row, rowIndex) => {
const layoutMenuOptions =
layouts.length > 1
const rowGridSpans = layoutGridSpans(row.columns.map((column) => column.span));
const layoutMenuOptions = [
...(rowIndex > 0
? [
{
icon: <ArrowUpIcon size={14} />,
id: "move-up",
tooltip: bentoMessage("moveLayoutUp", i18n),
onClick: () => moveLayout(rowIndex, rowIndex - 1),
},
]
: []),
...(rowIndex < layouts.length - 1
? [
...(rowIndex > 0
? [
{
icon: <ArrowUpIcon size={14} />,
id: "move-up",
tooltip: bentoMessage("moveLayoutUp", i18n),
onClick: () => moveLayout(rowIndex, rowIndex - 1),
},
]
: []),
...(rowIndex < layouts.length - 1
? [
{
icon: <ArrowDownIcon size={14} />,
id: "move-down",
tooltip: bentoMessage("moveLayoutDown", i18n),
onClick: () => moveLayout(rowIndex, rowIndex + 1),
},
]
: []),
{
icon: <TrashIcon size={14} />,
id: "remove",
tooltip: bentoMessage("removeLayout", i18n),
onClick: () => updateLayouts(layouts.filter((_row, index) => index !== rowIndex)),
icon: <ArrowDownIcon size={14} />,
id: "move-down",
tooltip: bentoMessage("moveLayoutDown", i18n),
onClick: () => moveLayout(rowIndex, rowIndex + 1),
},
]
: [];
: []),
{
icon: <TrashIcon size={14} />,
id: "remove",
tooltip: bentoMessage("removeLayout", i18n),
onClick: () => updateLayouts(layouts.filter((_row, index) => index !== rowIndex)),
},
];

return (
<section key={row.id} style={rowStyle}>
Expand All @@ -456,6 +494,7 @@ export function LayoutsField({
<LayoutPatternField
id={`${id}-${rowIndex}-layout`}
value={row.layout}
fallbackLayout={row.layout || columnsToLayout(row.columns)}
ariaLabel={bentoMessage("layout", i18n)}
onCommit={(layout) =>
updateRow(rowIndex, {
Expand All @@ -476,7 +515,7 @@ export function LayoutsField({
</div>
<div style={columnGridStyle}>
{row.columns.map((column, columnIndex) => {
const gridSpan = spanToGridColumns(column.span);
const gridSpan = rowGridSpans[columnIndex] ?? spanToGridColumns(column.span);
const columnSpanOptions = spanOptions(row.layout);
const columnSpanValue = columnSpanOptions.some((item) => item.value === column.span)
? column.span
Expand Down Expand Up @@ -650,6 +689,7 @@ export function LayoutsField({
);
}

/** EmDash admin field widget registry (`bento:layouts` maps to `LayoutsField`). */
export const fields = {
layouts: LayoutsField,
};
Loading