vira 33.1.0 → 33.3.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.
@@ -0,0 +1,68 @@
1
+ import { Directive, type PartInfo } from 'element-vir';
2
+ import { type DragReorderManager, type DragReorderMove } from '../util/drag-reorder-manager.js';
3
+ /**
4
+ * The directive class behind {@link dragReorderHandle}.
5
+ *
6
+ * @category Internal
7
+ */
8
+ export declare class DragReorderHandleDirective extends Directive {
9
+ protected manager: DragReorderManager | undefined;
10
+ protected element: HTMLElement | undefined;
11
+ constructor(partInfo: PartInfo);
12
+ /** Applies the latest directive arguments. */
13
+ update(partInfo: PartInfo, [manager]: [DragReorderManager]): undefined;
14
+ /** Renders nothing, this directive only attaches behavior to its element. */
15
+ render(manager: DragReorderManager): undefined;
16
+ }
17
+ /**
18
+ * Makes the element it is attached to start dragging the row that contains it. That row must be a
19
+ * direct child of the element that `dragReorderRows` with the same manager is attached to.
20
+ *
21
+ * @category Reorder
22
+ */
23
+ export declare const dragReorderHandle: (manager: DragReorderManager) => import("element-vir").DirectiveResult<typeof DragReorderHandleDirective>;
24
+ /**
25
+ * The directive class behind {@link dragReorderRows}.
26
+ *
27
+ * @category Internal
28
+ */
29
+ export declare class DragReorderRowsDirective extends Directive {
30
+ protected manager: DragReorderManager | undefined;
31
+ protected element: HTMLElement | undefined;
32
+ constructor(partInfo: PartInfo);
33
+ /** Applies the latest directive arguments. */
34
+ update(partInfo: PartInfo, [manager, onReorder,]: [
35
+ DragReorderManager,
36
+ (move: Readonly<DragReorderMove>) => void
37
+ ]): undefined;
38
+ /** Renders nothing, this directive only attaches behavior to its element. */
39
+ render(manager: DragReorderManager, onReorder: (move: Readonly<DragReorderMove>) => void): undefined;
40
+ }
41
+ /**
42
+ * Marks the element whose direct children are the reorderable rows, and receives every finished
43
+ * {@link DragReorderMove}. Indexes count those children, so render nothing (not an empty wrapper)
44
+ * for rows that should not take part.
45
+ *
46
+ * @category Reorder
47
+ */
48
+ export declare const dragReorderRows: (manager: DragReorderManager, onReorder: (move: Readonly<DragReorderMove>) => void) => import("element-vir").DirectiveResult<typeof DragReorderRowsDirective>;
49
+ /**
50
+ * The directive class behind {@link dragReorderGhost}.
51
+ *
52
+ * @category Internal
53
+ */
54
+ export declare class DragReorderGhostDirective extends Directive {
55
+ constructor(partInfo: PartInfo);
56
+ /** Applies the latest directive arguments. */
57
+ update(partInfo: PartInfo, [manager]: [DragReorderManager]): undefined;
58
+ /** Renders nothing, this directive only attaches behavior to its element. */
59
+ render(manager: DragReorderManager): undefined;
60
+ }
61
+ /**
62
+ * Turns the element it is attached to into the translucent copy of the dragged row: it is
63
+ * absolutely positioned over the dragged row and then follows the pointer. Render it only while the
64
+ * manager's `value.draggedIndex` is set, with a copy of that row inside it.
65
+ *
66
+ * @category Reorder
67
+ */
68
+ export declare const dragReorderGhost: (manager: DragReorderManager) => import("element-vir").DirectiveResult<typeof DragReorderGhostDirective>;
@@ -0,0 +1,114 @@
1
+ import { assertWrap } from '@augment-vir/assert';
2
+ import { assertIsElementPartInfo, directive, Directive } from 'element-vir';
3
+ /**
4
+ * The directive class behind {@link dragReorderHandle}.
5
+ *
6
+ * @category Internal
7
+ */
8
+ export class DragReorderHandleDirective extends Directive {
9
+ manager;
10
+ element;
11
+ constructor(partInfo) {
12
+ super(partInfo);
13
+ assertIsElementPartInfo(partInfo, 'dragReorderHandle');
14
+ }
15
+ /** Applies the latest directive arguments. */
16
+ update(partInfo, [manager]) {
17
+ assertIsElementPartInfo(partInfo, 'dragReorderHandle');
18
+ this.manager = manager;
19
+ const element = assertWrap.instanceOf(partInfo.element, HTMLElement);
20
+ if (element !== this.element) {
21
+ this.element = element;
22
+ element.draggable = true;
23
+ element.addEventListener('dragstart', (event) => {
24
+ this.manager?.startDrag(event, element);
25
+ });
26
+ element.addEventListener('dragend', () => this.manager?.endDrag());
27
+ }
28
+ return this.render(manager);
29
+ }
30
+ /** Renders nothing, this directive only attaches behavior to its element. */
31
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
32
+ render(manager) {
33
+ return undefined;
34
+ }
35
+ }
36
+ /**
37
+ * Makes the element it is attached to start dragging the row that contains it. That row must be a
38
+ * direct child of the element that `dragReorderRows` with the same manager is attached to.
39
+ *
40
+ * @category Reorder
41
+ */
42
+ export const dragReorderHandle = directive(DragReorderHandleDirective);
43
+ /**
44
+ * The directive class behind {@link dragReorderRows}.
45
+ *
46
+ * @category Internal
47
+ */
48
+ export class DragReorderRowsDirective extends Directive {
49
+ manager;
50
+ element;
51
+ constructor(partInfo) {
52
+ super(partInfo);
53
+ assertIsElementPartInfo(partInfo, 'dragReorderRows');
54
+ }
55
+ /** Applies the latest directive arguments. */
56
+ update(partInfo, [manager, onReorder,]) {
57
+ assertIsElementPartInfo(partInfo, 'dragReorderRows');
58
+ this.manager = manager;
59
+ const element = assertWrap.instanceOf(partInfo.element, HTMLElement);
60
+ manager.attachRows(element, onReorder);
61
+ if (element !== this.element) {
62
+ this.element = element;
63
+ element.addEventListener('dragover', (event) => this.manager?.dragOver(event));
64
+ element.addEventListener('drop', (event) => this.manager?.drop(event));
65
+ }
66
+ return this.render(manager, onReorder);
67
+ }
68
+ /** Renders nothing, this directive only attaches behavior to its element. */
69
+ render(
70
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
71
+ manager,
72
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
73
+ onReorder) {
74
+ return undefined;
75
+ }
76
+ }
77
+ /**
78
+ * Marks the element whose direct children are the reorderable rows, and receives every finished
79
+ * {@link DragReorderMove}. Indexes count those children, so render nothing (not an empty wrapper)
80
+ * for rows that should not take part.
81
+ *
82
+ * @category Reorder
83
+ */
84
+ export const dragReorderRows = directive(DragReorderRowsDirective);
85
+ /**
86
+ * The directive class behind {@link dragReorderGhost}.
87
+ *
88
+ * @category Internal
89
+ */
90
+ export class DragReorderGhostDirective extends Directive {
91
+ constructor(partInfo) {
92
+ super(partInfo);
93
+ assertIsElementPartInfo(partInfo, 'dragReorderGhost');
94
+ }
95
+ /** Applies the latest directive arguments. */
96
+ update(partInfo, [manager]) {
97
+ assertIsElementPartInfo(partInfo, 'dragReorderGhost');
98
+ manager.attachGhost(assertWrap.instanceOf(partInfo.element, HTMLElement));
99
+ return this.render(manager);
100
+ }
101
+ /** Renders nothing, this directive only attaches behavior to its element. */
102
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
103
+ render(manager) {
104
+ return undefined;
105
+ }
106
+ }
107
+ /**
108
+ * Turns the element it is attached to into the translucent copy of the dragged row: it is
109
+ * absolutely positioned over the dragged row and then follows the pointer. Render it only while the
110
+ * manager's `value.draggedIndex` is set, with a copy of that row inside it.
111
+ *
112
+ * @category Reorder
113
+ */
114
+ export const dragReorderGhost = directive(DragReorderGhostDirective);
@@ -1,2 +1,3 @@
1
+ export * from './drag-reorder.directive.js';
1
2
  export * from './popover.directive.js';
2
3
  export * from './tooltip.directive.js';
@@ -1,2 +1,3 @@
1
+ export * from './drag-reorder.directive.js';
1
2
  export * from './popover.directive.js';
2
3
  export * from './tooltip.directive.js';
@@ -19,6 +19,8 @@ export declare const ViraMenuItem: import("element-vir").DeclarativeElementDefin
19
19
  * _always_ be shown, even if `selected` is set to `false`.
20
20
  */
21
21
  iconOverride: ViraIconSvg;
22
+ /** Removes the icon entirely, including the space it reserves when not shown. */
23
+ hideIcon: boolean;
22
24
  /**
23
25
  * When `true`, activating this item will _not_ close the containing popover.
24
26
  *
@@ -1,5 +1,5 @@
1
1
  import { assertWrap } from '@augment-vir/assert';
2
- import { css, defineElementEvent, html } from 'element-vir';
2
+ import { css, defineElementEvent, html, nothing } from 'element-vir';
3
3
  import { listenTo } from 'typed-event-target';
4
4
  import { lucideIcons } from '../../icons/lucide-icons.js';
5
5
  import { createSizedIcon } from '../../icons/sized-icon.js';
@@ -241,9 +241,13 @@ export const ViraMenuItem = defineViraElement()({
241
241
  },
242
242
  render({ inputs }) {
243
243
  return html `
244
- <${ViraIcon.assign({
245
- icon: inputs.iconOverride || selectedCheckIcon,
246
- })}></${ViraIcon}>
244
+ ${inputs.hideIcon
245
+ ? nothing
246
+ : html `
247
+ <${ViraIcon.assign({
248
+ icon: inputs.iconOverride || selectedCheckIcon,
249
+ })}></${ViraIcon}>
250
+ `}
247
251
  <div class="slot-wrapper">
248
252
  <slot>&nbsp;</slot>
249
253
  </div>
@@ -7,7 +7,7 @@ import { ChevronUp16Icon } from '../icons/index.js';
7
7
  import { createFocusStyles } from '../styles/focus.js';
8
8
  import { viraFormCssVars } from '../styles/form-styles.js';
9
9
  import { ViraSize, viraSizeHeights } from '../styles/form-variants.js';
10
- import { noNativeFormStyles, noUserSelect, viraAnimationDurations } from '../styles/index.js';
10
+ import { noNativeFormStyles, noUserSelect, viraAnimationDurations, viraTheme, } from '../styles/index.js';
11
11
  import { defineViraElement } from '../util/define-vira-element.js';
12
12
  import { fuzzyMatch } from '../util/fuzzy-match.js';
13
13
  import { renderMenuItemEntries } from '../util/menu-helpers.js';
@@ -50,6 +50,24 @@ export const ViraDropdown = defineViraElement()({
50
50
  white-space: nowrap;
51
51
  }
52
52
 
53
+ .option-label {
54
+ display: inline-flex;
55
+ align-items: center;
56
+ gap: 8px;
57
+ max-width: 100%;
58
+ vertical-align: bottom;
59
+ }
60
+
61
+ .option-icon {
62
+ flex-shrink: 0;
63
+ }
64
+
65
+ .option-label-text {
66
+ overflow: hidden;
67
+ text-overflow: ellipsis;
68
+ white-space: nowrap;
69
+ }
70
+
53
71
  .search-input {
54
72
  ${noNativeFormStyles};
55
73
  flex-grow: 1;
@@ -131,20 +149,21 @@ export const ViraDropdown = defineViraElement()({
131
149
  .option-group-label,
132
150
  .no-options {
133
151
  ${noUserSelect};
134
- /* Aligns with the label text of each ViraMenuItem, past its check icon. */
135
- padding: 8px 12px 4px
136
- calc(${ViraMenuItem.cssVars['vira-menu-item-icon-gap'].value} * 3 + 16px);
137
- opacity: 0.6;
138
152
  text-align: left;
139
153
  }
140
154
 
141
155
  .option-group-label {
156
+ color: ${viraTheme.colors['vira-grey-foreground-placeholder'].foreground.value};
157
+ padding: 8px 12px 4px calc(${ViraMenuItem.cssVars['vira-menu-item-icon-gap'].value} * 2);
142
158
  font-weight: ${viraFormCssVars['vira-form-label-font-weight'].value};
143
159
  }
144
160
 
145
161
  .no-options {
146
162
  cursor: default;
147
- padding-bottom: 8px;
163
+ opacity: 0.6;
164
+ /* Aligns with the label text of each ViraMenuItem, past its check icon. */
165
+ padding: 8px 12px 8px
166
+ calc(${ViraMenuItem.cssVars['vira-menu-item-icon-gap'].value} * 3 + 16px);
148
167
  }
149
168
 
150
169
  ${ViraMenuItem}.option-group-label {
@@ -224,6 +243,29 @@ export const ViraDropdown = defineViraElement()({
224
243
  : inputs.isMultiSelect && selectedOptions.length > 1
225
244
  ? `${selectedOptions.length} Selected`
226
245
  : selectedOptions[0]?.label || '';
246
+ function renderOptionLabel(option) {
247
+ return option.icon || option.labelTemplate
248
+ ? html `
249
+ <span class="option-label">
250
+ ${option.icon
251
+ ? html `
252
+ <${ViraIcon.assign({
253
+ icon: option.icon,
254
+ })}
255
+ class="option-icon"
256
+ ></${ViraIcon}>
257
+ `
258
+ : nothing}
259
+ <span class="option-label-text">
260
+ ${option.labelTemplate ?? option.label}
261
+ </span>
262
+ </span>
263
+ `
264
+ : option.label;
265
+ }
266
+ const selectionTemplate = check.isLengthExactly(selectedOptions, 1)
267
+ ? renderOptionLabel(selectedOptions[0])
268
+ : selectionDisplay;
227
269
  function selectOption(option) {
228
270
  const newSelectedValues = inputs.isMultiSelect
229
271
  ? selectedOptions.includes(option)
@@ -294,6 +336,7 @@ export const ViraDropdown = defineViraElement()({
294
336
  ? html `
295
337
  <${ViraMenuItem.assign({
296
338
  disablePointerStyles: true,
339
+ hideIcon: true,
297
340
  })}
298
341
  class="option-group-label"
299
342
  ${state.navController ? nav(state.navController) : nothing}
@@ -330,7 +373,7 @@ export const ViraDropdown = defineViraElement()({
330
373
  function renderOptionEntries(options) {
331
374
  return renderMenuItemEntries(options.map((option) => {
332
375
  return {
333
- content: option.label,
376
+ content: renderOptionLabel(option),
334
377
  onClick() {
335
378
  selectOption(option);
336
379
  },
@@ -540,7 +583,7 @@ export const ViraDropdown = defineViraElement()({
540
583
  })}"
541
584
  title=${ifDefined(shouldUsePlaceholder ? undefined : selectionDisplay)}
542
585
  >
543
- ${prefixTemplate} ${selectionDisplay}
586
+ ${prefixTemplate} ${selectionTemplate}
544
587
  </span>
545
588
  `}
546
589
 
@@ -557,7 +600,7 @@ export const ViraDropdown = defineViraElement()({
557
600
  `;
558
601
  const contentTemplate = inputs.isReadonly
559
602
  ? html `
560
- <span class="readonly-value">${selectionDisplay}</span>
603
+ <span class="readonly-value">${selectionTemplate}</span>
561
604
  `
562
605
  : triggerTemplate;
563
606
  if (inputs.label) {
@@ -1,4 +1,5 @@
1
1
  import { type PartialWithUndefined } from '@augment-vir/common';
2
+ import { DragReorderManager } from '../util/drag-reorder-manager.js';
2
3
  import { type ViraFormField, type ViraFormFields } from '../util/vira-form-fields.js';
3
4
  /**
4
5
  * A form element.
@@ -44,8 +45,17 @@ export declare const ViraForm: import("element-vir").DeclarativeElementDefinitio
44
45
  * @default false
45
46
  */
46
47
  isReadonly: boolean;
48
+ /**
49
+ * When `true`, each visible field gets a drag handle. Dropping a field emits
50
+ * `fieldOrderChange` with every field key in its new order. The form does not reorder
51
+ * `fields` itself: pass them back in the emitted order.
52
+ *
53
+ * @default false
54
+ */
55
+ isReorderable: boolean;
47
56
  }>, {
48
57
  lastIsValid: boolean;
58
+ dragReorder: DragReorderManager;
49
59
  }, {
50
60
  valueChange: import("element-vir").DefineElementEvent<{
51
61
  key: string;
@@ -53,4 +63,6 @@ export declare const ViraForm: import("element-vir").DeclarativeElementDefinitio
53
63
  validChange: import("element-vir").DefineElementEvent<{
54
64
  allFieldsAreValid: boolean;
55
65
  }>;
56
- }, "vira-form-", "vira-form-", readonly [], readonly []>;
66
+ /** Every field key, hidden ones included, in the order the user dropped them. */
67
+ fieldOrderChange: import("element-vir").DefineElementEvent<string[]>;
68
+ }, "vira-form-", "vira-form-", readonly [], readonly ["dragHandle"]>;
@@ -1,12 +1,16 @@
1
1
  import { check } from '@augment-vir/assert';
2
2
  import { getObjectTypedEntries } from '@augment-vir/common';
3
- import { css, defineElementEvent, html, listen, nothing, testId, } from 'element-vir';
3
+ import { classMap, css, defineElementEvent, html, listen, nothing, testId, } from 'element-vir';
4
+ import { dragReorderGhost, dragReorderHandle, dragReorderRows, } from '../directives/drag-reorder.directive.js';
5
+ import { lucideIcons } from '../icons/lucide-icons.js';
4
6
  import { viraFormCssVars } from '../styles/form-styles.js';
5
7
  import { defineViraElement } from '../util/define-vira-element.js';
6
- import { applyRequiredLabel, areFormFieldsValid, ViraFormFieldType, } from '../util/vira-form-fields.js';
8
+ import { DragReorderManager } from '../util/drag-reorder-manager.js';
9
+ import { applyRequiredLabel, areFormFieldsValid, moveFormFieldKey, ViraFormFieldType, } from '../util/vira-form-fields.js';
7
10
  import { ViraCheckbox } from './vira-checkbox.element.js';
8
11
  import { ViraDateInput } from './vira-date-input.element.js';
9
12
  import { ViraDropdown } from './vira-dropdown.element.js';
13
+ import { ViraIcon } from './vira-icon.element.js';
10
14
  import { ViraInput, ViraInputType } from './vira-input.element.js';
11
15
  import { ViraTextArea } from './vira-text-area.element.js';
12
16
  /**
@@ -20,12 +24,18 @@ export const ViraForm = defineViraElement()({
20
24
  state() {
21
25
  return {
22
26
  lastIsValid: false,
27
+ dragReorder: new DragReorderManager(),
23
28
  };
24
29
  },
25
30
  events: {
26
31
  valueChange: defineElementEvent(),
27
32
  validChange: defineElementEvent(),
33
+ /** Every field key, hidden ones included, in the order the user dropped them. */
34
+ fieldOrderChange: defineElementEvent(),
28
35
  },
36
+ testIds: [
37
+ 'dragHandle',
38
+ ],
29
39
  styles: css `
30
40
  :host {
31
41
  display: flex;
@@ -43,6 +53,58 @@ export const ViraForm = defineViraElement()({
43
53
  }
44
54
  }
45
55
 
56
+ .drag-ghost {
57
+ opacity: 0.6;
58
+
59
+ & .drop-above,
60
+ & .drop-above > *,
61
+ & .drop-below,
62
+ & .drop-below > * {
63
+ box-shadow: none;
64
+ }
65
+
66
+ & .horizontal-fields {
67
+ border-spacing: 0;
68
+ table-layout: fixed;
69
+ }
70
+ }
71
+
72
+ .reorder-rows {
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 10px;
76
+ }
77
+
78
+ .reorder-row {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 4px;
82
+
83
+ & > :last-child {
84
+ flex-grow: 1;
85
+ }
86
+ }
87
+
88
+ .drag-handle {
89
+ display: flex;
90
+ cursor: grab;
91
+ color: ${viraFormCssVars['vira-form-placeholder-color'].value};
92
+
93
+ &:active {
94
+ cursor: grabbing;
95
+ }
96
+ }
97
+
98
+ div.drop-above,
99
+ tr.drop-above > * {
100
+ box-shadow: 0 -5px 0 0 ${viraFormCssVars['vira-form-focus-outline-color'].value};
101
+ }
102
+
103
+ div.drop-below,
104
+ tr.drop-below > * {
105
+ box-shadow: 0 5px 0 0 ${viraFormCssVars['vira-form-focus-outline-color'].value};
106
+ }
107
+
46
108
  .horizontal-fields {
47
109
  width: 100%;
48
110
  border-collapse: separate;
@@ -53,6 +115,11 @@ export const ViraForm = defineViraElement()({
53
115
  padding: 0;
54
116
  }
55
117
 
118
+ & .handle-cell {
119
+ width: 0;
120
+ vertical-align: middle;
121
+ }
122
+
56
123
  & th {
57
124
  padding: 0 8px;
58
125
  vertical-align: middle;
@@ -80,7 +147,7 @@ export const ViraForm = defineViraElement()({
80
147
  }
81
148
  }
82
149
  `,
83
- render({ inputs, dispatch, events, state, updateState }) {
150
+ render({ inputs, dispatch, events, state, updateState, testIds }) {
84
151
  const currentIsValid = areFormFieldsValid(inputs.fields);
85
152
  if (currentIsValid !== state.lastIsValid) {
86
153
  updateState({
@@ -92,15 +159,61 @@ export const ViraForm = defineViraElement()({
92
159
  },
93
160
  }));
94
161
  }
95
- function wrapFormField({ fieldTemplate, label, }) {
162
+ const visibleKeys = getObjectTypedEntries(inputs.fields)
163
+ .filter(([, field,]) => !field.isHidden)
164
+ .map(([key]) => key);
165
+ function dropField({ fromIndex, toIndex }) {
166
+ const movedKey = visibleKeys[fromIndex];
167
+ if (movedKey == undefined) {
168
+ return;
169
+ }
170
+ dispatch(new events.fieldOrderChange({
171
+ detail: moveFormFieldKey({
172
+ keys: Object.keys(inputs.fields),
173
+ movedKey,
174
+ beforeKey: visibleKeys[toIndex],
175
+ }),
176
+ }));
177
+ }
178
+ function wrapFormField({ fieldTemplate, label, key, }) {
179
+ const dropLine = state.dragReorder.readDropLine(visibleKeys.indexOf(key));
180
+ const rowClasses = classMap({
181
+ 'reorder-row': !inputs.useHorizontalLabels,
182
+ 'drop-above': dropLine.isAbove,
183
+ 'drop-below': dropLine.isBelow,
184
+ });
185
+ const dragHandle = inputs.isReorderable
186
+ ? html `
187
+ <div
188
+ class="drag-handle"
189
+ title="drag to reorder"
190
+ ${testId(testIds.dragHandle)}
191
+ ${dragReorderHandle(state.dragReorder)}
192
+ >
193
+ <${ViraIcon.assign({
194
+ icon: lucideIcons.GripVertical,
195
+ })}></${ViraIcon}>
196
+ </div>
197
+ `
198
+ : nothing;
96
199
  if (inputs.useHorizontalLabels) {
97
200
  return html `
98
- <tr>
201
+ <tr class=${rowClasses}>
202
+ ${inputs.isReorderable
203
+ ? html `
204
+ <td class="handle-cell">${dragHandle}</td>
205
+ `
206
+ : nothing}
99
207
  <th scope="row">${label}</th>
100
208
  <td>${fieldTemplate}</td>
101
209
  </tr>
102
210
  `;
103
211
  }
212
+ else if (inputs.isReorderable) {
213
+ return html `
214
+ <div class=${rowClasses}>${dragHandle} ${fieldTemplate}</div>
215
+ `;
216
+ }
104
217
  else {
105
218
  return fieldTemplate;
106
219
  }
@@ -114,6 +227,7 @@ export const ViraForm = defineViraElement()({
114
227
  else if (field.type === ViraFormFieldType.Checkbox) {
115
228
  const checkboxLabel = applyRequiredLabel(field.label, showRequiredMarker);
116
229
  return wrapFormField({
230
+ key,
117
231
  label: checkboxLabel,
118
232
  fieldTemplate: html `
119
233
  <${ViraCheckbox.assign({
@@ -157,6 +271,7 @@ export const ViraForm = defineViraElement()({
157
271
  : {};
158
272
  if (field.type === ViraFormFieldType.Select) {
159
273
  return wrapFormField({
274
+ key,
160
275
  label,
161
276
  fieldTemplate: html `
162
277
  <${ViraDropdown.assign({
@@ -186,6 +301,7 @@ export const ViraForm = defineViraElement()({
186
301
  }
187
302
  else if (field.type === ViraFormFieldType.TextArea) {
188
303
  return wrapFormField({
304
+ key,
189
305
  label,
190
306
  fieldTemplate: html `
191
307
  <${ViraTextArea.assign({
@@ -215,6 +331,7 @@ export const ViraForm = defineViraElement()({
215
331
  }
216
332
  else if (field.type === ViraFormFieldType.Number) {
217
333
  return wrapFormField({
334
+ key,
218
335
  label,
219
336
  fieldTemplate: html `
220
337
  <${ViraInput.assign({
@@ -264,6 +381,7 @@ export const ViraForm = defineViraElement()({
264
381
  }
265
382
  else if (field.type === ViraFormFieldType.Date) {
266
383
  return wrapFormField({
384
+ key,
267
385
  label,
268
386
  fieldTemplate: html `
269
387
  <${ViraDateInput.assign({
@@ -294,6 +412,7 @@ export const ViraForm = defineViraElement()({
294
412
  }
295
413
  else {
296
414
  return wrapFormField({
415
+ key,
297
416
  label,
298
417
  fieldTemplate: html `
299
418
  <${ViraInput.assign({
@@ -353,14 +472,53 @@ export const ViraForm = defineViraElement()({
353
472
  const formFieldsWrapper = inputs.useHorizontalLabels
354
473
  ? html `
355
474
  <table class="horizontal-fields">
356
- <tbody>${formFieldTemplates}</tbody>
475
+ <tbody ${dragReorderRows(state.dragReorder, dropField)}>
476
+ ${formFieldTemplates}
477
+ </tbody>
357
478
  </table>
358
479
  `
359
- : formFieldTemplates;
480
+ : inputs.isReorderable
481
+ ? html `
482
+ <div class="reorder-rows" ${dragReorderRows(state.dragReorder, dropField)}>
483
+ ${formFieldTemplates}
484
+ </div>
485
+ `
486
+ : formFieldTemplates;
487
+ const draggedKey = state.dragReorder.value.draggedIndex == undefined
488
+ ? undefined
489
+ : visibleKeys[state.dragReorder.value.draggedIndex];
490
+ const draggedRow = draggedKey == undefined
491
+ ? undefined
492
+ : formFieldTemplates[Object.keys(inputs.fields).indexOf(draggedKey)];
493
+ const dragGhost = draggedRow
494
+ ? html `
495
+ <div class="drag-ghost" ${dragReorderGhost(state.dragReorder)}>
496
+ ${inputs.useHorizontalLabels
497
+ ? html `
498
+ <table class="horizontal-fields">
499
+ <colgroup>
500
+ ${state.dragReorder.value.draggedCellWidths.map((cellWidth) => {
501
+ return html `
502
+ <col
503
+ style=${css `
504
+ width: ${cellWidth}px;
505
+ `}
506
+ />
507
+ `;
508
+ })}
509
+ </colgroup>
510
+ <tbody>${draggedRow}</tbody>
511
+ </table>
512
+ `
513
+ : draggedRow}
514
+ </div>
515
+ `
516
+ : nothing;
360
517
  return html `
361
518
  <form ${listen('submit', (event) => event.preventDefault())}>
362
519
  ${formFieldsWrapper}
363
520
  <slot></slot>
521
+ ${dragGhost}
364
522
  </form>
365
523
  `;
366
524
  },
@@ -10,5 +10,5 @@ export declare const viraIconCssVars: import("lit-css-vars").CssVarDefinitions<{
10
10
  readonly 'vira-icon-stroke-color': "currentColor";
11
11
  /** To be used for coloring an icon's fill. */
12
12
  readonly 'vira-icon-fill-color': "none";
13
- readonly 'vira-icon-stroke-width': "1.5px";
13
+ readonly 'vira-icon-stroke-width': "1px";
14
14
  }>;
@@ -11,5 +11,5 @@ export const viraIconCssVars = defineCssVars({
11
11
  'vira-icon-stroke-color': 'currentColor',
12
12
  /** To be used for coloring an icon's fill. */
13
13
  'vira-icon-fill-color': 'none',
14
- 'vira-icon-stroke-width': '1.5px',
14
+ 'vira-icon-stroke-width': '1px',
15
15
  });
@@ -8,7 +8,8 @@ import { type ViraIconSvg } from './icon-svg.js';
8
8
  export type LucideIconKey = keyof typeof lucideStaticImport;
9
9
  /**
10
10
  * All [Lucide icons](https://lucide.dev) in a format compatible with `ViraIcon`. Each icon entry
11
- * can be accessed directly as a {@link ViraIconSvg}.
11
+ * can be accessed directly as a {@link ViraIconSvg}. Icons render at 16px instead of Lucide's
12
+ * default 24px; use `createSizedIcon` for other sizes.
12
13
  *
13
14
  * @category Icon
14
15
  */
@@ -5,6 +5,8 @@ const defaultLucideAttributes = {
5
5
  fill: String(viraIconCssVars['vira-icon-fill-color'].value),
6
6
  stroke: String(viraIconCssVars['vira-icon-stroke-color'].value),
7
7
  'stroke-width': String(viraIconCssVars['vira-icon-stroke-width'].value),
8
+ width: '16',
9
+ height: '16',
8
10
  };
9
11
  function setSvgAttribute({ svgString, attributeName, value, }) {
10
12
  const escapedName = attributeName.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
@@ -43,7 +45,8 @@ function createLucideIconEntry(iconKey) {
43
45
  const lucideIconCache = new Map();
44
46
  /**
45
47
  * All [Lucide icons](https://lucide.dev) in a format compatible with `ViraIcon`. Each icon entry
46
- * can be accessed directly as a {@link ViraIconSvg}.
48
+ * can be accessed directly as a {@link ViraIconSvg}. Icons render at 16px instead of Lucide's
49
+ * default 24px; use `createSizedIcon` for other sizes.
47
50
  *
48
51
  * @category Icon
49
52
  */
@@ -0,0 +1,84 @@
1
+ import { Observable } from 'observavir';
2
+ /**
3
+ * A finished drag from {@link DragReorderManager}.
4
+ *
5
+ * @category Reorder
6
+ */
7
+ export type DragReorderMove = {
8
+ fromIndex: number;
9
+ /**
10
+ * Index, in the order before the move, of the row that the dragged row was dropped in front of.
11
+ * Equals the row count when it was dropped after the last row.
12
+ */
13
+ toIndex: number;
14
+ };
15
+ /**
16
+ * The current value of a {@link DragReorderManager}.
17
+ *
18
+ * @category Reorder
19
+ */
20
+ export type DragReorderState = {
21
+ draggedIndex: number | undefined;
22
+ /**
23
+ * Same meaning as {@link DragReorderMove.toIndex}. `undefined` while the pointer is where a drop
24
+ * would not move the row.
25
+ */
26
+ dropIndex: number | undefined;
27
+ rowCount: number;
28
+ /**
29
+ * Widths of the dragged row's direct children, so a ghost of a table row can keep the real
30
+ * column widths. Empty while nothing is dragged.
31
+ */
32
+ draggedCellWidths: number[];
33
+ };
34
+ /**
35
+ * Drag-to-reorder for rows that the host element renders itself, so the host keeps its own markup
36
+ * and styles (including table rows). Store one in an element's `state` so the element re-renders as
37
+ * the drag progresses, then attach `dragReorderRows` to the element whose direct children are the
38
+ * rows, `dragReorderHandle` to each row's handle, and `dragReorderGhost` to a copy of the dragged
39
+ * row rendered while `value.draggedIndex` is set. The ghost is positioned over the dragged row and
40
+ * follows the pointer; style its opacity yourself.
41
+ *
42
+ * Nothing is reordered for you: apply each {@link DragReorderMove} to your own data.
43
+ *
44
+ * @category Reorder
45
+ */
46
+ export declare class DragReorderManager extends Observable<DragReorderState> {
47
+ protected rowsElement: HTMLElement | undefined;
48
+ /** The callback given to `attachRows`. */
49
+ protected onReorder: ((move: Readonly<DragReorderMove>) => void) | undefined;
50
+ protected draggedRow: Element | undefined;
51
+ protected ghostElement: HTMLElement | undefined;
52
+ protected pointerStart: {
53
+ x: number;
54
+ y: number;
55
+ };
56
+ /** Removes the global listeners that move the ghost. Set only while a drag is in progress. */
57
+ protected stopTrackingPointer: (() => void) | undefined;
58
+ constructor();
59
+ /** Whether to draw the drop line above or below the row at `rowIndex`. */
60
+ readDropLine(rowIndex: number): {
61
+ isAbove: boolean;
62
+ isBelow: boolean;
63
+ };
64
+ /** Used by `dragReorderRows`. */
65
+ attachRows(rowsElement: HTMLElement, onReorder: (move: Readonly<DragReorderMove>) => void): void;
66
+ /** Used by `dragReorderGhost`. */
67
+ attachGhost(ghostElement: HTMLElement): void;
68
+ /** Used by `dragReorderHandle`. */
69
+ startDrag(event: DragEvent, handle: Element): void;
70
+ /** Used by `dragReorderRows`. */
71
+ dragOver(event: DragEvent): void;
72
+ /** Used by `dragReorderRows`. */
73
+ drop(event: DragEvent): void;
74
+ /**
75
+ * Used by `dragReorderHandle`. A release outside the rows still lands where the drop line was
76
+ * last drawn.
77
+ */
78
+ endDrag(): void;
79
+ destroy(): void;
80
+ /** Counts the rows whose vertical midpoint is above the pointer. */
81
+ protected readDropIndex(clientY: number): number | undefined;
82
+ /** Resets to the idle state and only calls `onReorder` when `dropIndex` is defined. */
83
+ protected finishDrag(dropIndex: number | undefined): void;
84
+ }
@@ -0,0 +1,213 @@
1
+ import { Observable } from 'observavir';
2
+ import { listenToGlobal } from 'typed-event-target';
3
+ const idleState = {
4
+ draggedIndex: undefined,
5
+ dropIndex: undefined,
6
+ rowCount: 0,
7
+ draggedCellWidths: [],
8
+ };
9
+ /**
10
+ * The ghost row replaces the browser's own drag preview, which only shows the handle that the drag
11
+ * started on.
12
+ */
13
+ function setEmptyDragImage(dataTransfer) {
14
+ const emptyImage = document.createElement('div');
15
+ emptyImage.style.position = 'fixed';
16
+ emptyImage.style.top = '0';
17
+ emptyImage.style.left = '0';
18
+ emptyImage.style.width = '1px';
19
+ emptyImage.style.height = '1px';
20
+ emptyImage.style.pointerEvents = 'none';
21
+ document.body.append(emptyImage);
22
+ dataTransfer.setDragImage(emptyImage, 0, 0);
23
+ globalThis.requestAnimationFrame(() => emptyImage.remove());
24
+ }
25
+ /**
26
+ * Drag-to-reorder for rows that the host element renders itself, so the host keeps its own markup
27
+ * and styles (including table rows). Store one in an element's `state` so the element re-renders as
28
+ * the drag progresses, then attach `dragReorderRows` to the element whose direct children are the
29
+ * rows, `dragReorderHandle` to each row's handle, and `dragReorderGhost` to a copy of the dragged
30
+ * row rendered while `value.draggedIndex` is set. The ghost is positioned over the dragged row and
31
+ * follows the pointer; style its opacity yourself.
32
+ *
33
+ * Nothing is reordered for you: apply each {@link DragReorderMove} to your own data.
34
+ *
35
+ * @category Reorder
36
+ */
37
+ export class DragReorderManager extends Observable {
38
+ rowsElement;
39
+ /** The callback given to `attachRows`. */
40
+ onReorder;
41
+ draggedRow;
42
+ ghostElement;
43
+ pointerStart = {
44
+ x: 0,
45
+ y: 0,
46
+ };
47
+ /** Removes the global listeners that move the ghost. Set only while a drag is in progress. */
48
+ stopTrackingPointer;
49
+ constructor() {
50
+ super({
51
+ defaultValue: idleState,
52
+ });
53
+ }
54
+ /** Whether to draw the drop line above or below the row at `rowIndex`. */
55
+ readDropLine(rowIndex) {
56
+ return {
57
+ isAbove: this.value.dropIndex === rowIndex,
58
+ isBelow: rowIndex === this.value.rowCount - 1 &&
59
+ this.value.dropIndex === this.value.rowCount,
60
+ };
61
+ }
62
+ /** Used by `dragReorderRows`. */
63
+ attachRows(rowsElement, onReorder) {
64
+ this.rowsElement = rowsElement;
65
+ this.onReorder = onReorder;
66
+ }
67
+ /** Used by `dragReorderGhost`. */
68
+ attachGhost(ghostElement) {
69
+ if (ghostElement === this.ghostElement) {
70
+ return;
71
+ }
72
+ this.ghostElement = ghostElement;
73
+ ghostElement.style.position = 'absolute';
74
+ ghostElement.style.boxSizing = 'border-box';
75
+ ghostElement.style.margin = '0';
76
+ ghostElement.style.pointerEvents = 'none';
77
+ ghostElement.style.visibility = 'hidden';
78
+ /** The ghost is not laid out until after the current render. */
79
+ globalThis.requestAnimationFrame(() => {
80
+ if (ghostElement !== this.ghostElement || !this.draggedRow) {
81
+ return;
82
+ }
83
+ const rowRect = this.draggedRow.getBoundingClientRect();
84
+ const translate = ghostElement.style.translate;
85
+ ghostElement.style.translate = '';
86
+ ghostElement.style.left = '0';
87
+ ghostElement.style.top = '0';
88
+ ghostElement.style.width = `${rowRect.width}px`;
89
+ /**
90
+ * Measuring from a zero offset works whatever the ghost's containing block is, so the
91
+ * host does not have to position anything.
92
+ */
93
+ const ghostRect = ghostElement.getBoundingClientRect();
94
+ ghostElement.style.left = `${rowRect.left - ghostRect.left}px`;
95
+ ghostElement.style.top = `${rowRect.top - ghostRect.top}px`;
96
+ ghostElement.style.translate = translate;
97
+ ghostElement.style.visibility = '';
98
+ });
99
+ }
100
+ /** Used by `dragReorderHandle`. */
101
+ startDrag(event, handle) {
102
+ const rows = [...(this.rowsElement?.children || [])];
103
+ const draggedIndex = rows.findIndex((row) => row.contains(handle));
104
+ const draggedRow = rows[draggedIndex];
105
+ if (!draggedRow) {
106
+ return;
107
+ }
108
+ this.stopTrackingPointer?.();
109
+ this.draggedRow = draggedRow;
110
+ this.pointerStart = {
111
+ x: event.clientX,
112
+ y: event.clientY,
113
+ };
114
+ if (event.dataTransfer) {
115
+ event.dataTransfer.setData('text/plain', '');
116
+ event.dataTransfer.effectAllowed = 'move';
117
+ setEmptyDragImage(event.dataTransfer);
118
+ }
119
+ const stopListeners = [
120
+ listenToGlobal('dragover', (dragEvent) => {
121
+ /**
122
+ * Accepting the drop everywhere stops the browser from animating its drag preview
123
+ * back to the start when the pointer is released.
124
+ */
125
+ dragEvent.preventDefault();
126
+ if (this.ghostElement) {
127
+ this.ghostElement.style.translate = [
128
+ `${dragEvent.clientX - this.pointerStart.x}px`,
129
+ `${dragEvent.clientY - this.pointerStart.y}px`,
130
+ ].join(' ');
131
+ }
132
+ }),
133
+ listenToGlobal('drop', (dropEvent) => dropEvent.preventDefault()),
134
+ ];
135
+ this.stopTrackingPointer = () => {
136
+ stopListeners.forEach((stopListener) => stopListener());
137
+ };
138
+ this.setValue({
139
+ draggedIndex,
140
+ dropIndex: undefined,
141
+ rowCount: rows.length,
142
+ draggedCellWidths: [...draggedRow.children].map((cell) => cell.getBoundingClientRect().width),
143
+ });
144
+ }
145
+ /** Used by `dragReorderRows`. */
146
+ dragOver(event) {
147
+ if (this.value.draggedIndex == undefined) {
148
+ return;
149
+ }
150
+ event.preventDefault();
151
+ if (event.dataTransfer) {
152
+ event.dataTransfer.dropEffect = 'move';
153
+ }
154
+ const dropIndex = this.readDropIndex(event.clientY);
155
+ if (dropIndex !== this.value.dropIndex) {
156
+ this.setValue({
157
+ ...this.value,
158
+ dropIndex,
159
+ });
160
+ }
161
+ }
162
+ /** Used by `dragReorderRows`. */
163
+ drop(event) {
164
+ if (this.value.draggedIndex == undefined) {
165
+ return;
166
+ }
167
+ event.preventDefault();
168
+ this.finishDrag(this.readDropIndex(event.clientY));
169
+ }
170
+ /**
171
+ * Used by `dragReorderHandle`. A release outside the rows still lands where the drop line was
172
+ * last drawn.
173
+ */
174
+ endDrag() {
175
+ this.finishDrag(this.value.dropIndex);
176
+ }
177
+ destroy() {
178
+ this.stopTrackingPointer?.();
179
+ super.destroy();
180
+ }
181
+ /** Counts the rows whose vertical midpoint is above the pointer. */
182
+ readDropIndex(clientY) {
183
+ const draggedIndex = this.value.draggedIndex;
184
+ const dropIndex = [...(this.rowsElement?.children || [])].filter((row) => {
185
+ const rect = row.getBoundingClientRect();
186
+ return clientY >= rect.top + rect.height / 2;
187
+ }).length;
188
+ /** Dropping a row directly above or below itself would not move it. */
189
+ return dropIndex === draggedIndex ||
190
+ draggedIndex == undefined ||
191
+ dropIndex === draggedIndex + 1
192
+ ? undefined
193
+ : dropIndex;
194
+ }
195
+ /** Resets to the idle state and only calls `onReorder` when `dropIndex` is defined. */
196
+ finishDrag(dropIndex) {
197
+ const fromIndex = this.value.draggedIndex;
198
+ this.stopTrackingPointer?.();
199
+ this.stopTrackingPointer = undefined;
200
+ this.draggedRow = undefined;
201
+ this.ghostElement = undefined;
202
+ if (fromIndex == undefined) {
203
+ return;
204
+ }
205
+ this.setValue(idleState);
206
+ if (dropIndex != undefined) {
207
+ this.onReorder?.({
208
+ fromIndex,
209
+ toIndex: dropIndex,
210
+ });
211
+ }
212
+ }
213
+ }
@@ -1,6 +1,7 @@
1
1
  export * from './anchored-popover.js';
2
2
  export * from './define-table.js';
3
3
  export * from './define-vira-element.js';
4
+ export * from './drag-reorder-manager.js';
4
5
  export * from './dynamic-element.js';
5
6
  export * from './fuzzy-match.js';
6
7
  export * from './menu-helpers.js';
@@ -1,6 +1,7 @@
1
1
  export * from './anchored-popover.js';
2
2
  export * from './define-table.js';
3
3
  export * from './define-vira-element.js';
4
+ export * from './drag-reorder-manager.js';
4
5
  export * from './dynamic-element.js';
5
6
  export * from './fuzzy-match.js';
6
7
  export * from './menu-helpers.js';
@@ -1,4 +1,6 @@
1
1
  import { type PartialWithUndefined } from '@augment-vir/common';
2
+ import { type HtmlInterpolation } from 'element-vir';
3
+ import { type ViraIconSvg } from '../icons/icon-svg.js';
2
4
  /**
3
5
  * Options for `ViraDropdown`.
4
6
  *
@@ -8,9 +10,17 @@ import { type PartialWithUndefined } from '@augment-vir/common';
8
10
  export type ViraDropdownOption = {
9
11
  /** A value or id, used to keep track of which option is selected. */
10
12
  value: string;
13
+ /**
14
+ * Plain text for the option. Still used for search filtering, tooltips, and the search input's
15
+ * text when `labelTemplate` is set.
16
+ */
11
17
  label: string;
12
18
  } & PartialWithUndefined<{
13
19
  disabled: boolean;
20
+ /** Drawn before the label. Not drawn inside a searchable dropdown's text input. */
21
+ icon: ViraIconSvg;
22
+ /** Rendered instead of `label`. Not rendered inside a searchable dropdown's text input. */
23
+ labelTemplate: HtmlInterpolation;
14
24
  }>;
15
25
  /**
16
26
  * A group of options for `ViraDropdown`, rendered under a group label.
@@ -138,3 +138,15 @@ export declare function applyRequiredLabel(label: string | HtmlInterpolation | u
138
138
  * @category Internal
139
139
  */
140
140
  export declare function areFormFieldsValid(formFields: Readonly<ViraFormFields>): boolean;
141
+ /**
142
+ * Moves one field key in front of another, keeping every other key (including hidden fields) in
143
+ * place. Used by `ViraForm` when its fields are reordered.
144
+ *
145
+ * @category Internal
146
+ */
147
+ export declare function moveFormFieldKey({ keys, movedKey, beforeKey, }: Readonly<{
148
+ keys: ReadonlyArray<string>;
149
+ movedKey: string;
150
+ /** The key that `movedKey` lands in front of. `undefined` moves it to the end. */
151
+ beforeKey: string | undefined;
152
+ }>): string[];
@@ -68,3 +68,19 @@ export function areFormFieldsValid(formFields) {
68
68
  }
69
69
  });
70
70
  }
71
+ /**
72
+ * Moves one field key in front of another, keeping every other key (including hidden fields) in
73
+ * place. Used by `ViraForm` when its fields are reordered.
74
+ *
75
+ * @category Internal
76
+ */
77
+ export function moveFormFieldKey({ keys, movedKey, beforeKey, }) {
78
+ const remainingKeys = keys.filter((key) => key !== movedKey);
79
+ const insertIndex = beforeKey == undefined ? -1 : remainingKeys.indexOf(beforeKey);
80
+ return insertIndex === -1
81
+ ? [
82
+ ...remainingKeys,
83
+ movedKey,
84
+ ]
85
+ : remainingKeys.toSpliced(insertIndex, 0, movedKey);
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vira",
3
- "version": "33.1.0",
3
+ "version": "33.3.0",
4
4
  "description": "A simple and highly versatile design system using element-vir.",
5
5
  "keywords": [
6
6
  "design",