DEV: A different approach to breadcrumbs (#27365)

Really fully authored by Jarek, I only made the PR :)

The `DBreadcrumbItem` and `DBreadcrumbContainer` components
introduced in 1239178f49 have
some limitations, mainly that the container has no awareness of
its items, so nothing that requires positional knowledge can
be used. This is needed to use `aria-current` on the last breadcrumb
item, see https://www.w3.org/WAI/ARIA/apg/patterns/breadcrumb/examples/breadcrumb/.

We change `DBreadcrumbItem` to always be a link, removing
the need for `LinkTo`. Then, we introduce a service to keep
track of containers and items (since all items are rendered into
all containers) and make the item itself responsible for registering
to the service, and introduce the needed `aria-current` behaviour.

---------

Co-authored-by: Jarek Radosz <jradosz@gmail.com>
This commit is contained in:
Martin Brennan 2024-06-07 11:31:46 +10:00 committed by GitHub
parent 36dbf06fe9
commit aef3f17b56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 113 additions and 166 deletions

View File

@ -1,6 +1,5 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import DBreadcrumbsContainer from "discourse/components/d-breadcrumbs-container";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import i18n from "discourse-common/helpers/i18n";
@ -28,27 +27,16 @@ export default class AdminPluginConfigPage extends Component {
<div class="admin-plugin-config-page">
<DBreadcrumbsContainer />
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="admin" class={{linkClass}}>
{{i18n "admin_title"}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="adminPlugins" class={{linkClass}}>
{{i18n "admin.plugins.title"}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem as |linkClass|>
<LinkTo
@route="adminPlugins.show"
@model={{@plugin}}
class={{linkClass}}
>
{{@plugin.nameTitleized}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem @route="admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@route="adminPlugins"
@label={{i18n "admin.plugins.title"}}
/>
<DBreadcrumbsItem
@route="adminPlugins.show"
@model={{@plugin}}
@label={{@plugin.nameTitleized}}
/>
<AdminPluginConfigMetadata @plugin={{@plugin}} />

View File

@ -1,16 +1,7 @@
<DBreadcrumbsContainer />
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="admin" class={{linkClass}}>
{{i18n "admin_title"}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="adminPlugins" class={{linkClass}}>
{{i18n "admin.plugins.title"}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem @route="admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem @route="adminPlugins" @label={{i18n "admin.plugins.title"}} />
<div class="admin-plugins-list-container">
{{#if this.model.length}}

View File

@ -1,12 +1,8 @@
<DBreadcrumbsItem as |linkClass|>
<LinkTo
@route="adminPlugins.show.settings"
@model={{@model.plugin}}
class={{linkClass}}
>
{{i18n "admin.plugins.change_settings_short"}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem
@route="adminPlugins.show.settings"
@model={{@model.plugin}}
@label={{i18n "admin.plugins.change_settings_short"}}
/>
<div
class="content-body admin-plugin-config-area__settings admin-detail pull-left"

View File

@ -1,17 +1,37 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import { modifier } from "ember-modifier";
import { eq } from "truth-helpers";
import concatClass from "discourse/helpers/concat-class";
import dBreadcrumbsContainerModifier from "discourse/modifiers/d-breadcrumbs-container-modifier";
const DBreadcrumbsContainer = <template>
<ul
class="d-breadcrumbs"
{{dBreadcrumbsContainerModifier
itemClass=(concatClass "d-breadcrumbs__item" @additionalItemClasses)
linkClass=(concatClass "d-breadcrumbs__link" @additionalLinkClasses)
}}
...attributes
>
{{yield}}
</ul>
</template>;
export default class DBreadcrumbsContainer extends Component {
@service breadcrumbs;
export default DBreadcrumbsContainer;
registerContainer = modifier((element) => {
const container = { element };
this.breadcrumbs.containers.add(container);
return () => this.breadcrumbs.containers.delete(container);
});
get lastItemIndex() {
return this.breadcrumbs.items.size - 1;
}
<template>
<ul {{this.registerContainer}} class="d-breadcrumbs" ...attributes>
{{#each this.breadcrumbs.items as |item index|}}
{{#let item.templateForContainer as |Template|}}
<Template
@linkClass={{concatClass
"d-breadcrumbs__link"
@additionalLinkClasses
}}
aria-current={{if (eq index this.lastItemIndex) "page"}}
class={{concatClass "d-breadcrumbs__item" @additionalItemClasses}}
/>
{{/let}}
{{/each}}
</ul>
</template>
}

View File

@ -1,16 +1,39 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
export default class DBreadcrumbsItem extends Component {
@service breadcrumbsService;
@service breadcrumbs;
@service router;
<template>
{{#each this.breadcrumbsService.containers as |container|}}
{{#in-element container.element insertBefore=null}}
<li class={{container.itemClass}} ...attributes>
{{yield container.linkClass}}
</li>
{{/in-element}}
{{/each}}
</template>
constructor() {
super(...arguments);
this.breadcrumbs.items.add(this);
}
willDestroy() {
super.willDestroy(...arguments);
this.breadcrumbs.items.delete(this);
}
get url() {
if (this.args.model) {
return this.router.urlFor(this.args.route, this.args.model);
} else {
return this.router.urlFor(this.args.route);
}
}
get templateForContainer() {
// Those are evaluated in a different context than the `@linkClass`
const { label } = this.args;
const url = this.url;
return <template>
<li ...attributes>
<a href={{url}} class={{@linkClass}}>
{{label}}
</a>
</li>
</template>;
}
}

View File

@ -1,27 +0,0 @@
import { registerDestructor } from "@ember/destroyable";
import { inject as service } from "@ember/service";
import Modifier from "ember-modifier";
export default class DBreadcrumbsContainerModifier extends Modifier {
@service breadcrumbsService;
container = null;
modify(element, _, { itemClass, linkClass }) {
if (this.container) {
return;
}
this.container = { element, itemClass, linkClass };
this.breadcrumbsService.registerContainer(this.container);
registerDestructor(this, unregisterContainer);
}
}
function unregisterContainer(instance) {
if (instance.container) {
instance.breadcrumbsService.unregisterContainer(instance.container);
}
}

View File

@ -1,40 +0,0 @@
import { tracked } from "@glimmer/tracking";
import { warn } from "@ember/debug";
import Service from "@ember/service";
export default class BreadcrumbsService extends Service {
@tracked containers = [];
#containers = [];
registerContainer(container) {
if (this.#isContainerRegistered(container)) {
warn(
"[BreadcrumbsService] A breadcrumb container with the same DOM element has already been registered before."
);
}
this.#containers = [...this.#containers, container];
this.containers = this.#containers;
}
unregisterContainer(container) {
if (!this.#isContainerRegistered(container)) {
warn(
"[BreadcrumbsService] No breadcrumb container was found with this DOM element."
);
}
this.#containers = this.#containers.filter((registeredContainer) => {
return container.element !== registeredContainer.element;
});
this.containers = this.#containers;
}
#isContainerRegistered(container) {
return this.#containers.some((registeredContainer) => {
return container.element === registeredContainer.element;
});
}
}

View File

@ -0,0 +1,7 @@
import Service from "@ember/service";
import { DeferredTrackedSet } from "discourse/lib/tracked-tools";
export default class Breadcrumbs extends Service {
containers = new DeferredTrackedSet();
items = new DeferredTrackedSet();
}

View File

@ -1,7 +1,9 @@
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
import { module, test } from "qunit";
import DBreadcrumbsContainer from "discourse/components/d-breadcrumbs-container";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import i18n from "discourse-common/helpers/i18n";
module(
"Component | DBreadcrumbsContainer and DBreadcrumbsItem",
@ -9,19 +11,11 @@ module(
setupRenderingTest(hooks);
test("it renders a DBreadcrumbsContainer with multiple DBreadcrumbsItems", async function (assert) {
await render(hbs`
<DBreadcrumbsContainer />
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="admin" class={{linkClass}}>
{{i18n "admin_title"}}
</LinkTo>
</DBreadcrumbsItem>
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="about" class={{linkClass}}>
{{i18n "about.simple_title"}}
</LinkTo>
</DBreadcrumbsItem>
`);
await render(<template>
<DBreadcrumbsContainer />
<DBreadcrumbsItem @route="admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem @route="about" @label={{i18n "about.simple_title"}} />
</template>);
assert
.dom(".d-breadcrumbs .d-breadcrumbs__item .d-breadcrumbs__link")
@ -29,14 +23,13 @@ module(
});
test("it renders a DBreadcrumbsItem with additional link and item classes", async function (assert) {
await render(hbs`
<DBreadcrumbsContainer @additionalLinkClasses="some-class" @additionalItemClasses="other-class" />
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="admin" class={{linkClass}}>
{{i18n "admin_title"}}
</LinkTo>
</DBreadcrumbsItem>
`);
await render(<template>
<DBreadcrumbsContainer
@additionalLinkClasses="some-class"
@additionalItemClasses="other-class"
/>
<DBreadcrumbsItem @route="admin" @label={{i18n "admin_title"}} />
</template>);
assert.dom(".d-breadcrumbs .d-breadcrumbs__item.other-class").exists();
assert
@ -47,15 +40,11 @@ module(
});
test("it renders multiple DBreadcrumbsContainer elements with the same DBreadcrumbsItem links", async function (assert) {
await render(hbs`
<DBreadcrumbsContainer />
<DBreadcrumbsContainer />
<DBreadcrumbsItem as |linkClass|>
<LinkTo @route="admin" class={{linkClass}}>
{{i18n "admin_title"}}
</LinkTo>
</DBreadcrumbsItem>
`);
await render(<template>
<DBreadcrumbsContainer />
<DBreadcrumbsContainer />
<DBreadcrumbsItem @route="admin" @label={{i18n "admin_title"}} />
</template>);
assert.dom(".d-breadcrumbs").exists({ count: 2 });
assert.dom(".d-breadcrumbs .d-breadcrumbs__item").exists({ count: 2 });