@gadxr/ui 0.0.0-stage → 0.1.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.
Files changed (114) hide show
  1. package/LICENSE +21 -0
  2. package/dist/Avatar.svelte +136 -0
  3. package/dist/Avatar.svelte.d.ts +13 -0
  4. package/dist/Button.svelte +29 -0
  5. package/dist/Button.svelte.d.ts +4 -0
  6. package/dist/Button2D.svelte +77 -0
  7. package/dist/Button2D.svelte.d.ts +4 -0
  8. package/dist/Button3D.svelte +98 -0
  9. package/dist/Button3D.svelte.d.ts +4 -0
  10. package/dist/ButtonGroup.svelte +59 -0
  11. package/dist/ButtonGroup.svelte.d.ts +17 -0
  12. package/dist/Checkbox.svelte +209 -0
  13. package/dist/Checkbox.svelte.d.ts +14 -0
  14. package/dist/DimensionScope.svelte +7 -0
  15. package/dist/DimensionScope.svelte.d.ts +9 -0
  16. package/dist/Input.svelte +293 -0
  17. package/dist/Input.svelte.d.ts +23 -0
  18. package/dist/Key3D.svelte +67 -0
  19. package/dist/Key3D.svelte.d.ts +13 -0
  20. package/dist/Keyboard.svelte +167 -0
  21. package/dist/Keyboard.svelte.d.ts +9 -0
  22. package/dist/Menu.svelte +193 -0
  23. package/dist/Menu.svelte.d.ts +23 -0
  24. package/dist/Modal.svelte +103 -0
  25. package/dist/Modal.svelte.d.ts +16 -0
  26. package/dist/Panel.svelte +132 -0
  27. package/dist/Panel.svelte.d.ts +19 -0
  28. package/dist/Pressable3D.svelte +82 -0
  29. package/dist/Pressable3D.svelte.d.ts +28 -0
  30. package/dist/ProgressBar.svelte +153 -0
  31. package/dist/ProgressBar.svelte.d.ts +16 -0
  32. package/dist/RadialMenu.svelte +322 -0
  33. package/dist/RadialMenu.svelte.d.ts +28 -0
  34. package/dist/Rule3D.svelte +17 -0
  35. package/dist/Rule3D.svelte.d.ts +9 -0
  36. package/dist/Scene3D.svelte +24 -0
  37. package/dist/Scene3D.svelte.d.ts +11 -0
  38. package/dist/Slider.svelte +209 -0
  39. package/dist/Slider.svelte.d.ts +17 -0
  40. package/dist/Space3D.svelte +6 -0
  41. package/dist/Space3D.svelte.d.ts +7 -0
  42. package/dist/Spinner.svelte +64 -0
  43. package/dist/Spinner.svelte.d.ts +10 -0
  44. package/dist/Stack.svelte +41 -0
  45. package/dist/Stack.svelte.d.ts +15 -0
  46. package/dist/StackScope.svelte +7 -0
  47. package/dist/StackScope.svelte.d.ts +9 -0
  48. package/dist/Surface.svelte +152 -0
  49. package/dist/Surface.svelte.d.ts +30 -0
  50. package/dist/Tab.svelte +40 -0
  51. package/dist/Tab.svelte.d.ts +10 -0
  52. package/dist/Tabs.svelte +145 -0
  53. package/dist/Tabs.svelte.d.ts +13 -0
  54. package/dist/Tag.svelte +71 -0
  55. package/dist/Tag.svelte.d.ts +12 -0
  56. package/dist/Text.svelte +75 -0
  57. package/dist/Text.svelte.d.ts +4 -0
  58. package/dist/TextLine3D.svelte +84 -0
  59. package/dist/TextLine3D.svelte.d.ts +19 -0
  60. package/dist/ThemeProvider.svelte +7 -0
  61. package/dist/ThemeProvider.svelte.d.ts +9 -0
  62. package/dist/Toaster.svelte +104 -0
  63. package/dist/Toaster.svelte.d.ts +11 -0
  64. package/dist/Tooltip.svelte +174 -0
  65. package/dist/Tooltip.svelte.d.ts +14 -0
  66. package/dist/aframe/frame.d.ts +1 -0
  67. package/dist/aframe/frame.js +37 -0
  68. package/dist/aframe/index.d.ts +1 -0
  69. package/dist/aframe/index.js +19 -0
  70. package/dist/aframe/measure.d.ts +3 -0
  71. package/dist/aframe/measure.js +42 -0
  72. package/dist/aframe/placement.d.ts +88 -0
  73. package/dist/aframe/placement.js +163 -0
  74. package/dist/aframe/rounded.d.ts +61 -0
  75. package/dist/aframe/rounded.js +121 -0
  76. package/dist/aframe/scene.d.ts +9 -0
  77. package/dist/aframe/scene.js +101 -0
  78. package/dist/assets/Geist-OFL.txt +93 -0
  79. package/dist/assets/Geist-Regular.ttf +0 -0
  80. package/dist/button-style.d.ts +22 -0
  81. package/dist/button-style.js +34 -0
  82. package/dist/component-style.d.ts +79 -0
  83. package/dist/component-style.js +129 -0
  84. package/dist/context.d.ts +16 -0
  85. package/dist/context.js +26 -0
  86. package/dist/fonts.css +7 -0
  87. package/dist/fonts.d.ts +2 -0
  88. package/dist/fonts.js +7 -0
  89. package/dist/index.d.ts +40 -0
  90. package/dist/index.js +40 -0
  91. package/dist/keyboard-layout.d.ts +28 -0
  92. package/dist/keyboard-layout.js +77 -0
  93. package/dist/layout-math.d.ts +20 -0
  94. package/dist/layout-math.js +22 -0
  95. package/dist/layout.svelte.d.ts +17 -0
  96. package/dist/layout.svelte.js +58 -0
  97. package/dist/radial-math.d.ts +33 -0
  98. package/dist/radial-math.js +90 -0
  99. package/dist/tabs-context.d.ts +13 -0
  100. package/dist/tabs-context.js +6 -0
  101. package/dist/text-edit.d.ts +46 -0
  102. package/dist/text-edit.js +133 -0
  103. package/dist/text-focus.svelte.d.ts +24 -0
  104. package/dist/text-focus.svelte.js +102 -0
  105. package/dist/theme.d.ts +335 -0
  106. package/dist/theme.js +312 -0
  107. package/dist/toast.svelte.d.ts +24 -0
  108. package/dist/toast.svelte.js +30 -0
  109. package/dist/types.d.ts +25 -0
  110. package/dist/types.js +1 -0
  111. package/dist/xr.svelte.d.ts +9 -0
  112. package/dist/xr.svelte.js +24 -0
  113. package/package.json +44 -3
  114. package/README.md +0 -3
@@ -0,0 +1,14 @@
1
+ import { type CheckboxType } from './component-style';
2
+ import type { Dimension } from './theme';
3
+ type Props = {
4
+ checked?: boolean;
5
+ label?: string;
6
+ type?: CheckboxType;
7
+ disabled?: boolean;
8
+ onchange?: (checked: boolean) => void;
9
+ dimension?: Dimension;
10
+ [attribute: string]: unknown;
11
+ };
12
+ declare const Checkbox: import("svelte").Component<Props, {}, "checked">;
13
+ type Checkbox = ReturnType<typeof Checkbox>;
14
+ export default Checkbox;
@@ -0,0 +1,7 @@
1
+ <script lang="ts">import { setDimension } from "./context";
2
+ let { dimension, children } = $props();
3
+ // svelte-ignore state_referenced_locally
4
+ setDimension(dimension);
5
+ </script>
6
+
7
+ {@render children()}
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { Dimension } from './theme';
3
+ type $$ComponentProps = {
4
+ dimension: Dimension;
5
+ children: Snippet;
6
+ };
7
+ declare const DimensionScope: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type DimensionScope = ReturnType<typeof DimensionScope>;
9
+ export default DimensionScope;
@@ -0,0 +1,293 @@
1
+ <script lang="ts">// Base UI-style single-line text Input: a label, the field, and a caption (or an
2
+ // error, which replaces it and turns the field red).
3
+ // 2D: a native <input>. 3D: a field with a blinking caret; clicking focuses it and
4
+ // places the caret. The physical keyboard types into a focused 3D field (desktop, or a
5
+ // keyboard paired to the headset); in VR a <Keyboard> placed in the scene opens.
6
+ // Enter submits (`onsubmit`; `blurOnSubmit={false}` keeps editing, e.g. chat, and
7
+ // `onsubmit` returning false keeps editing that once, e.g. an invalid value);
8
+ // Escape / the keyboard's close key restores the value from before editing (`oncancel`).
9
+ import { untrack } from "svelte";
10
+ import { ensure3d } from "./aframe";
11
+ import { setTroikaValue } from "./aframe/measure";
12
+ import { resolveInputStyle } from "./component-style";
13
+ import { getDimension, getTheme } from "./context";
14
+ import { font3d } from "./fonts";
15
+ import { useSlot } from "./layout.svelte";
16
+ import { clampCaret, edit } from "./text-edit";
17
+ import { textFocus } from "./text-focus.svelte";
18
+ import TextLine3D from "./TextLine3D.svelte";
19
+ import { xr } from "./xr.svelte";
20
+ const theme = getTheme();
21
+ let { value = $bindable(""), label = "", placeholder = "", caption = "", error = "", disabled = false, width = theme().input.width, maxLength, inputmode = "text", enterkeyhint = "enter", blurOnSubmit = true, focused = $bindable(false), onsubmit, oncancel, dimension, style: userStyle = "", ...rest } = $props();
22
+ const t = $derived(theme());
23
+ const contextDimension = getDimension();
24
+ // svelte-ignore state_referenced_locally
25
+ const is3d = (dimension ?? contextDimension) === "3d";
26
+ // The value when editing started, for cancel.
27
+ let original = "";
28
+ let hovered = $state(false);
29
+ const message = $derived(error || caption);
30
+ // ---- 3D ----
31
+ const m = (px) => px * t.space3d.metersPerPx;
32
+ if (is3d) ensure3d();
33
+ let rootEl = $state();
34
+ let caret = $state(0);
35
+ // The value can change from outside while editing (e.g. cleared after a chat send).
36
+ const caretPos = $derived(clampCaret(value, caret));
37
+ const editable = {
38
+ get text() {
39
+ return value;
40
+ },
41
+ get caret() {
42
+ return caretPos;
43
+ },
44
+ get placeholder() {
45
+ return placeholder;
46
+ },
47
+ get inputmode() {
48
+ return inputmode;
49
+ },
50
+ get enterKeyHint() {
51
+ return enterkeyhint;
52
+ },
53
+ get el() {
54
+ return rootEl;
55
+ },
56
+ edit(action) {
57
+ const result = edit({
58
+ text: value,
59
+ caret: caretPos
60
+ }, action, { maxLength });
61
+ if (!result) return;
62
+ value = result.state.text;
63
+ caret = result.state.caret;
64
+ },
65
+ setCaret(index) {
66
+ caret = clampCaret(value, index);
67
+ },
68
+ enter() {
69
+ if (onsubmit?.(value) !== false && blurOnSubmit) stop();
70
+ },
71
+ done: () => stop(),
72
+ cancel() {
73
+ value = original;
74
+ caret = value.length;
75
+ oncancel?.();
76
+ stop();
77
+ }
78
+ };
79
+ const hasFocus = $derived(is3d ? textFocus.current === editable : focused);
80
+ const s = $derived(resolveInputStyle(t, {
81
+ focused: hasFocus,
82
+ hovered,
83
+ error: !!error,
84
+ disabled
85
+ }));
86
+ let warned = false;
87
+ function start() {
88
+ if (disabled || textFocus.current === editable) return;
89
+ original = value;
90
+ caret = value.length;
91
+ textFocus.focus(editable);
92
+ if (xr.immersive && !textFocus.keyboards.size && !warned) {
93
+ warned = true;
94
+ console.warn("gadxr ui: a 3D Input has focus in VR, but no <Keyboard /> is in the scene to type with.");
95
+ }
96
+ }
97
+ function stop() {
98
+ textFocus.blur(editable);
99
+ }
100
+ if (is3d) {
101
+ // `focused` from outside → focus / blur; focus changing here → `focused`.
102
+ $effect(() => {
103
+ if (focused !== untrack(() => hasFocus)) untrack(() => focused ? start() : stop());
104
+ });
105
+ $effect(() => {
106
+ const f = hasFocus;
107
+ untrack(() => {
108
+ if (focused !== f) focused = f;
109
+ });
110
+ });
111
+ $effect(() => {
112
+ if (disabled) untrack(stop);
113
+ });
114
+ $effect(() => () => stop());
115
+ }
116
+ const W = $derived(m(width));
117
+ const textScale = $derived(t.space3d.textScale);
118
+ const labelH = $derived(label ? m(s.labelFont.lineHeight) * textScale : 0);
119
+ const fieldH = $derived(m(s.height));
120
+ const messageH = $derived(message ? m(s.captionFont.lineHeight) * textScale : 0);
121
+ const gap = $derived(m(t.input.gap));
122
+ const size = $derived({
123
+ width: W,
124
+ height: labelH + (label ? gap : 0) + fieldH + (message ? gap : 0) + messageH
125
+ });
126
+ const slot = is3d ? useSlot(() => size) : () => ({
127
+ x: 0,
128
+ y: 0
129
+ });
130
+ const labelY = $derived(size.height / 2 - labelH / 2);
131
+ const fieldY = $derived(size.height / 2 - labelH - (label ? gap : 0) - fieldH / 2);
132
+ const messageY = $derived(-size.height / 2 + messageH / 2);
133
+ const depth = .02;
134
+ const border = $derived(m(s.border));
135
+ const padX = $derived(m(s.paddingX));
136
+ let labelEl = $state();
137
+ let messageEl = $state();
138
+ $effect(() => setTroikaValue(labelEl, label));
139
+ $effect(() => setTroikaValue(messageEl, message));
140
+ function pick(index) {
141
+ start();
142
+ editable.setCaret(index);
143
+ }
144
+ // ---- 2D ----
145
+ let inputEl = $state();
146
+ $effect(() => {
147
+ if (is3d || !inputEl) return;
148
+ if (focused && document.activeElement !== inputEl) inputEl.focus();
149
+ else if (!focused && document.activeElement === inputEl) inputEl.blur();
150
+ });
151
+ function onkeydown(event) {
152
+ if (event.key === "Enter") {
153
+ event.preventDefault();
154
+ if (onsubmit?.(value) !== false && blurOnSubmit) inputEl?.blur();
155
+ } else if (event.key === "Escape") {
156
+ event.preventDefault();
157
+ value = original;
158
+ oncancel?.();
159
+ inputEl?.blur();
160
+ }
161
+ }
162
+ </script>
163
+
164
+ {#if is3d}
165
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_mouse_events_have_key_events -->
166
+ <a-entity
167
+ {...rest}
168
+ bind:this={rootEl}
169
+ onmouseenter={() => (hovered = !disabled)}
170
+ onmouseleave={() => (hovered = false)}
171
+ >
172
+ <a-entity position="{slot().x} {slot().y} 0">
173
+ {#if label}
174
+ <a-entity
175
+ bind:this={labelEl}
176
+ troika-text="fontSize: {m(s.labelFont.fontSize) * textScale}; color: {s.labelColor}; font: {font3d(t)}; anchor: left; baseline: center"
177
+ troika-text-material="shader: standard"
178
+ position="{-W / 2} {labelY} 0.002"
179
+ ></a-entity>
180
+ {/if}
181
+ <a-entity position="0 {fieldY} 0">
182
+ <!-- Border: a slab behind the field, showing around its edge. -->
183
+ <a-entity
184
+ rounded="width: {W}; height: {fieldH}; depth: {depth}; radius: {m(s.radius)}; color: {s.borderColor}"
185
+ position="{-W / 2} {-fieldH / 2} 0"
186
+ ></a-entity>
187
+ <a-entity
188
+ rounded="width: {W - 2 * border}; height: {fieldH - 2 * border}; depth: {depth}; radius: {Math.max(m(s.radius) - border, 0)}; color: {s.fill}"
189
+ position="{-W / 2 + border} {-fieldH / 2 + border} 0.002"
190
+ ></a-entity>
191
+ <a-entity position="0 0 {depth + 0.003}">
192
+ <TextLine3D
193
+ text={value}
194
+ caret={caretPos}
195
+ focused={hasFocus}
196
+ {placeholder}
197
+ font={s.font}
198
+ color={s.text}
199
+ placeholderColor={s.placeholder}
200
+ caretColor={s.caret}
201
+ width={W - 2 * padX}
202
+ hitWidth={W}
203
+ hitHeight={fieldH}
204
+ interactive={!disabled}
205
+ onpick={pick}
206
+ />
207
+ </a-entity>
208
+ </a-entity>
209
+ {#if message}
210
+ <a-entity
211
+ bind:this={messageEl}
212
+ troika-text="fontSize: {m(s.captionFont.fontSize) * textScale}; color: {s.captionColor}; font: {font3d(t)}; anchor: left; baseline: center"
213
+ troika-text-material="shader: standard"
214
+ position="{-W / 2} {messageY} 0.002"
215
+ ></a-entity>
216
+ {/if}
217
+ </a-entity>
218
+ </a-entity>
219
+ {:else}
220
+ <label
221
+ class="ui-input"
222
+ class:disabled
223
+ class:invalid={!!error}
224
+ style="--ui-width: {width}px; --ui-height: {s.height}px; --ui-padding-x: {s.paddingX}px; --ui-radius: {s.radius}px; --ui-border-width: {s.border}px; --ui-fill: {s.fill}; --ui-border: {s.borderColor}; --ui-hover: {t.colors.input.hover}; --ui-focus: {error ? t.colors.input.error : t.colors.input.focus}; --ui-text: {s.text}; --ui-placeholder: {s.placeholder}; --ui-caret: {s.caret}; --ui-label: {s.labelColor}; --ui-caption: {s.captionColor}; --ui-gap: {t.input.gap}px; --ui-font: {s.font.fontWeight} {s.font.fontSize}px/{s.font.lineHeight}px {t.typography.fontFamily}; --ui-label-font: {s.labelFont.fontWeight} {s.labelFont.fontSize}px/{s.labelFont.lineHeight}px {t.typography.fontFamily}; --ui-caption-font: {s.captionFont.fontWeight} {s.captionFont.fontSize}px/{s.captionFont.lineHeight}px {t.typography.fontFamily}; --ui-duration: {t.animation.timing200}ms; {userStyle}"
225
+ {...rest}
226
+ >
227
+ {#if label}<span class="label">{label}</span>{/if}
228
+ <input
229
+ bind:this={inputEl}
230
+ bind:value
231
+ type="text"
232
+ {placeholder}
233
+ {disabled}
234
+ {inputmode}
235
+ {enterkeyhint}
236
+ maxlength={maxLength}
237
+ aria-invalid={!!error}
238
+ onfocus={() => {
239
+ original = value;
240
+ focused = true;
241
+ }}
242
+ onblur={() => (focused = false)}
243
+ {onkeydown}
244
+ />
245
+ {#if message}<span class="message" role={error ? 'alert' : undefined}>{message}</span>{/if}
246
+ </label>
247
+ {/if}
248
+
249
+ <style>
250
+ .ui-input {
251
+ display: inline-flex;
252
+ flex-direction: column;
253
+ gap: var(--ui-gap);
254
+ width: var(--ui-width);
255
+ max-width: 100%;
256
+ text-align: start;
257
+ }
258
+ .label {
259
+ font: var(--ui-label-font);
260
+ color: var(--ui-label);
261
+ }
262
+ input {
263
+ box-sizing: border-box;
264
+ width: 100%;
265
+ height: var(--ui-height);
266
+ padding: 0 var(--ui-padding-x);
267
+ border: var(--ui-border-width) solid var(--ui-border);
268
+ border-radius: var(--ui-radius);
269
+ background: var(--ui-fill);
270
+ color: var(--ui-text);
271
+ caret-color: var(--ui-caret);
272
+ font: var(--ui-font);
273
+ outline: none;
274
+ transition: border-color var(--ui-duration);
275
+ }
276
+ input::placeholder {
277
+ color: var(--ui-placeholder);
278
+ opacity: 1;
279
+ }
280
+ .ui-input:not(.disabled):not(.invalid) input:hover:not(:focus) {
281
+ border-color: var(--ui-hover);
282
+ }
283
+ input:focus {
284
+ border-color: var(--ui-focus);
285
+ }
286
+ input:disabled {
287
+ cursor: not-allowed;
288
+ }
289
+ .message {
290
+ font: var(--ui-caption-font);
291
+ color: var(--ui-caption);
292
+ }
293
+ </style>
@@ -0,0 +1,23 @@
1
+ import type { EnterKeyHint } from './keyboard-layout';
2
+ import type { Dimension } from './theme';
3
+ type Props = {
4
+ value?: string;
5
+ label?: string;
6
+ placeholder?: string;
7
+ caption?: string;
8
+ error?: string;
9
+ disabled?: boolean;
10
+ width?: number;
11
+ maxLength?: number;
12
+ inputmode?: 'text' | 'numeric';
13
+ enterkeyhint?: EnterKeyHint;
14
+ blurOnSubmit?: boolean;
15
+ focused?: boolean;
16
+ onsubmit?: (value: string) => boolean | void;
17
+ oncancel?: () => void;
18
+ dimension?: Dimension;
19
+ [attribute: string]: unknown;
20
+ };
21
+ declare const Input: import("svelte").Component<Props, {}, "value" | "focused">;
22
+ type Input = ReturnType<typeof Input>;
23
+ export default Input;
@@ -0,0 +1,67 @@
1
+ <script lang="ts">// Internal: one key of the VR keyboard. Fixed size, centered on its position.
2
+ // Hover lightens it and lifts it toward you; pressing (cursor down) sinks it and turns it
3
+ // white, held for a moment so a quick trigger pull still shows; a press also pulses
4
+ // the controller that made it.
5
+ import { onDestroy } from "svelte";
6
+ import { setTroikaValue } from "./aframe/measure";
7
+ import { resolveKeyStyle } from "./component-style";
8
+ import { getTheme } from "./context";
9
+ import { font3d } from "./fonts";
10
+ let { label, width, height, font, kind = "char", on = false, onpress } = $props();
11
+ const theme = getTheme();
12
+ const t = $derived(theme());
13
+ const m = (px) => px * t.space3d.metersPerPx;
14
+ let hovered = $state(false);
15
+ let pressed = $state(false);
16
+ let releaseTimer;
17
+ onDestroy(() => clearTimeout(releaseTimer));
18
+ const s = $derived(resolveKeyStyle(t, {
19
+ kind,
20
+ hovered,
21
+ pressed,
22
+ on
23
+ }));
24
+ const depth = .03;
25
+ const z = $derived(pressed ? -t.space3d.pressDepth : hovered ? .008 : 0);
26
+ let labelEl = $state();
27
+ $effect(() => setTroikaValue(labelEl, label));
28
+ function down(event) {
29
+ clearTimeout(releaseTimer);
30
+ pressed = true;
31
+ pulse(event.detail?.cursorEl);
32
+ }
33
+ function up() {
34
+ releaseTimer = setTimeout(() => pressed = false, 90);
35
+ }
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ function pulse(cursorEl) {
38
+ try {
39
+ cursorEl?.components?.["tracked-controls"]?.controller?.gamepad?.hapticActuators?.[0]?.pulse?.(.25, 18);
40
+ } catch {}
41
+ }
42
+ </script>
43
+
44
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_mouse_events_have_key_events -->
45
+ <a-entity
46
+ position="0 0 {z}"
47
+ onclick={onpress}
48
+ onmouseenter={() => (hovered = true)}
49
+ onmouseleave={() => {
50
+ hovered = false;
51
+ up();
52
+ }}
53
+ onmousedown={down}
54
+ onmouseup={up}
55
+ >
56
+ <a-entity
57
+ class="raycastable"
58
+ rounded="width: {width}; height: {height}; depth: {depth}; radius: {m(t.keyboard.radius)}; color: {s.fill}"
59
+ position="{-width / 2} {-height / 2} 0"
60
+ ></a-entity>
61
+ <a-entity
62
+ bind:this={labelEl}
63
+ troika-text="fontSize: {m(font.fontSize) * t.space3d.textScale}; color: {s.text}; font: {font3d(t)}; anchor: center; baseline: center"
64
+ troika-text-material="shader: standard"
65
+ position="0 0 {depth + 0.002}"
66
+ ></a-entity>
67
+ </a-entity>
@@ -0,0 +1,13 @@
1
+ import type { Font } from './theme';
2
+ type Props = {
3
+ label: string;
4
+ width: number;
5
+ height: number;
6
+ font: Font;
7
+ kind?: 'char' | 'special' | 'accent';
8
+ on?: boolean;
9
+ onpress: (event: Event) => void;
10
+ };
11
+ declare const Key3D: import("svelte").Component<Props, {}, "">;
12
+ type Key3D = ReturnType<typeof Key3D>;
13
+ export default Key3D;
@@ -0,0 +1,167 @@
1
+ <script lang="ts">// The VR keyboard. Place one in the <a-scene> (not under the camera), like Toaster:
2
+ // <Keyboard />
3
+ // While a 3D Input has focus in an immersive session (or xr.simulated), it slides in on
4
+ // a Surface that lazily follows your gaze, below eye level and in front of other
5
+ // follow surfaces. The line on top shows the text being typed (click it to move the
6
+ // caret), so the field itself can be anywhere. Keys are pressed with the laser and
7
+ // trigger, or the mouse while simulating. Enter submits, Done keeps the text, × cancels.
8
+ import { untrack } from "svelte";
9
+ import { ensure3d } from "./aframe";
10
+ import { getTheme } from "./context";
11
+ import Key3D from "./Key3D.svelte";
12
+ import { KEYBOARD, ROW_UNITS, afterChar, keyChar, keyLabel, keyWidth, startPage, tapShift } from "./keyboard-layout";
13
+ import Surface from "./Surface.svelte";
14
+ import { textFocus } from "./text-focus.svelte";
15
+ import TextLine3D from "./TextLine3D.svelte";
16
+ import { xr } from "./xr.svelte";
17
+ let { distance = .75, height = -.5, scale = .3, tilt = 25 } = $props();
18
+ ensure3d();
19
+ const theme = getTheme();
20
+ const t = $derived(theme());
21
+ const m = (px) => px * t.space3d.metersPerPx;
22
+ const field = $derived(textFocus.current);
23
+ const open = $derived(!!field && xr.immersive);
24
+ let page = $state("letters");
25
+ let shift = $state("off");
26
+ let lastShiftTap = 0;
27
+ // Each newly focused field starts on its page, unshifted.
28
+ $effect(() => {
29
+ const f = field;
30
+ if (!f) return;
31
+ untrack(() => {
32
+ page = startPage(f.inputmode);
33
+ shift = "off";
34
+ });
35
+ });
36
+ let rootEl = $state();
37
+ $effect(() => {
38
+ const el = rootEl;
39
+ if (!el) return;
40
+ textFocus.keyboards.add(el);
41
+ return () => textFocus.keyboards.delete(el);
42
+ });
43
+ // Layout (meters). A key `w` units wide also covers the gaps it spans, so every row
44
+ // (ROW_UNITS wide) lines up whatever its key count.
45
+ const unit = $derived(m(t.keyboard.key));
46
+ const gap = $derived(m(t.keyboard.gap));
47
+ const pad = $derived(m(t.keyboard.padding));
48
+ const span = (w) => w * unit + (w - 1) * gap;
49
+ const rowsW = $derived(span(ROW_UNITS));
50
+ const rows = $derived(KEYBOARD[page]);
51
+ const previewH = $derived(m(t.input.height));
52
+ const W = $derived(rowsW + 2 * pad);
53
+ const H = $derived(pad + previewH + gap * 2 + rows.length * unit + (rows.length - 1) * gap + pad);
54
+ const keyFont = $derived(t.typography[t.keyboard.typography]);
55
+ const wordFont = $derived(t.typography[t.keyboard.wordTypography]);
56
+ const inputStyle = $derived(t.colors.input);
57
+ const placed = $derived(rows.flatMap((row, r) => {
58
+ let x = -rowsW / 2;
59
+ const y = H / 2 - pad - previewH - gap * 2 - unit / 2 - r * (unit + gap);
60
+ return row.map((key, k) => {
61
+ const w = span(keyWidth(key));
62
+ const item = {
63
+ key,
64
+ id: `${r}-${k}`,
65
+ x: x + w / 2,
66
+ y,
67
+ w
68
+ };
69
+ x += w + gap;
70
+ return item;
71
+ });
72
+ }));
73
+ const kindOf = (key) => key.action === "char" ? "char" : key.action === "enter" ? "accent" : "special";
74
+ function press(key) {
75
+ const f = textFocus.current;
76
+ if (!f) return;
77
+ switch (key.action) {
78
+ case "char":
79
+ f.edit({
80
+ type: "insert",
81
+ text: keyChar(key, shift)
82
+ });
83
+ shift = afterChar(shift);
84
+ break;
85
+ case "space":
86
+ f.edit({
87
+ type: "insert",
88
+ text: " "
89
+ });
90
+ break;
91
+ case "backspace":
92
+ f.edit({ type: "backspace" });
93
+ break;
94
+ case "shift": {
95
+ const now = performance.now();
96
+ shift = tapShift(shift, now - lastShiftTap);
97
+ lastShiftTap = now;
98
+ break;
99
+ }
100
+ case "page":
101
+ page = key.page;
102
+ shift = "off";
103
+ break;
104
+ case "enter":
105
+ f.enter();
106
+ break;
107
+ case "done":
108
+ f.done();
109
+ break;
110
+ case "close":
111
+ f.cancel();
112
+ break;
113
+ }
114
+ }
115
+ </script>
116
+
117
+ <a-entity bind:this={rootEl} class="ui-keyboard">
118
+ <Surface mode="3d" vr="follow" open={open} {distance} {height} {scale} maxAngle={25}>
119
+ <a-entity rotation="{-tilt} 0 0">
120
+ <!-- Raycastable so a press that misses a key lands here and keeps editing, instead of
121
+ counting as a click elsewhere (which closes the keyboard). -->
122
+ <a-entity
123
+ class="raycastable"
124
+ rounded="width: {W}; height: {H}; depth: 0.02; radius: {m(t.panel.radius)}; color: {t.colors.keyboard.fill}; opacity: {t.space3d.panelOpacity}"
125
+ position="{-W / 2} {-H / 2} -0.02"
126
+ ></a-entity>
127
+ <!-- What's being typed, with its caret. -->
128
+ <a-entity position="0 {H / 2 - pad - previewH / 2} 0">
129
+ <a-entity
130
+ rounded="width: {rowsW}; height: {previewH}; depth: 0.01; radius: {m(t.input.radius)}; color: {inputStyle.fill}"
131
+ position="{-rowsW / 2} {-previewH / 2} 0"
132
+ ></a-entity>
133
+ <a-entity position="0 0 0.012">
134
+ <TextLine3D
135
+ text={field?.text ?? ''}
136
+ caret={field?.caret ?? 0}
137
+ focused={!!field}
138
+ placeholder={field?.placeholder ?? ''}
139
+ font={t.typography[t.input.typography]}
140
+ color={inputStyle.text}
141
+ placeholderColor={inputStyle.placeholder}
142
+ caretColor={inputStyle.caret}
143
+ width={rowsW - 2 * m(t.input.paddingX)}
144
+ hitWidth={rowsW}
145
+ hitHeight={previewH}
146
+ onpick={(index) => textFocus.current?.setCaret(index)}
147
+ />
148
+ </a-entity>
149
+ </a-entity>
150
+ {#each placed as { key, id, x, y, w } (id)}
151
+ {#if key.action !== 'spacer'}
152
+ <a-entity position="{x} {y} 0">
153
+ <Key3D
154
+ label={keyLabel(key, shift, field?.enterKeyHint)}
155
+ width={w}
156
+ height={unit}
157
+ font={[...keyLabel(key, shift, field?.enterKeyHint)].length > 1 ? wordFont : keyFont}
158
+ kind={kindOf(key)}
159
+ on={key.action === 'shift' && shift !== 'off'}
160
+ onpress={() => press(key)}
161
+ />
162
+ </a-entity>
163
+ {/if}
164
+ {/each}
165
+ </a-entity>
166
+ </Surface>
167
+ </a-entity>
@@ -0,0 +1,9 @@
1
+ type Props = {
2
+ distance?: number;
3
+ height?: number;
4
+ scale?: number;
5
+ tilt?: number;
6
+ };
7
+ declare const Keyboard: import("svelte").Component<Props, {}, "">;
8
+ type Keyboard = ReturnType<typeof Keyboard>;
9
+ export default Keyboard;