@fusion.dev/helix-headless 0.12.2 → 0.13.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.
@@ -2,7 +2,7 @@ import { AriaForwardController, HelixFormElement, PopoverController } from '@fus
2
2
  import { type PropertyValues, type TemplateResult } from 'lit';
3
3
  import '../../listbox/index.js';
4
4
  import '../../tag/index.js';
5
- declare const HelixHeadlessCombobox_base: (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerOpenInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerValueInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinFieldInterface) & typeof HelixFormElement;
5
+ declare const HelixHeadlessCombobox_base: (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerOpenInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinTokensInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerValueInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinFieldInterface) & typeof HelixFormElement;
6
6
  /**
7
7
  * @summary A field that filters a list as it is typed into.
8
8
  *
@@ -121,21 +121,6 @@ export declare class HelixHeadlessCombobox extends HelixHeadlessCombobox_base {
121
121
  * What the list says while they are on their way.
122
122
  */
123
123
  loadingText: string;
124
- /**
125
- * How many tags to draw before the rest become a count. `multiple` only.
126
- *
127
- * `0` is all of them, and is the default: a cap that guesses is worse than no
128
- * cap, because the number that fits depends on the width of the field and the
129
- * length of what is in it, neither of which this element knows. What a cap is
130
- * genuinely for is a field in a row or a table cell, where a set of twelve
131
- * choices would push everything below it down a line at a time.
132
- *
133
- * The remainder is counted rather than hidden. `+3` is a fact a reader can act
134
- * on, where a row that silently stops is a field that appears to have lost
135
- * what was put in it. Date Input and Time Input carry the same property and
136
- * the same reasoning.
137
- */
138
- maxTags: number;
139
124
  /**
140
125
  * Whether more than one option can be chosen.
141
126
  *
@@ -295,6 +280,14 @@ export declare class HelixHeadlessCombobox extends HelixHeadlessCombobox_base {
295
280
  * when you clicked into the middle of your own typing would be unusable.
296
281
  */
297
282
  private readonly handleFieldClick;
283
+ /**
284
+ * The last chosen one goes, which is what Backspace on an empty field means.
285
+ *
286
+ * `MixinTokens` owns when this is asked for and this owns what it does: a
287
+ * token here is a chosen option, so it leaves by the same door a dismissed
288
+ * tag does.
289
+ */
290
+ protected removeLastToken(): void;
298
291
  /** One tag asking to be taken out of the set. */
299
292
  private readonly handleDismiss;
300
293
  /**
@@ -4,7 +4,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
4
4
  else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
- import { AriaForwardController, customElement, HelixCreateEvent, HelixFormElement, MixinControllerOpen, MixinControllerValue, MixinField, PopoverController, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, customElement, HelixCreateEvent, HelixFormElement, MixinControllerOpen, MixinControllerValue, MixinField, MixinTokens, PopoverController, } from '@fusion.dev/helix-core';
8
8
  import { caretDown } from '@fusion.dev/helix-icons';
9
9
  import { nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
@@ -13,7 +13,7 @@ import { html } from 'lit/static-html.js';
13
13
  import '../../listbox/index.js';
14
14
  import '../../tag/index.js';
15
15
  const isInsertion = (event) => !event.inputType?.startsWith('delete');
16
- let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerOpen(MixinControllerValue(MixinField(HelixFormElement))) {
16
+ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerOpen(MixinTokens(MixinControllerValue(MixinField(HelixFormElement)))) {
17
17
  constructor() {
18
18
  super(...arguments);
19
19
  this.aria = new AriaForwardController(this, {
@@ -26,7 +26,6 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
26
26
  this.listLabel = 'Options';
27
27
  this.loading = false;
28
28
  this.loadingText = 'Loading';
29
- this.maxTags = 0;
30
29
  this.multiple = false;
31
30
  this.placeholder = '';
32
31
  this.removeLabel = 'Remove';
@@ -71,12 +70,7 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
71
70
  }
72
71
  return;
73
72
  }
74
- if (event.key === 'Backspace' && this.multiple && this.typed === '') {
75
- const last = this.values.at(-1);
76
- if (last !== undefined) {
77
- event.preventDefault();
78
- this.drop(last);
79
- }
73
+ if (this.multiple && this.tokenKeydown(event, this.typed)) {
80
74
  return;
81
75
  }
82
76
  if (event.key === 'Enter' && this.creation !== '' && !this.showing) {
@@ -206,6 +200,12 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
206
200
  const match = (this.listbox?.options ?? []).find((option) => !option.hidden && !option.disabled && option.text.toLowerCase().startsWith(needle));
207
201
  return match && match.text.length > typed.length ? match.text : '';
208
202
  }
203
+ removeLastToken() {
204
+ const last = this.values.at(-1);
205
+ if (last !== undefined) {
206
+ this.drop(last);
207
+ }
208
+ }
209
209
  sync() {
210
210
  this.internals.setFormValue(this.value === '' ? null : this.value);
211
211
  this.reportRequired(this.value === '', 'Choose one to continue.', 'control');
@@ -284,12 +284,7 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
284
284
  <div part="field" @click=${this.handleFieldClick}>
285
285
  <slot name="start"></slot>
286
286
  ${this.multiple && this.chosen.length > 0
287
- ? html `<span part="tags">
288
- ${this.shown.map((option) => this.renderTag(option))}
289
- ${this.restCount > 0
290
- ? html `<span part="rest">${`+${this.restCount}`}</span>`
291
- : nothing}
292
- </span>`
287
+ ? this.renderTokenRow(this.shown.map((option) => this.renderTag(option)), this.restCount)
293
288
  : nothing}
294
289
  <input
295
290
  part="control"
@@ -344,9 +339,6 @@ __decorate([
344
339
  __decorate([
345
340
  property({ attribute: 'loading-text' })
346
341
  ], HelixHeadlessCombobox.prototype, "loadingText", void 0);
347
- __decorate([
348
- property({ attribute: 'max-tags', type: Number })
349
- ], HelixHeadlessCombobox.prototype, "maxTags", void 0);
350
342
  __decorate([
351
343
  property({ type: Boolean })
352
344
  ], HelixHeadlessCombobox.prototype, "multiple", void 0);
@@ -52,6 +52,7 @@ registerElementEvents({
52
52
  'helix-headless-drilldown': ['helix-select'],
53
53
  'helix-headless-drilldown-item': ['helix-select'],
54
54
  'helix-headless-file-input': ['change', 'helix-before-change', 'input'],
55
+ 'helix-headless-filter-input': ['change', 'helix-before-change', 'input'],
55
56
  'helix-headless-heatmap': ['helix-resize'],
56
57
  'helix-headless-image': ['error', 'load'],
57
58
  'helix-headless-input': ['change', 'helix-before-change', 'input'],
@@ -0,0 +1 @@
1
+ export * from './root/filter-input-root.js';
@@ -0,0 +1 @@
1
+ export * from './root/filter-input-root.js';
@@ -0,0 +1,129 @@
1
+ import { AriaForwardController, HelixFormElement } from '@fusion.dev/helix-core';
2
+ import { type PropertyValues, type TemplateResult } from 'lit';
3
+ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@fusion.dev/helix-core").MixinTokensInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinControllerValueInterface) & (new (...args: any[]) => import("@fusion.dev/helix-core").MixinFieldInterface) & typeof HelixFormElement;
4
+ /**
5
+ * @summary A field that narrows something else, beside what is already narrowing it.
6
+ *
7
+ * @description
8
+ * **The tokens are the consumer's own elements, slotted.** What a filter narrows
9
+ * by belongs to the consumer's model rather than to this element: a facet with a
10
+ * prefix, a saved query, a range. So they are slotted rather than rendered from
11
+ * a list of strings, and removing one is a request this element makes rather
12
+ * than a change it applies. Combobox is the other way round, and that is the
13
+ * difference between the two: its tokens are a view of a selection it owns,
14
+ * drawn from options it was handed.
15
+ *
16
+ * **`value` is the text being typed, not the tokens**, which is what makes this
17
+ * a field a consumer can listen to. Every keystroke announces `input` with the
18
+ * text on the element, so a table narrows as the reader types. The tokens need
19
+ * no reporting back, being markup the consumer already holds.
20
+ *
21
+ * Backspace on an empty field asks the last token to go, through the same
22
+ * `helix-dismiss` its own remove control fires, so a consumer who handled the
23
+ * pointer has already handled the keyboard.
24
+ *
25
+ * @since 0.1.0
26
+ * @status experimental
27
+ * @category forms
28
+ * @aliases SearchField, FacetInput, TokenInput
29
+ * @documentation https://helix.fusion.dev/components/filter-input
30
+ *
31
+ * @slot end - After the text, for a count or a control.
32
+ * @slot start - Before the text, for a search icon.
33
+ * @slot tags - What is already narrowing the results, as `Tag`s.
34
+ *
35
+ * @event {Event} change - Relayed from the input once the reader has finished.
36
+ * @event {HelixBeforeChangeEvent} helix-before-change - Fired as the text changes, and cancelable.
37
+ * @event {Event} input - Announced once the text has moved.
38
+ *
39
+ * @csspart clear - The button that empties the field.
40
+ * @csspart control - The input itself.
41
+ * @csspart feedback - What is wrong with it.
42
+ * @csspart field - The box holding everything.
43
+ * @csspart hint - Help under the field, while nothing is wrong.
44
+ * @csspart label - The question, over the field.
45
+ * @csspart rest - How many tokens did not fit.
46
+ * @csspart tags - The row the tokens sit in.
47
+ */
48
+ export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_base {
49
+ protected readonly aria: AriaForwardController;
50
+ /** Typed, so every keystroke is announced. See `announcesInput`. */
51
+ protected readonly announcesInput = true;
52
+ /**
53
+ * Whether the field offers a button that empties it.
54
+ *
55
+ * The text only. The tokens are the consumer's markup and this element is not
56
+ * entitled to take them out of the document, which is the same reason
57
+ * `hideOverflow` marks the ones over the cap `hidden` rather than removing
58
+ * them.
59
+ */
60
+ clearable: boolean;
61
+ /**
62
+ * What the clear button is called, since an icon has no name of its own.
63
+ */
64
+ clearLabel: string;
65
+ /**
66
+ * Shown while the field is empty.
67
+ *
68
+ * Not a label: it goes when typing starts.
69
+ */
70
+ placeholder: string;
71
+ /** How many tokens did not fit, which is what the row's count renders. */
72
+ private over;
73
+ /**
74
+ * The caret, held across a value the consumer wrote rather than the reader.
75
+ *
76
+ * Set in `willUpdate` and spent in `updated`, because Lit's `.value` binding
77
+ * lands between the two and takes the selection with it. See `holdCaret`.
78
+ */
79
+ private releaseCaret;
80
+ /** Focus is delegated to the input, so the host is never the focus target. */
81
+ static shadowRootOptions: ShadowRootInit;
82
+ private get control();
83
+ private get tokens();
84
+ /** Whether there is a clear button on screen right now. */
85
+ private get showsClear();
86
+ /**
87
+ * Every keystroke, cancelable, and put back where it is refused.
88
+ *
89
+ * Stopped for the reason `Input` gives: `input` is composed, so the inner
90
+ * control's own would arrive beside the one the mixin announces and a consumer
91
+ * would hear one keystroke twice, once carrying the value from before it.
92
+ */
93
+ private readonly handleInput;
94
+ /** Relayed, because `change` does not cross a shadow boundary on its own. */
95
+ private readonly handleChange;
96
+ /**
97
+ * Empties the text and gives the caret back.
98
+ *
99
+ * Focus returns to the input rather than staying on a button that is about to
100
+ * disappear, which would otherwise land on the body.
101
+ */
102
+ private readonly handleClear;
103
+ private readonly handleKeydown;
104
+ /** The row again, whenever what is in it changes. */
105
+ private readonly handleTokens;
106
+ /**
107
+ * Asks the last token to go, rather than taking it.
108
+ *
109
+ * `dismiss()` is the tag's own, so this is the code path its remove control
110
+ * already runs on a press: the consumer hears one `helix-dismiss`, cancels it
111
+ * or removes the element, and never learns which of the two asked. A slotted
112
+ * node that is not a `Tag` has no such method and is left alone, because
113
+ * hiding a consumer's element on Backspace would be this element deciding
114
+ * something it was never given.
115
+ */
116
+ protected removeLastToken(): void;
117
+ protected willUpdate(changed: PropertyValues): void;
118
+ protected firstUpdated(changed: PropertyValues): void;
119
+ protected updated(changed: PropertyValues): void;
120
+ /** Puts the caret in the field, the way a label pointing at one would. */
121
+ focus(options?: FocusOptions): void;
122
+ protected render(): TemplateResult;
123
+ }
124
+ declare global {
125
+ interface HTMLElementTagNameMap {
126
+ 'helix-headless-filter-input': HelixHeadlessFilterInput;
127
+ }
128
+ }
129
+ export {};
@@ -0,0 +1,140 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { AriaForwardController, customElement, drawIcon, HelixFormElement, holdCaret, MixinControllerValue, MixinField, MixinTokens, restoreValue, } from '@fusion.dev/helix-core';
8
+ import { x } from '@fusion.dev/helix-icons';
9
+ import { html, nothing } from 'lit';
10
+ import { property, state } from 'lit/decorators.js';
11
+ const asksToGo = (node) => typeof node?.dismiss === 'function';
12
+ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinTokens(MixinControllerValue(MixinField(HelixFormElement))) {
13
+ constructor() {
14
+ super(...arguments);
15
+ this.aria = new AriaForwardController(this, {
16
+ target: () => this.renderRoot?.querySelector('[part="control"]') ?? null,
17
+ });
18
+ this.announcesInput = true;
19
+ this.clearable = false;
20
+ this.clearLabel = 'Clear';
21
+ this.placeholder = '';
22
+ this.over = 0;
23
+ this.handleInput = (event) => {
24
+ event.stopPropagation();
25
+ const control = event.target;
26
+ this.requestValue(control.value);
27
+ restoreValue(control, this.value);
28
+ };
29
+ this.handleChange = (event) => {
30
+ event.stopPropagation();
31
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: false }));
32
+ };
33
+ this.handleClear = () => {
34
+ this.requestValue('');
35
+ const control = this.control;
36
+ if (control) {
37
+ restoreValue(control, this.value);
38
+ control.focus();
39
+ }
40
+ };
41
+ this.handleKeydown = (event) => {
42
+ this.tokenKeydown(event, this.value);
43
+ };
44
+ this.handleTokens = () => {
45
+ this.over = this.hideOverflow(this.tokens);
46
+ };
47
+ }
48
+ static { this.shadowRootOptions = {
49
+ ...HelixFormElement.shadowRootOptions,
50
+ delegatesFocus: true,
51
+ }; }
52
+ get control() {
53
+ return this.renderRoot.querySelector('[part="control"]');
54
+ }
55
+ get tokens() {
56
+ return this.renderRoot?.querySelector('slot[name="tags"]') ?? null;
57
+ }
58
+ get showsClear() {
59
+ return this.clearable && this.value !== '' && !this.isDisabled;
60
+ }
61
+ removeLastToken() {
62
+ const last = this.tokens?.assignedElements({ flatten: true }).at(-1);
63
+ if (asksToGo(last)) {
64
+ last.dismiss();
65
+ }
66
+ }
67
+ willUpdate(changed) {
68
+ super.willUpdate(changed);
69
+ if (!this.hasUpdated) {
70
+ return;
71
+ }
72
+ const control = this.control;
73
+ if (changed.has('value') && control && this.shadowRoot?.activeElement === control) {
74
+ this.releaseCaret = holdCaret(control);
75
+ }
76
+ this.handleTokens();
77
+ }
78
+ firstUpdated(changed) {
79
+ super.firstUpdated(changed);
80
+ this.handleTokens();
81
+ }
82
+ updated(changed) {
83
+ super.updated(changed);
84
+ this.releaseCaret?.();
85
+ this.releaseCaret = undefined;
86
+ this.internals.setFormValue(this.value);
87
+ }
88
+ focus(options) {
89
+ this.control?.focus(options);
90
+ }
91
+ render() {
92
+ return html `${this.renderLabel()}
93
+ <div part="field">
94
+ <slot name="start"></slot>
95
+ ${this.renderTokenRow(html `<slot name="tags" @slotchange=${this.handleTokens}></slot>`, this.over)}
96
+ <input
97
+ part="control"
98
+ id="control"
99
+ type="text"
100
+ .value=${this.value}
101
+ name=${this.name || nothing}
102
+ placeholder=${this.placeholder || nothing}
103
+ aria-describedby=${this.describedBy ?? nothing}
104
+ ?disabled=${this.isDisabled}
105
+ ?required=${this.required}
106
+ @input=${this.handleInput}
107
+ @change=${this.handleChange}
108
+ @keydown=${this.handleKeydown}
109
+ />
110
+ <slot name="end"></slot>
111
+ ${this.showsClear
112
+ ? html `<button
113
+ part="clear"
114
+ type="button"
115
+ aria-label=${this.clearLabel}
116
+ @click=${this.handleClear}
117
+ >
118
+ ${drawIcon(x)}
119
+ </button>`
120
+ : nothing}
121
+ </div>
122
+ ${this.renderHint()}${this.renderFeedback()}`;
123
+ }
124
+ };
125
+ __decorate([
126
+ property({ type: Boolean })
127
+ ], HelixHeadlessFilterInput.prototype, "clearable", void 0);
128
+ __decorate([
129
+ property({ attribute: 'clear-label' })
130
+ ], HelixHeadlessFilterInput.prototype, "clearLabel", void 0);
131
+ __decorate([
132
+ property()
133
+ ], HelixHeadlessFilterInput.prototype, "placeholder", void 0);
134
+ __decorate([
135
+ state()
136
+ ], HelixHeadlessFilterInput.prototype, "over", void 0);
137
+ HelixHeadlessFilterInput = __decorate([
138
+ customElement('helix-headless-filter-input')
139
+ ], HelixHeadlessFilterInput);
140
+ export { HelixHeadlessFilterInput };
package/dist/index.d.ts CHANGED
@@ -42,6 +42,7 @@ export * from './drawer/index.js';
42
42
  export * from './drilldown/index.js';
43
43
  export * from './empty/index.js';
44
44
  export * from './file-input/index.js';
45
+ export * from './filter-input/index.js';
45
46
  export * from './format-date/index.js';
46
47
  export * from './heatmap/index.js';
47
48
  export * from './icon/index.js';
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ export * from './drawer/index.js';
33
33
  export * from './drilldown/index.js';
34
34
  export * from './empty/index.js';
35
35
  export * from './file-input/index.js';
36
+ export * from './filter-input/index.js';
36
37
  export * from './format-date/index.js';
37
38
  export * from './heatmap/index.js';
38
39
  export * from './icon/index.js';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-headless",
3
3
  "description": "Headless component tier. Structure, accessibility, state, and form participation. No styling.",
4
- "version": "0.12.2",
4
+ "version": "0.13.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -45,14 +45,14 @@
45
45
  },
46
46
  "bugs": "https://github.com/project-fusion/fusion/issues",
47
47
  "dependencies": {
48
- "@fusion.dev/helix-core": "0.12.2",
49
- "@fusion.dev/helix-icons": "0.12.2",
48
+ "@fusion.dev/helix-core": "0.13.0",
49
+ "@fusion.dev/helix-icons": "0.13.0",
50
50
  "uqr": "^0.1.3"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@custom-elements-manifest/analyzer": "^0.11.0",
54
- "@fusion.dev/helix-cem": "0.12.2",
55
- "@fusion.dev/helix-testing": "0.12.2",
54
+ "@fusion.dev/helix-cem": "0.13.0",
55
+ "@fusion.dev/helix-testing": "0.13.0",
56
56
  "@fusion.dev/scripts": "0.0.0-dev",
57
57
  "@happy-dom/global-registrator": "^20.0.0",
58
58
  "@lit-labs/ssr": "^3.3.0",