@stubber/form-fields 1.0.11 → 1.1.1

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.
@@ -1,29 +1,20 @@
1
1
  <script>
2
- import { syncStoreToStore } from "../../utils/syncing";
3
- import { deepEqual } from "fast-equals";
4
- import _ from "lodash-es";
5
- import { onMount, onDestroy } from "svelte";
6
- import { writable } from "svelte/store";
7
- import * as utils from "../../utils/index.js";
2
+ import { onMount, tick } from "svelte";
8
3
 
9
4
  import { Label } from "@stubber/ui/label";
10
- import { scale } from "svelte/transition";
5
+ import * as Command from "@stubber/ui/command";
6
+ import * as Popover from "@stubber/ui/popover";
7
+ import { Button } from "@stubber/ui/button";
8
+ import { isArray, isEqual } from "lodash-es";
11
9
 
12
10
  export let field;
13
11
 
14
- const internal = writable();
15
- const { clickOutside } = utils;
16
-
17
- let is_focused = false;
18
- let filter_text = "";
19
- let input;
20
-
21
12
  $: state_key = $field.state?.state_key;
22
13
  $: label = $field.spec?.title;
23
14
  $: hide_label = $field.spec?.hide_label;
24
15
  $: isValid = !$field.state?.validation || $field.state?.validation?.valid;
25
16
  $: validationMessage = $field.state?.validation?.message;
26
- $: items = _.isArray($field.spec?.params?.options)
17
+ $: items = isArray($field.spec?.params?.options)
27
18
  ? $field.spec?.params?.options?.map((o, index) => {
28
19
  let { label, value } = o || {};
29
20
  let _value = value !== undefined ? value : label;
@@ -32,222 +23,87 @@
32
23
  return { _key, _label, _value };
33
24
  })
34
25
  : [];
35
- $: filteritems = items.filter((i) => {
36
- let _filter_text = filter_text?.toLowerCase();
37
- if (!_filter_text) return true;
38
- let _label = i?._label?.toLowerCase();
39
- return _label?.includes(_filter_text);
40
- });
41
26
 
42
27
  onMount(() => {
43
- // set field values that aren't set yet
44
- let f = _.cloneDeep($field);
45
- let initial_item = items?.find((i) => deepEqual($field.data.base, i._value));
46
- let initial_data = {
47
- ...f?.data,
48
- base: initial_item?._value ?? null,
49
- };
50
- if (!f?.spec?.without_value_details) initial_data.base_label = initial_item?._label;
51
- let initial_state_internal = {
52
- ...f?.state?.internal,
53
- focused_index: -1,
54
- selected_item_key: initial_item?._key,
55
- };
56
- _.set(f, "data", initial_data);
57
- _.set(f, "state.internal", initial_state_internal);
58
- if (!deepEqual(f, $field)) $field = f;
59
-
60
- syncStoreToStore(
61
- field,
62
- internal,
63
- (a, b) => {
64
- let _clone = _.cloneDeep(a.state?.internal) || {};
65
-
66
- // get parts from data
67
- let item = items.find((i) => i._value === a?.data?.base);
68
- if (item) {
69
- filter_text = item?._label;
70
- _clone.selected_item_key = item?._key;
71
-
72
- // set field_label if changed
73
- if (!deepEqual(a?.data?.base_label, item?._label) && !a?.spec?.without_value_details) {
74
- $field.data.base_label = item?._label;
75
- }
76
-
77
- // set field state if changed
78
- if (!deepEqual(a?.state?.internal, _clone)) {
79
- $field.state.internal = _clone;
80
- }
81
- }
82
-
83
- return _clone;
84
- },
85
- (a, b) => {
86
- let _clone = _.cloneDeep(a) || {};
87
- // update the state
88
- _.set(_clone, "state.internal", _.cloneDeep(b));
89
- // update the data
90
- let item = items.find((i) => i._key === b?.selected_item_key);
91
- _.set(_clone, "data.base", item?._value);
92
- if (!a?.spec?.without_value_details) _.set(_clone, "data.base_label", item?._label);
93
- return _clone;
28
+ if ($field.data.base && !$field.spec?.without_value_details) {
29
+ // If the field has a base value, we need to find the corresponding label
30
+ const item = items.find((i) => isEqual(i._value, $field.data.base));
31
+ if (item) {
32
+ $field.data.base_label = item._label;
94
33
  }
95
- );
96
- });
97
-
98
- let teleportedNode = null;
99
- onDestroy(() => {
100
- if (teleportedNode) {
101
- teleportedNode.remove();
102
- teleportedNode = null;
103
34
  }
104
35
  });
105
36
 
106
- // used to show the dropdown panel in the body instead of inside the component
107
- // fixes an issue where the dropdown panel would be cut off by the parent container
108
- function teleport(node) {
109
- // Get the original position and width
110
- let rect = node.getBoundingClientRect();
111
- let originalWidth = node.offsetWidth;
112
- let originalHeight = node.offsetHeight;
37
+ function simpleSetSelected(item_key) {
38
+ const item = items.find((i) => i._key === item_key);
39
+ if (!item) return;
113
40
 
114
- // Teleport to the body
115
- let teleportContainer = document.body;
116
- teleportContainer.appendChild(node);
117
-
118
- teleportedNode = node;
119
-
120
- // Apply the original width and position to the teleported element
121
- node.style.width = originalWidth + "px";
122
- node.style.height = originalHeight + "px";
123
- node.style.position = "absolute";
124
- node.style.top = rect.top + window.scrollY + "px";
125
- node.style.left = rect.left + "px";
126
-
127
- // set z-index to 1000
128
- node.style.zIndex = 1000;
41
+ field.update((f) => {
42
+ f.data.base = item?._value;
43
+ if (!f.spec?.without_value_details) {
44
+ f.data.base_label = item?._label;
45
+ }
46
+ return f;
47
+ });
129
48
  }
130
49
 
131
- function setSelected(item) {
132
- filter_text = item._label;
133
- let comparison = _.cloneDeep($internal);
134
- comparison.focused_index = -1;
135
- comparison.selected_item_key = item._key;
136
-
137
- if (!deepEqual(comparison, $internal)) {
138
- $internal = _.cloneDeep(comparison);
139
- }
140
-
141
- is_focused = false;
142
- input.blur();
50
+ let open = false;
51
+
52
+ $: dropdownLabel = items.find((i) => isEqual(i._value, $field.data.base))?._label ?? "Select...";
53
+ // We want to refocus the trigger button when the user selects
54
+ // an item from the list so users can continue navigating the
55
+ // rest of the form with the keyboard.
56
+ function closeAndFocusTrigger(triggerId) {
57
+ open = false;
58
+ tick().then(() => {
59
+ document.getElementById(triggerId)?.focus();
60
+ });
143
61
  }
62
+
63
+ const collisionBoundary = document.querySelector("body");
144
64
  </script>
145
65
 
146
- {#if $internal}
147
- <div
148
- use:clickOutside={() => {
149
- if (is_focused) {
150
- is_focused = false;
151
- filter_text = $field?.data?.base_label ?? "";
152
- }
153
- }}
154
- class="relative flex flex-col w-full text-surface-900 my-2"
155
- >
156
- <Label for="input_{state_key}" class="block py-2{hide_label ? 'hidden' : ''}">
157
- {label}
158
- </Label>
159
- <div class="relative flex rounded-md">
160
- <input
161
- on:keydown={(e) => {
162
- switch (e.key) {
163
- case "Enter":
164
- if ($internal.focused_index >= 0 && $internal.focused_index < items.length) {
165
- setSelected(filteritems[$internal.focused_index]);
166
- }
167
- e.preventDefault();
168
- break;
169
- case "ArrowDown":
170
- $internal.focused_index = ($internal.focused_index + 1) % items.length;
171
- e.preventDefault();
172
- break;
173
- case "ArrowUp":
174
- $internal.focused_index = ($internal.focused_index - 1 + items.length) % items.length;
175
- e.preventDefault();
176
- break;
177
- }
178
- }}
179
- on:focus={() => {
180
- if (!is_focused) {
181
- is_focused = true;
182
- filter_text = "";
183
- }
184
- }}
185
- bind:this={input}
186
- type="select"
187
- id="input_{state_key}"
188
- placeholder={label}
189
- class="bg-gradient-to-b from-[#2727271F] to-[#27272719] shadow-select
190
- flex h-7 w-full items-center justify-between rounded px-3 py-2
191
- text-sm focus:outline-none
192
- [&>span]:line-clamp-1 aria-[invalid]:border-destructive
193
- placeholder:text-muted-foreground
194
- {!isValid ? 'ring-danger-500' : 'ring-surface-300 focus:ring-transparent'}"
195
- name={state_key}
196
- bind:value={filter_text}
197
- autocomplete="off"
198
- />
199
- <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
200
- <i class="text-surface-300 fa-regular fa-arrows-up-down fa-fw" />
201
- </div>
202
- </div>
203
- {#if is_focused}
204
- <div use:teleport class="z-10 w-full inset-y-[70px] mt-1">
205
- <div
206
- transition:scale={{
207
- start: 0.95,
208
- opacity: 0,
209
- duration: 50,
210
- }}
211
- class="bg-popover text-popover-foreground relative min-w-[8rem]
212
- overflow-hidden rounded-md border shadow-md outline-none
213
- max-h-[200px] overflow-y-auto w-full p-1"
214
- >
215
- {#if filteritems.length > 0}
216
- <ul>
217
- {#each filteritems as item, index (item._key)}
218
- <li class="group {$internal.focused_index === index ? 'bg-primary-400' : ''} ">
219
- <button
220
- type="button"
221
- on:click|preventDefault={() => setSelected(item)}
222
- class="selectitem relative flex w-full cursor-default
223
- select-none items-center rounded-sm py-1 pl-8 pr-2
224
- text-sm outline-none hover:bg-accent
225
- hover:text-accent-foreground
226
- data-[highlighted]:bg-accent
227
- data-[highlighted]:text-accent-foreground
228
- data-[disabled]:pointer-events-none
229
- data-[disabled]:opacity-50"
230
- >
231
- {item._label}
232
- {#if item._key === $internal?.selected_item_key}
233
- <i class="absolute left-2 h-3.5 w-3.5 fa-regular fa-check" />
234
- {/if}
235
- </button>
236
- </li>
237
- {/each}
238
- </ul>
239
- {:else}
240
- <div class="flex items-center justify-center py-4">
241
- <span class="text-sm text-surface-400">
242
- {filter_text ? "No results found" : "Start typing to search..."}
243
- </span>
244
- </div>
245
- {/if}
246
- </div>
247
- </div>
248
- {/if}
249
- {#if validationMessage}
250
- <Label class={!isValid ? `text-danger-500` : `text-success-500`}>{validationMessage}</Label>
251
- {/if}
252
- </div>
253
- {/if}
66
+ <div class="flex flex-col w-full text-surface-900 my-2">
67
+ <Label for="input_{state_key}" class="block py-2 {hide_label ? 'hidden' : ''}">
68
+ {label}
69
+ </Label>
70
+ <Popover.Root bind:open let:ids>
71
+ <Popover.Trigger asChild let:builder>
72
+ <Button
73
+ builders={[builder]}
74
+ variant="outline"
75
+ role="combobox"
76
+ aria-expanded={open}
77
+ class="w-full border-input bg-white bg-opacity-[15] ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 justify-between"
78
+ style="box-shadow: inset 0px 16px 16px -16px rgba(0, 0, 0, 0.1333);"
79
+ >
80
+ {dropdownLabel}
81
+ <i class="fas fa-sort ml-2 h-4 w-4 shrink-0 opacity-50" />
82
+ </Button>
83
+ </Popover.Trigger>
84
+ <Popover.Content sameWidth {collisionBoundary} class="p-0">
85
+ <Command.Root>
86
+ <Command.Input placeholder="Search..." />
87
+ <Command.Empty>No results found.</Command.Empty>
88
+ <Command.Group>
89
+ {#each items as item}
90
+ <Command.Item
91
+ value={item._key}
92
+ onSelect={(item_key) => {
93
+ simpleSetSelected(item_key);
94
+ closeAndFocusTrigger(ids.trigger);
95
+ }}
96
+ class={$field.data.base === item._value ? "bg-primary-100" : ""}
97
+ >
98
+ {item._label}
99
+ </Command.Item>
100
+ {/each}
101
+ </Command.Group>
102
+ </Command.Root>
103
+ </Popover.Content>
104
+ </Popover.Root>
105
+
106
+ {#if validationMessage}
107
+ <Label class={!isValid ? `text-danger-500` : `text-success-500`}>{validationMessage}</Label>
108
+ {/if}
109
+ </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stubber/form-fields",
3
- "version": "1.0.11",
3
+ "version": "1.1.1",
4
4
  "description": "An automatic form builder based on field specifications",
5
5
  "keywords": [
6
6
  "components",
@@ -57,7 +57,7 @@
57
57
  "@codemirror/commands": "^6.7.1",
58
58
  "@codemirror/state": "^6.4.1",
59
59
  "@codemirror/view": "^6.34.1",
60
- "@stubber/ui": "^1.0.18",
60
+ "@stubber/ui": "^1.1.1",
61
61
  "ag-grid-community": "^31.0.2",
62
62
  "ag-grid-enterprise": "^31.0.2",
63
63
  "currency-symbol-map": "^5.1.0",