@fusion.dev/helix-headless 0.143.0 → 0.144.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.
Files changed (191) hide show
  1. package/custom-elements.json +8551 -375
  2. package/dist/area-chart/root/area-chart-root.d.ts +12 -4
  3. package/dist/area-chart/root/area-chart-root.js +24 -7
  4. package/dist/avatar/root/avatar-root.d.ts +14 -0
  5. package/dist/avatar/root/avatar-root.js +23 -9
  6. package/dist/badge/root/badge-root.d.ts +11 -7
  7. package/dist/badge/root/badge-root.js +1 -1
  8. package/dist/bar-chart/root/bar-chart-root.d.ts +12 -3
  9. package/dist/bar-chart/root/bar-chart-root.js +17 -10
  10. package/dist/button/root/button-root.d.ts +47 -1
  11. package/dist/button/root/button-root.js +38 -4
  12. package/dist/callout/root/callout-root.d.ts +18 -1
  13. package/dist/callout/root/callout-root.js +15 -4
  14. package/dist/card/root/card-root.d.ts +30 -1
  15. package/dist/card/root/card-root.js +38 -5
  16. package/dist/checkbox/root/checkbox-root.js +2 -0
  17. package/dist/checkbox-group/root/checkbox-group-root.d.ts +53 -3
  18. package/dist/checkbox-group/root/checkbox-group-root.js +71 -22
  19. package/dist/code-block/lines.d.ts +13 -0
  20. package/dist/code-block/lines.js +2 -0
  21. package/dist/code-block/root/code-block-root.d.ts +30 -0
  22. package/dist/code-block/root/code-block-root.js +23 -2
  23. package/dist/color-input/root/color-input-root.d.ts +34 -1
  24. package/dist/color-input/root/color-input-root.js +23 -4
  25. package/dist/color-picker/channels.d.ts +26 -0
  26. package/dist/color-picker/channels.js +84 -0
  27. package/dist/color-picker/root/color-picker-root.d.ts +81 -5
  28. package/dist/color-picker/root/color-picker-root.js +103 -10
  29. package/dist/combobox/root/combobox-root.d.ts +38 -3
  30. package/dist/combobox/root/combobox-root.js +64 -2
  31. package/dist/command/root/command-root.d.ts +30 -4
  32. package/dist/command/root/command-root.js +33 -3
  33. package/dist/copy-button/from.d.ts +22 -0
  34. package/dist/copy-button/from.js +28 -0
  35. package/dist/copy-button/root/copy-button-root.d.ts +27 -1
  36. package/dist/copy-button/root/copy-button-root.js +43 -7
  37. package/dist/date-input/root/date-input-root.d.ts +73 -4
  38. package/dist/date-input/root/date-input-root.js +93 -43
  39. package/dist/date-picker/root/date-picker-root.d.ts +145 -1
  40. package/dist/date-picker/root/date-picker-root.js +239 -48
  41. package/dist/dialog/description.d.ts +9 -0
  42. package/dist/dialog/description.js +14 -0
  43. package/dist/dialog/root/dialog-root.d.ts +64 -1
  44. package/dist/dialog/root/dialog-root.js +49 -23
  45. package/dist/donut-chart/root/donut-chart-root.d.ts +25 -4
  46. package/dist/donut-chart/root/donut-chart-root.js +21 -6
  47. package/dist/drawer/root/drawer-root.d.ts +63 -2
  48. package/dist/drawer/root/drawer-root.js +95 -19
  49. package/dist/drawer/swipe-start.d.ts +9 -0
  50. package/dist/drawer/swipe-start.js +12 -0
  51. package/dist/element-events.generated.js +8 -5
  52. package/dist/empty/root/empty-root.d.ts +1 -1
  53. package/dist/file-input/root/file-input-root.d.ts +47 -0
  54. package/dist/file-input/root/file-input-root.js +60 -22
  55. package/dist/file-input/screen.d.ts +35 -0
  56. package/dist/file-input/screen.js +49 -0
  57. package/dist/filter-input/root/filter-input-root.d.ts +38 -4
  58. package/dist/filter-input/root/filter-input-root.js +67 -3
  59. package/dist/filter-input/suggest.d.ts +17 -0
  60. package/dist/filter-input/suggest.js +23 -0
  61. package/dist/format-date/options.d.ts +33 -0
  62. package/dist/format-date/options.js +42 -0
  63. package/dist/format-date/root/format-date-root.d.ts +51 -0
  64. package/dist/format-date/root/format-date-root.js +58 -38
  65. package/dist/heatmap/root/heatmap-root.d.ts +23 -6
  66. package/dist/heatmap/root/heatmap-root.js +24 -13
  67. package/dist/icon/index.d.ts +2 -0
  68. package/dist/icon/index.js +2 -0
  69. package/dist/icon/registry.d.ts +37 -0
  70. package/dist/icon/registry.js +83 -0
  71. package/dist/icon/root/icon-root.d.ts +30 -4
  72. package/dist/icon/root/icon-root.js +41 -6
  73. package/dist/icon/root/index.d.ts +2 -0
  74. package/dist/icon/root/index.js +2 -0
  75. package/dist/icon/set.d.ts +1 -0
  76. package/dist/icon/set.js +3 -0
  77. package/dist/image/root/image-root.d.ts +14 -0
  78. package/dist/image/root/image-root.js +15 -2
  79. package/dist/input/root/input-root.d.ts +11 -0
  80. package/dist/input/root/input-root.js +15 -0
  81. package/dist/kbd/root/kbd-root.d.ts +20 -13
  82. package/dist/kbd/root/kbd-root.js +35 -37
  83. package/dist/kbd/shortcut.d.ts +18 -0
  84. package/dist/kbd/shortcut.js +35 -0
  85. package/dist/legend/hidden-series.d.ts +8 -0
  86. package/dist/legend/hidden-series.js +13 -0
  87. package/dist/legend/root/legend-root.d.ts +56 -0
  88. package/dist/legend/root/legend-root.js +54 -5
  89. package/dist/lightbox/item/lightbox-item.d.ts +15 -0
  90. package/dist/lightbox/item/lightbox-item.js +10 -1
  91. package/dist/lightbox/root/lightbox-root.d.ts +61 -1
  92. package/dist/lightbox/root/lightbox-root.js +94 -4
  93. package/dist/line-chart/root/line-chart-root.d.ts +11 -3
  94. package/dist/line-chart/root/line-chart-root.js +19 -5
  95. package/dist/listbox/range.d.ts +17 -0
  96. package/dist/listbox/range.js +5 -0
  97. package/dist/listbox/root/listbox-root.d.ts +74 -3
  98. package/dist/listbox/root/listbox-root.js +87 -9
  99. package/dist/loader/busy.d.ts +17 -0
  100. package/dist/loader/busy.js +24 -0
  101. package/dist/loader/root/loader-root.d.ts +37 -0
  102. package/dist/loader/root/loader-root.js +52 -3
  103. package/dist/meter/root/meter-root.d.ts +7 -0
  104. package/dist/meter/root/meter-root.js +6 -3
  105. package/dist/notifications/feed-keys.d.ts +10 -0
  106. package/dist/notifications/feed-keys.js +14 -0
  107. package/dist/notifications/notification/notification.d.ts +17 -0
  108. package/dist/notifications/notification/notification.js +21 -2
  109. package/dist/notifications/root/notifications-root.d.ts +42 -0
  110. package/dist/notifications/root/notifications-root.js +60 -1
  111. package/dist/number-input/root/number-input-root.d.ts +47 -0
  112. package/dist/number-input/root/number-input-root.js +58 -22
  113. package/dist/object-inspector/root/object-inspector-root.d.ts +1 -0
  114. package/dist/object-inspector/root/object-inspector-root.js +1 -1
  115. package/dist/option/root/option-root.d.ts +54 -5
  116. package/dist/option/root/option-root.js +34 -5
  117. package/dist/pin-input/format.d.ts +7 -0
  118. package/dist/pin-input/format.js +16 -0
  119. package/dist/pin-input/root/pin-input-root.d.ts +21 -0
  120. package/dist/pin-input/root/pin-input-root.js +29 -2
  121. package/dist/popover/root/popover-root.d.ts +62 -4
  122. package/dist/popover/root/popover-root.js +53 -5
  123. package/dist/progress/root/progress-root.d.ts +7 -0
  124. package/dist/progress/root/progress-root.js +9 -3
  125. package/dist/qr-code/export.d.ts +31 -0
  126. package/dist/qr-code/export.js +10 -0
  127. package/dist/qr-code/root/qr-code-root.d.ts +26 -0
  128. package/dist/qr-code/root/qr-code-root.js +49 -2
  129. package/dist/radio/group/radio-group.d.ts +23 -0
  130. package/dist/radio/group/radio-group.js +18 -0
  131. package/dist/rating/root/rating-root.d.ts +25 -0
  132. package/dist/rating/root/rating-root.js +45 -3
  133. package/dist/scatter-chart/root/scatter-chart-root.d.ts +17 -3
  134. package/dist/scatter-chart/root/scatter-chart-root.js +25 -7
  135. package/dist/select/root/select-root.d.ts +24 -0
  136. package/dist/select/root/select-root.js +20 -6
  137. package/dist/skeleton/root/skeleton-root.d.ts +25 -0
  138. package/dist/skeleton/root/skeleton-root.js +17 -2
  139. package/dist/slider/root/slider-root.d.ts +39 -0
  140. package/dist/slider/root/slider-root.js +45 -9
  141. package/dist/stat/root/stat-root.d.ts +2 -0
  142. package/dist/stat/root/stat-root.js +1 -1
  143. package/dist/steps/step/step.d.ts +1 -0
  144. package/dist/steps/step/step.js +1 -1
  145. package/dist/switch/root/switch-root.d.ts +12 -2
  146. package/dist/switch/root/switch-root.js +6 -1
  147. package/dist/tag/group/tag-group.d.ts +107 -0
  148. package/dist/tag/group/tag-group.js +157 -0
  149. package/dist/tag/index.d.ts +1 -0
  150. package/dist/tag/index.js +1 -0
  151. package/dist/tag/root/tag-root.d.ts +60 -0
  152. package/dist/tag/root/tag-root.js +74 -18
  153. package/dist/tag/selection.d.ts +10 -0
  154. package/dist/tag/selection.js +12 -0
  155. package/dist/textarea/grow.d.ts +22 -0
  156. package/dist/textarea/grow.js +20 -0
  157. package/dist/textarea/root/textarea-root.d.ts +9 -0
  158. package/dist/textarea/root/textarea-root.js +9 -1
  159. package/dist/time-input/root/time-input-root.d.ts +44 -5
  160. package/dist/time-input/root/time-input-root.js +69 -39
  161. package/dist/time-picker/root/time-picker-root.d.ts +54 -8
  162. package/dist/time-picker/root/time-picker-root.js +121 -91
  163. package/dist/toast/hotkey.d.ts +22 -0
  164. package/dist/toast/hotkey.js +23 -0
  165. package/dist/toast/item/toast-item.d.ts +16 -1
  166. package/dist/toast/item/toast-item.js +41 -13
  167. package/dist/toast/root/toast-root.d.ts +42 -8
  168. package/dist/toast/root/toast-root.js +64 -4
  169. package/dist/toggle/group/toggle-group.d.ts +57 -15
  170. package/dist/toggle/group/toggle-group.js +69 -9
  171. package/dist/toggle/root/toggle-root.d.ts +12 -1
  172. package/dist/toggle/root/toggle-root.js +11 -3
  173. package/dist/tooltip/focus-target.d.ts +6 -0
  174. package/dist/tooltip/focus-target.js +9 -0
  175. package/dist/tooltip/root/tooltip-root.d.ts +42 -17
  176. package/dist/tooltip/root/tooltip-root.js +50 -9
  177. package/dist/tour/naming.d.ts +30 -0
  178. package/dist/tour/naming.js +10 -0
  179. package/dist/tour/root/tour-root.d.ts +35 -0
  180. package/dist/tour/root/tour-root.js +56 -5
  181. package/dist/tour/stop/tour-stop.d.ts +53 -8
  182. package/dist/tour/stop/tour-stop.js +45 -15
  183. package/dist/tour/wait.d.ts +11 -0
  184. package/dist/tour/wait.js +19 -0
  185. package/dist/trend/root/trend-root.d.ts +13 -0
  186. package/dist/trend/root/trend-root.js +14 -9
  187. package/lint.json +1 -1
  188. package/package.json +5 -5
  189. package/types/jsx.d.ts +4862 -254
  190. package/types/svelte.d.ts +1 -0
  191. package/types/vue.d.ts +1149 -67
@@ -16,6 +16,12 @@ declare const HelixHeadlessFileInput_base: (new (...args: any[]) => import("@fus
16
16
  * visible trigger is a `<label for>`. The label is not focusable. The input is,
17
17
  * and Enter and Space on it open the picker.
18
18
  *
19
+ * Files arrive from the picker, a drop, or a paste onto the field, and each one
20
+ * is screened: `accept`, `max-file-size`, `max-files` and your own `validate`
21
+ * can refuse it. A refused file is left out, `helix-reject` lists each one with
22
+ * its reason, and a reader hears `rejected-text` with the names filled in.
23
+ * `directory` asks the picker for a folder.
24
+ *
19
25
  * @since 0.1.0
20
26
  * @status stable
21
27
  * @category forms
@@ -29,6 +35,7 @@ declare const HelixHeadlessFileInput_base: (new (...args: any[]) => import("@fus
29
35
  *
30
36
  * @event {Event} change - Fired after the value changes.
31
37
  * @event {HelixBeforeChangeEvent} helix-before-change - Fired before the chosen files change, with their names, and cancelable. Canceling it keeps the files the field had, even when the browser has already written a new pick.
38
+ * @event {HelixRejectEvent} helix-reject - Fired when files arrive and some are left out. `detail.files` lists each with its `reason`: `type`, `size`, `count` or `invalid`, and your `validate` message.
32
39
  * @event {Event} input - Fired after the value changes.
33
40
  *
34
41
  * @csspart control - The file input itself, which carries the label and validity.
@@ -38,19 +45,24 @@ declare const HelixHeadlessFileInput_base: (new (...args: any[]) => import("@fus
38
45
  * @csspart file - One chosen file.
39
46
  * @csspart hint - Help under the field, while nothing is wrong.
40
47
  * @csspart kind - A file's type.
48
+ * @csspart kind-cell - The grid cell around the tag, rendered only when the tag sits in a `Tag.Group`.
41
49
  * @csspart kind-dismiss - The remove button, when `dismissible`.
42
50
  * @csspart kind-dismiss-mark - The glyph on the dismiss control, where none is slotted.
43
51
  * @csspart kind-label - The label, trimmed to its capitals and baseline.
52
+ * @csspart kind-link - The link around the label, when the tag has an `href`.
44
53
  * @csspart kind-start - The category before the label.
45
54
  * @csspart label - The label above the field.
46
55
  * @csspart list - The chosen files.
56
+ * @csspart live - The live region that says `rejected-text` when files are refused. It is rendered from the first refusal.
47
57
  * @csspart meta - The row that holds a file's size and type.
48
58
  * @csspart name - A file's name.
49
59
  * @csspart remove - The control that removes a file.
50
60
  * @csspart size - A file's size.
61
+ * @csspart size-cell - The grid cell around the tag, rendered only when the tag sits in a `Tag.Group`.
51
62
  * @csspart size-dismiss - The remove button, when `dismissible`.
52
63
  * @csspart size-dismiss-mark - The glyph on the dismiss control, where none is slotted.
53
64
  * @csspart size-label - The label, trimmed to its capitals and baseline.
65
+ * @csspart size-link - The link around the label, when the tag has an `href`.
54
66
  * @csspart size-start - The category before the label.
55
67
  * @csspart trigger - The label that opens the picker.
56
68
  *
@@ -69,16 +81,35 @@ export declare class HelixHeadlessFileInput extends HelixHeadlessFileInput_base
69
81
  * Which camera or microphone a mobile picker should offer, if any.
70
82
  */
71
83
  capture?: 'environment' | 'user';
84
+ /**
85
+ * Whether the picker chooses a folder, and the field takes every file in
86
+ * it. The platform's `webkitdirectory`.
87
+ */
88
+ directory: boolean;
72
89
  /**
73
90
  * Language tag the sizes are written in.
74
91
  *
75
92
  * Absent uses the runtime's own.
76
93
  */
77
94
  locale: string;
95
+ /**
96
+ * The most files the field holds, under `multiple`. Files past it are
97
+ * refused.
98
+ */
99
+ maxFiles?: number;
100
+ /**
101
+ * The most bytes one file may have. A larger one is refused.
102
+ */
103
+ maxFileSize?: number;
78
104
  /**
79
105
  * Whether more than one file can be chosen.
80
106
  */
81
107
  multiple: boolean;
108
+ /**
109
+ * What a reader hears when files are refused, with `{count}` and `{names}`
110
+ * filled in.
111
+ */
112
+ rejectedText: string;
82
113
  /**
83
114
  * The accessible name of the control that removes a file. `{name}` is
84
115
  * replaced with the file's name.
@@ -92,6 +123,22 @@ export declare class HelixHeadlessFileInput extends HelixHeadlessFileInput_base
92
123
  * The trigger's text when nothing is slotted into it.
93
124
  */
94
125
  triggerLabel: string;
126
+ /**
127
+ * Your own check on each arriving file, which returns why it is refused, or
128
+ * nothing to take it. A property, since a function cannot be an attribute.
129
+ */
130
+ validate?: (file: File) => string | null | undefined;
131
+ /** The live region, rendered from the first refusal. */
132
+ private readonly status;
133
+ /**
134
+ * Screens arriving files and commits what is taken, after the files the
135
+ * field keeps. Refused files are announced and said.
136
+ */
137
+ private take;
138
+ /** Sends `helix-reject` and says which files were left out. */
139
+ private refuse;
140
+ /** Takes files pasted onto the field, as a drop takes them. */
141
+ private readonly handlePaste;
95
142
  /**
96
143
  * Whether something is being dragged over the field.
97
144
  *
@@ -4,11 +4,12 @@ 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, drawIcon, fillTemplate, HelixBeforeChangeEvent, HelixFormElement, MixinField, requestChange, restoreValue, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, customElement, drawIcon, drawStatus, fillTemplate, formatNumbers, HelixBeforeChangeEvent, HelixFormElement, HelixRejectEvent, MixinField, requestChange, restoreValue, StatusController, } from '@fusion.dev/helix-core';
8
8
  import { trash } from '@fusion.dev/helix-icons';
9
9
  import { nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
11
11
  import { html } from 'lit/static-html.js';
12
+ import { screenFiles } from '../screen.js';
12
13
  import '../../tag/index.js';
13
14
  const named = (label, name) => label.includes('{name}') ? fillTemplate(label, { name }) : `${label} ${name}`;
14
15
  const size = (bytes, locale) => {
@@ -33,22 +34,6 @@ const kind = (file) => {
33
34
  const subtype = file.type.split('/')[1]?.replace(/^x-/, '').split('+')[0] ?? '';
34
35
  return /^[a-z0-9]{1,6}$/i.test(subtype) ? subtype.toUpperCase() : 'File';
35
36
  };
36
- const accepts = (file, accept) => {
37
- const tokens = accept
38
- .split(',')
39
- .map((token) => token.trim().toLowerCase())
40
- .filter(Boolean);
41
- if (tokens.length === 0) {
42
- return true;
43
- }
44
- const name = file.name.toLowerCase();
45
- const type = file.type.toLowerCase();
46
- return tokens.some((token) => token.startsWith('.')
47
- ? name.endsWith(token)
48
- : token.endsWith('/*')
49
- ? type.startsWith(token.slice(0, -1))
50
- : type === token);
51
- };
52
37
  let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(HelixFormElement) {
53
38
  constructor() {
54
39
  super(...arguments);
@@ -57,15 +42,26 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
57
42
  target: () => this.partNamed('control'),
58
43
  });
59
44
  this.accept = '';
45
+ this.directory = false;
60
46
  this.locale = '';
61
47
  this.multiple = false;
48
+ this.rejectedText = 'Not added: {names}';
62
49
  this.removeLabel = 'Remove {name}';
63
50
  this.triggerLabel = 'Choose files';
51
+ this.status = new StatusController(this);
52
+ this.handlePaste = (event) => {
53
+ const pasted = [...(event.clipboardData?.files ?? [])];
54
+ if (this.isDisabled || pasted.length === 0) {
55
+ return;
56
+ }
57
+ event.preventDefault();
58
+ this.take(pasted, this.multiple ? this.files : []);
59
+ };
64
60
  this.dropping = false;
65
61
  this.committed = [];
66
62
  this.handleChange = (event) => {
67
63
  event.stopPropagation();
68
- this.commit(this.files);
64
+ this.take(this.files, [], true);
69
65
  };
70
66
  this.handleDragOver = (event) => {
71
67
  if (this.isDisabled) {
@@ -84,11 +80,11 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
84
80
  return;
85
81
  }
86
82
  event.preventDefault();
87
- const dropped = [...event.dataTransfer.files].filter((file) => accepts(file, this.accept));
83
+ const dropped = [...event.dataTransfer.files];
88
84
  if (dropped.length === 0) {
89
85
  return;
90
86
  }
91
- this.commit(this.multiple ? [...this.files, ...dropped] : dropped.slice(-1));
87
+ this.take(dropped, this.multiple ? this.files : []);
92
88
  };
93
89
  this.handleRemove = (event) => {
94
90
  const at = Number(event.currentTarget.dataset.at);
@@ -102,6 +98,30 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
102
98
  });
103
99
  };
104
100
  }
101
+ take(arriving, keeping, picked = false) {
102
+ const { kept, refused } = screenFiles(arriving, keeping, {
103
+ accept: this.accept,
104
+ maxFileSize: this.maxFileSize,
105
+ maxFiles: this.maxFiles,
106
+ multiple: this.multiple || picked,
107
+ validate: this.validate,
108
+ });
109
+ const same = (one, two) => one.length === two.length && one.every((file, at) => file === two[at]);
110
+ if (picked || !(same(kept, this.committed) && same(kept, this.files))) {
111
+ this.commit(kept);
112
+ }
113
+ if (refused.length > 0) {
114
+ this.refuse(refused);
115
+ }
116
+ }
117
+ refuse(refused) {
118
+ this.dispatchEvent(new HelixRejectEvent({ files: refused }));
119
+ const words = fillTemplate(this.rejectedText, {
120
+ count: formatNumbers(this.lang)(refused.length),
121
+ names: new Intl.ListFormat(this.lang || undefined, { style: 'short', type: 'unit' }).format(refused.map((one) => one.file.name)),
122
+ });
123
+ this.status.say(words);
124
+ }
105
125
  get files() {
106
126
  return [...(this.control?.files ?? [])];
107
127
  }
@@ -190,10 +210,10 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
190
210
  }
191
211
  renderMeta(file) {
192
212
  return html `<${this.tag('tag')} part="size"
193
- exportparts="dismiss: size-dismiss, dismiss-mark: size-dismiss-mark, label: size-label, start: size-start">${size(file.size, this.locale)}</${this.tag('tag')}>
213
+ exportparts="cell: size-cell, dismiss: size-dismiss, dismiss-mark: size-dismiss-mark, label: size-label, link: size-link, start: size-start">${size(file.size, this.locale)}</${this.tag('tag')}>
194
214
  <${this.tag('tag')}
195
215
  part="kind"
196
- exportparts="dismiss: kind-dismiss, dismiss-mark: kind-dismiss-mark, label: kind-label, start: kind-start">${kind(file)}</${this.tag('tag')}>`;
216
+ exportparts="cell: kind-cell, dismiss: kind-dismiss, dismiss-mark: kind-dismiss-mark, label: kind-label, link: kind-link, start: kind-start">${kind(file)}</${this.tag('tag')}>`;
197
217
  }
198
218
  render() {
199
219
  const files = this.files;
@@ -204,6 +224,7 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
204
224
  @dragleave=${this.handleDragLeave}
205
225
  @dragover=${this.handleDragOver}
206
226
  @drop=${this.handleDrop}
227
+ @paste=${this.handlePaste}
207
228
  >
208
229
  <input
209
230
  part="control"
@@ -217,6 +238,7 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
217
238
  ?disabled=${this.isDisabled}
218
239
  ?multiple=${this.multiple}
219
240
  ?required=${this.required}
241
+ ?webkitdirectory=${this.directory}
220
242
  @change=${this.handleChange}
221
243
  @input=${this.stopInner}
222
244
  />
@@ -228,6 +250,7 @@ let HelixHeadlessFileInput = class HelixHeadlessFileInput extends MixinField(Hel
228
250
  one ordering decision this component makes differently from the way it
229
251
  reads. Reasoning above renderMeta. -->
230
252
  ${this.renderHint()}${this.renderFeedback()}
253
+ ${drawStatus(this.status)}
231
254
  ${files.length === 0
232
255
  ? nothing
233
256
  : html `<ul part="list">
@@ -258,18 +281,33 @@ __decorate([
258
281
  __decorate([
259
282
  property()
260
283
  ], HelixHeadlessFileInput.prototype, "capture", void 0);
284
+ __decorate([
285
+ property({ type: Boolean })
286
+ ], HelixHeadlessFileInput.prototype, "directory", void 0);
261
287
  __decorate([
262
288
  property()
263
289
  ], HelixHeadlessFileInput.prototype, "locale", void 0);
290
+ __decorate([
291
+ property({ attribute: 'max-files', type: Number })
292
+ ], HelixHeadlessFileInput.prototype, "maxFiles", void 0);
293
+ __decorate([
294
+ property({ attribute: 'max-file-size', type: Number })
295
+ ], HelixHeadlessFileInput.prototype, "maxFileSize", void 0);
264
296
  __decorate([
265
297
  property({ reflect: true, type: Boolean })
266
298
  ], HelixHeadlessFileInput.prototype, "multiple", void 0);
299
+ __decorate([
300
+ property({ attribute: 'rejected-text' })
301
+ ], HelixHeadlessFileInput.prototype, "rejectedText", void 0);
267
302
  __decorate([
268
303
  property({ attribute: 'remove-label' })
269
304
  ], HelixHeadlessFileInput.prototype, "removeLabel", void 0);
270
305
  __decorate([
271
306
  property({ attribute: 'trigger-label' })
272
307
  ], HelixHeadlessFileInput.prototype, "triggerLabel", void 0);
308
+ __decorate([
309
+ property({ attribute: false })
310
+ ], HelixHeadlessFileInput.prototype, "validate", void 0);
273
311
  __decorate([
274
312
  state()
275
313
  ], HelixHeadlessFileInput.prototype, "dropping", void 0);
@@ -0,0 +1,35 @@
1
+ import { type RefusedFile } from '@fusion.dev/helix-core';
2
+ /**
3
+ * Which of the files arriving at a file field it takes, and why it refuses the
4
+ * rest: the rules `accept`, `max-file-size`, `max-files` and `validate` set.
5
+ * Zag's file upload screens a drop the same way and reports each refused file
6
+ * with its reason.
7
+ */
8
+ export type { Refusal, RefusedFile } from '@fusion.dev/helix-core';
9
+ /** The rules a field screens arriving files by. */
10
+ export interface ScreenRules {
11
+ /** A native `accept` list. Empty takes every type. */
12
+ readonly accept: string;
13
+ /** The most bytes one file may have. */
14
+ readonly maxFileSize?: number | undefined;
15
+ /** The most files the field holds, counting the ones it has. */
16
+ readonly maxFiles?: number | undefined;
17
+ /** Whether the field holds more than one file. */
18
+ readonly multiple: boolean;
19
+ /** Your own check, which returns why a file is refused, or nothing. */
20
+ readonly validate?: ((file: File) => string | null | undefined) | undefined;
21
+ }
22
+ /**
23
+ * Whether a file is one `accept` lists: an extension, a type, or a family
24
+ * such as `image/*`, as the platform reads it.
25
+ */
26
+ export declare const accepts: (file: File, accept: string) => boolean;
27
+ /**
28
+ * Screens arriving files against the rules, given the files the field keeps.
29
+ * `kept` is what the field holds afterward: the files it keeps and the
30
+ * arrivals it took, or for a single-file field the last arrival it took.
31
+ */
32
+ export declare const screenFiles: (arriving: File[], keeping: File[], rules: ScreenRules) => {
33
+ kept: File[];
34
+ refused: RefusedFile[];
35
+ };
@@ -0,0 +1,49 @@
1
+ import { commaList } from '@fusion.dev/helix-core';
2
+ export const accepts = (file, accept) => {
3
+ const tokens = commaList(accept).map((token) => token.toLowerCase());
4
+ if (tokens.length === 0) {
5
+ return true;
6
+ }
7
+ const name = file.name.toLowerCase();
8
+ const type = file.type.toLowerCase();
9
+ return tokens.some((token) => token.startsWith('.')
10
+ ? name.endsWith(token)
11
+ : token.endsWith('/*')
12
+ ? type.startsWith(token.slice(0, -1))
13
+ : type === token);
14
+ };
15
+ const refusal = (file, rules) => {
16
+ if (!accepts(file, rules.accept)) {
17
+ return { file, reason: 'type' };
18
+ }
19
+ if (rules.maxFileSize !== undefined && file.size > rules.maxFileSize) {
20
+ return { file, reason: 'size' };
21
+ }
22
+ const message = rules.validate?.(file);
23
+ return message ? { file, message, reason: 'invalid' } : undefined;
24
+ };
25
+ export const screenFiles = (arriving, keeping, rules) => {
26
+ const refused = [];
27
+ const taken = [];
28
+ for (const file of arriving) {
29
+ const refusedFor = refusal(file, rules);
30
+ if (refusedFor) {
31
+ refused.push(refusedFor);
32
+ }
33
+ else {
34
+ taken.push(file);
35
+ }
36
+ }
37
+ if (!rules.multiple) {
38
+ const last = taken.at(-1);
39
+ for (const file of taken.slice(0, -1)) {
40
+ refused.push({ file, reason: 'count' });
41
+ }
42
+ return { kept: last ? [last] : keeping.slice(0, 1), refused };
43
+ }
44
+ const room = rules.maxFiles === undefined ? taken.length : Math.max(0, rules.maxFiles - keeping.length);
45
+ for (const file of taken.slice(room)) {
46
+ refused.push({ file, reason: 'count' });
47
+ }
48
+ return { kept: [...keeping, ...taken.slice(0, room)], refused };
49
+ };
@@ -13,8 +13,10 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
13
13
  * its tokens show a selection it owns, drawn from the options it was given.
14
14
  *
15
15
  * `value` is the typed text. Every keystroke fires `input` with the text on the
16
- * element, so a table can narrow as the user types. Tokens are not reported,
17
- * because they are markup you already hold. Backspace in an empty field fires
16
+ * element, so a table can narrow as the user types. A form receives the text
17
+ * under `name`, and then one more entry under `name` for each token: its
18
+ * `value` attribute, or for a `FilterChip`, its field and values written
19
+ * `field:values` as the field reads them typed. Backspace in an empty field fires
18
20
  * `helix-dismiss` on the last token, the same event its remove control fires,
19
21
  * so one handler covers the pointer and the keyboard.
20
22
  *
@@ -38,8 +40,11 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
38
40
  * moves through them with the arrow keys, and presses one on Enter. The option
39
41
  * fires its own `helix-select`, and you decide what choosing it means. Keys
40
42
  * before a colon and values after it use the same panel with different
41
- * contents. `readFilterText` is exported so you can narrow the panel against
42
- * partly typed text. The panel opens when it has options and closes when it has
43
+ * contents. `filter-suggestions` narrows the panel to the options whose words
44
+ * hold the term at the caret, after its colon, ignoring case and accents, and
45
+ * hides the rest. `readFilterText` is exported so you can narrow it yourself
46
+ * against partly typed text. While the panel is open, a reader hears how many
47
+ * suggestions it offers. The panel opens when it has options and closes when it has
43
48
  * none, and a choice does not close it, so after picking a key you see its
44
49
  * values next. Use `selectionStart` and `setSelectionRange` to write a
45
50
  * completion back and put the caret after it, or the next parse reads the wrong
@@ -93,6 +98,7 @@ declare const HelixHeadlessFilterInput_base: (new (...args: any[]) => import("@f
93
98
  * @csspart hint - The help text under the field, shown while there is no error.
94
99
  * @csspart label - The label above the field.
95
100
  * @csspart lead - The box before the text that holds the `start` slot.
101
+ * @csspart live - The live region that says how many suggestions the open panel offers. It is rendered from the first time the panel opens with suggestions.
96
102
  * @csspart rest - The count of tokens that did not fit. It is a `<button>` with `expandable` and a `<span>` without it.
97
103
  * @csspart surface - The floating panel the suggestions sit in.
98
104
  * @csspart tags - The row the tokens sit in.
@@ -118,12 +124,40 @@ export declare class HelixHeadlessFilterInput extends HelixHeadlessFilterInput_b
118
124
  * an icon.
119
125
  */
120
126
  clearLabel: string;
127
+ /**
128
+ * Whether the panel narrows itself to the suggestions that hold the term
129
+ * being typed, as React Aria's Autocomplete filters. Off, the panel shows
130
+ * what you slot and you narrow it.
131
+ */
132
+ filterSuggestions: boolean;
121
133
  /**
122
134
  * The text shown while the field is empty.
123
135
  *
124
136
  * Not a label: it goes when typing starts.
125
137
  */
126
138
  placeholder: string;
139
+ /** What a reader hears for one suggestion, with `{count}` filled in. */
140
+ suggestionLabel: string;
141
+ /** What a reader hears for any other number of suggestions, with `{count}` filled in. */
142
+ suggestionsLabel: string;
143
+ /** The live region, rendered from the first count said. */
144
+ private readonly status;
145
+ /** The count last said, so the same panel is said once. */
146
+ private saidCount;
147
+ /** The options this element hid while narrowing, so only those are shown again. */
148
+ private readonly narrowed;
149
+ /**
150
+ * Hides the slotted options whose words do not hold the term at the caret,
151
+ * under `filter-suggestions`, and shows again the ones it hid before.
152
+ */
153
+ private narrow;
154
+ /** Says how many suggestions the open panel offers, when that changes. */
155
+ private sayCount;
156
+ /**
157
+ * What the form receives: the text, then one entry for each token that
158
+ * says what it filters. See the class description.
159
+ */
160
+ private formValue;
127
161
  /** How many tokens did not fit, which is what the row's count renders. */
128
162
  private over;
129
163
  /**
@@ -4,13 +4,14 @@ 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, drawIcon, HelixFacetEvent, HelixFormElement, holdCaret, MixinControllerOpen, MixinControllerValue, MixinField, MixinTokens, PopoverController, restoreValue, scrollItemIntoView, } from '@fusion.dev/helix-core';
7
+ import { AriaForwardController, countLabel, customElement, drawIcon, drawStatus, HelixFacetEvent, HelixFormElement, holdCaret, MixinControllerOpen, MixinControllerValue, MixinField, MixinTokens, PopoverController, restoreValue, StatusController, scrollItemIntoView, } from '@fusion.dev/helix-core';
8
8
  import { x } from '@fusion.dev/helix-icons';
9
9
  import { html, nothing } from 'lit';
10
10
  import { property, state } from 'lit/decorators.js';
11
11
  import { HelixHeadlessFilterField } from '../field/filter-field.js';
12
12
  import { readFilterText } from '../grammar.js';
13
13
  import { HelixHeadlessFilterMenu } from '../menu/filter-menu.js';
14
+ import { currentTerm, suggests, tokenEntry } from '../suggest.js';
14
15
  const asksToGo = (node) => typeof node?.dismiss === 'function';
15
16
  const isOption = (node) => 'active' in node && 'value' in node;
16
17
  let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinControllerOpen(MixinTokens(MixinControllerValue(MixinField(HelixFormElement)))) {
@@ -22,7 +23,13 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
22
23
  this.announcesInput = true;
23
24
  this.clearable = false;
24
25
  this.clearLabel = 'Clear';
26
+ this.filterSuggestions = false;
25
27
  this.placeholder = '';
28
+ this.suggestionLabel = '{count} suggestion';
29
+ this.suggestionsLabel = '{count} suggestions';
30
+ this.status = new StatusController(this);
31
+ this.saidCount = -1;
32
+ this.narrowed = new Set();
26
33
  this.over = 0;
27
34
  this.activeAt = -1;
28
35
  this.suggesting = false;
@@ -154,6 +161,51 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
154
161
  this.over = this.hideOverflow(this.tokens);
155
162
  };
156
163
  }
164
+ narrow() {
165
+ const slot = this.renderRoot?.querySelector('slot[name="suggestions"]');
166
+ const term = this.filterSuggestions
167
+ ? currentTerm(this.value, this.control?.selectionStart ?? this.value.length)
168
+ : '';
169
+ for (const node of slot?.assignedElements({ flatten: true }) ?? []) {
170
+ const options = isOption(node) ? [node] : [...node.children].filter(isOption);
171
+ for (const option of options) {
172
+ const hide = !suggests(option.textContent ?? '', term);
173
+ if (hide && !option.hidden) {
174
+ option.hidden = true;
175
+ this.narrowed.add(option);
176
+ }
177
+ else if (!hide && this.narrowed.has(option)) {
178
+ option.hidden = false;
179
+ this.narrowed.delete(option);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ sayCount() {
185
+ const count = this.open && this.suggesting ? this.suggestions.length : -1;
186
+ if (count === this.saidCount) {
187
+ return;
188
+ }
189
+ this.saidCount = count;
190
+ if (count < 0) {
191
+ return;
192
+ }
193
+ this.status.sayNow(countLabel(count, this.suggestionLabel, this.suggestionsLabel, this.lang));
194
+ }
195
+ formValue() {
196
+ const entries = (this.tokens?.assignedElements({ flatten: true }) ?? [])
197
+ .map(tokenEntry)
198
+ .filter((entry) => entry !== null);
199
+ if (this.name === '' || entries.length === 0) {
200
+ return this.value;
201
+ }
202
+ const data = new FormData();
203
+ data.append(this.name, this.value);
204
+ for (const entry of entries) {
205
+ data.append(this.name, entry);
206
+ }
207
+ return data;
208
+ }
157
209
  static { this.shadowRootOptions = {
158
210
  ...HelixFormElement.shadowRootOptions,
159
211
  delegatesFocus: true,
@@ -181,7 +233,7 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
181
233
  }
182
234
  }
183
235
  }
184
- return found.filter((option) => !option.disabled);
236
+ return found.filter((option) => !option.disabled && !option.hidden);
185
237
  }
186
238
  get selectionDirection() {
187
239
  return this.control?.selectionDirection ?? 'none';
@@ -264,6 +316,7 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
264
316
  from < 0 ? (by > 0 ? 0 : options.length - 1) : (from + by + options.length) % options.length;
265
317
  }
266
318
  refreshSuggesting() {
319
+ this.narrow();
267
320
  const options = this.suggestions;
268
321
  this.suggesting = options.length > 0;
269
322
  this.activeAt = this.activeAt < options.length ? this.activeAt : -1;
@@ -311,7 +364,8 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
311
364
  super.updated(changed);
312
365
  this.releaseCaret?.();
313
366
  this.releaseCaret = undefined;
314
- this.internals.setFormValue(this.value);
367
+ this.internals.setFormValue(this.formValue());
368
+ this.sayCount();
315
369
  const control = this.control;
316
370
  if (control) {
317
371
  this.copyValidity(control);
@@ -391,6 +445,7 @@ let HelixHeadlessFilterInput = class HelixHeadlessFilterInput extends MixinContr
391
445
  >
392
446
  <slot name="suggestions" @slotchange=${this.handleSuggestions}></slot>
393
447
  </div>
448
+ ${drawStatus(this.status)}
394
449
  ${this.renderHint()}${this.renderFeedback()}`;
395
450
  }
396
451
  };
@@ -400,9 +455,18 @@ __decorate([
400
455
  __decorate([
401
456
  property({ attribute: 'clear-label' })
402
457
  ], HelixHeadlessFilterInput.prototype, "clearLabel", void 0);
458
+ __decorate([
459
+ property({ attribute: 'filter-suggestions', type: Boolean })
460
+ ], HelixHeadlessFilterInput.prototype, "filterSuggestions", void 0);
403
461
  __decorate([
404
462
  property()
405
463
  ], HelixHeadlessFilterInput.prototype, "placeholder", void 0);
464
+ __decorate([
465
+ property({ attribute: 'suggestion-label' })
466
+ ], HelixHeadlessFilterInput.prototype, "suggestionLabel", void 0);
467
+ __decorate([
468
+ property({ attribute: 'suggestions-label' })
469
+ ], HelixHeadlessFilterInput.prototype, "suggestionsLabel", void 0);
406
470
  __decorate([
407
471
  state()
408
472
  ], HelixHeadlessFilterInput.prototype, "over", void 0);
@@ -0,0 +1,17 @@
1
+ /**
2
+ * What a filter field narrows its suggestions by, and what it sends a form,
3
+ * kept apart from the element so each can be read and tested alone.
4
+ */
5
+ /**
6
+ * The term being typed at the caret: the run of text since the last space,
7
+ * and after its colon where it has one, so `status:ope` narrows by `ope`.
8
+ */
9
+ export declare const currentTerm: (text: string, caret?: number) => string;
10
+ /** Whether a suggestion's words hold the term, ignoring case and accents. */
11
+ export declare const suggests: (words: string, term: string) => boolean;
12
+ /**
13
+ * What a token sends a form: its `value` attribute, or for a token naming a
14
+ * field, the field and the values it shows, written `field:values` the way the
15
+ * field reads them typed. Nothing for a token that says neither.
16
+ */
17
+ export declare const tokenEntry: (token: Element) => string | null;
@@ -0,0 +1,23 @@
1
+ export const currentTerm = (text, caret = text.length) => {
2
+ const before = text.slice(0, caret);
3
+ const word = before.slice(before.search(/\S*$/u));
4
+ const colon = word.indexOf(':');
5
+ return (colon === -1 ? word : word.slice(colon + 1)).replace(/^-/u, '');
6
+ };
7
+ export const suggests = (words, term) => {
8
+ const plain = (text) => text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
9
+ return term === '' || plain(words).includes(plain(term));
10
+ };
11
+ export const tokenEntry = (token) => {
12
+ const written = token.getAttribute('value');
13
+ if (written !== null) {
14
+ return written;
15
+ }
16
+ const field = token.getAttribute('name');
17
+ const values = (token.textContent ?? '').replace(/\s+/gu, ' ').trim();
18
+ if (!field || values === '') {
19
+ return null;
20
+ }
21
+ const negated = token.getAttribute('operator')?.startsWith('not') ? '-' : '';
22
+ return `${negated}${field}:${values.replace(/\s*,\s*/gu, ',')}`;
23
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * What a Format Date was asked for, in its own spelling: the `Intl` options a
3
+ * consumer set, and whether the timestamp is a day with no time of day.
4
+ */
5
+ export interface Asked {
6
+ dateOnly: boolean;
7
+ dateStyle?: 'full' | 'long' | 'medium' | 'short' | undefined;
8
+ day?: 'numeric' | '2-digit' | undefined;
9
+ era?: 'long' | 'narrow' | 'short' | undefined;
10
+ hour?: 'numeric' | '2-digit' | undefined;
11
+ hourCycle?: 'h11' | 'h12' | 'h23' | 'h24' | undefined;
12
+ minute?: 'numeric' | '2-digit' | undefined;
13
+ month?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow' | undefined;
14
+ second?: 'numeric' | '2-digit' | undefined;
15
+ timeStyle?: 'full' | 'long' | 'medium' | 'short' | undefined;
16
+ timeZone?: string | undefined;
17
+ timeZoneName?: 'long' | 'short' | undefined;
18
+ weekday?: 'long' | 'short' | 'narrow' | undefined;
19
+ year?: 'numeric' | '2-digit' | undefined;
20
+ }
21
+ /**
22
+ * The `Intl.DateTimeFormat` options for what was asked, and none that was not.
23
+ *
24
+ * **A style wins over the fields.** `dateStyle` and `timeStyle` are each
25
+ * locale's own whole pattern, and `Intl` throws a `TypeError` when one is
26
+ * given beside `month` or `hour`, so the fields are left out wherever a style
27
+ * is set. `hourCycle` and `timeZone` go with either.
28
+ *
29
+ * **A day with no time is read in UTC** unless a zone is given. Parsed, it is
30
+ * midnight UTC, and read in a zone west of it that is the day before: measured
31
+ * before this, `2026-08-04` read "August 3, 2026" in Los Angeles.
32
+ */
33
+ export declare const formatOptions: (asked: Asked) => Intl.DateTimeFormatOptions;