@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 arsra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<script lang="ts">// Base UI-style Avatar: a player's picture (`src`), or their initials on a color picked
|
|
2
|
+
// from their name. Optional `status` dot. 3D: a thin disc with the picture or initials
|
|
3
|
+
// on its face.
|
|
4
|
+
import { ensure3d } from "./aframe";
|
|
5
|
+
import { avatarColor, initials } from "./component-style";
|
|
6
|
+
import { getDimension, getTheme } from "./context";
|
|
7
|
+
import { font3d } from "./fonts";
|
|
8
|
+
import { useSlot } from "./layout.svelte";
|
|
9
|
+
let { name = "", src, size = "medium", status, color, dimension, style: userStyle = "", ...rest } = $props();
|
|
10
|
+
const theme = getTheme();
|
|
11
|
+
const t = $derived(theme());
|
|
12
|
+
const tk = $derived(t.avatar);
|
|
13
|
+
const c = $derived(t.colors.avatar);
|
|
14
|
+
const px = $derived(tk.sizes[size]);
|
|
15
|
+
const fill = $derived(color ?? avatarColor(name, c.palette));
|
|
16
|
+
const letters = $derived(initials(name));
|
|
17
|
+
const dot = $derived(Math.max(8, Math.round(px * tk.status)));
|
|
18
|
+
const label = $derived([name, status].filter(Boolean).join(", ") || "Avatar");
|
|
19
|
+
const contextDimension = getDimension();
|
|
20
|
+
// svelte-ignore state_referenced_locally
|
|
21
|
+
const is3d = (dimension ?? contextDimension) === "3d";
|
|
22
|
+
// The picture, once it has loaded; initials until then, and if it fails.
|
|
23
|
+
let image = $state(null);
|
|
24
|
+
const imageOk = $derived(!!image);
|
|
25
|
+
$effect(() => {
|
|
26
|
+
image = null;
|
|
27
|
+
if (!src) return;
|
|
28
|
+
const img = new Image();
|
|
29
|
+
img.crossOrigin = "anonymous";
|
|
30
|
+
img.onload = () => image = img;
|
|
31
|
+
img.src = src;
|
|
32
|
+
return () => {
|
|
33
|
+
img.onload = null;
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
// ---- 3D ----
|
|
37
|
+
const m = (v) => v * t.space3d.metersPerPx;
|
|
38
|
+
if (is3d) ensure3d();
|
|
39
|
+
const d = $derived(m(px));
|
|
40
|
+
const slot = is3d ? useSlot(() => ({
|
|
41
|
+
width: d,
|
|
42
|
+
height: d
|
|
43
|
+
})) : () => ({
|
|
44
|
+
x: 0,
|
|
45
|
+
y: 0
|
|
46
|
+
});
|
|
47
|
+
const depth = $derived(Math.min(.02, d * .12));
|
|
48
|
+
const face = $derived(depth / 2 + .001);
|
|
49
|
+
// Status dot on the rim at the bottom right (45°), cut out with a ring of the panel color.
|
|
50
|
+
const dotAt = $derived(d / 2 * Math.SQRT1_2);
|
|
51
|
+
const dotR = $derived(m(dot) / 2);
|
|
52
|
+
// The loaded <img> goes to the material as an object: a URL in the attribute string
|
|
53
|
+
// would be split at the `;` in data: URLs.
|
|
54
|
+
let faceEl = $state();
|
|
55
|
+
$effect(() => {
|
|
56
|
+
if (faceEl && image) faceEl.setAttribute("material", {
|
|
57
|
+
shader: "flat",
|
|
58
|
+
src: image
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
{#if is3d}
|
|
64
|
+
<a-entity {...rest}>
|
|
65
|
+
<a-entity position="{slot().x} {slot().y} 0">
|
|
66
|
+
<a-entity
|
|
67
|
+
geometry="primitive: cylinder; radius: {d / 2}; height: {depth}; segmentsRadial: 48"
|
|
68
|
+
material="color: {fill}; roughness: 0.6"
|
|
69
|
+
rotation="90 0 0"
|
|
70
|
+
></a-entity>
|
|
71
|
+
{#if src && imageOk}
|
|
72
|
+
<a-entity bind:this={faceEl} geometry="primitive: circle; radius: {d / 2}; segments: 48" position="0 0 {face}"></a-entity>
|
|
73
|
+
{:else if letters}
|
|
74
|
+
<a-entity
|
|
75
|
+
troika-text="value: {letters.replace(/;/g, ',')}; fontSize: {d * tk.initials}; color: {c.text}; font: {font3d(t)}; anchor: center; baseline: center"
|
|
76
|
+
troika-text-material="shader: standard"
|
|
77
|
+
position="0 0 {face}"
|
|
78
|
+
></a-entity>
|
|
79
|
+
{/if}
|
|
80
|
+
{#if status}
|
|
81
|
+
<a-entity position="{dotAt} {-dotAt} {face + 0.002}">
|
|
82
|
+
<a-entity geometry="primitive: circle; radius: {dotR + m(tk.statusRing)}; segments: 32" material="color: {c.statusRing}; shader: flat"></a-entity>
|
|
83
|
+
<a-entity geometry="primitive: circle; radius: {dotR}; segments: 32" material="color: {c.status[status]}; shader: flat" position="0 0 0.001"></a-entity>
|
|
84
|
+
</a-entity>
|
|
85
|
+
{/if}
|
|
86
|
+
</a-entity>
|
|
87
|
+
</a-entity>
|
|
88
|
+
{:else}
|
|
89
|
+
<span
|
|
90
|
+
class="ui-avatar"
|
|
91
|
+
role="img"
|
|
92
|
+
aria-label={label}
|
|
93
|
+
style="--ui-size: {px}px; --ui-fill: {fill}; --ui-text: {c.text}; --ui-font: 600 {Math.round(px * tk.initials)}px/1 {t.typography.fontFamily}; --ui-dot: {dot}px; --ui-ring: {tk.statusRing}px; --ui-ring-color: {c.statusRing}; {userStyle}"
|
|
94
|
+
{...rest}
|
|
95
|
+
>
|
|
96
|
+
{#if src && imageOk}
|
|
97
|
+
<img {src} alt="" />
|
|
98
|
+
{:else}
|
|
99
|
+
<span aria-hidden="true">{letters}</span>
|
|
100
|
+
{/if}
|
|
101
|
+
{#if status}<span class="status" style="background: {c.status[status]};"></span>{/if}
|
|
102
|
+
</span>
|
|
103
|
+
{/if}
|
|
104
|
+
|
|
105
|
+
<style>
|
|
106
|
+
.ui-avatar {
|
|
107
|
+
position: relative;
|
|
108
|
+
display: inline-flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
justify-content: center;
|
|
111
|
+
flex: none;
|
|
112
|
+
width: var(--ui-size);
|
|
113
|
+
height: var(--ui-size);
|
|
114
|
+
border-radius: 50%;
|
|
115
|
+
background: var(--ui-fill);
|
|
116
|
+
color: var(--ui-text);
|
|
117
|
+
font: var(--ui-font);
|
|
118
|
+
user-select: none;
|
|
119
|
+
}
|
|
120
|
+
img {
|
|
121
|
+
width: 100%;
|
|
122
|
+
height: 100%;
|
|
123
|
+
border-radius: 50%;
|
|
124
|
+
object-fit: cover;
|
|
125
|
+
}
|
|
126
|
+
.status {
|
|
127
|
+
position: absolute;
|
|
128
|
+
/* Centered on the rim at the bottom right (45°). */
|
|
129
|
+
right: calc(var(--ui-size) * 0.1464 - var(--ui-dot) / 2);
|
|
130
|
+
bottom: calc(var(--ui-size) * 0.1464 - var(--ui-dot) / 2);
|
|
131
|
+
width: var(--ui-dot);
|
|
132
|
+
height: var(--ui-dot);
|
|
133
|
+
border-radius: 50%;
|
|
134
|
+
box-shadow: 0 0 0 var(--ui-ring) var(--ui-ring-color);
|
|
135
|
+
}
|
|
136
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AvatarSize, AvatarStatus, Dimension } from './theme';
|
|
2
|
+
type Props = {
|
|
3
|
+
name?: string;
|
|
4
|
+
src?: string;
|
|
5
|
+
size?: AvatarSize;
|
|
6
|
+
status?: AvatarStatus;
|
|
7
|
+
color?: string;
|
|
8
|
+
dimension?: Dimension;
|
|
9
|
+
[attribute: string]: unknown;
|
|
10
|
+
};
|
|
11
|
+
declare const Avatar: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type Avatar = ReturnType<typeof Avatar>;
|
|
13
|
+
export default Avatar;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script lang="ts">// Base UI-style button. Renders HTML, or A-Frame entities inside <Space3D>
|
|
2
|
+
// (or with dimension="3d"). Inside a ButtonGroup, unset kind/size/shape/disabled
|
|
3
|
+
// come from the group, and clicks drive the group's selection.
|
|
4
|
+
import Button2D from "./Button2D.svelte";
|
|
5
|
+
import Button3D from "./Button3D.svelte";
|
|
6
|
+
import { getButtonGroup, getDimension } from "./context";
|
|
7
|
+
let { dimension, kind, size, shape, disabled, isSelected, onclick, ...rest } = $props();
|
|
8
|
+
const contextDimension = getDimension();
|
|
9
|
+
const group = getButtonGroup();
|
|
10
|
+
const index = group?.register() ?? -1;
|
|
11
|
+
const resolved = $derived({
|
|
12
|
+
kind: kind ?? group?.kind,
|
|
13
|
+
size: size ?? group?.size,
|
|
14
|
+
shape: shape ?? group?.shape,
|
|
15
|
+
disabled: disabled ?? group?.disabled ?? false,
|
|
16
|
+
isSelected: isSelected ?? group?.isSelected(index)
|
|
17
|
+
});
|
|
18
|
+
function handleClick(event) {
|
|
19
|
+
if (resolved.disabled) return;
|
|
20
|
+
group?.select(index);
|
|
21
|
+
onclick?.(event);
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
{#if (dimension ?? contextDimension) === '3d'}
|
|
26
|
+
<Button3D {...rest} {...resolved} onclick={handleClick} />
|
|
27
|
+
{:else}
|
|
28
|
+
<Button2D {...rest} {...resolved} onclick={handleClick} />
|
|
29
|
+
{/if}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts">import { resolveButtonStyle } from "./button-style";
|
|
2
|
+
import { getTheme } from "./context";
|
|
3
|
+
let { label = "", children, kind, size, shape, isSelected, disabled = false, inert = false, onclick, class: className = "", style: userStyle = "", ...rest } = $props();
|
|
4
|
+
const theme = getTheme();
|
|
5
|
+
const t = $derived(theme());
|
|
6
|
+
const s = $derived(resolveButtonStyle(t, {
|
|
7
|
+
kind,
|
|
8
|
+
size,
|
|
9
|
+
shape,
|
|
10
|
+
selected: isSelected,
|
|
11
|
+
disabled
|
|
12
|
+
}));
|
|
13
|
+
const vars = $derived([
|
|
14
|
+
`--ui-height: ${s.height}px`,
|
|
15
|
+
`--ui-padding-x: ${s.paddingX}px`,
|
|
16
|
+
`--ui-radius: ${s.radius}px`,
|
|
17
|
+
`--ui-fill: ${s.fill}`,
|
|
18
|
+
`--ui-hover: ${s.hoverFill}`,
|
|
19
|
+
`--ui-active: ${s.activeFill}`,
|
|
20
|
+
`--ui-text: ${s.text}`,
|
|
21
|
+
`--ui-focus: ${t.colors.accent}`,
|
|
22
|
+
`--ui-font: ${s.font.fontWeight} ${s.font.fontSize}px/${s.font.lineHeight}px ${t.typography.fontFamily}`,
|
|
23
|
+
`--ui-duration: ${t.animation.timing200}ms`,
|
|
24
|
+
`--ui-easing: ${t.animation.easeOutCurve}`
|
|
25
|
+
].join("; "));
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
class="ui-button {className}"
|
|
31
|
+
class:square={s.square}
|
|
32
|
+
style="{vars}; {userStyle}"
|
|
33
|
+
aria-pressed={isSelected}
|
|
34
|
+
{disabled}
|
|
35
|
+
{inert}
|
|
36
|
+
{onclick}
|
|
37
|
+
{...rest}
|
|
38
|
+
>
|
|
39
|
+
{#if children}{@render children()}{:else}{label}{/if}
|
|
40
|
+
</button>
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
.ui-button {
|
|
44
|
+
display: inline-flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
justify-content: center;
|
|
47
|
+
gap: 8px;
|
|
48
|
+
box-sizing: border-box;
|
|
49
|
+
height: var(--ui-height);
|
|
50
|
+
min-width: var(--ui-height);
|
|
51
|
+
padding: 0 var(--ui-padding-x);
|
|
52
|
+
border: 0;
|
|
53
|
+
border-radius: var(--ui-radius);
|
|
54
|
+
background: var(--ui-fill);
|
|
55
|
+
color: var(--ui-text);
|
|
56
|
+
font: var(--ui-font);
|
|
57
|
+
white-space: nowrap;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
transition: background-color var(--ui-duration) var(--ui-easing);
|
|
60
|
+
}
|
|
61
|
+
.square {
|
|
62
|
+
width: var(--ui-height);
|
|
63
|
+
}
|
|
64
|
+
.ui-button:hover:not(:disabled) {
|
|
65
|
+
background: var(--ui-hover);
|
|
66
|
+
}
|
|
67
|
+
.ui-button:active:not(:disabled) {
|
|
68
|
+
background: var(--ui-active);
|
|
69
|
+
}
|
|
70
|
+
.ui-button:focus-visible {
|
|
71
|
+
outline: 3px solid var(--ui-focus);
|
|
72
|
+
outline-offset: 2px;
|
|
73
|
+
}
|
|
74
|
+
.ui-button:disabled {
|
|
75
|
+
cursor: not-allowed;
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">// A-Frame rendering of Button: a `rounded` slab plus a troika label, centered on
|
|
2
|
+
// the entity's position and sized to fit the label. Clicked through the A-Frame
|
|
3
|
+
// cursor: the slab carries `.raycastable` (the raycaster only tests meshes set
|
|
4
|
+
// directly on matching entities), and its events bubble up to the root.
|
|
5
|
+
import { ensure3d } from "./aframe";
|
|
6
|
+
import { watchTextSize } from "./aframe/measure";
|
|
7
|
+
import { resolveButtonStyle } from "./button-style";
|
|
8
|
+
import { getTheme } from "./context";
|
|
9
|
+
import { font3d } from "./fonts";
|
|
10
|
+
import { useSlot } from "./layout.svelte";
|
|
11
|
+
let { label = "", children: _children, kind, size, shape, isSelected, disabled = false, inert = false, onclick, class: className = "", ...rest } = $props();
|
|
12
|
+
// Registers `rounded` before the entities below are created.
|
|
13
|
+
ensure3d();
|
|
14
|
+
const theme = getTheme();
|
|
15
|
+
const t = $derived(theme());
|
|
16
|
+
const s = $derived(resolveButtonStyle(t, {
|
|
17
|
+
kind,
|
|
18
|
+
size,
|
|
19
|
+
shape,
|
|
20
|
+
selected: isSelected,
|
|
21
|
+
disabled
|
|
22
|
+
}));
|
|
23
|
+
const m = (px) => px * t.space3d.metersPerPx;
|
|
24
|
+
let hovered = $state(false);
|
|
25
|
+
let pressed = $state(false);
|
|
26
|
+
let textWidth = $state(0);
|
|
27
|
+
let labelEl = $state();
|
|
28
|
+
const interactive = $derived(!disabled && !inert);
|
|
29
|
+
const height = $derived(m(s.height));
|
|
30
|
+
const width = $derived(s.square ? height : Math.max(height, textWidth + 2 * m(s.paddingX)));
|
|
31
|
+
const depth = $derived(t.space3d.depth);
|
|
32
|
+
// Position within a ButtonGroup / Panel, if in one.
|
|
33
|
+
const slot = useSlot(() => ({
|
|
34
|
+
width,
|
|
35
|
+
height
|
|
36
|
+
}));
|
|
37
|
+
// Losing interactivity mid-hover never gets a mouseleave, so reset here.
|
|
38
|
+
$effect(() => {
|
|
39
|
+
if (!interactive) hovered = pressed = false;
|
|
40
|
+
});
|
|
41
|
+
const slab = $derived.by(() => {
|
|
42
|
+
const glass = s.material === "glass";
|
|
43
|
+
// Tertiary has no slab until hovered.
|
|
44
|
+
const hidden = s.material === "none" && !hovered && !pressed;
|
|
45
|
+
const fill = pressed ? s.activeFill : hovered || hidden ? s.hoverFill : s.fill;
|
|
46
|
+
return [
|
|
47
|
+
`width: ${width}`,
|
|
48
|
+
`height: ${height}`,
|
|
49
|
+
`depth: ${depth}`,
|
|
50
|
+
`radius: ${m(s.radius)}`,
|
|
51
|
+
`glass: ${glass}`,
|
|
52
|
+
`color: ${glass ? "#FFFFFF" : fill}`,
|
|
53
|
+
`opacity: ${s.material === "none" ? .8 : 1}`,
|
|
54
|
+
`enabled: ${!hidden}`
|
|
55
|
+
].join("; ");
|
|
56
|
+
});
|
|
57
|
+
const text = $derived([
|
|
58
|
+
`value: ${label.replace(/;/g, ",")}`,
|
|
59
|
+
`fontSize: ${m(s.font.fontSize) * t.space3d.textScale}`,
|
|
60
|
+
`color: ${s.text3d}`,
|
|
61
|
+
`font: ${font3d(t)}`,
|
|
62
|
+
"anchor: center",
|
|
63
|
+
"baseline: center"
|
|
64
|
+
].join("; "));
|
|
65
|
+
const scale = $derived(hovered && interactive ? t.space3d.hoverScale : 1);
|
|
66
|
+
const motion = $derived(`dur: ${t.animation.timing100}; easing: ${t.animation.easeOut3d}`);
|
|
67
|
+
// Measure the label once troika has laid it out, to size the slab.
|
|
68
|
+
$effect(() => {
|
|
69
|
+
if (labelEl) return watchTextSize(labelEl, (w) => textWidth = w);
|
|
70
|
+
});
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<!-- 3D button clicked via the A-Frame cursor, not a DOM control -->
|
|
74
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_mouse_events_have_key_events -->
|
|
75
|
+
<a-entity
|
|
76
|
+
{...rest}
|
|
77
|
+
class={className}
|
|
78
|
+
onclick={(event: Event) => interactive && onclick?.(event)}
|
|
79
|
+
onmouseenter={() => interactive && (hovered = true)}
|
|
80
|
+
onmouseleave={() => (hovered = pressed = false)}
|
|
81
|
+
onmousedown={() => interactive && (pressed = true)}
|
|
82
|
+
onmouseup={() => (pressed = false)}
|
|
83
|
+
>
|
|
84
|
+
<a-entity position="{slot().x} {slot().y} 0">
|
|
85
|
+
<a-entity
|
|
86
|
+
animation__uiscale="property: scale; to: {scale} {scale} 1; {motion}"
|
|
87
|
+
animation__uipress="property: object3D.position.z; to: {pressed ? -t.space3d.pressDepth : 0}; {motion}"
|
|
88
|
+
>
|
|
89
|
+
<a-entity class={interactive ? 'raycastable' : ''} rounded={slab} position="{-width / 2} {-height / 2} 0"></a-entity>
|
|
90
|
+
<a-entity
|
|
91
|
+
bind:this={labelEl}
|
|
92
|
+
troika-text={text}
|
|
93
|
+
troika-text-material="shader: standard"
|
|
94
|
+
position="0 0 {depth + 0.002}"
|
|
95
|
+
></a-entity>
|
|
96
|
+
</a-entity>
|
|
97
|
+
</a-entity>
|
|
98
|
+
</a-entity>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script lang="ts">import { getDimension, getTheme, setButtonGroup, setDimension } from "./context";
|
|
2
|
+
import { createStack, setStack, useSlot } from "./layout.svelte";
|
|
3
|
+
const theme = getTheme();
|
|
4
|
+
let { children, kind, size, shape, disabled = false, mode, selected = $bindable(), gap = theme().sizing.scale300, dimension, style: userStyle = "", ...rest } = $props();
|
|
5
|
+
const contextDimension = getDimension();
|
|
6
|
+
// So the buttons inside follow an explicit `dimension` on the group. Context is set
|
|
7
|
+
// once, so changing `dimension` later has no effect.
|
|
8
|
+
// svelte-ignore state_referenced_locally
|
|
9
|
+
if (dimension) setDimension(dimension);
|
|
10
|
+
let count = 0;
|
|
11
|
+
// 3D layout. Order matters: join the parent's stack before providing our own.
|
|
12
|
+
// svelte-ignore state_referenced_locally
|
|
13
|
+
const is3d = (dimension ?? contextDimension) === "3d";
|
|
14
|
+
const row = is3d ? createStack(() => ({
|
|
15
|
+
direction: "row",
|
|
16
|
+
gap: gap * theme().space3d.metersPerPx
|
|
17
|
+
})) : undefined;
|
|
18
|
+
const slot = is3d ? useSlot(() => row.size) : () => ({
|
|
19
|
+
x: 0,
|
|
20
|
+
y: 0
|
|
21
|
+
});
|
|
22
|
+
setStack(row);
|
|
23
|
+
setButtonGroup({
|
|
24
|
+
get kind() {
|
|
25
|
+
return kind;
|
|
26
|
+
},
|
|
27
|
+
get size() {
|
|
28
|
+
return size;
|
|
29
|
+
},
|
|
30
|
+
get shape() {
|
|
31
|
+
return shape;
|
|
32
|
+
},
|
|
33
|
+
get disabled() {
|
|
34
|
+
return disabled;
|
|
35
|
+
},
|
|
36
|
+
register: () => count++,
|
|
37
|
+
isSelected(index) {
|
|
38
|
+
if (!mode) return undefined;
|
|
39
|
+
return Array.isArray(selected) ? selected.includes(index) : selected === index;
|
|
40
|
+
},
|
|
41
|
+
select(index) {
|
|
42
|
+
if (mode === "radio") selected = index;
|
|
43
|
+
if (mode === "checkbox") {
|
|
44
|
+
const current = Array.isArray(selected) ? selected : [];
|
|
45
|
+
selected = current.includes(index) ? current.filter((i) => i !== index) : [...current, index];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
{#if (dimension ?? contextDimension) === '3d'}
|
|
52
|
+
<a-entity {...rest}>
|
|
53
|
+
<a-entity position="{slot().x} {slot().y} 0">{@render children()}</a-entity>
|
|
54
|
+
</a-entity>
|
|
55
|
+
{:else}
|
|
56
|
+
<div role="group" style="display: inline-flex; gap: {gap}px; {userStyle}" {...rest}>
|
|
57
|
+
{@render children()}
|
|
58
|
+
</div>
|
|
59
|
+
{/if}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { ButtonKind, ButtonShape, ButtonSize, Dimension } from './theme';
|
|
3
|
+
type Props = {
|
|
4
|
+
children: Snippet;
|
|
5
|
+
kind?: ButtonKind;
|
|
6
|
+
size?: ButtonSize;
|
|
7
|
+
shape?: ButtonShape;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
mode?: 'radio' | 'checkbox';
|
|
10
|
+
selected?: number | number[];
|
|
11
|
+
gap?: number;
|
|
12
|
+
dimension?: Dimension;
|
|
13
|
+
[attribute: string]: unknown;
|
|
14
|
+
};
|
|
15
|
+
declare const ButtonGroup: import("svelte").Component<Props, {}, "selected">;
|
|
16
|
+
type ButtonGroup = ReturnType<typeof ButtonGroup>;
|
|
17
|
+
export default ButtonGroup;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<script lang="ts">// Base UI-style Checkbox, as a box or a toggle switch (`type="toggle"`).
|
|
2
|
+
// 2D wraps a native <input type="checkbox">, so keyboard and screen readers work.
|
|
3
|
+
// 3D: the whole row (control + label) is one click target for the A-Frame cursor.
|
|
4
|
+
import { untrack } from "svelte";
|
|
5
|
+
import { ensure3d } from "./aframe";
|
|
6
|
+
import { watchTextSize } from "./aframe/measure";
|
|
7
|
+
import { resolveCheckboxStyle } from "./component-style";
|
|
8
|
+
import { getDimension, getTheme } from "./context";
|
|
9
|
+
import { font3d } from "./fonts";
|
|
10
|
+
import { useSlot } from "./layout.svelte";
|
|
11
|
+
let { checked = $bindable(false), label = "", type = "checkbox", disabled = false, onchange, dimension, style: userStyle = "", ...rest } = $props();
|
|
12
|
+
const theme = getTheme();
|
|
13
|
+
const t = $derived(theme());
|
|
14
|
+
const s = $derived(resolveCheckboxStyle(t, {
|
|
15
|
+
type,
|
|
16
|
+
checked,
|
|
17
|
+
disabled
|
|
18
|
+
}));
|
|
19
|
+
const toggle = $derived(type === "toggle");
|
|
20
|
+
const contextDimension = getDimension();
|
|
21
|
+
// svelte-ignore state_referenced_locally
|
|
22
|
+
const is3d = (dimension ?? contextDimension) === "3d";
|
|
23
|
+
// ---- 3D ----
|
|
24
|
+
const m = (px) => px * t.space3d.metersPerPx;
|
|
25
|
+
if (is3d) ensure3d();
|
|
26
|
+
let hovered = $state(false);
|
|
27
|
+
let labelSize = $state({
|
|
28
|
+
width: 0,
|
|
29
|
+
height: 0
|
|
30
|
+
});
|
|
31
|
+
let labelEl = $state();
|
|
32
|
+
const control = $derived({
|
|
33
|
+
width: m(s.width),
|
|
34
|
+
height: m(s.height)
|
|
35
|
+
});
|
|
36
|
+
const gap = $derived(label ? m(t.checkbox.gap) : 0);
|
|
37
|
+
const size = $derived({
|
|
38
|
+
width: control.width + gap + labelSize.width,
|
|
39
|
+
height: Math.max(control.height, labelSize.height)
|
|
40
|
+
});
|
|
41
|
+
const slot = is3d ? useSlot(() => size) : () => ({
|
|
42
|
+
x: 0,
|
|
43
|
+
y: 0
|
|
44
|
+
});
|
|
45
|
+
const controlX = $derived(-size.width / 2 + control.width / 2);
|
|
46
|
+
const depth = .02;
|
|
47
|
+
const knobX = $derived(checked ? control.width / 2 - control.height / 2 : -control.width / 2 + control.height / 2);
|
|
48
|
+
// The knob's x is driven by its animation; the position attribute only sets where it starts.
|
|
49
|
+
const startKnobX = untrack(() => knobX);
|
|
50
|
+
const scale = $derived(hovered && !disabled ? t.space3d.hoverScale : 1);
|
|
51
|
+
const motion = $derived(`dur: ${t.animation.timing200}; easing: ${t.animation.easeOut3d}`);
|
|
52
|
+
$effect(() => {
|
|
53
|
+
if (labelEl) return watchTextSize(labelEl, (width, height) => labelSize = {
|
|
54
|
+
width,
|
|
55
|
+
height
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
function toggleChecked() {
|
|
59
|
+
if (disabled) return;
|
|
60
|
+
checked = !checked;
|
|
61
|
+
onchange?.(checked);
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
{#if is3d}
|
|
66
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_mouse_events_have_key_events -->
|
|
67
|
+
<a-entity
|
|
68
|
+
{...rest}
|
|
69
|
+
onclick={toggleChecked}
|
|
70
|
+
onmouseenter={() => (hovered = true)}
|
|
71
|
+
onmouseleave={() => (hovered = false)}
|
|
72
|
+
>
|
|
73
|
+
<a-entity position="{slot().x} {slot().y} 0">
|
|
74
|
+
<!-- Invisible hit area over control + label (the raycaster ignores `visible`). -->
|
|
75
|
+
<a-entity
|
|
76
|
+
class={disabled ? '' : 'raycastable'}
|
|
77
|
+
geometry="primitive: plane; width: {size.width}; height: {size.height}"
|
|
78
|
+
material="visible: false"
|
|
79
|
+
position="0 0 {depth}"
|
|
80
|
+
></a-entity>
|
|
81
|
+
<a-entity position="{controlX} 0 0" animation__uiscale="property: scale; to: {scale} {scale} 1; {motion}">
|
|
82
|
+
<a-entity
|
|
83
|
+
rounded="width: {control.width}; height: {control.height}; depth: {depth}; radius: {m(s.radius)}; color: {s.fill3d}"
|
|
84
|
+
position="{-control.width / 2} {-control.height / 2} 0"
|
|
85
|
+
></a-entity>
|
|
86
|
+
{#if toggle}
|
|
87
|
+
<a-entity
|
|
88
|
+
geometry="primitive: cylinder; radius: {m(s.knob) / 2}; height: 0.008; segmentsRadial: 32"
|
|
89
|
+
material="color: {s.knobColor}; roughness: 0.4"
|
|
90
|
+
rotation="90 0 0"
|
|
91
|
+
position="{startKnobX} 0 {depth + t.space3d.raise / 2}"
|
|
92
|
+
animation__knob="property: object3D.position.x; to: {knobX}; {motion}"
|
|
93
|
+
></a-entity>
|
|
94
|
+
{:else}
|
|
95
|
+
<a-entity
|
|
96
|
+
visible={checked}
|
|
97
|
+
troika-text="value: ✓; fontSize: {control.height * 0.95}; color: {s.mark}; font: {font3d(t)}; anchor: center; baseline: center"
|
|
98
|
+
troika-text-material="shader: standard"
|
|
99
|
+
position="0 0 {depth + 0.002}"
|
|
100
|
+
></a-entity>
|
|
101
|
+
{/if}
|
|
102
|
+
</a-entity>
|
|
103
|
+
{#if label}
|
|
104
|
+
<a-entity
|
|
105
|
+
bind:this={labelEl}
|
|
106
|
+
troika-text="value: {label.replace(/;/g, ',')}; fontSize: {m(s.font.fontSize) * t.space3d.textScale}; color: {s.labelColor}; font: {font3d(t)}; anchor: left; baseline: center"
|
|
107
|
+
troika-text-material="shader: standard"
|
|
108
|
+
position="{-size.width / 2 + control.width + gap} 0 0.002"
|
|
109
|
+
></a-entity>
|
|
110
|
+
{/if}
|
|
111
|
+
</a-entity>
|
|
112
|
+
</a-entity>
|
|
113
|
+
{:else}
|
|
114
|
+
<label
|
|
115
|
+
class="ui-checkbox"
|
|
116
|
+
class:disabled
|
|
117
|
+
class:checked
|
|
118
|
+
style="--ui-width: {s.width}px; --ui-height: {s.height}px; --ui-radius: {s.radius}px; --ui-fill: {s.fill}; --ui-border: {s.border}; --ui-mark: {s.mark}; --ui-knob: {s.knob}px; --ui-knob-color: {s.knobColor}; --ui-label: {s.labelColor}; --ui-gap: {t.checkbox.gap}px; --ui-focus: {t.colors.accent}; --ui-font: {s.font.fontWeight} {s.font.fontSize}px/{s.font.lineHeight}px {t.typography.fontFamily}; --ui-duration: {t.animation.timing200}ms; {userStyle}"
|
|
119
|
+
{...rest}
|
|
120
|
+
>
|
|
121
|
+
<input
|
|
122
|
+
type="checkbox"
|
|
123
|
+
role={toggle ? 'switch' : undefined}
|
|
124
|
+
bind:checked
|
|
125
|
+
{disabled}
|
|
126
|
+
onchange={() => onchange?.(checked)}
|
|
127
|
+
/>
|
|
128
|
+
{#if toggle}
|
|
129
|
+
<span class="control track"><span class="knob"></span></span>
|
|
130
|
+
{:else}
|
|
131
|
+
<span class="control box">
|
|
132
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7" /></svg>
|
|
133
|
+
</span>
|
|
134
|
+
{/if}
|
|
135
|
+
{#if label}<span class="label">{label}</span>{/if}
|
|
136
|
+
</label>
|
|
137
|
+
{/if}
|
|
138
|
+
|
|
139
|
+
<style>
|
|
140
|
+
.ui-checkbox {
|
|
141
|
+
position: relative;
|
|
142
|
+
display: inline-flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
gap: var(--ui-gap);
|
|
145
|
+
font: var(--ui-font);
|
|
146
|
+
color: var(--ui-label);
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
user-select: none;
|
|
149
|
+
}
|
|
150
|
+
.ui-checkbox.disabled {
|
|
151
|
+
cursor: not-allowed;
|
|
152
|
+
}
|
|
153
|
+
input {
|
|
154
|
+
position: absolute;
|
|
155
|
+
opacity: 0;
|
|
156
|
+
width: 1px;
|
|
157
|
+
height: 1px;
|
|
158
|
+
margin: 0;
|
|
159
|
+
pointer-events: none;
|
|
160
|
+
}
|
|
161
|
+
.control {
|
|
162
|
+
position: relative;
|
|
163
|
+
box-sizing: border-box;
|
|
164
|
+
flex: none;
|
|
165
|
+
width: var(--ui-width);
|
|
166
|
+
height: var(--ui-height);
|
|
167
|
+
border-radius: var(--ui-radius);
|
|
168
|
+
background: var(--ui-fill);
|
|
169
|
+
border: 2px solid var(--ui-border);
|
|
170
|
+
transition: background-color var(--ui-duration), border-color var(--ui-duration);
|
|
171
|
+
}
|
|
172
|
+
input:focus-visible + .control {
|
|
173
|
+
outline: 3px solid var(--ui-focus);
|
|
174
|
+
outline-offset: 2px;
|
|
175
|
+
}
|
|
176
|
+
.box svg {
|
|
177
|
+
position: absolute;
|
|
178
|
+
inset: 0;
|
|
179
|
+
width: 100%;
|
|
180
|
+
height: 100%;
|
|
181
|
+
fill: none;
|
|
182
|
+
stroke: var(--ui-mark);
|
|
183
|
+
stroke-width: 2;
|
|
184
|
+
stroke-linecap: round;
|
|
185
|
+
stroke-linejoin: round;
|
|
186
|
+
opacity: 0;
|
|
187
|
+
transition: opacity var(--ui-duration);
|
|
188
|
+
}
|
|
189
|
+
.checked .box svg {
|
|
190
|
+
opacity: 1;
|
|
191
|
+
}
|
|
192
|
+
.track {
|
|
193
|
+
border: 0;
|
|
194
|
+
}
|
|
195
|
+
.knob {
|
|
196
|
+
position: absolute;
|
|
197
|
+
top: 50%;
|
|
198
|
+
left: calc((var(--ui-height) - var(--ui-knob)) / 2);
|
|
199
|
+
width: var(--ui-knob);
|
|
200
|
+
height: var(--ui-knob);
|
|
201
|
+
border-radius: 50%;
|
|
202
|
+
background: var(--ui-knob-color);
|
|
203
|
+
transform: translateY(-50%);
|
|
204
|
+
transition: transform var(--ui-duration);
|
|
205
|
+
}
|
|
206
|
+
.checked .knob {
|
|
207
|
+
transform: translate(calc(var(--ui-width) - var(--ui-height)), -50%);
|
|
208
|
+
}
|
|
209
|
+
</style>
|