- sprucecss
- scss
- package.json
- README.md
@use 'sprucecss/scss/spruce' as *;
.tree-view {
@include clear-list;
font-family: config('font-family-mono', $typography);
font-size: config('font-size-sm', $typography);
ul {
@include clear-list;
border-inline-start: config('border-width-base', $display) solid color('border');
margin-inline-start: spacer('s');
padding-inline-start: spacer('xs');
}
li {
margin: 0 !important;
&:focus {
outline: none;
}
&:focus-visible > .tree-view__label {
outline: 2px solid color('focus-ring');
outline-offset: -2px;
}
&[aria-expanded='false'] > ul {
display: none;
}
&[aria-selected='true'] > .tree-view__label {
background-color: color-alpha('primary', 0.12);
color: color('primary');
}
}
&__label {
align-items: center;
border-radius: radius('sm');
color: color('heading');
cursor: pointer;
display: flex;
gap: spacer('xxs');
padding: spacer('xxs') spacer('xs');
&:hover {
background-color: color('surface-muted');
}
&::before {
background-color: currentcolor;
block-size: 0.9em;
content: '';
flex-shrink: 0;
inline-size: 0.9em;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E") center / contain no-repeat;
@include transition($property: 'rotate');
}
&--file::before {
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpath d='M14 2v6h6'/%3E%3C/svg%3E");
opacity: 0.6;
}
}
[aria-expanded='true'] > &__label::before {
rotate: 90deg;
}
}
[dir='rtl'] .tree-view__label:not(.tree-view__label--file)::before {
scale: -1 1;
}
<ul class="tree-view" role="tree" aria-label="Project files" data-tree>
<li role="treeitem" aria-expanded="true" tabindex="0">
<span class="tree-view__label">sprucecss</span>
<ul role="group">
<li role="treeitem" aria-expanded="true" tabindex="-1">
<span class="tree-view__label">scss</span>
<ul role="group">
<li role="treeitem" aria-expanded="false" tabindex="-1">
<span class="tree-view__label">config</span>
<ul role="group">
<li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">_color.scss</span></li>
<li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">_style.scss</span></li>
</ul>
</li>
<li role="treeitem" aria-expanded="false" tabindex="-1">
<span class="tree-view__label">mixin</span>
<ul role="group">
<li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">_layout.scss</span></li>
<li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">_theme.scss</span></li>
</ul>
</li>
<li role="treeitem" tabindex="-1" aria-selected="true"><span class="tree-view__label tree-view__label--file">spruce.scss</span></li>
</ul>
</li>
<li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">package.json</span></li>
<li role="treeitem" tabindex="-1"><span class="tree-view__label tree-view__label--file">README.md</span></li>
</ul>
</li>
</ul>
(() => {
document.querySelectorAll('[data-tree]').forEach((tree) => {
const visibleItems = () => [...tree.querySelectorAll('[role="treeitem"]')].filter((item) => !item.parentElement.closest('[aria-expanded="false"]'));
const label = (item) => item.querySelector('.tree-view__label').textContent.trim().toLowerCase();
const focus = (item) => {
tree.querySelectorAll('[role="treeitem"]').forEach((other) => { other.tabIndex = -1; });
item.tabIndex = 0;
item.focus();
};
const select = (item) => {
tree.querySelectorAll('[aria-selected]').forEach((other) => other.removeAttribute('aria-selected'));
item.setAttribute('aria-selected', 'true');
};
const toggle = (item, force) => {
if (item.hasAttribute('aria-expanded')) {
item.setAttribute('aria-expanded', String(force ?? item.getAttribute('aria-expanded') !== 'true'));
}
};
tree.addEventListener('click', (event) => {
const item = event.target.closest('[role="treeitem"]');
if (item && event.target.closest('.tree-view__label')) {
event.stopPropagation();
focus(item);
item.hasAttribute('aria-expanded') ? toggle(item) : select(item);
}
});
tree.addEventListener('keydown', (event) => {
const item = event.target.closest('[role="treeitem"]');
const items = visibleItems();
const index = items.indexOf(item);
const isRtl = getComputedStyle(tree).direction === 'rtl';
const open = isRtl ? 'ArrowLeft' : 'ArrowRight';
const closeKey = isRtl ? 'ArrowRight' : 'ArrowLeft';
const isExpanded = item.getAttribute('aria-expanded') === 'true';
if (event.key === 'ArrowDown' && items[index + 1]) focus(items[index + 1]);
else if (event.key === 'ArrowUp' && items[index - 1]) focus(items[index - 1]);
else if (event.key === 'Home') focus(items[0]);
else if (event.key === 'End') focus(items[items.length - 1]);
else if (event.key === open && item.hasAttribute('aria-expanded')) isExpanded ? focus(item.querySelector('[role="treeitem"]')) : toggle(item, true);
else if (event.key === closeKey) isExpanded ? toggle(item, false) : item.parentElement.closest('[role="treeitem"]') && focus(item.parentElement.closest('[role="treeitem"]'));
else if (event.key === 'Enter' || event.key === ' ') item.hasAttribute('aria-expanded') ? toggle(item) : select(item);
else if (event.key.length === 1 && /\S/.test(event.key)) {
const next = [...items.slice(index + 1), ...items.slice(0, index)].find((other) => label(other).startsWith(event.key.toLowerCase()));
if (next) focus(next);
} else return;
event.preventDefault();
});
});
})();