@lapismd/design-core 0.1.0 → 0.2.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 (124) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/LICENSE.md +3 -3
  3. package/README.md +21 -1
  4. package/package.json +34 -15
  5. package/spec/src/styling-and-themes.md +11 -9
  6. package/src/shared/ai/chat.css +25 -1
  7. package/src/shared/ai/chat.ts +12 -0
  8. package/src/shared/ai/composer-input/ComposerInput.svelte +3 -2
  9. package/src/shared/ai/context.svelte.ts +2 -0
  10. package/src/shared/ai/experimental/reaction-bar/ReactionBar.svelte +1 -1
  11. package/src/shared/ai/layout/Layout.svelte +116 -10
  12. package/src/shared/ai/message-list/MessageList.svelte +123 -43
  13. package/src/shared/ai/message-list/MessageListHistoryFixture.svelte +112 -0
  14. package/src/shared/ai/message-list/MessageListLegacyFixture.svelte +52 -0
  15. package/src/shared/ai/message-list/VirtualizedMessageRows.svelte +299 -0
  16. package/src/shared/ai/message-list/position.ts +7 -0
  17. package/src/shared/ai/new-messages.svelte.ts +24 -5
  18. package/src/shared/ai/stream-scroll.svelte.ts +135 -6
  19. package/src/shared/ai/tool-call-detail/ToolCallDetail.svelte +71 -0
  20. package/src/shared/ai/tool-call-detail/tool-call-detail.ts +209 -0
  21. package/src/shared/ai/tool-calls/ToolCalls.svelte +1 -1
  22. package/src/shared/ai/trigger-menu.ts +2 -3
  23. package/src/shared/diff/core/merge/actions.ts +4 -3
  24. package/src/shared/diff/core/merge/render-model.ts +14 -10
  25. package/src/shared/diff/file-diff/FileDiff.css +68 -1
  26. package/src/shared/diff/file-diff/FileDiff.svelte +27 -14
  27. package/src/shared/diff/file-diff/FileDiffComposer.svelte +98 -16
  28. package/src/shared/diff/file-diff/index.ts +2 -0
  29. package/src/shared/diff/file-diff/types.ts +13 -0
  30. package/src/shared/diff/file-listing/FileListingViewModeToggle.css +42 -5
  31. package/src/shared/diff/index.ts +2 -0
  32. package/src/shared/diff/merge-editor/MergeEditor.svelte +2 -7
  33. package/src/shared/filter/filter-query/filters.ts +2 -2
  34. package/src/shared/filter/filter-query/parser.ts +9 -8
  35. package/src/shared/filter/power-search/PowerSearch.svelte +1 -1
  36. package/src/shared/filter/search-filter-bar/SearchFilterBar.svelte +2 -2
  37. package/src/shared/filter/search-filter-bar/SearchFilterInput.svelte +1 -1
  38. package/src/shared/filter/search-filter-bar/SearchFilterPredicateEditor.svelte +1 -1
  39. package/src/shared/filter/search-filter-bar/search-filter-syntax.ts +1 -1
  40. package/src/shared/forms/autocomplete-input/AutocompleteInput.svelte +19 -7
  41. package/src/shared/forms/confirm-button/ConfirmButton.svelte +38 -0
  42. package/src/shared/forms/confirm-button/ConfirmButtonExample.svelte +14 -0
  43. package/src/shared/forms/confirm-button/index.ts +1 -0
  44. package/src/shared/forms/confirm-dialog/ConfirmDialog.css +21 -0
  45. package/src/shared/forms/confirm-dialog/ConfirmDialog.svelte +116 -0
  46. package/src/shared/forms/confirm-dialog/ConfirmDialogExample.svelte +17 -0
  47. package/src/shared/forms/confirm-dialog/index.ts +2 -0
  48. package/src/shared/forms/dialog-frame/DialogFrame.css +130 -0
  49. package/src/shared/forms/dialog-frame/DialogFrame.svelte +108 -0
  50. package/src/shared/forms/dialog-frame/DialogFrameExample.svelte +37 -0
  51. package/src/shared/forms/dialog-frame/index.ts +6 -0
  52. package/src/shared/forms/dialog.ts +8 -0
  53. package/src/shared/forms/index.ts +11 -0
  54. package/src/shared/shadcn/button/button.svelte +4 -0
  55. package/src/shared/shadcn/code-block/highlightRanges.ts +10 -6
  56. package/src/shared/shadcn/code-block/syntax/defineSyntaxTheme.ts +4 -2
  57. package/src/shared/shadcn/code-block/tokenizer.ts +3 -3
  58. package/src/shared/shadcn/command/command-dialog.svelte +1 -1
  59. package/src/shared/shadcn/command/command-group.svelte +3 -1
  60. package/src/shared/shadcn/command-view/command-view.svelte +1 -6
  61. package/src/shared/shadcn/dialog/dialog-content.svelte +3 -4
  62. package/src/shared/shadcn/dialog/dialog-portal.svelte +10 -2
  63. package/src/shared/shadcn/dropdown-menu/dropdown-menu-checkbox-item.svelte +0 -691
  64. package/src/shared/shadcn/dropdown-menu/dropdown-menu-content.svelte +691 -0
  65. package/src/shared/shadcn/dropdown-menu/dropdown-menu-radio-group.svelte +1 -1
  66. package/src/shared/shadcn/scroll-area/scroll-area.svelte +2 -0
  67. package/src/shared/shadcn/spinner/spinner.svelte +3 -3
  68. package/src/shared/shadcn/tooltip/tooltip-content.svelte +10 -4
  69. package/src/shared/shadcn/tooltip/tooltip.tokens.ts +4 -0
  70. package/src/shared/shell/app-shell/AppShell.css +112 -32
  71. package/src/shared/shell/app-shell/AppShell.demo.css +37 -0
  72. package/src/shared/shell/app-shell/AppShellBodyContent.svelte +5 -1
  73. package/src/shared/shell/app-shell/AppShellBodySidebar.svelte +5 -1
  74. package/src/shared/shell/app-shell/AppShellMain.svelte +12 -1
  75. package/src/shared/shell/app-shell/AppShellMobilePanelSelector.svelte +1 -1
  76. package/src/shared/shell/app-shell/AppShellRoot.svelte +27 -13
  77. package/src/shared/shell/app-shell/AppShellSidebar.svelte +121 -19
  78. package/src/shared/shell/app-shell/AppShellSidebarClose.svelte +17 -17
  79. package/src/shared/shell/app-shell/AppShellSidebarToggle.svelte +38 -58
  80. package/src/shared/shell/app-shell/AppShellSurfaceLayer.svelte +173 -0
  81. package/src/shared/shell/app-shell/AppShellSurfaceLayerBody.svelte +32 -0
  82. package/src/shared/shell/app-shell/AppShellSurfaceLayerHeader.svelte +26 -0
  83. package/src/shared/shell/app-shell/app-shell-context.svelte.ts +6 -3
  84. package/src/shared/shell/app-shell/app-shell-controller.svelte.ts +269 -11
  85. package/src/shared/shell/app-shell/app-shell-sidebar-context.svelte.ts +1 -0
  86. package/src/shared/shell/app-shell/examples/AppShellBodyDemo.svelte +4 -0
  87. package/src/shared/shell/app-shell/examples/AppShellCompleteDemo.svelte +1 -0
  88. package/src/shared/shell/app-shell/examples/AppShellConversationDemo.svelte +1 -0
  89. package/src/shared/shell/app-shell/examples/AppShellExamples.css +624 -0
  90. package/src/shared/shell/app-shell/examples/AppShellFilesSidebarDemo.svelte +1 -0
  91. package/src/shared/shell/app-shell/examples/AppShellMarkdownDocumentDemo.svelte +6 -1
  92. package/src/shared/shell/app-shell/examples/AppShellMarkdownFilesDemo.svelte +1 -0
  93. package/src/shared/shell/app-shell/examples/AppShellModeSwitchDemo.svelte +1 -0
  94. package/src/shared/shell/app-shell/examples/AppShellMultiPanelDemo.svelte +187 -0
  95. package/src/shared/shell/app-shell/examples/AppShellProjectSidebarDemo.svelte +1 -0
  96. package/src/shared/shell/app-shell/examples/AppShellSidebarDemo.svelte +1 -0
  97. package/src/shared/shell/app-shell/examples/AppShellToolbarDemo.svelte +1 -0
  98. package/src/shared/shell/app-shell/index.ts +14 -0
  99. package/src/shared/shell/shell.tokens.css +2 -0
  100. package/src/shared/shell/shell.tokens.ts +5 -0
  101. package/src/shared/workspace/menu/WorkspaceContextMenuItems.svelte +2 -1
  102. package/src/shared/workspace/menu/WorkspaceMenuItems.svelte +2 -1
  103. package/src/shared/workspace/problems/ProblemsQuickFix.svelte +1 -4
  104. package/src/shared/workspace/settings/AppSettingsContent.svelte +6 -0
  105. package/src/shared/workspace/settings/AppSettingsNavigation.svelte +7 -0
  106. package/src/shared/workspace/settings/CopyableValue.svelte +81 -0
  107. package/src/shared/workspace/settings/DeclarativeSettingsSection.svelte +20 -0
  108. package/src/shared/workspace/settings/WorkspaceSettingField.svelte +110 -41
  109. package/src/shared/workspace/settings/WorkspaceSettingMultiSelect.svelte +7 -6
  110. package/src/shared/workspace/settings/WorkspaceSettingObjectTable.svelte +187 -23
  111. package/src/shared/workspace/settings/WorkspaceSettingSelect.svelte +29 -29
  112. package/src/shared/workspace/settings/WorkspaceSettings.css +190 -4
  113. package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomField.svelte +2 -2
  114. package/src/shared/workspace/settings/WorkspaceSettingsStoryCustomPage.svelte +13 -0
  115. package/src/shared/workspace/settings/WorkspaceSettingsSurface.svelte +50 -0
  116. package/src/shared/workspace/settings/configuration.ts +5 -0
  117. package/src/shared/workspace/settings/copyable-value.ts +10 -0
  118. package/src/shared/workspace/settings/index.ts +5 -0
  119. package/src/shared/workspace/settings/settings-controller.svelte.ts +349 -15
  120. package/src/shared/workspace/settings/types.ts +90 -8
  121. package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.css +25 -0
  122. package/src/storybook/app-shell-reference/AppShellTwoExpandedSidebarsReference.svelte +48 -0
  123. package/src/storybook/app-shell-reference/index.ts +1 -0
  124. package/src/theme.css +10 -0
@@ -21,6 +21,7 @@
21
21
  import WorkspaceSettingObjectTable from "./WorkspaceSettingObjectTable.svelte";
22
22
  import WorkspaceSettingSelect from "./WorkspaceSettingSelect.svelte";
23
23
  import WorkspaceSettingToggleTable from "./WorkspaceSettingToggleTable.svelte";
24
+ import CopyableValue from "./CopyableValue.svelte";
24
25
 
25
26
  let {
26
27
  controller,
@@ -31,7 +32,12 @@
31
32
  } = $props();
32
33
 
33
34
  let value = $derived(controller.get(field.id));
34
- let error = $derived(controller.validationErrors[field.id]);
35
+ let error = $derived(controller.getError(field.id));
36
+ let busy = $derived(controller.isBusy(field.id));
37
+ let actionResult = $derived(controller.getActionResult(field.id));
38
+ let controlDisabled = $derived(
39
+ field.disabled === true || field.readOnly === true,
40
+ );
35
41
  let options = $state<WorkspaceSettingOption[]>([]);
36
42
  let optionRequest = 0;
37
43
  let keyValueOptions = $derived.by(() => {
@@ -100,7 +106,8 @@
100
106
  return field.type === "key-value" ||
101
107
  field.type === "object-array" ||
102
108
  field.type === "object-grid" ||
103
- field.type === "object-map"
109
+ field.type === "object-map" ||
110
+ (field.type === "custom" && field.presentation === "full-width")
104
111
  ? "stacked"
105
112
  : "row";
106
113
  }
@@ -123,7 +130,7 @@
123
130
  const next = { ...current };
124
131
  delete next[currentKey];
125
132
  next[nextKey] = nextValue;
126
- controller.update(field.id, next);
133
+ void controller.set(field.id, next);
127
134
  }
128
135
 
129
136
  function nextKey(field: WorkspaceKeyValueSetting) {
@@ -180,17 +187,17 @@
180
187
  <Switch
181
188
  id={`setting-${field.id}`}
182
189
  checked={value === true}
183
- disabled={field.disabled}
184
- onCheckedChange={(checked) => controller.update(field.id, checked)}
190
+ disabled={controlDisabled}
191
+ onCheckedChange={(checked) => void controller.set(field.id, checked)}
185
192
  />
186
193
  {:else if field.type === "string" && field.presentation === "icon"}
187
194
  <WorkspaceIconPicker
188
195
  id={`setting-${field.id}`}
189
196
  value={String(value ?? "")}
190
- disabled={field.disabled}
197
+ disabled={controlDisabled}
191
198
  placeholder={field.placeholder ?? "Select an icon"}
192
199
  ariaLabel={field.title}
193
- onValueChange={(next) => controller.update(field.id, next)}
200
+ onValueChange={(next) => void controller.set(field.id, next)}
194
201
  />
195
202
  {:else if field.type === "string" && field.presentation === "textarea"}
196
203
  <Textarea
@@ -198,30 +205,30 @@
198
205
  rows={1}
199
206
  value={String(value ?? "")}
200
207
  placeholder={field.placeholder}
201
- disabled={field.disabled}
208
+ disabled={controlDisabled}
202
209
  aria-invalid={Boolean(error)}
203
210
  oninput={(event) =>
204
- controller.update(field.id, event.currentTarget.value)}
211
+ void controller.set(field.id, event.currentTarget.value)}
205
212
  />
206
213
  {:else if field.type === "string" && (field.presentation === "combobox" || field.optionsSource)}
207
214
  <WorkspaceSettingSelect
208
215
  id={`setting-${field.id}`}
209
216
  items={options}
210
217
  value={String(value ?? field.default)}
211
- disabled={field.disabled}
218
+ disabled={controlDisabled}
212
219
  ariaLabel={field.title}
213
220
  placeholder={field.placeholder}
214
- onValueChange={(next: string) => controller.update(field.id, next)}
221
+ onValueChange={(next: string) => void controller.set(field.id, next)}
215
222
  />
216
223
  {:else if field.type === "string" && field.presentation === "password"}
217
224
  <PasswordInput
218
225
  id={`setting-${field.id}`}
219
226
  value={String(value ?? "")}
220
227
  placeholder={field.placeholder}
221
- disabled={field.disabled}
228
+ disabled={controlDisabled}
222
229
  aria-invalid={Boolean(error)}
223
230
  oninput={(event) =>
224
- controller.update(field.id, event.currentTarget.value)}
231
+ void controller.set(field.id, event.currentTarget.value)}
225
232
  />
226
233
  {:else if field.type === "string"}
227
234
  <Input
@@ -239,10 +246,10 @@
239
246
  : "text"}
240
247
  value={String(value ?? "")}
241
248
  placeholder={field.placeholder}
242
- disabled={field.disabled}
249
+ disabled={controlDisabled}
243
250
  aria-invalid={Boolean(error)}
244
251
  oninput={(event) =>
245
- controller.update(field.id, event.currentTarget.value)}
252
+ void controller.set(field.id, event.currentTarget.value)}
246
253
  />
247
254
  {:else if (field.type === "number" || field.type === "integer") && field.minimum !== undefined && field.maximum !== undefined}
248
255
  <div class="ui-workspace-setting-range">
@@ -253,9 +260,10 @@
253
260
  min={field.minimum}
254
261
  max={field.maximum}
255
262
  step={field.step ?? (field.type === "integer" ? 1 : 0.1)}
256
- disabled={field.disabled}
263
+ disabled={controlDisabled}
257
264
  aria-label={field.title}
258
- onValueChange={(next: number) => controller.update(field.id, next)}
265
+ onValueChange={(next: number) =>
266
+ void controller.set(field.id, next)}
259
267
  />
260
268
  <output for={`setting-${field.id}`}
261
269
  >{Number(value ?? field.default)}</output
@@ -269,18 +277,18 @@
269
277
  min={field.minimum}
270
278
  max={field.maximum}
271
279
  step={field.step ?? (field.type === "integer" ? 1 : "any")}
272
- disabled={field.disabled}
280
+ disabled={controlDisabled}
273
281
  onchange={(event) =>
274
- controller.update(field.id, event.currentTarget.valueAsNumber)}
282
+ void controller.set(field.id, event.currentTarget.valueAsNumber)}
275
283
  />
276
284
  {:else if field.type === "enum"}
277
285
  <WorkspaceSettingSelect
278
286
  id={`setting-${field.id}`}
279
287
  items={options}
280
288
  value={String(value ?? field.default)}
281
- disabled={field.disabled}
289
+ disabled={controlDisabled}
282
290
  ariaLabel={field.title}
283
- onValueChange={(next: string) => controller.update(field.id, next)}
291
+ onValueChange={(next: string) => void controller.set(field.id, next)}
284
292
  />
285
293
  {:else if field.type === "multi-enum"}
286
294
  <WorkspaceSettingSelect
@@ -292,10 +300,11 @@
292
300
  (entry): entry is string => typeof entry === "string",
293
301
  )
294
302
  : field.default}
295
- disabled={field.disabled}
303
+ disabled={controlDisabled}
296
304
  ariaLabel={field.title}
297
305
  placeholder="Select options..."
298
- onValueChange={(next: string[]) => controller.update(field.id, next)}
306
+ onValueChange={(next: string[]) =>
307
+ void controller.set(field.id, next)}
299
308
  />
300
309
  {:else if field.type === "list"}
301
310
  <WorkspaceSettingList
@@ -303,19 +312,26 @@
303
312
  label={field.title}
304
313
  itemLabels={field.itemLabels}
305
314
  value={Array.isArray(value) ? value : []}
306
- disabled={field.disabled}
315
+ disabled={controlDisabled}
307
316
  maximumItems={field.maximumItems}
308
- onValueChange={(next) => controller.update(field.id, next)}
317
+ onValueChange={(next) => void controller.set(field.id, next)}
309
318
  />
310
319
  {:else if field.type === "object-array" || field.type === "object-grid"}
311
320
  <WorkspaceSettingObjectTable
312
321
  label={field.title}
313
322
  properties={field.properties}
314
323
  value={value ?? field.default}
315
- disabled={field.disabled}
324
+ disabled={controlDisabled}
316
325
  minimumItems={field.minimumItems}
317
326
  maximumItems={field.maximumItems}
318
- onValueChange={(next) => controller.update(field.id, next)}
327
+ rowKey={field.rowKey}
328
+ reorderable={field.reorderable}
329
+ allowAdd={field.allowAdd}
330
+ allowRemove={field.allowRemove}
331
+ stateRevision={controller.sourceRevision}
332
+ addLabel={field.addLabel}
333
+ rowActions={field.rowActions}
334
+ onValueChange={(next) => void controller.set(field.id, next)}
319
335
  />
320
336
  {:else if field.type === "object-map"}
321
337
  <WorkspaceSettingObjectTable
@@ -323,8 +339,8 @@
323
339
  properties={field.properties}
324
340
  mode="map"
325
341
  value={value ?? field.default}
326
- disabled={field.disabled}
327
- onValueChange={(next) => controller.update(field.id, next)}
342
+ disabled={controlDisabled}
343
+ onValueChange={(next) => void controller.set(field.id, next)}
328
344
  />
329
345
  {:else if field.type === "key-value"}
330
346
  <div class="ui-workspace-setting-key-value">
@@ -370,7 +386,7 @@
370
386
  onclick={() => {
371
387
  const next = { ...keyValue(field) };
372
388
  delete next[key];
373
- controller.update(field.id, next);
389
+ void controller.set(field.id, next);
374
390
  }}
375
391
  >
376
392
  <WorkspaceIcon name="trash-2" />
@@ -383,20 +399,62 @@
383
399
  <WorkspaceSettingAddButton
384
400
  label={field.addLabel ?? "Add entry"}
385
401
  onclick={() =>
386
- controller.update(field.id, {
402
+ void controller.set(field.id, {
387
403
  ...keyValue(field),
388
404
  [nextKey(field)]: keyValueOptions[0]?.value ?? "",
389
405
  })}
390
406
  />
391
407
  </div>
408
+ {:else if field.type === "output"}
409
+ {#if field.presentation === "copyable"}
410
+ <CopyableValue
411
+ value={value == null ? null : String(value)}
412
+ label={field.copyLabel ?? field.title}
413
+ emptyLabel={field.emptyLabel}
414
+ leadingCharacters={field.leadingCharacters}
415
+ trailingCharacters={field.trailingCharacters}
416
+ />
417
+ {:else}
418
+ <output
419
+ id={`setting-${field.id}`}
420
+ class="ui-workspace-setting-output"
421
+ data-presentation={field.presentation ?? "text"}
422
+ >
423
+ {#if field.presentation === "code"}
424
+ <code
425
+ >{value == null
426
+ ? (field.emptyLabel ?? "None")
427
+ : String(value)}</code
428
+ >
429
+ {:else}
430
+ {value == null ? (field.emptyLabel ?? "None") : String(value)}
431
+ {/if}
432
+ </output>
433
+ {/if}
392
434
  {:else if field.type === "custom"}
393
- {@const CustomComponent = field.component}
394
- <CustomComponent
395
- id={field.id}
396
- {value}
397
- disabled={field.disabled}
398
- update={(next) => controller.update(field.id, next)}
399
- />
435
+ {@const CustomComponent = field.adapter
436
+ ? controller.resolveCustomAdapter(field.id, field.adapter)?.component
437
+ : field.component}
438
+ {#if CustomComponent}
439
+ <CustomComponent
440
+ id={field.id}
441
+ {field}
442
+ {value}
443
+ disabled={controlDisabled}
444
+ readOnly={field.readOnly}
445
+ {busy}
446
+ {error}
447
+ setValue={(next) => controller.set(field.id, next)}
448
+ update={(next) => controller.update(field.id, next)}
449
+ />
450
+ {:else}
451
+ <Alert.Root class="ui-workspace-setting-unsupported">
452
+ <Alert.Title>Unavailable custom field</Alert.Title>
453
+ <Alert.Description>
454
+ The adapter for this setting is not registered.
455
+ </Alert.Description>
456
+ </Alert.Root>
457
+ {/if}
400
458
  {:else if field.type === "unsupported"}
401
459
  <Alert.Root class="ui-workspace-setting-unsupported">
402
460
  <Alert.Title>Unsupported setting</Alert.Title>
@@ -410,7 +468,8 @@
410
468
  <Button
411
469
  id={`setting-${field.id}`}
412
470
  variant={field.variant ?? "outline"}
413
- disabled={field.disabled}
471
+ disabled={controller.isDisabled(field.id) || busy}
472
+ aria-busy={busy}
414
473
  onclick={() => controller.runAction(field.id)}
415
474
  >
416
475
  {#if field.icon}<WorkspaceIcon name={field.icon} />{/if}
@@ -418,14 +477,24 @@
418
477
  </Button>
419
478
  {/if}
420
479
 
421
- {#if field.type !== "action"}
480
+ {#if field.type === "action" && actionResult}
481
+ <p
482
+ class="ui-workspace-setting-action-result"
483
+ data-tone={actionResult.tone}
484
+ role={actionResult.tone === "error" ? "alert" : "status"}
485
+ >
486
+ {actionResult.message}
487
+ </p>
488
+ {/if}
489
+
490
+ {#if field.type !== "action" && field.type !== "output" && "default" in field && field.default !== undefined}
422
491
  <Button
423
492
  class="ui-workspace-setting-restore"
424
493
  variant="ghost"
425
494
  size="icon-sm"
426
495
  aria-label={`Restore ${field.title} default`}
427
496
  title="Restore default"
428
- disabled={field.disabled}
497
+ disabled={controlDisabled}
429
498
  onclick={() => controller.restoreDefault(field.id)}
430
499
  >
431
500
  <WorkspaceIcon name="rotate-ccw" />
@@ -85,7 +85,9 @@
85
85
  dataUiComponent="workspace-setting-multiselect"
86
86
  data-ui-part="trigger"
87
87
  >
88
- <span class="ui-workspace-setting-select__value">{triggerContent}</span>
88
+ <span class="ui-workspace-setting-select__value"
89
+ >{triggerContent}</span
90
+ >
89
91
  <span data-ui-part="chevron">
90
92
  <WorkspaceIcon name="chevrons-up-down" />
91
93
  </span>
@@ -100,17 +102,16 @@
100
102
  data-ui-component="workspace-setting-multiselect"
101
103
  data-ui-part="content"
102
104
  >
103
- <CommandView.Root
104
- shouldFilter={false}
105
- label={ariaLabel ?? placeholder}
106
- >
105
+ <CommandView.Root shouldFilter={false} label={ariaLabel ?? placeholder}>
107
106
  <CommandView.Input
108
107
  bind:value={query}
109
108
  placeholder="Search options..."
110
109
  autocomplete="off"
111
110
  spellcheck="false"
112
111
  />
113
- <CommandView.List aria-label={ariaLabel ? `${ariaLabel} options` : "Options"}>
112
+ <CommandView.List
113
+ aria-label={ariaLabel ? `${ariaLabel} options` : "Options"}
114
+ >
114
115
  <CommandView.Empty>No options found.</CommandView.Empty>
115
116
  {#if filtered.length > 0}
116
117
  <CommandView.Group>
@@ -5,13 +5,17 @@
5
5
  import * as Table from "@lapismd/design-core/shadcn/table";
6
6
  import WorkspaceIcon from "../icon/WorkspaceIcon.svelte";
7
7
  import WorkspaceSettingAddButton from "./WorkspaceSettingAddButton.svelte";
8
+ import CopyableValue from "./CopyableValue.svelte";
8
9
  import {
9
10
  asObjectMap,
10
11
  asObjectRows,
11
12
  createObjectRow,
12
13
  nextObjectMapKey,
13
14
  } from "./object-collection.js";
14
- import type { WorkspaceObjectProperty } from "./types.js";
15
+ import type {
16
+ WorkspaceObjectProperty,
17
+ WorkspaceObjectRowAction,
18
+ } from "./types.js";
15
19
 
16
20
  let {
17
21
  label,
@@ -22,6 +26,12 @@
22
26
  minimumItems,
23
27
  maximumItems,
24
28
  addLabel,
29
+ rowKey,
30
+ reorderable = false,
31
+ allowAdd = true,
32
+ allowRemove = true,
33
+ stateRevision = 0,
34
+ rowActions = [],
25
35
  onValueChange,
26
36
  }: {
27
37
  label: string;
@@ -32,9 +42,23 @@
32
42
  minimumItems?: number;
33
43
  maximumItems?: number;
34
44
  addLabel?: string;
45
+ rowKey?: string;
46
+ reorderable?: boolean;
47
+ allowAdd?: boolean;
48
+ allowRemove?: boolean;
49
+ stateRevision?: number;
50
+ rowActions?: WorkspaceObjectRowAction[];
35
51
  onValueChange: (value: unknown) => void;
36
52
  } = $props();
37
53
 
54
+ let busyActions = $state<Record<string, boolean>>({});
55
+ let actionResults = $state<
56
+ Record<string, { tone: "success" | "warning" | "error"; message: string }>
57
+ >({});
58
+ let visibleProperties = $derived(
59
+ properties.filter((property) => property.presentation !== "hidden"),
60
+ );
61
+
38
62
  let rows = $derived.by(() => {
39
63
  if (mode === "map") {
40
64
  return Object.entries(asObjectMap(value)).map(([key, row]) => ({
@@ -43,15 +67,22 @@
43
67
  }));
44
68
  }
45
69
  return asObjectRows(value).map((row, index) => ({
46
- key: String(index),
70
+ key:
71
+ rowKey && typeof row[rowKey] === "string" && String(row[rowKey]).trim()
72
+ ? String(row[rowKey])
73
+ : String(index),
47
74
  row,
48
75
  }));
49
76
  });
50
77
  let canAdd = $derived(
51
- !disabled && (maximumItems === undefined || rows.length < maximumItems),
78
+ allowAdd &&
79
+ !disabled &&
80
+ (maximumItems === undefined || rows.length < maximumItems),
52
81
  );
53
82
  let canRemove = $derived(
54
- !disabled && (minimumItems === undefined || rows.length > minimumItems),
83
+ allowRemove &&
84
+ !disabled &&
85
+ (minimumItems === undefined || rows.length > minimumItems),
55
86
  );
56
87
  let resolvedAddLabel = $derived(
57
88
  addLabel ?? (mode === "map" ? `Add ${label} entry` : `Add ${label} row`),
@@ -131,6 +162,69 @@
131
162
  .map((entry) => entry.row),
132
163
  );
133
164
  }
165
+
166
+ function moveRow(index: number, offset: -1 | 1) {
167
+ if (mode !== "array" || disabled || !reorderable) return;
168
+ const target = index + offset;
169
+ if (target < 0 || target >= rows.length) return;
170
+ const next = rows.map((entry) => entry.row);
171
+ [next[index], next[target]] = [next[target]!, next[index]!];
172
+ emitArray(next);
173
+ }
174
+
175
+ function actionVisible(
176
+ action: WorkspaceObjectRowAction,
177
+ row: Record<string, unknown>,
178
+ ): boolean {
179
+ return typeof action.hidden === "function"
180
+ ? !action.hidden(row)
181
+ : action.hidden !== true;
182
+ }
183
+
184
+ function actionDisabled(
185
+ action: WorkspaceObjectRowAction,
186
+ row: Record<string, unknown>,
187
+ key: string,
188
+ ): boolean {
189
+ stateRevision;
190
+ const configured =
191
+ typeof action.disabled === "function"
192
+ ? action.disabled(row)
193
+ : action.disabled === true;
194
+ return (
195
+ disabled ||
196
+ configured ||
197
+ busyActions[`${key}:${action.id}`] === true ||
198
+ action.busy?.(row) === true
199
+ );
200
+ }
201
+
202
+ async function runRowAction(
203
+ action: WorkspaceObjectRowAction,
204
+ row: Record<string, unknown>,
205
+ index: number,
206
+ key: string,
207
+ ): Promise<void> {
208
+ if (actionDisabled(action, row, key)) return;
209
+ if (action.confirm && !(await action.confirm(row))) return;
210
+ const actionKey = `${key}:${action.id}`;
211
+ delete actionResults[actionKey];
212
+ busyActions[actionKey] = true;
213
+ try {
214
+ const result = await action.run(row, index);
215
+ if (result) actionResults[actionKey] = result;
216
+ } catch (caught) {
217
+ actionResults[actionKey] = {
218
+ tone: "error",
219
+ message:
220
+ caught instanceof Error
221
+ ? caught.message
222
+ : "Unable to run this action",
223
+ };
224
+ } finally {
225
+ busyActions[actionKey] = false;
226
+ }
227
+ }
134
228
  </script>
135
229
 
136
230
  <div class="ui-workspace-setting-object-collection">
@@ -140,14 +234,14 @@
140
234
  {#if mode === "map"}
141
235
  <Table.Head>Name</Table.Head>
142
236
  {/if}
143
- {#each properties as property (property.id)}
237
+ {#each visibleProperties as property (property.id)}
144
238
  <Table.Head>{property.title}</Table.Head>
145
239
  {/each}
146
240
  <Table.Head>Actions</Table.Head>
147
241
  </Table.Row>
148
242
  </Table.Header>
149
243
  <Table.Body>
150
- {#each rows as entry, index (mode === "map" ? entry.key : index)}
244
+ {#each rows as entry, index (entry.key)}
151
245
  <Table.Row>
152
246
  {#if mode === "map"}
153
247
  <Table.Cell>
@@ -160,19 +254,40 @@
160
254
  />
161
255
  </Table.Cell>
162
256
  {/if}
163
- {#each properties as property (property.id)}
257
+ {#each visibleProperties as property (property.id)}
164
258
  <Table.Cell>
165
259
  {#if property.type === "boolean"}
166
260
  <Switch
167
261
  checked={Boolean(entry.row[property.id])}
168
- {disabled}
262
+ disabled={disabled || property.readOnly}
169
263
  aria-label={`${label} ${property.title} row ${index + 1}`}
170
264
  onCheckedChange={(checked) =>
171
265
  updateCell(index, property.id, checked)}
172
266
  />
267
+ {:else if property.presentation === "copyable"}
268
+ <CopyableValue
269
+ value={String(entry.row[property.id] ?? "")}
270
+ label={`${label} ${property.title}`}
271
+ />
272
+ {:else if property.presentation === "code"}
273
+ <code class="ui-workspace-setting-object-code"
274
+ >{String(entry.row[property.id] ?? "")}</code
275
+ >
276
+ {:else if property.presentation === "status"}
277
+ <span class="ui-workspace-setting-object-status"
278
+ >{String(entry.row[property.id] ?? "")}</span
279
+ >
280
+ {:else if property.readOnly}
281
+ <span class="ui-workspace-setting-object-value"
282
+ >{String(entry.row[property.id] ?? "")}</span
283
+ >
173
284
  {:else}
174
285
  <Input
175
- type={property.type === "string" ? "text" : "number"}
286
+ type={property.type === "string"
287
+ ? property.presentation === "url"
288
+ ? "url"
289
+ : "text"
290
+ : "number"}
176
291
  step={property.type === "integer" ? 1 : "any"}
177
292
  aria-label={`${label} ${property.title} row ${index + 1}`}
178
293
  value={String(entry.row[property.id] ?? "")}
@@ -190,23 +305,72 @@
190
305
  </Table.Cell>
191
306
  {/each}
192
307
  <Table.Cell>
193
- <Button
194
- variant="ghost"
195
- size="icon-sm"
196
- aria-label={`Remove ${label} row ${index + 1}`}
197
- disabled={!canRemove}
198
- onclick={() => removeRow(index)}
199
- >
200
- <WorkspaceIcon name="x" />
201
- </Button>
308
+ <div class="ui-workspace-setting-object-actions">
309
+ {#if mode === "array" && reorderable}
310
+ <Button
311
+ variant="ghost"
312
+ size="icon-sm"
313
+ aria-label={`Move ${label} row ${index + 1} up`}
314
+ disabled={disabled || index === 0}
315
+ onclick={() => moveRow(index, -1)}
316
+ >
317
+ <WorkspaceIcon name="arrow-up" />
318
+ </Button>
319
+ <Button
320
+ variant="ghost"
321
+ size="icon-sm"
322
+ aria-label={`Move ${label} row ${index + 1} down`}
323
+ disabled={disabled || index === rows.length - 1}
324
+ onclick={() => moveRow(index, 1)}
325
+ >
326
+ <WorkspaceIcon name="arrow-down" />
327
+ </Button>
328
+ {/if}
329
+ {#each rowActions.filter( (action) => actionVisible(action, entry.row), ) as action (action.id)}
330
+ <Button
331
+ variant={action.variant ?? "ghost"}
332
+ size={action.icon ? "icon-sm" : "sm"}
333
+ aria-label={action.label}
334
+ title={action.label}
335
+ disabled={actionDisabled(action, entry.row, entry.key)}
336
+ onclick={() =>
337
+ void runRowAction(action, entry.row, index, entry.key)}
338
+ >
339
+ {#if action.icon}<WorkspaceIcon name={action.icon} />{/if}
340
+ {#if !action.icon}{action.label}{/if}
341
+ </Button>
342
+ {@const result = actionResults[`${entry.key}:${action.id}`]}
343
+ {#if result}
344
+ <span
345
+ class="ui-workspace-setting-object-action-result"
346
+ data-tone={result.tone}
347
+ role={result.tone === "error" ? "alert" : "status"}
348
+ >{result.message}</span
349
+ >
350
+ {/if}
351
+ {/each}
352
+ {#if !disabled && allowRemove}
353
+ <Button
354
+ variant="ghost"
355
+ size="icon-sm"
356
+ aria-label={`Remove ${label} row ${index + 1}`}
357
+ disabled={!canRemove}
358
+ onclick={() => removeRow(index)}
359
+ >
360
+ <WorkspaceIcon name="x" />
361
+ </Button>
362
+ {/if}
363
+ </div>
202
364
  </Table.Cell>
203
365
  </Table.Row>
204
366
  {/each}
205
367
  </Table.Body>
206
368
  </Table.Root>
207
- <WorkspaceSettingAddButton
208
- label={resolvedAddLabel}
209
- disabled={!canAdd}
210
- onclick={addRow}
211
- />
369
+ {#if !disabled && allowAdd}
370
+ <WorkspaceSettingAddButton
371
+ label={resolvedAddLabel}
372
+ disabled={!canAdd}
373
+ onclick={addRow}
374
+ />
375
+ {/if}
212
376
  </div>