@zeyos/zx 4.3.4 → 5.0.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 (231) hide show
  1. package/CHANGELOG.md +438 -0
  2. package/README.md +49 -10
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +217 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +33 -3
  6. package/dist/types/components/avatar/avatar.d.ts +46 -7
  7. package/dist/types/components/badge/badge.d.ts +21 -1
  8. package/dist/types/components/button/button.d.ts +23 -0
  9. package/dist/types/components/calendar/calendar.d.ts +19 -0
  10. package/dist/types/components/card/card.d.ts +7 -0
  11. package/dist/types/components/card-view/card-view.d.ts +11 -3
  12. package/dist/types/components/card-view/record-card.d.ts +7 -1
  13. package/dist/types/components/chart/chart.d.ts +10 -0
  14. package/dist/types/components/checklist/checklist.d.ts +9 -0
  15. package/dist/types/components/code-editor/code-editor.d.ts +149 -0
  16. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  17. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  18. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  19. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  20. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  21. package/dist/types/components/datebox/datebox.d.ts +7 -2
  22. package/dist/types/components/dialog/dialog.d.ts +75 -6
  23. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  24. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  25. package/dist/types/components/field/field.d.ts +24 -2
  26. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  27. package/dist/types/components/file-item/file-item.d.ts +262 -0
  28. package/dist/types/components/file-list/file-list.d.ts +171 -0
  29. package/dist/types/components/filter/filter.d.ts +91 -36
  30. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  31. package/dist/types/components/form/form.d.ts +12 -1
  32. package/dist/types/components/kanban-view/kanban-card.d.ts +146 -0
  33. package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
  34. package/dist/types/components/launcher/launcher.d.ts +278 -26
  35. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  36. package/dist/types/components/menu-button/menu-button.d.ts +37 -42
  37. package/dist/types/components/message/message.d.ts +126 -17
  38. package/dist/types/components/modal/modal.d.ts +61 -2
  39. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  40. package/dist/types/components/panel/panel.d.ts +7 -0
  41. package/dist/types/components/search/search.d.ts +7 -0
  42. package/dist/types/components/select/color.d.ts +12 -0
  43. package/dist/types/components/select/glyphs.d.ts +124 -0
  44. package/dist/types/components/select/select.d.ts +15 -3
  45. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  46. package/dist/types/components/stat-tile/stat-tile.d.ts +288 -0
  47. package/dist/types/components/tabbox/tabbox.d.ts +16 -0
  48. package/dist/types/components/table/table.d.ts +190 -1
  49. package/dist/types/components/table-view/table-view.d.ts +11 -5
  50. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  51. package/dist/types/components/timebox/timebox.d.ts +7 -0
  52. package/dist/types/components/tree/tree.d.ts +13 -0
  53. package/dist/types/components/value-list/value-list.d.ts +8 -0
  54. package/dist/types/components/view/record-view.d.ts +96 -5
  55. package/dist/types/core/breakpoint.d.ts +1 -1
  56. package/dist/types/core/icons.d.ts +37 -22
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +15 -1
  59. package/dist/types/index.d.ts +16 -3
  60. package/dist/types/internal/app-rail.d.ts +10 -0
  61. package/dist/types/internal/menu-items.d.ts +134 -0
  62. package/dist/types/zeyos/connect.d.ts +6 -0
  63. package/dist/types/zeyos/form.d.ts +50 -9
  64. package/dist/types/zeyos/index.d.ts +1 -1
  65. package/dist/types/zeyos/query.d.ts +79 -0
  66. package/dist/types/zeyos/schema.d.ts +24 -1
  67. package/dist/types/zeyos/table.d.ts +100 -14
  68. package/dist/types/zeyos/view.d.ts +10 -0
  69. package/dist/zx-zeyos.esm.js +2206 -489
  70. package/dist/zx-zeyos.esm.min.js +56 -1
  71. package/dist/zx.css +3322 -724
  72. package/dist/zx.esm.js +12244 -5809
  73. package/dist/zx.esm.min.js +58 -3
  74. package/dist/zx.global.js +12246 -5811
  75. package/dist/zx.global.min.js +58 -3
  76. package/dist/zx.min.css +1 -1
  77. package/docs/api.json +1683 -146
  78. package/docs/llms.md +777 -110
  79. package/docs/llms.txt +14 -8
  80. package/package.json +1 -1
  81. package/src/components/account-menu/account-menu.css +35 -10
  82. package/src/components/activity-item/activity-item.css +137 -0
  83. package/src/components/activity-item/activity-item.js +602 -0
  84. package/src/components/activity-list/activity-list.css +99 -0
  85. package/src/components/activity-list/activity-list.js +409 -0
  86. package/src/components/app-icon/app-icon.css +1 -1
  87. package/src/components/app-rail/app-rail.css +191 -22
  88. package/src/components/app-sidebar/app-sidebar.css +349 -24
  89. package/src/components/app-sidebar/app-sidebar.js +86 -11
  90. package/src/components/aurora/aurora.css +5 -3
  91. package/src/components/avatar/avatar.css +32 -7
  92. package/src/components/avatar/avatar.js +72 -8
  93. package/src/components/badge/badge.css +54 -2
  94. package/src/components/badge/badge.js +29 -2
  95. package/src/components/button/button.css +74 -8
  96. package/src/components/button/button.js +44 -1
  97. package/src/components/calendar/calendar.css +46 -46
  98. package/src/components/calendar/calendar.js +33 -3
  99. package/src/components/card/card.css +2 -2
  100. package/src/components/card/card.js +12 -1
  101. package/src/components/card-view/card-view.css +10 -7
  102. package/src/components/card-view/card-view.js +25 -9
  103. package/src/components/card-view/record-card.js +7 -3
  104. package/src/components/chart/chart.css +4 -1
  105. package/src/components/chart/chart.js +12 -0
  106. package/src/components/check-button/check-button.css +10 -2
  107. package/src/components/checklist/checklist.css +9 -2
  108. package/src/components/checklist/checklist.js +11 -5
  109. package/src/components/checklist/field-adapter.js +3 -1
  110. package/src/components/code-editor/code-editor.css +68 -0
  111. package/src/components/code-editor/code-editor.js +306 -0
  112. package/src/components/code-editor/field-adapter.js +34 -0
  113. package/src/components/context-menu/context-menu.css +87 -5
  114. package/src/components/context-menu/context-menu.js +22 -30
  115. package/src/components/copy/copy-button.css +6 -0
  116. package/src/components/copy/copy-input.css +8 -3
  117. package/src/components/data-filter/data-filter.css +5 -4
  118. package/src/components/date-picker/date-picker.css +6 -6
  119. package/src/components/date-picker/date-picker.js +22 -8
  120. package/src/components/date-picker/date-range-picker.css +3 -3
  121. package/src/components/date-picker/month-picker.js +3 -3
  122. package/src/components/date-picker/time-picker.css +1 -1
  123. package/src/components/date-picker/time-picker.js +20 -8
  124. package/src/components/datebox/date-range-box.css +79 -2
  125. package/src/components/datebox/date-range-box.js +13 -2
  126. package/src/components/datebox/datebox.css +79 -2
  127. package/src/components/datebox/datebox.js +13 -2
  128. package/src/components/dialog/dialog.css +26 -0
  129. package/src/components/dialog/dialog.js +174 -70
  130. package/src/components/dropdown/dropdown.css +1 -1
  131. package/src/components/dropdown/dropdown.js +7 -5
  132. package/src/components/empty-state/empty-state.css +2 -2
  133. package/src/components/entity-ref/entity-ref.css +182 -0
  134. package/src/components/entity-ref/entity-ref.js +496 -0
  135. package/src/components/field/field.css +28 -9
  136. package/src/components/field/field.js +68 -26
  137. package/src/components/field-upload/field-upload.css +1 -1
  138. package/src/components/fieldset/fieldset.css +5 -3
  139. package/src/components/fieldset/fieldset.js +28 -5
  140. package/src/components/file-item/file-item.css +88 -0
  141. package/src/components/file-item/file-item.js +505 -0
  142. package/src/components/file-list/file-list.css +51 -0
  143. package/src/components/file-list/file-list.js +320 -0
  144. package/src/components/filter/filter.css +27 -1
  145. package/src/components/filter/filter.js +205 -47
  146. package/src/components/filter-panel/filter-panel.css +104 -0
  147. package/src/components/filter-panel/filter-panel.js +964 -0
  148. package/src/components/finder/finder.css +15 -13
  149. package/src/components/form/form.css +5 -4
  150. package/src/components/form/form.js +11 -2
  151. package/src/components/grid/grid.css +1 -1
  152. package/src/components/groupbox/groupbox.css +1 -1
  153. package/src/components/kanban-view/kanban-card.js +247 -0
  154. package/src/components/kanban-view/kanban-view.css +226 -8
  155. package/src/components/kanban-view/kanban-view.js +255 -12
  156. package/src/components/launcher/launcher.css +107 -25
  157. package/src/components/launcher/launcher.js +557 -63
  158. package/src/components/list-toolbar/list-toolbar.css +194 -0
  159. package/src/components/list-toolbar/list-toolbar.js +851 -0
  160. package/src/components/master-panel/master-panel.css +2 -0
  161. package/src/components/menu-button/menu-button.css +87 -5
  162. package/src/components/menu-button/menu-button.js +86 -46
  163. package/src/components/message/message.css +38 -5
  164. package/src/components/message/message.js +230 -17
  165. package/src/components/modal/modal.css +15 -0
  166. package/src/components/modal/modal.js +159 -3
  167. package/src/components/navigation-bar/navigation-bar.css +95 -11
  168. package/src/components/navigation-bar/navigation-bar.js +171 -4
  169. package/src/components/number-field/field-adapter.js +1 -0
  170. package/src/components/number-field/number-field.css +70 -0
  171. package/src/components/number-field/number-field.js +2 -0
  172. package/src/components/pagination/pagination.css +8 -6
  173. package/src/components/panel/panel.css +1 -1
  174. package/src/components/panel/panel.js +13 -2
  175. package/src/components/rating/rating.css +7 -0
  176. package/src/components/search/search.js +17 -5
  177. package/src/components/select/color.js +22 -0
  178. package/src/components/select/field-adapter.js +6 -1
  179. package/src/components/select/glyphs.js +165 -0
  180. package/src/components/select/select.css +138 -45
  181. package/src/components/select/select.js +86 -47
  182. package/src/components/sheet/sheet.css +11 -8
  183. package/src/components/slider/slider.css +16 -2
  184. package/src/components/slider/slider.js +1 -1
  185. package/src/components/sort-control/sort-control.css +20 -0
  186. package/src/components/sort-control/sort-control.js +140 -0
  187. package/src/components/stat-tile/stat-tile.css +208 -0
  188. package/src/components/stat-tile/stat-tile.js +601 -0
  189. package/src/components/tabbox/tabbox.css +32 -27
  190. package/src/components/tabbox/tabbox.js +17 -2
  191. package/src/components/table/table.css +155 -31
  192. package/src/components/table/table.js +386 -33
  193. package/src/components/table-view/table-view.css +8 -2
  194. package/src/components/table-view/table-view.js +21 -6
  195. package/src/components/tag-picker/tag-picker.css +86 -6
  196. package/src/components/tag-picker/tag-picker.js +25 -8
  197. package/src/components/timebox/timebox.css +22 -2
  198. package/src/components/timebox/timebox.js +18 -5
  199. package/src/components/toggle/toggle.css +12 -1
  200. package/src/components/toolbar/toolbar.js +2 -2
  201. package/src/components/tooltip/tooltip.css +4 -0
  202. package/src/components/tooltip/tooltip.js +10 -3
  203. package/src/components/tree/tree.css +25 -16
  204. package/src/components/tree/tree.js +31 -0
  205. package/src/components/value-list/value-list.css +3 -3
  206. package/src/components/value-list/value-list.js +22 -6
  207. package/src/components/view/record-view.css +5 -0
  208. package/src/components/view/record-view.js +100 -7
  209. package/src/core/breakpoint.js +1 -1
  210. package/src/core/icons.js +259 -57
  211. package/src/core/motion.js +147 -0
  212. package/src/core/overlay-host.js +21 -2
  213. package/src/index.js +30 -3
  214. package/src/internal/app-rail.js +18 -1
  215. package/src/internal/menu-items.js +142 -0
  216. package/src/zeyos/connect.js +6 -2
  217. package/src/zeyos/form.js +67 -27
  218. package/src/zeyos/index.js +1 -1
  219. package/src/zeyos/query.js +153 -0
  220. package/src/zeyos/schema.js +144 -27
  221. package/src/zeyos/table.js +68 -5
  222. package/src/zeyos/view.js +6 -0
  223. package/styles/base.css +56 -0
  224. package/styles/icons.css +11 -1
  225. package/styles/print.css +192 -0
  226. package/styles/tokens/dark.css +113 -37
  227. package/styles/tokens/global.css +121 -27
  228. package/styles/tokens/semantic.css +100 -31
  229. package/styles/tokens/themes.css +7 -6
  230. package/styles/zx.css +11 -1
  231. package/styles/tokens/density.css +0 -9
@@ -1,5 +1,6 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h } from '../../core/dom.js';
3
+ import { prefersReducedMotion, watchMotion } from '../../core/motion.js';
3
4
  import { uid } from '../../core/util.js';
4
5
 
5
6
  /** @typedef {{label?: string, data?: unknown[], [key: string]: unknown}} ChartDataset */
@@ -9,6 +10,9 @@ import { uid } from '../../core/util.js';
9
10
  * @property {string} type Chart type.
10
11
  * @property {ChartData} data Chart data.
11
12
  * @property {Record<string, unknown>} options Engine-specific options.
13
+ * @property {boolean} reducedMotion Whether the chart must not animate: the operating system asks
14
+ * for reduced motion, or the host sits inside a `data-zx-motion="reduced"` region. An adapter turns
15
+ * its engine's own animation off when this is true; `ChartJsAdapter` sets `animation: false`.
12
16
  * @property {(detail: ChartSelectDetail) => void} onSelect Point-selection callback.
13
17
  */
14
18
  /**
@@ -145,6 +149,9 @@ export class Chart extends Component {
145
149
 
146
150
  this._observer = typeof ResizeObserver === 'function' ? new ResizeObserver(() => this.resize()) : null;
147
151
  this._observer?.observe(viewport);
152
+ // The engine tweens in script, where no stylesheet can reach it: when motion is reduced, or
153
+ // stops being reduced, the mounted chart is updated with the new answer in its spec.
154
+ this._stopWatchingMotion = watchMotion(root, () => this._mountOrUpdate(false));
148
155
  return root;
149
156
  }
150
157
 
@@ -232,6 +239,7 @@ export class Chart extends Component {
232
239
  this._destroyed = true;
233
240
  const original = this._original;
234
241
  this._observer?.disconnect();
242
+ this._stopWatchingMotion?.();
235
243
  this._destroyHandle();
236
244
  super.destroy();
237
245
  if (!this._createdRoot && original) {
@@ -277,6 +285,7 @@ export class Chart extends Component {
277
285
  ...themeChartOptions(/** @type {HTMLElement} */ (this.el)),
278
286
  ...cloneChartValue(this._chartOptions)
279
287
  },
288
+ reducedMotion: prefersReducedMotion(this.el),
280
289
  onSelect: (detail) => this.emit('select', detail)
281
290
  };
282
291
  }
@@ -386,6 +395,9 @@ function chartJsConfig(spec, defaults, getInstance) {
386
395
  ...cloneChartValue(defaults),
387
396
  ...supplied
388
397
  };
398
+ // Reduced motion wins over any animation the application configured: a person who asked for
399
+ // less motion gets it from every chart, not only from the ones that remembered to ask.
400
+ if (spec.reducedMotion) options.animation = false;
389
401
  const originalClick = options.onClick;
390
402
  options.onClick = (event, elements, chart) => {
391
403
  if (typeof originalClick === 'function') originalClick(event, elements, chart);
@@ -11,6 +11,7 @@
11
11
  padding-inline: var(--zx-control-pad-x);
12
12
  font: inherit;
13
13
  font-size: var(--zx-control-font-size);
14
+ font-weight: var(--zx-control-font-weight);
14
15
  cursor: pointer;
15
16
  }
16
17
 
@@ -18,9 +19,16 @@
18
19
  background: var(--zx-color-bg-hover);
19
20
  }
20
21
 
22
+ /* Pressed is a state the button is in, so the button itself goes neutral — the current fill, a
23
+ stronger border, the full text colour — and only the check glyph, which is the mark, keeps the
24
+ accent, as a checkbox would. */
21
25
  .zx-check-button[aria-pressed="true"] {
22
- border-color: var(--zx-color-accent);
23
- background: var(--zx-color-bg-selected);
26
+ border-color: var(--zx-color-border-strong);
27
+ background: var(--zx-color-bg-current);
28
+ color: var(--zx-color-text);
29
+ }
30
+
31
+ .zx-check-button__on {
24
32
  color: var(--zx-color-accent);
25
33
  }
26
34
 
@@ -7,7 +7,9 @@
7
7
  }
8
8
 
9
9
  .zx-checklist__label {
10
- font-weight: 650;
10
+ color: var(--zx-color-text-muted);
11
+ font-size: var(--zx-text-sm);
12
+ font-weight: var(--zx-weight-medium);
11
13
  }
12
14
 
13
15
  .zx-checklist__search {
@@ -31,10 +33,12 @@
31
33
  display: flex;
32
34
  align-items: center;
33
35
  gap: var(--zx-space-2);
34
- min-block-size: var(--zx-control-height);
36
+ /* The same row as a listbox option. */
37
+ min-block-size: calc(var(--zx-control-height) - 2px);
35
38
  border-radius: var(--zx-radius-sm);
36
39
  cursor: pointer;
37
40
  padding: var(--zx-space-1) var(--zx-space-2);
41
+ line-height: 1.35;
38
42
  }
39
43
 
40
44
  .zx-checklist__item:hover {
@@ -45,8 +49,11 @@
45
49
  background: var(--zx-color-bg-selected);
46
50
  }
47
51
 
52
+ /* The base control height is for text fields; a checkbox that inherited it would make every row
53
+ a field tall. */
48
54
  .zx-checklist__item input {
49
55
  flex: none;
56
+ min-block-size: 0;
50
57
  accent-color: var(--zx-color-accent);
51
58
  }
52
59
 
@@ -13,6 +13,9 @@ import { matchItems } from '../select/filter.js';
13
13
  * @property {boolean} [search=true] Whether the search field is shown.
14
14
  * @property {number} [height=280] Maximum list height in pixels.
15
15
  * @property {boolean} [defaultChecked=false] Fallback initial checked state.
16
+ * @property {boolean} [hideLabel=false] Whether the heading is left out. The group keeps the
17
+ * heading text as its accessible name (`aria-label`); a Field labelling the checklist names it
18
+ * instead, which is why the `checklist` field type sets this.
16
19
  * @property {(() => Promise<ChecklistItem[]>|ChecklistItem[])|null} [load=null] Async item loader.
17
20
  * @property {Record<string, string>|Record<string, Record<string, string>>} [msg] Localized messages.
18
21
  * @property {(event: CustomEvent<ChecklistChangeDetail>) => void} [onchange] Change listener.
@@ -40,6 +43,7 @@ export class Checklist extends Component {
40
43
  search: true,
41
44
  height: 280,
42
45
  defaultChecked: false,
46
+ hideLabel: false,
43
47
  load: null
44
48
  };
45
49
 
@@ -53,15 +57,16 @@ export class Checklist extends Component {
53
57
  const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
54
58
  this.el = root;
55
59
  if (!this._createdRoot) this._originalChildren = Array.from(root.childNodes);
56
- for (const name of ['role', 'aria-labelledby', 'data-state']) {
60
+ for (const name of ['role', 'aria-label', 'aria-labelledby', 'data-state']) {
57
61
  this._originalAttributes.set(name, root.getAttribute(name));
58
62
  }
59
63
 
64
+ const labelText = this._message('checklist.label', 'Checklist');
60
65
  const labelId = uid('zx-checklist-label');
61
- const title = h('div', {
66
+ const title = this.options.hideLabel ? null : h('div', {
62
67
  class: 'zx-checklist__label',
63
68
  id: labelId
64
- }, this._message('checklist.label', 'Checklist'));
69
+ }, labelText);
65
70
  const search = h('input', {
66
71
  ref: 'search',
67
72
  class: 'zx-checklist__search',
@@ -76,9 +81,10 @@ export class Checklist extends Component {
76
81
  class: 'zx-checklist__list'
77
82
  });
78
83
  list.style.maxBlockSize = `${Math.max(0, Number(this.options.height) || 0)}px`;
79
- root.replaceChildren(title, search, list);
84
+ root.replaceChildren(...[title, search, list].filter(Boolean));
80
85
  root.setAttribute('role', 'group');
81
- root.setAttribute('aria-labelledby', labelId);
86
+ if (title) root.setAttribute('aria-labelledby', labelId);
87
+ else root.setAttribute('aria-label', labelText);
82
88
 
83
89
  this.listen(search, 'input', () => this.search(/** @type {HTMLInputElement} */ (search).value));
84
90
  this.listen(list, 'change', (event) => {
@@ -7,7 +7,9 @@ import { Checklist } from './checklist.js';
7
7
  */
8
8
  export function registerChecklistFieldAdapter() {
9
9
  Field.register('checklist', (field, options) => {
10
- const checklist = field.own(new Checklist(null, { ...(options.props ?? {}) }));
10
+ // The Field's label names the list (`aria-labelledby`), so the checklist draws no heading of
11
+ // its own under it.
12
+ const checklist = field.own(new Checklist(null, { hideLabel: true, ...(options.props ?? {}) }));
11
13
  let disabled = false;
12
14
  let setting = false;
13
15
  const syncDisabled = () => {
@@ -0,0 +1,68 @@
1
+ .zx-code-editor {
2
+ overflow: hidden;
3
+ border: 1px solid var(--zx-color-border-control);
4
+ border-radius: var(--zx-control-radius);
5
+ background: var(--zx-color-bg-control);
6
+ }
7
+
8
+ .zx-code-editor:focus-within {
9
+ box-shadow: var(--zx-focus-ring);
10
+ }
11
+
12
+ .zx-code-editor__header {
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: space-between;
16
+ gap: var(--zx-space-3);
17
+ min-block-size: var(--zx-control-height);
18
+ padding-inline: var(--zx-space-3);
19
+ border-block-end: 1px solid var(--zx-color-border);
20
+ background: var(--zx-color-bg-muted);
21
+ color: var(--zx-color-text-muted);
22
+ font-size: var(--zx-text-xs);
23
+ }
24
+
25
+ /* The language is an identifier (`json`, `sql`), so it is shown as one — in the mono face, as
26
+ written — rather than shouted in letter-spaced capitals. */
27
+ .zx-code-editor__language {
28
+ overflow: hidden;
29
+ font-family: var(--zx-font-mono);
30
+ font-weight: var(--zx-weight-medium);
31
+ text-overflow: ellipsis;
32
+ text-wrap: nowrap;
33
+ }
34
+
35
+ .zx-code-editor__position {
36
+ font-variant-numeric: tabular-nums;
37
+ text-wrap: nowrap;
38
+ }
39
+
40
+ .zx-code-editor__input {
41
+ display: block;
42
+ inline-size: 100%;
43
+ min-block-size: calc(var(--zx-control-height) * 4);
44
+ resize: vertical;
45
+ padding: var(--zx-space-3);
46
+ border: 0;
47
+ border-radius: 0;
48
+ outline: 0;
49
+ background: transparent;
50
+ box-shadow: none;
51
+ color: var(--zx-color-text);
52
+ font-family: var(--zx-font-mono);
53
+ font-size: var(--zx-text-sm);
54
+ line-height: 1.55;
55
+ tab-size: 2;
56
+ }
57
+
58
+ .zx-code-editor__input:focus-visible {
59
+ box-shadow: none;
60
+ }
61
+
62
+ .zx-code-editor[data-readonly="true"] {
63
+ background: var(--zx-color-bg-muted);
64
+ }
65
+
66
+ .zx-code-editor[data-disabled="true"] {
67
+ opacity: 0.6;
68
+ }
@@ -0,0 +1,306 @@
1
+ import { Component } from '../../core/component.js';
2
+ import { h } from '../../core/dom.js';
3
+
4
+ /**
5
+ * @typedef {Object} CodeEditorOptions
6
+ * @property {unknown} [value=''] Initial text.
7
+ * @property {string} [name=''] Form field name.
8
+ * @property {string} [language='text'] Language identifier shown to the user and exposed as data.
9
+ * @property {string} [label='Code editor'] Accessible editor label.
10
+ * @property {string} [placeholder=''] Placeholder text.
11
+ * @property {number} [rows=12] Visible textarea rows.
12
+ * @property {string} [indent='\t'] Text inserted by Tab and removed by Shift+Tab.
13
+ * @property {'off'|'soft'|'hard'} [wrap='off'] Textarea wrapping behavior.
14
+ * @property {boolean} [spellcheck=false] Whether browser spellcheck is enabled.
15
+ * @property {boolean} [readOnly=false] Whether content can be selected but not changed.
16
+ * @property {boolean} [disabled=false] Whether the editor is disabled.
17
+ * @property {boolean} [status=true] Whether the line/column status is visible.
18
+ * @property {(event: CustomEvent<{value:string}>) => void} [oninput] Input listener.
19
+ * @property {(event: CustomEvent<{value:string}>) => void} [onchange] Change listener.
20
+ */
21
+
22
+ /** Keys that only modify the next one: pressing them is not the keystroke that ends a Tab release. */
23
+ const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'AltGraph', 'CapsLock', 'Fn', 'OS']);
24
+
25
+ /**
26
+ * Dependency-free source editor for structured text, Markdown, JSON and code. It deliberately uses
27
+ * a native textarea: values remain text-safe, browser undo and IME behavior stay intact, and Tab /
28
+ * Shift+Tab provide predictable indentation without a third-party editor runtime.
29
+ *
30
+ * Capturing Tab is what a code surface has to do, and it would be a keyboard trap if nothing undid
31
+ * it: Escape releases the capture until the next keystroke in the editor, so Escape then Tab (or
32
+ * Shift+Tab) moves focus out of the editor as it would leave any other field.
33
+ * @fires CodeEditor#input
34
+ * @fires CodeEditor#change
35
+ * @extends {Component<CodeEditorOptions>}
36
+ */
37
+ export class CodeEditor extends Component {
38
+ static cssName = 'code-editor';
39
+
40
+ /** @type {CodeEditorOptions} */
41
+ static defaults = {
42
+ value: '',
43
+ name: '',
44
+ language: 'text',
45
+ label: 'Code editor',
46
+ placeholder: '',
47
+ rows: 12,
48
+ indent: '\t',
49
+ wrap: 'off',
50
+ spellcheck: false,
51
+ readOnly: false,
52
+ disabled: false,
53
+ status: true
54
+ };
55
+
56
+ /** @returns {HTMLElement} */
57
+ render() {
58
+ this._createdRoot = this.el === null;
59
+ const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
60
+ this.el = root;
61
+ this._initialChildren = this._createdRoot ? [] : [...root.childNodes];
62
+ this._initialAttributes = new Map(['data-language', 'data-readonly', 'data-disabled']
63
+ .map((name) => [name, root.getAttribute(name)]));
64
+ this._language = normalizeLanguage(this.options.language);
65
+ this._languageLabel = h('span', { class: 'zx-code-editor__language' }, this._language);
66
+ this._position = h('span', {
67
+ class: 'zx-code-editor__position',
68
+ role: 'status',
69
+ ariaLive: 'polite'
70
+ });
71
+ this._header = h('div', { class: 'zx-code-editor__header' }, this._languageLabel,
72
+ this.options.status ? this._position : null);
73
+ this._input = /** @type {HTMLTextAreaElement} */ (h('textarea', {
74
+ class: 'zx-code-editor__input',
75
+ name: this.options.name ? String(this.options.name) : undefined,
76
+ ariaLabel: String(this.options.label ?? 'Code editor'),
77
+ placeholder: String(this.options.placeholder ?? ''),
78
+ rows: normalizeRows(this.options.rows),
79
+ wrap: normalizeWrap(this.options.wrap),
80
+ spellcheck: Boolean(this.options.spellcheck),
81
+ readOnly: Boolean(this.options.readOnly),
82
+ disabled: Boolean(this.options.disabled),
83
+ autocomplete: 'off',
84
+ autocapitalize: 'off'
85
+ }));
86
+ this._input.value = String(this.options.value ?? '');
87
+ root.dataset.language = this._language;
88
+ root.dataset.readonly = String(Boolean(this.options.readOnly));
89
+ root.dataset.disabled = String(Boolean(this.options.disabled));
90
+ root.replaceChildren(this._header, this._input);
91
+ /** Set by Escape: the next Tab moves focus instead of indenting. */
92
+ this._tabReleased = false;
93
+
94
+ this.listen(this._input, 'keydown', (event) => this._handleKeydown(event));
95
+ this.listen(this._input, 'blur', () => { this._tabReleased = false; });
96
+ this.listen(this._input, 'input', () => {
97
+ this._updatePosition();
98
+ this.emit('input', { value: this.getValue() });
99
+ });
100
+ this.listen(this._input, 'change', () => this.emit('change', { value: this.getValue() }));
101
+ for (const type of ['click', 'keyup', 'select', 'focus']) {
102
+ this.listen(this._input, type, () => this._updatePosition());
103
+ }
104
+ this._updatePosition();
105
+ return root;
106
+ }
107
+
108
+ /** @returns {string} */
109
+ getValue() { return this._input.value; }
110
+
111
+ /**
112
+ * Replaces the editor text.
113
+ * @param {unknown} value New value.
114
+ * @param {{silent?:boolean}} [options={}] Suppress the `change` event.
115
+ * @returns {this}
116
+ */
117
+ setValue(value, { silent = false } = {}) {
118
+ const next = String(value ?? '');
119
+ if (this._input.value === next) return this;
120
+ this._input.value = next;
121
+ this._updatePosition();
122
+ if (!silent) this.emit('change', { value: next });
123
+ return this;
124
+ }
125
+
126
+ /** @param {unknown} language New language identifier. @returns {this} */
127
+ setLanguage(language) {
128
+ this._language = normalizeLanguage(language);
129
+ this.el.dataset.language = this._language;
130
+ this._languageLabel.textContent = this._language;
131
+ return this;
132
+ }
133
+
134
+ /** @returns {this} */
135
+ focus() { this._input.focus(); return this; }
136
+
137
+ /** @returns {this} */
138
+ enable() { this._input.disabled = false; this.el.dataset.disabled = 'false'; return this; }
139
+
140
+ /** @returns {this} */
141
+ disable() { this._input.disabled = true; this.el.dataset.disabled = 'true'; return this; }
142
+
143
+ /** @param {boolean} value Read-only state. @returns {this} */
144
+ setReadOnly(value) {
145
+ this._input.readOnly = Boolean(value);
146
+ this.el.dataset.readonly = String(Boolean(value));
147
+ return this;
148
+ }
149
+
150
+ /** @param {KeyboardEvent} event @returns {void} */
151
+ _handleKeydown(event) {
152
+ if (event.isComposing || MODIFIER_KEYS.has(event.key)) return;
153
+ if (event.key === 'Escape') {
154
+ this._tabReleased = true;
155
+ return;
156
+ }
157
+ // Any other keystroke ends the release, the Tab it was for included.
158
+ const released = this._tabReleased;
159
+ this._tabReleased = false;
160
+ if (released || event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey ||
161
+ this._input.readOnly || this._input.disabled) return;
162
+ const indent = String(this.options.indent ?? '\t');
163
+ if (!indent) return;
164
+ event.preventDefault();
165
+ const result = indentSelection(
166
+ this._input.value,
167
+ this._input.selectionStart ?? 0,
168
+ this._input.selectionEnd ?? 0,
169
+ indent,
170
+ event.shiftKey
171
+ );
172
+ if (result.value === this._input.value) return;
173
+ this._input.value = result.value;
174
+ this._input.setSelectionRange(result.start, result.end);
175
+ this._updatePosition();
176
+ this.emit('input', { value: this.getValue() });
177
+ }
178
+
179
+ /** @returns {void} */
180
+ _updatePosition() {
181
+ if (!this.options.status) return;
182
+ const position = editorPosition(this._input.value, this._input.selectionStart ?? 0,
183
+ this._input.selectionEnd ?? 0);
184
+ this._position.textContent = position.lines > 1
185
+ ? `Ln ${position.line}, Col ${position.column} · ${position.lines} lines selected`
186
+ : `Ln ${position.line}, Col ${position.column}`;
187
+ }
188
+
189
+ /** @returns {void} */
190
+ destroy() {
191
+ if (!this._createdRoot) {
192
+ this.el.replaceChildren(...this._initialChildren);
193
+ for (const [name, value] of this._initialAttributes) {
194
+ if (value === null) this.el.removeAttribute(name);
195
+ else this.el.setAttribute(name, value);
196
+ }
197
+ }
198
+ super.destroy();
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Applies Tab or Shift+Tab to a textarea selection.
204
+ * @param {string} value Complete source value.
205
+ * @param {number} start Selection start.
206
+ * @param {number} end Selection end.
207
+ * @param {string} indent Indentation text.
208
+ * @param {boolean} outdent Whether to remove indentation.
209
+ * @returns {{value:string,start:number,end:number}}
210
+ */
211
+ export function indentSelection(value, start, end, indent = '\t', outdent = false) {
212
+ const source = String(value ?? '');
213
+ const unit = String(indent ?? '');
214
+ const from = clampIndex(start, source.length);
215
+ const to = clampIndex(end, source.length);
216
+ if (!unit) return { value: source, start: from, end: to };
217
+
218
+ if (from === to && !outdent) {
219
+ return {
220
+ value: source.slice(0, from) + unit + source.slice(to),
221
+ start: from + unit.length,
222
+ end: from + unit.length
223
+ };
224
+ }
225
+
226
+ const lineStart = source.lastIndexOf('\n', Math.max(0, from - 1)) + 1;
227
+ let lineEnd = source.indexOf('\n', to);
228
+ if (lineEnd < 0) lineEnd = source.length;
229
+ const block = source.slice(lineStart, lineEnd);
230
+ const lines = block.split('\n');
231
+ let removedFirst = 0;
232
+ let delta = 0;
233
+ const transformed = lines.map((line, index) => {
234
+ if (!outdent) {
235
+ delta += unit.length;
236
+ return unit + line;
237
+ }
238
+ const removed = removableIndent(line, unit);
239
+ if (index === 0) removedFirst = removed;
240
+ delta -= removed;
241
+ return line.slice(removed);
242
+ }).join('\n');
243
+ const nextStart = outdent
244
+ ? Math.max(lineStart, from - Math.min(removedFirst, from - lineStart))
245
+ : from + unit.length;
246
+ return {
247
+ value: source.slice(0, lineStart) + transformed + source.slice(lineEnd),
248
+ start: nextStart,
249
+ end: Math.max(nextStart, to + delta)
250
+ };
251
+ }
252
+
253
+ /** @param {string} value @param {number} start @param {number} end @returns {{line:number,column:number,lines:number}} */
254
+ export function editorPosition(value, start, end = start) {
255
+ const source = String(value ?? '');
256
+ const from = clampIndex(start, source.length);
257
+ const to = clampIndex(end, source.length);
258
+ const before = source.slice(0, from);
259
+ return {
260
+ line: before.split('\n').length,
261
+ column: from - before.lastIndexOf('\n'),
262
+ lines: source.slice(from, to).split('\n').length
263
+ };
264
+ }
265
+
266
+ /** @param {string} line @param {string} indent @returns {number} */
267
+ function removableIndent(line, indent) {
268
+ if (line.startsWith(indent)) return indent.length;
269
+ if (indent === '\t') return line.startsWith('\t') ? 1 : Math.min(/^ +/.exec(line)?.[0].length ?? 0, 2);
270
+ return Math.min(/^\s+/.exec(line)?.[0].length ?? 0, indent.length);
271
+ }
272
+
273
+ /** @param {unknown} value @returns {string} */
274
+ function normalizeLanguage(value) {
275
+ const language = String(value ?? 'text').trim().toLowerCase();
276
+ return /^[a-z0-9][a-z0-9+_.-]*$/.test(language) ? language : 'text';
277
+ }
278
+
279
+ /** @param {unknown} value @returns {number} */
280
+ function normalizeRows(value) {
281
+ const rows = Number(value);
282
+ return Number.isFinite(rows) ? Math.max(2, Math.min(100, Math.round(rows))) : 12;
283
+ }
284
+
285
+ /** @param {unknown} value @returns {'off'|'soft'|'hard'} */
286
+ function normalizeWrap(value) {
287
+ return ['off', 'soft', 'hard'].includes(String(value)) ? /** @type {'off'|'soft'|'hard'} */ (value) : 'off';
288
+ }
289
+
290
+ /** @param {unknown} value @param {number} length @returns {number} */
291
+ function clampIndex(value, length) {
292
+ const number = Number(value);
293
+ return Math.max(0, Math.min(length, Number.isFinite(number) ? Math.round(number) : 0));
294
+ }
295
+
296
+ /**
297
+ * Code editor input event.
298
+ * @event CodeEditor#input
299
+ * @type {CustomEvent<{value:string}>}
300
+ */
301
+
302
+ /**
303
+ * Code editor committed change event.
304
+ * @event CodeEditor#change
305
+ * @type {CustomEvent<{value:string}>}
306
+ */
@@ -0,0 +1,34 @@
1
+ import { CodeEditor } from './code-editor.js';
2
+ import { Field } from '../field/field.js';
3
+
4
+ /**
5
+ * Registers the CodeEditor-backed `code` field type.
6
+ * @returns {void}
7
+ */
8
+ export function registerCodeEditorFieldAdapter() {
9
+ Field.register('code', (field, options) => {
10
+ const editor = field.own(new CodeEditor(null, {
11
+ label: options.label ? String(options.label) : 'Code editor',
12
+ placeholder: options.placeholder,
13
+ readOnly: Boolean(options.props?.readOnly),
14
+ disabled: Boolean(options.disabled),
15
+ ...(options.props ?? {}),
16
+ value: options.value
17
+ }));
18
+ let setting = false;
19
+ editor.on('input', (event) => {
20
+ if (!setting) field.emit('change', { value: event.detail.value });
21
+ });
22
+ return {
23
+ el: editor.el,
24
+ get: () => editor.getValue(),
25
+ set: (value) => {
26
+ setting = true;
27
+ try { editor.setValue(value, { silent: true }); }
28
+ finally { setting = false; }
29
+ },
30
+ focus: () => editor.focus(),
31
+ setDisabled: (value) => (value ? editor.disable() : editor.enable())
32
+ };
33
+ });
34
+ }