@fusion.dev/helix-headless 0.12.3 → 0.13.1

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.
@@ -49,6 +49,7 @@ declare const HelixHeadlessCombobox_base: (new (...args: any[]) => import("@fusi
49
49
  * @event {HelixBeforeCloseEvent} helix-before-close - Fired before it closes. Cancelable: refusing it takes the decision and nothing moves.
50
50
  * @event {HelixBeforeOpenEvent} helix-before-open - Fired before the panel opens. Cancelable: refusing it takes the decision and nothing moves.
51
51
  * @event {HelixCreateEvent} helix-create - Fired when the reader asks for something the list does not hold, and cancelable.
52
+ * @event {HelixFilterEvent} helix-filter - Fired as the reader types, carrying the text the list is being narrowed to. Cancelable: refusing it leaves the list whole, and is how a consumer takes the filtering for themselves.
52
53
  * @event {Event} input - Announced once the value has moved.
53
54
  * @event {Event} toggle - Says the state moved, carrying `newState` and `oldState`.
54
55
  *
@@ -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, MixinTokens, PopoverController, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, customElement, HelixCreateEvent, HelixFilterEvent, 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';
@@ -47,7 +47,10 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
47
47
  event.stopPropagation();
48
48
  const typed = event.target.value;
49
49
  this.typed = typed;
50
- this.listbox?.requestFilter(typed);
50
+ const narrowing = new HelixFilterEvent({ value: typed });
51
+ if (this.dispatchEvent(narrowing)) {
52
+ this.listbox?.requestFilter(typed);
53
+ }
51
54
  this.suggestion = this.suggest && isInsertion(event) ? this.completionFor(typed) : '';
52
55
  if (!this.open) {
53
56
  this.requestOpen(true);
@@ -251,6 +254,7 @@ let HelixHeadlessCombobox = class HelixHeadlessCombobox extends MixinControllerO
251
254
  .value=${this.value}
252
255
  @helix-before-change=${this.handleChange}
253
256
  @change=${this.stopInner}
257
+ @helix-filter=${this.stopInner}
254
258
  @input=${this.stopInner}
255
259
  >
256
260
  <!-- The field's words are read off the options, and on the first
@@ -32,6 +32,7 @@ registerElementEvents({
32
32
  'helix-before-close',
33
33
  'helix-before-open',
34
34
  'helix-create',
35
+ 'helix-filter',
35
36
  'input',
36
37
  'toggle',
37
38
  ],
@@ -52,6 +53,7 @@ registerElementEvents({
52
53
  'helix-headless-drilldown': ['helix-select'],
53
54
  'helix-headless-drilldown-item': ['helix-select'],
54
55
  'helix-headless-file-input': ['change', 'helix-before-change', 'input'],
56
+ 'helix-headless-filter-input': ['change', 'helix-before-change', 'input'],
55
57
  'helix-headless-heatmap': ['helix-resize'],
56
58
  'helix-headless-image': ['error', 'load'],
57
59
  '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.3",
4
+ "version": "0.13.1",
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.3",
49
- "@fusion.dev/helix-icons": "0.12.3",
48
+ "@fusion.dev/helix-core": "0.13.1",
49
+ "@fusion.dev/helix-icons": "0.13.1",
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.3",
55
- "@fusion.dev/helix-testing": "0.12.3",
54
+ "@fusion.dev/helix-cem": "0.13.1",
55
+ "@fusion.dev/helix-testing": "0.13.1",
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",