@rvx/ui 0.1.13 → 0.1.14

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 (94) hide show
  1. package/dist/common/theme.d.ts +10 -0
  2. package/dist/components/button.js +3 -2
  3. package/dist/components/button.js.map +1 -1
  4. package/dist/components/card.d.ts +1 -0
  5. package/dist/components/card.js +6 -3
  6. package/dist/components/card.js.map +1 -1
  7. package/dist/components/checkbox.js +6 -6
  8. package/dist/components/checkbox.js.map +1 -1
  9. package/dist/components/collapse.d.ts +16 -2
  10. package/dist/components/collapse.js +85 -4
  11. package/dist/components/collapse.js.map +1 -1
  12. package/dist/components/column.d.ts +1 -0
  13. package/dist/components/column.js +1 -0
  14. package/dist/components/column.js.map +1 -1
  15. package/dist/components/dialog.d.ts +1 -2
  16. package/dist/components/dialog.js +3 -5
  17. package/dist/components/dialog.js.map +1 -1
  18. package/dist/components/dropdown-input.js +2 -2
  19. package/dist/components/dropdown-input.js.map +1 -1
  20. package/dist/components/dropdown.js +2 -2
  21. package/dist/components/dropdown.js.map +1 -1
  22. package/dist/components/label.js +1 -1
  23. package/dist/components/label.js.map +1 -1
  24. package/dist/components/link.js +2 -1
  25. package/dist/components/link.js.map +1 -1
  26. package/dist/components/nav-list.js +2 -1
  27. package/dist/components/nav-list.js.map +1 -1
  28. package/dist/components/popout.d.ts +1 -2
  29. package/dist/components/popout.js +1 -2
  30. package/dist/components/popout.js.map +1 -1
  31. package/dist/components/popover.js +1 -2
  32. package/dist/components/popover.js.map +1 -1
  33. package/dist/components/radio-buttons.js +11 -10
  34. package/dist/components/radio-buttons.js.map +1 -1
  35. package/dist/components/slider.js +1 -2
  36. package/dist/components/slider.js.map +1 -1
  37. package/dist/components/tabs.d.ts +10 -0
  38. package/dist/components/tabs.js +31 -0
  39. package/dist/components/tabs.js.map +1 -0
  40. package/dist/components/text-input.js +5 -4
  41. package/dist/components/text-input.js.map +1 -1
  42. package/dist/components/validation-rules.d.ts +11 -0
  43. package/dist/components/validation-rules.js +37 -0
  44. package/dist/components/validation-rules.js.map +1 -0
  45. package/dist/components/validation.d.ts +53 -77
  46. package/dist/components/validation.js +117 -95
  47. package/dist/components/validation.js.map +1 -1
  48. package/dist/index.d.ts +2 -3
  49. package/dist/index.js +2 -3
  50. package/dist/index.js.map +1 -1
  51. package/dist/theme.module.css +75 -0
  52. package/dist/theme.module.css.map +1 -1
  53. package/package.json +3 -3
  54. package/src/common/theme.tsx +11 -0
  55. package/src/components/button.tsx +3 -2
  56. package/src/components/card.tsx +10 -5
  57. package/src/components/checkbox.tsx +6 -6
  58. package/src/components/collapse.tsx +127 -5
  59. package/src/components/column.tsx +2 -0
  60. package/src/components/dialog.tsx +3 -5
  61. package/src/components/dropdown-input.tsx +2 -2
  62. package/src/components/dropdown.tsx +2 -2
  63. package/src/components/label.tsx +1 -3
  64. package/src/components/link.tsx +2 -1
  65. package/src/components/nav-list.tsx +2 -1
  66. package/src/components/popout.tsx +1 -2
  67. package/src/components/popover.tsx +1 -2
  68. package/src/components/radio-buttons.tsx +21 -20
  69. package/src/components/slider.tsx +1 -2
  70. package/src/components/tabs.tsx +67 -0
  71. package/src/components/text-input.tsx +5 -4
  72. package/src/components/validation-rules.tsx +50 -0
  73. package/src/components/validation.tsx +175 -177
  74. package/src/index.tsx +2 -3
  75. package/src/theme/base.scss +7 -0
  76. package/src/theme/components/card.scss +4 -1
  77. package/src/theme/components/checkbox.scss +5 -0
  78. package/src/theme/components/column.scss +5 -0
  79. package/src/theme/components/radio-buttons.scss +5 -0
  80. package/src/theme/components/slider.scss +1 -0
  81. package/src/theme/components/tabs.scss +72 -0
  82. package/src/theme/theme.scss +1 -0
  83. package/dist/common/debounce.d.ts +0 -12
  84. package/dist/common/debounce.js +0 -23
  85. package/dist/common/debounce.js.map +0 -1
  86. package/dist/common/parsers.d.ts +0 -88
  87. package/dist/common/parsers.js +0 -62
  88. package/dist/common/parsers.js.map +0 -1
  89. package/dist/common/trim.d.ts +0 -12
  90. package/dist/common/trim.js +0 -16
  91. package/dist/common/trim.js.map +0 -1
  92. package/src/common/debounce.tsx +0 -36
  93. package/src/common/parsers.tsx +0 -167
  94. package/src/common/trim.tsx +0 -30
@@ -1,10 +1,10 @@
1
- import { ClassValue, Expression, get, optionalString, Signal, string, StyleValue, watch } from "rvx";
1
+ import { ClassValue, Expression, get, Signal, StyleValue, uniqueId, watch } from "rvx";
2
2
  import { isPending } from "rvx/async";
3
- import { uniqueId } from "rvx/id";
4
3
 
4
+ import { optionalString, string } from "rvx/convert";
5
5
  import { THEME } from "../common/theme.js";
6
6
  import { Text } from "./text.js";
7
- import { validatorFor } from "./validation.js";
7
+ import { closestValidator } from "./validation.js";
8
8
 
9
9
  export function Checkbox(props: {
10
10
  checked?: Expression<boolean | undefined>;
@@ -23,7 +23,7 @@ export function Checkbox(props: {
23
23
  ? () => isPending() || get(props.disabled)
24
24
  : () => true;
25
25
 
26
- const validator = props.checked instanceof Signal ? validatorFor(props.checked) : undefined;
26
+ const validator = props.checked instanceof Signal ? closestValidator(props.checked) : undefined;
27
27
 
28
28
  const input = <input
29
29
  id={id}
@@ -36,7 +36,7 @@ export function Checkbox(props: {
36
36
  }}
37
37
  aria-readonly={string(!(props.checked instanceof Signal))}
38
38
  aria-invalid={validator ? optionalString(validator.invalid) : undefined}
39
- aria-errormessage={validator ? validator.errorMessageIds : undefined}
39
+ aria-errormessage={validator ? validator.messageIds : undefined}
40
40
  autofocus={props.autofocus}
41
41
  disabled={disabled}
42
42
  /> as HTMLInputElement;
@@ -54,7 +54,7 @@ export function Checkbox(props: {
54
54
  ]}
55
55
  style={props.style}
56
56
  >
57
- {input}
57
+ {theme?.checkbox_padding ? <div class={theme.checkbox_padding}>{input}</div> : input}
58
58
  <Text class={theme?.checkbox_content}>
59
59
  {props.children}
60
60
  </Text>
@@ -1,10 +1,12 @@
1
- import { $, ClassValue, Expression, get, map, optionalString, StyleValue, teardown } from "rvx";
2
- import { Event } from "rvx/event";
1
+ import { $, ClassValue, Component, Event, Expression, For, get, map, Signal, StyleValue, teardown, watch } from "rvx";
2
+ import { useMicrotask, useTimeout } from "rvx/async";
3
+ import { optionalString } from "rvx/convert";
3
4
  import { THEME } from "../common/theme.js";
4
5
  import { AriaLive, AriaRelevant } from "../common/types.js";
5
6
 
6
7
  export function Collapse(props: {
7
8
  visible?: Expression<boolean | undefined>;
9
+ fadein?: Expression<boolean | undefined>;
8
10
  alert?: Event<[]>;
9
11
  children?: unknown;
10
12
  class?: ClassValue;
@@ -15,7 +17,6 @@ export function Collapse(props: {
15
17
  "aria-atomic"?: Expression<boolean | undefined>;
16
18
  }): unknown {
17
19
  const theme = THEME.current;
18
- const visible = map(props.visible, v => v ?? false);
19
20
  const alert = $(false);
20
21
  const size = $<number | undefined>(undefined);
21
22
 
@@ -37,7 +38,7 @@ export function Collapse(props: {
37
38
  });
38
39
 
39
40
  props.alert?.(() => {
40
- if (get(visible)) {
41
+ if (get(props.visible) ?? false) {
41
42
  alert.value = false;
42
43
  // Force a reflow:
43
44
  void root.offsetWidth;
@@ -45,8 +46,27 @@ export function Collapse(props: {
45
46
  }
46
47
  });
47
48
 
49
+ let visible = props.visible;
50
+ if (props.fadein !== undefined) {
51
+ const visibleSig = visible = $(false);
52
+ watch(props.visible, visible => {
53
+ const fadein = get(props.fadein);
54
+ if (fadein) {
55
+ visibleSig.value = false;
56
+ let handle = requestAnimationFrame(() => {
57
+ handle = requestAnimationFrame(() => {
58
+ visibleSig.value = visible ?? false;
59
+ });
60
+ });
61
+ teardown(() => cancelAnimationFrame(handle));
62
+ } else {
63
+ visibleSig.value = visible ?? false;
64
+ }
65
+ });
66
+ }
67
+
48
68
  const root = <div
49
- inert={map(visible, v => !v)}
69
+ inert={map(props.visible, v => !v)}
50
70
  class={[
51
71
  theme?.collapse,
52
72
  () => size.value === undefined ? undefined : theme?.collapse_sized,
@@ -73,3 +93,105 @@ export function Collapse(props: {
73
93
  </div> as HTMLDivElement;
74
94
  return root;
75
95
  }
96
+
97
+ export interface CollapseItem<T> {
98
+ value: T;
99
+ alert?: Event<[]>;
100
+ class?: ClassValue;
101
+ style?: StyleValue;
102
+ id?: Expression<string | undefined>;
103
+ "aria-live"?: Expression<AriaLive | undefined>;
104
+ "aria-relevant"?: Expression<AriaRelevant | undefined>;
105
+ "aria-atomic"?: Expression<boolean | undefined>;
106
+ }
107
+
108
+ export function CollapseFor<T>(props: {
109
+ each: Expression<Iterable<CollapseItem<T>>>;
110
+ children: Component<T>;
111
+ }): unknown {
112
+
113
+ interface Entry {
114
+ /** item */
115
+ i: CollapseItem<T>;
116
+ /** visible */
117
+ v: Signal<boolean>;
118
+ }
119
+
120
+ const entries = $<Entry[]>([]);
121
+ const fadein = $(false);
122
+ useMicrotask(() => fadein.value = true);
123
+
124
+ watch(() => {
125
+ const iter = get(props.each);
126
+ return Array.isArray(iter) ? iter : Array.from(iter);
127
+ }, items => {
128
+ entries.update(entries => {
129
+ let itemIndex = 0;
130
+ let entryIndex = 0;
131
+
132
+ function hasRemainingItem(value: T): boolean {
133
+ for (let i = itemIndex + 1; i < items.length; i++) {
134
+ if (Object.is(items[i].value, value)) {
135
+ return true;
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+
141
+ function spliceRemainingEntry(value: T): Entry | undefined {
142
+ for (let i = entryIndex + 1; i < entries.length; i++) {
143
+ if (Object.is(entries[i].i.value, value)) {
144
+ return entries.splice(i, 1)[0];
145
+ }
146
+ }
147
+ }
148
+
149
+ items: while (itemIndex < items.length) {
150
+ const item = items[itemIndex];
151
+ let entry = entries[entryIndex] as Entry | undefined;
152
+ if (entry && Object.is(entry.i.value, item.value)) {
153
+ entry.v.value = true;
154
+ } else if (entry && !hasRemainingItem(entry.i.value)) {
155
+ entry.v.value = false;
156
+ entryIndex++;
157
+ continue items;
158
+ } else if (entry = spliceRemainingEntry(item.value)) {
159
+ entries.splice(entryIndex, 0, entry);
160
+ entry.v.value = true;
161
+ } else {
162
+ entries.splice(entryIndex, 0, { i: item, v: $(true) });
163
+ }
164
+ itemIndex++;
165
+ entryIndex++;
166
+ }
167
+
168
+ while (entryIndex < entries.length) {
169
+ entries[entryIndex].v.value = false;
170
+ entryIndex++;
171
+ }
172
+ });
173
+
174
+ useTimeout(() => {
175
+ const filtered = entries.value.filter(e => e.v.value);
176
+ if (filtered.length < entries.value.length) {
177
+ entries.value = filtered;
178
+ }
179
+ }, 1000);
180
+ });
181
+
182
+ return <For each={entries}>
183
+ {instance => <Collapse
184
+ visible={instance.v}
185
+ fadein={fadein}
186
+ alert={instance.i.alert}
187
+ class={instance.i.class}
188
+ style={instance.i.style}
189
+ id={instance.i.id}
190
+ aria-live={instance.i["aria-live"]}
191
+ aria-relevant={instance.i["aria-relevant"]}
192
+ aria-atomic={instance.i["aria-atomic"]}
193
+ >
194
+ {props.children(instance.i.value)}
195
+ </Collapse>}
196
+ </For>;
197
+ }
@@ -11,12 +11,14 @@ export function Column(props: {
11
11
  style?: StyleValue;
12
12
  id?: Expression<string | undefined>;
13
13
  children?: unknown;
14
+ padded?: boolean;
14
15
  }): unknown {
15
16
  const theme = THEME.current;
16
17
  return <div
17
18
  class={[
18
19
  theme?.column,
19
20
  map(props.size, size => theme?.[`column_${size ?? "content"}`]),
21
+ map(props.padded, padded => padded ? theme?.column_padded : undefined),
20
22
  props.class,
21
23
  ]}
22
24
  style={props.style}
@@ -1,7 +1,5 @@
1
- import { $, captureSelf, ClassValue, Context, created, Expression, map, render, StyleValue, teardown } from "rvx";
2
- import { TASKS, Tasks } from "rvx/async";
3
- import { Emitter, Event } from "rvx/event";
4
- import { uniqueId } from "rvx/id";
1
+ import { $, captureSelf, ClassValue, Context, Emitter, Event, Expression, map, render, StyleValue, teardown, uniqueId } from "rvx";
2
+ import { TASKS, Tasks, useMicrotask } from "rvx/async";
5
3
  import { FlexSpace, Heading, Row, Text, THEME } from "../index.js";
6
4
  import { LAYER, Layer } from "./layer.js";
7
5
 
@@ -126,7 +124,7 @@ export function DialogBody(props: {
126
124
  </div>
127
125
  </div> as HTMLElement;
128
126
 
129
- created(() => {
127
+ useMicrotask(() => {
130
128
  if (theme?.dialog_fadein) {
131
129
  body.offsetParent;
132
130
  body.classList.add(theme.dialog_fadein);
@@ -2,7 +2,7 @@ import { ClassValue, Expression, get, map, Signal, StyleValue } from "rvx";
2
2
  import { Button, ButtonVariant } from "./button.js";
3
3
  import { Dropdown, DropdownItem } from "./dropdown.js";
4
4
  import { PopoutAlignment, PopoutPlacement } from "./popout.js";
5
- import { validatorFor } from "./validation.js";
5
+ import { closestValidator } from "./validation.js";
6
6
 
7
7
  export interface DropdownValue<T> {
8
8
  value: T;
@@ -46,7 +46,7 @@ export function DropdownInput<T>(props: {
46
46
  role="combobox"
47
47
  aria-label={props["aria-label"]}
48
48
  aria-labelledby={props["aria-labelledby"]}
49
- validator={props.value instanceof Signal ? validatorFor(props.value) : undefined}
49
+ validator={props.value instanceof Signal ? closestValidator(props.value) : undefined}
50
50
  >
51
51
  {props.children ?? (() => {
52
52
  const value = get(props.value);
@@ -1,5 +1,5 @@
1
- import { $, ClassValue, Expression, For, get, map, memo, optionalString, render, StyleValue, View, watch } from "rvx";
2
- import { uniqueId } from "rvx/id";
1
+ import { $, ClassValue, Expression, For, get, map, memo, render, StyleValue, uniqueId, View, watch } from "rvx";
2
+ import { optionalString } from "rvx/convert";
3
3
  import { Action, createPassiveActionEvent, handleActionEvent, keyFor, startDelayedHoverOnMouseenter } from "../common/events.js";
4
4
  import { THEME } from "../common/theme.js";
5
5
  import { LAYER } from "./layer.js";
@@ -1,7 +1,5 @@
1
- import { ClassValue, Expression, StyleValue } from "rvx";
2
-
1
+ import { ClassValue, Expression, StyleValue, uniqueId } from "rvx";
3
2
  import { THEME } from "../common/theme.js";
4
- import { uniqueId } from "rvx/id";
5
3
 
6
4
  export function Label(props: {
7
5
  class?: ClassValue;
@@ -1,5 +1,6 @@
1
- import { ClassValue, Expression, get, map, optionalString, StyleValue } from "rvx";
1
+ import { ClassValue, Expression, get, map, StyleValue } from "rvx";
2
2
  import { isPending } from "rvx/async";
3
+ import { optionalString } from "rvx/convert";
3
4
  import { Action, handleActionEvent, keyFor } from "../common/events.js";
4
5
  import { THEME } from "../common/theme.js";
5
6
  import { separated } from "../common/types.js";
@@ -1,5 +1,6 @@
1
- import { ClassValue, Expression, get, map, optionalString, StyleValue } from "rvx";
1
+ import { ClassValue, Expression, get, map, StyleValue } from "rvx";
2
2
  import { isPending } from "rvx/async";
3
+ import { optionalString } from "rvx/convert";
3
4
  import { Action, handleActionEvent, keyFor } from "../common/events.js";
4
5
  import { THEME } from "../common/theme.js";
5
6
 
@@ -1,5 +1,4 @@
1
- import { $, captureSelf, Context, Expression, get, render, teardown, TeardownHook, untrack, View, viewNodes } from "rvx";
2
- import { Emitter, Event as RvxEvent } from "rvx/event";
1
+ import { $, captureSelf, Context, Emitter, Expression, get, render, Event as RvxEvent, teardown, TeardownHook, untrack, View, viewNodes } from "rvx";
3
2
  import { PASSIVE_ACTION_EVENT } from "../common/events.js";
4
3
  import { axisEquals, Direction, DOWN, flip, getBlockStart, getInlineStart, getSize, getWindowSize, getWindowSpaceAround, INSET, LEFT, RIGHT, ScriptDirection, UP, WritingMode } from "../common/writing-mode.js";
5
4
  import { LAYER, Layer } from "./layer.js";
@@ -1,5 +1,4 @@
1
- import { $, ClassValue, Expression, get, Inject, map, render, StyleValue, SVG, watch, XMLNS } from "rvx";
2
- import { uniqueId } from "rvx/id";
1
+ import { $, ClassValue, Expression, get, Inject, map, render, StyleValue, SVG, uniqueId, watch, XMLNS } from "rvx";
3
2
  import { Action } from "../common/events.js";
4
3
  import { THEME } from "../common/theme.js";
5
4
  import { DOWN, getSize, getXY, LEFT, RIGHT, UP } from "../common/writing-mode.js";
@@ -1,9 +1,9 @@
1
- import { ClassValue, Expression, For, get, map, optionalString, Signal, string, StyleValue } from "rvx";
1
+ import { ClassValue, Expression, For, get, map, Signal, StyleValue, uniqueId } from "rvx";
2
2
  import { isPending } from "rvx/async";
3
+ import { optionalString, string } from "rvx/convert";
3
4
  import { THEME } from "../common/theme.js";
4
5
  import { Text } from "./text.js";
5
- import { validatorFor } from "./validation.js";
6
- import { uniqueId } from "rvx/id";
6
+ import { closestValidator } from "./validation.js";
7
7
 
8
8
  export interface RadioOption<T> {
9
9
  value: T;
@@ -32,7 +32,7 @@ export function RadioButtons<T>(props: {
32
32
  ? () => isPending() || get(props.disabled)
33
33
  : true;
34
34
 
35
- const validator = props.value instanceof Signal ? validatorFor(props.value) : undefined;
35
+ const validator = props.value instanceof Signal ? closestValidator(props.value) : undefined;
36
36
 
37
37
  return <div
38
38
  role="radiogroup"
@@ -44,33 +44,34 @@ export function RadioButtons<T>(props: {
44
44
  style={props.style}
45
45
  aria-readonly={string(!(props.options instanceof Signal))}
46
46
  aria-invalid={validator ? optionalString(validator.invalid) : undefined}
47
- aria-errormessage={validator ? validator.errorMessageIds : undefined}
47
+ aria-errormessage={validator ? validator.messageIds : undefined}
48
48
  aria-label={props["aria-label"]}
49
49
  aria-labelledby={props["aria-labelledby"]}
50
50
  >
51
51
  <For each={props.options}>
52
52
  {(option, index) => {
53
53
  const id = uniqueId();
54
+ const input = <input
55
+ id={id}
56
+ type="radio"
57
+ class={theme?.radio_button_input}
58
+ name={group}
59
+ value={id}
60
+ disabled={disabled}
61
+ autofocus={() => get(props.autofocus) && index() === 0}
62
+ prop:checked={map(props.value, x => x === option.value)}
63
+ on:input={() => {
64
+ if (props.value instanceof Signal) {
65
+ props.value.value = option.value;
66
+ }
67
+ }}
68
+ />;
54
69
 
55
70
  return <label
56
71
  for={id}
57
72
  class={theme?.radio_button_label}
58
73
  >
59
- <input
60
- id={id}
61
- type="radio"
62
- class={theme?.radio_button_input}
63
- name={group}
64
- value={id}
65
- disabled={disabled}
66
- autofocus={() => get(props.autofocus) && index() === 0}
67
- prop:checked={map(props.value, x => x === option.value)}
68
- on:input={() => {
69
- if (props.value instanceof Signal) {
70
- props.value.value = option.value;
71
- }
72
- }}
73
- />
74
+ {theme?.radio_button_padding ? <div class={theme.radio_button_padding}>{input}</div> : input}
74
75
  <Text class={theme?.radio_button_content}>
75
76
  {option.label}
76
77
  </Text>
@@ -1,5 +1,4 @@
1
- import { Expression, map, Show, Signal } from "rvx";
2
- import { uniqueId } from "rvx/id";
1
+ import { Expression, map, Show, Signal, uniqueId } from "rvx";
3
2
  import { THEME } from "../common/theme.js";
4
3
 
5
4
  export function Slider(props: {
@@ -0,0 +1,67 @@
1
+ import { $, Component, Expression, For, get, map, Show, Signal, uniqueIdFor, watch } from "rvx";
2
+ import { string } from "rvx/convert";
3
+ import { THEME } from "../common/theme.js";
4
+
5
+ export interface Tab {
6
+ label: Component;
7
+ content: Component;
8
+ }
9
+
10
+ export function Tabs(props: {
11
+ tabs: Expression<Iterable<Tab>>;
12
+ selected?: Signal<Tab | undefined>;
13
+ padded?: Expression<boolean | undefined>;
14
+ }) {
15
+ const theme = THEME.current;
16
+ const selected = props.selected ?? $(undefined);
17
+
18
+ watch(selected, current => {
19
+ if (current === undefined) {
20
+ for (const tab of get(props.tabs)) {
21
+ selected.value = tab;
22
+ break;
23
+ }
24
+ }
25
+ });
26
+
27
+ return <>
28
+ <div
29
+ role="tablist"
30
+ class={[
31
+ theme?.tab_list,
32
+ map(props.padded, padded => padded ? theme?.tab_list_padded : undefined),
33
+ ]}
34
+ >
35
+ <For each={props.tabs}>
36
+ {tab => <button
37
+ role="tab"
38
+ class={[
39
+ theme?.tab_handle,
40
+ () => selected.value === tab ? theme?.tab_handle_current : undefined,
41
+ ]}
42
+ aria-selected={string(() => selected.value === tab)}
43
+ aria-controls={uniqueIdFor(tab)}
44
+ on:click={event => {
45
+ event.stopImmediatePropagation();
46
+ event.preventDefault();
47
+ selected.value = tab;
48
+ }}
49
+ >
50
+ {tab.label()}
51
+ </button>}
52
+ </For>
53
+ </div>
54
+ <Show when={selected}>
55
+ {tab => <div
56
+ role="tabpanel"
57
+ id={uniqueIdFor(tab)}
58
+ class={[
59
+ theme?.tab_panel,
60
+ map(props.padded, padded => padded ? theme?.tab_panel_padded : undefined),
61
+ ]}
62
+ >
63
+ {tab.content()}
64
+ </div>}
65
+ </Show>
66
+ </>;
67
+ }
@@ -1,8 +1,9 @@
1
- import { ClassValue, Expression, get, optionalString, Signal, StyleValue } from "rvx";
1
+ import { ClassValue, Expression, get, Signal, StyleValue } from "rvx";
2
2
  import { isPending, waitFor } from "rvx/async";
3
+ import { optionalString } from "rvx/convert";
3
4
  import { keyFor } from "../common/events.js";
4
5
  import { THEME } from "../common/theme.js";
5
- import { validatorFor } from "./validation.js";
6
+ import { closestValidator } from "./validation.js";
6
7
 
7
8
  export type TextInputType = "text" | "password";
8
9
  export type TextAreaWrap = "hard" | "soft";
@@ -66,7 +67,7 @@ export function TextInput(props: ({
66
67
  const theme = THEME.current;
67
68
  const disabled = () => isPending() || get(props.disabled);
68
69
 
69
- const validator = props.value instanceof Signal ? validatorFor(props.value) : undefined;
70
+ const validator = props.value instanceof Signal ? closestValidator(props.value) : undefined;
70
71
 
71
72
  const InputTag = props.multiline ? "textarea" : "input";
72
73
  const input = <InputTag
@@ -109,7 +110,7 @@ export function TextInput(props: ({
109
110
  aria-labelledby={props["aria-labelledby"]}
110
111
 
111
112
  aria-invalid={validator ? optionalString(validator.invalid) : undefined}
112
- aria-errormessage={validator ? validator.errorMessageIds : undefined}
113
+ aria-errormessage={validator ? validator.messageIds : undefined}
113
114
  /> as HTMLInputElement;
114
115
 
115
116
  return input;
@@ -0,0 +1,50 @@
1
+ import { $, Component, Expression, get, Signal, watchUpdates } from "rvx";
2
+ import { Validator } from "./validation.js";
3
+
4
+ export function rule<T>(source: Signal<T>, condition: (value: T) => boolean, message: Component): Signal<T> {
5
+ Validator.get(source).prependRule(() => {
6
+ if (!condition(source.value)) {
7
+ return [message];
8
+ }
9
+ });
10
+ return source;
11
+ }
12
+
13
+ export interface IntParserOptions {
14
+ /** The validation message for invalid formats. */
15
+ format: Component;
16
+ /** The validation message for an out of range value. Defaults to the format message. */
17
+ range?: Component;
18
+ min?: Expression<number>;
19
+ max?: Expression<number>;
20
+ }
21
+
22
+ export function intParser(source: Signal<number>, options: IntParserOptions): Signal<string> {
23
+ const input = $(String(source.value), source);
24
+
25
+ const messages = $<Component[]>([]);
26
+ Validator.get(source).prependRule(() => messages.value);
27
+
28
+ const min = options.min ?? Number.MIN_SAFE_INTEGER;
29
+ const max = options.max ?? Number.MAX_SAFE_INTEGER;
30
+
31
+ watchUpdates(source, value => {
32
+ input.value = String(value);
33
+ });
34
+
35
+ watchUpdates(input, value => {
36
+ if (/^-?\d+$/.test(value)) {
37
+ const num = Number.parseInt(value);
38
+ if (Number.isSafeInteger(num) && num >= get(min) && num <= get(max)) {
39
+ messages.value = [];
40
+ source.value = num;
41
+ } else {
42
+ messages.value = [options.range ?? options.format];
43
+ }
44
+ } else {
45
+ messages.value = [options.format];
46
+ }
47
+ });
48
+
49
+ return input;
50
+ }