@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
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,4 @@
1
+ import type { ButtonProps } from './types';
2
+ declare const Button: import("svelte").Component<ButtonProps, {}, "">;
3
+ type Button = ReturnType<typeof Button>;
4
+ export default Button;
@@ -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,4 @@
1
+ import type { ButtonProps } from './types';
2
+ declare const Button2D: import("svelte").Component<ButtonProps, {}, "">;
3
+ type Button2D = ReturnType<typeof Button2D>;
4
+ export default Button2D;
@@ -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,4 @@
1
+ import type { ButtonProps } from './types';
2
+ declare const Button3D: import("svelte").Component<ButtonProps, {}, "">;
3
+ type Button3D = ReturnType<typeof Button3D>;
4
+ export default Button3D;
@@ -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>