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
7 changes: 7 additions & 0 deletions .changeset/11684-highlight-share-row.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@object-ui/plugin-detail': patch
---

**The record header's highlight chips share the row's free width and truncate only when it runs out (objectui#11684).** A Product with SKU "QA Widget 1" read "QA Wid…" in the record drawer's highlight row, with most of the row empty. Every chip in `HeaderHighlight` was a fixed column: a 9rem basis (16rem for wide types and for a column being edited), no grow, and a 16rem / 24rem cap. A value wider than the chip was clipped, however much room the row had left.

A chip's basis is now its floor, and the chips grow into the line's free width. Each chip is capped at its own content, so a chip that fits stops growing and the rest of the free width goes to the chips that still need it. Which chips share a line does not change, because line breaking still reads the floors. A short value keeps its 9rem column, and a sparse strip still packs left, because no chip is ever wider than what it shows. A value is cut with an ellipsis only once its line has no free width left. The whole value stays available as the hover title. A column being edited still takes the 16rem floor, and it can grow to its editor's natural width.
24 changes: 22 additions & 2 deletions packages/plugin-detail/src/HeaderHighlight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,32 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
// expand-on-edit) instead of cramming it into a 9rem column.
const useWide = isWide || editorActive;

// How the row's width is shared (objectui#11684). The basis is the
// column's FLOOR, and line breaking still reads it, so which chips
// share a line is what it always was. `grow` then hands the line's
// free width to the columns, and `max-w-max` caps each one at its
// own content: a column that already fits stops growing and the
// rest of the free width goes on to the columns that still need
// it. A value truncates only once its line has no free width left.
//
// The old fixed caps (16rem / 24rem, no grow) cut "QA Widget 1" to
// "QA Wid…" in a 9rem chip with most of the drawer row empty. The
// content cap keeps what those caps were for: no column is ever
// wider than what it shows, so a sparse strip still packs left
// and the hover pencil stays beside its value.
//
// The floor is a min-width as well as the basis, so a short value
// keeps its 9rem column. The `min(…, 100%)` lets a column narrow to
// the row when the row itself is narrower than the floor.
return (
<div
key={field.name}
className={cn(
'group flex flex-col gap-1 min-w-[7rem] px-5 border-l border-border/60 first:border-l-0 first:pl-0',
useWide ? 'basis-[16rem] max-w-[24rem]' : 'basis-[9rem] max-w-[16rem]',
'group flex flex-col gap-1 px-5 border-l border-border/60 first:border-l-0 first:pl-0',
'grow max-w-max',
useWide
? 'basis-[16rem] min-w-[min(16rem,100%)]'
: 'basis-[9rem] min-w-[min(9rem,100%)]',
)}
>
<span className="text-xs font-medium text-muted-foreground">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#11684: the header highlights share the row's free width and
* truncate only when it runs out.
*
* The 2026-10-06 console sweep created a Product with SKU "QA Widget 1" and
* read "QA Wid…" in the record drawer's highlights with most of the row empty.
* Every chip was a fixed column: a `basis-[9rem]` with no grow and a 16rem
* cap. A value wider than the chip's ~79px of content was clipped, however much
* of the row was left.
*
* The distribution now has three parts, and each is pinned below:
* - the basis stays the FLOOR, so line breaking (which chips share a line)
* reads what it always read;
* - `grow` hands the line's free width to the chips;
* - `max-w-max` caps each chip at its own content, so a chip that fits stops
* growing and the free width goes on to the chips that still need it. No
* chip is wider than what it shows, which is what kept a sparse strip
* packed left under the old fixed caps.
*
* happy-dom has no layout engine, so these are class-semantics pins, not pixel
* measurements (the same approach as
* `RecordHighlightsRenderer.phoneWidth-11659.test.tsx`). The Chromium reading
* they rest on is in the pull request that landed this file. It was taken in
* the console's own drawer at its default width, before and after.
*/

import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import * as React from 'react';
import type { HighlightField } from '@object-ui/types';
import { InlineEditProvider, RecordContextProvider } from '@object-ui/react';
import { HeaderHighlight } from '../HeaderHighlight';
import { RecordHighlightsRenderer } from '../renderers/record-highlights';

/** The value the sweep measured, in both chips it was cut in. */
const SKU = 'QA Widget 1';

/** The showcase Product's own field types (`showcase_product`). */
const productSchema = {
fields: {
name: { type: 'text', label: 'Name' },
sku: { type: 'text', label: 'SKU' },
description: { type: 'text', label: 'Description' },
unit_price: { type: 'currency', label: 'Unit Price' },
},
};

/** The fixed caps and the shrink floor this card retired. */
const RETIRED = ['max-w-[16rem]', 'max-w-[24rem]', 'min-w-[7rem]'];

const renderProductStrip = () =>
render(
<RecordContextProvider
objectName="showcase_product"
recordId="P1"
data={{ name: 'QA Widget', sku: SKU, description: SKU, unit_price: 19.99 }}
objectSchema={productSchema}
>
<RecordHighlightsRenderer schema={{ fields: ['sku', 'description', 'unit_price'] }} />
</RecordContextProvider>,
);

/** The chip column (`group flex flex-col …`) around an element. */
const chipAround = (el: Element) => el.closest('div.group') as HTMLElement;

describe('record:highlights shares the row instead of fixing each chip (objectui#11684)', () => {
it('gives every chip carrying the measured SKU a floor, a grow and a content cap', () => {
renderProductStrip();

const chips = screen.getAllByText(SKU).map(chipAround);
// SKU and Description: the sweep read "QA Wid…" in both.
expect(chips).toHaveLength(2);
for (const chip of chips) {
expect(chip).toHaveClass('grow', 'max-w-max', 'basis-[9rem]', 'min-w-[min(9rem,100%)]');
for (const retired of RETIRED) expect(chip).not.toHaveClass(retired);
}
});

it('keeps truncation for the case the line DOES run out, with the whole value as the title', () => {
renderProductStrip();

for (const text of screen.getAllByText(SKU)) {
const clipBox = chipAround(text).querySelector('span.truncate');
expect(clipBox).not.toBeNull();
expect(clipBox).toHaveAttribute('title', SKU);
}
});

it('applies the same three parts to a short chip, so no chip keeps a fixed width', () => {
const { container } = renderProductStrip();

const chips = Array.from(container.querySelectorAll('div.group'));
expect(chips).toHaveLength(3);
for (const chip of chips) {
expect(chip).toHaveClass('grow', 'max-w-max');
for (const retired of RETIRED) expect(chip).not.toHaveClass(retired);
}
});

it('still lays the chips out on a wrapping flex row', () => {
const { container } = renderProductStrip();

const row = container.querySelector('div.group')?.parentElement;
expect(row).toHaveClass('flex', 'flex-wrap');
});
});

describe('the wide floor keeps its role under the shared row (objectui#11684)', () => {
const fields: HighlightField[] = [
{ name: 'owner', label: 'Owner', type: 'text' },
{ name: 'email', label: 'Email', type: 'email' },
];
const data = { owner: 'Alice', email: 'ada@example.com' };

it('gives a wide display type the 16rem floor, the grow and the content cap', () => {
render(<HeaderHighlight fields={fields} data={data} />);

const chip = chipAround(screen.getByText('ada@example.com'));
expect(chip).toHaveClass('grow', 'max-w-max', 'basis-[16rem]', 'min-w-[min(16rem,100%)]');
expect(chip).not.toHaveClass('basis-[9rem]');
for (const retired of RETIRED) expect(chip).not.toHaveClass(retired);
});

it('widens an editing column to the 16rem floor, which still grows (expand-on-edit)', () => {
render(
<InlineEditProvider canEdit>
<HeaderHighlight fields={fields} data={data} />
</InlineEditProvider>,
);
const readChip = chipAround(screen.getByText('Alice'));
expect(readChip).toHaveClass('basis-[9rem]', 'min-w-[min(9rem,100%)]');

fireEvent.doubleClick(screen.getByText('Alice'));

const editChip = chipAround(screen.getByDisplayValue('Alice'));
expect(editChip).toHaveClass('grow', 'max-w-max', 'basis-[16rem]', 'min-w-[min(16rem,100%)]');
expect(editChip).not.toHaveClass('basis-[9rem]');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

/**
* objectstack#5066 — a `percent` chip in `record:highlights` showed a smaller,
* plausible number. The chip clips with `truncate` (`basis-[9rem]`, shrinking
* toward `min-w-[7rem]` as more chips are added, so ~72-104px of content), and
* plausible number. The chip clips with `truncate` (then a fixed `basis-[9rem]`
* column, ~72-104px of content; since objectui#11684 a chip grows into its
* line's free width, but a full line still clips it), and
* the percent display renderer put a `w-16 shrink-0` bar in front of a
* shrinkable value span: bar (64) + gap (8) left the value 0-32px and `truncate`
* removed the rest. Downstream a stored `33.33` was read off the screen as `3`
Expand Down
Loading