@swiftbrowser/web 0.3.1 → 0.5.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/README.md +4 -1
- package/app/index.html +1 -0
- package/package.json +5 -2
- package/src/animation.ts +154 -3
- package/src/dragDrop.ts +549 -0
- package/src/hostContent.ts +37 -0
- package/src/interaction.ts +128 -22
- package/src/largeTitle.ts +98 -54
- package/src/layout/engine.ts +582 -135
- package/src/layout/index.ts +8 -5
- package/src/layout/measure.ts +2 -0
- package/src/layout/text.ts +6 -3
- package/src/layout/types.ts +16 -1
- package/src/layout/typography.ts +30 -10
- package/src/layoutDom.ts +87 -16
- package/src/page.css +234 -0
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +219 -9
- package/src/renderer.ts +355 -15
- package/src/scrollEdge.ts +109 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +457 -251
- package/src/symbols.ts +3 -1
- package/src/typography.ts +5 -2
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a page with a mouse adds to a phone's UI: `hover` events (`.onHover`),
|
|
3
|
+
* tooltips (`.help`), cursors (`.pointerStyle`), and SF Symbols' effects
|
|
4
|
+
* (`.symbolEffect`) drawn with CSS. The Swift shim draws an iPhone and sends
|
|
5
|
+
* none of these; producers in the page (@swiftbrowser/react) do.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ElementId, Style, SymbolEffectStyle } from './protocol';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Hover
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const HOVER_ATTR = 'sbHover';
|
|
15
|
+
|
|
16
|
+
/** Sends `hover` as a mouse enters and leaves the boxes marked `data-sb-hover` under `root`. */
|
|
17
|
+
export class HoverTracker {
|
|
18
|
+
private readonly hovered = new Set<HTMLElement>();
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly root: HTMLElement,
|
|
22
|
+
private readonly send: (id: ElementId, value: boolean) => void,
|
|
23
|
+
) {
|
|
24
|
+
root.addEventListener('pointerover', this.onOver);
|
|
25
|
+
root.addEventListener('pointerout', this.onOut);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
dispose(): void {
|
|
29
|
+
this.root.removeEventListener('pointerover', this.onOver);
|
|
30
|
+
this.root.removeEventListener('pointerout', this.onOut);
|
|
31
|
+
this.hovered.clear();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private readonly onOver = (event: PointerEvent): void => {
|
|
35
|
+
if (event.pointerType === 'touch') return;
|
|
36
|
+
for (let el = event.target as HTMLElement | null; el && el !== this.root; el = el.parentElement) {
|
|
37
|
+
if (el.dataset[HOVER_ATTR] === undefined || this.hovered.has(el)) continue;
|
|
38
|
+
this.hovered.add(el);
|
|
39
|
+
this.report(el, true);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
private readonly onOut = (event: PointerEvent): void => {
|
|
44
|
+
if (event.pointerType === 'touch') return;
|
|
45
|
+
const next = event.relatedTarget as Node | null;
|
|
46
|
+
for (const el of [...this.hovered]) {
|
|
47
|
+
if (next && el.contains(next)) continue;
|
|
48
|
+
this.hovered.delete(el);
|
|
49
|
+
this.report(el, false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
private report(el: HTMLElement, value: boolean): void {
|
|
54
|
+
const id = Number(el.dataset['sbId']);
|
|
55
|
+
if (Number.isInteger(id) && el.isConnected) this.send(id, value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Style fields
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** One run of each effect at speed 1 (seconds) and its keyframes on the CSS `scale`, `rotate`, `translate` and `opacity` properties. */
|
|
64
|
+
const EFFECTS: Record<string, { duration: number; frames: Keyframe[] }> = {
|
|
65
|
+
bounce: {
|
|
66
|
+
duration: 0.5,
|
|
67
|
+
frames: [
|
|
68
|
+
{ scale: '1', translate: '0 0', offset: 0 },
|
|
69
|
+
{ scale: '1.18', translate: '0 -12%', offset: 0.35 },
|
|
70
|
+
{ scale: '0.94', translate: '0 0', offset: 0.7 },
|
|
71
|
+
{ scale: '1', translate: '0 0', offset: 1 },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
pulse: { duration: 1.2, frames: [{ opacity: '1' }, { opacity: '0.35' }, { opacity: '1' }] },
|
|
75
|
+
variableColor: { duration: 1.2, frames: [{ opacity: '1' }, { opacity: '0.5', offset: 0.33 }, { opacity: '0.75', offset: 0.66 }, { opacity: '1' }] },
|
|
76
|
+
rotate: { duration: 1, frames: [{ rotate: '0deg' }, { rotate: '360deg' }] },
|
|
77
|
+
wiggle: {
|
|
78
|
+
duration: 0.6,
|
|
79
|
+
frames: [{ rotate: '0deg' }, { rotate: '-14deg', offset: 0.25 }, { rotate: '14deg', offset: 0.6 }, { rotate: '0deg' }],
|
|
80
|
+
},
|
|
81
|
+
breathe: { duration: 2, frames: [{ scale: '1', opacity: '1' }, { scale: '1.1', opacity: '0.7' }, { scale: '1', opacity: '1' }] },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const playing = new WeakMap<HTMLElement, { key: string; animation: globalThis.Animation }>();
|
|
85
|
+
const triggers = new WeakMap<HTMLElement, number>();
|
|
86
|
+
|
|
87
|
+
/** Apply the pointer and effect fields of a `styled` element's style (called on create and update). */
|
|
88
|
+
export function applyPointerAndEffects(el: HTMLElement, style: Style): void {
|
|
89
|
+
if (style.hover) el.dataset[HOVER_ATTR] = '';
|
|
90
|
+
if (style.help) {
|
|
91
|
+
el.title = style.help;
|
|
92
|
+
el.dataset['sbHelp'] = '';
|
|
93
|
+
} else if (el.hasAttribute('title') && el.classList.contains('sb-styled')) {
|
|
94
|
+
el.removeAttribute('title');
|
|
95
|
+
}
|
|
96
|
+
if (style.pointerStyle) el.style.cursor = style.pointerStyle;
|
|
97
|
+
applySymbolEffect(el, style.symbolEffect);
|
|
98
|
+
if (style.matchedGeometry) el.dataset['sbMatched'] = style.matchedGeometry;
|
|
99
|
+
if (style.contentTransition) {
|
|
100
|
+
el.dataset['sbContentTransition'] = style.contentTransition.kind;
|
|
101
|
+
const { countsDown } = style.contentTransition;
|
|
102
|
+
if (countsDown === undefined) delete el.dataset['sbCountsDown'];
|
|
103
|
+
else el.dataset['sbCountsDown'] = String(countsDown);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function applySymbolEffect(el: HTMLElement, effect: SymbolEffectStyle | undefined): void {
|
|
108
|
+
const s = el.style;
|
|
109
|
+
if (!effect) {
|
|
110
|
+
stopEffect(el);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
el.dataset['sbSymbolEffect'] = effect.effect;
|
|
114
|
+
// State effects: a look kept while the style carries them.
|
|
115
|
+
if (effect.effect === 'scale') {
|
|
116
|
+
s.transition = 'scale 0.25s';
|
|
117
|
+
s.scale = effect.direction === 'down' ? '0.85' : '1.2';
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (effect.effect === 'appear' || effect.effect === 'disappear') {
|
|
121
|
+
s.transition = 'opacity 0.25s, scale 0.25s';
|
|
122
|
+
const shown = effect.effect === 'appear';
|
|
123
|
+
s.opacity = shown ? '1' : '0';
|
|
124
|
+
s.scale = shown ? '1' : '0.6';
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const spec = EFFECTS[effect.effect];
|
|
128
|
+
if (!spec || typeof el.animate !== 'function') return;
|
|
129
|
+
const duration = (spec.duration / Math.max(effect.speed ?? 1, 0.01)) * 1000;
|
|
130
|
+
const repeat = effect.repeat === 'forever' ? Infinity : Math.max(1, effect.repeat ?? 1);
|
|
131
|
+
if (effect.trigger !== undefined) {
|
|
132
|
+
// A discrete effect plays once per new trigger (never for the first value).
|
|
133
|
+
const last = triggers.get(el);
|
|
134
|
+
triggers.set(el, effect.trigger);
|
|
135
|
+
if (last === undefined || last === effect.trigger) return;
|
|
136
|
+
playing.get(el)?.animation.cancel();
|
|
137
|
+
playing.set(el, { key: `once:${effect.trigger}`, animation: el.animate(spec.frames, { duration, iterations: repeat, easing: 'ease-in-out' }) });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// An indefinite effect keeps running across updates that leave it alone.
|
|
141
|
+
const key = `${effect.effect}:${duration}:${repeat === Infinity ? 'forever' : repeat}`;
|
|
142
|
+
const current = playing.get(el);
|
|
143
|
+
if (current && current.key === key && current.animation.playState !== 'finished') return;
|
|
144
|
+
current?.animation.cancel();
|
|
145
|
+
const iterations = effect.repeat === undefined ? Infinity : repeat;
|
|
146
|
+
playing.set(el, { key, animation: el.animate(spec.frames, { duration, iterations, easing: 'ease-in-out' }) });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function stopEffect(el: HTMLElement): void {
|
|
150
|
+
const current = playing.get(el);
|
|
151
|
+
if (current && !current.key.startsWith('once:')) {
|
|
152
|
+
current.animation.cancel();
|
|
153
|
+
playing.delete(el);
|
|
154
|
+
}
|
|
155
|
+
triggers.delete(el);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Geometry readers that report their frame
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export interface SafeAreaInsets {
|
|
163
|
+
top: number;
|
|
164
|
+
leading: number;
|
|
165
|
+
bottom: number;
|
|
166
|
+
trailing: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** How far the device's safe area reaches into a box at `rect` in a root of `size`. */
|
|
170
|
+
export function safeAreaOverlap(
|
|
171
|
+
rect: { x: number; y: number; width: number; height: number },
|
|
172
|
+
size: { width: number; height: number },
|
|
173
|
+
safeArea: SafeAreaInsets,
|
|
174
|
+
): SafeAreaInsets {
|
|
175
|
+
const clamp = (value: number, max: number): number => Math.round(Math.min(Math.max(value, 0), max));
|
|
176
|
+
return {
|
|
177
|
+
top: clamp(safeArea.top - rect.y, rect.height),
|
|
178
|
+
bottom: clamp(rect.y + rect.height - (size.height - safeArea.bottom), rect.height),
|
|
179
|
+
leading: clamp(safeArea.leading - rect.x, rect.width),
|
|
180
|
+
trailing: clamp(rect.x + rect.width - (size.width - safeArea.trailing), rect.width),
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -719,7 +719,15 @@ export interface PickerProps {
|
|
|
719
719
|
* `GeometryReader`: fills its proposal and reports its size through a
|
|
720
720
|
* `geometry` event whenever it changes. Exactly one child.
|
|
721
721
|
*/
|
|
722
|
-
export
|
|
722
|
+
export interface GeometryProps {
|
|
723
|
+
/**
|
|
724
|
+
* Report the reader's frame on screen too (`x`, `y` and `safeArea` in its
|
|
725
|
+
* `geometry` events, kept current as its ancestors scroll), for
|
|
726
|
+
* `GeometryProxy.frame(in: .global)` and `safeAreaInsets`. Absent: the size
|
|
727
|
+
* only, as before.
|
|
728
|
+
*/
|
|
729
|
+
reportsFrame?: boolean;
|
|
730
|
+
}
|
|
723
731
|
|
|
724
732
|
// Phase 6 kinds (docs/phase6/layout-containers.md).
|
|
725
733
|
|
|
@@ -795,6 +803,16 @@ export interface LazyVGridProps {
|
|
|
795
803
|
spacing: number | null;
|
|
796
804
|
alignment: FrameAlignment;
|
|
797
805
|
}
|
|
806
|
+
/**
|
|
807
|
+
* `LazyHGrid`: `LazyVGrid` turned on its side. The `rows` share the height
|
|
808
|
+
* the grid is proposed; the cells fill them column by column, `spacing`
|
|
809
|
+
* (default 8) apart. Children are the cells (flattened). Nothing is lazy.
|
|
810
|
+
*/
|
|
811
|
+
export interface LazyHGridProps {
|
|
812
|
+
rows: GridItem[];
|
|
813
|
+
spacing: number | null;
|
|
814
|
+
alignment: FrameAlignment;
|
|
815
|
+
}
|
|
798
816
|
export type GaugeStyle =
|
|
799
817
|
| 'automatic'
|
|
800
818
|
| 'linearCapacity'
|
|
@@ -851,6 +869,12 @@ export type ToolbarPlacement = 'trailing' | 'leading' | 'principal' | 'bottom';
|
|
|
851
869
|
*/
|
|
852
870
|
export interface ToolbarProps {
|
|
853
871
|
placement: ToolbarPlacement;
|
|
872
|
+
/**
|
|
873
|
+
* A `ToolbarSpacer` (no children): it separates glass groups, and in a
|
|
874
|
+
* bottom bar flexible ones share the room left over and push the groups
|
|
875
|
+
* apart; fixed ones are 8 wide.
|
|
876
|
+
*/
|
|
877
|
+
spacer?: 'flexible' | 'fixed';
|
|
854
878
|
}
|
|
855
879
|
/**
|
|
856
880
|
* `.searchable(text:)`, an extra child of a `navscreen`: a 36pt rounded
|
|
@@ -870,6 +894,19 @@ export interface TapGestureProps {
|
|
|
870
894
|
location?: boolean;
|
|
871
895
|
}
|
|
872
896
|
|
|
897
|
+
/**
|
|
898
|
+
* A box the page fills with its own DOM (src/hostContent.ts): `content` is
|
|
899
|
+
* the key the page registered its element under. `sizing: "fill"` takes the
|
|
900
|
+
* proposal, the content's own size on an unconstrained axis; `"content"`
|
|
901
|
+
* takes the content's size, its width capped by the proposal and its height
|
|
902
|
+
* measured at that width, like text. Only page-side producers (such as
|
|
903
|
+
* @swiftbrowser/react) emit it; the Swift core never does.
|
|
904
|
+
*/
|
|
905
|
+
export interface HostProps {
|
|
906
|
+
content: string;
|
|
907
|
+
sizing: 'fill' | 'content';
|
|
908
|
+
}
|
|
909
|
+
|
|
873
910
|
export interface ElementPropsByKind {
|
|
874
911
|
text: TextProps;
|
|
875
912
|
vstack: VStackProps;
|
|
@@ -899,12 +936,14 @@ export interface ElementPropsByKind {
|
|
|
899
936
|
grid: GridProps;
|
|
900
937
|
gridrow: GridRowProps;
|
|
901
938
|
lazyvgrid: LazyVGridProps;
|
|
939
|
+
lazyhgrid: LazyHGridProps;
|
|
902
940
|
gauge: GaugeProps;
|
|
903
941
|
decorated: DecoratedProps;
|
|
904
942
|
menu: MenuProps;
|
|
905
943
|
toolbar: ToolbarProps;
|
|
906
944
|
searchfield: SearchFieldProps;
|
|
907
945
|
tapgesture: TapGestureProps;
|
|
946
|
+
host: HostProps;
|
|
908
947
|
}
|
|
909
948
|
|
|
910
949
|
export type ElementKind = keyof ElementPropsByKind;
|
|
@@ -1289,6 +1328,16 @@ export interface GeometryEvent {
|
|
|
1289
1328
|
id: ElementId;
|
|
1290
1329
|
width: number;
|
|
1291
1330
|
height: number;
|
|
1331
|
+
/** With `reportsFrame`: the box's origin in the root's coordinates, scrolling included (whole px). */
|
|
1332
|
+
x?: number;
|
|
1333
|
+
y?: number;
|
|
1334
|
+
/** With `reportsFrame`: how far the device's safe area reaches into the box on each edge. */
|
|
1335
|
+
safeArea?: { top: number; leading: number; bottom: number; trailing: number };
|
|
1336
|
+
/**
|
|
1337
|
+
* With `reportsFrame`, inside a scroll view or list: its visible rectangle
|
|
1338
|
+
* in the root's coordinates (whole px) and the axis it scrolls along.
|
|
1339
|
+
*/
|
|
1340
|
+
scrollView?: { x: number; y: number; width: number; height: number; axis: 'horizontal' | 'vertical' };
|
|
1292
1341
|
}
|
|
1293
1342
|
|
|
1294
1343
|
/** Host measurements of one subview of a `layout` element (points, 2 decimals; `"infinity"` for an unbounded axis). */
|
|
@@ -1333,7 +1382,8 @@ export type RendererEvent =
|
|
|
1333
1382
|
// A `video` element's playback (src/video.ts)
|
|
1334
1383
|
| VideoEvent
|
|
1335
1384
|
| LazyEvent
|
|
1336
|
-
| MenuEvent
|
|
1385
|
+
| MenuEvent
|
|
1386
|
+
| HoverEvent;
|
|
1337
1387
|
|
|
1338
1388
|
/**
|
|
1339
1389
|
* Phase 13: the page's answer to the `request` op with this `id`: `value`
|
|
@@ -1451,7 +1501,10 @@ export function isOp(value: unknown): value is Op {
|
|
|
1451
1501
|
// ---------------------------------------------------------------------------
|
|
1452
1502
|
|
|
1453
1503
|
/** `NavigationSplitView` in the regular size class: children are `[sidebar, detail]`, each a `navstack`. */
|
|
1454
|
-
export
|
|
1504
|
+
export interface NavSplitProps {
|
|
1505
|
+
/** iPadOS: false hides the sidebar (`NavigationSplitViewVisibility.detailOnly`); absent or true shows it. */
|
|
1506
|
+
sidebarVisible?: boolean;
|
|
1507
|
+
}
|
|
1455
1508
|
|
|
1456
1509
|
export type ViewThatFitsAxes = 'horizontal' | 'vertical' | 'both';
|
|
1457
1510
|
/**
|
|
@@ -1466,6 +1519,17 @@ export interface ViewThatFitsProps {
|
|
|
1466
1519
|
export interface ElementPropsByKind {
|
|
1467
1520
|
navsplit: NavSplitProps;
|
|
1468
1521
|
viewthatfits: ViewThatFitsProps;
|
|
1522
|
+
inspector: InspectorProps;
|
|
1523
|
+
inspectorpanel: Record<string, never>;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* `.inspector(isPresented:)` in the regular size class: the children are the
|
|
1528
|
+
* content, then an `inspectorpanel` (the trailing column's content).
|
|
1529
|
+
*/
|
|
1530
|
+
export interface InspectorProps {
|
|
1531
|
+
/** Whether the panel is shown; false slides it off the trailing edge. */
|
|
1532
|
+
presented: boolean;
|
|
1469
1533
|
}
|
|
1470
1534
|
|
|
1471
1535
|
export interface Style {
|
|
@@ -1688,14 +1752,40 @@ export interface ContextMenuProps {
|
|
|
1688
1752
|
}
|
|
1689
1753
|
|
|
1690
1754
|
/**
|
|
1691
|
-
* A `List` row that can be selected (`selectable`, `selected`)
|
|
1692
|
-
* (`deletable`, from `ForEach.onDelete`)
|
|
1693
|
-
* `
|
|
1755
|
+
* A `List` row that can be selected (`selectable`, `selected`), deleted
|
|
1756
|
+
* (`deletable`, from `ForEach.onDelete`) or moved (`movable`, from
|
|
1757
|
+
* `ForEach.onMove`; absent means false). A tap on a selectable row sends
|
|
1758
|
+
* `tap` with the row id; swipe-to-delete or the edit-mode minus sends
|
|
1759
|
+
* `delete`. A movable row shows a grip at its trailing edge while the list
|
|
1760
|
+
* is editing, and a long press lifts it otherwise; dragging it among its
|
|
1761
|
+
* neighbours and letting go sends `move` (src/dragDrop.ts).
|
|
1694
1762
|
*/
|
|
1695
1763
|
export interface ListRowProps {
|
|
1696
1764
|
selectable?: boolean;
|
|
1697
1765
|
selected?: boolean;
|
|
1698
1766
|
deletable?: boolean;
|
|
1767
|
+
movable?: boolean;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* `.draggable` / `.onDrag`: exactly one child, the child's size. A long
|
|
1772
|
+
* press (touch) or a 10 px drag (mouse) lifts a copy of the content, which
|
|
1773
|
+
* follows the pointer; `dragstart` goes out as it lifts and `dragend` once
|
|
1774
|
+
* it is dropped or let go (src/dragDrop.ts).
|
|
1775
|
+
*/
|
|
1776
|
+
export type DraggableProps = Record<string, never>;
|
|
1777
|
+
|
|
1778
|
+
/**
|
|
1779
|
+
* `.dropDestination` / `.onDrop`: exactly one child, the child's size. A
|
|
1780
|
+
* lifted `draggable` over it sends `drop` with the phase `entered` /
|
|
1781
|
+
* `exited`, and `performed` with the source when released over it. Text,
|
|
1782
|
+
* links and files dragged in from outside the page (the browser's own drag
|
|
1783
|
+
* and drop) are offered when their MIME type matches one of `accepts`
|
|
1784
|
+
* (`text/plain`, `image/*`, or a star over a star for anything); they
|
|
1785
|
+
* arrive as `items`.
|
|
1786
|
+
*/
|
|
1787
|
+
export interface DropDestinationProps {
|
|
1788
|
+
accepts?: string[];
|
|
1699
1789
|
}
|
|
1700
1790
|
|
|
1701
1791
|
export interface ElementPropsByKind {
|
|
@@ -1703,12 +1793,14 @@ export interface ElementPropsByKind {
|
|
|
1703
1793
|
swipeactions: SwipeActionsProps;
|
|
1704
1794
|
contextmenu: ContextMenuProps;
|
|
1705
1795
|
listrow: ListRowProps;
|
|
1796
|
+
draggable: DraggableProps;
|
|
1797
|
+
dropdestination: DropDestinationProps;
|
|
1706
1798
|
}
|
|
1707
1799
|
|
|
1708
|
-
export const INTERACTION_KINDS: readonly ElementKind[] = ['gesture', 'swipeactions', 'contextmenu', 'listrow'];
|
|
1800
|
+
export const INTERACTION_KINDS: readonly ElementKind[] = ['gesture', 'swipeactions', 'contextmenu', 'listrow', 'draggable', 'dropdestination'];
|
|
1709
1801
|
|
|
1710
1802
|
export interface ListProps {
|
|
1711
|
-
/** `EditButton` / `editMode`:
|
|
1803
|
+
/** `EditButton` / `editMode`: a selectable or deletable `listrow` gains a 44 pt leading control and its content shifts right 40; a movable one a trailing grip. */
|
|
1712
1804
|
editing?: boolean;
|
|
1713
1805
|
/** `.refreshable`: a pull past 60 pt at the top sends `refresh`. */
|
|
1714
1806
|
refreshable?: boolean;
|
|
@@ -1772,9 +1864,52 @@ export interface RefreshEvent {
|
|
|
1772
1864
|
type: 'refresh';
|
|
1773
1865
|
id: ElementId;
|
|
1774
1866
|
}
|
|
1867
|
+
/**
|
|
1868
|
+
* A movable `listrow` was dragged to a new place: `to` is the index among
|
|
1869
|
+
* its container's children (the `list`'s, or its `section`'s) before which
|
|
1870
|
+
* it goes, counted before it leaves (0 ... the child count), as
|
|
1871
|
+
* `move(fromOffsets:toOffset:)` takes its destination.
|
|
1872
|
+
*/
|
|
1873
|
+
export interface MoveEvent {
|
|
1874
|
+
type: 'move';
|
|
1875
|
+
id: ElementId;
|
|
1876
|
+
to: number;
|
|
1877
|
+
}
|
|
1878
|
+
/** A `draggable` was lifted: its payload is made now and kept until `dragend`. */
|
|
1879
|
+
export interface DragStartEvent {
|
|
1880
|
+
type: 'dragstart';
|
|
1881
|
+
id: ElementId;
|
|
1882
|
+
}
|
|
1883
|
+
/** The lifted `draggable` was dropped or let go. */
|
|
1884
|
+
export interface DragEndEvent {
|
|
1885
|
+
type: 'dragend';
|
|
1886
|
+
id: ElementId;
|
|
1887
|
+
}
|
|
1888
|
+
/** One thing dragged in from outside the page: text (or a link, `text/uri-list`), or a file's bytes in base64. */
|
|
1889
|
+
export interface DropItem {
|
|
1890
|
+
type: string;
|
|
1891
|
+
text?: string;
|
|
1892
|
+
name?: string;
|
|
1893
|
+
data?: string;
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* A drag over a `dropdestination`: it `entered`, `exited`, or `performed`
|
|
1897
|
+
* (was released over it). `source` is the lifted `draggable`'s id for a drag
|
|
1898
|
+
* that started in the app; `items` what came from outside the page. The
|
|
1899
|
+
* point is in the destination's own coordinates (0, 0 while entering and leaving).
|
|
1900
|
+
*/
|
|
1901
|
+
export interface DropEvent {
|
|
1902
|
+
type: 'drop';
|
|
1903
|
+
id: ElementId;
|
|
1904
|
+
phase: 'entered' | 'exited' | 'performed';
|
|
1905
|
+
source?: ElementId;
|
|
1906
|
+
items?: DropItem[];
|
|
1907
|
+
x: number;
|
|
1908
|
+
y: number;
|
|
1909
|
+
}
|
|
1775
1910
|
|
|
1776
1911
|
/** The Phase 8 events (merged into `RendererEvent` above). */
|
|
1777
|
-
export type InteractionEvent = DragEvent | LongPressEvent | DeleteEvent | RefreshEvent;
|
|
1912
|
+
export type InteractionEvent = DragEvent | LongPressEvent | DeleteEvent | RefreshEvent | MoveEvent | DragStartEvent | DragEndEvent | DropEvent;
|
|
1778
1913
|
// Phase 8: alerts, controls, effects
|
|
1779
1914
|
// (docs/ops-protocol.md, "Phase 8: interaction"; gestures, swipe actions,
|
|
1780
1915
|
// context menus, list rows and refresh live in their own block)
|
|
@@ -1970,11 +2105,24 @@ export interface ListProps {
|
|
|
1970
2105
|
background?: 'hidden';
|
|
1971
2106
|
/** `.scrollDismissesKeyboard(.immediately | .interactively)`: scrolling blurs the text input being edited inside. */
|
|
1972
2107
|
dismissesKeyboard?: boolean;
|
|
2108
|
+
/**
|
|
2109
|
+
* `.scrollEdgeEffectStyle(_:for:)` / `.scrollEdgeEffectHidden(_:for:)`: the
|
|
2110
|
+
* scroll edge effect where the list runs under its screen's bar (`top`) or
|
|
2111
|
+
* a bar covering the bottom (`bottom`); an absent edge is automatic (soft).
|
|
2112
|
+
*/
|
|
2113
|
+
edgeEffect?: EdgeEffectProps;
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
export interface EdgeEffectProps {
|
|
2117
|
+
top?: 'hard' | 'soft' | 'hidden';
|
|
2118
|
+
bottom?: 'hard' | 'soft' | 'hidden';
|
|
1973
2119
|
}
|
|
1974
2120
|
|
|
1975
2121
|
export interface ScrollViewProps {
|
|
1976
2122
|
/** As for `list`. */
|
|
1977
2123
|
dismissesKeyboard?: boolean;
|
|
2124
|
+
/** As for `list`. */
|
|
2125
|
+
edgeEffect?: EdgeEffectProps;
|
|
1978
2126
|
}
|
|
1979
2127
|
|
|
1980
2128
|
/**
|
|
@@ -1992,6 +2140,68 @@ export interface ElementPropsByKind {
|
|
|
1992
2140
|
}
|
|
1993
2141
|
|
|
1994
2142
|
/** `ScrollViewProxy.scrollTo(_:anchor:)`: where the target should end up (null: as little scrolling as shows it). */
|
|
2143
|
+
/**
|
|
2144
|
+
* SF Symbols' effects (`.symbolEffect`), drawn with CSS on the styled box:
|
|
2145
|
+
* `bounce`, `pulse`, `variableColor` (as a pulse), `scale` (up while
|
|
2146
|
+
* active; `down` shrinks), `rotate`, `wiggle`, `breathe`, `appear` /
|
|
2147
|
+
* `disappear` (shown / hidden while active). Without `trigger` the effect
|
|
2148
|
+
* runs while the style carries it (`repeat` times, or forever); with one, it
|
|
2149
|
+
* plays once (`repeat` times) whenever `trigger` changes, like
|
|
2150
|
+
* `.symbolEffect(_:value:)`.
|
|
2151
|
+
*/
|
|
2152
|
+
export interface SymbolEffectStyle {
|
|
2153
|
+
effect: 'bounce' | 'pulse' | 'variableColor' | 'scale' | 'rotate' | 'wiggle' | 'breathe' | 'appear' | 'disappear';
|
|
2154
|
+
direction?: 'up' | 'down';
|
|
2155
|
+
/** Seconds per run at speed 1 is the effect's own; `speed` divides it. */
|
|
2156
|
+
speed?: number;
|
|
2157
|
+
repeat?: number | 'forever';
|
|
2158
|
+
trigger?: number;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
export interface Style {
|
|
2162
|
+
/** The page sends `hover` events as a mouse enters and leaves the box (`.onHover`). */
|
|
2163
|
+
hover?: boolean;
|
|
2164
|
+
/** `.help(_:)`: a tooltip on the box. */
|
|
2165
|
+
help?: string;
|
|
2166
|
+
/** `.pointerStyle(_:)`: the CSS cursor over the box (`pointer`, `text`, `grab`…). */
|
|
2167
|
+
pointerStyle?: string;
|
|
2168
|
+
symbolEffect?: SymbolEffectStyle;
|
|
2169
|
+
/**
|
|
2170
|
+
* `.matchedGeometryEffect(id:in:)`: when an animated pass removes a box
|
|
2171
|
+
* with this id and inserts another with it, the inserted one starts from
|
|
2172
|
+
* the removed one's frame and the removed one's exit travels to the new
|
|
2173
|
+
* frame. The namespace is part of the id.
|
|
2174
|
+
*/
|
|
2175
|
+
matchedGeometry?: string;
|
|
2176
|
+
/**
|
|
2177
|
+
* `.contentTransition(_:)`: how a text under the box changes in a pass that
|
|
2178
|
+
* animates. `numericText` rolls the old text out upward and the new one in
|
|
2179
|
+
* from below, or the other way when the new text reads as a smaller number
|
|
2180
|
+
* (`countsDown` fixes the direction); `opacity` and `interpolate` crossfade
|
|
2181
|
+
* them.
|
|
2182
|
+
*/
|
|
2183
|
+
contentTransition?: { kind: 'numericText' | 'opacity' | 'interpolate'; countsDown?: boolean };
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
export interface Style {
|
|
2187
|
+
/**
|
|
2188
|
+
* `.containerRelativeFrame(_:…)`: on the marked axes the box is
|
|
2189
|
+
* `a × container + b` long, the container being the nearest scroll view's
|
|
2190
|
+
* visible area (or the screen), and its content is aligned in that frame.
|
|
2191
|
+
*/
|
|
2192
|
+
containerRelativeFrame?: { horizontal?: boolean; vertical?: boolean; a: number; b: number; alignment: FrameAlignment };
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* A mouse (or pen) entered (`value: true`) or left a box whose style has
|
|
2197
|
+
* `hover`. Touch never hovers.
|
|
2198
|
+
*/
|
|
2199
|
+
export interface HoverEvent {
|
|
2200
|
+
type: 'hover';
|
|
2201
|
+
id: ElementId;
|
|
2202
|
+
value: boolean;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
1995
2205
|
export interface ScrollToCommand {
|
|
1996
2206
|
anchor: UnitPoint | null;
|
|
1997
2207
|
/** Increases with every request (across readers); the page carries out each serial once. */
|