Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
import * as api from '../lib/bricks-api.js';
import { slugify } from '../lib/slugify.js';
import { validateName } from '../lib/validate.js';
import { applyToSubtree, buildPlan } from '../lib/apply.js';
import { applyToSubtree, buildPlan, computeBlockAssignment } from '../lib/apply.js';
import { suggestElementName, isLayoutContainer, suggestContainerName, SOLE_CHILD_LABEL_OVERRIDES } from '../lib/element-types.js';
import { pickMigratableKeys, pickSkippedKeys } from '../lib/migrate-keys.js';
import Row from './Row.svelte';
Expand All @@ -34,11 +34,10 @@
let { rootId, onClose } = $props();

const MODE_HINTS = {
add: 'Attaches a new BEM class to each element without removing any existing classes.',
rename: 'Replaces the first existing class with a new name, seeding its settings from the old class.',
replace: 'Replaces ALL existing classes with a single new BEM class (clean slate, no settings carried over).',
modifier: 'Appends a --modifier variant. The base class is auto-added to the element if absent.',
migrate: 'Lifts inline element styles (padding, color, typography, etc.) into a new global class.',
add: 'Attaches a new BEM class to each element without removing any existing classes.',
rename: 'Replaces the first existing class with a new name, seeding its settings from the old class.',
replace: 'Replaces ALL existing classes with a single new BEM class (clean slate, no settings carried over).',
migrate: 'Lifts inline element styles (padding, color, typography, etc.) into a new global class.',
};

let mode = $state('add');
Expand All @@ -49,9 +48,17 @@
let toast = $state(null);
let panelEl = $state(null);

const blockName = $derived(slugify(rows[0]?.name ?? ''));
const rootLabel = $derived(rows[0]?.originalLabel ?? '');

/**
* Per-row owning block name for prefix display. Uses the same stack
* walk as buildPlan so the displayed prefix always matches the plan.
*/
const rowBlockNames = $derived.by(() => {
if (rows.length === 0) return new Map();
return computeBlockAssignment(rows, rootId);
});

/**
* For migrate mode the panel-level summary tells the user how many
* keys *would* be lifted across the included rows, and how many are
Expand All @@ -72,25 +79,22 @@
});

/**
* Per-row preview of the post-numbering BEM class name that will
* actually be created/attached on Apply. Built from the same pure
* `buildPlan` step that the apply path uses, so the "use existing
* class" hint never disagrees with what apply does (semantic-review
* issue #7). Reactive on rows + mode + rootId.
*
* Important: `buildPlan` mutates the per-op `suggestedFrom` to
* `'auto-number'` when it renumbers. We don't write that back into
* the source rows here — the next derivation re-reads `row.suggestedFrom`
* from the original state, so the next preview is computed fresh
* from the user's authoritative inputs.
* Per-row preview of all class names that will be created/attached on
* Apply: the base finalClass plus any modifier classes derived from
* row.modifiers. Stored as string[] per row so the row component can
* surface "use existing" hints for any of them. Built from the same
* pure `buildPlan` step that the apply path uses, so hints always
* agree with apply-time behavior.
*/
const previewClassNames = $derived.by(() => {
if (rows.length === 0) return new Map();
const result = buildPlan({ rootId, rows, mode });
const map = new Map();
if (!result.ok) return map;
for (const op of result.ops) {
map.set(op.row.id, op.finalClass);
const names = [op.finalClass];
for (const slug of op.modifierSlugs ?? []) names.push(`${op.finalClass}--${slug}`);
map.set(op.row.id, names);
}
return map;
});
Expand Down Expand Up @@ -128,7 +132,8 @@
bricksType: elementType,
originalLabel: label || (isRoot ? 'block' : 'element'),
name,
modifier: '',
modifiers: [''],
isBlockRoot: false,
include: true,
suggestedFrom,
migrateKeys: pickMigratableKeys(el.settings),
Expand Down Expand Up @@ -213,6 +218,16 @@
return;
}

// Validate any sub-block roots before hitting applyToSubtree.
for (const row of rows) {
if (!row.isBlockRoot || !row.include) continue;
const sv = validateName(slugify(row.name ?? ''));
if (!sv.ok) {
toast = { kind: 'error', message: `Sub-block "${row.originalLabel}": ${sv.reason}` };
return;
}
}

const result = applyToSubtree({ rootId, rows, mode, syncLabels });
if (!result.ok) {
toast = { kind: 'error', message: result.error };
Expand Down Expand Up @@ -248,16 +263,11 @@
<option value="add">Add</option>
<option value="rename">Rename</option>
<option value="replace">Replace</option>
<option value="modifier">Add modifier</option>
<option value="migrate">Migrate ID styles</option>
</select>
</label>
<label class="rebemer-field rebemer-field--inline">
<input
type="checkbox"
bind:checked={syncLabels}
disabled={mode === 'modifier'}
/>
<input type="checkbox" bind:checked={syncLabels} />
<span>Sync labels</span>
</label>
</section>
Expand Down Expand Up @@ -287,10 +297,11 @@
<Row
bind:row={rows[i]}
{mode}
{blockName}
isRoot={row.id === rootId}
blockName={rowBlockNames.get(row.id) ?? ''}
isRoot={row.id === rootId || row.isBlockRoot}
{rootId}
globalClasses={globalClassesAtOpen}
finalClassName={previewClassNames.get(row.id) ?? ''}
finalClassNames={previewClassNames.get(row.id) ?? []}
/>
{/each}
</section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,46 +21,59 @@
* in apply.js's validateMigrate().
*/

import { slugify } from '../lib/slugify.js';

let {
row = $bindable(),
mode,
blockName,
isRoot,
rootId = '',
globalClasses = [],
/**
* The post-numbering BEM class name `apply.js` will actually
* create/attach for this row. Empty string means the row is
* either skipped, has an invalid name, or the plan is otherwise
* incomplete (e.g. modifier mode with empty modifier). The panel
* derives this map from `buildPlan` so it always agrees with
* apply-time behavior.
* All class names apply.js will create/attach for this row: the
* base finalClass plus any modifier classes. Empty array means the
* row is skipped or has an invalid name. The panel derives this from
* `buildPlan` so it always agrees with apply-time behavior.
*/
finalClassName = '',
finalClassNames = [],
} = $props();

const showModifier = $derived(mode === 'modifier');
// Modifier inputs are shown in all modes except migrate.
const showModifier = $derived(mode !== 'migrate');
const showMigrate = $derived(mode === 'migrate');
const showRename = $derived(mode === 'rename');
const prefix = $derived(!isRoot && blockName ? `${blockName}__` : '');

/** Whether this row is a sub-block root (not the overall tree root). */
const isSubBlockRoot = $derived(row.isBlockRoot === true && row.id !== rootId);
/** Sub-block root toggle is available on all non-tree-root rows. */
const canToggleBlockRoot = $derived(row.id !== rootId);

/** Style suggested vs user-typed names differently so the user can
* see at a glance which rows reBEMer pre-filled. */
const isSuggested = $derived(
row.suggestedFrom === 'element-type' || row.suggestedFrom === 'fallback'
);

/**
* Find an existing global class with the same name. Snapshot at
* panel open via the `globalClasses` prop, NOT live — refreshes
* only when the panel is reopened. Used to surface a one-click
* "use existing" affordance (§11.3 `recommendedAction: "attach"`).
* Find an existing global class matching any of this row's preview
* class names. Returns the first match, or null.
*/
const existingClassMatch = $derived.by(() => {
if (!finalClassName || !Array.isArray(globalClasses)) return null;
if (!row.include) return null;
return globalClasses.find(c => c && c.name === finalClassName) || null;
if (!finalClassNames.length || !row.include || !Array.isArray(globalClasses)) return null;
for (const name of finalClassNames) {
const found = globalClasses.find(c => c && c.name === name);
if (found) return found;
}
return null;
});

/** True when all modifier inputs are empty (or there are none). */
const allModifiersEmpty = $derived(
!Array.isArray(row.modifiers) || row.modifiers.every(m => !slugify(m))
);

function markAsUserTyped() {
if (row.suggestedFrom !== 'user') row.suggestedFrom = 'user';
}
Expand All @@ -83,9 +96,18 @@

<div class="rebemer-row__meta">
<span class="rebemer-row__label">{row.originalLabel}</span>
<span class="rebemer-row__type">
{isRoot ? 'BLOCK' : (row.bricksType || 'ELEM').toUpperCase()}
</span>
{#if canToggleBlockRoot}
<button
type="button"
class="rebemer-row__type rebemer-row__type--toggle"
class:rebemer-row__type--block={isSubBlockRoot}
title={isSubBlockRoot ? 'Sub-block root — click to revert to element' : 'Click to promote to block root'}
onclick={() => { row.isBlockRoot = !row.isBlockRoot; }}
disabled={!row.include}
>{isSubBlockRoot ? 'BLOCK' : (row.bricksType || 'ELEM').toUpperCase()}</button>
{:else}
<span class="rebemer-row__type">BLOCK</span>
{/if}
{#if isSuggested && row.include}
<span
class="rebemer-row__hint"
Expand All @@ -108,23 +130,41 @@
/>
</div>
{#if showModifier}
<input
type="text"
class="rebemer-row__modifier"
bind:value={row.modifier}
oninput={markAsUserTyped}
placeholder="modifier"
disabled={!row.include}
spellcheck="false"
autocomplete="off"
/>
<div class="rebemer-row__modifiers">
{#each row.modifiers as _mod, mi (mi)}
<div class="rebemer-row__modifier-row">
<input
type="text"
class="rebemer-row__modifier"
bind:value={row.modifiers[mi]}
placeholder="--modifier"
disabled={!row.include}
spellcheck="false"
autocomplete="off"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{#if row.modifiers.length > 1}
<button
type="button"
class="rebemer-row__modifier-remove"
aria-label="Remove modifier"
onclick={() => { row.modifiers = row.modifiers.filter((_, idx) => idx !== mi); }}
disabled={!row.include}
>×</button>
{/if}
</div>
{/each}
{#if !allModifiersEmpty}
<button
type="button"
class="rebemer-row__modifier-add"
onclick={() => { row.modifiers = [...row.modifiers, '']; }}
disabled={!row.include}
>+ Add modifier</button>
{/if}
</div>
{/if}
</div>

{#if showModifier && row.include && !row.modifier}
<p class="rebemer-row__warn" role="note">Enter a modifier name — the base class will be added automatically if absent.</p>
{/if}

{#if showRename && row.include && row.currentClassCount === 0}
<p class="rebemer-row__warn" role="note">This element has no existing classes. Rename will create a new class instead.</p>
{:else if showRename && row.include && row.currentClassCount > 1}
Expand All @@ -134,11 +174,11 @@
{#if existingClassMatch}
<p class="rebemer-row__recommend" role="note">
{#if showMigrate}
A class named <code>{finalClassName}</code> already exists.
A class named <code>{existingClassMatch.name}</code> already exists.
On Apply, missing style keys will be merged into it. Conflicting
values block the migration — pick a different name or use Add.
{:else}
A class named <code>{finalClassName}</code> already exists
A class named <code>{existingClassMatch.name}</code> already exists
globally. Apply will attach the existing class instead of
creating a duplicate.
{/if}
Expand All @@ -150,7 +190,7 @@
{#if row.migrateKeys?.length}
<span class="rebemer-row__chips-label">Migrate:</span>
{#each row.migrateKeys as key}
<span class="rebemer-chip" title="Will be lifted into {finalClassName || 'the new class'}">{chipLabel(key)}</span>
<span class="rebemer-chip" title="Will be lifted into {finalClassNames[0] || 'the new class'}">{chipLabel(key)}</span>
{/each}
{:else}
<span class="rebemer-row__chips-empty">No migratable keys on this element.</span>
Expand Down
Loading