@globalbrain/sefirot 4.57.0 → 4.59.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 (55) hide show
  1. package/lib/blocks/lens/components/LensCatalog.vue +55 -32
  2. package/lib/blocks/lens/components/LensFormFilterCondition.vue +2 -1
  3. package/lib/blocks/lens/components/LensFormFilterGroup.vue +1 -1
  4. package/lib/blocks/lens/components/{LensAvatarInput.vue → LensInputAvatar.vue} +5 -3
  5. package/lib/blocks/lens/components/LensInputDropdown.vue +102 -0
  6. package/lib/blocks/lens/components/LensSheetAvatarField.vue +2 -2
  7. package/lib/blocks/lens/components/LensTable.vue +9 -6
  8. package/lib/blocks/lens/components/LensTableAvatarCell.vue +2 -2
  9. package/lib/blocks/lens/components/LensTableEditableCell.vue +8 -7
  10. package/lib/blocks/lens/composables/LensEdit.ts +9 -0
  11. package/lib/blocks/lens/fields/AvatarField.ts +2 -2
  12. package/lib/blocks/lens/fields/Field.ts +10 -6
  13. package/lib/blocks/lens/filter-inputs/SelectFilterInput.ts +2 -1
  14. package/lib/components/SButton.vue +2 -10
  15. package/lib/components/SControlInputSearch.vue +6 -3
  16. package/lib/components/SDescPill.vue +3 -2
  17. package/lib/components/SDescState.vue +3 -2
  18. package/lib/components/SInputAddon.vue +4 -2
  19. package/lib/components/{SInputSelectSearch.vue → SInputAsyncDropdown.vue} +91 -83
  20. package/lib/components/SInputBase.vue +1 -3
  21. package/lib/components/SInputCheckbox.vue +10 -18
  22. package/lib/components/SInputCheckboxes.vue +16 -33
  23. package/lib/components/SInputDate.vue +10 -20
  24. package/lib/components/SInputDropdown.vue +22 -43
  25. package/lib/components/SInputDropdownItem.vue +7 -24
  26. package/lib/components/SInputFile.vue +10 -18
  27. package/lib/components/SInputFileUpload.vue +12 -34
  28. package/lib/components/SInputFileUploadItem.vue +7 -21
  29. package/lib/components/SInputHMS.vue +6 -7
  30. package/lib/components/SInputImage.vue +11 -16
  31. package/lib/components/SInputNumber.vue +1 -3
  32. package/lib/components/SInputRadio.vue +9 -19
  33. package/lib/components/SInputRadios.vue +24 -40
  34. package/lib/components/SInputSegments.vue +15 -26
  35. package/lib/components/SInputSegmentsOption.vue +7 -7
  36. package/lib/components/SInputSelect.vue +14 -19
  37. package/lib/components/SInputSwitch.vue +10 -19
  38. package/lib/components/SInputSwitches.vue +16 -30
  39. package/lib/components/SInputText.vue +1 -4
  40. package/lib/components/SInputTextarea.vue +0 -2
  41. package/lib/components/SInputYMD.vue +6 -7
  42. package/lib/components/SPagination.vue +1 -1
  43. package/lib/components/SPill.vue +2 -2
  44. package/lib/components/SState.vue +2 -2
  45. package/lib/components/STableCellPill.vue +3 -2
  46. package/lib/components/STableCellState.vue +3 -2
  47. package/lib/composables/Table.ts +8 -15
  48. package/lib/support/Color.ts +4 -0
  49. package/lib/support/InputBase.ts +5 -0
  50. package/lib/support/InputDropdown.ts +15 -0
  51. package/lib/support/InputFileUpload.ts +22 -0
  52. package/lib/support/InputOption.ts +8 -0
  53. package/lib/support/InputText.ts +6 -0
  54. package/package.json +1 -1
  55. package/lib/blocks/lens/components/LensInputSelect.vue +0 -92
@@ -63,9 +63,6 @@ export interface Props {
63
63
  // Whether to show advanced filters.
64
64
  canFilter?: boolean
65
65
 
66
- // Whether to show advanced sorting options.
67
- canSort?: boolean
68
-
69
66
  // Whether to hide the condition blocks.
70
67
  hideConditions?: boolean
71
68
 
@@ -129,12 +126,16 @@ export interface Props {
129
126
  // setup, so toggling it afterwards has no effect.
130
127
  urlSync?: boolean
131
128
 
132
- // Enable CRUD editing for the catalog. When set, fields the backend
133
- // marks `showOnUpdate` become inline-editable, clicking a row's id cell
134
- // opens the record sheet (view + per-field edit + delete), and the
135
- // `openCreate()` method / sheet create mode become available. The CRUD
136
- // endpoints are derived from `endpoint` by replacing the trailing
137
- // `/search` with `/update`, `/create`, and `/delete`.
129
+ // Enable CRUD editing for the catalog.
130
+ //
131
+ // Every catalog already renders a **read-only detail sheet**: clicking a
132
+ // row's index-field cell opens the record for viewing (each `showOnDetail`
133
+ // field, read-only). `editable` builds on that same sheet. When set, fields
134
+ // the backend marks `showOnUpdate` become inline-editable in the table, the
135
+ // sheet gains per-field editing and a delete button, and the `openCreate()`
136
+ // method / sheet create mode become available. The CRUD endpoints are derived
137
+ // from `endpoint` by replacing the trailing `/search` with `/update`,
138
+ // `/create`, and `/delete`.
138
139
  //
139
140
  // The row identifier (`indexField`, defaulting to `id`) is never
140
141
  // inline- or sheet-editable on an existing row: optimistically changing
@@ -142,27 +143,28 @@ export interface Props {
142
143
  // follow-up save/delete would address the not-yet-synced new id. It can
143
144
  // still be set on creation (via a `showOnCreate` field).
144
145
  //
145
- // An editable catalog must keep its index field among the rendered columns,
146
- // because the row's sheet opener and therefore the only way to view or
147
- // delete a record is the index-field cell. The default `id` identifier is
148
- // kept when `select` is empty (server defaults), so that case works as-is; a
149
- // *custom* `indexField`, however, must be listed in `select` explicitly (an
150
- // empty/default `select` drops it from the rendered columns, leaving the rows
151
- // with no opener).
146
+ // The sheet opener and therefore the only way to view, edit, or delete a
147
+ // record is the index-field cell, so that field must be a rendered column.
148
+ // The default `id` identifier is kept when `select` is empty (server
149
+ // defaults), so that case works as-is; a *custom* `indexField`, or `id`
150
+ // alongside an explicit `select`, must be listed in `select` explicitly (an
151
+ // empty/default `select` otherwise drops it, leaving the rows with no opener).
152
152
  //
153
153
  // Turning this on also enables `creatable`, `deletable`, and `inlineEditable`
154
154
  // by default; pass `false` to any of them to opt out.
155
155
  //
156
156
  // Pass a predicate `(record) => boolean` instead of `true` to allow editing
157
157
  // only some rows (e.g. from a per-record policy): the inline affordance and
158
- // the sheet's per-field edit are hidden for the rows it rejects.
158
+ // the sheet's per-field edit are hidden for the rows it rejects (the sheet
159
+ // still opens read-only).
159
160
  editable?: boolean | ((record: Record<string, any>) => boolean)
160
161
 
161
162
  // Whether records can be deleted from the sheet. Defaults to enabled for an
162
163
  // editable catalog. Pass `false` to hide the delete button entirely, or a
163
164
  // predicate `(record) => boolean` to allow deleting only some rows (e.g. from
164
- // a per-record policy). Delete is reachable only through the sheet, so this
165
- // has no effect unless the catalog is `editable`.
165
+ // a per-record policy). The delete button lives in the record sheet which
166
+ // opens for read-only viewing on any catalog but stays gated behind edit
167
+ // permission, so this has no effect unless the catalog is `editable`.
166
168
  deletable?: boolean | ((record: Record<string, any>) => boolean)
167
169
 
168
170
  // Whether new records can be created (enables create mode in the sheet and
@@ -182,7 +184,6 @@ export interface Props {
182
184
 
183
185
  const props = withDefaults(defineProps<Props>(), {
184
186
  canFilter: true,
185
- canSort: true,
186
187
  // Default these on so an editable catalog gets create / delete / inline edit
187
188
  // without opting in to each. A boolean prop can't tell "omitted" from `false`
188
189
  // (Vue casts an absent boolean to `false`), so they default on here and the
@@ -623,7 +624,8 @@ const tableIndexField = computed(() => {
623
624
  // address each row; when no `indexField` is configured the identifier
624
625
  // defaults to `id`. When the caller has no concrete select list, nothing
625
626
  // is added either — leaving the request empty lets the server use its own
626
- // defaults. See `indexField` prop docs above.
627
+ // defaults (which include `id`, so the read-only sheet still opens). See
628
+ // `indexField` prop docs above.
627
629
  function withIndexField(fields: string[]): string[] {
628
630
  if (fields.length === 0) { return [] }
629
631
  const field = props.indexField ?? (props.editable ? 'id' : null)
@@ -1029,6 +1031,19 @@ async function refreshCatalog(): Promise<void> {
1029
1031
  await forceRefresh()
1030
1032
  }
1031
1033
 
1034
+ // Close an open *view* sheet when the effective identifier itself changes (an
1035
+ // async `indexField` resolving from the `id` default to `slug`, say): the open
1036
+ // record was keyed under the old field and can't be re-matched to the new one
1037
+ // (its row lacks the new key, so `resolveId` would be `undefined`), which would
1038
+ // let a delete/save act on the wrong — or no — id. Watched on `idField` alone,
1039
+ // not the editable-gated source below: that source can't tell an identifier
1040
+ // change from `editable` merely switching on when both happen in the same flush,
1041
+ // and `editable` flipping on with the identifier unchanged must leave an open
1042
+ // read-only sheet alone (viewing is the default; its record is still valid).
1043
+ watch(idField, () => {
1044
+ if (sheet.state.value && sheetMode.value === 'view') { sheet.off() }
1045
+ })
1046
+
1032
1047
  // When the effective row identifier appears or changes while editing is enabled,
1033
1048
  // the loaded rows won't carry it until a refetch runs — so refetch here. Two
1034
1049
  // cases: `editable` resolving true after mount (async permissions), so
@@ -1037,16 +1052,10 @@ async function refreshCatalog(): Promise<void> {
1037
1052
  // identifier, `rowsCarryIndexField` keeps editing gated off, and selection keys
1038
1053
  // go `undefined` until an unrelated query change. A no-op when the identifier was
1039
1054
  // already present (same request input → cached result reused).
1040
- //
1041
- // Close an open record sheet first: it was identified under the old field and
1042
- // can't be reliably re-matched to the new one (its row lacks the new key, so
1043
- // `resolveId` would be `undefined`), which would let a delete/save act on the
1044
- // wrong — or no — id during and after the refetch.
1045
1055
  watch(
1046
1056
  () => (props.editable ? idField.value : null),
1047
1057
  (field, prev) => {
1048
1058
  if (!field || field === prev) { return }
1049
- if (sheet.state.value && sheetMode.value === 'view') { sheet.off() }
1050
1059
  // Supersede any search still in flight under the previous identifier state: it
1051
1060
  // was issued before the new id/index field, so its rows won't carry the new
1052
1061
  // key. If it resolved after this refetch it would assign those id-less rows
@@ -1468,14 +1477,24 @@ function editEnabled(): boolean {
1468
1477
  return !!props.editable && rowsCarryIndexField.value
1469
1478
  }
1470
1479
 
1480
+ // Catalog-level sheet gate: the read-only detail sheet is reachable on any
1481
+ // catalog — viewing is the default — as soon as the rows carry the index field
1482
+ // the opener resolves an id from. Editing, create, and delete stay gated on
1483
+ // `editable` (via `canEdit` / `canDelete` / the `creatable` getter), so a
1484
+ // non-editable catalog opens the same sheet read-only.
1485
+ function viewEnabled(): boolean {
1486
+ return rowsCarryIndexField.value
1487
+ }
1488
+
1471
1489
  // Per-record refinement: a predicate `editable`/`deletable` decides each row, a
1472
1490
  // boolean applies to all.
1473
1491
  function canEdit(record: Record<string, any>): boolean {
1474
1492
  return editEnabled() && (typeof props.editable === 'function' ? props.editable(record) : true)
1475
1493
  }
1476
1494
 
1477
- // Delete is a stronger action than edit and rides the same editable sheet, so a
1478
- // row must be editable before it can be deleted building on `canEdit` makes a
1495
+ // Delete is a stronger action than edit and lives in the record sheet (whose
1496
+ // delete button only renders when editable), so a row must be editable before it
1497
+ // can be deleted — building on `canEdit` makes a
1479
1498
  // per-record `editable` predicate gate delete too (a row it rejects is never
1480
1499
  // deletable). `deletable` then refines further: on unless explicitly `false`, or
1481
1500
  // its own per-record predicate.
@@ -1486,13 +1505,18 @@ function canDelete(record: Record<string, any>): boolean {
1486
1505
  provideLensEdit({
1487
1506
  // Getters so the injected context tracks prop changes after mount (e.g.
1488
1507
  // permissions resolving async, or a flag toggling `editable` off): LensTable
1489
- // gates inline editing and the id-cell sheet on `edit.editable`.
1508
+ // gates inline editing on `edit.editable` and the id-cell sheet opener on the
1509
+ // broader `edit.viewable`.
1490
1510
  //
1491
1511
  // Also gated on `rowsCarryIndexField`: when `editable` flips true after mount we
1492
1512
  // trigger a refetch to pull in the identifier, but it lands asynchronously —
1493
1513
  // keep editing off until the rows actually carry it, so a save in that window
1494
1514
  // can't `resolveId()` to `undefined`.
1495
1515
  get editable() { return editEnabled() },
1516
+ // `LensTable` gates the id-cell sheet opener on this: the read-only sheet is
1517
+ // reachable on any catalog once the rows carry the index field, independent of
1518
+ // `editable`.
1519
+ get viewable() { return viewEnabled() },
1496
1520
  get creatable() { return !!props.editable && props.creatable },
1497
1521
  // Use the same `__no_entity__` fallback as the search / CRUD requests so slot
1498
1522
  // side-channel saves (which read this) target the same entity, not an empty one.
@@ -1575,7 +1599,6 @@ defineExpose({
1575
1599
  :selected
1576
1600
  :show-query="!!(queryKeys && queryKeys.length > 0)"
1577
1601
  :show-filters="canFilter"
1578
- :show-sort="canSort"
1579
1602
  :is-condition-active="!hideConditions"
1580
1603
  :is-condition-disabled="!hasConditions"
1581
1604
  @search="onQuery"
@@ -1696,7 +1719,7 @@ defineExpose({
1696
1719
  </SModal>
1697
1720
 
1698
1721
  <LensSheet
1699
- v-if="editable && result?.fields"
1722
+ v-if="(editable || rowsCarryIndexField) && result?.fields"
1700
1723
  :open="sheet.state.value"
1701
1724
  :mode="sheetMode"
1702
1725
  :entity="entityName"
@@ -2,10 +2,11 @@
2
2
  import IconTrash from '~icons/ph/trash'
3
3
  import { type Ref, computed, markRaw, ref, watch } from 'vue'
4
4
  import SButton from '../../../components/SButton.vue'
5
- import SInputDropdown, { type Option } from '../../../components/SInputDropdown.vue'
5
+ import SInputDropdown from '../../../components/SInputDropdown.vue'
6
6
  import SSpinner from '../../../components/SSpinner.vue'
7
7
  import { useTrans } from '../../../composables/Lang'
8
8
  import { useValidation } from '../../../composables/Validation'
9
+ import { type Option } from '../../../support/InputDropdown'
9
10
  import { required } from '../../../validation/rules'
10
11
  import { type FieldData } from '../FieldData'
11
12
  import { type FilterOperator, FilterOperatorLabelDict } from '../FilterOperator'
@@ -2,9 +2,9 @@
2
2
  import IconPlusCircle from '~icons/ph/plus-circle'
3
3
  import IconTrash from '~icons/ph/trash'
4
4
  import SButton from '../../../components/SButton.vue'
5
- import { type Option } from '../../../components/SInputDropdown.vue'
6
5
  import SInputSegments from '../../../components/SInputSegments.vue'
7
6
  import { useTrans } from '../../../composables/Lang'
7
+ import { type Option } from '../../../support/InputDropdown'
8
8
  import { type FieldData } from '../FieldData'
9
9
  import LensFormFilterCondition, { type FilterCondition } from './LensFormFilterCondition.vue'
10
10
 
@@ -3,7 +3,7 @@ import SInputImage, { type ImageType, type Size } from '../../../components/SInp
3
3
  import { useTrans } from '../../../composables/Lang'
4
4
  import { type Validatable } from '../../../composables/Validation'
5
5
 
6
- withDefaults(defineProps<{
6
+ export interface Props {
7
7
  label?: string
8
8
  help?: string
9
9
  accept?: string
@@ -11,7 +11,9 @@ withDefaults(defineProps<{
11
11
  size?: Size
12
12
  disabled?: boolean
13
13
  validation?: Validatable
14
- }>(), {
14
+ }
15
+
16
+ withDefaults(defineProps<Props>(), {
15
17
  imageType: 'circle',
16
18
  size: 'small'
17
19
  })
@@ -30,7 +32,7 @@ const { t } = useTrans({
30
32
  <template>
31
33
  <SInputImage
32
34
  v-model="model"
33
- class="LensAvatarInput"
35
+ class="LensInputAvatar"
34
36
  :label
35
37
  :help
36
38
  :accept
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts" generic="T, Multiple extends boolean = false">
2
+ import SInputAsyncDropdown from '../../../components/SInputAsyncDropdown.vue'
3
+ import { useMutation } from '../../../composables/Api'
4
+ import { useLang } from '../../../composables/Lang'
5
+ import { type Option } from '../../../support/InputDropdown'
6
+ import { type LensQuerySettings, type LensQuerySort } from '../LensQuery'
7
+ import { isAuthError } from '../validation/ServerErrors'
8
+
9
+ // A lens-backed search-select that works entirely in the consumer's own model
10
+ // type. It searches the server as the user types (via SInputAsyncDropdown): each
11
+ // result row is turned into a model by `toModel`, and each model is rendered as
12
+ // its dropdown/chip option by `toOption`. The v-model is those models — a `T[]`
13
+ // when `multiple`, otherwise `T | null`.
14
+ //
15
+ // Remaining selection UI props (nullable, disabled, placeholder, size, label,
16
+ // validation, position, closeOnSelect, debounce, …) fall through as attributes.
17
+ export interface Props<T = any, Multiple extends boolean = false> {
18
+ // The lens search endpoint (e.g. `/api/admin/lens/search`).
19
+ endpoint: string
20
+
21
+ // The entity to search.
22
+ entity: string
23
+
24
+ // Columns to fetch for each row, passed to `toModel`.
25
+ select: string[]
26
+
27
+ // Columns matched against the typed query — OR-ed together with `contains`. An
28
+ // empty query fetches the initial (unfiltered) page. Named to match
29
+ // LensCatalog's `queryKeys`.
30
+ queryKeys: string[]
31
+
32
+ // Sort applied to the results (e.g. `[['name', 'asc']]`). Re-read on each fetch,
33
+ // so a locale-dependent sort stays current.
34
+ sort?: LensQuerySort[]
35
+
36
+ // Page size of each search. The server enforces its own perPage ceiling.
37
+ perPage?: number
38
+
39
+ // Per-request settings (e.g. language). Usually unneeded — the server
40
+ // negotiates language from the request headers.
41
+ settings?: LensQuerySettings
42
+
43
+ // Whether multiple models can be selected. Typed `boolean & Multiple` (equal to
44
+ // `Multiple`) so Vue keeps runtime Boolean casting for a bare `multiple` while
45
+ // `Multiple` still drives the arity-conditional model type. See SInputAsyncDropdown.
46
+ multiple?: boolean & Multiple
47
+
48
+ // Build a model from a raw result row.
49
+ toModel: (row: Record<string, any>) => T
50
+
51
+ // Render a model as its dropdown/chip option (`value` is the identity).
52
+ toOption: (item: T) => Option
53
+ }
54
+
55
+ const props = defineProps<Props<T, Multiple>>()
56
+
57
+ // The selected model(s): a `T[]` when `multiple`, otherwise `T | null`.
58
+ const model = defineModel<Multiple extends true ? T[] : T | null>({ required: true })
59
+
60
+ // Don't let lens-config attributes leak onto the child; only the explicitly
61
+ // forwarded selection props (via `$attrs`) should.
62
+ defineOptions({ inheritAttrs: false })
63
+
64
+ // Default the request language to the active app language (like LensCatalog), so
65
+ // server-localized fields come back in the right locale even when HttpConfig /
66
+ // request headers differ; explicit `settings` (incl. `lang`) overrides it.
67
+ const lang = useLang()
68
+
69
+ const { execute } = useMutation((http, query: string) =>
70
+ http.post<{ data: Record<string, any>[] }>(props.endpoint, {
71
+ entity: props.entity,
72
+ select: props.select,
73
+ filters: query
74
+ ? [['$or', props.queryKeys.map((key) => [key, 'contains', query])]]
75
+ : [],
76
+ sort: props.sort ?? [],
77
+ page: 1,
78
+ perPage: props.perPage ?? 25,
79
+ settings: { lang, ...props.settings }
80
+ })
81
+ )
82
+
83
+ // Read every config field through `props` at call time so a reactive change (e.g.
84
+ // a locale-dependent sort or label) takes effect on the next search.
85
+ async function fetch(query: string): Promise<T[]> {
86
+ const res = await execute(query)
87
+ return res.data.map(props.toModel)
88
+ }
89
+ </script>
90
+
91
+ <template>
92
+ <SInputAsyncDropdown
93
+ v-model="model"
94
+ :fetch
95
+ :to-option
96
+ :multiple
97
+ :rethrow="isAuthError"
98
+ v-bind="$attrs"
99
+ >
100
+ <template v-if="$slots.info" #info><slot name="info" /></template>
101
+ </SInputAsyncDropdown>
102
+ </template>
@@ -8,7 +8,7 @@ import { type FieldData } from '../FieldData'
8
8
  import { useLensEdit } from '../composables/LensEdit'
9
9
  import { type AvatarField } from '../fields/AvatarField'
10
10
  import { type Field } from '../fields/Field'
11
- import LensAvatarInput from './LensAvatarInput.vue'
11
+ import LensInputAvatar from './LensInputAvatar.vue'
12
12
 
13
13
  const props = defineProps<{
14
14
  field: Field<FieldData>
@@ -116,7 +116,7 @@ async function onChange(value: File | string | null | undefined) {
116
116
  <SDataListItem v-if="editable">
117
117
  <template #label>{{ field.label() }}</template>
118
118
  <template #value>
119
- <LensAvatarInput
119
+ <LensInputAvatar
120
120
  :model-value="model"
121
121
  :accept="avatarField.accept()"
122
122
  :image-type="avatarField.imageType()"
@@ -117,11 +117,14 @@ const columns = computedAsync(async () => {
117
117
 
118
118
  const column = field.tableColumn()
119
119
 
120
- // When editing is enabled, clicking the row-identifier cell opens the record
121
- // sheet (view + per-field edit + delete) instead of following a link. Keyed
122
- // off the configured index field for any field type a slug/code identifier
123
- // opens the sheet just like an `id` so other id-type columns (e.g. a
124
- // `company_id` reference link) keep their own navigation.
120
+ // The detail sheet is reachable on any catalog once the rows carry the index
121
+ // field (`edit.viewable`) viewing is the default; `editable` only adds the
122
+ // per-field edit / delete affordances inside it. So clicking the row-identifier
123
+ // cell opens the record sheet (read-only, or editable when the catalog is)
124
+ // instead of following a link. Keyed off the configured index field for any
125
+ // field type — a slug/code identifier opens the sheet just like an `id` — so
126
+ // other id-type columns (e.g. a `company_id` reference link) keep their own
127
+ // navigation.
125
128
  //
126
129
  // Exception: an `id` field whose server value carries a `path` renders that
127
130
  // path as a link; respect it so those rows navigate to the details page instead
@@ -130,7 +133,7 @@ const columns = computedAsync(async () => {
130
133
  // a `link` / `slack_message` identifier) is still turned into the sheet opener,
131
134
  // as is a column whose `cell` is undefined (falls straight through to the opener).
132
135
  if (
133
- edit?.editable
136
+ edit?.viewable
134
137
  && key === edit.indexField
135
138
  ) {
136
139
  const original = column.cell
@@ -424,9 +424,9 @@ function onEditorKeydown(event: KeyboardEvent) {
424
424
  display: flex;
425
425
  flex-direction: column;
426
426
  gap: 8px;
427
- padding: 8px;
427
+ padding: 10px;
428
428
  border: 1px solid var(--c-border);
429
- border-radius: 8px;
429
+ border-radius: 6px;
430
430
  background-color: var(--c-bg-1);
431
431
  box-shadow: var(--shadow-depth-3);
432
432
  }
@@ -4,11 +4,12 @@ import { useElementBounding } from '@vueuse/core'
4
4
  import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from 'vue'
5
5
  import SButton from '../../../components/SButton.vue'
6
6
  import SLink from '../../../components/SLink.vue'
7
- import SPill, { type Mode as PillMode } from '../../../components/SPill.vue'
8
- import SState, { type Mode as StateMode } from '../../../components/SState.vue'
7
+ import SPill from '../../../components/SPill.vue'
8
+ import SState from '../../../components/SState.vue'
9
9
  import { useManualDropdownPosition } from '../../../composables/Dropdown'
10
10
  import { useTrans } from '../../../composables/Lang'
11
11
  import { useValidation } from '../../../composables/Validation'
12
+ import { type ColorMode } from '../../../support/Color'
12
13
  import { day } from '../../../support/Day'
13
14
  import { dispatchEditorKeydown, focusFirstEditable } from '../../../support/Dom'
14
15
  import { type FieldData } from '../FieldData'
@@ -75,7 +76,7 @@ const resolvedCell = computed<any>(() => {
75
76
 
76
77
  // A `pills` cell (e.g. a multi-select with displayAs: 'pills') renders as pills
77
78
  // rather than text, mirroring the read-only `STableCellPills` column.
78
- const displayPills = computed<{ label: string; color?: PillMode }[] | null>(() => {
79
+ const displayPills = computed<{ label: string; color?: ColorMode }[] | null>(() => {
79
80
  const cell = resolvedCell.value
80
81
  return cell && cell.type === 'pills' ? cell.pills : null
81
82
  })
@@ -83,7 +84,7 @@ const displayPills = computed<{ label: string; color?: PillMode }[] | null>(() =
83
84
  // A `state` cell (e.g. a select with displayAs: 'state') renders as a status
84
85
  // badge rather than its bare label, mirroring the read-only `STableCellState`
85
86
  // column.
86
- const displayState = computed<{ label: string; mode?: StateMode } | null>(() => {
87
+ const displayState = computed<{ label: string; mode?: ColorMode } | null>(() => {
87
88
  const cell = resolvedCell.value
88
89
  return cell && cell.type === 'state' ? { label: cell.label, mode: cell.mode } : null
89
90
  })
@@ -146,7 +147,7 @@ const { validation, validate, reset } = useValidation(
146
147
 
147
148
  const editorStyle = computed(() => ({
148
149
  ...inset.value,
149
- width: `${Math.max(bounds.width.value, 240)}px`
150
+ width: `${Math.max(bounds.width.value, 224)}px`
150
151
  }))
151
152
 
152
153
  function start() {
@@ -351,9 +352,9 @@ function onEditorKeydown(event: KeyboardEvent) {
351
352
  display: flex;
352
353
  flex-direction: column;
353
354
  gap: 8px;
354
- padding: 8px;
355
+ padding: 10px;
355
356
  border: 1px solid var(--c-border);
356
- border-radius: 8px;
357
+ border-radius: 6px;
357
358
  background-color: var(--c-bg-1);
358
359
  box-shadow: var(--shadow-depth-3);
359
360
  }
@@ -10,6 +10,15 @@ export interface LensEditContext {
10
10
  /** Whether the catalog has editing enabled at all. */
11
11
  editable: boolean
12
12
 
13
+ /**
14
+ * Whether the detail sheet is reachable. Viewing is the default: true on any
15
+ * catalog once the rows carry the index field the opener resolves an id from,
16
+ * independent of `editable`. The index-field cell opens the sheet on this gate,
17
+ * while the mutation affordances inside stay gated on `canEdit` / `canDelete` /
18
+ * `creatable`, so a non-editable catalog opens a read-only sheet.
19
+ */
20
+ viewable: boolean
21
+
13
22
  /** Whether new records may be created. */
14
23
  creatable: boolean
15
24
 
@@ -3,7 +3,7 @@ import SAvatar from '../../../components/SAvatar.vue'
3
3
  import { type TableCell } from '../../../composables/Table'
4
4
  import { type AvatarFieldData } from '../FieldData'
5
5
  import { type FilterOperator } from '../FilterOperator'
6
- import LensAvatarInput from '../components/LensAvatarInput.vue'
6
+ import LensInputAvatar from '../components/LensInputAvatar.vue'
7
7
  import { type FilterInput } from '../filter-inputs/FilterInput'
8
8
  import { Field } from './Field'
9
9
 
@@ -62,7 +62,7 @@ export class AvatarField extends Field<AvatarFieldData> {
62
62
 
63
63
  override formInputComponent(): any {
64
64
  return this.defineFormInputComponent((props, { emit }) => {
65
- return () => h(LensAvatarInput, {
65
+ return () => h(LensInputAvatar, {
66
66
  'label': this.formInputLabel(),
67
67
  'help': this.help() || undefined,
68
68
  'accept': this.accept(),
@@ -50,9 +50,11 @@ export abstract class Field<T extends FieldData> {
50
50
  * on the current app language.
51
51
  */
52
52
  placeholder(): string | null {
53
- return this.ctx.lang === 'ja'
54
- ? (this.data as any).placeholderJa ?? null
55
- : (this.data as any).placeholderEn ?? null
53
+ return this.ctx.lang === 'ja' && 'placeholderJa' in this.data
54
+ ? this.data.placeholderJa ?? null
55
+ : this.ctx.lang === 'en' && 'placeholderEn' in this.data
56
+ ? this.data.placeholderEn ?? null
57
+ : null
56
58
  }
57
59
 
58
60
  /**
@@ -60,9 +62,11 @@ export abstract class Field<T extends FieldData> {
60
62
  * on the current app language.
61
63
  */
62
64
  help(): string | null {
63
- return this.ctx.lang === 'ja'
64
- ? (this.data as any).helpJa ?? null
65
- : (this.data as any).helpEn ?? null
65
+ return this.ctx.lang === 'ja' && 'helpJa' in this.data
66
+ ? this.data.helpJa ?? null
67
+ : this.ctx.lang === 'en' && 'helpEn' in this.data
68
+ ? this.data.helpEn ?? null
69
+ : null
66
70
  }
67
71
 
68
72
  /**
@@ -1,6 +1,7 @@
1
1
  import { type ValidationArgs } from '@vuelidate/core'
2
2
  import { defineAsyncComponent } from 'vue'
3
- import SInputDropdown, { type Option } from '../../../components/SInputDropdown.vue'
3
+ import SInputDropdown from '../../../components/SInputDropdown.vue'
4
+ import { type Option } from '../../../support/InputDropdown'
4
5
  import { required } from '../../../validation/rules'
5
6
  import { isAuthError } from '../validation/ServerErrors'
6
7
  import { FilterInput } from './FilterInput'
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { type Component, type MaybeRef, computed, unref, useSlots } from 'vue'
3
3
  import { type Position } from '../composables/Tooltip'
4
+ import { type ColorMode } from '../support/Color'
4
5
  import SFragment from './SFragment.vue'
5
6
  import SLink from './SLink.vue'
6
7
  import SSpinner from './SSpinner.vue'
@@ -20,16 +21,7 @@ export type Size =
20
21
 
21
22
  export type Type = 'fill' | 'outline' | 'text'
22
23
 
23
- export type Mode =
24
- | 'default'
25
- | 'mute'
26
- | 'neutral'
27
- | 'white'
28
- | 'black'
29
- | 'info'
30
- | 'success'
31
- | 'warning'
32
- | 'danger'
24
+ export type Mode = ColorMode | 'white' | 'black'
33
25
 
34
26
  export interface Tooltip {
35
27
  tag?: Component | string
@@ -4,9 +4,10 @@ import { type Component, computed } from 'vue'
4
4
  import { useControlSize } from '../composables/Control'
5
5
  import { useTrans } from '../composables/Lang'
6
6
  import { type Validatable } from '../composables/Validation'
7
- import SInputText, { type Align, type TextColor } from './SInputText.vue'
7
+ import { type Align, type TextColor } from '../support/InputText'
8
+ import SInputText from './SInputText.vue'
8
9
 
9
- const props = defineProps<{
10
+ export interface Props {
10
11
  placeholder?: string
11
12
  unitAfter?: Component | string
12
13
  textColor?: TextColor | ((value: string | null) => TextColor)
@@ -16,7 +17,9 @@ const props = defineProps<{
16
17
  modelValue?: string | null
17
18
  displayValue?: string | null
18
19
  validation?: Validatable
19
- }>()
20
+ }
21
+
22
+ const props = defineProps<Props>()
20
23
 
21
24
  defineEmits<{
22
25
  'update:model-value': [value: string | null]
@@ -1,10 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
+ import { type ColorMode } from '../support/Color'
3
4
  import SDescEmpty from './SDescEmpty.vue'
4
- import SPill, { type Mode } from './SPill.vue'
5
+ import SPill from './SPill.vue'
5
6
 
6
7
  export interface Pill {
7
- mode?: Mode
8
+ mode?: ColorMode
8
9
  label: string
9
10
  }
10
11
 
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
+ import { type ColorMode } from '../support/Color'
2
3
  import SDescEmpty from './SDescEmpty.vue'
3
- import SState, { type Mode } from './SState.vue'
4
+ import SState from './SState.vue'
4
5
 
5
6
  export interface State {
6
- mode?: Mode
7
+ mode?: ColorMode
7
8
  label: string
8
9
  }
9
10
 
@@ -9,14 +9,16 @@ import {
9
9
  import { useFlyout } from '../composables/Flyout'
10
10
  import SDropdown from './SDropdown.vue'
11
11
 
12
- const props = withDefaults(defineProps<{
12
+ export interface Props {
13
13
  label?: Component | string
14
14
  clickable?: boolean
15
15
  dropdown?: DropdownSection[]
16
16
  dropdownCaret?: boolean
17
17
  dropdownPosition?: 'top' | 'bottom'
18
18
  disabled?: boolean
19
- }>(), {
19
+ }
20
+
21
+ const props = withDefaults(defineProps<Props>(), {
20
22
  clickable: true,
21
23
  dropdown: () => [],
22
24
  dropdownCaret: true