@zeyos/zx 4.3.4 → 4.4.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 (128) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +7 -0
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +195 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
  6. package/dist/types/components/button/button.d.ts +8 -0
  7. package/dist/types/components/calendar/calendar.d.ts +19 -0
  8. package/dist/types/components/card/card.d.ts +7 -0
  9. package/dist/types/components/card-view/card-view.d.ts +11 -3
  10. package/dist/types/components/card-view/record-card.d.ts +7 -1
  11. package/dist/types/components/code-editor/code-editor.d.ts +160 -0
  12. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  13. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  14. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  15. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  16. package/dist/types/components/dialog/dialog.d.ts +29 -0
  17. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  18. package/dist/types/components/file-item/file-item.d.ts +234 -0
  19. package/dist/types/components/file-list/file-list.d.ts +171 -0
  20. package/dist/types/components/filter/filter.d.ts +91 -36
  21. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  22. package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
  23. package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
  24. package/dist/types/components/launcher/launcher.d.ts +19 -12
  25. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  26. package/dist/types/components/menu-button/menu-button.d.ts +4 -37
  27. package/dist/types/components/message/message.d.ts +7 -0
  28. package/dist/types/components/modal/modal.d.ts +48 -0
  29. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  30. package/dist/types/components/panel/panel.d.ts +7 -0
  31. package/dist/types/components/search/search.d.ts +7 -0
  32. package/dist/types/components/select/select.d.ts +4 -0
  33. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  34. package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
  35. package/dist/types/components/tabbox/tabbox.d.ts +7 -0
  36. package/dist/types/components/table/table.d.ts +144 -0
  37. package/dist/types/components/table-view/table-view.d.ts +11 -5
  38. package/dist/types/components/timebox/timebox.d.ts +7 -0
  39. package/dist/types/components/value-list/value-list.d.ts +8 -0
  40. package/dist/types/components/view/record-view.d.ts +96 -5
  41. package/dist/types/core/icons.d.ts +17 -0
  42. package/dist/types/index.d.ts +11 -0
  43. package/dist/types/internal/app-rail.d.ts +10 -0
  44. package/dist/types/internal/menu-items.d.ts +134 -0
  45. package/dist/zx-zeyos.esm.js +616 -77
  46. package/dist/zx-zeyos.esm.min.js +8 -1
  47. package/dist/zx.css +2085 -238
  48. package/dist/zx.esm.js +6704 -1542
  49. package/dist/zx.esm.min.js +10 -3
  50. package/dist/zx.global.js +6704 -1542
  51. package/dist/zx.global.min.js +10 -3
  52. package/dist/zx.min.css +1 -1
  53. package/docs/api.json +1479 -127
  54. package/docs/llms.md +315 -10
  55. package/docs/llms.txt +11 -5
  56. package/package.json +1 -1
  57. package/src/components/activity-item/activity-item.css +137 -0
  58. package/src/components/activity-item/activity-item.js +599 -0
  59. package/src/components/activity-list/activity-list.css +64 -0
  60. package/src/components/activity-list/activity-list.js +375 -0
  61. package/src/components/app-rail/app-rail.css +227 -19
  62. package/src/components/app-sidebar/app-sidebar.css +414 -24
  63. package/src/components/app-sidebar/app-sidebar.js +65 -5
  64. package/src/components/button/button.css +8 -0
  65. package/src/components/button/button.js +6 -1
  66. package/src/components/calendar/calendar.js +33 -3
  67. package/src/components/card/card.js +12 -1
  68. package/src/components/card-view/card-view.css +1 -0
  69. package/src/components/card-view/card-view.js +25 -9
  70. package/src/components/card-view/record-card.js +7 -3
  71. package/src/components/code-editor/code-editor.css +67 -0
  72. package/src/components/code-editor/code-editor.js +288 -0
  73. package/src/components/code-editor/field-adapter.js +34 -0
  74. package/src/components/context-menu/context-menu.css +80 -3
  75. package/src/components/context-menu/context-menu.js +22 -30
  76. package/src/components/date-picker/date-picker.js +22 -8
  77. package/src/components/date-picker/month-picker.js +3 -3
  78. package/src/components/date-picker/time-picker.js +20 -8
  79. package/src/components/dialog/dialog.js +88 -66
  80. package/src/components/dropdown/dropdown.js +2 -2
  81. package/src/components/entity-ref/entity-ref.css +182 -0
  82. package/src/components/entity-ref/entity-ref.js +496 -0
  83. package/src/components/file-item/file-item.css +88 -0
  84. package/src/components/file-item/file-item.js +371 -0
  85. package/src/components/file-list/file-list.css +51 -0
  86. package/src/components/file-list/file-list.js +320 -0
  87. package/src/components/filter/filter.css +27 -1
  88. package/src/components/filter/filter.js +199 -45
  89. package/src/components/filter-panel/filter-panel.css +104 -0
  90. package/src/components/filter-panel/filter-panel.js +964 -0
  91. package/src/components/kanban-view/kanban-card.js +239 -0
  92. package/src/components/kanban-view/kanban-view.css +194 -0
  93. package/src/components/kanban-view/kanban-view.js +251 -8
  94. package/src/components/launcher/launcher.js +59 -27
  95. package/src/components/list-toolbar/list-toolbar.css +189 -0
  96. package/src/components/list-toolbar/list-toolbar.js +851 -0
  97. package/src/components/menu-button/menu-button.css +80 -3
  98. package/src/components/menu-button/menu-button.js +21 -37
  99. package/src/components/message/message.css +5 -2
  100. package/src/components/message/message.js +13 -2
  101. package/src/components/modal/modal.css +15 -0
  102. package/src/components/modal/modal.js +141 -0
  103. package/src/components/navigation-bar/navigation-bar.css +77 -2
  104. package/src/components/navigation-bar/navigation-bar.js +171 -4
  105. package/src/components/panel/panel.js +13 -2
  106. package/src/components/search/search.js +17 -5
  107. package/src/components/select/field-adapter.js +4 -1
  108. package/src/components/select/select.js +3 -0
  109. package/src/components/sort-control/sort-control.css +17 -0
  110. package/src/components/sort-control/sort-control.js +140 -0
  111. package/src/components/stat-tile/stat-tile.css +213 -0
  112. package/src/components/stat-tile/stat-tile.js +590 -0
  113. package/src/components/tabbox/tabbox.js +12 -1
  114. package/src/components/table/table.css +51 -2
  115. package/src/components/table/table.js +298 -19
  116. package/src/components/table-view/table-view.css +8 -2
  117. package/src/components/table-view/table-view.js +21 -6
  118. package/src/components/tag-picker/tag-picker.css +3 -1
  119. package/src/components/timebox/timebox.js +18 -5
  120. package/src/components/value-list/value-list.js +22 -6
  121. package/src/components/view/record-view.css +5 -0
  122. package/src/components/view/record-view.js +100 -7
  123. package/src/core/icons.js +74 -5
  124. package/src/index.js +25 -0
  125. package/src/internal/app-rail.js +19 -1
  126. package/src/internal/menu-items.js +142 -0
  127. package/styles/print.css +192 -0
  128. package/styles/zx.css +11 -0
@@ -1,6 +1,7 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
+ import { printf } from '../../core/i18n.js';
4
5
  import {
5
6
  CALENDAR_VIEWS, addCalendarDays, calendarDayDifference, calendarDayKey,
6
7
  calendarEventIntersects, calendarEventSpansDays, calendarPageDate, calendarRange,
@@ -189,7 +190,7 @@ export class Calendar extends Component {
189
190
  ref: 'views',
190
191
  class: 'zx-calendar__views',
191
192
  role: 'group',
192
- ariaLabel: 'Calendar view'
193
+ ariaLabel: this._message('calendar.viewSwitch', 'Calendar view')
193
194
  }, this._views.map((view) => h('button', {
194
195
  type: 'button',
195
196
  class: 'zx-calendar__view-button',
@@ -643,7 +644,7 @@ export class Calendar extends Component {
643
644
  eventCount: String(count)
644
645
  },
645
646
  ariaCurrent: sameCalendarDay(day, this._now()) ? 'date' : null,
646
- ariaLabel: `${this._format(day, { dateStyle: 'full' })}, ${count} ${count === 1 ? 'event' : 'events'}`,
647
+ ariaLabel: this._dayCountLabel(day, count),
647
648
  disabled: this.options.disabled
648
649
  }, this._format(day, { day: 'numeric' })));
649
650
  }
@@ -708,7 +709,7 @@ export class Calendar extends Component {
708
709
  activity: String(Math.min(3, count))
709
710
  },
710
711
  ariaCurrent: sameCalendarDay(day, this._now()) ? 'date' : null,
711
- ariaLabel: `${this._format(day, { dateStyle: 'full' })}, ${count} ${count === 1 ? 'event' : 'events'}`,
712
+ ariaLabel: this._dayCountLabel(day, count),
712
713
  disabled: this.options.disabled
713
714
  }, String(day.getDate())));
714
715
  }
@@ -1473,6 +1474,35 @@ export class Calendar extends Component {
1473
1474
  if (!this._destroyed) this.refs.status.textContent = message;
1474
1475
  });
1475
1476
  }
1477
+
1478
+ /**
1479
+ * Names a month or year cell by its date and how many events it holds. Singular and plural are
1480
+ * separate keys because a language that inflects the noun cannot reach it from one template.
1481
+ * @param {Date} day Day the cell stands for.
1482
+ * @param {number} count Events on that day.
1483
+ * @returns {string}
1484
+ */
1485
+ _dayCountLabel(day, count) {
1486
+ const date = this._format(day, { dateStyle: 'full' });
1487
+ return count === 1
1488
+ ? this._message('calendar.dayEvent', '%1, %2 event', date, count)
1489
+ : this._message('calendar.dayEvents', '%1, %2 events', date, count);
1490
+ }
1491
+
1492
+ /**
1493
+ * Resolves a message through the host translator, falling back to the built-in English text.
1494
+ *
1495
+ * Distinct from `msg()`: the built-in `msg` option answers the calendar's own vocabulary, so a
1496
+ * dotted key falls past it to the host translator, which is what these strings never had.
1497
+ * @param {string} key Message key.
1498
+ * @param {string} fallback Built-in text, with `%1`-style placeholders.
1499
+ * @param {...unknown} args Interpolation values.
1500
+ * @returns {string}
1501
+ */
1502
+ _message(key, fallback, ...args) {
1503
+ const message = this.msg(key, ...args);
1504
+ return message === key ? printf(fallback, args) : message;
1505
+ }
1476
1506
  }
1477
1507
 
1478
1508
  /** @param {unknown} value @returns {CalendarView[]} */
@@ -86,7 +86,7 @@ export class Card extends Component {
86
86
  ref: 'actions',
87
87
  class: 'zx-card__actions',
88
88
  role: 'group',
89
- ariaLabel: 'Card actions',
89
+ ariaLabel: this._message('card.actions', 'Card actions'),
90
90
  hidden: true
91
91
  });
92
92
  const content = h('div', { ref: 'content', class: 'zx-card__content', hidden: true });
@@ -225,6 +225,17 @@ export class Card extends Component {
225
225
  if (!this.refs.header) return;
226
226
  this.refs.header.hidden = this._title === '' && this.refs.actions.childElementCount === 0;
227
227
  }
228
+
229
+ /**
230
+ * Resolves a message through the host translator, falling back to the built-in English text.
231
+ * @param {string} key Message key.
232
+ * @param {string} fallback English default.
233
+ * @returns {string}
234
+ */
235
+ _message(key, fallback) {
236
+ const message = this.msg(key);
237
+ return message === key ? fallback : message;
238
+ }
228
239
  }
229
240
 
230
241
  /** @param {Element} host @param {CardContent} content @param {string} label */
@@ -589,3 +589,4 @@
589
589
  min-block-size: 44px;
590
590
  }
591
591
  }
592
+ .zx-card-view .zx-record-card[data-selection-trigger="card"] { cursor: pointer; }
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
  import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
- import { RecordView, readViewField } from '../view/record-view.js';
3
+ import { normalizeViewFieldControls, RecordView, readViewField } from '../view/record-view.js';
4
4
  import {
5
5
  createRecordCard, resolveRecordActions
6
6
  } from './record-card.js';
@@ -21,10 +21,13 @@ import {
21
21
  * @property {import('../view/record-view.js').ViewSort|null} [sort=null] Initial shared sort.
22
22
  * @property {'local'|'server'} [sortMode='local'] Local sorting or event-only server sorting.
23
23
  * @property {false|'single'|'multi'} [selectable=false] Record selection behavior.
24
+ * @property {'checkbox'|'card'} [selectionTrigger='checkbox'] Select through a checkbox or the non-interactive card surface.
24
25
  * @property {unknown[]} [selection=[]] Initially selected record ids.
25
26
  * @property {string[]} [fieldOrder=[]] Preferred shared field order.
26
27
  * @property {string[]} [hiddenFields=[]] Initially hidden metadata fields.
27
- * @property {boolean} [fieldControls=true] Whether to expose shared field controls.
28
+ * @property {boolean|import('../view/record-view.js').ViewFieldControlsConfig} [fieldControls=true]
29
+ * Whether to expose shared field controls. `true` renders the chooser in the card toolbar and
30
+ * `false` omits it; an object customises its `label` and mounts it into `target`.
28
31
  * @property {string|Node|(()=>string|Node)|null} [emptyText=null] Empty-result content.
29
32
  * @property {RecordCardValueSource} [titleField=null] Title field id or explicit resolver.
30
33
  * @property {RecordCardValueSource} [subtitleField=null] Subtitle field id or explicit resolver.
@@ -79,6 +82,7 @@ export class CardView extends RecordView {
79
82
  /** @type {Readonly<CardViewOptions & RecordViewOptions>} */
80
83
  static defaults = {
81
84
  ...RecordView.defaults,
85
+ selectionTrigger: 'checkbox',
82
86
  titleField: null,
83
87
  subtitleField: null,
84
88
  preview: undefined,
@@ -124,10 +128,12 @@ export class CardView extends RecordView {
124
128
  this._initRecordView(root);
125
129
  root.setAttribute('aria-label', String(options.label || 'Card view'));
126
130
  const sort = h('label', { ref: 'sortWrap', class: 'zx-card-view__sort' },
127
- h('span', { class: 'zx-card-view__sort-label' }, 'Sort'),
128
- h('select', { ref: 'sort', ariaLabel: 'Sort cards' }));
131
+ h('span', { class: 'zx-card-view__sort-label' }, this._message('cardView.sort', 'Sort')),
132
+ h('select', { ref: 'sort', ariaLabel: this._message('cardView.sortCards', 'Sort cards') }));
129
133
  const toolbar = h('div', { class: 'zx-record-view__toolbar zx-card-view__toolbar' }, sort);
130
- if (this.options.fieldControls) toolbar.append(this._createViewFieldControls('Fields'));
134
+ if (normalizeViewFieldControls(this.options.fieldControls).enabled) {
135
+ this._mountViewFieldControls(toolbar);
136
+ }
131
137
  const content = h('div', { ref: 'content', class: 'zx-card-view__content' });
132
138
  root.replaceChildren(toolbar, content);
133
139
  this.listen(this.refs.sort, 'change', () => this._changeCardSort());
@@ -140,6 +146,7 @@ export class CardView extends RecordView {
140
146
  this._renderCards();
141
147
  return root;
142
148
  } catch (error) {
149
+ this._removeViewFieldControls();
143
150
  if (!created) restoreTarget(root, this._cardOriginal);
144
151
  throw error;
145
152
  }
@@ -212,16 +219,18 @@ export class CardView extends RecordView {
212
219
  if (!this.refs?.sort || !this.refs?.sortWrap) return;
213
220
  const select = /** @type {HTMLSelectElement} */ (this.refs.sort);
214
221
  const sort = this.getSort();
215
- const options = [h('option', { value: '', selected: sort === null }, 'Unsorted')];
222
+ const options = [h('option', {
223
+ value: '', selected: sort === null
224
+ }, this._message('cardView.unsorted', 'Unsorted'))];
216
225
  for (const field of this.getFields().filter((candidate) => candidate.sortable)) {
217
226
  options.push(h('option', {
218
227
  selected: sort?.id === field.id && sort.dir === 'asc',
219
228
  dataset: { sortId: field.id, sortDir: 'asc' }
220
- }, `${field.label} (ascending)`));
229
+ }, this._message('cardView.sortAscending', '%1 (ascending)', field.label)));
221
230
  options.push(h('option', {
222
231
  selected: sort?.id === field.id && sort.dir === 'desc',
223
232
  dataset: { sortId: field.id, sortDir: 'desc' }
224
- }, `${field.label} (descending)`));
233
+ }, this._message('cardView.sortDescending', '%1 (descending)', field.label)));
225
234
  }
226
235
  select.replaceChildren(...options);
227
236
  /** @type {HTMLElement} */ (this.refs.sortWrap).hidden = options.length === 1;
@@ -286,6 +295,7 @@ export class CardView extends RecordView {
286
295
  link: options.link,
287
296
  actions: options.actions,
288
297
  selectable: this.options.selectable,
298
+ selectionTrigger: options.selectionTrigger,
289
299
  selected: this._isViewSelected(id),
290
300
  variant: this._cardVariant,
291
301
  headingLevel: this._cardHeadingLevel
@@ -355,6 +365,11 @@ export class CardView extends RecordView {
355
365
  return;
356
366
  }
357
367
  if (isInteractiveWithin(target, card) || hasCardTextSelection(card)) return;
368
+ if (this.options.selectable && this._cardOptions().selectionTrigger === 'card') {
369
+ this.toggleSelection(id, { range: event.shiftKey });
370
+ card.focus({ preventScroll: true });
371
+ return;
372
+ }
358
373
  this._activateCard(record, id, index, event);
359
374
  if (this.options.selectable === 'single') this.toggleSelection(id, { selected: true });
360
375
  }
@@ -381,7 +396,8 @@ export class CardView extends RecordView {
381
396
  const id = this._viewRecordId(record);
382
397
  if (event.key === 'Enter') {
383
398
  event.preventDefault();
384
- this._activateCard(record, id, index, event);
399
+ if (this.options.selectable && this._cardOptions().selectionTrigger === 'card') this.toggleSelection(id);
400
+ else this._activateCard(record, id, index, event);
385
401
  } else if (event.key === ' ' && this.options.selectable) {
386
402
  event.preventDefault();
387
403
  this.toggleSelection(id);
@@ -62,6 +62,7 @@ import { readViewField, renderViewField } from '../view/record-view.js';
62
62
  * @property {RecordCardLink|((record:ViewRecord,index:number)=>RecordCardLink)} [link]
63
63
  * @property {RecordCardAction[]|((record:ViewRecord,index:number)=>RecordCardAction[])} [actions=[]]
64
64
  * @property {false|'single'|'multi'} [selectable=false] Selection presentation.
65
+ * @property {'checkbox'|'card'} [selectionTrigger='checkbox'] Selection affordance for owning views.
65
66
  * @property {boolean} [selected=false] Current selection state.
66
67
  * @property {'outlined'|'raised'|'filled'} [variant='outlined'] Surface treatment.
67
68
  * @property {1|2|3|4|5|6} [headingLevel=3] Card title heading level.
@@ -84,7 +85,8 @@ import { readViewField, renderViewField } from '../view/record-view.js';
84
85
  *
85
86
  * The returned `<li>` belongs in a semantic `<ul>`/`<ol>`. When selectable its global
86
87
  * `aria-description` announces selection without applying the unsupported `aria-selected` state
87
- * to a listitem; multi-selection also adds a native checkbox. It deliberately remains a listitem
88
+ * to a listitem; multi-selection adds a native checkbox unless the owner opts into card-surface
89
+ * selection. It deliberately remains a listitem
88
90
  * rather than becoming an ARIA option because cards may contain native links and buttons, which
89
91
  * the listbox pattern does not permit inside an option. Preview images are paired with a stable
90
92
  * fallback. The owning component listens for image `error` and marks the fallback.
@@ -129,7 +131,7 @@ export function createRecordCard(record, index, options = {}) {
129
131
  const titleRow = titlePrefix
130
132
  ? h('div', { class: 'zx-record-card__title-row' }, titlePrefix, heading)
131
133
  : heading;
132
- const selection = selectable === 'multi' ? h('label', {
134
+ const selection = selectable === 'multi' && options.selectionTrigger !== 'card' ? h('label', {
133
135
  class: 'zx-record-card__selection'
134
136
  }, h('input', {
135
137
  type: 'checkbox',
@@ -154,10 +156,12 @@ export function createRecordCard(record, index, options = {}) {
154
156
  tabindex: 0,
155
157
  ariaDescription: selectable ? selected ? 'Selected' : 'Not selected' : undefined,
156
158
  ariaLabelledby: headingId,
159
+ ariaKeyshortcuts: selectable && options.selectionTrigger === 'card' ? 'Space Enter' : undefined,
157
160
  dataset: {
158
161
  recordIndex: String(index),
159
162
  variant: normalizeVariant(options.variant),
160
- selected: String(selected)
163
+ selected: String(selected),
164
+ selectionTrigger: selectable && options.selectionTrigger === 'card' ? 'card' : 'checkbox'
161
165
  }
162
166
  }, preview ? createPreview(preview) : null, body));
163
167
  return card;
@@ -0,0 +1,67 @@
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-subtle);
21
+ color: var(--zx-color-text-muted);
22
+ font-size: var(--zx-text-xs);
23
+ }
24
+
25
+ .zx-code-editor__language {
26
+ overflow: hidden;
27
+ font-weight: 600;
28
+ letter-spacing: 0.04em;
29
+ text-overflow: ellipsis;
30
+ text-transform: uppercase;
31
+ text-wrap: nowrap;
32
+ }
33
+
34
+ .zx-code-editor__position {
35
+ font-variant-numeric: tabular-nums;
36
+ text-wrap: nowrap;
37
+ }
38
+
39
+ .zx-code-editor__input {
40
+ display: block;
41
+ inline-size: 100%;
42
+ min-block-size: calc(var(--zx-control-height) * 4);
43
+ resize: vertical;
44
+ padding: var(--zx-space-3);
45
+ border: 0;
46
+ border-radius: 0;
47
+ outline: 0;
48
+ background: transparent;
49
+ box-shadow: none;
50
+ color: var(--zx-color-text);
51
+ font-family: var(--zx-font-mono);
52
+ font-size: var(--zx-text-sm);
53
+ line-height: 1.55;
54
+ tab-size: 2;
55
+ }
56
+
57
+ .zx-code-editor__input:focus-visible {
58
+ box-shadow: none;
59
+ }
60
+
61
+ .zx-code-editor[data-readonly="true"] {
62
+ background: var(--zx-color-bg-subtle);
63
+ }
64
+
65
+ .zx-code-editor[data-disabled="true"] {
66
+ opacity: 0.6;
67
+ }
@@ -0,0 +1,288 @@
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
+ /**
23
+ * Dependency-free source editor for structured text, Markdown, JSON and code. It deliberately uses
24
+ * a native textarea: values remain text-safe, browser undo and IME behavior stay intact, and Tab /
25
+ * Shift+Tab provide predictable indentation without a third-party editor runtime.
26
+ * @fires CodeEditor#input
27
+ * @fires CodeEditor#change
28
+ * @extends {Component<CodeEditorOptions>}
29
+ */
30
+ export class CodeEditor extends Component {
31
+ static cssName = 'code-editor';
32
+
33
+ /** @type {CodeEditorOptions} */
34
+ static defaults = {
35
+ value: '',
36
+ name: '',
37
+ language: 'text',
38
+ label: 'Code editor',
39
+ placeholder: '',
40
+ rows: 12,
41
+ indent: '\t',
42
+ wrap: 'off',
43
+ spellcheck: false,
44
+ readOnly: false,
45
+ disabled: false,
46
+ status: true
47
+ };
48
+
49
+ /** @returns {HTMLElement} */
50
+ render() {
51
+ this._createdRoot = this.el === null;
52
+ const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
53
+ this.el = root;
54
+ this._initialChildren = this._createdRoot ? [] : [...root.childNodes];
55
+ this._initialAttributes = new Map(['data-language', 'data-readonly', 'data-disabled']
56
+ .map((name) => [name, root.getAttribute(name)]));
57
+ this._language = normalizeLanguage(this.options.language);
58
+ this._languageLabel = h('span', { class: 'zx-code-editor__language' }, this._language);
59
+ this._position = h('span', {
60
+ class: 'zx-code-editor__position',
61
+ role: 'status',
62
+ ariaLive: 'polite'
63
+ });
64
+ this._header = h('div', { class: 'zx-code-editor__header' }, this._languageLabel,
65
+ this.options.status ? this._position : null);
66
+ this._input = /** @type {HTMLTextAreaElement} */ (h('textarea', {
67
+ class: 'zx-code-editor__input',
68
+ name: this.options.name ? String(this.options.name) : undefined,
69
+ ariaLabel: String(this.options.label ?? 'Code editor'),
70
+ placeholder: String(this.options.placeholder ?? ''),
71
+ rows: normalizeRows(this.options.rows),
72
+ wrap: normalizeWrap(this.options.wrap),
73
+ spellcheck: Boolean(this.options.spellcheck),
74
+ readOnly: Boolean(this.options.readOnly),
75
+ disabled: Boolean(this.options.disabled),
76
+ autocomplete: 'off',
77
+ autocapitalize: 'off'
78
+ }));
79
+ this._input.value = String(this.options.value ?? '');
80
+ root.dataset.language = this._language;
81
+ root.dataset.readonly = String(Boolean(this.options.readOnly));
82
+ root.dataset.disabled = String(Boolean(this.options.disabled));
83
+ root.replaceChildren(this._header, this._input);
84
+
85
+ this.listen(this._input, 'keydown', (event) => this._handleKeydown(event));
86
+ this.listen(this._input, 'input', () => {
87
+ this._updatePosition();
88
+ this.emit('input', { value: this.getValue() });
89
+ });
90
+ this.listen(this._input, 'change', () => this.emit('change', { value: this.getValue() }));
91
+ for (const type of ['click', 'keyup', 'select', 'focus']) {
92
+ this.listen(this._input, type, () => this._updatePosition());
93
+ }
94
+ this._updatePosition();
95
+ return root;
96
+ }
97
+
98
+ /** @returns {string} */
99
+ getValue() { return this._input.value; }
100
+
101
+ /**
102
+ * Replaces the editor text.
103
+ * @param {unknown} value New value.
104
+ * @param {{silent?:boolean}} [options={}] Suppress the `change` event.
105
+ * @returns {this}
106
+ */
107
+ setValue(value, { silent = false } = {}) {
108
+ const next = String(value ?? '');
109
+ if (this._input.value === next) return this;
110
+ this._input.value = next;
111
+ this._updatePosition();
112
+ if (!silent) this.emit('change', { value: next });
113
+ return this;
114
+ }
115
+
116
+ /** @param {unknown} language New language identifier. @returns {this} */
117
+ setLanguage(language) {
118
+ this._language = normalizeLanguage(language);
119
+ this.el.dataset.language = this._language;
120
+ this._languageLabel.textContent = this._language;
121
+ return this;
122
+ }
123
+
124
+ /** @returns {this} */
125
+ focus() { this._input.focus(); return this; }
126
+
127
+ /** @returns {this} */
128
+ enable() { this._input.disabled = false; this.el.dataset.disabled = 'false'; return this; }
129
+
130
+ /** @returns {this} */
131
+ disable() { this._input.disabled = true; this.el.dataset.disabled = 'true'; return this; }
132
+
133
+ /** @param {boolean} value Read-only state. @returns {this} */
134
+ setReadOnly(value) {
135
+ this._input.readOnly = Boolean(value);
136
+ this.el.dataset.readonly = String(Boolean(value));
137
+ return this;
138
+ }
139
+
140
+ /** @param {KeyboardEvent} event @returns {void} */
141
+ _handleKeydown(event) {
142
+ if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey ||
143
+ this._input.readOnly || this._input.disabled) return;
144
+ const indent = String(this.options.indent ?? '\t');
145
+ if (!indent) return;
146
+ event.preventDefault();
147
+ const result = indentSelection(
148
+ this._input.value,
149
+ this._input.selectionStart ?? 0,
150
+ this._input.selectionEnd ?? 0,
151
+ indent,
152
+ event.shiftKey
153
+ );
154
+ if (result.value === this._input.value) return;
155
+ this._input.value = result.value;
156
+ this._input.setSelectionRange(result.start, result.end);
157
+ this._updatePosition();
158
+ this.emit('input', { value: this.getValue() });
159
+ }
160
+
161
+ /** @returns {void} */
162
+ _updatePosition() {
163
+ if (!this.options.status) return;
164
+ const position = editorPosition(this._input.value, this._input.selectionStart ?? 0,
165
+ this._input.selectionEnd ?? 0);
166
+ this._position.textContent = position.lines > 1
167
+ ? `Ln ${position.line}, Col ${position.column} · ${position.lines} lines selected`
168
+ : `Ln ${position.line}, Col ${position.column}`;
169
+ }
170
+
171
+ /** @returns {void} */
172
+ destroy() {
173
+ if (!this._createdRoot) {
174
+ this.el.replaceChildren(...this._initialChildren);
175
+ for (const [name, value] of this._initialAttributes) {
176
+ if (value === null) this.el.removeAttribute(name);
177
+ else this.el.setAttribute(name, value);
178
+ }
179
+ }
180
+ super.destroy();
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Applies Tab or Shift+Tab to a textarea selection.
186
+ * @param {string} value Complete source value.
187
+ * @param {number} start Selection start.
188
+ * @param {number} end Selection end.
189
+ * @param {string} indent Indentation text.
190
+ * @param {boolean} outdent Whether to remove indentation.
191
+ * @returns {{value:string,start:number,end:number}}
192
+ */
193
+ export function indentSelection(value, start, end, indent = '\t', outdent = false) {
194
+ const source = String(value ?? '');
195
+ const unit = String(indent ?? '');
196
+ const from = clampIndex(start, source.length);
197
+ const to = clampIndex(end, source.length);
198
+ if (!unit) return { value: source, start: from, end: to };
199
+
200
+ if (from === to && !outdent) {
201
+ return {
202
+ value: source.slice(0, from) + unit + source.slice(to),
203
+ start: from + unit.length,
204
+ end: from + unit.length
205
+ };
206
+ }
207
+
208
+ const lineStart = source.lastIndexOf('\n', Math.max(0, from - 1)) + 1;
209
+ let lineEnd = source.indexOf('\n', to);
210
+ if (lineEnd < 0) lineEnd = source.length;
211
+ const block = source.slice(lineStart, lineEnd);
212
+ const lines = block.split('\n');
213
+ let removedFirst = 0;
214
+ let delta = 0;
215
+ const transformed = lines.map((line, index) => {
216
+ if (!outdent) {
217
+ delta += unit.length;
218
+ return unit + line;
219
+ }
220
+ const removed = removableIndent(line, unit);
221
+ if (index === 0) removedFirst = removed;
222
+ delta -= removed;
223
+ return line.slice(removed);
224
+ }).join('\n');
225
+ const nextStart = outdent
226
+ ? Math.max(lineStart, from - Math.min(removedFirst, from - lineStart))
227
+ : from + unit.length;
228
+ return {
229
+ value: source.slice(0, lineStart) + transformed + source.slice(lineEnd),
230
+ start: nextStart,
231
+ end: Math.max(nextStart, to + delta)
232
+ };
233
+ }
234
+
235
+ /** @param {string} value @param {number} start @param {number} end @returns {{line:number,column:number,lines:number}} */
236
+ export function editorPosition(value, start, end = start) {
237
+ const source = String(value ?? '');
238
+ const from = clampIndex(start, source.length);
239
+ const to = clampIndex(end, source.length);
240
+ const before = source.slice(0, from);
241
+ return {
242
+ line: before.split('\n').length,
243
+ column: from - before.lastIndexOf('\n'),
244
+ lines: source.slice(from, to).split('\n').length
245
+ };
246
+ }
247
+
248
+ /** @param {string} line @param {string} indent @returns {number} */
249
+ function removableIndent(line, indent) {
250
+ if (line.startsWith(indent)) return indent.length;
251
+ if (indent === '\t') return line.startsWith('\t') ? 1 : Math.min(/^ +/.exec(line)?.[0].length ?? 0, 2);
252
+ return Math.min(/^\s+/.exec(line)?.[0].length ?? 0, indent.length);
253
+ }
254
+
255
+ /** @param {unknown} value @returns {string} */
256
+ function normalizeLanguage(value) {
257
+ const language = String(value ?? 'text').trim().toLowerCase();
258
+ return /^[a-z0-9][a-z0-9+_.-]*$/.test(language) ? language : 'text';
259
+ }
260
+
261
+ /** @param {unknown} value @returns {number} */
262
+ function normalizeRows(value) {
263
+ const rows = Number(value);
264
+ return Number.isFinite(rows) ? Math.max(2, Math.min(100, Math.round(rows))) : 12;
265
+ }
266
+
267
+ /** @param {unknown} value @returns {'off'|'soft'|'hard'} */
268
+ function normalizeWrap(value) {
269
+ return ['off', 'soft', 'hard'].includes(String(value)) ? /** @type {'off'|'soft'|'hard'} */ (value) : 'off';
270
+ }
271
+
272
+ /** @param {unknown} value @param {number} length @returns {number} */
273
+ function clampIndex(value, length) {
274
+ const number = Number(value);
275
+ return Math.max(0, Math.min(length, Number.isFinite(number) ? Math.round(number) : 0));
276
+ }
277
+
278
+ /**
279
+ * Code editor input event.
280
+ * @event CodeEditor#input
281
+ * @type {CustomEvent<{value:string}>}
282
+ */
283
+
284
+ /**
285
+ * Code editor committed change event.
286
+ * @event CodeEditor#change
287
+ * @type {CustomEvent<{value:string}>}
288
+ */
@@ -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
+ }