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