@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.
- package/LICENSE +21 -0
- package/dist/Avatar.svelte +136 -0
- package/dist/Avatar.svelte.d.ts +13 -0
- package/dist/Button.svelte +29 -0
- package/dist/Button.svelte.d.ts +4 -0
- package/dist/Button2D.svelte +77 -0
- package/dist/Button2D.svelte.d.ts +4 -0
- package/dist/Button3D.svelte +98 -0
- package/dist/Button3D.svelte.d.ts +4 -0
- package/dist/ButtonGroup.svelte +59 -0
- package/dist/ButtonGroup.svelte.d.ts +17 -0
- package/dist/Checkbox.svelte +209 -0
- package/dist/Checkbox.svelte.d.ts +14 -0
- package/dist/DimensionScope.svelte +7 -0
- package/dist/DimensionScope.svelte.d.ts +9 -0
- package/dist/Input.svelte +293 -0
- package/dist/Input.svelte.d.ts +23 -0
- package/dist/Key3D.svelte +67 -0
- package/dist/Key3D.svelte.d.ts +13 -0
- package/dist/Keyboard.svelte +167 -0
- package/dist/Keyboard.svelte.d.ts +9 -0
- package/dist/Menu.svelte +193 -0
- package/dist/Menu.svelte.d.ts +23 -0
- package/dist/Modal.svelte +103 -0
- package/dist/Modal.svelte.d.ts +16 -0
- package/dist/Panel.svelte +132 -0
- package/dist/Panel.svelte.d.ts +19 -0
- package/dist/Pressable3D.svelte +82 -0
- package/dist/Pressable3D.svelte.d.ts +28 -0
- package/dist/ProgressBar.svelte +153 -0
- package/dist/ProgressBar.svelte.d.ts +16 -0
- package/dist/RadialMenu.svelte +322 -0
- package/dist/RadialMenu.svelte.d.ts +28 -0
- package/dist/Rule3D.svelte +17 -0
- package/dist/Rule3D.svelte.d.ts +9 -0
- package/dist/Scene3D.svelte +24 -0
- package/dist/Scene3D.svelte.d.ts +11 -0
- package/dist/Slider.svelte +209 -0
- package/dist/Slider.svelte.d.ts +17 -0
- package/dist/Space3D.svelte +6 -0
- package/dist/Space3D.svelte.d.ts +7 -0
- package/dist/Spinner.svelte +64 -0
- package/dist/Spinner.svelte.d.ts +10 -0
- package/dist/Stack.svelte +41 -0
- package/dist/Stack.svelte.d.ts +15 -0
- package/dist/StackScope.svelte +7 -0
- package/dist/StackScope.svelte.d.ts +9 -0
- package/dist/Surface.svelte +152 -0
- package/dist/Surface.svelte.d.ts +30 -0
- package/dist/Tab.svelte +40 -0
- package/dist/Tab.svelte.d.ts +10 -0
- package/dist/Tabs.svelte +145 -0
- package/dist/Tabs.svelte.d.ts +13 -0
- package/dist/Tag.svelte +71 -0
- package/dist/Tag.svelte.d.ts +12 -0
- package/dist/Text.svelte +75 -0
- package/dist/Text.svelte.d.ts +4 -0
- package/dist/TextLine3D.svelte +84 -0
- package/dist/TextLine3D.svelte.d.ts +19 -0
- package/dist/ThemeProvider.svelte +7 -0
- package/dist/ThemeProvider.svelte.d.ts +9 -0
- package/dist/Toaster.svelte +104 -0
- package/dist/Toaster.svelte.d.ts +11 -0
- package/dist/Tooltip.svelte +174 -0
- package/dist/Tooltip.svelte.d.ts +14 -0
- package/dist/aframe/frame.d.ts +1 -0
- package/dist/aframe/frame.js +37 -0
- package/dist/aframe/index.d.ts +1 -0
- package/dist/aframe/index.js +19 -0
- package/dist/aframe/measure.d.ts +3 -0
- package/dist/aframe/measure.js +42 -0
- package/dist/aframe/placement.d.ts +88 -0
- package/dist/aframe/placement.js +163 -0
- package/dist/aframe/rounded.d.ts +61 -0
- package/dist/aframe/rounded.js +121 -0
- package/dist/aframe/scene.d.ts +9 -0
- package/dist/aframe/scene.js +101 -0
- package/dist/assets/Geist-OFL.txt +93 -0
- package/dist/assets/Geist-Regular.ttf +0 -0
- package/dist/button-style.d.ts +22 -0
- package/dist/button-style.js +34 -0
- package/dist/component-style.d.ts +79 -0
- package/dist/component-style.js +129 -0
- package/dist/context.d.ts +16 -0
- package/dist/context.js +26 -0
- package/dist/fonts.css +7 -0
- package/dist/fonts.d.ts +2 -0
- package/dist/fonts.js +7 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +40 -0
- package/dist/keyboard-layout.d.ts +28 -0
- package/dist/keyboard-layout.js +77 -0
- package/dist/layout-math.d.ts +20 -0
- package/dist/layout-math.js +22 -0
- package/dist/layout.svelte.d.ts +17 -0
- package/dist/layout.svelte.js +58 -0
- package/dist/radial-math.d.ts +33 -0
- package/dist/radial-math.js +90 -0
- package/dist/tabs-context.d.ts +13 -0
- package/dist/tabs-context.js +6 -0
- package/dist/text-edit.d.ts +46 -0
- package/dist/text-edit.js +133 -0
- package/dist/text-focus.svelte.d.ts +24 -0
- package/dist/text-focus.svelte.js +102 -0
- package/dist/theme.d.ts +335 -0
- package/dist/theme.js +312 -0
- package/dist/toast.svelte.d.ts +24 -0
- package/dist/toast.svelte.js +30 -0
- package/dist/types.d.ts +25 -0
- package/dist/types.js +1 -0
- package/dist/xr.svelte.d.ts +9 -0
- package/dist/xr.svelte.js +24 -0
- package/package.json +44 -3
- 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,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>
|