Skip to content

Commit fc883c7

Browse files
committed
initial commit
1 parent 7b9c64b commit fc883c7

File tree

10 files changed

+769
-84
lines changed

10 files changed

+769
-84
lines changed

packages/nimble-components/src/chip/index.ts

Lines changed: 170 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { attr, nullableNumberConverter, observable } from '@ni/fast-element';
2+
import { keyEnter, keyEscape, keySpace } from '@ni/fast-web-utilities';
23
import {
34
applyMixins,
45
DesignSystem,
@@ -10,7 +11,7 @@ import {
1011
} from '@ni/fast-foundation';
1112
import { styles } from './styles';
1213
import { template } from './template';
13-
import { ChipAppearance } from './types';
14+
import { ChipAppearance, ChipSelectionMode } from './types';
1415
import { slotTextContent } from '../utilities/models/slot-text-content';
1516
import { itemRemoveLabel } from '../label-provider/core/label-tokens';
1617

@@ -19,6 +20,10 @@ declare global {
1920
'nimble-chip': Chip;
2021
}
2122
}
23+
export {
24+
ChipSelectionMode,
25+
type ChipSelectionMode as ChipSelectionModeType
26+
} from './types';
2227

2328
export type ChipOptions = FoundationElementDefinition &
2429
StartOptions &
@@ -34,12 +39,29 @@ export class Chip extends FoundationElement {
3439
@attr({ mode: 'boolean' })
3540
public disabled = false;
3641

42+
@attr({ attribute: 'selection-mode' })
43+
public selectionMode: ChipSelectionMode;
44+
45+
@attr({ mode: 'boolean' })
46+
public selected = false;
47+
3748
@attr()
3849
public appearance: ChipAppearance = ChipAppearance.outline;
3950

4051
@attr({ attribute: 'tabindex', converter: nullableNumberConverter })
4152
public override tabIndex!: number;
4253

54+
/**
55+
* Indicates whether the remove button is currently in a mousedown state.
56+
* Used to prevent the chip's active styling from showing when the remove button is being clicked.
57+
*
58+
* @internal
59+
* @remarks
60+
* This attribute is automatically managed by handleRemoveMousedown and should not be set directly.
61+
*/
62+
@attr({ attribute: 'remove-button-active', mode: 'boolean' })
63+
public removeButtonActive = false;
64+
4365
/** @internal */
4466
public readonly content?: HTMLElement[];
4567

@@ -60,22 +82,164 @@ export class Chip extends FoundationElement {
6082
/** @internal */
6183
public contentSlot!: HTMLSlotElement;
6284

85+
private managingTabIndex = false;
86+
private suppressTabIndexChanged = false;
87+
private mouseUpHandler: EventListener | null = null;
88+
89+
public override connectedCallback(): void {
90+
super.connectedCallback();
91+
this.updateManagedTabIndex();
92+
}
93+
94+
public override disconnectedCallback(): void {
95+
super.disconnectedCallback();
96+
if (this.mouseUpHandler) {
97+
document.removeEventListener('mouseup', this.mouseUpHandler);
98+
this.mouseUpHandler = null;
99+
}
100+
}
101+
63102
/** @internal */
64-
public handleRemoveClick(): void {
103+
public clickHandler(_e: MouseEvent): boolean {
104+
if (this.disabled) {
105+
return false;
106+
}
107+
108+
if (this.selectionMode === ChipSelectionMode.single) {
109+
this.selected = !this.selected;
110+
this.$emit('selected-change');
111+
return false;
112+
}
113+
return true;
114+
}
115+
116+
/** @internal */
117+
public keyupHandler(e: KeyboardEvent): boolean {
118+
if (this.disabled) {
119+
return false;
120+
}
121+
switch (e.key) {
122+
case keySpace:
123+
case keyEnter:
124+
if (this.selectionMode === ChipSelectionMode.single) {
125+
this.selected = !this.selected;
126+
this.$emit('selected-change');
127+
}
128+
return true;
129+
case keyEscape:
130+
if (
131+
this.removable
132+
&& this.selectionMode === ChipSelectionMode.single
133+
) {
134+
this.$emit('remove');
135+
return false;
136+
}
137+
return true;
138+
default:
139+
return true;
140+
}
141+
}
142+
143+
/** @internal */
144+
public handleRemoveClick(event: MouseEvent): void {
145+
event.stopPropagation();
65146
if (this.removable) {
66147
this.$emit('remove');
67148
}
68149
}
150+
151+
/**
152+
* Handles mousedown events on the remove button.
153+
* Sets removeButtonActive to true and registers a document-level mouseup handler to clear it.
154+
*
155+
* @internal
156+
* @remarks
157+
* The mouseup listener is added to document to ensure it fires even if the mouse moves
158+
* outside the chip before being released. The listener is cleaned up in disconnectedCallback
159+
* to prevent memory leaks.
160+
*/
161+
public handleRemoveMousedown(event: MouseEvent): void {
162+
event.stopPropagation();
163+
this.removeButtonActive = true;
164+
165+
// Clean up any existing listener first
166+
if (this.mouseUpHandler) {
167+
document.removeEventListener('mouseup', this.mouseUpHandler);
168+
}
169+
170+
this.mouseUpHandler = (): void => {
171+
this.removeButtonActive = false;
172+
if (this.mouseUpHandler) {
173+
document.removeEventListener('mouseup', this.mouseUpHandler);
174+
this.mouseUpHandler = null;
175+
}
176+
};
177+
document.addEventListener('mouseup', this.mouseUpHandler);
178+
}
179+
180+
/** @internal */
181+
public handleRemoveKeyup(event: KeyboardEvent): void {
182+
event.stopPropagation();
183+
}
184+
185+
protected selectionModeChanged(
186+
_oldValue: ChipSelectionMode | undefined,
187+
_newValue: ChipSelectionMode | undefined
188+
): void {
189+
this.updateManagedTabIndex();
190+
}
191+
192+
protected disabledChanged(_oldValue: boolean, _newValue: boolean): void {
193+
this.updateManagedTabIndex();
194+
}
195+
196+
protected tabIndexChanged(): void {
197+
if (this.suppressTabIndexChanged) {
198+
this.suppressTabIndexChanged = false;
199+
return;
200+
}
201+
202+
this.managingTabIndex = false;
203+
}
204+
205+
private updateManagedTabIndex(): void {
206+
if (!this.$fastController?.isConnected) {
207+
return;
208+
}
209+
210+
const shouldManage = this.selectionMode === ChipSelectionMode.single && !this.disabled;
211+
212+
if (shouldManage) {
213+
if (!this.hasAttribute('tabindex')) {
214+
this.setManagedTabIndex(0);
215+
}
216+
} else {
217+
this.removeManagedTabIndex();
218+
}
219+
}
220+
221+
private setManagedTabIndex(value: number): void {
222+
this.managingTabIndex = true;
223+
this.suppressTabIndexChanged = true;
224+
this.tabIndex = value;
225+
}
226+
227+
private removeManagedTabIndex(): void {
228+
if (!this.managingTabIndex) {
229+
return;
230+
}
231+
232+
this.managingTabIndex = false;
233+
this.suppressTabIndexChanged = true;
234+
this.removeAttribute('tabindex');
235+
}
69236
}
70237
applyMixins(Chip, StartEnd);
71238

72239
const nimbleChip = Chip.compose<ChipOptions>({
73240
baseName: 'chip',
74241
template,
75-
styles,
76-
shadowOptions: {
77-
delegatesFocus: true
78-
}
242+
styles
79243
});
80244

81245
DesignSystem.getOrCreate().withPrefix('nimble').register(nimbleChip());

packages/nimble-components/src/chip/specs/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,18 @@ We will provide styling for the `disabled` attribute state.
145145
_Consider the accessibility of the component, including:_
146146

147147
- _Keyboard Navigation and Focus_
148-
- when the chip component is removable, the remove button will be focusable, otherwise it will not receive focus (following the `nimble-banner` pattern).
148+
- When the chip is selectable (`selection-mode="single"`) and removable:
149+
- The chip itself is focusable and receives keyboard events
150+
- Space/Enter toggles the selected state
151+
- Escape removes the chip (emits `remove` event)
152+
- The remove button is **not** focusable (`tabindex="-1"`) to avoid nested interactive controls (violates [WCAG 4.1.2](https://dequeuniversity.com/rules/axe/4.11/nested-interactive))
153+
- When the chip is removable but not selectable (`selection-mode="none"`):
154+
- The remove button is focusable and can be activated with Space or Enter
149155
- _Form Input_
150156
- N/A
151157
- _Use with Assistive Technology_
158+
- When selectable, the chip has `role="button"` and `aria-pressed` to indicate its toggle state
152159
- a `chip`'s accessible name comes from the element's contents by default
153-
- no ARIA `role` seems necessary to define for the chip, as it isn't interactive itself (only the remove button is which has a `role`). The only valid role seemed to be [`status`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role), but that also didn't seem helpful from an accessibility perspective, particularly since it mainly relates to providing helpful information when the content changes (which we don't expect).
154160
- the remove button will have its content set to provide a label provider token for "Remove".
155161
- title will not be set, which aligns with decisions for the filterable select clear button and the banner
156162
- ideally this would include the contents of the chip itself (so a screen reader would announce "Remove <Chip>") but differing word order between
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# Chip Selection Toggle
2+
3+
## Problem Statement
4+
5+
The `nimble-chip` component currently lacks a built-in mechanism to represent a selected or toggled state. This feature is required to support use cases where chips act as filter toggles or selectable items in a list. This design proposes adding a selection state to the `nimble-chip`.
6+
7+
## Links To Relevant Work Items and Reference Material
8+
9+
* [Nimble Chip Component](../index.ts)
10+
* [Figma Design - Chip Interactive States](https://www.figma.com/design/PO9mFOu5BCl8aJvFchEeuN/Nimble_Components?node-id=2227-78839&m=dev)
11+
* [FAST Toggle Button](https://explore.fast.design/components/toggle-button)
12+
* [FAST Checkbox](https://explore.fast.design/components/checkbox)
13+
* [ARIA: button role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/button_role) (specifically `aria-pressed`)
14+
* [WCAG 4.1.2: Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html) (nested interactive controls)
15+
16+
## Implementation / Design
17+
18+
### API Proposal
19+
20+
The chip uses the `selection-mode` attribute to control whether the chip is selectable.
21+
22+
* **Attribute:** `selection-mode` (enum: `none` (default: `undefined`) | `single` (`'single'`))
23+
* `none`: The chip is not selectable. Does not have `role="button"` or `aria-pressed`. User-supplied `tabindex` is forwarded to the remove button if removable.
24+
* `single`: The chip can be toggled on/off via click or Space/Enter keys. Has `role="button"` and `aria-pressed`. Automatically receives `tabindex="0"` unless user provides a different value.
25+
* **Attribute:** `selected` (boolean, default: `false`)
26+
* Indicates the current selection state when `selection-mode="single"`.
27+
* When `true`, the chip displays with `fillSelectedColor` background.
28+
* **Event:** `selected-change`
29+
* Emitted when the user toggles the chip state via click or keyboard (Space/Enter).
30+
* Only emitted when `selection-mode="single"`.
31+
* **Event:** `remove`
32+
* Emitted when the user activates the remove button (click) or presses Escape key (when selectable and removable).
33+
34+
### Keyboard Interaction
35+
36+
* **Space/Enter:** Toggles `selected` state (when `selection-mode="single"`).
37+
* **Escape:** Removes the chip (when `selection-mode="single"`, `removable`, and not `disabled`).
38+
* This provides keyboard access to the remove functionality without requiring the remove button to be focusable, which would violate WCAG 4.1.2 (nested interactive controls).
39+
40+
### Accessibility
41+
42+
* **Role:**
43+
* If `selection-mode="none"`: No explicit role (generic container).
44+
* If `selection-mode="single"`: `role="button"` with `aria-pressed="true"` or `aria-pressed="false"`.
45+
* **Tabindex Management:**
46+
* When `selection-mode="single"`: The chip automatically manages its own `tabindex` (defaults to `0`). User-supplied values are preserved.
47+
* When `selection-mode="none"`: The chip does not manage `tabindex`. User-supplied values are forwarded to the remove button if `removable`.
48+
* **Nested Interactive Controls:** To comply with WCAG 4.1.2, when a chip is both selectable and removable, the remove button is set to `tabindex="-1"` (not keyboard-focusable) and the Escape key provides the keyboard mechanism for removal.
49+
50+
### Visual Design
51+
52+
Visual states follow the [Figma design specification](https://www.figma.com/design/PO9mFOu5BCl8aJvFchEeuN/Nimble_Components?node-id=2227-78839&m=dev).
53+
54+
* **Default State:**
55+
* Border: `rgba(actionRgbPartialColor, 0.3)` for selectable chips; `rgba(borderRgbPartialColor, 0.3)` for non-selectable, non-block appearance
56+
* Background: transparent
57+
* Cursor: pointer (when `selection-mode="single"`)
58+
* **Selected State:**
59+
* Background: `fillSelectedColor`
60+
* Border: `rgba(actionRgbPartialColor, 0.3)`
61+
* **Hover State** (selectable chips only):
62+
* Border: `borderHoverColor` (2px green)
63+
* Outline: 2px green (`borderHoverColor`) at -2px offset (creates 2px green outline appearance)
64+
* **Focus-Visible State** (selectable chips only):
65+
* 3-ring effect using layered box-shadows:
66+
* Outer: 2px green border (`borderHoverColor`)
67+
* Middle: 2px white ring (`applicationBackgroundColor` inset)
68+
* Inner: 1px green ring (`borderHoverColor` inset)
69+
* **Active State** (selectable chips only, during mousedown/click):
70+
* Background: `rgba(fillSelectedRgbPartialColor, 0.3)` (30% opacity green)
71+
* Border: `borderHoverColor` (green)
72+
* Outline: 1px green at -1px offset
73+
* Box-shadow: 1px white ring inset
74+
* **Note:** Active styling is suppressed when the remove button is in mousedown state (via `remove-button-active` attribute)
75+
* **Disabled State:**
76+
* Text color: `bodyDisabledFontColor`
77+
* Icons: 30% opacity
78+
* No hover, focus, or active styling
79+
* Remove button hidden
80+
81+
### Implementation Details
82+
83+
* **CSS Cascade Layers:** Styles are organized using `@layer base, hover, focusVisible, active, disabled, top` to ensure proper precedence of interactive states.
84+
* **Tabindex Management:**
85+
* The chip tracks whether it's managing tabindex via internal `managingTabIndex` flag.
86+
* When `selection-mode` changes or the chip connects/disconnects, `updateManagedTabIndex()` is called.
87+
* User-supplied tabindex values are preserved by detecting attribute changes that didn't originate from internal management.
88+
* **Remove Button Active State:**
89+
* The `remove-button-active` attribute is set during remove button mousedown to prevent chip active styling from appearing.
90+
* A document-level mouseup listener clears this state, ensuring it works even if the mouse moves outside the chip.
91+
* The listener is cleaned up in `disconnectedCallback()` to prevent memory leaks.
92+
93+
## Alternative Implementations / Designs
94+
95+
### Alternative 1: New Component
96+
* Create a `nimble-toggle-chip` instead of modifying `nimble-chip`.
97+
* **Pros:** Separation of concerns. `nimble-chip` stays simple (just for display/dismiss).
98+
* **Cons:** Code duplication. Users might expect `nimble-chip` to handle this common case.
99+
* **Decision:** Rejected. The `selection-mode` attribute provides a clean API for opt-in behavior without requiring a separate component.
100+
101+
### Alternative 2: Focusable Remove Button (Initial Implementation)
102+
* Make the remove button keyboard-focusable when the chip is selectable and removable.
103+
* **Pros:** Direct keyboard access to remove button matches mouse interaction.
104+
* **Cons:** Violates WCAG 4.1.2 (nested interactive controls - a button within a button).
105+
* **Decision:** Rejected. Implemented Escape key pattern instead to maintain accessibility compliance.
106+
107+
### Alternative 3: Use AbortController for Event Cleanup
108+
* Use `AbortController` to manage the document mouseup listener instead of storing the handler.
109+
* **Pros:** Modern JavaScript pattern, automatic cleanup.
110+
* **Cons:** No precedent in Nimble codebase for this pattern.
111+
* **Decision:** Rejected. Followed established Nimble pattern of storing handler and cleaning up in `disconnectedCallback()`.
112+
113+
## Open Issues
114+
115+
### Resolved
116+
* ~~Does this affect the `remove` functionality? Can a chip be both selectable and removable?~~
117+
* **Resolution:** Yes, chips can be both selectable and removable. When both are true, the chip uses the Escape key for keyboard removal to avoid nested interactive controls (WCAG 4.1.2 violation).

0 commit comments

Comments
 (0)