Skip to content

Commit

Permalink
Select list option groups (#2111)
Browse files Browse the repository at this point in the history
# Pull Request

## 🤨 Rationale

- #791

This PR only introduces groups to the `Select`. Providing this feature
for the `Combobox` will be handled separately.
  • Loading branch information
atmgrifter00 authored Jun 4, 2024
1 parent e5a6a07 commit 9ed4ff0
Show file tree
Hide file tree
Showing 21 changed files with 1,728 additions and 119 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "List option groups for Select",
"packageName": "@ni/nimble-components",
"email": "26874831+atmgrifter00@users.noreply.github.com",
"dependentChangeType": "patch"
}
1 change: 1 addition & 0 deletions packages/nimble-components/src/all-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import './icons/all-icons';
import './label-provider/core';
import './label-provider/table';
import './list-option';
import './list-option-group';
import './mapping/empty';
import './mapping/icon';
import './mapping/spinner';
Expand Down
2 changes: 1 addition & 1 deletion packages/nimble-components/src/combobox/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ ComboboxOptions
?disabled="${x => x.disabled}"
${ref('listbox')}
>
<slot
<slot name="option"
${slotted({
filter: (n: Node) => n instanceof HTMLElement && Listbox.slottedOptionFilter(n),
flatten: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Combobox } from '..';
import { waitForUpdatesAsync } from '../../testing/async-helpers';
import { fixture } from '../../utilities/tests/fixture';
import { template } from '../template';
import { listOptionTag } from '../../list-option';

describe('Combobox', () => {
const combobox = Combobox.compose({
Expand Down Expand Up @@ -42,13 +43,13 @@ describe('Combobox', () => {

element.id = 'combobox';

const option1 = document.createElement('fast-option') as ListboxOption;
const option1 = document.createElement(listOptionTag) as ListboxOption;
option1.textContent = 'one';

const option2 = document.createElement('fast-option') as ListboxOption;
const option2 = document.createElement(listOptionTag) as ListboxOption;
option2.textContent = 'two';

const option3 = document.createElement('fast-option') as ListboxOption;
const option3 = document.createElement(listOptionTag) as ListboxOption;
option3.textContent = 'three';

element.appendChild(option1);
Expand Down
157 changes: 157 additions & 0 deletions packages/nimble-components/src/list-option-group/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { DesignSystem, FoundationElement } from '@microsoft/fast-foundation';
import {
observable,
attr,
volatile,
Observable
} from '@microsoft/fast-element';
import { styles } from './styles';
import { template } from './template';
import { ListOption } from '../list-option';

declare global {
interface HTMLElementTagNameMap {
'nimble-list-option-group': ListOptionGroup;
}
}

/**
* A nimble-styled HTML listbox option group
*/
export class ListOptionGroup extends FoundationElement {
/**
* The label for the group.
*
* @public
* @remarks
* If a label is also provided via slotted content, the label attribute
* will have precedence.
*/
@attr
public label?: string;

/**
* The hidden state of the element.
*
* @public
* @defaultValue - false
* @remarks
* HTML Attribute: hidden
*/
@attr({ mode: 'boolean' })
public override hidden = false;

/**
* @internal
* This attribute is required to allow use-cases that offer dynamic filtering
* (like the Select) to visually hide groups that are filtered out, but still
* allow users to use the native 'hidden' attribute without it being affected
* by the filtering process.
*/
@attr({ attribute: 'visually-hidden', mode: 'boolean' })
public visuallyHidden = false;

/**
* @internal
*/
@attr({ attribute: 'top-separator-visible', mode: 'boolean' })
public topSeparatorVisible = false;

/**
* @internal
*/
@attr({ attribute: 'bottom-separator-visible', mode: 'boolean' })
public bottomSeparatorVisible = false;

/** @internal */
@observable
public hasOverflow = false;

/** @internal */
public labelSlot!: HTMLSlotElement;

/** @internal */
@observable
public listOptions!: ListOption[];

/** @internal */
@volatile
public get labelContent(): string {
if (this.label) {
return this.label;
}

if (!this.$fastController.isConnected) {
return '';
}

const nodes = this.labelSlot.assignedNodes();
return nodes
.filter(node => node.textContent?.trim() !== '')
.map(node => node.textContent?.trim())
.join(' ');
}

private readonly hiddenOptions: Set<ListOption> = new Set();

/**
* @internal
*/
public clickHandler(e: MouseEvent): void {
e.preventDefault();
e.stopImmediatePropagation();
}

/**
* @internal
*/
public handleChange(source: unknown, propertyName: string): void {
if (
source instanceof ListOption
&& (propertyName === 'hidden' || propertyName === 'visuallyHidden')
) {
if (source.hidden || source.visuallyHidden) {
this.hiddenOptions.add(source);
} else {
this.hiddenOptions.delete(source);
}

this.visuallyHidden = this.hiddenOptions.size === this.listOptions.length;
}
}

private listOptionsChanged(
prev: ListOption[] | undefined,
next: ListOption[]
): void {
this.hiddenOptions.clear();
next.filter(o => o.hidden || o.visuallyHidden).forEach(o => this.hiddenOptions.add(o));
prev?.forEach(o => {
const notifier = Observable.getNotifier(o);
notifier.unsubscribe(this, 'hidden');
notifier.unsubscribe(this, 'visuallyHidden');
});

let allOptionsHidden = true;
next?.forEach(o => {
const notifier = Observable.getNotifier(o);
notifier.subscribe(this, 'hidden');
notifier.subscribe(this, 'visuallyHidden');
allOptionsHidden = allOptionsHidden && (o.hidden || o.visuallyHidden);
});

this.visuallyHidden = next.length === 0 || allOptionsHidden;
}
}

const nimbleListOptionGroup = ListOptionGroup.compose({
baseName: 'list-option-group',
baseClass: FoundationElement,
template,
styles
});

DesignSystem.getOrCreate()
.withPrefix('nimble')
.register(nimbleListOptionGroup());
export const listOptionGroupTag = 'nimble-list-option-group';
72 changes: 72 additions & 0 deletions packages/nimble-components/src/list-option-group/styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { css } from '@microsoft/fast-element';
import { display } from '../utilities/style/display';

import {
borderColor,
fillHoverColor,
fillHoverSelectedColor,
fillSelectedColor,
groupHeaderFont,
groupHeaderFontColor,
groupHeaderTextTransform,
smallPadding
} from '../theme-provider/design-tokens';

export const styles = css`
${display('flex')}
:host {
cursor: default;
flex-direction: column;
}
:host([visually-hidden]) {
display: none;
}
:host::after,
:host::before {
content: ' ';
margin-top: ${smallPadding};
margin-bottom: ${smallPadding};
border-bottom: ${borderColor} 2px solid;
opacity: 0.1;
display: none;
}
:host([top-separator-visible])::before,
:host([bottom-separator-visible])::after {
display: block;
}
slot[name='option']::slotted([role='option']) {
background-color: transparent;
}
slot[name='option']::slotted([role='option']:hover) {
background-color: ${fillHoverColor};
}
slot[name='option']::slotted([role='option'][active-option]) {
background-color: ${fillSelectedColor};
}
slot[name='option']::slotted([role='option'][active-option]:hover) {
background-color: ${fillHoverSelectedColor};
}
.label-display {
font: ${groupHeaderFont};
text-transform: ${groupHeaderTextTransform};
color: ${groupHeaderFontColor};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-left: ${smallPadding};
margin-bottom: ${smallPadding};
}
.label-slot.hidden {
display: none;
}
`;
39 changes: 39 additions & 0 deletions packages/nimble-components/src/list-option-group/template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { html, ref, slotted, when } from '@microsoft/fast-element';
import type { ListOptionGroup } from '.';
import { overflow } from '../utilities/directive/overflow';
import { ListOption } from '../list-option';

const isListOption = (n: Node): boolean => {
return n instanceof ListOption;
};

// prettier-ignore
export const template = html<ListOptionGroup>`
<template
role="group"
aria-label="${x => x.labelContent}"
slot="option"
>
<span ${overflow('hasOverflow')}
class="label-display"
aria-hidden="true"
title=${x => (x.hasOverflow && x.labelContent ? x.labelContent : null)}
@click="${(x, c) => x.clickHandler(c.event as MouseEvent)}"
>
${when(x => (typeof x.label === 'string'), html<ListOptionGroup>`${x => x.label}`)}
<slot ${ref('labelSlot')}
class="label-slot ${x => (typeof x.label === 'string' ? 'hidden' : '')}"
>
</slot>
</span>
<span class="content" part="content" role="none">
<slot name="option"
${slotted({
flatten: true,
filter: (n: Node) => isListOption(n),
property: 'listOptions'
})}
></slot>
</span>
</template>
`;
Loading

0 comments on commit 9ed4ff0

Please sign in to comment.