@rcarls/rc-listbox 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,43 +1,67 @@
1
1
  import { LitElement } from 'lit';
2
- export interface ListboxOption {
3
- value: string;
4
- label: string;
5
- disabled?: boolean;
2
+ import { ActiveDescendantController, ItemsCollectionController, ItemsCollectionActionOption, ItemsCollectionOption, ItemsCollectionSelectableOption, ItemsCollectionFilterStrategy } from '@rcarls/rc-common';
3
+ declare global {
4
+ interface HTMLElementTagNameMap {
5
+ 'rc-listbox': RCListbox;
6
+ }
6
7
  }
8
+ /** Option shape accepted by `rc-listbox`. */
9
+ export type ListboxOption = ItemsCollectionOption;
10
+ export type ListboxSelectableOption = ItemsCollectionSelectableOption;
11
+ export type ListboxActionOption<Action extends string = string> = ItemsCollectionActionOption & {
12
+ action: Action;
13
+ };
7
14
  /**
8
- * Determines how `filterOptions()` matches option labels against the query string.
15
+ * Listbox that keeps option DOM in light DOM for aria-activedescendant navigation,
16
+ * following the WAI-ARIA Listbox pattern.
9
17
  *
10
- * - `'prefix'` — label must start with the query (default, matches native `<select>` type-ahead).
11
- * - `'contains'` — label must contain the query anywhere.
12
- * - `function` — custom predicate; receives lowercased label and query, return `true` to show the option.
18
+ * - `'prefix'`: label must start with the query (default, matches native `<select>` type-ahead).
19
+ * - `'contains'`: label must contain the query anywhere.
20
+ * - `function`: custom predicate; receives lowercased label and query, return `true` to show the option.
13
21
  */
14
- export type FilterStrategy = 'prefix' | 'contains' | ((label: string, query: string) => boolean);
15
- export interface RCListboxChangeEvent {
22
+ export type FilterStrategy = ItemsCollectionFilterStrategy;
23
+ export interface RCListboxSelectChangeEvent {
24
+ reason: 'select';
16
25
  /** Canonical selected value for single mode, or selected values for multi mode. */
17
26
  value: string | string[];
18
27
  /** Whether the option was selected (false means it was deselected). */
19
28
  selected: boolean;
20
- /** The option value that was activated. `'__create__'` for the create option. */
29
+ /** The option value that was activated. */
21
30
  optionValue: string;
22
31
  /** The option that was activated. */
23
- option: ListboxOption | null;
24
- /** Selected values as a consistently-array-shaped convenience value. */
32
+ option: ListboxSelectableOption;
33
+ /** Selected values array (length=1 for single-selection mode). */
25
34
  selectedValues: string[];
26
- /** Selected option objects. */
27
- selectedOptions: ListboxOption[];
35
+ /** Selected options. */
36
+ selectedOptions: ListboxSelectableOption[];
28
37
  }
29
- declare global {
30
- interface HTMLElementTagNameMap {
31
- 'rc-listbox': RCListbox;
32
- }
38
+ export interface RCListboxActionChangeEvent<Action extends string = string> {
39
+ reason: 'action';
40
+ /** Current canonical selected value; action rows do not mutate selection. */
41
+ value: string | string[];
42
+ /** Action activations never select the action row. */
43
+ selected: false;
44
+ /** The action option value that was activated. */
45
+ optionValue: string;
46
+ /** The action option that was activated. */
47
+ option: ListboxActionOption<Action>;
48
+ /** The activated action command. */
49
+ action: Action;
50
+ /** Selected values array (length=1 for single-selection mode). */
51
+ selectedValues: string[];
52
+ /** Selected options. */
53
+ selectedOptions: ListboxSelectableOption[];
33
54
  }
55
+ export type RCListboxChangeEvent = RCListboxSelectChangeEvent | RCListboxActionChangeEvent;
34
56
  /**
35
- * A headless listbox popup component following the WAI-ARIA listbox pattern.
57
+ * A WAI-ARIA listbox component that renders a `<ul role="presentation">`
58
+ * + `<li role="option">` subtree directly into its own light DOM to facilitate
59
+ * `aria-activedescendant` virtual navigation with IDs inside the same document
60
+ * or shadow root.
36
61
  *
37
- * Renders into its own light DOM (no shadow root) so that when placed inside
38
- * another component's shadow root, option element IDs resolve within that same
39
- * shadow root — enabling `aria-activedescendant` and `aria-controls` IDREFs to
40
- * work correctly from the parent trigger.
62
+ * Pre-rendered `<ul>/<li>` children are accepted as a progressive enhancement
63
+ * baseline. The component reads them on first connect if no `options` setter
64
+ * has been called.
41
65
  *
42
66
  * Consumers drive this component via its JS API:
43
67
  * - Set `options` to populate the list
@@ -46,54 +70,78 @@ declare global {
46
70
  * - Call `filterOptions()` to filter visible options
47
71
  * - Read `navigableItems` to feed `ActiveDescendantController`
48
72
  *
49
- * @slot — No slots; options are rendered programmatically from the `options` property.
73
+ * @see {@link https://richardcarls.github.io/rc-webcomponents/components/rc-listbox rc-listbox docs}
74
+ * @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ WAI-ARIA Listbox pattern}
75
+ *
76
+ * @slot — Accepts pre-rendered `<ul>` with `<li>` children for progressive enhancement.
77
+ *
50
78
  * @fires rc-listbox-change - Fired when an option is activated (clicked or Enter/Space)
51
- * @csspart option - Individual option elements
52
- * @csspart option-checkmark - The checkmark indicator inside each option
53
- * @csspart option-label - The label text span inside each option
79
+ *
80
+ * @csspart option - Individual `<li role="option">` elements
81
+ * @csspart option-checkmark - The checkmark `<span>` inside each option (when `checkmark` is true)
54
82
  * @csspart create-option - The "Create" option when allow-create is active
83
+ *
84
+ * @cssprop [--rc-listbox-option-gap=0.25rem] - Gap between the checkmark and option label.
85
+ * @cssprop [--rc-listbox-option-min-block-size=0px] - Minimum block size (height) of each option row.
86
+ * @cssprop [--rc-listbox-option-padding-block=2px] - Block-axis padding of each option row.
87
+ * @cssprop [--rc-listbox-option-padding-inline=4px] - Inline-axis padding of each option row.
88
+ * @cssprop [--rc-listbox-option-transition] - CSS transition applied to each option row.
89
+ * @cssprop [--rc-listbox-hover-bg] - Background of a hovered option.
90
+ * @cssprop [--rc-listbox-hover-color] - Text color of a hovered option.
91
+ * @cssprop [--rc-listbox-active-bg] - Background of the keyboard-active option.
92
+ * @cssprop [--rc-listbox-active-color] - Text color of the keyboard-active option.
93
+ * @cssprop [--rc-listbox-selected-bg] - Background of a selected option.
94
+ * @cssprop [--rc-listbox-selected-color] - Text color of a selected option.
95
+ * @cssprop [--rc-listbox-disabled-color] - Text color of a disabled option.
96
+ * @cssprop [--rc-listbox-disabled-opacity] - Opacity of a disabled option.
55
97
  */
56
98
  export declare class RCListbox extends LitElement {
57
- /** Renders into the host element — no shadow root — so option IDs resolve in the parent shadow root. */
58
- createRenderRoot(): this;
99
+ static styles: import('lit').CSSResult;
100
+ private static readonly _styledRoots;
101
+ private static _ensureBaseStyles;
59
102
  /** Allow multiple selection. Reflected as `aria-multiselectable` on the host. */
60
103
  multiple: boolean;
61
104
  /**
62
105
  * Render a checkmark indicator inside each option element.
106
+ *
63
107
  * Hidden by default; enable for combobox / select patterns where the
64
108
  * consumer's CSS shows it conditionally via `[aria-selected='true']`.
65
109
  */
66
110
  checkmark: boolean;
67
111
  /**
68
112
  * How option labels are matched against the active filter text.
113
+ *
69
114
  * Defaults to `'contains'` (substring). Set to `'prefix'` for starts-with
70
115
  * matching, or pass a custom predicate for full control.
71
116
  * Function values are JS-only; string values may be set via the
72
117
  * `filter-strategy` attribute.
73
118
  */
74
119
  filterStrategy: FilterStrategy;
75
- private _options;
76
- private _selectedValues;
77
- private _filterText;
78
- private _createLabel;
79
120
  private _defaultValue;
80
121
  private _value;
81
122
  private _selectionInitialized;
82
- private readonly _uid;
83
- private readonly _adc;
123
+ private _filterText;
124
+ /** Unique ID prefix for all rendered option elements in this instance. */
125
+ protected readonly _uid: string;
126
+ /** Manages the `<ul>/<li>` option DOM subtree in the component's light DOM. */
127
+ protected readonly _itemsCollectionCtrl: ItemsCollectionController;
128
+ /** Active-descendant controller; tracks keyboard focus within the option list. */
129
+ protected readonly _activeDescendantCtrl: ActiveDescendantController;
84
130
  connectedCallback(): void;
85
131
  disconnectedCallback(): void;
86
- updated(): void;
132
+ updated(changed: Map<PropertyKey, unknown>): void;
133
+ protected render(): import('lit').TemplateResult<1>;
87
134
  /** All options regardless of filter state. */
88
135
  get allOptions(): readonly ListboxOption[];
89
136
  /** Options currently passing the active filter. */
90
137
  get filteredOptions(): readonly ListboxOption[];
91
- /** Replace the full options list. Triggers a re-render. */
138
+ /** Replace the full options list. */
92
139
  get options(): ListboxOption[];
93
- /** Replace the full options list. Triggers a re-render. */
94
- set options(opts: ListboxOption[]);
140
+ /** Replace the full options list. */
141
+ set options(options: ListboxOption[]);
95
142
  /** Append a single option without replacing the list. */
96
- appendOption(opt: ListboxOption): void;
143
+ appendOption(option: ListboxOption): void;
144
+ /** Current selection as a consistently-array-shaped read-only view. */
97
145
  get selectedValues(): string[];
98
146
  /** Current selection. Host writes update silently. */
99
147
  get value(): string | string[];
@@ -107,40 +155,72 @@ export declare class RCListbox extends LitElement {
107
155
  setSelectedValues(values: string[]): void;
108
156
  /**
109
157
  * Toggle the selected state of the option with `value`.
110
- * In single-select mode, toggleing a selected item deselects it (and selects
158
+ * In single-select mode, toggling a selected item deselects it (and selects
111
159
  * the new item). Fires `rc-listbox-change`.
112
160
  */
113
161
  toggleOption(value: string): void;
162
+ /** Clears all selected values without firing `rc-listbox-change`. */
114
163
  clearSelection(): void;
115
- /** Filter visible options to those whose label starts with `text` (case-insensitive). */
164
+ /** Filter visible options to those whose label matches `text` (case-insensitive). */
116
165
  filterOptions(text: string): void;
166
+ /** Removes any active filter, making all options visible. */
117
167
  clearFilter(): void;
118
168
  /**
119
- * Ordered list of option elements currently navigable: visible and not disabled.
169
+ * Ordered list of currently navigable option elements (visible and not disabled).
170
+ *
120
171
  * Feed this to `ActiveDescendantController.items` in the parent component.
121
172
  * Includes the create option element when one is set.
122
173
  */
123
174
  get navigableItems(): Element[];
124
175
  /**
125
176
  * Show or hide the "Create" option at the end of the list.
126
- * Pass `null` to hide it, or a non-empty string to show "Create '{label}'".
127
- * Fires `rc-listbox-change` with `value: '__create__'` when activated.
177
+ *
178
+ * Pass `null` to hide it, or a non-empty string to show `Create "{label}"`.
179
+ * Fires `rc-listbox-change` with `reason: 'action'` and `action: 'create'`
180
+ * when activated.
128
181
  */
129
182
  setCreateOption(label: string | null): void;
130
- protected render(): import('lit').TemplateResult<1>;
131
- private _onBlur;
132
- private _onKeydown;
183
+ /** Clears the active descendant when focus leaves the listbox. */
184
+ protected _onBlur: () => void;
133
185
  /**
134
- * Selects the active-descendant item without toggling (single-select safe).
186
+ * Handles arrow-key navigation and Enter/Space activation.
187
+ *
188
+ * Passes modifier-key combos through to parent handlers unchanged.
189
+ */
190
+ protected _onKeydown: (e: KeyboardEvent) => void;
191
+ /** Routes pointer activations from the controller. */
192
+ private _handleActivate;
193
+ /**
194
+ * Selects the active-descendant item without toggling.
195
+ *
135
196
  * Used by arrow-key navigation so the item under the cursor is always selected.
136
197
  */
137
- private _selectActiveItem;
138
- private _optId;
139
- private _isVisible;
140
- private _applySelection;
141
- private _applySelectionFromSource;
142
- private _normalizeValue;
143
- private _dispatchChange;
144
- private _selectedOptionsFor;
198
+ protected _selectActiveItem(): void;
199
+ /**
200
+ * Returns `true` when `opt` passes the current `_filterText` and `filterStrategy`.
201
+ */
202
+ protected _isVisible(option: ListboxOption): boolean;
203
+ /**
204
+ * Directly replaces the selection without firing an event.
205
+ *
206
+ * Enforces the single-select limit when `multiple` is false.
207
+ */
208
+ protected _applySelection(values: string[]): void;
209
+ /**
210
+ * Re-applies `_value` or `_defaultValue` after `options` or DOM bootstrap changes.
211
+ */
212
+ protected _applySelectionFromSource(): void;
213
+ /** Coerces a `string | string[]` value to `string[]`. */
214
+ protected _normalizeValue(value: string | string[]): string[];
215
+ protected _activeOption(): ListboxOption | null;
216
+ /** Fires `rc-listbox-change` for a command/action row without mutating selection. */
217
+ protected _dispatchAction(option: ListboxActionOption): void;
218
+ /** Fires `rc-listbox-change` with a fully-populated detail object. */
219
+ protected _dispatchChange(option: ListboxSelectableOption, selected: boolean): void;
220
+ /**
221
+ * Maps value strings to their `ListboxOption` objects.
222
+ * Creates synthetic `{ value, label: value }` entries for values not in the list.
223
+ */
224
+ protected _selectedOptionsFor(values: string[]): ListboxSelectableOption[];
145
225
  }
146
226
  export default RCListbox;
package/package.json CHANGED
@@ -3,14 +3,14 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.2.0",
7
- "description": "Headless WAI-ARIA listbox web component built with Lit",
6
+ "version": "0.3.0",
7
+ "description": "Listbox that keeps option DOM in light DOM for aria-activedescendant navigation.",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/richardcarls/rc-webcomponents.git",
11
11
  "directory": "packages/rc-listbox"
12
12
  },
13
- "homepage": "https://github.com/richardcarls/rc-webcomponents#readme",
13
+ "homepage": "https://richardcarls.github.io/rc-webcomponents/components/rc-listbox",
14
14
  "license": "MIT",
15
15
  "type": "module",
16
16
  "files": [
@@ -37,21 +37,18 @@
37
37
  ],
38
38
  "customElements": "dist/custom-elements.json",
39
39
  "scripts": {
40
- "dev": "vite",
41
40
  "build": "tsc && vite build && cem analyze",
42
41
  "cem:analyze": "cem analyze",
43
42
  "preview": "vite preview",
44
- "test:browser": "vitest",
45
- "test:browser:chrome": "vitest --project=chromium",
46
- "test:browser:firefox": "vitest --project=firefox",
47
- "yalc:publish": "yalc publish --push"
43
+ "test:browser": "vitest --run",
44
+ "test:browser:chrome": "vitest --run --project=chromium",
45
+ "test:browser:firefox": "vitest --run --project=firefox"
48
46
  },
49
47
  "dependencies": {
50
- "@rcarls/rc-common": "workspace:^"
48
+ "@rcarls/rc-common": "workspace:*"
51
49
  },
52
50
  "devDependencies": {
53
51
  "@custom-elements-manifest/analyzer": "0.11.0",
54
- "@guanghechen/rollup-plugin-copy": "^6.0.9",
55
52
  "@vitest/browser-playwright": "4.1.5",
56
53
  "lit": "^3.0.0",
57
54
  "playwright": "^1.56.0",
package/dist/demo.css DELETED
@@ -1,85 +0,0 @@
1
- *, *::before, *::after {
2
- box-sizing: border-box;
3
- }
4
-
5
- :root {
6
- color-scheme: light dark;
7
- --rc-accent: Highlight; /* Chrome doesn't support AccentColor on the open web */
8
- }
9
-
10
- @supports (color: AccentColor) {
11
- :root {
12
- --rc-accent: AccentColor;
13
- }
14
- }
15
-
16
- [data-theme="light"] { color-scheme: light; }
17
- [data-theme="dark"] { color-scheme: dark; }
18
-
19
- [data-theme="solarized-light"] {
20
- color-scheme: light;
21
- --rc-surface: #fdf6e3;
22
- --rc-text: #657b83;
23
- --rc-border: 1px solid #93a1a1;
24
- --rc-shadow: 0 2px 8px rgba(0, 0, 0, .12);
25
- }
26
-
27
- [data-theme="solarized-dark"] {
28
- color-scheme: dark;
29
- --rc-surface: #002b36;
30
- --rc-text: #839496;
31
- --rc-border: 1px solid #586e75;
32
- --rc-shadow: 0 2px 8px rgba(0, 0, 0, .3);
33
- }
34
-
35
- body {
36
- font-family: system-ui, sans-serif;
37
- margin: 0;
38
- }
39
-
40
- .demo-page {
41
- max-width: 60rem;
42
- margin: 0 auto;
43
- padding: 2rem 1.5rem;
44
- }
45
-
46
- .demo-controls {
47
- display: flex;
48
- align-items: center;
49
- gap: 1rem;
50
- margin-bottom: 2rem;
51
- padding-bottom: 1rem;
52
- border-bottom: 1px solid ButtonBorder;
53
- }
54
-
55
- .demo-controls .demo-title {
56
- flex: 1;
57
- }
58
-
59
- .demo-controls h1 {
60
- margin: 0 0 0.15rem;
61
- font-size: 1.5rem;
62
- }
63
-
64
- .demo-controls a {
65
- font-size: 0.8rem;
66
- }
67
-
68
- .demo-section {
69
- margin-bottom: 2rem;
70
- }
71
-
72
- .demo-section h2 {
73
- margin: 0 0 0.5rem;
74
- }
75
-
76
- .theme-picker {
77
- padding: 0.35em 0.75em;
78
- font-family: inherit;
79
- font-size: 0.8rem;
80
- cursor: pointer;
81
- border: 1px solid ButtonBorder;
82
- border-radius: 4px;
83
- background: Canvas;
84
- color: CanvasText;
85
- }
package/dist/demo.js DELETED
@@ -1,40 +0,0 @@
1
- const THEMES = ['', 'light', 'dark', 'solarized-light', 'solarized-dark'];
2
- const LABELS = ['Auto', 'Light', 'Dark', 'Solarized ☀', 'Solarized ☾'];
3
-
4
- const stored = localStorage.getItem('rc-demo-theme') ?? '';
5
- applyTheme(stored);
6
-
7
- function applyTheme(theme) {
8
- if (theme) {
9
- document.documentElement.dataset.theme = theme;
10
- } else {
11
- delete document.documentElement.dataset.theme;
12
- }
13
- }
14
-
15
- function currentIndex() {
16
- const current = document.documentElement.dataset.theme ?? '';
17
- const idx = THEMES.indexOf(current);
18
- return idx === -1 ? 0 : idx;
19
- }
20
-
21
- function updateButtons() {
22
- const label = LABELS[currentIndex()];
23
- document.querySelectorAll('.theme-picker').forEach((btn) => {
24
- btn.textContent = label;
25
- });
26
- }
27
-
28
- window.cycleTheme = function () {
29
- const next = THEMES[(currentIndex() + 1) % THEMES.length];
30
- applyTheme(next);
31
- localStorage.setItem('rc-demo-theme', next);
32
- updateButtons();
33
- };
34
-
35
- document.addEventListener('DOMContentLoaded', () => {
36
- updateButtons();
37
- document.querySelectorAll('.theme-picker').forEach((btn) => {
38
- btn.addEventListener('click', window.cycleTheme);
39
- });
40
- });