@quillmark/svelte 0.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/LICENSE +201 -0
- package/NOTICE +58 -0
- package/README.md +241 -0
- package/THEMING.md +153 -0
- package/dist/core/address.d.ts +56 -0
- package/dist/core/address.js +95 -0
- package/dist/core/bloom.d.ts +37 -0
- package/dist/core/bloom.js +116 -0
- package/dist/core/codec/anchor.d.ts +19 -0
- package/dist/core/codec/anchor.js +36 -0
- package/dist/core/codec/code.d.ts +12 -0
- package/dist/core/codec/code.js +118 -0
- package/dist/core/codec/decode.d.ts +24 -0
- package/dist/core/codec/decode.js +270 -0
- package/dist/core/codec/encode.d.ts +71 -0
- package/dist/core/codec/encode.js +480 -0
- package/dist/core/codec/field.d.ts +171 -0
- package/dist/core/codec/field.js +493 -0
- package/dist/core/codec/index.d.ts +22 -0
- package/dist/core/codec/index.js +43 -0
- package/dist/core/codec/inputrules.d.ts +7 -0
- package/dist/core/codec/inputrules.js +168 -0
- package/dist/core/codec/islands.d.ts +36 -0
- package/dist/core/codec/islands.js +82 -0
- package/dist/core/codec/keymap.d.ts +13 -0
- package/dist/core/codec/keymap.js +29 -0
- package/dist/core/codec/lists.d.ts +22 -0
- package/dist/core/codec/lists.js +104 -0
- package/dist/core/codec/marks.d.ts +37 -0
- package/dist/core/codec/marks.js +74 -0
- package/dist/core/codec/positions.d.ts +17 -0
- package/dist/core/codec/positions.js +108 -0
- package/dist/core/codec/prose.css +458 -0
- package/dist/core/codec/reconcile.d.ts +26 -0
- package/dist/core/codec/reconcile.js +64 -0
- package/dist/core/codec/schema.d.ts +13 -0
- package/dist/core/codec/schema.js +155 -0
- package/dist/core/codec/slash.d.ts +79 -0
- package/dist/core/codec/slash.js +0 -0
- package/dist/core/codec/table-view.d.ts +50 -0
- package/dist/core/codec/table-view.js +928 -0
- package/dist/core/codec/table.d.ts +85 -0
- package/dist/core/codec/table.js +224 -0
- package/dist/core/errors.d.ts +73 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/index.d.ts +5 -0
- package/dist/core/index.js +19 -0
- package/dist/core/lifecycle.d.ts +7 -0
- package/dist/core/lifecycle.js +21 -0
- package/dist/core/rebind.svelte.d.ts +18 -0
- package/dist/core/rebind.svelte.js +44 -0
- package/dist/core/teardown.d.ts +20 -0
- package/dist/core/teardown.js +37 -0
- package/dist/core/theme.css +276 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +9 -0
- package/dist/preset/index.css +10 -0
- package/dist/preset/recipes.css +188 -0
- package/dist/preset/scale.css +97 -0
- package/dist/preview/Preview.svelte +118 -0
- package/dist/preview/Preview.svelte.d.ts +39 -0
- package/dist/preview/bridge.d.ts +13 -0
- package/dist/preview/bridge.js +88 -0
- package/dist/preview/controller.d.ts +40 -0
- package/dist/preview/controller.js +141 -0
- package/dist/preview/geometry.d.ts +37 -0
- package/dist/preview/geometry.js +45 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +14 -0
- package/dist/preview/overlay.d.ts +10 -0
- package/dist/preview/overlay.js +82 -0
- package/dist/preview/paint.d.ts +17 -0
- package/dist/preview/paint.js +262 -0
- package/dist/preview/strings.d.ts +14 -0
- package/dist/preview/strings.js +22 -0
- package/dist/visual/ArrayField.svelte +362 -0
- package/dist/visual/ArrayField.svelte.d.ts +24 -0
- package/dist/visual/BooleanField.svelte +88 -0
- package/dist/visual/BooleanField.svelte.d.ts +20 -0
- package/dist/visual/Card.svelte +849 -0
- package/dist/visual/Card.svelte.d.ts +43 -0
- package/dist/visual/CardControls.svelte +92 -0
- package/dist/visual/CardControls.svelte.d.ts +11 -0
- package/dist/visual/DateField.svelte +203 -0
- package/dist/visual/DateField.svelte.d.ts +28 -0
- package/dist/visual/DiagnosticList.svelte +50 -0
- package/dist/visual/DiagnosticList.svelte.d.ts +7 -0
- package/dist/visual/EnumField.svelte +169 -0
- package/dist/visual/EnumField.svelte.d.ts +25 -0
- package/dist/visual/Field.svelte +270 -0
- package/dist/visual/Field.svelte.d.ts +37 -0
- package/dist/visual/FieldHint.svelte +185 -0
- package/dist/visual/FieldHint.svelte.d.ts +13 -0
- package/dist/visual/FieldLabel.svelte +141 -0
- package/dist/visual/FieldLabel.svelte.d.ts +19 -0
- package/dist/visual/FormatPopover.svelte +396 -0
- package/dist/visual/FormatPopover.svelte.d.ts +9 -0
- package/dist/visual/NumberField.svelte +72 -0
- package/dist/visual/NumberField.svelte.d.ts +19 -0
- package/dist/visual/ObjectField.svelte +131 -0
- package/dist/visual/ObjectField.svelte.d.ts +17 -0
- package/dist/visual/ProseArrayElement.svelte +98 -0
- package/dist/visual/ProseArrayElement.svelte.d.ts +19 -0
- package/dist/visual/ProseField.svelte +213 -0
- package/dist/visual/ProseField.svelte.d.ts +50 -0
- package/dist/visual/SlashMenu.svelte +126 -0
- package/dist/visual/SlashMenu.svelte.d.ts +14 -0
- package/dist/visual/TextField.svelte +63 -0
- package/dist/visual/TextField.svelte.d.ts +23 -0
- package/dist/visual/TipsCard.svelte +191 -0
- package/dist/visual/TipsCard.svelte.d.ts +13 -0
- package/dist/visual/VisualEditor.svelte +96 -0
- package/dist/visual/VisualEditor.svelte.d.ts +14 -0
- package/dist/visual/VisualEditorInner.svelte +1032 -0
- package/dist/visual/VisualEditorInner.svelte.d.ts +17 -0
- package/dist/visual/controls.css +272 -0
- package/dist/visual/diagnostics.d.ts +52 -0
- package/dist/visual/diagnostics.js +83 -0
- package/dist/visual/domid.d.ts +21 -0
- package/dist/visual/domid.js +36 -0
- package/dist/visual/ext.d.ts +19 -0
- package/dist/visual/ext.js +29 -0
- package/dist/visual/icons/Icon.svelte +39 -0
- package/dist/visual/icons/Icon.svelte.d.ts +12 -0
- package/dist/visual/icons/nodes.d.ts +103 -0
- package/dist/visual/icons/nodes.js +55 -0
- package/dist/visual/index.d.ts +9 -0
- package/dist/visual/index.js +24 -0
- package/dist/visual/motion.d.ts +18 -0
- package/dist/visual/motion.js +53 -0
- package/dist/visual/props.d.ts +77 -0
- package/dist/visual/props.js +1 -0
- package/dist/visual/signals.d.ts +59 -0
- package/dist/visual/signals.js +1 -0
- package/dist/visual/strings.d.ts +123 -0
- package/dist/visual/strings.js +80 -0
- package/dist/visual/structure.d.ts +224 -0
- package/dist/visual/structure.js +270 -0
- package/dist/visual/synced.svelte.d.ts +15 -0
- package/dist/visual/synced.svelte.js +34 -0
- package/dist/visual/tips.d.ts +24 -0
- package/dist/visual/tips.js +50 -0
- package/package.json +102 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Document, Quill, Addr, Diagnostic } from '@quillmark/wasm';
|
|
2
|
+
import type { EditorErrorHandler } from '../core/errors.js';
|
|
3
|
+
import type { FieldController } from '../core/codec/index.js';
|
|
4
|
+
import type { CardModel } from './structure.js';
|
|
5
|
+
import type { FieldDomIds } from './domid.js';
|
|
6
|
+
/** The per-card operation bundle the VisualEditor builds (all resolve id→index lazily). */
|
|
7
|
+
interface CardOps {
|
|
8
|
+
makeAddr: (field?: string) => Addr;
|
|
9
|
+
leafKey: (field?: string) => string;
|
|
10
|
+
domIds: (field?: string) => FieldDomIds;
|
|
11
|
+
panelId: (group: string) => string;
|
|
12
|
+
commit: (name: string, value: unknown) => void;
|
|
13
|
+
move: (dir: -1 | 1) => void;
|
|
14
|
+
remove: () => void;
|
|
15
|
+
retype: (kind: string) => void;
|
|
16
|
+
rename: (title: string) => void;
|
|
17
|
+
diagFor: (field?: string) => Diagnostic[] | undefined;
|
|
18
|
+
/** Consumer enum-option policy for a field; true when no hook set. */
|
|
19
|
+
enumAllowed: (field: string, value: string) => boolean;
|
|
20
|
+
}
|
|
21
|
+
interface Props {
|
|
22
|
+
card: CardModel;
|
|
23
|
+
doc: Document;
|
|
24
|
+
/** The schema a prose leaf reads its content through (`ProseField`). */
|
|
25
|
+
quill: Quill;
|
|
26
|
+
index: number;
|
|
27
|
+
isFirst: boolean;
|
|
28
|
+
isLast: boolean;
|
|
29
|
+
kinds: string[];
|
|
30
|
+
ops: CardOps;
|
|
31
|
+
onFocus?: (addr: Addr) => void;
|
|
32
|
+
onCaretMove?: (addr: Addr, pos: number) => void;
|
|
33
|
+
onChange?: (addr: Addr) => void;
|
|
34
|
+
onError?: EditorErrorHandler;
|
|
35
|
+
register?: (key: string, controller: FieldController) => void;
|
|
36
|
+
unregister?: (key: string) => void;
|
|
37
|
+
}
|
|
38
|
+
declare const Card: import("svelte").Component<Props, {
|
|
39
|
+
revealLeaf: (key: string) => void;
|
|
40
|
+
scrollIntoViewCard: (block: ScrollLogicalPosition) => void;
|
|
41
|
+
}, "">;
|
|
42
|
+
type Card = ReturnType<typeof Card>;
|
|
43
|
+
export default Card;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Card controls, composable cards only.
|
|
3
|
+
Right-aligned in the header: a move-up/move-down chevron pair revealed while the
|
|
4
|
+
pointer or the caret is in the card (each disabled at its edge), then an
|
|
5
|
+
always-visible delete. Reorder is BUTTONS, not drag (V1). The card scopes that
|
|
6
|
+
reveal, so its condition lives in the parent's CSS; the pair keeps the narrower
|
|
7
|
+
guarantee here, that a focused chevron is a visible one. The card also hangs this
|
|
8
|
+
cluster to land these glyphs on its right gutter, and reads `--_qm-glyph-control`
|
|
9
|
+
to do it: the size is the stylesheet's below, never an attribute.
|
|
10
|
+
-->
|
|
11
|
+
<script lang="ts">
|
|
12
|
+
import { wording } from './strings.js';
|
|
13
|
+
|
|
14
|
+
// The surface's words, ambient from the editor root; the package's English
|
|
15
|
+
// off-tree, so this component renders standalone too.
|
|
16
|
+
const t = wording();
|
|
17
|
+
import Icon from './icons/Icon.svelte';
|
|
18
|
+
import './controls.css';
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
isFirst: boolean;
|
|
22
|
+
isLast: boolean;
|
|
23
|
+
onMoveUp: () => void;
|
|
24
|
+
onMoveDown: () => void;
|
|
25
|
+
onDelete: () => void;
|
|
26
|
+
}
|
|
27
|
+
let { isFirst, isLast, onMoveUp, onMoveDown, onDelete }: Props = $props();
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<div class="qm-card-controls">
|
|
31
|
+
<div class="qm-card-reorder">
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
class="qm-icon-btn"
|
|
35
|
+
title={t.strings.cardMoveUp}
|
|
36
|
+
disabled={isFirst}
|
|
37
|
+
onclick={onMoveUp}><Icon name="chevron-up" /></button
|
|
38
|
+
>
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
class="qm-icon-btn"
|
|
42
|
+
title={t.strings.cardMoveDown}
|
|
43
|
+
disabled={isLast}
|
|
44
|
+
onclick={onMoveDown}><Icon name="chevron-down" /></button
|
|
45
|
+
>
|
|
46
|
+
</div>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
class="qm-icon-btn qm-card-delete"
|
|
50
|
+
title={t.strings.cardDelete}
|
|
51
|
+
onclick={onDelete}><Icon name="x" /></button
|
|
52
|
+
>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
.qm-card-controls {
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: var(--_qm-space);
|
|
60
|
+
}
|
|
61
|
+
/* The glyph rung in CSS rather than through the icon's `size`, because the card
|
|
62
|
+
header's right hang is arithmetic over this number and the tap floor: an
|
|
63
|
+
attribute set in the script is a second place the size lives, and the balance
|
|
64
|
+
breaks silently when the two disagree. A stylesheet beats an SVG presentation
|
|
65
|
+
attribute, so the rung wins without the attribute being removed from the icon. */
|
|
66
|
+
.qm-card-controls :global(svg) {
|
|
67
|
+
width: var(--_qm-glyph-control);
|
|
68
|
+
height: var(--_qm-glyph-control);
|
|
69
|
+
}
|
|
70
|
+
.qm-card-reorder {
|
|
71
|
+
display: flex;
|
|
72
|
+
gap: var(--_qm-space-half);
|
|
73
|
+
opacity: 0;
|
|
74
|
+
transition: opacity var(--_qm-duration-fast) var(--_qm-ease-reverse);
|
|
75
|
+
}
|
|
76
|
+
/* Opacity hides the pair without taking it out of the tab order, so the
|
|
77
|
+
component holds its own floor: a chevron that has focus is drawn, and its ring
|
|
78
|
+
with it. The card's reveal already covers this; the rule is here because the
|
|
79
|
+
hidden state is. */
|
|
80
|
+
.qm-card-reorder:focus-within {
|
|
81
|
+
opacity: 1;
|
|
82
|
+
}
|
|
83
|
+
/* Box and disabled state come from `.qm-icon-btn` (controls.css); only the ink
|
|
84
|
+
is this component's: the reorder pair recedes to the label tone, delete
|
|
85
|
+
carries the danger hue (scoped, so both beat the layered base). */
|
|
86
|
+
.qm-icon-btn {
|
|
87
|
+
color: var(--_qm-ink-label);
|
|
88
|
+
}
|
|
89
|
+
.qm-card-delete {
|
|
90
|
+
color: var(--_qm-danger);
|
|
91
|
+
}
|
|
92
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import './controls.css';
|
|
2
|
+
interface Props {
|
|
3
|
+
isFirst: boolean;
|
|
4
|
+
isLast: boolean;
|
|
5
|
+
onMoveUp: () => void;
|
|
6
|
+
onMoveDown: () => void;
|
|
7
|
+
onDelete: () => void;
|
|
8
|
+
}
|
|
9
|
+
declare const CardControls: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type CardControls = ReturnType<typeof CardControls>;
|
|
11
|
+
export default CardControls;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A `date` (or `datetime`) field → a styled segmented date field on bits-ui. The
|
|
3
|
+
stored value is a string (fixture uses `YYYY-MM-DD`, blank to mean "today at
|
|
4
|
+
render"); a cleared control commits `undefined` (the unset rung): the parent
|
|
5
|
+
removes the field, so the memo quill's blank-date → `datetime.today`
|
|
6
|
+
substitution applies. The value-object a date field lowers to is a
|
|
7
|
+
render-time concern: the editor only sees the stored string.
|
|
8
|
+
|
|
9
|
+
Styled rather than a native `<input type="date">`: that control's calendar popup
|
|
10
|
+
is UA-owned and reaches no dial. `DateField` (segments, no
|
|
11
|
+
calendar) rather than `DatePicker`: the segments are the entry affordance, and a
|
|
12
|
+
calendar is a second surface this field does not need.
|
|
13
|
+
|
|
14
|
+
THE BOUNDARY IS A STRING, AND THE LOCAL IS TOO. The primitive speaks
|
|
15
|
+
`CalendarDate`; the document speaks `YYYY-MM-DD`. `CalendarDate` carries no time
|
|
16
|
+
and no zone, so the round-trip is lossless and no local-midnight shift can occur:
|
|
17
|
+
the hazard that makes `new Date('2026-07-25')` the wrong tool here. Authored
|
|
18
|
+
values are data, not input: `parseDate` THROWS on anything malformed, so a bad
|
|
19
|
+
string degrades to an empty field rather than taking the editor down with it.
|
|
20
|
+
|
|
21
|
+
`syncedLocal` reconciles by IDENTITY, so the local must hold the string, not the
|
|
22
|
+
parsed value: a fresh `CalendarDate` is never `===` the last one, which would
|
|
23
|
+
make every reconcile fire and re-render all seven segments on each commit.
|
|
24
|
+
|
|
25
|
+
THE GHOST IS THE DEFAULT'S DIGITS, NOT A FORMAT HINT. An unset field
|
|
26
|
+
carrying a `default:` prints the default's digits in the segments, ghost-toned,
|
|
27
|
+
instead of the primitive's `mm`/`dd`/`yyyy` hints, which say "empty" where the
|
|
28
|
+
rung says "will render 2026-01-01". The ghost is painted in the SEGMENT SNIPPET,
|
|
29
|
+
over an unset primitive: substituting the default for `value` instead would make
|
|
30
|
+
the field indistinguishable from an authored one to every path that reads it
|
|
31
|
+
(`areAllSegmentsFilled`, Backspace, the hidden input), and the primitive shadows
|
|
32
|
+
a written-back `value` prop it was not `bind:`-ed to, so re-seating the ghost
|
|
33
|
+
after a clear never lands. `placeholder` (the `DateValue` the segments COUNT
|
|
34
|
+
from, never one they display) carries the default too, so arrowing an empty
|
|
35
|
+
segment starts at the render's date rather than today's.
|
|
36
|
+
-->
|
|
37
|
+
<script lang="ts">
|
|
38
|
+
import { DateField as BitsDateField } from 'bits-ui';
|
|
39
|
+
import { parseDate, type DateValue } from '@internationalized/date';
|
|
40
|
+
import { syncedLocal } from './synced.svelte.js';
|
|
41
|
+
import './controls.css';
|
|
42
|
+
|
|
43
|
+
interface Props {
|
|
44
|
+
value: string | undefined;
|
|
45
|
+
/** The resolved `default:` in the boundary's currency (`YYYY-MM-DD`): parsed
|
|
46
|
+
* for display only, shown while unset, never written. */
|
|
47
|
+
fallback?: string;
|
|
48
|
+
/** Accessible name for a field NOTHING else names: an object property, whose
|
|
49
|
+
* name is the field label plus the property's. A field's own date takes
|
|
50
|
+
* `labelledBy` instead. */
|
|
51
|
+
label?: string;
|
|
52
|
+
/** The field label's own id. `for` CANNOT reach this control: the segment
|
|
53
|
+
* container is not a labelable element; so the association runs the other way,
|
|
54
|
+
* and the label's click comes back through {@link focus}. It lands on the
|
|
55
|
+
* container, which the primitive gives `role="group"`: the name belongs to the
|
|
56
|
+
* SET of segments, and entering any of them announces it. (bits builds each
|
|
57
|
+
* segment's own `aria-labelledby` from a `DateField.Label` inside its root:
|
|
58
|
+
* unreachable from here, since the field's label is a grid child of the field,
|
|
59
|
+
* not of the control.) */
|
|
60
|
+
labelledBy?: string;
|
|
61
|
+
/** The parked `description` (FieldLabel): announced after the name. */
|
|
62
|
+
describedBy?: string;
|
|
63
|
+
onCommit: (v: string | undefined) => void;
|
|
64
|
+
}
|
|
65
|
+
let { value, fallback, label, labelledBy, describedBy, onCommit }: Props = $props();
|
|
66
|
+
|
|
67
|
+
let wrapEl: HTMLElement | undefined = $state();
|
|
68
|
+
/** Take the caret: what the label click, and a parent placing focus here, calls.
|
|
69
|
+
* The FIRST segment, not the field: focus lives on a segment (which is why the
|
|
70
|
+
* ring is `.qm-focus-ring-within`), and the container holds none. `literal` is the
|
|
71
|
+
* separator between segments: present, never focusable. */
|
|
72
|
+
export function focus(): void {
|
|
73
|
+
wrapEl?.querySelector<HTMLElement>('[data-segment]:not([data-segment="literal"])')?.focus();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The stored form may carry a time (`datetime`); the date half is what a date
|
|
77
|
+
// field edits, so anything past `YYYY-MM-DD` is not this control's. `parseDate`
|
|
78
|
+
// throws on a malformed authored value: an empty field is the honest render.
|
|
79
|
+
function toDateValue(s: string): DateValue | undefined {
|
|
80
|
+
if (!s) return undefined;
|
|
81
|
+
try {
|
|
82
|
+
return parseDate(s);
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Local value synced to `value` as a STRING (see the identity note above);
|
|
89
|
+
// own-edits stay local, only an external change reconciles back in. Driven
|
|
90
|
+
// CONTROLLED (`value` + `onValueChange`, never `bind:`) so reconciliation stays
|
|
91
|
+
// the package's.
|
|
92
|
+
const local = syncedLocal(() => value?.slice(0, 10) ?? '');
|
|
93
|
+
const parsed = $derived(toDateValue(local.value));
|
|
94
|
+
// The date the empty segments ghost, or undefined when there is nothing to ghost:
|
|
95
|
+
// the field is unset AND the default has a date form. A non-blank local that
|
|
96
|
+
// fails to parse is AUTHORED-but-malformed, which the empty field states
|
|
97
|
+
// honestly: the ghost would claim it unset. Held as the parsed value rather than
|
|
98
|
+
// a boolean so the substitution below narrows on the one fact it needs.
|
|
99
|
+
const fallbackDate = $derived(toDateValue(fallback?.slice(0, 10) ?? ''));
|
|
100
|
+
const ghost = $derived(local.value === '' ? fallbackDate : undefined);
|
|
101
|
+
|
|
102
|
+
// What one segment prints, and whether that text is shown-never-written.
|
|
103
|
+
//
|
|
104
|
+
// An UNFILLED segment is always shown-never-written, whether it prints the
|
|
105
|
+
// default's digits or the primitive's own `mm`/`dd`/`yyyy` hint: both state
|
|
106
|
+
// "nothing authored here". Only an unfilled segment ghosts: a half-entered date
|
|
107
|
+
// holds digits while the VALUE is still undefined (one unfilled segment unsets
|
|
108
|
+
// the whole field), so ghosting unconditionally would paint over, and dim, the
|
|
109
|
+
// digits just typed. The segment's own text is the tell: its unfilled hint is
|
|
110
|
+
// alphabetic in every locale bits ships (`mm`/`yyyy`, `аа`, `年`), a filled one
|
|
111
|
+
// is digits.
|
|
112
|
+
//
|
|
113
|
+
// The separators are `literal` parts, never unfilled; substitution covers the
|
|
114
|
+
// date parts only, so any time part keeps the primitive's text. Digits are
|
|
115
|
+
// zero-padded to the segment widths the field displays.
|
|
116
|
+
function segmentText(part: string, text: string): { text: string; ghosted: boolean } {
|
|
117
|
+
if (part === 'literal' || /\d/.test(text)) return { text, ghosted: false };
|
|
118
|
+
if (!ghost) return { text, ghosted: true };
|
|
119
|
+
switch (part) {
|
|
120
|
+
case 'year':
|
|
121
|
+
return { text: String(ghost.year).padStart(4, '0'), ghosted: true };
|
|
122
|
+
case 'month':
|
|
123
|
+
return { text: String(ghost.month).padStart(2, '0'), ghosted: true };
|
|
124
|
+
case 'day':
|
|
125
|
+
return { text: String(ghost.day).padStart(2, '0'), ghosted: true };
|
|
126
|
+
default:
|
|
127
|
+
return { text, ghosted: true };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
<span class="qm-date-wrap" bind:this={wrapEl}>
|
|
133
|
+
<BitsDateField.Root
|
|
134
|
+
value={parsed}
|
|
135
|
+
placeholder={fallbackDate}
|
|
136
|
+
onValueChange={(d) => {
|
|
137
|
+
// `CalendarDate.toString()` is exactly `YYYY-MM-DD`. A cleared or
|
|
138
|
+
// half-typed field yields undefined: the unset rung.
|
|
139
|
+
local.value = d?.toString() ?? '';
|
|
140
|
+
onCommit(d?.toString());
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<!-- `data-ghosted` states the rung the way the enum trigger does. The date
|
|
144
|
+
primitive emits no per-segment placeholder marker (only bits' `select`
|
|
145
|
+
does), so the TONE rides the same attribute, set per segment from the
|
|
146
|
+
substitution itself; which is also what keeps a half-typed date's own
|
|
147
|
+
digits at full ink while the segments around them ghost. -->
|
|
148
|
+
<BitsDateField.Input
|
|
149
|
+
class="qm-date qm-control-box qm-focus-ring-within"
|
|
150
|
+
aria-label={labelledBy ? undefined : label}
|
|
151
|
+
aria-labelledby={labelledBy}
|
|
152
|
+
aria-describedby={describedBy}
|
|
153
|
+
data-ghosted={ghost ? '' : undefined}
|
|
154
|
+
>
|
|
155
|
+
{#snippet children({ segments })}
|
|
156
|
+
<!-- Keyed by INDEX: `part` repeats (the `literal` separators between
|
|
157
|
+
segments all carry it) so a part-keyed block collides. -->
|
|
158
|
+
{#each segments as seg, i (i)}
|
|
159
|
+
{@const shown = segmentText(seg.part, seg.value)}
|
|
160
|
+
<BitsDateField.Segment
|
|
161
|
+
class="qm-date-segment"
|
|
162
|
+
part={seg.part}
|
|
163
|
+
data-ghosted={shown.ghosted ? '' : undefined}
|
|
164
|
+
>
|
|
165
|
+
{shown.text}
|
|
166
|
+
</BitsDateField.Segment>
|
|
167
|
+
{/each}
|
|
168
|
+
{/snippet}
|
|
169
|
+
</BitsDateField.Input>
|
|
170
|
+
</BitsDateField.Root>
|
|
171
|
+
</span>
|
|
172
|
+
|
|
173
|
+
<style>
|
|
174
|
+
/* A primitive renders its OWN element, which a scoped selector cannot reach:
|
|
175
|
+
styled through the wrapper with `:global`. */
|
|
176
|
+
/* The box is `.qm-control-box` (controls.css), carried on the primitive's own
|
|
177
|
+
element beside `.qm-focus-ring-within`; the segments inherit its size and ink
|
|
178
|
+
rungs, so the field and its neighbours agree without a second rule. */
|
|
179
|
+
.qm-date-wrap :global(.qm-date) {
|
|
180
|
+
display: flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
width: 100%;
|
|
183
|
+
box-sizing: border-box;
|
|
184
|
+
}
|
|
185
|
+
/* The ring rides `.qm-focus-ring-within` (controls.css) rather than the plain
|
|
186
|
+
marker: focus lives on the SEGMENT, so it rings the field, not the segment
|
|
187
|
+
the caret happens to be in. */
|
|
188
|
+
.qm-date-wrap :global(.qm-date-segment) {
|
|
189
|
+
padding: 0 var(--_qm-space-half);
|
|
190
|
+
border-radius: var(--_qm-radius-inner);
|
|
191
|
+
outline: none;
|
|
192
|
+
}
|
|
193
|
+
.qm-date-wrap :global(.qm-date-segment:focus) {
|
|
194
|
+
background: var(--_qm-surface-hover);
|
|
195
|
+
}
|
|
196
|
+
/* An unfilled segment is ghost-toned whatever it prints: the resolved `default:`
|
|
197
|
+
when there is one to ghost, the `dd`/`mm`/`yyyy` hint when there is not. Shown,
|
|
198
|
+
never written, either way. The marker is the component's own (see the snippet):
|
|
199
|
+
the date primitive emits no placeholder attribute of its own. */
|
|
200
|
+
.qm-date-wrap :global(.qm-date-segment[data-ghosted]) {
|
|
201
|
+
color: var(--_qm-ink-ghost);
|
|
202
|
+
}
|
|
203
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import './controls.css';
|
|
2
|
+
interface Props {
|
|
3
|
+
value: string | undefined;
|
|
4
|
+
/** The resolved `default:` in the boundary's currency (`YYYY-MM-DD`): parsed
|
|
5
|
+
* for display only, shown while unset, never written. */
|
|
6
|
+
fallback?: string;
|
|
7
|
+
/** Accessible name for a field NOTHING else names: an object property, whose
|
|
8
|
+
* name is the field label plus the property's. A field's own date takes
|
|
9
|
+
* `labelledBy` instead. */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** The field label's own id. `for` CANNOT reach this control: the segment
|
|
12
|
+
* container is not a labelable element; so the association runs the other way,
|
|
13
|
+
* and the label's click comes back through {@link focus}. It lands on the
|
|
14
|
+
* container, which the primitive gives `role="group"`: the name belongs to the
|
|
15
|
+
* SET of segments, and entering any of them announces it. (bits builds each
|
|
16
|
+
* segment's own `aria-labelledby` from a `DateField.Label` inside its root:
|
|
17
|
+
* unreachable from here, since the field's label is a grid child of the field,
|
|
18
|
+
* not of the control.) */
|
|
19
|
+
labelledBy?: string;
|
|
20
|
+
/** The parked `description` (FieldLabel): announced after the name. */
|
|
21
|
+
describedBy?: string;
|
|
22
|
+
onCommit: (v: string | undefined) => void;
|
|
23
|
+
}
|
|
24
|
+
declare const DateField: import("svelte").Component<Props, {
|
|
25
|
+
focus: () => void;
|
|
26
|
+
}, "">;
|
|
27
|
+
type DateField = ReturnType<typeof DateField>;
|
|
28
|
+
export default DateField;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Inline diagnostic display (VISUAL_EDITOR §Diagnostics), severity-styled via
|
|
3
|
+
CSS vars, NON-GATING: purely additive, never disables the field it sits
|
|
4
|
+
under. Shared by `Field.svelte` (a scalar/prose field) and `Card.svelte` (the
|
|
5
|
+
body leaf, which has no `Field.svelte` wrapper), so a card's body diagnostics
|
|
6
|
+
render the same way a field's do.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { Diagnostic } from '@quillmark/wasm';
|
|
10
|
+
import { diagnosticText, wording } from './strings.js';
|
|
11
|
+
|
|
12
|
+
// The consumer's formatter, ambient from the editor root. The fallback to
|
|
13
|
+
// `d.message` is load-bearing rather than defensive: the parse and render lanes
|
|
14
|
+
// carry their parameters only inside that English string, so a formatter that
|
|
15
|
+
// routes on `code` has nothing to build from and says so by returning
|
|
16
|
+
// `undefined` (FormatDiagnostic). That text is the whole rendering: no `hint`
|
|
17
|
+
// beside it, which would ship a two-language diagnostic.
|
|
18
|
+
const t = wording();
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
diagnostics: Diagnostic[] | undefined;
|
|
22
|
+
}
|
|
23
|
+
let { diagnostics }: Props = $props();
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
{#if diagnostics && diagnostics.length}
|
|
27
|
+
<!-- role=status: commit errors appear mid-typing; announce without stealing focus. -->
|
|
28
|
+
<div class="qm-diag-list" role="status">
|
|
29
|
+
{#each diagnostics as d, i (i)}
|
|
30
|
+
<span class="qm-diag-line" data-severity={d.severity}
|
|
31
|
+
>{diagnosticText(d, t.formatDiagnostic)}</span
|
|
32
|
+
>
|
|
33
|
+
{/each}
|
|
34
|
+
</div>
|
|
35
|
+
{/if}
|
|
36
|
+
|
|
37
|
+
<style>
|
|
38
|
+
.qm-diag-list {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
gap: var(--_qm-space-half);
|
|
42
|
+
}
|
|
43
|
+
.qm-diag-line {
|
|
44
|
+
font-size: var(--_qm-text-meta);
|
|
45
|
+
color: var(--_qm-warning);
|
|
46
|
+
}
|
|
47
|
+
.qm-diag-line[data-severity='error'] {
|
|
48
|
+
color: var(--_qm-danger);
|
|
49
|
+
}
|
|
50
|
+
</style>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Diagnostic } from '@quillmark/wasm';
|
|
2
|
+
interface Props {
|
|
3
|
+
diagnostics: Diagnostic[] | undefined;
|
|
4
|
+
}
|
|
5
|
+
declare const DiagnosticList: import("svelte").Component<Props, {}, "">;
|
|
6
|
+
type DiagnosticList = ReturnType<typeof DiagnosticList>;
|
|
7
|
+
export default DiagnosticList;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A `string`+`enum` (or `type: 'enum'`) field → a styled listbox over `enum ??
|
|
3
|
+
values`, on bits-ui. When nothing is authored the list shows a distinct UNSET
|
|
4
|
+
sentinel that GHOSTS the `default:` (muted, shown-never-written), distinguishable
|
|
5
|
+
from an authored pick and re-selectable; so re-picking the default fires a
|
|
6
|
+
change. The sentinel commits nothing; any real pick: INCLUDING the
|
|
7
|
+
value that equals the default: commits via the parent's typed `writer.set`.
|
|
8
|
+
Explicitly picking the default is the one place "commit the default" is genuine
|
|
9
|
+
intent, expressible. The sentinel stays in the list once a value is authored, as
|
|
10
|
+
the "clear back to default" (unset) affordance.
|
|
11
|
+
|
|
12
|
+
Styled rather than a native `<select>`: the dropdown list is UA-owned and reaches
|
|
13
|
+
no dial. Listbox semantics and typeahead come with the primitive.
|
|
14
|
+
-->
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import { Select } from 'bits-ui';
|
|
17
|
+
import Icon from './icons/Icon.svelte';
|
|
18
|
+
import { syncedLocal } from './synced.svelte.js';
|
|
19
|
+
import './controls.css';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
value: string | undefined;
|
|
23
|
+
values: string[];
|
|
24
|
+
fallback?: string;
|
|
25
|
+
/** Accessible name for a trigger NOTHING else names: an object property, whose
|
|
26
|
+
* name is the field label plus the property's. A field's own trigger takes `id`
|
|
27
|
+
* instead and is named by the `<label for>` beside it (the trigger is a
|
|
28
|
+
* `<button>`, so `for` reaches it and a label click opens the list: what the
|
|
29
|
+
* native `<select>` this replaces would have done). */
|
|
30
|
+
label?: string;
|
|
31
|
+
/** `<label for>` target. Set → the label names this trigger, so `aria-label`
|
|
32
|
+
* comes off: two names is where implementations disagree about which wins. */
|
|
33
|
+
id?: string;
|
|
34
|
+
/** The parked `description` (FieldLabel): announced after the name. */
|
|
35
|
+
describedBy?: string;
|
|
36
|
+
onCommit: (v: string | undefined) => void;
|
|
37
|
+
/** Consumer policy: `false` disables an option so it can't be picked.
|
|
38
|
+
* A disallowed value already authored stays SELECTED and visible (disabled), never
|
|
39
|
+
* stripped or mutated. The UNSET sentinel is exempt: clear-to-default always works. */
|
|
40
|
+
optionAllowed?: (value: string) => boolean;
|
|
41
|
+
}
|
|
42
|
+
let { value, values, fallback, label, id, describedBy, onCommit, optionAllowed }: Props =
|
|
43
|
+
$props();
|
|
44
|
+
|
|
45
|
+
// The sentinel's option value: a namespaced marker that no schema-authored
|
|
46
|
+
// enum member would ever be (`values` are classification markings, seal ids, and
|
|
47
|
+
// the like), so it never collides with a real option.
|
|
48
|
+
const UNSET = '__qm_unset__';
|
|
49
|
+
|
|
50
|
+
// Local selection synced to `value` (sentinel when unauthored); own-picks stay
|
|
51
|
+
// local, only an external change reconciles back in (see `syncedLocal`). Driven
|
|
52
|
+
// CONTROLLED (`value` + `onValueChange`, never `bind:`) so reconciliation stays
|
|
53
|
+
// the package's rather than the primitive's.
|
|
54
|
+
const local = syncedLocal(() => value ?? UNSET);
|
|
55
|
+
|
|
56
|
+
/** An empty enum member has no glyph of its own; an em dash stands in for it. */
|
|
57
|
+
const dash = (v: string | undefined) => v || '—';
|
|
58
|
+
|
|
59
|
+
const unset = $derived(local.value === UNSET);
|
|
60
|
+
const ghostText = $derived(dash(fallback));
|
|
61
|
+
/** What the closed trigger shows: the pick, or the ghosted default while unset. */
|
|
62
|
+
const shown = $derived(unset ? ghostText : dash(local.value));
|
|
63
|
+
|
|
64
|
+
/** The root to portal INTO: `document.body` would escape the consumer's dials
|
|
65
|
+
* along with the editor's subtree. `undefined` falls back to bits-ui's default. */
|
|
66
|
+
let wrapEl = $state<HTMLElement | undefined>(undefined);
|
|
67
|
+
const portalTarget = $derived(wrapEl?.closest<HTMLElement>('[data-qm-root]') ?? undefined);
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<span class="qm-select-wrap" bind:this={wrapEl}>
|
|
71
|
+
<!-- `allowDeselect={false}`: the UNSET sentinel is the clear-to-default
|
|
72
|
+
affordance, so the primitive's own deselect must stay off. It reports a
|
|
73
|
+
deselect as `''`: INDISTINGUISHABLE from picking the empty-string enum
|
|
74
|
+
member the reference quill actually declares. -->
|
|
75
|
+
<Select.Root
|
|
76
|
+
type="single"
|
|
77
|
+
allowDeselect={false}
|
|
78
|
+
value={local.value}
|
|
79
|
+
onValueChange={(v) => {
|
|
80
|
+
if (v == null) return;
|
|
81
|
+
local.value = v;
|
|
82
|
+
// Sentinel → unset (parent `removeField`, default renders); any real pick
|
|
83
|
+
// (incl. the default value) → a genuine write.
|
|
84
|
+
onCommit(v === UNSET ? undefined : v);
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<Select.Trigger
|
|
88
|
+
class="qm-select qm-control-box qm-focus-ring"
|
|
89
|
+
{id}
|
|
90
|
+
aria-label={id ? undefined : label}
|
|
91
|
+
aria-describedby={describedBy}
|
|
92
|
+
data-ghosted={unset ? '' : undefined}
|
|
93
|
+
>
|
|
94
|
+
{shown}
|
|
95
|
+
<Icon name="chevron-down" size={14} />
|
|
96
|
+
</Select.Trigger>
|
|
97
|
+
<Select.Portal to={portalTarget}>
|
|
98
|
+
<Select.Content sideOffset={4}>
|
|
99
|
+
<!-- The list PORTALS out of the trigger's DOM but INTO the nearest
|
|
100
|
+
`[data-qm-root]`, and carries the marker itself, like FormatPopover.
|
|
101
|
+
The pill is this
|
|
102
|
+
element (not the primitive's) because scoped CSS keys off which
|
|
103
|
+
component OWNS the markup: a `class` passed to a primitive is a
|
|
104
|
+
plain string and never picks up the scoping hash. -->
|
|
105
|
+
<div class="qm-menu-surface qm-select-content" data-qm-root>
|
|
106
|
+
<Select.Viewport>
|
|
107
|
+
<Select.Item class="qm-menu-item qm-select-item" value={UNSET} label={ghostText}>
|
|
108
|
+
<span class="qm-select-ghost">{ghostText}</span>
|
|
109
|
+
</Select.Item>
|
|
110
|
+
{#each values as v (v)}
|
|
111
|
+
<Select.Item
|
|
112
|
+
class="qm-menu-item qm-select-item"
|
|
113
|
+
value={v}
|
|
114
|
+
label={dash(v)}
|
|
115
|
+
disabled={optionAllowed?.(v) === false}
|
|
116
|
+
>
|
|
117
|
+
{dash(v)}
|
|
118
|
+
</Select.Item>
|
|
119
|
+
{/each}
|
|
120
|
+
</Select.Viewport>
|
|
121
|
+
</div>
|
|
122
|
+
</Select.Content>
|
|
123
|
+
</Select.Portal>
|
|
124
|
+
</Select.Root>
|
|
125
|
+
</span>
|
|
126
|
+
|
|
127
|
+
<style>
|
|
128
|
+
/* A primitive renders its OWN element, which a scoped selector cannot reach:
|
|
129
|
+
styled through the wrapper with `:global`. */
|
|
130
|
+
/* The box is `.qm-control-box` (controls.css), carried on the primitive's own
|
|
131
|
+
element the same way `.qm-focus-ring` is; only what a TRIGGER adds over a typed
|
|
132
|
+
value's box is here. */
|
|
133
|
+
.qm-select-wrap :global(.qm-select) {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
justify-content: space-between;
|
|
137
|
+
gap: var(--_qm-space);
|
|
138
|
+
width: 100%;
|
|
139
|
+
box-sizing: border-box;
|
|
140
|
+
text-align: left;
|
|
141
|
+
cursor: pointer;
|
|
142
|
+
}
|
|
143
|
+
/* The focus ring rides `.qm-focus-ring` on the trigger (controls.css). */
|
|
144
|
+
/* Shown-never-written: the closed control reads muted while unset, matching the
|
|
145
|
+
ghosted placeholder the text/number controls show. */
|
|
146
|
+
.qm-select-wrap :global(.qm-select[data-ghosted]) {
|
|
147
|
+
color: var(--_qm-ink-ghost);
|
|
148
|
+
}
|
|
149
|
+
/* The open list is `.qm-menu-surface` and its rows `.qm-menu-item` (controls.css):
|
|
150
|
+
the lift, the inset and the highlight are the shared menu recipe. What a LISTBOX
|
|
151
|
+
adds over a menu is here: it spans its trigger rather than its own content, it
|
|
152
|
+
scrolls past a screenful, and it marks the value already stored. */
|
|
153
|
+
.qm-select-content {
|
|
154
|
+
min-width: var(--bits-select-anchor-width);
|
|
155
|
+
max-height: 16rem;
|
|
156
|
+
overflow-y: auto;
|
|
157
|
+
}
|
|
158
|
+
.qm-select-content :global(.qm-select-item[data-selected]) {
|
|
159
|
+
font-weight: var(--_qm-weight-label);
|
|
160
|
+
}
|
|
161
|
+
/* Consumer policy: offered but unpickable, never hidden. */
|
|
162
|
+
.qm-select-content :global(.qm-select-item[data-disabled]) {
|
|
163
|
+
opacity: var(--_qm-opacity-muted);
|
|
164
|
+
cursor: default;
|
|
165
|
+
}
|
|
166
|
+
.qm-select-ghost {
|
|
167
|
+
color: var(--_qm-ink-ghost);
|
|
168
|
+
}
|
|
169
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import './controls.css';
|
|
2
|
+
interface Props {
|
|
3
|
+
value: string | undefined;
|
|
4
|
+
values: string[];
|
|
5
|
+
fallback?: string;
|
|
6
|
+
/** Accessible name for a trigger NOTHING else names: an object property, whose
|
|
7
|
+
* name is the field label plus the property's. A field's own trigger takes `id`
|
|
8
|
+
* instead and is named by the `<label for>` beside it (the trigger is a
|
|
9
|
+
* `<button>`, so `for` reaches it and a label click opens the list: what the
|
|
10
|
+
* native `<select>` this replaces would have done). */
|
|
11
|
+
label?: string;
|
|
12
|
+
/** `<label for>` target. Set → the label names this trigger, so `aria-label`
|
|
13
|
+
* comes off: two names is where implementations disagree about which wins. */
|
|
14
|
+
id?: string;
|
|
15
|
+
/** The parked `description` (FieldLabel): announced after the name. */
|
|
16
|
+
describedBy?: string;
|
|
17
|
+
onCommit: (v: string | undefined) => void;
|
|
18
|
+
/** Consumer policy: `false` disables an option so it can't be picked.
|
|
19
|
+
* A disallowed value already authored stays SELECTED and visible (disabled), never
|
|
20
|
+
* stripped or mutated. The UNSET sentinel is exempt: clear-to-default always works. */
|
|
21
|
+
optionAllowed?: (value: string) => boolean;
|
|
22
|
+
}
|
|
23
|
+
declare const EnumField: import("svelte").Component<Props, {}, "">;
|
|
24
|
+
type EnumField = ReturnType<typeof EnumField>;
|
|
25
|
+
export default EnumField;
|