Item lists

The smallest theme hook anybody has to style, and the one whose markup differs most between core and this theme: `mantra_starter` keeps the `div.item-list` wrapper it inherited from `starterkit_theme` and styles everything through it, where core emits no wrapper at all. The first half of the page is the hook -- what each of its seven variables does to the markup, and the four shapes core puts a list into. The second is the stylesheet: the grid wrapper, `break-inside: avoid`, `--item-list-line-height` and the adjacent-sibling margin, each rendered twice, with the declaration and without it. Every item is made up and nothing is saved.

What the theme emits

Three templates are in play and they do not agree on the markup. Core's own -- `core/modules/system/templates/item-list.html.twig` -- emits no wrapper: the `h3` and the list are siblings in whatever happens to contain them, and there is nothing on the page that is the list *and* its label. `starterkit_theme`, which `mantra_starter` was generated from, wraps both in a `div.item-list` and puts `item-list--` followed by the style name on that wrapper. `mantra_starter` keeps the wrapper and names the two elements inside it, `item-list__label` on the heading and `item-list__list` on the list. Neither of those two classes is styled by anything, in this theme or in core; they exist so that a rule has somewhere to land that is not an element selector, because `h3` and `ul` inside a wrapper are shared with everything else on the page. Everything the theme *does* style, it styles through `.item-list` on the wrapper -- which is why the wrapper matters here in a way it does not in core, and why every declaration in the second half of this page is written against it rather than against the list.

The plainest call there is: `#items` and nothing else. `Drupal\book\Controller\BookController::bookRender()` is this exact array.
  • Article
  • Basic page
  • Task
<div class="item-list">
  <ul class="item-list__list">
    <li>Article</li>
    <li>Basic page</li>
    <li>Task</li>
  </ul>
</div>
.item-list
--item-list-line-height: 1; line-height: var(--item-list-line-height); break-inside: avoid; display: grid
.item-list + .item-list
margin-top: calc(var(--spacing) * 4)

The title, and the empty message

`#title` becomes an `h3` and takes whatever it is given -- a string, a `0`, or a whole render array; the template tests `is not empty`, which in Twig is false for `0`, so a list titled zero keeps its heading. `#empty` is the one that surprises: it renders *instead of* the list rather than inside it, so what comes back is the wrapper, the heading if there is one, and a bare text node where the list would have been. There is no `li`, no `ul`, and nothing carrying `item-list__list` -- a stylesheet that reaches the empty message through the list will never find it. With neither items nor an empty message the template emits nothing at all, not an empty wrapper: `{% if items or empty %}` guards the whole body, which is what lets a caller hand an item list a possibly-empty array without guarding it themselves.

A title over items. `Drupal\Core\Config\Entity\ConfigDependencyDeleteFormTrait::addDependencyListsToForm()` builds one of these per entity type, which is where the adjacent-sibling rule at the foot of this page earns its keep.
Content type
  • Article
  • Basic page
  • Task
<div class="item-list">
  <div class="item-list__label">
    Content type
  </div>
  <ul class="item-list__list">
    <li>Article</li>
    <li>Basic page</li>
    <li>Task</li>
  </ul>
</div>
A title over an empty message. `BookController::bookRender()` passes `#empty` for the same list it passes `#items` to, and gets one or the other.
Content type
No content types available.
<div class="item-list">
  <div class="item-list__label">
    Content type
  </div>
  No content types available.
</div>
Neither. The wrapper is not emitted either, so nothing on the page has to know whether the list was going to have anything in it.

Ordered and unordered

`#list_type` defaults to `ul` and `ol` is the only other value the template does anything sensible with -- it is interpolated straight into the tag, so it is a tag name and not a flag. The class goes on whichever element results, so `item-list__list` is on the `ol` too and a stylesheet does not have to name both. Core reaches for `ol` in four places and they are all sequences rather than sets: the numbered steps `Drupal\system\Controller\DbUpdateController::info()` puts in front of the update runner, the migration overview, and search results. Note that the theme puts nothing on `ol` from `item-list.css`: the numbering, the indent and the marker come from the bare element styles of the theme, or from `presetTypography` when the list is inside formatted text, and this component adds none of it.

The default.
  • Article
  • Basic page
  • Task
<div class="item-list">
  <ul class="item-list__list">
    <li>Article</li>
    <li>Basic page</li>
    <li>Task</li>
  </ul>
</div>
`#list_type: ol`, as `DbUpdateController::info()` builds it.
  1. Back up your database. This process will change values in your database.
  2. Back up your code.
  3. Put your site into maintenance mode.
<div class="item-list">
  <ol class="item-list__list">
    <li>Back up your database. This process will change values in your database.</li>
    <li>Back up your code.</li>
    <li>Put your site into maintenance mode.</li>
  </ol>
</div>

The comma list

`#context` is a free-form bag the template mostly ignores; `list_style` is the one key it reads, and it turns into two classes -- `item-list--comma-list` on the wrapper, `item-list__comma-list` on the list. Core sets it in eleven places and never to anything but `comma-list`: the *Requires* and *Required by* columns of the modules page, the *Used in* column of the field storage list, both views UI reports, and the summary line inline form errors puts at the top of a form. The rules used to arrive from `system/base`. They do not any more: `mantra_starter.info.yml` sets `system/base: {css: {component: {css/components/item-list.module.css: false}}}` and re-ships the same four rules from `libraries/global/component/item-list.css`, so they aggregate with the rest of the theme's component CSS instead of arriving separately ahead of it. Drupal 11.3 splits that file out of `system/base` into a library of its own, `system/item-list`, attached from `template_preprocess_item_list()` so it only loads on a page that has an item list on it. When this site reaches 11.3 the override above will match nothing and has to name the new library instead -- and it will keep matching nothing silently, because `libraries-override` does not complain about a file that is no longer there.

The *Used in* column of `Drupal\field_ui\FieldStorageConfigListBuilder::buildRow()`, which is where most people first meet this.
  • Article
  • Basic page
  • Task
<div class="item-list--comma-list item-list">
  <ul class="item-list__comma-list item-list__list">
    <li>Article</li>
    <li>Basic page</li>
    <li>Task</li>
  </ul>
</div>
.item-list__comma-list, .item-list__comma-list li
display: inline
.item-list__comma-list
margin: 0; padding: 0
.item-list__comma-list li::after
content: ", "
.item-list__comma-list li:last-child::after
content: ""

One of those four declarations no longer does what it says. `display: inline` is set on the list, but the list is a child of `.item-list`, and `.item-list` is now `display: grid` -- so the list is a grid item, and a grid item's `display` is blockified: `inline` computes to `block`. The commas survive, because they come from `li::after` and the `li` are children of the list rather than of the grid, but the list itself takes a line of its own. `Drupal\inline_form_errors\FormErrorHandler::displayErrorMessages()` is the call site that minds: it renders "3 errors have been found: " and the comma list into a single message string and expects the list to finish the sentence.

As the theme renders it now: the sentence stops and the list starts on the next line.
3 errors have been found:
  • Title
  • Body
  • Authored by
3 errors have been found:
<div class="item-list--comma-list item-list">
  <ul class="item-list__comma-list item-list__list">
    <li>Title</li>
    <li>Body</li>
    <li>Authored by</li>
  </ul>
</div>
The same array with `display: grid` taken off the wrapper by this page's own stylesheet, so the list is no longer a grid item and its `display: inline` is honoured. This is what the call site was written against.
3 errors have been found:
  • Title
  • Body
  • Authored by
3 errors have been found:
<div class="item-list--comma-list item-list">
  <ul class="item-list__comma-list item-list__list">
    <li>Title</li>
    <li>Body</li>
    <li>Authored by</li>
  </ul>
</div>

Nesting, and the shorthand for it

`template_preprocess_item_list()` inherits downwards. A child of an item that is not a fully-specified render array -- no `#type`, no `#theme`, no `#markup` -- is taken to be a nested list: its own children that are not properties are moved into `#items` for it, and it is handed the parent's `#theme`, theme hook suggestions and all, and the parent's `#list_type`. So the two demos below are the same markup written two ways, and the shorthand is the one the docblock in `theme.inc` is advertising. The thing to notice in the output is that a nested list is a whole `div.item-list` inside the `li`, not a bare `ul`: that is what puts the four declarations of the second half of this page inside the nesting as well as around it, and it is why a nested list that follows another nested list gets the adjacent-sibling margin.

Written out: the child names its own `#theme`.
  • Article
  • Basic page
    • Title
    • Body
  • Task
<div class="item-list">
  <ul class="item-list__list">
    <li>Article</li>
    <li>
      Basic page
      <div class="item-list">
        <ul class="item-list__list">
          <li>Title</li>
          <li>Body</li>
        </ul>
      </div>
    </li>
    <li>Task</li>
  </ul>
</div>
The shorthand: the child names nothing, so the preprocess gives it the parent's theme hook and moves its children into `#items` for it. Identical output.
  • Article
  • Basic page
    • Title
    • Body
  • Task
<div class="item-list">
  <ul class="item-list__list">
    <li>Article</li>
    <li>
      Basic page
      <div class="item-list">
        <ul class="item-list__list">
          <li>Title</li>
          <li>Body</li>
        </ul>
      </div>
    </li>
    <li>Task</li>
  </ul>
</div>

Three sets of attributes, three elements

`#wrapper_attributes` on the array goes on the wrapper `div`; `#attributes` goes on the list element; `#wrapper_attributes` on an *item* goes on the `li` of that item. The last is the one core actually uses -- `DbUpdateController::results()` marks every finished update `success` or `failure` that way, `Drupal\user\Form\UserPermissionsForm` puts the module and permission classes on the rows, and `Drupal\menu_ui\MenuForm` marks the enabled checkbox. An item has to be a render array to carry them, which is why core writes an item as a `#markup` and a `#wrapper_attributes` beside it rather than as a plain string. The wrapper set is worth knowing about for one reason beyond classes: `--item-list-line-height` is a custom property on the wrapper, so a `style` attribute there is the supported way to change the leading of a single list without writing a selector for it. That is the last section of this page.

Update 9301
  • Converted 12 fields.
  • Failed: field_legacy_reference.
<div class="update-results item-list" id="update-9301">
  <div class="item-list__label">
    Update 9301
  </div>
  <ul class="update-results__list item-list__list">
    <li class="success">Converted 12 fields.</li>
    <li class="failure">Failed: field_legacy_reference.</li>
  </ul>
</div>

Where core puts them

Four shapes, and they are not interchangeable. Inside a table cell, where the list is the value of a column and the comma style keeps the row one line high. Inside a `details`, as several titled lists in a row, which is the only place in core where two item lists are genuinely adjacent siblings. As a set of theme hook suggestions, which is how search results get their own template and their own classes. And rendered to a string and handed to `Messenger`, which is what `update.manager.inc` and `Drupal\config\Form\ConfigSync` both do -- worth knowing because a message is rendered outside the render context of the page, so anything the list would have bubbled up is discarded on the way.

A comma list per cell, as `Drupal\views_ui\Controller\ViewsUIController::reportFields()` builds the *Used in* column.
Field name Used in
field_body
  • content
  • frontpage
field_tags
  • taxonomy_term
  • content_recent
<div class="table-scroll">
  <table class="responsive-enabled" data-striping="1">
    <thead>
      <tr>
        <th>Field name</th>
        <th>Used in</th>
      </tr>
    </thead>
    <tbody>
      <tr class="odd">
        <td>field_body</td>
        <td>
          <div class="item-list--comma-list item-list">
            <ul class="item-list__comma-list item-list__list">
              <li>content</li>
              <li>frontpage</li>
            </ul>
          </div>
        </td>
      </tr>
      <tr class="even">
        <td>field_tags</td>
        <td>
          <div class="item-list--comma-list item-list">
            <ul class="item-list__comma-list item-list__list">
              <li>taxonomy_term</li>
              <li>content_recent</li>
            </ul>
          </div>
        </td>
      </tr>
    </tbody>
  </table>
</div>
One titled list per entity type, as `ConfigDependencyDeleteFormTrait::addDependencyListsToForm()` builds the *Configuration deletions* pane of the module uninstall confirmation. The three lists are siblings, so the two gaps between them are the adjacent-sibling rule and not a margin any of the lists has.
Configuration deletions
The listed configuration will be deleted.
Field
  • node.article.field_tags
  • node.page.field_tags
View
  • taxonomy_term
Entity view display
  • node.article.default
  • node.article.teaser
<details class="js-form-wrapper form-wrapper details" open="open">
  <summary role="button" aria-expanded="true">Configuration deletions</summary>
  <div class="details-wrapper clearfix">
    <div class="details-description">
      The listed configuration will be deleted.
    </div>
    <div class="item-list">
      <div class="item-list__label">
        Field
      </div>
      <ul class="item-list__list">
        <li>node.article.field_tags</li>
        <li>node.page.field_tags</li>
      </ul>
    </div>
    <div class="item-list">
      <div class="item-list__label">
        View
      </div>
      <ul class="item-list__list">
        <li>taxonomy_term</li>
      </ul>
    </div>
    <div class="item-list">
      <div class="item-list__label">
        Entity view display
      </div>
      <ul class="item-list__list">
        <li>node.article.default</li>
        <li>node.article.teaser</li>
      </ul>
    </div>
  </div>
</details>
`Drupal\search\Controller\SearchController::view()`: an array of theme hook suggestions rather than a hook, so the theme can answer at whichever level it has a template for. `mantra_starter` has `item-list--search-results.html.twig`, which extends the base override and adds `search-results` and the plugin name followed by `-results` to the list -- the plugin name it builds the second class from is read from `#context`, which is the other thing that key is for.
  1. Getting started with the editor
  2. Uploading an image
<div class="item-list">
  <ol class="search-results node_search-results item-list__list">
    <li>Getting started with the editor</li>
    <li>Uploading an image</li>
  </ol>
</div>
`Drupal\user\Plugin\Block\UserLoginBlock::build()`: two links under the login form, as an item list of `#type: link` elements rather than as a `links` element. The difference matters -- `links` produces `ul.links` and this theme lays that out with flex; `item_list` produces the wrapper and its grid.
<div class="item-list">
  <ul class="item-list__list">
    <li><a href="/ui-examples/item_lists">Create new account</a></li>
    <li><a href="/ui-examples/item_lists">Reset your password</a></li>
  </ul>
</div>

The wrapper is a grid

`display: grid` on a wrapper holding a heading and a list is not there for the layout -- one column of two rows is what a block would have done. It is there for the two side effects. A grid container establishes a grid formatting context, so margins inside it never collapse: the heading's bottom margin and the list's top margin both apply and add up, where under a block wrapper the larger of the two would have swallowed the other. That is what let the two rules this file used to carry go -- `> h3 { margin-bottom: .5rem }` and `li + li { margin-top: .25rem }` -- and their removal is the change this page was written for. The second effect is blockification, which is the comma list further up: every child of a grid container has its `display` blockified, so a list that asks to be `inline` is `block` instead. The pair below is the same render array twice, the second with `display: block` put back by this page's own stylesheet.

As the theme ships it: a grid, so the gap under the heading is the heading's own margin and the list's, uncollapsed.
Content type
  • Article
  • Basic page
  • Task
<div class="item-list">
  <div class="item-list__label">
    Content type
  </div>
  <ul class="item-list__list">
    <li>Article</li>
    <li>Basic page</li>
    <li>Task</li>
  </ul>
</div>
.item-list
display: grid
The same list as a block. The two margins collapse into one, so the heading sits closer to the list.
Content type
  • Article
  • Basic page
  • Task
<div class="item-list">
  <div class="item-list__label">
    Content type
  </div>
  <ul class="item-list__list">
    <li>Article</li>
    <li>Basic page</li>
    <li>Task</li>
  </ul>
</div>

A list is not broken across columns

`break-inside: avoid` has no effect on an ordinary page and is the whole point of the wrapper on one that has columns. The page it was added for is the style guide: `libraries/modules/styleguide.css` sets `#styleguide-header.styleguide { columns: 18rem }`, and a column of item lists that is allowed to break puts a heading at the foot of one column and its list at the head of the next, where it reads as the heading of whatever list follows. The declaration is on the wrapper rather than on the list because the heading and the list have to be kept together, and only the wrapper is both of them. Both demos below are the same two lists in the same box, given two columns and a height short enough that something has to break; the second has `break-inside: auto` put back by this page's own stylesheet.

As the theme ships it: each list is whole, and a column ends early rather than splitting one.
Content type
  • Article
  • Basic page
  • Task
  • Event
Text format
  • Basic HTML
  • Full HTML
  • Restricted HTML
  • Plain text
<div class="item-list-demo__columns">
  <div class="item-list">
    <div class="item-list__label">
      Content type
    </div>
    <ul class="item-list__list">
      <li>Article</li>
      <li>Basic page</li>
      <li>Task</li>
      <li>Event</li>
    </ul>
  </div>
  <div class="item-list">
    <div class="item-list__label">
      Text format
    </div>
    <ul class="item-list__list">
      <li>Basic HTML</li>
      <li>Full HTML</li>
      <li>Restricted HTML</li>
      <li>Plain text</li>
    </ul>
  </div>
</div>
.item-list
break-inside: avoid
The same box allowed to break. The second list is cut across the column boundary -- part of it at the foot of the first column, the rest at the head of the next, where it reads as belonging to whatever heading is above it.
Content type
  • Article
  • Basic page
  • Task
  • Event
Text format
  • Basic HTML
  • Full HTML
  • Restricted HTML
  • Plain text
<div class="item-list-demo__columns item-list-demo__columns--split">
  <div class="item-list">
    <div class="item-list__label">
      Content type
    </div>
    <ul class="item-list__list">
      <li>Article</li>
      <li>Basic page</li>
      <li>Task</li>
      <li>Event</li>
    </ul>
  </div>
  <div class="item-list">
    <div class="item-list__label">
      Text format
    </div>
    <ul class="item-list__list">
      <li>Basic HTML</li>
      <li>Full HTML</li>
      <li>Restricted HTML</li>
      <li>Plain text</li>
    </ul>
  </div>
</div>

The leading, and how to change it

The wrapper sets `--item-list-line-height: 1` and reads it back on the next line. Written that way rather than as a bare `line-height: 1` for one reason: the property is on the wrapper, so it can be overridden per list from a `style` attribute -- which an item list can carry through `#wrapper_attributes` -- or from a selector one level up, without either of them having to restate the `line-height` declaration or beat it on specificity. A line height of `1` is the right default for what item lists mostly hold, which is short labels: machine names, module names, entity ids. It is the wrong one for the case where an item is a sentence, because the two lines of a wrapped item then touch, and there is nothing between one item and the next either. Both demos below hold the same three long items.

As the theme ships it.
  • Back up your database. This process will change values in your database, and there is no undo.
  • Put your site into maintenance mode, so that nobody reads a half-updated page.
  • Install your new files in the appropriate location, as described in the handbook.
<div class="item-list">
  <ul class="item-list__list">
    <li>Back up your database. This process will change values in your database, and there is no undo.</li>
    <li>Put your site into maintenance mode, so that nobody reads a half-updated page.</li>
    <li>Install your new files in the appropriate location, as described in the handbook.</li>
  </ul>
</div>
.item-list
--item-list-line-height: 1; line-height: var(--item-list-line-height)
The same list with the property set on the wrapper through `#wrapper_attributes`, which is the intended way to do it and needs no stylesheet at all.
  • Back up your database. This process will change values in your database, and there is no undo.
  • Put your site into maintenance mode, so that nobody reads a half-updated page.
  • Install your new files in the appropriate location, as described in the handbook.
<div style="--item-list-line-height: 1.5" class="item-list">
  <ul class="item-list__list">
    <li>Back up your database. This process will change values in your database, and there is no undo.</li>
    <li>Put your site into maintenance mode, so that nobody reads a half-updated page.</li>
    <li>Install your new files in the appropriate location, as described in the handbook.</li>
  </ul>
</div>

Two lists in a row

`.item-list + .item-list` is the only spacing the component has, and it is spacing *between* lists rather than around one: a single item list on a page has no margin of its own from this stylesheet at all, and takes whatever the thing containing it gives it. The selector is the adjacent sibling combinator, so it wants two wrappers next to each other with nothing between them -- which, since the theme's template wraps every list including a nested one, reaches the *Configuration deletions* pane further up this page and a nested list that follows another nested list inside the same `li`. Put anything at all between two lists -- a paragraph, a heading, an empty `div` a layout added -- and the rule stops matching and the gap disappears. That is the second demo, and it is worth having seen once, because the markup that causes it is usually a wrapper somebody added for an unrelated reason.

Adjacent: the gap between the two is the rule firing.
Field
  • node.article.field_tags
  • node.page.field_tags
View
  • taxonomy_term
<div class="item-list">
  <div class="item-list__label">
    Field
  </div>
  <ul class="item-list__list">
    <li>node.article.field_tags</li>
    <li>node.page.field_tags</li>
  </ul>
</div>
<div class="item-list">
  <div class="item-list__label">
    View
  </div>
  <ul class="item-list__list">
    <li>taxonomy_term</li>
  </ul>
</div>
.item-list + .item-list
margin-top: calc(var(--spacing) * 4)
The same two lists with one paragraph between them. The rule no longer matches, so the second list has no top margin and sits as close to the paragraph as the paragraph allows.
Field
  • node.article.field_tags
  • node.page.field_tags

The listed configuration will be deleted.

View
  • taxonomy_term
<div class="item-list">
  <div class="item-list__label">
    Field
  </div>
  <ul class="item-list__list">
    <li>node.article.field_tags</li>
    <li>node.page.field_tags</li>
  </ul>
</div>
<p>The listed configuration will be deleted.</p>
<div class="item-list">
  <div class="item-list__label">
    View
  </div>
  <ul class="item-list__list">
    <li>taxonomy_term</li>
  </ul>
</div>

Every rule, and where it lives

Six rules, and it takes four files to explain them. `libraries/global/component/item-list.css` is the source and is written in UnoCSS directives, so `@apply grid break-inside-avoid` is what is on disk and the declarations below are what the build turns it into -- read `dist/component.css` to see the real thing, and remember that editing the source changes nothing until `ddev theme build` has run and the site's CSS aggregate has been rebuilt. `libraries/global/component.css` is what imports it. `mantra_starter.info.yml` is where the last four rules come from being the theme's rather than core's, through the `libraries-override` that switches off `system/base`'s copy of `item-list.module.css`. And `templates/dataset/item-list.html.twig` is what puts `.item-list` on an element in the first place, which core's own template does not.

.item-list
--item-list-line-height: 1; line-height: var(--item-list-line-height); break-inside: avoid; display: grid
.item-list + .item-list
margin-top: calc(var(--spacing) * 4)
.item-list__comma-list, .item-list__comma-list li
display: inline
.item-list__comma-list
margin: 0; padding: 0
.item-list__comma-list li::after
content: ", "
.item-list__comma-list li:last-child::after
content: ""
The typography example, for the lists inside formatted text, which none of this touches