@threadlabs/looma 0.33.1 → 0.34.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 (184) hide show
  1. package/components/shared/overlay.js +8 -2
  2. package/components/shared/surface-material.js +21 -0
  3. package/components/ui-button/ui-button.html +63 -47
  4. package/components/ui-card/ui-card.html +44 -2
  5. package/components/ui-curve-editor/curve.js +10 -0
  6. package/components/ui-curve-editor/ui-curve-editor.html +181 -0
  7. package/components/ui-curve-editor/ui-curve-editor.js +51 -0
  8. package/components/ui-dialog/ui-dialog.html +62 -1
  9. package/components/ui-dialog/ui-dialog.js +9 -1
  10. package/components/ui-icon-button/ui-icon-button.html +75 -23
  11. package/components/ui-menu/ui-menu.html +60 -0
  12. package/components/ui-menu/ui-menu.js +1 -1
  13. package/components/ui-number-input/arithmetic.js +47 -0
  14. package/components/ui-number-input/ui-number-input.html +62 -0
  15. package/components/ui-number-input/ui-number-input.js +44 -0
  16. package/components/ui-popover/ui-popover.html +64 -1
  17. package/components/ui-popover/ui-popover.js +1 -1
  18. package/components/ui-slider/ui-slider.html +84 -0
  19. package/components/ui-slider/ui-slider.js +29 -0
  20. package/components/ui-tabs/ui-tabs.html +6 -1
  21. package/components/ui-text/ui-text.html +31 -6
  22. package/components/ui-tooltip/ui-tooltip.html +60 -0
  23. package/components/ui-tooltip/ui-tooltip.js +1 -1
  24. package/dist/index.js +46 -43
  25. package/docs/README.md +34 -4
  26. package/docs/card.html +16 -2
  27. package/docs/card.js +17 -2
  28. package/docs/catalog.json +1154 -144
  29. package/docs/customizer-render.mjs +35 -9
  30. package/docs/customizer.js +83 -20
  31. package/docs/documentation.css +9 -1
  32. package/docs/index.d.ts +4 -2
  33. package/docs/index.mjs +1 -1
  34. package/docs/preview.js +2 -0
  35. package/docs/render.mjs +15 -6
  36. package/docs/site.mjs +50 -9
  37. package/docs/theme-session.mjs +51 -0
  38. package/docs/theme.mjs +134 -15
  39. package/docs/value-editors.mjs +33 -0
  40. package/package.json +2 -2
  41. package/styles/ui-button.css +45 -45
  42. package/styles/ui-card.css +47 -0
  43. package/styles/ui-curve-editor.css +109 -0
  44. package/styles/ui-dialog.css +132 -0
  45. package/styles/ui-icon-button.css +63 -20
  46. package/styles/ui-menu.css +72 -0
  47. package/styles/ui-number-input.css +34 -0
  48. package/styles/ui-popover.css +74 -0
  49. package/styles/ui-slider.css +53 -0
  50. package/styles/ui-tabs.css +6 -1
  51. package/styles/ui-text.css +23 -1
  52. package/styles/ui-tooltip.css +72 -0
  53. package/theme-system.css +26 -10
  54. package/tokens.css +8 -2
  55. package/vanilla/UiButton.d.ts +1 -0
  56. package/vanilla/UiButton.js +195 -130
  57. package/vanilla/UiCard.d.ts +1 -1
  58. package/vanilla/UiCard.js +1 -1
  59. package/vanilla/UiCurveEditor.d.ts +28 -0
  60. package/vanilla/UiCurveEditor.js +134 -0
  61. package/vanilla/UiIconButton.d.ts +2 -1
  62. package/vanilla/UiIconButton.js +58 -31
  63. package/vanilla/UiNumberInput.d.ts +33 -0
  64. package/vanilla/UiNumberInput.js +79 -0
  65. package/vanilla/UiSlider.d.ts +33 -0
  66. package/vanilla/UiSlider.js +125 -0
  67. package/vanilla/UiText.d.ts +2 -2
  68. package/vanilla/UiText.js +45 -11
  69. package/vanilla/index.js +3 -0
  70. package/vue/UiButton.d.ts +6 -0
  71. package/vue/UiButton.vue +155 -56
  72. package/vue/UiCard.d.ts +2 -2
  73. package/vue/UiCard.vue +55 -5
  74. package/vue/UiCurveEditor.d.ts +53 -0
  75. package/vue/UiCurveEditor.js +2 -0
  76. package/vue/UiCurveEditor.vue +427 -0
  77. package/vue/UiDialog.vue +177 -1
  78. package/vue/UiIconButton.d.ts +8 -2
  79. package/vue/UiIconButton.vue +164 -34
  80. package/vue/UiMenu.vue +89 -0
  81. package/vue/UiNumberInput.d.ts +83 -0
  82. package/vue/UiNumberInput.js +2 -0
  83. package/vue/UiNumberInput.vue +370 -0
  84. package/vue/UiPopover.vue +92 -1
  85. package/vue/UiSlider.d.ts +83 -0
  86. package/vue/UiSlider.js +2 -0
  87. package/vue/UiSlider.vue +412 -0
  88. package/vue/UiTabs.vue +6 -1
  89. package/vue/UiText.d.ts +7 -5
  90. package/vue/UiText.vue +104 -22
  91. package/vue/UiTooltip.vue +89 -0
  92. package/vue/chunks/UiActionBar.js +1 -1
  93. package/vue/chunks/UiAffordanceScope.js +2 -2
  94. package/vue/chunks/UiAvatar.js +2 -2
  95. package/vue/chunks/UiAvatarGroup.js +2 -2
  96. package/vue/chunks/UiBadge.js +2 -2
  97. package/vue/chunks/UiBreadcrumbItem.js +2 -2
  98. package/vue/chunks/UiBreadcrumbs.js +2 -2
  99. package/vue/chunks/UiButton.js +94 -11
  100. package/vue/chunks/UiCallout.js +2 -2
  101. package/vue/chunks/UiCard.js +6 -5
  102. package/vue/chunks/UiCheckbox.js +2 -2
  103. package/vue/chunks/UiCluster.js +2 -2
  104. package/vue/chunks/UiColorPicker.js +2 -2
  105. package/vue/chunks/UiCombobox.js +2 -2
  106. package/vue/chunks/UiContainer.js +2 -2
  107. package/vue/chunks/UiContextMenu.js +2 -2
  108. package/vue/chunks/UiCover.js +2 -2
  109. package/vue/chunks/UiCurveEditor.js +413 -0
  110. package/vue/chunks/UiDescriptionItem.js +2 -2
  111. package/vue/chunks/UiDescriptionList.js +2 -2
  112. package/vue/chunks/UiDialog.js +4 -4
  113. package/vue/chunks/UiDisclosure.js +2 -2
  114. package/vue/chunks/UiEditable.js +2 -2
  115. package/vue/chunks/UiEditorBlockActions.js +2 -2
  116. package/vue/chunks/UiEditorChipEditor.js +2 -2
  117. package/vue/chunks/UiEditorCodeLanguage.js +2 -2
  118. package/vue/chunks/UiEditorFootnoteEditor.js +2 -2
  119. package/vue/chunks/UiEditorImageToolbar.js +3 -3
  120. package/vue/chunks/UiEditorInsertTableGrid.js +2 -2
  121. package/vue/chunks/UiEditorLinkCard.js +3 -3
  122. package/vue/chunks/UiEditorLinkForm.js +3 -3
  123. package/vue/chunks/UiEditorMentionMenu.js +2 -2
  124. package/vue/chunks/UiEditorMentionMenuItem.js +2 -2
  125. package/vue/chunks/UiEditorSlashMenu.js +2 -2
  126. package/vue/chunks/UiEditorSlashMenuGroup.js +2 -2
  127. package/vue/chunks/UiEditorSlashMenuItem.js +2 -2
  128. package/vue/chunks/UiEditorSurface.js +2 -2
  129. package/vue/chunks/UiEditorTableContextMenu.js +2 -2
  130. package/vue/chunks/UiEditorTableOverlay.js +2 -2
  131. package/vue/chunks/UiEditorTableToolbar.js +2 -2
  132. package/vue/chunks/UiEditorToolbar.js +2 -2
  133. package/vue/chunks/UiFloatingActionButton.js +2 -2
  134. package/vue/chunks/UiFormField.js +2 -2
  135. package/vue/chunks/UiGrid.js +2 -2
  136. package/vue/chunks/UiIcon.js +2 -2
  137. package/vue/chunks/UiIconButton.js +77 -11
  138. package/vue/chunks/UiImage.js +2 -2
  139. package/vue/chunks/UiInput.js +2 -2
  140. package/vue/chunks/UiInputGroup.js +2 -2
  141. package/vue/chunks/UiList.js +2 -2
  142. package/vue/chunks/UiListItem.js +2 -2
  143. package/vue/chunks/UiListbox.js +2 -2
  144. package/vue/chunks/UiMenu.js +3 -3
  145. package/vue/chunks/UiMenuGroup.js +2 -2
  146. package/vue/chunks/UiMenuItem.js +2 -2
  147. package/vue/chunks/UiMeter.js +2 -2
  148. package/vue/chunks/UiNavItem.js +2 -2
  149. package/vue/chunks/UiNumberInput.js +390 -0
  150. package/vue/chunks/UiPageHeader.js +2 -2
  151. package/vue/chunks/UiPopover.js +4 -4
  152. package/vue/chunks/UiRadio.js +2 -2
  153. package/vue/chunks/UiRadioGroup.js +2 -2
  154. package/vue/chunks/UiReel.js +2 -2
  155. package/vue/chunks/UiScrollArea.js +2 -2
  156. package/vue/chunks/UiSearchResultRow.js +2 -2
  157. package/vue/chunks/UiSearchShell.js +2 -2
  158. package/vue/chunks/UiSection.js +2 -2
  159. package/vue/chunks/UiSelect.js +2 -2
  160. package/vue/chunks/UiSeparator.js +2 -2
  161. package/vue/chunks/UiSidebar.js +2 -2
  162. package/vue/chunks/UiSlider.js +396 -0
  163. package/vue/chunks/UiSpinner.js +2 -2
  164. package/vue/chunks/UiStack.js +2 -2
  165. package/vue/chunks/UiStatusMessage.js +2 -2
  166. package/vue/chunks/UiSwatch.js +2 -2
  167. package/vue/chunks/UiSwitch.js +2 -2
  168. package/vue/chunks/UiSwitcher.js +2 -2
  169. package/vue/chunks/UiTable.js +2 -2
  170. package/vue/chunks/UiTabs.js +3 -3
  171. package/vue/chunks/UiText.js +53 -12
  172. package/vue/chunks/UiTextarea.js +2 -2
  173. package/vue/chunks/UiToast.js +2 -2
  174. package/vue/chunks/UiToastRegion.js +2 -2
  175. package/vue/chunks/UiTooltip.js +3 -3
  176. package/vue/chunks/UiTopBar.js +1 -1
  177. package/vue/chunks/UiTree.js +2 -2
  178. package/vue/chunks/UiTreeItem.js +2 -2
  179. package/vue/chunks/_plugin-vue_export-helper.js +68 -2
  180. package/vue/chunks/props.js +24 -1
  181. package/vue/components.css +2215 -1203
  182. package/vue/editor/index.js +623 -211
  183. package/vue/index.d.ts +3 -0
  184. package/vue/index.js +5 -2
@@ -0,0 +1,51 @@
1
+ import { curvePoints, curveText } from "./curve.js";
2
+
3
+ export function connect(host) {
4
+ const { plot, first, second, firstLine, secondLine, curve, result } = host.refs;
5
+ const fields = [...host.element.querySelectorAll("[data-curve-coordinate]")];
6
+ let points = [.25, .1, .25, 1]; let external; let drag;
7
+ const limit = () => { points = curvePoints(curveText(points)); };
8
+ const paint = () => {
9
+ limit();
10
+ const x = index => points[index] * 100;
11
+ const y = index => (2 - points[index]) / 3 * 100;
12
+ for (const [index, handle] of [[0, first], [2, second]]) {
13
+ handle.style.left = `${x(index)}%`; handle.style.top = `${y(index + 1)}%`;
14
+ handle.setAttribute("aria-description", `X ${points[index]}, Y ${points[index + 1]}`);
15
+ }
16
+ firstLine.setAttribute("d", `M0 66.667 L${x(0)} ${y(1)}`);
17
+ secondLine.setAttribute("d", `M100 33.333 L${x(2)} ${y(3)}`);
18
+ curve.setAttribute("d", `M0 66.667 C${x(0)} ${y(1)} ${x(2)} ${y(3)} 100 33.333`);
19
+ fields.forEach((field, index) => { host.state[`point${index}`] = String(points[index]); });
20
+ result.value = curveText(points); result.textContent = curveText(points);
21
+ };
22
+ const emit = type => host.dispatch(type, { value: curveText(points) });
23
+ const move = event => {
24
+ if (!drag || event.pointerId !== drag.id || host.props.disabled.value) return;
25
+ const rect = plot.getBoundingClientRect();
26
+ if (!rect.width || !rect.height) return;
27
+ points[drag.index] = (event.clientX - rect.left) / rect.width;
28
+ points[drag.index + 1] = 2 - (event.clientY - rect.top) / rect.height * 3;
29
+ paint(); emit("input");
30
+ };
31
+ const finish = event => { if (drag?.id !== event.pointerId) return; drag = undefined; if (!host.props.disabled.value) emit("change"); };
32
+ const cleanups = [];
33
+ for (const [index, handle] of [[0, first], [2, second]]) {
34
+ const down = event => { if (host.props.disabled.value || event.button !== 0) return; event.preventDefault(); handle.focus(); drag = { id: event.pointerId, index }; handle.setPointerCapture(event.pointerId); };
35
+ const key = event => {
36
+ if (host.props.disabled.value || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
37
+ event.preventDefault(); const step = event.shiftKey ? .1 : .01;
38
+ points[index + (event.key === "ArrowUp" || event.key === "ArrowDown" ? 1 : 0)] += (event.key === "ArrowLeft" || event.key === "ArrowDown" ? -step : step);
39
+ paint(); emit("input"); emit("change");
40
+ };
41
+ for (const [type, callback] of [["pointerdown", down], ["pointermove", move], ["pointerup", finish], ["pointercancel", finish], ["keydown", key]]) {
42
+ handle.addEventListener(type, callback); cleanups.push(() => handle.removeEventListener(type, callback));
43
+ }
44
+ }
45
+ fields.forEach((field, index) => {
46
+ const edit = event => { event.stopPropagation(); if (host.props.disabled.value) return; points[index] = Number(event.detail.value); paint(); emit(event.type); };
47
+ for (const type of ["input", "change"]) { field.addEventListener(type, edit); cleanups.push(() => field.removeEventListener(type, edit)); }
48
+ });
49
+ const stop = host.effect(() => { const value = host.props.value.value; if (value === external) return; external = value; try { points = curvePoints(value); } catch { /* Keep the last valid curve. */ } paint(); });
50
+ return () => { stop(); cleanups.forEach(cleanup => cleanup()); };
51
+ }
@@ -13,7 +13,8 @@
13
13
  closerequest when modal, none when not.</prop>
14
14
  <prop name="alert" type="boolean" default="false"
15
15
  >Use alert dialog semantics and require an explicit action instead of the header close button. An alert dialog is always modal.</prop>
16
- <prop name="size" type="keyword" values="sm, md, lg, fullscreen" default="md">Choose a compact, standard, wide, or viewport-filling dialog.</prop>
16
+ <prop name="size" type="keyword" values="sm, md, lg, fullscreen" default="md"
17
+ >Choose a compact, standard, wide, or viewport-filling dialog. Shared surface separation adapts to dialog purpose and rendered size.</prop>
17
18
  <prop name="label" type="string">Visible title in the dialog header; also its accessible name.</prop>
18
19
  <prop name="for" type="string" default="">ID of the control that opens the dialog.</prop>
19
20
  <state type="boolean" name="internalOpen" value="false"></state>
@@ -75,6 +76,7 @@
75
76
  }
76
77
 
77
78
  :host {
79
+ --_ui-material-scale: 1;
78
80
  /* Hooks do not inherit, so the parts below read them from the root. */
79
81
  --_ui-dialog-duration: var(--ui-dialog-duration);
80
82
 
@@ -107,6 +109,65 @@
107
109
  overlay var(--ui-dialog-duration, var(--ui-motion-base)) var(--ui-motion-ease) allow-discrete;
108
110
  }
109
111
 
112
+ /* Purpose and measured size shape one shared separation lever. The content plane stays opaque;
113
+ transparency, saturation and edge light belong to the surrounding decorative material. */
114
+ :host {
115
+ --_ui-material-glassiness: clamp(0, var(--ui-glassiness, 0), 1);
116
+ --_ui-material-separation: clamp(0, var(--ui-surface-separation, 0), 1);
117
+ --_ui-material-active: clamp(0, calc((var(--_ui-material-glassiness) + var(--_ui-material-separation)) * 100000), 1);
118
+ --_ui-material-rim: calc((var(--_ui-material-separation) + var(--_ui-material-glassiness) * 0.25) * var(--_ui-material-scale, 1) * 8px);
119
+ --_ui-material-content-color: var(--ui-dialog-surface, var(--ui-material-content, var(--ui-overlay-surface, var(--ui-surface-elevated))));
120
+ --_ui-material-rim-color: color-mix(in srgb, var(--ui-material-glass, var(--ui-surface-elevated)) calc(var(--_ui-material-active) * 100%), transparent);
121
+ background:
122
+ linear-gradient(var(--_ui-material-content-color), var(--_ui-material-content-color)) padding-box,
123
+ linear-gradient(var(--_ui-material-rim-color), var(--_ui-material-rim-color)) border-box,
124
+ var(--_ui-material-content-color) padding-box;
125
+ border-width: calc(var(--ui-border-width) + var(--_ui-material-rim));
126
+ border-color: var(
127
+ --ui-dialog-border,
128
+ color-mix(in oklab, var(--ui-overlay-border, var(--ui-border-strong)) calc((1 - var(--_ui-material-active)) * 100%), transparent)
129
+ );
130
+ box-shadow: var(
131
+ --ui-dialog-shadow,
132
+ var(
133
+ --ui-overlay-shadow,
134
+ 0 calc(var(--_ui-material-separation) * var(--_ui-material-scale, 1) * 8px) calc(
135
+ var(--_ui-material-separation) * var(--_ui-material-scale, 1) * 24px
136
+ ) color-mix(in oklab, var(--ui-text-primary) 15%, transparent),
137
+ inset 0 0 0 calc(var(--_ui-material-active) * var(--ui-border-width)) var(--ui-material-edge, var(--ui-border-strong)),
138
+ var(--ui-elevation-dialog, var(--ui-shadow-xl))
139
+ )
140
+ );
141
+ backdrop-filter: blur(calc(var(--_ui-material-glassiness) * var(--_ui-material-scale, 1) * 18px))
142
+ saturate(calc(1 + var(--_ui-material-glassiness) * var(--_ui-material-scale, 1) * 0.18));
143
+ -webkit-backdrop-filter: blur(calc(var(--_ui-material-glassiness) * var(--_ui-material-scale, 1) * 18px))
144
+ saturate(calc(1 + var(--_ui-material-glassiness) * var(--_ui-material-scale, 1) * 0.18));
145
+ }
146
+
147
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
148
+ :host {
149
+ background: var(--_ui-material-content-color);
150
+ }
151
+ }
152
+
153
+ @media (prefers-reduced-transparency: reduce) {
154
+ :host {
155
+ background: var(--_ui-material-content-color);
156
+ backdrop-filter: none;
157
+ -webkit-backdrop-filter: none;
158
+ }
159
+ }
160
+
161
+ @media (forced-colors: active) {
162
+ :host {
163
+ background: Canvas;
164
+ border-color: CanvasText;
165
+ box-shadow: none;
166
+ backdrop-filter: none;
167
+ -webkit-backdrop-filter: none;
168
+ }
169
+ }
170
+
110
171
  :host[open] {
111
172
  display: flex !important;
112
173
  visibility: visible;
@@ -1,4 +1,5 @@
1
- import { closeDialog, closeOverlay, createIdResolver, openOverlay, requestTopOverlayClose, showDialog } from "../shared/overlay.js";
1
+ import { updateSurfaceMaterial } from "../shared/surface-material.js";
2
+ import { closeDialog, closeOverlay, createIdResolver, openOverlay, observeOverlayViewport, requestTopOverlayClose, showDialog } from "../shared/overlay.js";
2
3
 
3
4
  function inferredLabel(element, explicit) {
4
5
  if (String(explicit ?? "").trim()) return String(explicit).trim();
@@ -18,6 +19,7 @@ function observeContentHeight(dialog, refs) {
18
19
  };
19
20
  const observer = new owner.ResizeObserver(() => {
20
21
  if (!dialog.open) { reset(); return; }
22
+ updateSurfaceMaterial(dialog, "dialog");
21
23
  const previous = animation ? parseFloat(owner.getComputedStyle(dialog).height) : lastHeight;
22
24
  animation?.cancel();
23
25
  animation = null;
@@ -63,6 +65,7 @@ export function connect(host) {
63
65
  let lastFor = "";
64
66
  let lastExternalOpen = Boolean(host.props.open.value);
65
67
  let lastOpen;
68
+ let stopMaterialViewport = null;
66
69
  host.state.internalOpen = lastExternalOpen;
67
70
 
68
71
  const requestClose = (reason, input) => {
@@ -126,9 +129,13 @@ export function connect(host) {
126
129
  dialog.setAttribute("closedby", closedBy);
127
130
  if (open) {
128
131
  showDialog(dialog, modal);
132
+ updateSurfaceMaterial(dialog, "dialog");
133
+ stopMaterialViewport ??= observeOverlayViewport(document, () => updateSurfaceMaterial(dialog, "dialog"));
129
134
  openOverlay({ id: overlayId, modal, element, modalElement: dialog, dismissible: true, canClose: (reason) => reason === "light-dismiss" ? closedBy === "any" : reason !== "escape" || closedBy !== "none", requestClose });
130
135
  } else {
131
136
  resize.reset();
137
+ stopMaterialViewport?.();
138
+ stopMaterialViewport = null;
132
139
  closeDialog(dialog);
133
140
  closeOverlay(document, overlayId);
134
141
  }
@@ -176,6 +183,7 @@ export function connect(host) {
176
183
  ids.stop();
177
184
  observer.disconnect();
178
185
  resize.destroy();
186
+ stopMaterialViewport?.();
179
187
  dialog?.removeEventListener("close", onClose, true);
180
188
  dialog?.removeEventListener("cancel", onCancel);
181
189
  closeButton?.removeEventListener("click", onCloseClick);
@@ -1,6 +1,12 @@
1
1
  <link rel="component" href="../ui-spinner/ui-spinner.html">
2
2
  <template component="ui-icon-button" module="./ui-icon-button.js">
3
3
  <defs>
4
+ <prop name="preset" type="keyword" values="none, primary, standard, quiet, destructive" default="none"
5
+ >The same shortcuts as Button: primary is solid accent, standard is solid neutral, quiet is ghost neutral, and destructive is solid danger. A named preset
6
+ wins over variant and tone; none preserves independent choices.</prop>
7
+ <!-- Computed maps read every member in HTML Next. auto keeps an omitted tone concrete so the preset map can resolve, while retaining the existing variant-dependent appearance. -->
8
+ <computed name="appearance" from="{ none: $variant, primary: 'solid', standard: 'solid', quiet: 'ghost', destructive: 'solid' }[$preset]"></computed>
9
+ <computed name="colorTone" from="{ none: $tone, primary: 'accent', standard: 'neutral', quiet: 'neutral', destructive: 'danger' }[$preset]"></computed>
4
10
  <prop name="disabled" type="boolean" default="false"
5
11
  >Disables the button, so it cannot be pressed or focused, and gives it a flat neutral surface and muted icon.</prop>
6
12
  <prop name="loading" type="boolean" default="false"
@@ -17,9 +23,10 @@
17
23
  <prop name="variant" type="keyword" values="ghost, outline, solid" default="ghost"
18
24
  >Emphasis. ghost shows only the icon until it is hovered, for toolbars and dense rows; outline adds a border on the default surface; solid fills with the
19
25
  accent colour, for the one primary action in a group.</prop>
20
- <prop name="tone" type="keyword" values="accent, neutral"
21
- >Colour for every variant. When omitted, ghost and outline keep their neutral appearance and solid keeps its accent fill. Set accent for a coloured action
22
- or neutral for a quiet one. Native aria-pressed="true" keeps an outline toggle visibly pressed; the consumer owns that state.</prop>
26
+ <prop name="tone" type="keyword" values="auto, accent, neutral, danger, success, warning, info" default="auto"
27
+ >Colour for every variant. auto preserves the existing variant-specific default. When omitted, ghost and outline keep their neutral appearance and solid
28
+ keeps its accent fill. Set accent for a coloured action or neutral for a quiet one. danger, success, warning and info share Button's intent colors. Native
29
+ aria-pressed="true" keeps an outline toggle visibly pressed; the consumer owns that state.</prop>
23
30
  <prop name="anticipatory" type="boolean" default="false"
24
31
  >Keeps the button out of sight until it is wanted: at rest a fine pointer sees only a small guide dot, and the button appears on hover, on focus, or when
25
32
  the pointer comes near inside a ui-affordance-scope. Touch devices and the solid variant always show it.</prop>
@@ -32,6 +39,8 @@
32
39
  <prop name="round" type="boolean" default="false">Use a fully circular shape.</prop>
33
40
  </defs>
34
41
  <button
42
+ from:data-appearance="$appearance"
43
+ from:data-tone="$colorTone"
35
44
  from:disabled="$disabled"
36
45
  type="button"
37
46
  from:aria-label="$label"
@@ -144,7 +153,7 @@
144
153
  --_ui-action-start: -1px;
145
154
  }
146
155
 
147
- :host([tone="accent"]) {
156
+ :host[data-tone="accent"] {
148
157
  --_icon-button-border: var(--ui-accent-solid);
149
158
  --_icon-button-hover-border: var(--ui-accent-hover);
150
159
  --_icon-button-text: var(--ui-accent-subtle-text, var(--ui-accent-solid));
@@ -152,8 +161,52 @@
152
161
  color: var(--_icon-button-text);
153
162
  }
154
163
 
164
+ :host[data-tone="danger"] {
165
+ --_icon-button-border: var(--ui-danger);
166
+ --_icon-button-hover-border: var(--ui-danger-hover);
167
+ --_icon-button-text: var(--ui-danger-subtle-text);
168
+ --_icon-button-outline-surface: color-mix(in srgb, var(--ui-danger) 5%, var(--ui-surface-default));
169
+ --_icon-button-solid-surface: var(--ui-danger);
170
+ --_icon-button-solid-hover-surface: var(--ui-danger-hover);
171
+ --_icon-button-solid-text: var(--ui-danger-on-strong);
172
+ color: var(--_icon-button-text);
173
+ }
174
+
175
+ :host[data-tone="success"] {
176
+ --_icon-button-border: var(--ui-success);
177
+ --_icon-button-hover-border: var(--ui-success-hover);
178
+ --_icon-button-text: var(--ui-success-subtle-text);
179
+ --_icon-button-outline-surface: color-mix(in srgb, var(--ui-success) 5%, var(--ui-surface-default));
180
+ --_icon-button-solid-surface: var(--ui-success);
181
+ --_icon-button-solid-hover-surface: var(--ui-success-hover);
182
+ --_icon-button-solid-text: var(--ui-success-on-strong);
183
+ color: var(--_icon-button-text);
184
+ }
185
+
186
+ :host[data-tone="warning"] {
187
+ --_icon-button-border: var(--ui-warning);
188
+ --_icon-button-hover-border: var(--ui-warning-hover);
189
+ --_icon-button-text: var(--ui-warning-subtle-text);
190
+ --_icon-button-outline-surface: color-mix(in srgb, var(--ui-warning) 5%, var(--ui-surface-default));
191
+ --_icon-button-solid-surface: var(--ui-warning);
192
+ --_icon-button-solid-hover-surface: var(--ui-warning-hover);
193
+ --_icon-button-solid-text: var(--ui-warning-on-strong);
194
+ color: var(--_icon-button-text);
195
+ }
196
+
197
+ :host[data-tone="info"] {
198
+ --_icon-button-border: var(--ui-info);
199
+ --_icon-button-hover-border: var(--ui-info-hover);
200
+ --_icon-button-text: var(--ui-info-subtle-text);
201
+ --_icon-button-outline-surface: color-mix(in srgb, var(--ui-info) 5%, var(--ui-surface-default));
202
+ --_icon-button-solid-surface: var(--ui-info);
203
+ --_icon-button-solid-hover-surface: var(--ui-info-hover);
204
+ --_icon-button-solid-text: var(--ui-info-on-strong);
205
+ color: var(--_icon-button-text);
206
+ }
207
+
155
208
  /* Omitted tone retains the existing accent solid; explicit neutral is additive. */
156
- :host([tone="neutral"]) {
209
+ :host[data-tone="neutral"] {
157
210
  --_icon-button-solid-surface: var(--ui-text-primary);
158
211
  --_icon-button-solid-hover-surface: var(--ui-text-primary);
159
212
  --_icon-button-solid-text: var(--ui-surface-default);
@@ -198,13 +251,13 @@
198
251
  }
199
252
 
200
253
  @media (hover: hover) and (pointer: fine) {
201
- :host([anticipatory]):not(:host([revealed]), :host([variant="solid"]), :host([loading]), :host([pending]), :disabled, [aria-pressed="true"], [data-ui-proximity="near"], :hover, :focus-visible) {
254
+ :host([anticipatory]):not(:host([revealed]), :host[data-appearance="solid"], :host([loading]), :host([pending]), :disabled, [aria-pressed="true"], [data-ui-proximity="near"], :hover, :focus-visible) {
202
255
  border-color: transparent;
203
256
  background-color: transparent;
204
257
  box-shadow: none;
205
258
  }
206
259
 
207
- :host([anticipatory]):not(:host([revealed]), :host([variant="solid"]), :host([loading]), :host([pending]), :disabled, [aria-pressed="true"], [data-ui-proximity="near"], :hover, :focus-visible)
260
+ :host([anticipatory]):not(:host([revealed]), :host[data-appearance="solid"], :host([loading]), :host([pending]), :disabled, [aria-pressed="true"], [data-ui-proximity="near"], :hover, :focus-visible)
208
261
  .content {
209
262
  opacity: 0;
210
263
  transform: scale(0.65);
@@ -212,7 +265,7 @@
212
265
  }
213
266
  }
214
267
 
215
- :host([anticipatory]):is(:host([revealed]), :host([variant="solid"]), [aria-pressed="true"], [data-ui-proximity="near"], :hover, :focus-visible)::before {
268
+ :host([anticipatory]):is(:host([revealed]), :host[data-appearance="solid"], [aria-pressed="true"], [data-ui-proximity="near"], :hover, :focus-visible)::before {
216
269
  opacity: 0;
217
270
  }
218
271
 
@@ -321,7 +374,7 @@
321
374
  border-radius: var(--ui-radius-round);
322
375
  }
323
376
 
324
- :host([variant="outline"]) {
377
+ :host[data-appearance="outline"] {
325
378
  border-color: var(--ui-icon-button-border, var(--_icon-button-border, var(--ui-control-border, var(--ui-border-strong))));
326
379
  background-color: var(--ui-icon-button-outline-surface, var(--_icon-button-outline-surface, var(--ui-surface-default)));
327
380
  background-image: none;
@@ -329,7 +382,7 @@
329
382
  box-shadow: var(--ui-action-shadow, var(--_ui-action-shadow));
330
383
  }
331
384
 
332
- :host([variant="solid"]) {
385
+ :host[data-appearance="solid"] {
333
386
  background-color: var(--ui-icon-button-solid-surface, var(--_icon-button-solid-surface, var(--ui-accent-solid)));
334
387
  background-image: none;
335
388
  color: var(--ui-icon-button-solid-text, var(--_icon-button-solid-text, var(--ui-text-on-accent)));
@@ -342,27 +395,26 @@
342
395
  color: var(--ui-icon-button-hover-text, var(--_icon-button-text, var(--ui-text-primary)));
343
396
  }
344
397
 
345
- :host([variant="outline"]):hover:enabled:not([aria-disabled="true"]) {
398
+ :host[data-appearance="outline"]:hover:enabled:not([aria-disabled="true"]) {
346
399
  border-color: var(--ui-icon-button-hover-border, var(--_icon-button-hover-border, var(--ui-control-border-hover, var(--ui-control-border))));
347
400
  }
348
401
 
349
- :host([variant="solid"]):hover:enabled:not([aria-disabled="true"]) {
402
+ :host[data-appearance="solid"]:hover:enabled:not([aria-disabled="true"]) {
350
403
  background-color: var(--ui-icon-button-solid-hover-surface, var(--_icon-button-solid-hover-surface, var(--ui-accent-hover)));
351
404
  }
352
405
 
353
- :host([variant="solid"][tone="accent"]):hover:enabled:not([aria-disabled="true"]),
354
- :host([variant="solid"][tone="neutral"]):hover:enabled:not([aria-disabled="true"]) {
406
+ :host[data-appearance="solid"]:hover:enabled:not([aria-disabled="true"]) {
355
407
  color: var(--ui-icon-button-solid-text, var(--_icon-button-solid-text, var(--ui-text-on-accent)));
356
408
  }
357
409
 
358
- :host([variant="ghost"])[aria-expanded="true"]:enabled:not([aria-disabled="true"]) {
410
+ :host[data-appearance="ghost"][aria-expanded="true"]:enabled:not([aria-disabled="true"]) {
359
411
  background-color: var(--ui-icon-button-active-surface, color-mix(in srgb, currentColor 16%, transparent));
360
412
  color: var(--ui-icon-button-hover-text, var(--_icon-button-text, var(--ui-text-primary)));
361
413
  box-shadow: var(--ui-pressed);
362
414
  }
363
415
 
364
416
  /* Native toggle state persists after pointer release, including during loading. */
365
- :host([variant="outline"])[aria-pressed="true"]:enabled {
417
+ :host[data-appearance="outline"][aria-pressed="true"]:enabled {
366
418
  background-color: var(--ui-icon-button-active-surface, color-mix(in srgb, currentColor 12%, var(--ui-surface-default)));
367
419
  background-image: none;
368
420
  box-shadow: var(--ui-pressed);
@@ -397,35 +449,35 @@
397
449
  box-shadow: var(--ui-focus-halo);
398
450
  }
399
451
 
400
- :host([variant="outline"]):focus-visible {
452
+ :host[data-appearance="outline"]:focus-visible {
401
453
  box-shadow:
402
454
  var(--ui-action-shadow, var(--_ui-action-shadow)),
403
455
  var(--ui-focus-halo);
404
456
  }
405
457
 
406
- :host([variant="solid"]):focus-visible {
458
+ :host[data-appearance="solid"]:focus-visible {
407
459
  box-shadow:
408
460
  var(--ui-icon-button-solid-shadow, var(--ui-action-shadow, var(--_ui-action-shadow))),
409
461
  var(--ui-focus-halo);
410
462
  }
411
463
 
412
- :host:is(:host([variant="outline"]), :host([variant="solid"])):active:enabled:not([aria-disabled="true"]) {
464
+ :host:is(:host[data-appearance="outline"], :host[data-appearance="solid"]):active:enabled:not([aria-disabled="true"]) {
413
465
  background-image: none;
414
466
  box-shadow: var(--ui-pressed);
415
467
  }
416
468
 
417
- :host([variant="outline"])[aria-pressed="true"]:enabled:focus-visible {
469
+ :host[data-appearance="outline"][aria-pressed="true"]:enabled:focus-visible {
418
470
  box-shadow:
419
471
  var(--ui-pressed),
420
472
  var(--ui-focus-halo);
421
473
  }
422
474
 
423
475
  @media (forced-colors: active) {
424
- :host([variant="outline"])[aria-pressed="true"]:enabled {
476
+ :host[data-appearance="outline"][aria-pressed="true"]:enabled {
425
477
  outline: var(--ui-focus-width) solid Highlight;
426
478
  outline-offset: calc(-2 * var(--ui-focus-width));
427
479
  }
428
- :host([variant="outline"])[aria-pressed="true"]:enabled:focus-visible {
480
+ :host[data-appearance="outline"][aria-pressed="true"]:enabled:focus-visible {
429
481
  outline-offset: var(--ui-focus-width);
430
482
  }
431
483
  }
@@ -442,7 +494,7 @@
442
494
  background-image: none;
443
495
  }
444
496
 
445
- :host([variant="ghost"]):disabled {
497
+ :host[data-appearance="ghost"]:disabled {
446
498
  border-color: transparent;
447
499
  }
448
500
 
@@ -84,6 +84,7 @@
84
84
  }
85
85
 
86
86
  :host {
87
+ --_ui-material-scale: 0.28;
87
88
  --_ui-menu-surface: var(--ui-menu-surface);
88
89
  --_ui-menu-border: var(--ui-menu-border);
89
90
  --_ui-menu-radius: var(--ui-menu-radius);
@@ -125,6 +126,65 @@
125
126
  overflow: auto;
126
127
  }
127
128
 
129
+ /* Purpose and measured size shape one shared separation lever. The content plane stays opaque;
130
+ transparency, saturation and edge light belong to the surrounding decorative material. */
131
+ :host:not([inline]) .surface {
132
+ --_ui-material-glassiness: clamp(0, var(--ui-glassiness, 0), 1);
133
+ --_ui-material-separation: clamp(0, var(--ui-surface-separation, 0), 1);
134
+ --_ui-material-active: clamp(0, calc((var(--_ui-material-glassiness) + var(--_ui-material-separation)) * 100000), 1);
135
+ --_ui-material-rim: calc((var(--_ui-material-separation) + var(--_ui-material-glassiness) * 0.25) * var(--_ui-material-scale, 0.28) * 8px);
136
+ --_ui-material-content-color: var(--_ui-menu-surface, var(--ui-material-content, var(--ui-overlay-surface, var(--ui-surface-elevated))));
137
+ --_ui-material-rim-color: color-mix(in srgb, var(--ui-material-glass, var(--ui-surface-elevated)) calc(var(--_ui-material-active) * 100%), transparent);
138
+ background:
139
+ linear-gradient(var(--_ui-material-content-color), var(--_ui-material-content-color)) padding-box,
140
+ linear-gradient(var(--_ui-material-rim-color), var(--_ui-material-rim-color)) border-box,
141
+ var(--_ui-material-content-color) padding-box;
142
+ border-width: calc(var(--ui-border-width) + var(--_ui-material-rim));
143
+ border-color: var(
144
+ --_ui-menu-border,
145
+ color-mix(in oklab, var(--ui-overlay-border, var(--ui-border-strong)) calc((1 - var(--_ui-material-active)) * 100%), transparent)
146
+ );
147
+ box-shadow: var(
148
+ --_ui-menu-shadow,
149
+ var(
150
+ --ui-overlay-shadow,
151
+ 0 calc(var(--_ui-material-separation) * var(--_ui-material-scale, 0.28) * 8px) calc(
152
+ var(--_ui-material-separation) * var(--_ui-material-scale, 0.28) * 24px
153
+ ) color-mix(in oklab, var(--ui-text-primary) 15%, transparent),
154
+ inset 0 0 0 calc(var(--_ui-material-active) * var(--ui-border-width)) var(--ui-material-edge, var(--ui-border-strong)),
155
+ var(--ui-elevation-overlay, var(--ui-shadow-lg))
156
+ )
157
+ );
158
+ backdrop-filter: blur(calc(var(--_ui-material-glassiness) * var(--_ui-material-scale, 0.28) * 18px))
159
+ saturate(calc(1 + var(--_ui-material-glassiness) * var(--_ui-material-scale, 0.28) * 0.18));
160
+ -webkit-backdrop-filter: blur(calc(var(--_ui-material-glassiness) * var(--_ui-material-scale, 0.28) * 18px))
161
+ saturate(calc(1 + var(--_ui-material-glassiness) * var(--_ui-material-scale, 0.28) * 0.18));
162
+ }
163
+
164
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
165
+ :host:not([inline]) .surface {
166
+ background: var(--_ui-material-content-color);
167
+ }
168
+ }
169
+
170
+ @media (prefers-reduced-transparency: reduce) {
171
+ :host:not([inline]) .surface {
172
+ background: var(--_ui-material-content-color);
173
+ backdrop-filter: none;
174
+ -webkit-backdrop-filter: none;
175
+ }
176
+ }
177
+
178
+ @media (forced-colors: active) {
179
+ :host:not([inline]) .surface {
180
+ background: Canvas;
181
+ border-color: CanvasText;
182
+ box-shadow: none;
183
+ backdrop-filter: none;
184
+ -webkit-backdrop-filter: none;
185
+ }
186
+ }
187
+
128
188
  .surface hr {
129
189
  align-self: stretch;
130
190
  block-size: 0;
@@ -63,7 +63,7 @@ export function connect(host) {
63
63
  anchor = ids.get(nextFor);
64
64
  if (anchor) anchorAria = { "aria-haspopup": anchor.getAttribute("aria-haspopup"), "aria-expanded": anchor.getAttribute("aria-expanded") };
65
65
  anchor?.addEventListener("click", onAnchorClick);
66
- surface = host.props.inline.value ? null : createAnchoredSurface(element, { anchor, placement: nextPlacement });
66
+ surface = host.props.inline.value ? null : createAnchoredSurface(element, { anchor, placement: nextPlacement, materialRole: "menu" });
67
67
  };
68
68
  const apply = () => {
69
69
  const inline = Boolean(host.props.inline.value);
@@ -0,0 +1,47 @@
1
+ // Restricted dimensional arithmetic: unitless factors and one matching unit, without code evaluation.
2
+ export function calculateValue(source, unit = "") {
3
+ const text = String(source).trim();
4
+ if (!text || text.length > 256) throw new Error("Enter a number or arithmetic expression.");
5
+ const tokens = text.match(/(?:\d*\.?\d+(?:e[+-]?\d+)?)(?:[a-z%]+)?|[()+*/-]/gi) ?? [];
6
+ if (tokens.join("") !== text.replace(/\s+/g, "")) throw new Error("Unsupported expression.");
7
+ let index = 0;
8
+ const add = () => {
9
+ let left = multiply();
10
+ while (["+", "-"].includes(tokens[index])) {
11
+ const operator = tokens[index++]; const right = multiply();
12
+ // A bare number in addition uses the field's unit.
13
+ if (left.unit && right.unit && left.unit !== right.unit) throw new Error("Use matching units.");
14
+ left = { number: left.number + (operator === "+" ? 1 : -1) * right.number, unit: left.unit || right.unit };
15
+ }
16
+ return left;
17
+ };
18
+ const multiply = () => {
19
+ let left = atom();
20
+ while (["*", "/"].includes(tokens[index])) {
21
+ const operator = tokens[index++]; const right = atom();
22
+ if (operator === "*" && left.unit && right.unit) throw new Error("Multiply by a unitless number.");
23
+ if (operator === "/" && right.unit) throw new Error("Divide by a unitless number.");
24
+ left = { number: operator === "*" ? left.number * right.number : left.number / right.number, unit: left.unit || right.unit };
25
+ }
26
+ return left;
27
+ };
28
+ const atom = () => {
29
+ const token = tokens[index++];
30
+ if (token === "+" || token === "-") { const value = atom(); return { ...value, number: (token === "-" ? -1 : 1) * value.number }; }
31
+ if (token === "(") { const value = add(); if (tokens[index++] !== ")") throw new Error("Close the parentheses."); return value; }
32
+ const match = /^(\d*\.?\d+(?:e[+-]?\d+)?)([a-z%]*)$/i.exec(token ?? "");
33
+ if (!match) throw new Error("Enter a number.");
34
+ if (match[2] && match[2] !== unit) throw new Error(`Use ${unit || "unitless values"}.`);
35
+ return { number: Number(match[1]), unit: match[2] };
36
+ };
37
+ const result = add();
38
+ if (index !== tokens.length || !Number.isFinite(result.number)) throw new Error("Enter a finite result.");
39
+ return result.number;
40
+ }
41
+
42
+ export function boundedNumber(value, min, max) {
43
+ const lower = Number.isFinite(Number(min)) ? Number(min) : -Number.MAX_VALUE;
44
+ const upper = Number.isFinite(Number(max)) ? Number(max) : Number.MAX_VALUE;
45
+ return Math.min(Math.max(value, Math.min(lower, upper)), Math.max(lower, upper));
46
+ }
47
+ export function numberText(value, unit = "") { return `${Number(value.toFixed(6))}${unit}`; }
@@ -0,0 +1,62 @@
1
+ <link rel="component" href="../ui-input/ui-input.html">
2
+ <link rel="component" href="../ui-icon-button/ui-icon-button.html">
3
+ <link rel="component" href="../ui-icon/ui-icon.html">
4
+ <template component="ui-number-input" module="./ui-number-input.js">
5
+ <defs>
6
+ <prop name="value" type="string" default="0"
7
+ >Exact value. Arithmetic is calculated on Enter or blur; the resulting value includes unit. Invalid expressions stay visible and expose invalid
8
+ state.</prop>
9
+ <prop name="unit" type="string" default="">One accepted unit, such as px, rem, ms or %. Bare numbers use this unit. No automatic unit conversion.</prop>
10
+ <prop name="min" type="number" default="-1000000">Inclusive lower bound applied to typing, arithmetic, stepping and external values.</prop>
11
+ <prop name="max" type="number" default="1000000">Inclusive upper bound applied to every editing path.</prop>
12
+ <prop name="step" type="number" default="1">Positive increment used by arrows and step buttons; Shift multiplies it by ten.</prop>
13
+ <prop name="name" type="string" default="">Optional form field name.</prop>
14
+ <prop name="label" type="string" default="Value">Accessible name for the editing control.</prop>
15
+ <prop name="size" type="keyword" values="sm, md, lg" default="md">Shared field and button height.</prop>
16
+ <prop name="disabled" type="boolean" default="false">Prevents typing, stepping and pointer or keyboard changes while keeping the value visible.</prop>
17
+ <event name="input" type="object"><prop name="value" type="string" required></prop></event>
18
+ <event name="change" type="object"><prop name="value" type="string" required></prop></event>
19
+ </defs>
20
+ <span>
21
+ <ui-icon-button
22
+ data-number-step="decrease"
23
+ match-button
24
+ type="button"
25
+ from:size="$size"
26
+ from:disabled="$disabled"
27
+ from:label="concat('Decrease ', $label)"
28
+ ><ui-icon name="minus"></ui-icon></ui-icon-button>
29
+ <ui-input
30
+ data-ui-field-control
31
+ from:size="$size"
32
+ from:disabled="$disabled"
33
+ from:value="$value"
34
+ from:aria-label="$label"
35
+ from:name="$name"
36
+ inputmode="decimal"
37
+ spellcheck="false"
38
+ aria-description="Enter calculates arithmetic. Arrow keys step; Shift changes ten steps."
39
+ ></ui-input>
40
+ <ui-icon-button
41
+ data-number-step="increase"
42
+ match-button
43
+ type="button"
44
+ from:size="$size"
45
+ from:disabled="$disabled"
46
+ from:label="concat('Increase ', $label)"
47
+ ><ui-icon name="plus"></ui-icon></ui-icon-button>
48
+ </span>
49
+ <style>
50
+ :host {
51
+ display: flex !important;
52
+ align-items: center !important;
53
+ gap: var(--ui-space-1) !important;
54
+ margin: 0 !important;
55
+ padding: 0 !important;
56
+ min-inline-size: 0;
57
+ }
58
+ :host[hidden] {
59
+ display: none !important;
60
+ }
61
+ </style>
62
+ </template>
@@ -0,0 +1,44 @@
1
+ import { calculateValue, boundedNumber, numberText } from "./arithmetic.js";
2
+ import { afterFormReset } from "../shared/form-reset.js";
3
+
4
+ export function connect(host) {
5
+ const input = host.element.querySelector('input');
6
+ const decrease = host.element.querySelector('[data-number-step="decrease"]');
7
+ const increase = host.element.querySelector('[data-number-step="increase"]');
8
+ let current = 0;
9
+ let external;
10
+ const unit = () => host.props.unit.value || "";
11
+ const normalize = value => boundedNumber(value, host.props.min.value, host.props.max.value);
12
+ const paint = value => { current = normalize(value); input.value = numberText(current, unit()); input.removeAttribute("aria-invalid"); input.setCustomValidity(""); input.setAttribute("role", "spinbutton"); input.setAttribute("aria-valuenow", String(current)); input.setAttribute("aria-valuetext", input.value); input.setAttribute("aria-valuemin", String(host.props.min.value)); input.setAttribute("aria-valuemax", String(host.props.max.value)); };
13
+ const emit = () => { const value = numberText(current, unit()); host.dispatch("input", { value }); host.dispatch("change", { value }); };
14
+ const commit = () => {
15
+ if (host.props.disabled.value) return;
16
+ try { paint(calculateValue(input.value, unit())); emit(); }
17
+ catch (error) { input.setAttribute("aria-invalid", "true"); input.setCustomValidity(error.message); }
18
+ };
19
+ const step = (direction, multiplier = 1) => {
20
+ if (host.props.disabled.value) return;
21
+ let value = current;
22
+ try { value = calculateValue(input.value, unit()); } catch { /* Recover from an unfinished expression using the last result. */ }
23
+ const increment = Math.abs(Number(host.props.step.value)) || 1;
24
+ paint(value + direction * increment * multiplier); emit();
25
+ };
26
+ const key = event => {
27
+ if (event.key === "Enter") { event.preventDefault(); commit(); }
28
+ if (event.key === "ArrowUp" || event.key === "ArrowDown") { event.preventDefault(); step(event.key === "ArrowUp" ? 1 : -1, event.shiftKey ? 10 : 1); }
29
+ if (event.key === "Escape") { paint(current); }
30
+ };
31
+ // Do not leak unfinished expressions as accepted component values.
32
+ const stopInput = event => event.stopPropagation();
33
+ const down = () => step(-1); const up = () => step(1);
34
+ input.addEventListener("input", stopInput); input.addEventListener("change", stopInput);
35
+ input.addEventListener("keydown", key); input.addEventListener("blur", commit);
36
+ decrease.addEventListener("click", down); increase.addEventListener("click", up);
37
+ const stop = host.effect(() => {
38
+ const signature = JSON.stringify([host.props.value.value, unit(), host.props.min.value, host.props.max.value]);
39
+ if (signature === external) return; external = signature;
40
+ try { paint(calculateValue(host.props.value.value, unit())); input.defaultValue = input.value; } catch { paint(current); }
41
+ });
42
+ const reset = afterFormReset(input, () => { try { paint(calculateValue(input.defaultValue, unit())); } catch { paint(0); } });
43
+ return () => { stop(); reset(); input.removeEventListener("input", stopInput); input.removeEventListener("change", stopInput); input.removeEventListener("keydown", key); input.removeEventListener("blur", commit); decrease.removeEventListener("click", down); increase.removeEventListener("click", up); };
44
+ }