Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
16 changes: 16 additions & 0 deletions src/components/cc-breadcrumbs/cc-breadcrumbs.events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { CcEvent } from '../../lib/events.js';

/**
* Dispatched when a breadcrumb item is clicked.
* @extends {CcEvent<{path: Array<string>}>}
*/
export class CcBreadcrumbClickEvent extends CcEvent {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: tough one but I wonder if removing the "s" here is a good idea, it makes it very easy to get the event name wrong since the component name contains a "s" (but I do understand why it's singlar here 🤷)

Or we could use a totally different event name like cc-navigation-request or something like that 🤔

static TYPE = 'cc-breadcrumb-click';

/**
* @param {{path: Array<string>}} details
*/
constructor(details) {
super(CcBreadcrumbClickEvent.TYPE, details);
}
}
127 changes: 127 additions & 0 deletions src/components/cc-breadcrumbs/cc-breadcrumbs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { css, html, LitElement } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import '../cc-link/cc-link.js';
import { CcBreadcrumbClickEvent } from './cc-breadcrumbs.events.js';

/**
* @import { CcBreadcrumbsItem } from './cc-breadcrumbs.types.js'
*/

/**
* A breadcrumb navigation component that displays a hierarchical path of items.
*
* Each breadcrumb item is clickable except the last item.
* Clicking a breadcrumb item dispatches a `CcBreadcrumbClickEvent` with the path to that item.
*
* WARNING: When a breadcrumb item's `label` is an empty string, you MUST set the `iconA11yName` value to provide accessible text for the icon.
* Otherwise, the link will have no discernible text, which is a serious accessibility issue (WCAG: Links must have discernible text).
*
* @cssdisplay block
*/
export class CcBreadcrumbs extends LitElement {
static get properties() {
return {
disabled: { type: Boolean },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix: this property is not implemented

items: { type: Array },
};
}

static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };

constructor() {
super();

/** @type {Array<CcBreadcrumbsItem>} Set the items. */
this.items = [];
}

/**
* @param {Event} event
* @param {Array<string>} path
*/
_onItemClick(event, path) {
event.preventDefault();
event.stopPropagation();
this.dispatchEvent(new CcBreadcrumbClickEvent({ path }));
}

render() {
/** @type {Array<string>} */
let current = [];
const parts = this.items ?? [];

return html`<ul>
${parts.map((item, index) => {
let path = [...current, item.value];
current = path;
const isLast = index === parts.length - 1;
const label = item.label ?? item.value;

const itemView =
item.icon != null
? html`<span class="item-wrapper"
><cc-icon .icon=${item.icon} .a11yName=${item.iconA11yName}></cc-icon>${label}</span
>`
: label;

return html`<li aria-current=${ifDefined(isLast ? 'page' : undefined)}>
${isLast ? itemView : ''}
${!isLast
? html`<cc-link href="#" @click=${/** @param {Event} event */ (event) => this._onItemClick(event, path)}
>${itemView}</cc-link
>`
: ''}
</li>`;
})}
</ul>`;
}

static get styles() {
return [
// language=CSS
css`
:host {
display: block;

--gap: 0.5em;
}

ul {
align-items: end;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix: this breaks alignment for breadcrumbs with no icons.

Image

To fix your issue, you can:

  1. set display: inline-flex; on cc-link (this will remove the blank space below the icon that led you to choose align-items: end in the first place),
  2. remove this align-items (or replace end with center but I don't think you need it anyway 🤔)
Image Image

display: flex;
flex-wrap: wrap;
gap: var(--gap);
list-style: none;
margin: 0;
padding: 0;
}

li {
align-items: center;
display: flex;
gap: var(--gap);
}

.item-wrapper {
align-items: center;
display: flex;
gap: 0.2em;
min-width: 0;
}

cc-icon {
flex-shrink: 0;
}

li + li::before {
color: var(--cc-color-text-disabled, #ccc);
content: '/';
font-size: 1.2em;
font-weight: bold;
}
`,
];
}
}

window.customElements.define('cc-breadcrumbs', CcBreadcrumbs);
100 changes: 100 additions & 0 deletions src/components/cc-breadcrumbs/cc-breadcrumbs.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { html, render } from 'lit';
import { iconRemixHome_3Line as iconHouse } from '../../assets/cc-remix.icons.js';
import { makeStory } from '../../stories/lib/make-story.js';
import './cc-breadcrumbs.js';

export default {
tags: ['autodocs'],
title: '🧬 Atoms/<cc-breadcrumbs>',
component: 'cc-breadcrumbs',
};

/**
* @import { CcBreadcrumbs } from './cc-breadcrumbs.js'
*/

const conf = {
component: 'cc-breadcrumbs',
};

export const defaultStory = makeStory(conf, {
/** @type {Partial<CcBreadcrumbs>[]} */
items: [
{
items: [{ value: 'first' }, { value: 'second' }, { value: 'third' }],
},
],
});

export const withIconStory = makeStory(conf, {
/** @type {Partial<CcBreadcrumbs>[]} */
items: [
{
items: [{ value: 'home', icon: iconHouse }, { value: 'first' }, { value: 'second' }, { value: 'third' }],
},
],
});

export const withNoLabelStory = makeStory(conf, {
/** @type {Partial<CcBreadcrumbs>[]} */
items: [
{
items: [
{ value: 'home', label: '', icon: iconHouse, iconA11yName: 'Home' },
{ value: 'first' },
{ value: 'second' },
{ value: 'third' },
],
},
],
});

export const withSmallContainerStory = makeStory(conf, {
dom: /** @param {HTMLElement} container */ (container) => {
let width = '25';

/**
* @param {Event & { target: { value: string }}} e
*/
function _onChange(e) {
width = e.target.value;
refresh();
}

const items = [{ value: 'home', icon: iconHouse }, { value: 'first' }, { value: 'second' }, { value: 'third' }];

function refresh() {
render(
html`
<div class="main">
<div class="form">
<label for="width">Width: (${width}%)</label>
<input type="range" id="width" .value=${String(width)} min="3" max="100" @input=${_onChange} />
</div>
<cc-breadcrumbs .items=${items} style="width:${width}%"></cc-breadcrumbs>
</div>
`,
container,
);
}

refresh();
},
// language=CSS
css: `
cc-breadcrumbs {
border: 2px solid #ccc;
}
.main {
display: flex;
flex-direction: column;
gap: 1em;
}
.form {
display: flex;
flex-direction: column;
gap: 0.25em;
align-items: start;
}
`,
});
12 changes: 12 additions & 0 deletions src/components/cc-breadcrumbs/cc-breadcrumbs.types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IconModel } from '../common.types.js';

export interface CcBreadcrumbsItem {
value: string;
label?: string;
icon?: IconModel;
/**
* WARNING: When `label` is an empty string, you MUST set this value to provide accessible text for the icon.
* Otherwise, the link will have no discernible text, which is a serious accessibility issue (WCAG: Links must have discernible text).
*/
iconA11yName?: string;
}
30 changes: 20 additions & 10 deletions src/components/cc-grid/cc-grid.js
Original file line number Diff line number Diff line change
Expand Up @@ -558,16 +558,26 @@ export class CcGrid extends LitElement {
if (cell.type === 'link') {
return {
focusable: !skeleton,
template: html`<cc-button
tabindex=${hasFocus ? '0' : '-1'}
data-focusable=${skeleton ? 'false' : 'true'}
link
.icon=${cell.icon}
?skeleton=${skeleton}
?disabled=${this.disabled}
@cc-click=${() => this._onCellClick(rowIndex, columnIndex, cell.onClick)}
>${cell.value}</cc-button
>`,
template: html`<div class="icon-label">
${cell.icon != null ? html`<cc-icon .icon=${cell.icon} ?skeleton=${skeleton}></cc-icon>` : ''}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: are we sure we'll always have decorative icons or should we expose & use a cell.iconA11yName?

<cc-button
tabindex=${hasFocus ? '0' : '-1'}
data-focusable=${skeleton ? 'false' : 'true'}
link
?skeleton=${skeleton}
?disabled=${this.disabled}
@cc-click=${() => this._onCellClick(rowIndex, columnIndex, cell.onClick)}
>${cell.value}</cc-button
>
${cell.enableCopyToClipboard
? html`<cc-clipboard
tabindex=${hasFocus ? '0' : '-1'}
data-focusable=${skeleton ? 'false' : 'true'}
?skeleton=${skeleton}
value=${cell.value}
></cc-clipboard>`
: ''}
</div>`,
};
}

Expand Down
23 changes: 21 additions & 2 deletions src/components/cc-grid/cc-grid.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,27 @@ const conf = {
* @returns {CcGridCell}
*/
const simpleCellAt = (_, row, col) => {
if (col === 1) {
if (col === 0) {
return {
type: 'link',
value: `item ${row + 1} col ${col + 1}`,
icon: iconLink,
onClick: () => {},
};
}
if (col === 1) {
return {
type: 'link',
value: `item ${row + 1} col ${col + 1}`,
onClick: () => {},
enableCopyToClipboard: true,
};
}
return {
type: 'text',
value: `item ${row + 1} col ${col + 1}`,
icon: col === 0 ? iconHeart : null,
enableCopyToClipboard: col === 0,
enableCopyToClipboard: col === 3,
};
};

Expand All @@ -68,6 +76,7 @@ export const defaultStory = makeStory(conf, {
{ header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)' },
{ header: 'Column2', cellAt: simpleCellAt, width: 'max-content' },
{ header: 'Column3', cellAt: simpleCellAt, width: 'max-content' },
{ header: 'Column4', cellAt: simpleCellAt, width: 'max-content' },
{ header: '', cellAt: buttonCellAt },
],
items: items(),
Expand All @@ -84,6 +93,7 @@ export const withSmallNumberOfItems = makeStory(conf, {
{ header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)' },
{ header: 'Column2', cellAt: simpleCellAt, width: 'max-content' },
{ header: 'Column3', cellAt: simpleCellAt, width: 'max-content' },
{ header: 'Column4', cellAt: simpleCellAt, width: 'max-content' },
{ header: '', cellAt: buttonCellAt },
],
items: items(5),
Expand All @@ -100,6 +110,7 @@ export const withFullySkeleton = makeStory(conf, {
{ header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)' },
{ header: 'Column2', cellAt: simpleCellAt, width: 'max-content' },
{ header: 'Column3', cellAt: simpleCellAt, width: 'max-content' },
{ header: 'Column4', cellAt: simpleCellAt, width: 'max-content' },
{ header: '', cellAt: buttonCellAt },
],
items: items(),
Expand Down Expand Up @@ -129,6 +140,11 @@ export const withPartiallySkeleton = makeStory(conf, {
cellAt: (o, row, col) => ({ ...simpleCellAt(o, row, col), skeleton: row > 2 }),
width: 'max-content',
},
{
header: 'Column4',
cellAt: (o, row, col) => ({ ...simpleCellAt(o, row, col), skeleton: row > 2 }),
width: 'max-content',
},
{ header: '', cellAt: (_, row) => ({ ...buttonCellAt(), skeleton: row > 2 }) },
],
items: items(5),
Expand All @@ -145,6 +161,7 @@ export const withSortableColumns = makeStory(conf, {
{ header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)', sort: 'none' },
{ header: 'Column2', cellAt: simpleCellAt, width: 'max-content', sort: 'none' },
{ header: 'Column3', cellAt: simpleCellAt, width: 'max-content' },
{ header: 'Column4', cellAt: simpleCellAt, width: 'max-content' },
{ header: '', cellAt: buttonCellAt },
],
items: items(),
Expand All @@ -161,6 +178,7 @@ export const withSortedColumns = makeStory(conf, {
{ header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)', sort: 'asc' },
{ header: 'Column2', cellAt: simpleCellAt, width: 'max-content', sort: 'desc' },
{ header: 'Column3', cellAt: simpleCellAt, width: 'max-content', sort: 'none' },
{ header: 'Column4', cellAt: simpleCellAt, width: 'max-content', sort: 'none' },
{ header: '', cellAt: buttonCellAt },
],
items: items(),
Expand All @@ -177,6 +195,7 @@ export const withDisabled = makeStory(conf, {
{ header: 'Column1', cellAt: simpleCellAt, width: 'minmax(max-content, 1fr)', sort: 'asc' },
{ header: 'Column2', cellAt: simpleCellAt, width: 'max-content', sort: 'desc' },
{ header: 'Column3', cellAt: simpleCellAt, width: 'max-content', sort: 'none' },
{ header: 'Column4', cellAt: simpleCellAt, width: 'max-content', sort: 'none' },
{ header: '', cellAt: buttonCellAt },
],
items: items(),
Expand Down
1 change: 1 addition & 0 deletions src/components/cc-grid/cc-grid.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface CcGridCellLink {
icon?: IconModel;
onClick: () => void;
skeleton?: boolean;
enableCopyToClipboard?: boolean;
}

interface CcGridCellButton {
Expand Down
Loading