Skip to content

Commit 01a88f7

Browse files
[Content List] Column sizing & full-width page layout (elastic#267373)
Co-authored-by: Ryan Keairns <contactryank@gmail.com>
1 parent c652a33 commit 01a88f7

23 files changed

Lines changed: 890 additions & 98 deletions

src/platform/packages/shared/content-management/content_list/kbn-content-list-docs/src/dashboard_listing.stories.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,10 @@ const OriginalStory = () => {
124124
</Filters>
125125
</ContentListToolbar>
126126
<ContentListTable title="Dashboards">
127-
<Column.Name showDescription showTags showStarred width="56%" />
128-
<Column.CreatedBy width="180px" />
129-
<Column.UpdatedAt width="160px" />
130-
<Column.Actions width="96px">
127+
<Column.Name showDescription showTags showStarred />
128+
<Column.CreatedBy />
129+
<Column.UpdatedAt />
130+
<Column.Actions>
131131
<Action.Edit />
132132
<Action.Delete />
133133
</Column.Actions>
@@ -187,10 +187,10 @@ const ProposalStory = () => {
187187
</Filters>
188188
</ContentListToolbar>
189189
<ContentListTable title="Dashboards">
190-
<Column.Name showDescription showTags showStarred width="56%" />
191-
<Column.CreatedBy width="180px" />
192-
<Column.UpdatedAt width="160px" />
193-
<Column.Actions width="128px">
190+
<Column.Name showDescription showTags showStarred />
191+
<Column.CreatedBy />
192+
<Column.UpdatedAt />
193+
<Column.Actions>
194194
<Action.Inspect />
195195
<Action.Edit />
196196
<Action.Delete />

src/platform/packages/shared/content-management/content_list/kbn-content-list-docs/src/files_management.stories.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ const OriginalStory = () => {
165165
return formatBytes(size !== undefined && Number.isFinite(size) ? size : undefined);
166166
}}
167167
/>
168-
<Column.Actions width="96px">
168+
<Column.Actions>
169169
<Action.Delete />
170170
</Column.Actions>
171171
</ContentListTable>
@@ -212,7 +212,7 @@ const ProposalStory = () => {
212212
</Filters>
213213
</ContentListToolbar>
214214
<ContentListTable title="Files">
215-
<Column.Name showDescription width="44%" />
215+
<Column.Name showDescription />
216216
<Column
217217
id="fileKind"
218218
name="Type"
@@ -236,8 +236,8 @@ const ProposalStory = () => {
236236
);
237237
}}
238238
/>
239-
<Column.UpdatedAt width="160px" />
240-
<Column.Actions width="128px">
239+
<Column.UpdatedAt />
240+
<Column.Actions>
241241
<Action.Inspect />
242242
<Action.Delete />
243243
</Column.Actions>

src/platform/packages/shared/content-management/content_list/kbn-content-list-docs/src/maps.stories.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,9 @@ const OriginalStory = () => {
115115
</Filters>
116116
</ContentListToolbar>
117117
<ContentListTable title="Maps">
118-
<Column.Name showDescription showTags width="72%" />
119-
<Column.UpdatedAt width="160px" />
120-
<Column.Actions width="96px">
118+
<Column.Name showDescription showTags />
119+
<Column.UpdatedAt />
120+
<Column.Actions>
121121
<Action.Delete />
122122
</Column.Actions>
123123
</ContentListTable>
@@ -168,10 +168,10 @@ const ProposalStory = () => {
168168
</Filters>
169169
</ContentListToolbar>
170170
<ContentListTable title="Maps">
171-
<Column.Name showDescription showTags width="56%" />
172-
<Column.CreatedBy width="180px" />
173-
<Column.UpdatedAt width="160px" />
174-
<Column.Actions width="128px">
171+
<Column.Name showDescription showTags />
172+
<Column.CreatedBy />
173+
<Column.UpdatedAt />
174+
<Column.Actions>
175175
<Action.Inspect />
176176
<Action.Delete />
177177
</Column.Actions>

src/platform/packages/shared/content-management/content_list/kbn-content-list-docs/src/playground/instructions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ Use the **component palette** below the tree to add columns, actions, or filters
3535
| `Column (Type)` | Custom column rendering `item.type` as a badge — demonstrates the base `Column` API. |
3636
| `Column.Actions` | Row-level action buttons (edit, delete, custom). |
3737

38+
> **Tip:** Each preset (`Column.Name`, `Column.UpdatedAt`, `Column.CreatedBy`, `Column.Starred`, `Column.Actions`) ships with sensible default `width` / `minWidth` / `maxWidth`, so the `width` input in the builder is an **override** — leave it blank to use the default. See the package README's _Defaults_ section for the full table.
39+
3840
## Available actions
3941

4042
Actions are children of `Column.Actions`:

src/platform/packages/shared/content-management/content_list/kbn-content-list-docs/src/playground/playground_state.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ export interface PropDefinition {
9292
defaultValue: unknown;
9393
}
9494

95+
/**
96+
* Each preset (`Column.Name`, `Column.UpdatedAt`, `Column.CreatedBy`,
97+
* `Column.Starred`, `Column.Actions`) ships with a baked-in default `width`
98+
* (and matching `minWidth` / `maxWidth`) — see the package README's
99+
* "Defaults" section for the full table. The `width` input on every column
100+
* below is therefore an **override**: leave it blank (`defaultValue: ''`)
101+
* to use the preset default, or supply a CSS length (e.g. `'14em'`,
102+
* `'200px'`) to override it. The reducer's `UPDATE_COLUMN_PROPS` case
103+
* propagates the override into the rendered preset attributes; an empty
104+
* string is filtered out before reaching the preset, so the default still
105+
* fires.
106+
*/
95107
export const COLUMN_DEFINITIONS: ColumnDefinition[] = [
96108
{
97109
type: 'name',
@@ -102,7 +114,7 @@ export const COLUMN_DEFINITIONS: ColumnDefinition[] = [
102114
{ name: 'showDescription', label: 'showDescription', type: 'boolean', defaultValue: true },
103115
{ name: 'showTags', label: 'showTags', type: 'boolean', defaultValue: false },
104116
{ name: 'showStarred', label: 'showStarred', type: 'boolean', defaultValue: false },
105-
{ name: 'width', label: 'width', type: 'string', defaultValue: '' },
117+
{ name: 'width', label: 'width (override)', type: 'string', defaultValue: '' },
106118
{ name: 'columnTitle', label: 'columnTitle', type: 'string', defaultValue: '' },
107119
],
108120
},
@@ -112,7 +124,7 @@ export const COLUMN_DEFINITIONS: ColumnDefinition[] = [
112124
allowMultiple: false,
113125
defaultProps: {},
114126
configurableProps: [
115-
{ name: 'width', label: 'width', type: 'string', defaultValue: '' },
127+
{ name: 'width', label: 'width (override)', type: 'string', defaultValue: '' },
116128
{ name: 'columnTitle', label: 'columnTitle', type: 'string', defaultValue: '' },
117129
],
118130
},
@@ -122,6 +134,7 @@ export const COLUMN_DEFINITIONS: ColumnDefinition[] = [
122134
allowMultiple: false,
123135
defaultProps: {},
124136
configurableProps: [
137+
// Generic `<Column>` has no preset default; this is a required width.
125138
{ name: 'width', label: 'width', type: 'string', defaultValue: '' },
126139
{ name: 'columnTitle', label: 'columnTitle', type: 'string', defaultValue: '' },
127140
],
@@ -131,15 +144,17 @@ export const COLUMN_DEFINITIONS: ColumnDefinition[] = [
131144
label: 'Column.Starred',
132145
allowMultiple: false,
133146
defaultProps: {},
134-
configurableProps: [{ name: 'width', label: 'width', type: 'string', defaultValue: '' }],
147+
configurableProps: [
148+
{ name: 'width', label: 'width (override)', type: 'string', defaultValue: '' },
149+
],
135150
},
136151
{
137152
type: 'createdBy',
138153
label: 'Column.CreatedBy',
139154
allowMultiple: false,
140155
defaultProps: {},
141156
configurableProps: [
142-
{ name: 'width', label: 'width', type: 'string', defaultValue: '' },
157+
{ name: 'width', label: 'width (override)', type: 'string', defaultValue: '' },
143158
{ name: 'columnTitle', label: 'columnTitle', type: 'string', defaultValue: '' },
144159
],
145160
},
@@ -149,7 +164,7 @@ export const COLUMN_DEFINITIONS: ColumnDefinition[] = [
149164
allowMultiple: false,
150165
defaultProps: {},
151166
configurableProps: [
152-
{ name: 'width', label: 'width', type: 'string', defaultValue: '' },
167+
{ name: 'width', label: 'width (override)', type: 'string', defaultValue: '' },
153168
{ name: 'columnTitle', label: 'columnTitle', type: 'string', defaultValue: '' },
154169
],
155170
},

src/platform/packages/shared/content-management/content_list/kbn-content-list-page/src/kibana_content_list_page.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,27 @@
1010
import React from 'react';
1111
import { render, screen } from '@testing-library/react';
1212
import { EuiButton } from '@elastic/eui';
13+
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
1314
import { KibanaContentListPage } from './kibana_content_list_page';
1415

16+
jest.mock('@kbn/shared-ux-page-kibana-template', () => {
17+
const actual = jest.requireActual('@kbn/shared-ux-page-kibana-template');
18+
// Wrap the real `KibanaPageTemplate` in a jest.fn so callers can assert
19+
// forwarded props (`restrictWidth`, etc.) without losing the real
20+
// sub-component slots (`Header`, `Section`).
21+
const KibanaPageTemplateMock = jest.fn(actual.KibanaPageTemplate);
22+
// Preserve the compound API EUI exposes via `Object.assign` in the
23+
// upstream module.
24+
Object.assign(KibanaPageTemplateMock, actual.KibanaPageTemplate);
25+
return { ...actual, KibanaPageTemplate: KibanaPageTemplateMock };
26+
});
27+
28+
const KibanaPageTemplateMock = KibanaPageTemplate as unknown as jest.Mock;
29+
30+
beforeEach(() => {
31+
KibanaPageTemplateMock.mockClear();
32+
});
33+
1534
describe('KibanaContentListPage', () => {
1635
it('renders children verbatim inside the page template', () => {
1736
render(
@@ -34,6 +53,34 @@ describe('KibanaContentListPage', () => {
3453
expect(screen.getByTestId('maps-page')).toBeInTheDocument();
3554
});
3655

56+
describe('restrictWidth', () => {
57+
it('defaults to `false` so listing pages run full-width', () => {
58+
render(
59+
<KibanaContentListPage>
60+
<div />
61+
</KibanaContentListPage>
62+
);
63+
64+
expect(KibanaPageTemplateMock).toHaveBeenCalledWith(
65+
expect.objectContaining({ restrictWidth: false }),
66+
expect.anything()
67+
);
68+
});
69+
70+
it('forwards an explicit `restrictWidth` to `KibanaPageTemplate`', () => {
71+
render(
72+
<KibanaContentListPage restrictWidth={1024}>
73+
<div />
74+
</KibanaContentListPage>
75+
);
76+
77+
expect(KibanaPageTemplateMock).toHaveBeenCalledWith(
78+
expect.objectContaining({ restrictWidth: 1024 }),
79+
expect.anything()
80+
);
81+
});
82+
});
83+
3784
describe('KibanaContentListPage.Header', () => {
3885
it('renders title, description, and actions', () => {
3986
render(

src/platform/packages/shared/content-management/content_list/kbn-content-list-page/src/kibana_content_list_page.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,28 @@ export interface KibanaContentListPageProps {
158158
* context value, so `Header` and `Section` are always synchronized.
159159
*/
160160
headingId?: string;
161+
/**
162+
* Forwarded to {@link KibanaPageTemplate}'s `restrictWidth` prop.
163+
*
164+
* Defaults to `false` so listing pages run full-width — the
165+
* `ContentListTable` column presets each have their own `maxWidth` cap, so
166+
* trailing whitespace lives inside the table rather than as a centred
167+
* page-section gutter. This intentionally overrides EUI's `true` default
168+
* (which resolves to a 1200px page cap and would prevent column
169+
* `maxWidth` defaults from engaging on wide viewports).
170+
*
171+
* Pass `true` for the EUI default (1200px), or a specific number/string
172+
* to centre the page chrome at a custom width.
173+
*/
174+
restrictWidth?: boolean | number | string;
161175
/** Override the root `data-test-subj`. */
162176
'data-test-subj'?: string;
163177
}
164178

165179
const KibanaContentListPageRoot = ({
166180
children,
167181
headingId: headingIdProp,
182+
restrictWidth = false,
168183
'data-test-subj': dataTestSubj = DEFAULT_DATA_TEST_SUBJ,
169184
}: KibanaContentListPageProps) => {
170185
// Generate a unique id per instance so two `KibanaContentListPage` trees
@@ -179,7 +194,7 @@ const KibanaContentListPageRoot = ({
179194

180195
return (
181196
<KibanaContentListPageContext.Provider value={contextValue}>
182-
<KibanaPageTemplate panelled data-test-subj={dataTestSubj}>
197+
<KibanaPageTemplate panelled restrictWidth={restrictWidth} data-test-subj={dataTestSubj}>
183198
{children}
184199
</KibanaPageTemplate>
185200
</KibanaContentListPageContext.Provider>
@@ -196,6 +211,12 @@ const KibanaContentListPageRoot = ({
196211
* - {@link KibanaContentListPage.Header} — page title, description, and actions.
197212
* - {@link KibanaContentListPage.Section} — a labelled `KibanaPageTemplate.Section`.
198213
*
214+
* The page renders full-width by default (`restrictWidth={false}`). The
215+
* `ContentListTable` column presets each have their own `maxWidth` cap, so
216+
* trailing whitespace lives inside the table rather than as a centred
217+
* page-section gutter. Pass `restrictWidth={true}` (or a specific
218+
* number/string) to opt back into a centred page chrome.
219+
*
199220
* @example
200221
* ```tsx
201222
* <KibanaContentListPage>
@@ -209,6 +230,14 @@ const KibanaContentListPageRoot = ({
209230
* </KibanaContentListPage.Section>
210231
* </KibanaContentListPage>
211232
* ```
233+
*
234+
* @example Restrict the page width (legacy 1200px chrome)
235+
* ```tsx
236+
* <KibanaContentListPage restrictWidth>
237+
* <KibanaContentListPage.Header title="Maps" />
238+
* <KibanaContentListPage.Section>{...}</KibanaContentListPage.Section>
239+
* </KibanaContentListPage>
240+
* ```
212241
*/
213242
export const KibanaContentListPage = Object.assign(KibanaContentListPageRoot, {
214243
Header: KibanaContentListPageHeader,

src/platform/packages/shared/content-management/content_list/kbn-content-list-table/README.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ Use the `Column` compound component to declare columns as children. Order in JSX
2121
const { Column } = ContentListTable;
2222

2323
<ContentListTable title="Dashboards">
24-
<Column.Name width="32em" minWidth="24em" maxWidth="64em" truncateText={false} />
24+
<Column.Name />
25+
<Column.UpdatedAt />
2526
<Column
2627
id="status"
2728
name="Status"
@@ -50,3 +51,42 @@ Column sizing props (`width`, `minWidth`, `maxWidth`, `truncateText`) map to `Eu
5051

5152
Custom columns use the `Column` component with `id`, `name`, and `render` props.
5253

54+
### Defaults
55+
56+
Each preset bakes in `width` / `minWidth` / `maxWidth` defaults so listings look consistent out of the box. The defaults follow a shared three-layer contract:
57+
58+
- **`width`** — preferred English baseline. Drives the look users see most of the time. Every preset ships with one so each column has a deterministic preferred footprint.
59+
- **`minWidth`** — the floor. For text-bearing presets this defaults to the CSS keyword `'max-content'`, which lets the header expand to fit a translated label (e.g. de "Letzte Aktualisierung") without truncation. `min-width` wins over `max-width` per the CSS spec, so the floor never fights the cap. `Column.Name` instead pins a fixed `'18em'` floor because user-supplied titles can be arbitrarily long.
60+
- **`maxWidth`** — the cap. Always pinned to the same value as `width`. The cap is advisory on its own (browsers ignore `max-width` on table cells per the [CSS Tables spec](https://drafts.csswg.org/css-tables/#computing-the-table-width)), but the matching `width` does the actual locking — together they keep the column at its preferred footprint regardless of available slack.
61+
62+
| Preset | `width` | `minWidth` | `maxWidth` | Notes |
63+
|---|---|---|---|---|
64+
| `Column.Name` | `64em` | `18em` | `64em` | `64em` (~896px) is a comfortable reading-line ceiling. The browser shrinks Name from `width` toward `minWidth` first when the viewport gets too narrow to fit every column at its preferred width. |
65+
| `Column.UpdatedAt` | `9.5em` | `'max-content'` | `9.5em` | Locked at `9.5em` in English. Long-text locales expand the column via the `'max-content'` floor. |
66+
| `Column.CreatedBy` | `88px` | `'max-content'` | `88px` | Header (~75px) plus breathing room for the centred 24px avatar. |
67+
| `Column.Starred` | `40px` | `40px` | `40px` | Pure icon column — header is a 16px `EuiIcon`. No locale exposure, so no `'max-content'` floor. |
68+
| `Column.Actions` | _(auto from action count)_ | `'max-content'` | _(equal to derived width)_ | Width still computed by the `36N + 12` formula. `'max-content'` floor lets translated headers expand the column when wider than the icon row. |
69+
70+
#### Trailing whitespace on wide pages
71+
72+
`ContentListTable` renders a CSS `::after` pseudo-cell on every `<tr>` (`tr::after { display: table-cell; content: ''; }`). The browser treats it as an anonymous, unsized table cell for layout purposes, so on a wide page it becomes the only column without an explicit `width` and absorbs all the leftover horizontal space. Populated columns sit left-aligned at their preferred widths and the trailing whitespace lives _after_ the last populated column, all the way to the right edge of the table.
73+
74+
On viewports too narrow to fit all preferred widths the pseudo-cell collapses (no slack to absorb) and the browser shrinks the populated columns proportionally; `Column.Name` shrinks first because it has the most range between its `width` and `minWidth`. No consumer action needed for either case.
75+
76+
There is no DOM, accessibility, or clipboard impact: the rendered table has exactly the columns you declared, screen readers read the correct column count, and copying rows into a spreadsheet doesn't add a trailing tab. See `cssTrailingSpacer` in `content_list_table.tsx` for the rationale and the alternatives that were considered.
77+
78+
#### Wide-viewport `Column.Name` upgrade
79+
80+
On viewports ≥ `2560px` (common 4K external displays), `ContentListTable` widens `Column.Name` from `64em` to `90em` via a media-query CSS override (`cssWideViewportNameWidth` in `content_list_table.tsx`). The trailing pseudo-cell still absorbs whatever horizontal slack remains, so populated sibling columns (`UpdatedAt`, `CreatedBy`, `Actions`, etc.) stay at their preferred footprints; only the Name column / spacer ratio shifts. Because EUI applies `width` / `max-width` as inline styles, the rule uses `!important` and so applies regardless of consumer-supplied `width` overrides — the wide-viewport bump is a cross-cutting layout decision rather than a per-instance default.
81+
82+
#### Overriding defaults
83+
84+
There are two ways to opt out of a baked-in default:
85+
86+
- **Replace** with another value: `<Column.UpdatedAt width="14em" />` swaps the `9.5em` default for `14em`. Useful when you want a different size, not "no constraint".
87+
- **Clear** with explicit `undefined`: `<Column.Name width={undefined} maxWidth={undefined} />` skips the default entirely — no `width` or `max-width` style is emitted, and the column becomes the slack absorber on wide viewports (taking the slack that would otherwise land in the trailing pseudo-cell). Distinct from omitting the prop, which falls back to the documented default.
88+
89+
A consumer-supplied `width` also re-anchors the `maxWidth` default — so `<Column.UpdatedAt width="14em" />` produces `width: 14em; max-width: 14em;` (the column stays locked at the new preferred footprint).
90+
91+
Use `KibanaContentListPage` for the page chrome — it disables EUI's 1200px `restrictWidth` cap by default so the table actually runs to the full page width and the trailing pseudo-cell has slack to absorb. Pass `restrictWidth={true}` (or a specific value) to opt back in.
92+
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
/**
11+
* Layout constants shared by the table shell and individual column cells.
12+
*
13+
* Lives in its own module so column-level files can read it without
14+
* importing `content_list_table.tsx`, which itself imports `./column`
15+
* (a cycle that breaks Jest's module loader and produces
16+
* `Cannot read properties of undefined (reading 'NameColumn')`).
17+
*/
18+
19+
/**
20+
* Viewport width (px) at which `Column.Name` is allowed to grow past its
21+
* default `64em` cap. `2560px` matches a common 4K external display width
22+
* — at that size the default footprint leaves enough trailing whitespace
23+
* that giving some of it back to the title column is a clear win.
24+
*
25+
* Not tied to a named EUI breakpoint because EUI tops out at `xl` (~1200px),
26+
* so the value would have to be a hard-coded magic number either way.
27+
*/
28+
export const WIDE_VIEWPORT_NAME_BREAKPOINT_PX = 2560;

0 commit comments

Comments
 (0)