@fusion.dev/helix-core 0.149.1 → 0.150.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.
@@ -19688,6 +19688,13 @@
19688
19688
  "privacy": "private",
19689
19689
  "readonly": true
19690
19690
  },
19691
+ {
19692
+ "kind": "field",
19693
+ "name": "handleEditKey",
19694
+ "privacy": "private",
19695
+ "readonly": true,
19696
+ "description": "The first key that moves the caret or edits, while the focused field\nshows its rendering, puts the value in the field before the key acts.\n\nPicking hands focus back to a field that shows `August 5, 2026`, which is\nwhat the reader chose. `Intl` formats and does not parse, so a key that\nedited the rendering wrote text no date could be read from. The caret\nkeeps its place at the start, at the end, or around the whole text, and\ngoes to the end otherwise."
19697
+ },
19691
19698
  {
19692
19699
  "kind": "field",
19693
19700
  "name": "handleFocus",
@@ -20492,7 +20499,7 @@
20492
20499
  },
20493
20500
  {
20494
20501
  "kind": "mixin",
20495
- "description": "Grafts the segmented field React Aria's DateField and TimeField are, and Web\nAwesome's time input has, onto a picker field: one `spinbutton` for each part\nof the date or the time, in the order the locale writes them.\n\n**Typing into a text field asks the reader to know the format.** Date Input\nand Time Input read ISO, `2026-08-04` and `14:30`, because `Intl` formats and\ndoes not parse, and a reader in Germany who types `4.8.2026` is refused. A\nsegment needs no format: the month is a month, set with digits or the arrow\nkeys, and the layout puts it where the locale does, so the field reads\n`04.08.2026` in German and `08/04/2026` in English with nothing to parse.\n\n**What a segment does with a key**, which is React Aria's: digits fill it and\nmove on once it cannot take another, Up and Down step it and wrap, Home and\nEnd go to its ends, Backspace and Delete empty it, Left and Right move between\nsegments along the line of text, and Alt+Down opens the picker. Each segment\nis a tab stop, as each is in React Aria.\n\n**The value is whole or empty.** A date with no year is no date, so the value\nstays empty while any segment is, and the field reports `badInput` as a\nnative date input does with a half-typed date. The arithmetic is\n`date-segments`, so this draws and listens and nothing else.\n\n**Opt-in, with `segmented`**, because the text field is what every example and\nevery form already holds, and a row of segments draws differently.",
20502
+ "description": "Grafts the segmented field React Aria's DateField and TimeField are, and Web\nAwesome's time input has, onto a picker field: one `spinbutton` for each part\nof the date or the time, in the order the locale writes them.\n\n**Typing into a text field asks the reader to know the format.** Date Input\nand Time Input read ISO, `2026-08-04` and `14:30`, because `Intl` formats and\ndoes not parse, and a reader in Germany who types `4.8.2026` is refused. A\nsegment needs no format: the month is a month, set with digits or the arrow\nkeys, and the layout puts it where the locale does, so the field reads\n`04.08.2026` in German and `08/04/2026` in English with nothing to parse.\n\n**What a segment does with a key**, which is React Aria's: digits fill it and\nmove on once it cannot take another, Up and Down step it and wrap, Page Up\nand Page Down step it by a page, a week of days, five years or fifteen\nminutes, Home and End go to its ends, Backspace and Delete empty it, Left and Right move between\nsegments along the line of text, and Alt+Down opens the picker. Each segment\nis a tab stop, as each is in React Aria.\n\n**The value is whole or empty.** A date with no year is no date, so the value\nstays empty while any segment is, and the field reports `badInput` as a\nnative date input does with a half-typed date. The arithmetic is\n`date-segments`, so this draws and listens and nothing else.\n\n**Opt-in, with `segmented`**, because the text field is what every example and\nevery form already holds, and a row of segments draws differently.",
20496
20503
  "name": "MixinSegmentedField",
20497
20504
  "members": [
20498
20505
  {
@@ -21187,6 +21194,17 @@
21187
21194
  }
21188
21195
  },
21189
21196
  "description": "What the host does when the last token is asked to go.\n\nThe policy is shared and the mechanism is not: a combobox drops a value out\nof its selection, and a filter field takes a token out of its own list."
21197
+ },
21198
+ {
21199
+ "kind": "method",
21200
+ "name": "tokenElements",
21201
+ "privacy": "protected",
21202
+ "return": {
21203
+ "type": {
21204
+ "text": "HTMLElement[]"
21205
+ }
21206
+ },
21207
+ "description": "The tokens the arrow keys move between, in the order they are shown.\n\nEach is an element whose remove button is its `dismiss` part and whose\n`dismiss()` asks it to go, which `Tag` and `FilterInput.Chip` both are.\nNone by default."
21190
21208
  }
21191
21209
  ],
21192
21210
  "helix": {}
@@ -21315,6 +21333,44 @@
21315
21333
  }
21316
21334
  ]
21317
21335
  },
21336
+ {
21337
+ "kind": "method",
21338
+ "name": "tokenElements",
21339
+ "privacy": "protected",
21340
+ "return": {
21341
+ "type": {
21342
+ "text": "HTMLElement[]"
21343
+ }
21344
+ }
21345
+ },
21346
+ {
21347
+ "kind": "method",
21348
+ "name": "tokenStops",
21349
+ "privacy": "private",
21350
+ "return": {
21351
+ "type": {
21352
+ "text": "HTMLElement[]"
21353
+ }
21354
+ },
21355
+ "description": "The remove button of each token shown that has one, in order."
21356
+ },
21357
+ {
21358
+ "kind": "field",
21359
+ "name": "tokenControl",
21360
+ "type": {
21361
+ "text": "HTMLInputElement | null"
21362
+ },
21363
+ "privacy": "private",
21364
+ "description": "The host's text field.",
21365
+ "readonly": true
21366
+ },
21367
+ {
21368
+ "kind": "field",
21369
+ "name": "handleTokenKeydown",
21370
+ "privacy": "private",
21371
+ "readonly": true,
21372
+ "description": "The arrow keys between the tokens, and Delete or Backspace on one.\n\nThe arrow toward the end of the line moves to the next token, and past\nthe last into the text with the caret at its start. Delete or Backspace\nasks the focused token to go, as its remove button does, and focus that\nleaves with it comes back to the text."
21373
+ },
21318
21374
  {
21319
21375
  "kind": "method",
21320
21376
  "name": "renderRest",
@@ -25093,7 +25149,7 @@
25093
25149
  }
25094
25150
  }
25095
25151
  ],
25096
- "description": "What an editing key does to a segment, or `null` for a key that edits\nnothing.\n\nThe arrows step it and wrap, Home and End take it to its lowest and highest,\nBackspace and Delete empty it, a digit is typed as `typeDigit` types it, and\non the meridiem a letter picks the half whose word starts with it. `advance`\nsays the segment is full and focus moves on."
25152
+ "description": "What an editing key does to a segment, or `null` for a key that edits\nnothing.\n\nThe arrows step it and wrap, Page Up and Page Down step it by a page, Home\nand End take it to its lowest and highest, Backspace and Delete empty it, a\ndigit is typed as `typeDigit` types it, and on the meridiem a letter picks\nthe half whose word starts with it. `advance` says the segment is full and\nfocus moves on."
25097
25153
  },
25098
25154
  {
25099
25155
  "kind": "function",
@@ -31407,7 +31463,7 @@
31407
31463
  "type": {
31408
31464
  "text": "string"
31409
31465
  },
31410
- "default": "'0.149.1'",
31466
+ "default": "'0.150.0'",
31411
31467
  "description": "The version of the package this file ships in.\n\n**Written by `fusion/scripts/version.mjs`** wherever it writes the package's\n`package.json`, and stamped back into the repository beside it after a\nrelease, so the two never disagree; a spec beside this holds them to it.\nNothing else in the running code knows which release it is."
31412
31468
  }
31413
31469
  ],
@@ -31967,6 +32023,6 @@
31967
32023
  ],
31968
32024
  "helix": {
31969
32025
  "package": "@fusion.dev/helix-core",
31970
- "version": "0.149.1"
32026
+ "version": "0.150.0"
31971
32027
  }
31972
32028
  }
@@ -5,6 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
7
  import { property, state } from 'lit/decorators.js';
8
+ const caretKeys = new Set(['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'End', 'Home']);
8
9
  export const MixinPickerEditing = (Base) => {
9
10
  class PickerEditingElement extends Base {
10
11
  constructor() {
@@ -18,6 +19,37 @@ export const MixinPickerEditing = (Base) => {
18
19
  this.handlePress = (event) => {
19
20
  this.pressing = event.type === 'pointerdown' && event.composedPath()[0] === this.control;
20
21
  };
22
+ this.handleEditKey = (event) => {
23
+ const control = this.control;
24
+ if (this.editing ||
25
+ this.readOnly ||
26
+ this.isDisabled ||
27
+ !control ||
28
+ event.composedPath()[0] !== control ||
29
+ event.altKey ||
30
+ event.ctrlKey ||
31
+ event.metaKey ||
32
+ event.isComposing ||
33
+ (event.key.length !== 1 && !caretKeys.has(event.key))) {
34
+ return;
35
+ }
36
+ const shown = control.value.length;
37
+ const start = control.selectionStart ?? shown;
38
+ const end = control.selectionEnd ?? shown;
39
+ this.editing = true;
40
+ const text = this.display;
41
+ if (text === control.value) {
42
+ return;
43
+ }
44
+ control.value = text;
45
+ if (start === 0 && end === shown) {
46
+ control.setSelectionRange(0, text.length);
47
+ }
48
+ else {
49
+ const at = end === 0 ? 0 : text.length;
50
+ control.setSelectionRange(at, at);
51
+ }
52
+ };
21
53
  this.handleFocus = (event) => {
22
54
  this.editing = !this.returning;
23
55
  const pressed = this.pressing;
@@ -49,12 +81,14 @@ export const MixinPickerEditing = (Base) => {
49
81
  this.addEventListener('pointerdown', this.handlePress);
50
82
  this.addEventListener('pointercancel', this.handlePress);
51
83
  this.addEventListener('click', this.handlePress);
84
+ this.addEventListener('keydown', this.handleEditKey);
52
85
  }
53
86
  disconnectedCallback() {
54
87
  super.disconnectedCallback();
55
88
  this.removeEventListener('pointerdown', this.handlePress);
56
89
  this.removeEventListener('pointercancel', this.handlePress);
57
90
  this.removeEventListener('click', this.handlePress);
91
+ this.removeEventListener('keydown', this.handleEditKey);
58
92
  }
59
93
  }
60
94
  __decorate([
@@ -62,8 +62,9 @@ export declare class MixinSegmentedFieldInterface {
62
62
  * `04.08.2026` in German and `08/04/2026` in English with nothing to parse.
63
63
  *
64
64
  * **What a segment does with a key**, which is React Aria's: digits fill it and
65
- * move on once it cannot take another, Up and Down step it and wrap, Home and
66
- * End go to its ends, Backspace and Delete empty it, Left and Right move between
65
+ * move on once it cannot take another, Up and Down step it and wrap, Page Up
66
+ * and Page Down step it by a page, a week of days, five years or fifteen
67
+ * minutes, Home and End go to its ends, Backspace and Delete empty it, Left and Right move between
67
68
  * segments along the line of text, and Alt+Down opens the picker. Each segment
68
69
  * is a tab stop, as each is in React Aria.
69
70
  *
@@ -236,6 +236,7 @@ export const MixinSegmentedField = (Base) => {
236
236
  aria-valuenow=${spin ? (shown ?? nothing) : nothing}
237
237
  aria-valuetext=${spin ? (empty ? this.emptySegmentLabel : segmentValueText(kind, this.parts, twelve, locale)) : nothing}
238
238
  aria-disabled=${this.isDisabled ? 'true' : nothing}
239
+ aria-required=${this.required ? 'true' : nothing}
239
240
  aria-readonly=${this.readOnly ? 'true' : nothing}
240
241
  @keydown=${this.handleSegmentKeydown}
241
242
  >${text}</span
@@ -99,6 +99,14 @@ export declare class MixinTokensInterface {
99
99
  * of its selection, and a filter field takes a token out of its own list.
100
100
  */
101
101
  protected removeLastToken(): void;
102
+ /**
103
+ * The tokens the arrow keys move between, in the order they are shown.
104
+ *
105
+ * Each is an element whose remove button is its `dismiss` part and whose
106
+ * `dismiss()` asks it to go, which `Tag` and `FilterInput.Chip` both are.
107
+ * None by default.
108
+ */
109
+ protected tokenElements(): HTMLElement[];
102
110
  }
103
111
  /**
104
112
  * A row of tokens inside a field, and the behavior that goes with it.
@@ -8,6 +8,7 @@ import { caretUp } from '@fusion.dev/helix-icons';
8
8
  import { html, nothing } from 'lit';
9
9
  import { property, state } from 'lit/decorators.js';
10
10
  import { drawIcon } from '../../utils/draw-icon/draw-icon.js';
11
+ import { inlineStep } from '../../utils/inline-step/inline-step.js';
11
12
  import { formatWords, words } from '../../words/words.js';
12
13
  export const MixinTokens = (Base) => {
13
14
  class TokensElement extends Base {
@@ -22,6 +23,42 @@ export const MixinTokens = (Base) => {
22
23
  this.toggleExpanded = () => {
23
24
  this.expanded = !this.expanded;
24
25
  };
26
+ this.handleTokenKeydown = (event) => {
27
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) {
28
+ return;
29
+ }
30
+ const stops = this.tokenStops();
31
+ const path = event.composedPath();
32
+ const at = stops.findIndex((stop) => path.includes(stop));
33
+ if (at < 0) {
34
+ return;
35
+ }
36
+ const step = inlineStep(this, event.key);
37
+ if (step !== 0) {
38
+ event.preventDefault();
39
+ const next = stops[at + step];
40
+ if (next) {
41
+ next.focus();
42
+ }
43
+ else if (step > 0) {
44
+ this.tokenControl?.focus();
45
+ this.tokenControl?.setSelectionRange(0, 0);
46
+ }
47
+ return;
48
+ }
49
+ if (event.key !== 'Delete' && event.key !== 'Backspace') {
50
+ return;
51
+ }
52
+ event.preventDefault();
53
+ const stop = stops[at];
54
+ const token = this.tokenElements().find((one) => one.shadowRoot?.contains(stop ?? null));
55
+ token?.dismiss?.();
56
+ requestAnimationFrame(() => {
57
+ if (!this.matches(':focus-within')) {
58
+ this.tokenControl?.focus();
59
+ }
60
+ });
61
+ };
25
62
  }
26
63
  static { this.words = ''; }
27
64
  get shownLimit() {
@@ -30,6 +67,26 @@ export const MixinTokens = (Base) => {
30
67
  renderTokenRow(content, over) {
31
68
  return html `<span part="tags">${content}${this.renderRest(over)}</span>`;
32
69
  }
70
+ connectedCallback() {
71
+ super.connectedCallback();
72
+ this.addEventListener('keydown', this.handleTokenKeydown);
73
+ }
74
+ disconnectedCallback() {
75
+ super.disconnectedCallback();
76
+ this.removeEventListener('keydown', this.handleTokenKeydown);
77
+ }
78
+ tokenElements() {
79
+ return [];
80
+ }
81
+ tokenStops() {
82
+ return this.tokenElements()
83
+ .filter((token) => !token.hidden)
84
+ .map((token) => token.shadowRoot?.querySelector('[part~="dismiss"]'))
85
+ .filter((stop) => stop !== null && stop !== undefined);
86
+ }
87
+ get tokenControl() {
88
+ return this.renderRoot.querySelector('[part~="control"]');
89
+ }
33
90
  renderRest(over) {
34
91
  if (!this.expandable) {
35
92
  return over > 0
@@ -59,6 +116,22 @@ export const MixinTokens = (Base) => {
59
116
  return limit > 0 ? Math.max(0, tokens.length - limit) : 0;
60
117
  }
61
118
  tokenKeydown(event, typed) {
119
+ const control = event.target;
120
+ if (inlineStep(this, event.key) === -1 &&
121
+ !event.shiftKey &&
122
+ !event.altKey &&
123
+ !event.ctrlKey &&
124
+ !event.metaKey &&
125
+ control?.selectionStart === 0 &&
126
+ control.selectionEnd === 0) {
127
+ const last = this.tokenStops().at(-1);
128
+ if (last) {
129
+ event.preventDefault();
130
+ last.focus();
131
+ return true;
132
+ }
133
+ return false;
134
+ }
62
135
  if (event.key !== 'Backspace' || typed !== '') {
63
136
  return false;
64
137
  }
@@ -86,10 +86,11 @@ export interface SegmentKeyState {
86
86
  * What an editing key does to a segment, or `null` for a key that edits
87
87
  * nothing.
88
88
  *
89
- * The arrows step it and wrap, Home and End take it to its lowest and highest,
90
- * Backspace and Delete empty it, a digit is typed as `typeDigit` types it, and
91
- * on the meridiem a letter picks the half whose word starts with it. `advance`
92
- * says the segment is full and focus moves on.
89
+ * The arrows step it and wrap, Page Up and Page Down step it by a page, Home
90
+ * and End take it to its lowest and highest, Backspace and Delete empty it, a
91
+ * digit is typed as `typeDigit` types it, and on the meridiem a letter picks
92
+ * the half whose word starts with it. `advance` says the segment is full and
93
+ * focus moves on.
93
94
  */
94
95
  export declare const segmentKey: (key: string, { kind, locale, segments, twelve, typed }: SegmentKeyState) => {
95
96
  advance: boolean;
@@ -119,11 +119,23 @@ export const typeDigit = (segments, kind, typed, digit, twelve) => {
119
119
  const next = value >= min ? setSegment(segments, kind, value, twelve) : segments;
120
120
  return { advance: full && value >= min, segments: next, typed: full ? '' : text };
121
121
  };
122
+ const pageStep = {
123
+ day: 7,
124
+ hour: 2,
125
+ minute: 15,
126
+ month: 2,
127
+ second: 15,
128
+ year: 5,
129
+ };
122
130
  export const segmentKey = (key, { kind, locale, segments, twelve, typed }) => {
123
131
  const done = (next) => next === undefined ? null : { advance: false, segments: next, typed: '' };
124
132
  if (key === 'ArrowUp' || key === 'ArrowDown') {
125
133
  return done(stepSegment(segments, kind, key === 'ArrowUp' ? 1 : -1, twelve));
126
134
  }
135
+ if (key === 'PageUp' || key === 'PageDown') {
136
+ const by = (key === 'PageUp' ? 1 : -1) * (pageStep[kind] ?? 1);
137
+ return done(stepSegment(segments, kind, by, twelve));
138
+ }
127
139
  if (key === 'Home' || key === 'End') {
128
140
  const { max, min } = segmentRange(kind, segments, twelve);
129
141
  return done(setSegment(segments, kind, key === 'Home' ? min : max, twelve));
package/dist/version.d.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * release, so the two never disagree; a spec beside this holds them to it.
7
7
  * Nothing else in the running code knows which release it is.
8
8
  */
9
- export declare const VERSION = "0.149.1";
9
+ export declare const VERSION = "0.150.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.149.1';
1
+ export const VERSION = '0.150.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-core",
3
3
  "description": "Helix element primitives: base classes, mixins, decorators, utilities.",
4
- "version": "0.149.1",
4
+ "version": "0.150.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -56,11 +56,11 @@
56
56
  "bugs": "https://github.com/project-fusion/fusion/issues",
57
57
  "dependencies": {
58
58
  "@floating-ui/dom": "^1.8.0",
59
- "@fusion.dev/helix-icons": "0.149.1"
59
+ "@fusion.dev/helix-icons": "0.150.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@fusion.dev/helix-cem": "0.149.1",
63
- "@fusion.dev/helix-testing": "0.149.1",
62
+ "@fusion.dev/helix-cem": "0.150.0",
63
+ "@fusion.dev/helix-testing": "0.150.0",
64
64
  "@lit-labs/ssr": "^4.1.0",
65
65
  "lit": "^3.2.1"
66
66
  },