@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.
- package/CHANGELOG.md +266 -1
- package/README.md +7 -3
- package/dist/types/components/activity-item/activity-item.d.ts +351 -0
- package/dist/types/components/activity-list/activity-list.d.ts +195 -0
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
- package/dist/types/components/button/button.d.ts +8 -0
- package/dist/types/components/calendar/calendar-model.d.ts +22 -0
- package/dist/types/components/calendar/calendar.d.ts +19 -0
- package/dist/types/components/card/card.d.ts +7 -0
- package/dist/types/components/card-view/card-view.d.ts +12 -4
- package/dist/types/components/card-view/record-card.d.ts +53 -5
- package/dist/types/components/code-editor/code-editor.d.ts +160 -0
- package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +18 -2
- package/dist/types/components/date-picker/date-picker.d.ts +8 -0
- package/dist/types/components/date-picker/time-picker.d.ts +7 -0
- package/dist/types/components/dialog/dialog.d.ts +29 -0
- package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
- package/dist/types/components/file-item/file-item.d.ts +234 -0
- package/dist/types/components/file-list/file-list.d.ts +171 -0
- package/dist/types/components/filter/filter.d.ts +91 -36
- package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
- package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
- package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
- package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
- package/dist/types/components/launcher/launcher.d.ts +19 -12
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
- package/dist/types/components/menu-button/menu-button.d.ts +4 -37
- package/dist/types/components/message/message.d.ts +7 -0
- package/dist/types/components/modal/modal.d.ts +48 -0
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
- package/dist/types/components/panel/panel.d.ts +7 -0
- package/dist/types/components/search/search.d.ts +7 -0
- package/dist/types/components/select/select.d.ts +4 -0
- package/dist/types/components/sort-control/sort-control.d.ts +105 -0
- package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
- package/dist/types/components/tabbox/tabbox.d.ts +7 -0
- package/dist/types/components/table/table.d.ts +144 -0
- package/dist/types/components/table-view/table-view.d.ts +11 -5
- package/dist/types/components/timebox/timebox.d.ts +7 -0
- package/dist/types/components/value-list/value-list.d.ts +8 -0
- package/dist/types/components/view/record-view.d.ts +96 -5
- package/dist/types/core/component.d.ts +6 -1
- package/dist/types/core/icons.d.ts +17 -0
- package/dist/types/index.d.ts +13 -1
- package/dist/types/internal/app-rail.d.ts +10 -0
- package/dist/types/internal/menu-items.d.ts +134 -0
- package/dist/zx-zeyos.esm.js +651 -88
- package/dist/zx-zeyos.esm.min.js +8 -1
- package/dist/zx.css +6266 -3900
- package/dist/zx.esm.js +8487 -1805
- package/dist/zx.esm.min.js +10 -3
- package/dist/zx.global.js +8487 -1805
- package/dist/zx.global.min.js +10 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1811 -247
- package/docs/llms.md +345 -40
- package/docs/llms.txt +11 -7
- package/package.json +1 -2
- package/src/components/activity-item/activity-item.css +137 -0
- package/src/components/activity-item/activity-item.js +599 -0
- package/src/components/activity-list/activity-list.css +64 -0
- package/src/components/activity-list/activity-list.js +375 -0
- package/src/components/app-rail/app-rail.css +227 -19
- package/src/components/app-sidebar/app-sidebar.css +414 -24
- package/src/components/app-sidebar/app-sidebar.js +65 -5
- package/src/components/button/button.css +8 -0
- package/src/components/button/button.js +6 -1
- package/src/components/calendar/calendar-model.js +45 -0
- package/src/components/calendar/calendar.css +30 -10
- package/src/components/calendar/calendar.js +41 -13
- package/src/components/card/card.js +12 -1
- package/src/components/card-view/card-view.css +212 -9
- package/src/components/card-view/card-view.js +32 -16
- package/src/components/card-view/record-card.js +98 -21
- package/src/components/code-editor/code-editor.css +67 -0
- package/src/components/code-editor/code-editor.js +288 -0
- package/src/components/code-editor/field-adapter.js +34 -0
- package/src/components/context-menu/context-menu.css +80 -3
- package/src/components/context-menu/context-menu.js +22 -30
- package/src/components/date-picker/date-picker.js +22 -8
- package/src/components/date-picker/month-picker.js +3 -3
- package/src/components/date-picker/time-picker.js +20 -8
- package/src/components/dialog/dialog.js +88 -66
- package/src/components/dropdown/dropdown.js +2 -2
- package/src/components/entity-ref/entity-ref.css +182 -0
- package/src/components/entity-ref/entity-ref.js +496 -0
- package/src/components/file-item/file-item.css +88 -0
- package/src/components/file-item/file-item.js +371 -0
- package/src/components/file-list/file-list.css +51 -0
- package/src/components/file-list/file-list.js +320 -0
- package/src/components/filter/filter.css +27 -1
- package/src/components/filter/filter.js +199 -45
- package/src/components/filter-panel/filter-panel.css +104 -0
- package/src/components/filter-panel/filter-panel.js +964 -0
- package/src/components/kanban-view/kanban-card.js +239 -0
- package/src/components/kanban-view/kanban-policy.js +412 -0
- package/src/components/kanban-view/kanban-view.css +674 -36
- package/src/components/kanban-view/kanban-view.js +1762 -240
- package/src/components/launcher/launcher.js +59 -27
- package/src/components/list-toolbar/list-toolbar.css +189 -0
- package/src/components/list-toolbar/list-toolbar.js +851 -0
- package/src/components/menu-button/menu-button.css +80 -3
- package/src/components/menu-button/menu-button.js +21 -37
- package/src/components/message/message.css +14 -12
- package/src/components/message/message.js +13 -2
- package/src/components/modal/modal.css +15 -0
- package/src/components/modal/modal.js +141 -0
- package/src/components/navigation-bar/navigation-bar.css +77 -2
- package/src/components/navigation-bar/navigation-bar.js +171 -4
- package/src/components/panel/panel.js +13 -2
- package/src/components/search/search.js +17 -5
- package/src/components/select/field-adapter.js +4 -1
- package/src/components/select/select.js +3 -0
- package/src/components/sort-control/sort-control.css +17 -0
- package/src/components/sort-control/sort-control.js +140 -0
- package/src/components/stat-tile/stat-tile.css +213 -0
- package/src/components/stat-tile/stat-tile.js +590 -0
- package/src/components/tabbox/tabbox.js +12 -1
- package/src/components/table/table.css +51 -2
- package/src/components/table/table.js +298 -19
- package/src/components/table-view/table-view.css +8 -2
- package/src/components/table-view/table-view.js +21 -6
- package/src/components/tag-picker/tag-picker.css +3 -1
- package/src/components/timebox/timebox.js +18 -5
- package/src/components/value-list/value-list.js +22 -6
- package/src/components/view/record-view.css +5 -0
- package/src/components/view/record-view.js +100 -7
- package/src/core/component.js +8 -3
- package/src/core/icons.js +74 -5
- package/src/index.js +33 -3
- package/src/internal/app-rail.js +19 -1
- package/src/internal/menu-items.js +142 -0
- package/styles/print.css +192 -0
- package/styles/zx.css +11 -0
- 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
|
|
77
|
-
* `aria-
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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' },
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
253
|
-
* @param {
|
|
318
|
+
* @param {string} className
|
|
319
|
+
* @param {RecordCardFieldSlot|null} [slot=null]
|
|
254
320
|
* @returns {HTMLElement|null}
|
|
255
321
|
*/
|
|
256
|
-
function
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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:'
|
|
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:
|
|
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
|
|
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-
|
|
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);
|