@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.
- package/CHANGELOG.md +155 -0
- package/README.md +7 -0
- 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.d.ts +19 -0
- package/dist/types/components/card/card.d.ts +7 -0
- package/dist/types/components/card-view/card-view.d.ts +11 -3
- package/dist/types/components/card-view/record-card.d.ts +7 -1
- 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-view.d.ts +170 -4
- 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/icons.d.ts +17 -0
- package/dist/types/index.d.ts +11 -0
- 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 +616 -77
- package/dist/zx-zeyos.esm.min.js +8 -1
- package/dist/zx.css +2085 -238
- package/dist/zx.esm.js +6704 -1542
- package/dist/zx.esm.min.js +10 -3
- package/dist/zx.global.js +6704 -1542
- package/dist/zx.global.min.js +10 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +1479 -127
- package/docs/llms.md +315 -10
- package/docs/llms.txt +11 -5
- package/package.json +1 -1
- 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.js +33 -3
- package/src/components/card/card.js +12 -1
- package/src/components/card-view/card-view.css +1 -0
- package/src/components/card-view/card-view.js +25 -9
- package/src/components/card-view/record-card.js +7 -3
- 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-view.css +194 -0
- package/src/components/kanban-view/kanban-view.js +251 -8
- 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 +5 -2
- 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/icons.js +74 -5
- package/src/index.js +25 -0
- 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
|
@@ -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:
|
|
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:
|
|
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 */
|
|
@@ -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]
|
|
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)
|
|
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', {
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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.
|
|
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
|
|
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
|
+
}
|