@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
package/dist/Tabs.svelte
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<script lang="ts">import { nextIndex } from "./component-style";
|
|
2
|
+
import { getDimension, getTheme } from "./context";
|
|
3
|
+
import { createStack, setStack, useSlot } from "./layout.svelte";
|
|
4
|
+
import Pressable3D from "./Pressable3D.svelte";
|
|
5
|
+
import Stack from "./Stack.svelte";
|
|
6
|
+
import { setTabs } from "./tabs-context";
|
|
7
|
+
let { children, active = $bindable(), onchange, gap = 12, dimension, style: userStyle = "", ...rest } = $props();
|
|
8
|
+
const theme = getTheme();
|
|
9
|
+
const t = $derived(theme());
|
|
10
|
+
const c = $derived(t.colors.tabs);
|
|
11
|
+
const font = $derived(t.typography[t.tabs.typography]);
|
|
12
|
+
const uid = `ui-tabs-${Math.random().toString(36).slice(2, 8)}`;
|
|
13
|
+
let tabs = $state.raw([]);
|
|
14
|
+
const current = $derived(active ?? tabs.find((tab) => !tab.disabled)?.key);
|
|
15
|
+
function select(key) {
|
|
16
|
+
if (key === current) return;
|
|
17
|
+
active = key;
|
|
18
|
+
onchange?.(key);
|
|
19
|
+
}
|
|
20
|
+
setTabs({
|
|
21
|
+
id: uid,
|
|
22
|
+
register: (tab) => tabs = [...tabs, tab],
|
|
23
|
+
unregister: (tab) => tabs = tabs.filter((x) => x !== tab),
|
|
24
|
+
get active() {
|
|
25
|
+
return current;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
const contextDimension = getDimension();
|
|
29
|
+
// svelte-ignore state_referenced_locally
|
|
30
|
+
const is3d = (dimension ?? contextDimension) === "3d";
|
|
31
|
+
const m = (px) => px * t.space3d.metersPerPx;
|
|
32
|
+
const column = is3d ? createStack(() => ({
|
|
33
|
+
direction: "column",
|
|
34
|
+
gap: m(gap),
|
|
35
|
+
align: "start"
|
|
36
|
+
})) : undefined;
|
|
37
|
+
const slot = is3d ? useSlot(() => column.size) : () => ({
|
|
38
|
+
x: 0,
|
|
39
|
+
y: 0
|
|
40
|
+
});
|
|
41
|
+
setStack(column);
|
|
42
|
+
// 2D keyboard: arrows / Home / End move to (and select) another enabled tab.
|
|
43
|
+
let buttons = $state([]);
|
|
44
|
+
function onkeydown(event) {
|
|
45
|
+
const usable = tabs.map((tab) => !tab.disabled);
|
|
46
|
+
const from = tabs.findIndex((tab) => tab.key === current);
|
|
47
|
+
const to = event.key === "ArrowRight" ? nextIndex(usable, from, 1) : event.key === "ArrowLeft" ? nextIndex(usable, from, -1) : event.key === "Home" ? nextIndex(usable, -1, 1, false) : event.key === "End" ? nextIndex(usable, tabs.length, -1, false) : -1;
|
|
48
|
+
if (to < 0 || to === from) return;
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
select(tabs[to].key);
|
|
51
|
+
buttons[to]?.focus();
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
{#if is3d}
|
|
56
|
+
<a-entity {...rest}>
|
|
57
|
+
<a-entity position="{slot().x} {slot().y} 0">
|
|
58
|
+
<Stack direction="row" gap={t.tabs.gap}>
|
|
59
|
+
{#each tabs as tab (tab)}
|
|
60
|
+
<Pressable3D
|
|
61
|
+
label={tab.title}
|
|
62
|
+
{font}
|
|
63
|
+
textColor={tab.disabled ? t.colors.buttonDisabledText : tab.key === current ? c.textActive : c.text}
|
|
64
|
+
height={t.tabs.height}
|
|
65
|
+
paddingX={t.tabs.paddingX}
|
|
66
|
+
radius={t.tabs.radius}
|
|
67
|
+
hoverFill={c.hover}
|
|
68
|
+
activeFill={c.hover}
|
|
69
|
+
indicator={{ color: c.indicator, height: t.tabs.indicator }}
|
|
70
|
+
selected={tab.key === current}
|
|
71
|
+
disabled={tab.disabled}
|
|
72
|
+
onclick={() => select(tab.key)}
|
|
73
|
+
/>
|
|
74
|
+
{/each}
|
|
75
|
+
</Stack>
|
|
76
|
+
{@render children()}
|
|
77
|
+
</a-entity>
|
|
78
|
+
</a-entity>
|
|
79
|
+
{:else}
|
|
80
|
+
<div
|
|
81
|
+
class="ui-tabs"
|
|
82
|
+
style="--ui-text: {c.text}; --ui-text-active: {c.textActive}; --ui-hover: {c.hover}; --ui-indicator: {c.indicator}; --ui-border: {c.border}; --ui-height: {t.tabs.height}px; --ui-padding-x: {t.tabs.paddingX}px; --ui-radius: {t.tabs.radius}px; --ui-indicator-height: {t.tabs.indicator}px; --ui-gap: {gap}px; --ui-focus: {t.colors.accent}; --ui-disabled: {t.colors.buttonDisabledText}; --ui-font: {font.fontWeight} {font.fontSize}px/{font.lineHeight}px {t.typography.fontFamily}; {userStyle}"
|
|
83
|
+
{...rest}
|
|
84
|
+
>
|
|
85
|
+
<div role="tablist" tabindex="-1" {onkeydown}>
|
|
86
|
+
{#each tabs as tab, i (tab)}
|
|
87
|
+
<button
|
|
88
|
+
bind:this={buttons[i]}
|
|
89
|
+
type="button"
|
|
90
|
+
role="tab"
|
|
91
|
+
id="{uid}-tab-{tab.key}"
|
|
92
|
+
aria-controls="{uid}-panel-{tab.key}"
|
|
93
|
+
aria-selected={tab.key === current}
|
|
94
|
+
tabindex={tab.key === current ? 0 : -1}
|
|
95
|
+
disabled={tab.disabled}
|
|
96
|
+
onclick={() => select(tab.key)}
|
|
97
|
+
>
|
|
98
|
+
{tab.title}
|
|
99
|
+
</button>
|
|
100
|
+
{/each}
|
|
101
|
+
</div>
|
|
102
|
+
{@render children()}
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
105
|
+
|
|
106
|
+
<style>
|
|
107
|
+
.ui-tabs {
|
|
108
|
+
display: flex;
|
|
109
|
+
flex-direction: column;
|
|
110
|
+
gap: var(--ui-gap);
|
|
111
|
+
align-self: stretch;
|
|
112
|
+
}
|
|
113
|
+
[role='tablist'] {
|
|
114
|
+
display: flex;
|
|
115
|
+
gap: 4px;
|
|
116
|
+
border-bottom: 1px solid var(--ui-border);
|
|
117
|
+
}
|
|
118
|
+
button {
|
|
119
|
+
height: var(--ui-height);
|
|
120
|
+
padding: 0 var(--ui-padding-x);
|
|
121
|
+
border: 0;
|
|
122
|
+
border-radius: var(--ui-radius) var(--ui-radius) 0 0;
|
|
123
|
+
background: transparent;
|
|
124
|
+
color: var(--ui-text);
|
|
125
|
+
font: var(--ui-font);
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
box-shadow: inset 0 calc(-1 * var(--ui-indicator-height)) 0 transparent;
|
|
128
|
+
transition: background-color 0.2s, box-shadow 0.2s, color 0.2s;
|
|
129
|
+
}
|
|
130
|
+
button:hover:not(:disabled) {
|
|
131
|
+
background: var(--ui-hover);
|
|
132
|
+
}
|
|
133
|
+
button[aria-selected='true'] {
|
|
134
|
+
color: var(--ui-text-active);
|
|
135
|
+
box-shadow: inset 0 calc(-1 * var(--ui-indicator-height)) 0 var(--ui-indicator);
|
|
136
|
+
}
|
|
137
|
+
button:disabled {
|
|
138
|
+
color: var(--ui-disabled);
|
|
139
|
+
cursor: not-allowed;
|
|
140
|
+
}
|
|
141
|
+
button:focus-visible {
|
|
142
|
+
outline: 3px solid var(--ui-focus);
|
|
143
|
+
outline-offset: -3px;
|
|
144
|
+
}
|
|
145
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { Dimension } from './theme';
|
|
3
|
+
type Props = {
|
|
4
|
+
children: Snippet;
|
|
5
|
+
active?: string;
|
|
6
|
+
onchange?: (key: string) => void;
|
|
7
|
+
gap?: number;
|
|
8
|
+
dimension?: Dimension;
|
|
9
|
+
[attribute: string]: unknown;
|
|
10
|
+
};
|
|
11
|
+
declare const Tabs: import("svelte").Component<Props, {}, "active">;
|
|
12
|
+
type Tabs = ReturnType<typeof Tabs>;
|
|
13
|
+
export default Tabs;
|
package/dist/Tag.svelte
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script lang="ts">// Base UI-style Tag: a small status chip ("Live", "3 players"). Not interactive.
|
|
2
|
+
import { ensure3d } from "./aframe";
|
|
3
|
+
import { watchTextSize } from "./aframe/measure";
|
|
4
|
+
import { resolveTagStyle } from "./component-style";
|
|
5
|
+
import { getDimension, getTheme } from "./context";
|
|
6
|
+
import { font3d } from "./fonts";
|
|
7
|
+
import { useSlot } from "./layout.svelte";
|
|
8
|
+
let { label, kind, variant, size, dimension, style: userStyle = "", ...rest } = $props();
|
|
9
|
+
const theme = getTheme();
|
|
10
|
+
const t = $derived(theme());
|
|
11
|
+
const s = $derived(resolveTagStyle(t, {
|
|
12
|
+
kind,
|
|
13
|
+
variant,
|
|
14
|
+
size
|
|
15
|
+
}));
|
|
16
|
+
const contextDimension = getDimension();
|
|
17
|
+
// svelte-ignore state_referenced_locally
|
|
18
|
+
const is3d = (dimension ?? contextDimension) === "3d";
|
|
19
|
+
// ---- 3D ----
|
|
20
|
+
const m = (px) => px * t.space3d.metersPerPx;
|
|
21
|
+
if (is3d) ensure3d();
|
|
22
|
+
let textWidth = $state(0);
|
|
23
|
+
let labelEl = $state();
|
|
24
|
+
const height = $derived(m(s.height));
|
|
25
|
+
const width = $derived(Math.max(height, textWidth + 2 * m(s.paddingX)));
|
|
26
|
+
const slot = is3d ? useSlot(() => ({
|
|
27
|
+
width,
|
|
28
|
+
height
|
|
29
|
+
})) : () => ({
|
|
30
|
+
x: 0,
|
|
31
|
+
y: 0
|
|
32
|
+
});
|
|
33
|
+
const depth = .01;
|
|
34
|
+
$effect(() => {
|
|
35
|
+
if (labelEl) return watchTextSize(labelEl, (w) => textWidth = w);
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
{#if is3d}
|
|
40
|
+
<a-entity {...rest}>
|
|
41
|
+
<a-entity position="{slot().x} {slot().y} 0">
|
|
42
|
+
<a-entity
|
|
43
|
+
rounded="width: {width}; height: {height}; depth: {depth}; radius: {m(s.radius)}; color: {s.fill}"
|
|
44
|
+
position="{-width / 2} {-height / 2} 0"
|
|
45
|
+
></a-entity>
|
|
46
|
+
<a-entity
|
|
47
|
+
bind:this={labelEl}
|
|
48
|
+
troika-text="value: {label.replace(/;/g, ',')}; fontSize: {m(s.font.fontSize) * t.space3d.textScale}; color: {s.text}; font: {font3d(t)}; anchor: center; baseline: center"
|
|
49
|
+
troika-text-material="shader: standard"
|
|
50
|
+
position="0 0 {depth + 0.002}"
|
|
51
|
+
></a-entity>
|
|
52
|
+
</a-entity>
|
|
53
|
+
</a-entity>
|
|
54
|
+
{:else}
|
|
55
|
+
<span
|
|
56
|
+
class="ui-tag"
|
|
57
|
+
style="height: {s.height}px; padding: 0 {s.paddingX}px; border-radius: {s.radius}px; background: {s.fill}; color: {s.text}; font: {s.font.fontWeight} {s.font.fontSize}px/{s.font.lineHeight}px {t.typography.fontFamily}; {userStyle}"
|
|
58
|
+
{...rest}
|
|
59
|
+
>
|
|
60
|
+
{label}
|
|
61
|
+
</span>
|
|
62
|
+
{/if}
|
|
63
|
+
|
|
64
|
+
<style>
|
|
65
|
+
.ui-tag {
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
box-sizing: border-box;
|
|
69
|
+
white-space: nowrap;
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Dimension, TagKind, TagSize, TagVariant } from './theme';
|
|
2
|
+
type Props = {
|
|
3
|
+
label: string;
|
|
4
|
+
kind?: TagKind;
|
|
5
|
+
variant?: TagVariant;
|
|
6
|
+
size?: TagSize;
|
|
7
|
+
dimension?: Dimension;
|
|
8
|
+
[attribute: string]: unknown;
|
|
9
|
+
};
|
|
10
|
+
declare const Tag: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type Tag = ReturnType<typeof Tag>;
|
|
12
|
+
export default Tag;
|
package/dist/Text.svelte
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script lang="ts">// Typography. 2D: a <span>. 3D: troika text, anchored by `align` (default center).
|
|
2
|
+
// Inside a Panel / ButtonGroup it measures itself and takes its place in the stack.
|
|
3
|
+
import { ensure3d } from "./aframe";
|
|
4
|
+
import { setTroikaValue, watchTextSize } from "./aframe/measure";
|
|
5
|
+
import { getDimension, getTheme } from "./context";
|
|
6
|
+
import { font3d } from "./fonts";
|
|
7
|
+
import { inStack, useSlot } from "./layout.svelte";
|
|
8
|
+
let { text = "", children, variant = "ParagraphMedium", color, align, maxWidth, dimension, style: userStyle = "", ...rest } = $props();
|
|
9
|
+
const contextDimension = getDimension();
|
|
10
|
+
const theme = getTheme();
|
|
11
|
+
const t = $derived(theme());
|
|
12
|
+
const font = $derived(t.typography[variant]);
|
|
13
|
+
const textColor = $derived(color ?? t.colors.contentPrimary);
|
|
14
|
+
const is3d = $derived((dimension ?? contextDimension) === "3d");
|
|
15
|
+
const anchor = $derived(align ?? "center");
|
|
16
|
+
// 3D only.
|
|
17
|
+
let size = $state({
|
|
18
|
+
width: 0,
|
|
19
|
+
height: 0
|
|
20
|
+
});
|
|
21
|
+
let textEl = $state();
|
|
22
|
+
// svelte-ignore state_referenced_locally
|
|
23
|
+
const stacked = (dimension ?? contextDimension) === "3d" && inStack();
|
|
24
|
+
const slot = stacked ? useSlot(() => size) : () => ({
|
|
25
|
+
x: 0,
|
|
26
|
+
y: 0
|
|
27
|
+
});
|
|
28
|
+
// In a stack the slot is the text's center, so shift left/right-anchored text to fill it.
|
|
29
|
+
// Outside one, the anchor is relative to the entity's own position.
|
|
30
|
+
const anchorShift = $derived(!stacked ? 0 : anchor === "left" ? -size.width / 2 : anchor === "right" ? size.width / 2 : 0);
|
|
31
|
+
$effect(() => {
|
|
32
|
+
if (is3d) ensure3d();
|
|
33
|
+
});
|
|
34
|
+
$effect(() => {
|
|
35
|
+
if (textEl) return watchTextSize(textEl, (width, height) => size = {
|
|
36
|
+
width,
|
|
37
|
+
height
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
// Set apart from the attribute string, which A-Frame splits on `;` (chat text can have one).
|
|
41
|
+
$effect(() => setTroikaValue(textEl, text));
|
|
42
|
+
const troika = $derived.by(() => {
|
|
43
|
+
const m = t.space3d.metersPerPx;
|
|
44
|
+
return [
|
|
45
|
+
`fontSize: ${font.fontSize * m * t.space3d.textScale}`,
|
|
46
|
+
`lineHeight: ${font.lineHeight / font.fontSize}`,
|
|
47
|
+
`color: ${textColor}`,
|
|
48
|
+
`font: ${font3d(t)}`,
|
|
49
|
+
`align: ${anchor}`,
|
|
50
|
+
`anchor: ${anchor}`,
|
|
51
|
+
maxWidth ? `maxWidth: ${maxWidth * m}` : ""
|
|
52
|
+
].filter(Boolean).join("; ");
|
|
53
|
+
});
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
{#if is3d}
|
|
57
|
+
<a-entity {...rest}>
|
|
58
|
+
<a-entity
|
|
59
|
+
bind:this={textEl}
|
|
60
|
+
troika-text={troika}
|
|
61
|
+
troika-text-material="shader: standard"
|
|
62
|
+
position="{slot().x + anchorShift} {slot().y} 0"
|
|
63
|
+
></a-entity>
|
|
64
|
+
</a-entity>
|
|
65
|
+
{:else}
|
|
66
|
+
<span
|
|
67
|
+
class="ui-text"
|
|
68
|
+
style="font: {font.fontWeight} {font.fontSize}px/{font.lineHeight}px {t.typography.fontFamily}; color: {textColor}; text-align: {align ?? 'inherit'}; {maxWidth
|
|
69
|
+
? `max-width: ${maxWidth}px; display: inline-block;`
|
|
70
|
+
: ''} {userStyle}"
|
|
71
|
+
{...rest}
|
|
72
|
+
>
|
|
73
|
+
{#if children}{@render children()}{:else}{text}{/if}
|
|
74
|
+
</span>
|
|
75
|
+
{/if}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script lang="ts">// Internal: one line of editable text in 3D — troika text clipped to a window, a
|
|
2
|
+
// blinking caret, a placeholder, and horizontal scroll that keeps the caret in view.
|
|
3
|
+
// Clicking it reports the caret index under the cursor (`onpick`). Used by Input's
|
|
4
|
+
// field and the VR keyboard's preview line. Centered on its position.
|
|
5
|
+
import { onDestroy } from "svelte";
|
|
6
|
+
import { setTroikaValue, watchTextLayout } from "./aframe/measure";
|
|
7
|
+
import { getTheme } from "./context";
|
|
8
|
+
import { font3d } from "./fonts";
|
|
9
|
+
import { caretIndexAt, caretX, scrollToCaret } from "./text-edit";
|
|
10
|
+
let { text, caret, focused = false, placeholder = "", font, color, placeholderColor, caretColor, width, hitWidth = width, hitHeight, interactive = true, onpick } = $props();
|
|
11
|
+
const theme = getTheme();
|
|
12
|
+
const t = $derived(theme());
|
|
13
|
+
const m = (px) => px * t.space3d.metersPerPx;
|
|
14
|
+
const fontSize = $derived(m(font.fontSize) * t.space3d.textScale);
|
|
15
|
+
const lineHeight = $derived(m(font.lineHeight) * t.space3d.textScale);
|
|
16
|
+
let textEl = $state();
|
|
17
|
+
let placeholderEl = $state();
|
|
18
|
+
// troika's caret positions for the text as last laid out (it lays out asynchronously).
|
|
19
|
+
let positions = $state.raw(null);
|
|
20
|
+
const laidOut = $derived(positions ? positions.length / 4 : 0);
|
|
21
|
+
const caretAt = $derived(caretX(Math.min(caret, laidOut), positions, laidOut));
|
|
22
|
+
const textWidth = $derived(caretX(laidOut, positions, laidOut));
|
|
23
|
+
let scroll = $state(0);
|
|
24
|
+
$effect(() => {
|
|
25
|
+
scroll = scrollToCaret(caretAt, scroll, width, textWidth);
|
|
26
|
+
});
|
|
27
|
+
$effect(() => {
|
|
28
|
+
if (textEl) return watchTextLayout(textEl, (info) => positions = info.caretPositions ?? null);
|
|
29
|
+
});
|
|
30
|
+
$effect(() => setTroikaValue(textEl, text));
|
|
31
|
+
$effect(() => setTroikaValue(placeholderEl, placeholder));
|
|
32
|
+
// Blink, solid again whenever the text or caret moves. A timer, not
|
|
33
|
+
// requestAnimationFrame, so it keeps going in an immersive session.
|
|
34
|
+
let blinkOn = $state(true);
|
|
35
|
+
let timer;
|
|
36
|
+
$effect(() => {
|
|
37
|
+
void text;
|
|
38
|
+
void caret;
|
|
39
|
+
blinkOn = true;
|
|
40
|
+
clearInterval(timer);
|
|
41
|
+
if (focused) timer = setInterval(() => blinkOn = !blinkOn, 530);
|
|
42
|
+
});
|
|
43
|
+
onDestroy(() => clearInterval(timer));
|
|
44
|
+
const common = $derived(`fontSize: ${fontSize}; color: ${color}; font: ${font3d(t)}; anchor: left; baseline: center; whiteSpace: nowrap`);
|
|
45
|
+
function pick(event) {
|
|
46
|
+
const point = event.detail?.intersection?.point;
|
|
47
|
+
const obj = textEl?.object3D;
|
|
48
|
+
if (!point || !obj || !onpick) return;
|
|
49
|
+
const x = obj.worldToLocal(point.clone()).x;
|
|
50
|
+
onpick(caretIndexAt(x, positions, laidOut));
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
55
|
+
<a-entity onclick={pick}>
|
|
56
|
+
<!-- Hit area (the raycaster ignores `visible`). -->
|
|
57
|
+
<a-entity
|
|
58
|
+
class={interactive ? 'raycastable' : ''}
|
|
59
|
+
geometry="primitive: plane; width: {hitWidth}; height: {hitHeight ?? lineHeight * 1.6}"
|
|
60
|
+
material="visible: false"
|
|
61
|
+
position="0 0 0.003"
|
|
62
|
+
></a-entity>
|
|
63
|
+
<a-entity
|
|
64
|
+
bind:this={textEl}
|
|
65
|
+
troika-text="{common}; clipRect: {scroll} {-lineHeight} {scroll + width} {lineHeight}"
|
|
66
|
+
troika-text-material="shader: standard"
|
|
67
|
+
position="{-width / 2 - scroll} 0 0.001"
|
|
68
|
+
></a-entity>
|
|
69
|
+
{#if !text && placeholder}
|
|
70
|
+
<a-entity
|
|
71
|
+
bind:this={placeholderEl}
|
|
72
|
+
troika-text="{common}; color: {placeholderColor}; clipRect: 0 {-lineHeight} {width} {lineHeight}"
|
|
73
|
+
troika-text-material="shader: standard"
|
|
74
|
+
position="{-width / 2} 0 0.001"
|
|
75
|
+
></a-entity>
|
|
76
|
+
{/if}
|
|
77
|
+
{#if focused}
|
|
78
|
+
<a-entity
|
|
79
|
+
geometry="primitive: plane; width: {m(t.input.caretWidth) * t.space3d.textScale}; height: {fontSize * 1.25}"
|
|
80
|
+
material="shader: flat; color: {caretColor}; opacity: {blinkOn ? 1 : 0}; transparent: true"
|
|
81
|
+
position="{-width / 2 + caretAt - scroll} 0 0.002"
|
|
82
|
+
></a-entity>
|
|
83
|
+
{/if}
|
|
84
|
+
</a-entity>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Font } from './theme';
|
|
2
|
+
type Props = {
|
|
3
|
+
text: string;
|
|
4
|
+
caret: number;
|
|
5
|
+
focused?: boolean;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
font: Font;
|
|
8
|
+
color: string;
|
|
9
|
+
placeholderColor: string;
|
|
10
|
+
caretColor: string;
|
|
11
|
+
width: number;
|
|
12
|
+
hitWidth?: number;
|
|
13
|
+
hitHeight?: number;
|
|
14
|
+
interactive?: boolean;
|
|
15
|
+
onpick?: (index: number) => void;
|
|
16
|
+
};
|
|
17
|
+
declare const TextLine3D: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type TextLine3D = ReturnType<typeof TextLine3D>;
|
|
19
|
+
export default TextLine3D;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type Theme } from './theme';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
theme?: Theme;
|
|
5
|
+
children: Snippet;
|
|
6
|
+
};
|
|
7
|
+
declare const ThemeProvider: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type ThemeProvider = ReturnType<typeof ThemeProvider>;
|
|
9
|
+
export default ThemeProvider;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script lang="ts">// Shows the toast queue (toast.svelte.ts). 2D: fixed in a corner of the page.
|
|
2
|
+
// 3D: a column whose `placement` corner sits at this entity's position (bottom-right:
|
|
3
|
+
// grows up and to the left from it) — e.g. under the camera, low and to the side.
|
|
4
|
+
import { flip } from "svelte/animate";
|
|
5
|
+
import { fly } from "svelte/transition";
|
|
6
|
+
import Button from "./Button.svelte";
|
|
7
|
+
import { getDimension, getTheme } from "./context";
|
|
8
|
+
import { createStack, setStack, useSlot } from "./layout.svelte";
|
|
9
|
+
import Panel from "./Panel.svelte";
|
|
10
|
+
import Text from "./Text.svelte";
|
|
11
|
+
import { toaster } from "./toast.svelte";
|
|
12
|
+
let { placement = "bottom-right", max = 4, dimension, ...rest } = $props();
|
|
13
|
+
const theme = getTheme();
|
|
14
|
+
const t = $derived(theme());
|
|
15
|
+
const tk = $derived(t.toast);
|
|
16
|
+
const visible = $derived(toaster.items.slice(-max));
|
|
17
|
+
const contextDimension = getDimension();
|
|
18
|
+
// svelte-ignore state_referenced_locally
|
|
19
|
+
const is3d = (dimension ?? contextDimension) === "3d";
|
|
20
|
+
const m = (px) => px * t.space3d.metersPerPx;
|
|
21
|
+
const column = is3d ? createStack(() => ({
|
|
22
|
+
direction: "column",
|
|
23
|
+
gap: m(8),
|
|
24
|
+
align: placement.endsWith("left") ? "start" : placement.endsWith("right") ? "end" : "center"
|
|
25
|
+
})) : undefined;
|
|
26
|
+
const slot = is3d ? useSlot(() => column.size) : () => ({
|
|
27
|
+
x: 0,
|
|
28
|
+
y: 0
|
|
29
|
+
});
|
|
30
|
+
setStack(column);
|
|
31
|
+
// Shift the column so its `placement` corner/edge is at the origin.
|
|
32
|
+
const anchor = $derived({
|
|
33
|
+
x: placement.endsWith("right") ? -(column?.size.width ?? 0) / 2 : placement.endsWith("left") ? (column?.size.width ?? 0) / 2 : 0,
|
|
34
|
+
y: placement.startsWith("bottom") ? (column?.size.height ?? 0) / 2 : -(column?.size.height ?? 0) / 2
|
|
35
|
+
});
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
{#snippet toastPanel(toast: Toast)}
|
|
39
|
+
{@const colors = t.colors.toast[toast.kind]}
|
|
40
|
+
<Panel
|
|
41
|
+
fill={colors.fill}
|
|
42
|
+
width={tk.width}
|
|
43
|
+
padding={tk.padding}
|
|
44
|
+
style="border-left: 4px solid {colors.accent};"
|
|
45
|
+
role={toast.kind === 'negative' ? 'alert' : 'status'}
|
|
46
|
+
>
|
|
47
|
+
{#snippet action()}
|
|
48
|
+
<Button label="×" kind="tertiary" shape="circle" size="mini" aria-label="Dismiss" onclick={() => toaster.dismiss(toast.id)} />
|
|
49
|
+
{/snippet}
|
|
50
|
+
<Text variant="LabelMedium" align="left" color={colors.text} maxWidth={tk.width - 2 * tk.padding - 32} text={toast.message} />
|
|
51
|
+
</Panel>
|
|
52
|
+
{/snippet}
|
|
53
|
+
|
|
54
|
+
{#if is3d}
|
|
55
|
+
<a-entity {...rest}>
|
|
56
|
+
<a-entity position="{slot().x + anchor.x} {slot().y + anchor.y} 0">
|
|
57
|
+
{#each visible as toast (toast.id)}
|
|
58
|
+
<!-- Slides forward into place when it appears. -->
|
|
59
|
+
<a-entity animation__in="property: position; from: 0 0 -0.08; to: 0 0 0; dur: {t.animation.timing300}; easing: {t.animation.easeOut3d}">
|
|
60
|
+
{@render toastPanel(toast)}
|
|
61
|
+
</a-entity>
|
|
62
|
+
{/each}
|
|
63
|
+
</a-entity>
|
|
64
|
+
</a-entity>
|
|
65
|
+
{:else}
|
|
66
|
+
<div class="ui-toaster" data-placement={placement} aria-live="polite" {...rest}>
|
|
67
|
+
{#each visible as toast (toast.id)}
|
|
68
|
+
<div animate:flip={{ duration: 200 }} transition:fly={{ y: placement.startsWith('top') ? -16 : 16, duration: 200 }}>
|
|
69
|
+
{@render toastPanel(toast)}
|
|
70
|
+
</div>
|
|
71
|
+
{/each}
|
|
72
|
+
</div>
|
|
73
|
+
{/if}
|
|
74
|
+
|
|
75
|
+
<style>
|
|
76
|
+
.ui-toaster {
|
|
77
|
+
position: fixed;
|
|
78
|
+
z-index: 1000;
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-direction: column;
|
|
81
|
+
gap: 8px;
|
|
82
|
+
pointer-events: none;
|
|
83
|
+
}
|
|
84
|
+
.ui-toaster > :global(*) {
|
|
85
|
+
pointer-events: auto;
|
|
86
|
+
}
|
|
87
|
+
[data-placement^='top'] {
|
|
88
|
+
top: 16px;
|
|
89
|
+
}
|
|
90
|
+
[data-placement^='bottom'] {
|
|
91
|
+
bottom: 16px;
|
|
92
|
+
}
|
|
93
|
+
[data-placement$='left'] {
|
|
94
|
+
left: 16px;
|
|
95
|
+
}
|
|
96
|
+
[data-placement$='right'] {
|
|
97
|
+
right: 16px;
|
|
98
|
+
}
|
|
99
|
+
[data-placement='top'],
|
|
100
|
+
[data-placement='bottom'] {
|
|
101
|
+
left: 50%;
|
|
102
|
+
transform: translateX(-50%);
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Dimension } from './theme';
|
|
2
|
+
type Placement = 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right';
|
|
3
|
+
type Props = {
|
|
4
|
+
placement?: Placement;
|
|
5
|
+
max?: number;
|
|
6
|
+
dimension?: Dimension;
|
|
7
|
+
[attribute: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
declare const Toaster: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type Toaster = ReturnType<typeof Toaster>;
|
|
11
|
+
export default Toaster;
|