@zeyos/zx 4.3.0 → 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 (136) hide show
  1. package/CHANGELOG.md +266 -1
  2. package/README.md +7 -3
  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-model.d.ts +22 -0
  8. package/dist/types/components/calendar/calendar.d.ts +19 -0
  9. package/dist/types/components/card/card.d.ts +7 -0
  10. package/dist/types/components/card-view/card-view.d.ts +12 -4
  11. package/dist/types/components/card-view/record-card.d.ts +53 -5
  12. package/dist/types/components/code-editor/code-editor.d.ts +160 -0
  13. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  14. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  15. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  16. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  17. package/dist/types/components/dialog/dialog.d.ts +29 -0
  18. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  19. package/dist/types/components/file-item/file-item.d.ts +234 -0
  20. package/dist/types/components/file-list/file-list.d.ts +171 -0
  21. package/dist/types/components/filter/filter.d.ts +91 -36
  22. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  23. package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
  24. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  25. package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
  26. package/dist/types/components/launcher/launcher.d.ts +19 -12
  27. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  28. package/dist/types/components/menu-button/menu-button.d.ts +4 -37
  29. package/dist/types/components/message/message.d.ts +7 -0
  30. package/dist/types/components/modal/modal.d.ts +48 -0
  31. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  32. package/dist/types/components/panel/panel.d.ts +7 -0
  33. package/dist/types/components/search/search.d.ts +7 -0
  34. package/dist/types/components/select/select.d.ts +4 -0
  35. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  36. package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
  37. package/dist/types/components/tabbox/tabbox.d.ts +7 -0
  38. package/dist/types/components/table/table.d.ts +144 -0
  39. package/dist/types/components/table-view/table-view.d.ts +11 -5
  40. package/dist/types/components/timebox/timebox.d.ts +7 -0
  41. package/dist/types/components/value-list/value-list.d.ts +8 -0
  42. package/dist/types/components/view/record-view.d.ts +96 -5
  43. package/dist/types/core/component.d.ts +6 -1
  44. package/dist/types/core/icons.d.ts +17 -0
  45. package/dist/types/index.d.ts +13 -1
  46. package/dist/types/internal/app-rail.d.ts +10 -0
  47. package/dist/types/internal/menu-items.d.ts +134 -0
  48. package/dist/zx-zeyos.esm.js +651 -88
  49. package/dist/zx-zeyos.esm.min.js +8 -1
  50. package/dist/zx.css +6266 -3900
  51. package/dist/zx.esm.js +8487 -1805
  52. package/dist/zx.esm.min.js +10 -3
  53. package/dist/zx.global.js +8487 -1805
  54. package/dist/zx.global.min.js +10 -3
  55. package/dist/zx.min.css +1 -1
  56. package/docs/api.json +1811 -247
  57. package/docs/llms.md +345 -40
  58. package/docs/llms.txt +11 -7
  59. package/package.json +1 -2
  60. package/src/components/activity-item/activity-item.css +137 -0
  61. package/src/components/activity-item/activity-item.js +599 -0
  62. package/src/components/activity-list/activity-list.css +64 -0
  63. package/src/components/activity-list/activity-list.js +375 -0
  64. package/src/components/app-rail/app-rail.css +227 -19
  65. package/src/components/app-sidebar/app-sidebar.css +414 -24
  66. package/src/components/app-sidebar/app-sidebar.js +65 -5
  67. package/src/components/button/button.css +8 -0
  68. package/src/components/button/button.js +6 -1
  69. package/src/components/calendar/calendar-model.js +45 -0
  70. package/src/components/calendar/calendar.css +30 -10
  71. package/src/components/calendar/calendar.js +41 -13
  72. package/src/components/card/card.js +12 -1
  73. package/src/components/card-view/card-view.css +212 -9
  74. package/src/components/card-view/card-view.js +32 -16
  75. package/src/components/card-view/record-card.js +98 -21
  76. package/src/components/code-editor/code-editor.css +67 -0
  77. package/src/components/code-editor/code-editor.js +288 -0
  78. package/src/components/code-editor/field-adapter.js +34 -0
  79. package/src/components/context-menu/context-menu.css +80 -3
  80. package/src/components/context-menu/context-menu.js +22 -30
  81. package/src/components/date-picker/date-picker.js +22 -8
  82. package/src/components/date-picker/month-picker.js +3 -3
  83. package/src/components/date-picker/time-picker.js +20 -8
  84. package/src/components/dialog/dialog.js +88 -66
  85. package/src/components/dropdown/dropdown.js +2 -2
  86. package/src/components/entity-ref/entity-ref.css +182 -0
  87. package/src/components/entity-ref/entity-ref.js +496 -0
  88. package/src/components/file-item/file-item.css +88 -0
  89. package/src/components/file-item/file-item.js +371 -0
  90. package/src/components/file-list/file-list.css +51 -0
  91. package/src/components/file-list/file-list.js +320 -0
  92. package/src/components/filter/filter.css +27 -1
  93. package/src/components/filter/filter.js +199 -45
  94. package/src/components/filter-panel/filter-panel.css +104 -0
  95. package/src/components/filter-panel/filter-panel.js +964 -0
  96. package/src/components/kanban-view/kanban-card.js +239 -0
  97. package/src/components/kanban-view/kanban-policy.js +412 -0
  98. package/src/components/kanban-view/kanban-view.css +674 -36
  99. package/src/components/kanban-view/kanban-view.js +1762 -240
  100. package/src/components/launcher/launcher.js +59 -27
  101. package/src/components/list-toolbar/list-toolbar.css +189 -0
  102. package/src/components/list-toolbar/list-toolbar.js +851 -0
  103. package/src/components/menu-button/menu-button.css +80 -3
  104. package/src/components/menu-button/menu-button.js +21 -37
  105. package/src/components/message/message.css +14 -12
  106. package/src/components/message/message.js +13 -2
  107. package/src/components/modal/modal.css +15 -0
  108. package/src/components/modal/modal.js +141 -0
  109. package/src/components/navigation-bar/navigation-bar.css +77 -2
  110. package/src/components/navigation-bar/navigation-bar.js +171 -4
  111. package/src/components/panel/panel.js +13 -2
  112. package/src/components/search/search.js +17 -5
  113. package/src/components/select/field-adapter.js +4 -1
  114. package/src/components/select/select.js +3 -0
  115. package/src/components/sort-control/sort-control.css +17 -0
  116. package/src/components/sort-control/sort-control.js +140 -0
  117. package/src/components/stat-tile/stat-tile.css +213 -0
  118. package/src/components/stat-tile/stat-tile.js +590 -0
  119. package/src/components/tabbox/tabbox.js +12 -1
  120. package/src/components/table/table.css +51 -2
  121. package/src/components/table/table.js +298 -19
  122. package/src/components/table-view/table-view.css +8 -2
  123. package/src/components/table-view/table-view.js +21 -6
  124. package/src/components/tag-picker/tag-picker.css +3 -1
  125. package/src/components/timebox/timebox.js +18 -5
  126. package/src/components/value-list/value-list.js +22 -6
  127. package/src/components/view/record-view.css +5 -0
  128. package/src/components/view/record-view.js +100 -7
  129. package/src/core/component.js +8 -3
  130. package/src/core/icons.js +74 -5
  131. package/src/index.js +33 -3
  132. package/src/internal/app-rail.js +19 -1
  133. package/src/internal/menu-items.js +142 -0
  134. package/styles/print.css +192 -0
  135. package/styles/zx.css +11 -0
  136. package/DESIGN-SYSTEM.md +0 -182
@@ -7,6 +7,15 @@ import { readViewField, renderViewField } from '../view/record-view.js';
7
7
  /** @typedef {Record<string, any>} ViewRecord */
8
8
  /** @typedef {import('../view/record-view.js').ViewField} ViewField */
9
9
  /** @typedef {string|((record:ViewRecord,index:number)=>unknown)|null} RecordCardValueSource */
10
+ /** @typedef {'eyebrow-start'|'eyebrow-end'|'title-prefix'} RecordCardFieldSlot */
11
+
12
+ /**
13
+ * @typedef {Object} RecordCardFieldGroups
14
+ * @property {ViewField[]} eyebrowStart Fields shown at the logical start of the eyebrow row.
15
+ * @property {ViewField[]} eyebrowEnd Fields shown at the logical end of the eyebrow row.
16
+ * @property {ViewField[]} titlePrefix Fields shown immediately before, but outside, the heading.
17
+ * @property {ViewField[]} metadata Remaining labelled metadata fields.
18
+ */
10
19
 
11
20
  /**
12
21
  * @typedef {Object} RecordCardPreviewDescriptor
@@ -53,6 +62,7 @@ import { readViewField, renderViewField } from '../view/record-view.js';
53
62
  * @property {RecordCardLink|((record:ViewRecord,index:number)=>RecordCardLink)} [link]
54
63
  * @property {RecordCardAction[]|((record:ViewRecord,index:number)=>RecordCardAction[])} [actions=[]]
55
64
  * @property {false|'single'|'multi'} [selectable=false] Selection presentation.
65
+ * @property {'checkbox'|'card'} [selectionTrigger='checkbox'] Selection affordance for owning views.
56
66
  * @property {boolean} [selected=false] Current selection state.
57
67
  * @property {'outlined'|'raised'|'filled'} [variant='outlined'] Surface treatment.
58
68
  * @property {1|2|3|4|5|6} [headingLevel=3] Card title heading level.
@@ -73,11 +83,13 @@ import { readViewField, renderViewField } from '../view/record-view.js';
73
83
  * becomes text, URLs are normalized before reaching native attributes, and actions expose stable
74
84
  * data attributes for a component-owned delegated listener.
75
85
  *
76
- * The returned `<li>` belongs in a semantic `<ul>`/`<ol>`. When selectable it carries
77
- * `aria-selected`; multi-selection adds a native checkbox. It deliberately retains list-item
78
- * semantics rather than becoming an ARIA option because cards may contain native links and
79
- * buttons, which the listbox pattern does not permit inside an option. Preview images are paired
80
- * with a stable fallback. The owning component listens for image `error` and marks the fallback.
86
+ * The returned `<li>` belongs in a semantic `<ul>`/`<ol>`. When selectable its global
87
+ * `aria-description` announces selection without applying the unsupported `aria-selected` state
88
+ * to a listitem; multi-selection adds a native checkbox unless the owner opts into card-surface
89
+ * selection. It deliberately remains a listitem
90
+ * rather than becoming an ARIA option because cards may contain native links and buttons, which
91
+ * the listbox pattern does not permit inside an option. Preview images are paired with a stable
92
+ * fallback. The owning component listens for image `error` and marks the fallback.
81
93
  *
82
94
  * @param {ViewRecord} record Record to render.
83
95
  * @param {number} index Current display index.
@@ -112,7 +124,14 @@ export function createRecordCard(record, index, options = {}) {
112
124
  class: 'zx-record-card__heading',
113
125
  id: headingId
114
126
  }, headingContent);
115
- const selection = selectable === 'multi' ? h('label', {
127
+ const fieldGroups = partitionRecordCardFields(visibleFields, titleSource, subtitleSource);
128
+ const eyebrow = createEyebrow(record, index, fieldGroups.eyebrowStart, fieldGroups.eyebrowEnd);
129
+ const titlePrefix = createFieldList(record, index, fieldGroups.titlePrefix,
130
+ 'zx-record-card__title-prefix', 'title-prefix');
131
+ const titleRow = titlePrefix
132
+ ? h('div', { class: 'zx-record-card__title-row' }, titlePrefix, heading)
133
+ : heading;
134
+ const selection = selectable === 'multi' && options.selectionTrigger !== 'card' ? h('label', {
116
135
  class: 'zx-record-card__selection'
117
136
  }, h('input', {
118
137
  type: 'checkbox',
@@ -127,25 +146,57 @@ export function createRecordCard(record, index, options = {}) {
127
146
  ariaLabel: `Actions for ${textAlternative(title) || 'record'}`
128
147
  }, controls, actionNodes) : null;
129
148
  const header = h('header', { class: 'zx-record-card__header' }, selection,
130
- h('div', { class: 'zx-record-card__titles' }, heading,
149
+ h('div', { class: 'zx-record-card__titles' }, titleRow,
131
150
  isEmptyContent(subtitle) ? null : h('p', { class: 'zx-record-card__subtitle' }, contentNode(subtitle))),
132
151
  actionArea);
133
- const metadata = createMetadata(record, index, visibleFields, titleSource, subtitleSource);
134
- const body = h('div', { class: 'zx-record-card__body' }, header, metadata);
152
+ const metadata = createFieldList(record, index, fieldGroups.metadata, 'zx-record-card__metadata');
153
+ const body = h('div', { class: 'zx-record-card__body' }, eyebrow, header, metadata);
135
154
  const card = /** @type {HTMLLIElement} */ (h('li', {
136
155
  class: 'zx-record-card',
137
156
  tabindex: 0,
138
- ariaSelected: selectable ? String(selected) : undefined,
157
+ ariaDescription: selectable ? selected ? 'Selected' : 'Not selected' : undefined,
139
158
  ariaLabelledby: headingId,
159
+ ariaKeyshortcuts: selectable && options.selectionTrigger === 'card' ? 'Space Enter' : undefined,
140
160
  dataset: {
141
161
  recordIndex: String(index),
142
162
  variant: normalizeVariant(options.variant),
143
- selected: String(selected)
163
+ selected: String(selected),
164
+ selectionTrigger: selectable && options.selectionTrigger === 'card' ? 'card' : 'checkbox'
144
165
  }
145
166
  }, preview ? createPreview(preview) : null, body));
146
167
  return card;
147
168
  }
148
169
 
170
+ /**
171
+ * Partitions ordered visible fields into the shared record-card slots. A recognized
172
+ * `field.view.card.slot` moves the field to that semantic region; absent or unknown values remain
173
+ * ordinary metadata. Title and subtitle fields retain the existing no-duplication rule.
174
+ *
175
+ * The input array and descriptors are never mutated. Each field appears in at most one result
176
+ * array, and the order within every result follows the supplied visible-field order.
177
+ *
178
+ * @param {ViewField[]} fields Ordered visible fields.
179
+ * @param {RecordCardValueSource} [titleSource=null] Active title field or resolver.
180
+ * @param {RecordCardValueSource} [subtitleSource=null] Active subtitle field or resolver.
181
+ * @returns {RecordCardFieldGroups} Partitioned fields.
182
+ */
183
+ export function partitionRecordCardFields(fields, titleSource = null, subtitleSource = null) {
184
+ /** @type {RecordCardFieldGroups} */
185
+ const groups = { eyebrowStart: [], eyebrowEnd: [], titlePrefix: [], metadata: [] };
186
+ const titleId = typeof titleSource === 'string' ? titleSource : null;
187
+ const subtitleId = typeof subtitleSource === 'string' ? subtitleSource : null;
188
+
189
+ for (const field of Array.isArray(fields) ? fields : []) {
190
+ if (!field.duplicate && (field.id === titleId || field.id === subtitleId)) continue;
191
+ const slot = resolveRecordCardFieldSlot(field);
192
+ if (slot === 'eyebrow-start') groups.eyebrowStart.push(field);
193
+ else if (slot === 'eyebrow-end') groups.eyebrowEnd.push(field);
194
+ else if (slot === 'title-prefix') groups.titlePrefix.push(field);
195
+ else groups.metadata.push(field);
196
+ }
197
+ return groups;
198
+ }
199
+
149
200
  /**
150
201
  * Resolves a preview without touching the DOM. Unsafe, malformed, and non-image URL schemes are
151
202
  * rejected into the same descriptor shape so callers can still render the stable fallback.
@@ -245,26 +296,52 @@ function createPreview(preview) {
245
296
  }, fallback, image);
246
297
  }
247
298
 
299
+ /**
300
+ * @param {ViewRecord} record
301
+ * @param {number} index
302
+ * @param {ViewField[]} eyebrowStart
303
+ * @param {ViewField[]} eyebrowEnd
304
+ * @returns {HTMLElement|null}
305
+ */
306
+ function createEyebrow(record, index, eyebrowStart, eyebrowEnd) {
307
+ const start = createFieldList(record, index, eyebrowStart,
308
+ 'zx-record-card__eyebrow-group', 'eyebrow-start');
309
+ const end = createFieldList(record, index, eyebrowEnd,
310
+ 'zx-record-card__eyebrow-group', 'eyebrow-end');
311
+ return start || end ? h('div', { class: 'zx-record-card__eyebrow' }, start, end) : null;
312
+ }
313
+
248
314
  /**
249
315
  * @param {ViewRecord} record
250
316
  * @param {number} index
251
317
  * @param {ViewField[]} fields
252
- * @param {RecordCardValueSource} titleSource
253
- * @param {RecordCardValueSource} subtitleSource
318
+ * @param {string} className
319
+ * @param {RecordCardFieldSlot|null} [slot=null]
254
320
  * @returns {HTMLElement|null}
255
321
  */
256
- function createMetadata(record, index, fields, titleSource, subtitleSource) {
257
- const titleId = typeof titleSource === 'string' ? titleSource : null;
258
- const subtitleId = typeof subtitleSource === 'string' ? subtitleSource : null;
259
- const visible = fields.filter((field) => field.duplicate || field.id !== titleId && field.id !== subtitleId);
260
- if (visible.length === 0) return null;
261
- return h('dl', { class: 'zx-record-card__metadata' }, visible.map((field) => h('div', {
322
+ function createFieldList(record, index, fields, className, slot = null) {
323
+ if (fields.length === 0) return null;
324
+ return h('dl', {
325
+ class: className,
326
+ dataset: slot ? { cardSlot: slot } : undefined
327
+ }, fields.map((field) => h('div', {
262
328
  class: 'zx-record-card__field',
263
- dataset: { fieldId: field.id }
329
+ dataset: { fieldId: field.id, ...(slot ? { cardSlot: slot } : {}) }
264
330
  }, h('dt', { class: 'zx-record-card__label' }, field.label),
265
331
  h('dd', { class: 'zx-record-card__value' }, contentNode(renderViewField(field, record, index))))));
266
332
  }
267
333
 
334
+ /** @param {ViewField} field @returns {RecordCardFieldSlot|null} */
335
+ function resolveRecordCardFieldSlot(field) {
336
+ const view = field?.view;
337
+ if (!view || typeof view !== 'object' || Array.isArray(view)) return null;
338
+ const card = view.card;
339
+ if (!card || typeof card !== 'object' || Array.isArray(card)) return null;
340
+ const slot = /** @type {{slot?:unknown}} */ (card).slot;
341
+ return slot === 'eyebrow-start' || slot === 'eyebrow-end' || slot === 'title-prefix'
342
+ ? slot : null;
343
+ }
344
+
268
345
  /** @param {RecordCardAction} action @returns {HTMLElement} */
269
346
  function createAction(action) {
270
347
  const label = String(action.label ?? '');
@@ -356,7 +433,7 @@ function safePreviewUrl(value) {
356
433
  try {
357
434
  const base = globalThis.location?.href ?? 'https://zx.invalid/';
358
435
  const protocol = new URL(href, base).protocol.toLowerCase();
359
- return ['http:', 'https:', 'blob:', 'file:'].includes(protocol) ? href : null;
436
+ return ['http:', 'https:', 'blob:'].includes(protocol) ? href : null;
360
437
  } catch {
361
438
  return null;
362
439
  }
@@ -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
+ }
@@ -16,15 +16,19 @@
16
16
  }
17
17
 
18
18
  .zx-context-menu__item {
19
- display: flex;
19
+ display: grid;
20
+ grid-template-columns: auto minmax(0, 1fr) auto;
20
21
  align-items: center;
21
22
  gap: var(--zx-space-2);
22
23
  inline-size: 100%;
23
24
  min-block-size: var(--zx-control-height);
24
- border-color: transparent;
25
+ border: 1px solid transparent;
25
26
  background: transparent;
26
27
  padding-inline: var(--zx-control-pad-x);
28
+ color: inherit;
29
+ font: inherit;
27
30
  text-align: start;
31
+ text-decoration: none;
28
32
  }
29
33
 
30
34
  .zx-context-menu__item:hover,
@@ -42,11 +46,84 @@
42
46
  opacity: 0.65;
43
47
  }
44
48
 
45
- .zx-context-menu__icon {
49
+ .zx-context-menu__leading,
50
+ .zx-context-menu__meta {
46
51
  display: inline-flex;
52
+ align-items: center;
53
+ gap: var(--zx-space-2);
47
54
  flex: none;
48
55
  }
49
56
 
57
+ .zx-context-menu__meta {
58
+ justify-content: flex-end;
59
+ }
60
+
61
+ .zx-context-menu__leading {
62
+ justify-content: center;
63
+ inline-size: var(--zx-space-4);
64
+ }
65
+
66
+ .zx-context-menu__body {
67
+ display: flex;
68
+ min-inline-size: 0;
69
+ flex-direction: column;
70
+ }
71
+
72
+ .zx-context-menu__label,
73
+ .zx-context-menu__description {
74
+ overflow: hidden;
75
+ text-overflow: ellipsis;
76
+ white-space: nowrap;
77
+ }
78
+
79
+ .zx-context-menu__description,
80
+ .zx-context-menu__shortcut,
81
+ .zx-context-menu__adornment {
82
+ color: var(--zx-color-text-muted);
83
+ font-size: var(--zx-text-xs);
84
+ }
85
+
86
+ .zx-context-menu__badge {
87
+ min-inline-size: var(--zx-space-4);
88
+ border-radius: var(--zx-radius-full);
89
+ background: var(--zx-color-bg-muted);
90
+ padding-inline: var(--zx-space-1);
91
+ color: var(--zx-color-text-muted);
92
+ font-size: var(--zx-text-xs);
93
+ text-align: center;
94
+ }
95
+
96
+ .zx-context-menu__shortcut {
97
+ border: 0;
98
+ background: transparent;
99
+ padding: 0;
100
+ font-family: var(--zx-font-mono);
101
+ }
102
+
103
+ .zx-context-menu__check {
104
+ inline-size: var(--zx-space-3);
105
+ block-size: var(--zx-space-3);
106
+ border: 1px solid var(--zx-color-border-strong);
107
+ border-radius: var(--zx-radius-sm);
108
+ }
109
+
110
+ .zx-context-menu__check[data-check-kind="radio"] {
111
+ border-radius: var(--zx-radius-full);
112
+ }
113
+
114
+ .zx-context-menu__item[aria-checked="true"] .zx-context-menu__check {
115
+ border-color: var(--zx-color-accent);
116
+ background: var(--zx-color-accent);
117
+ box-shadow: inset 0 0 0 2px var(--zx-color-bg-raised);
118
+ }
119
+
120
+ .zx-context-menu__heading {
121
+ padding: var(--zx-space-2) var(--zx-control-pad-x) var(--zx-space-1);
122
+ color: var(--zx-color-text-muted);
123
+ font-size: var(--zx-text-xs);
124
+ font-weight: 600;
125
+ }
126
+
50
127
  .zx-context-menu__separator {
51
128
  block-size: 1px;
52
129
  margin: var(--zx-space-1) var(--zx-space-2);