@globalbrain/sefirot 4.46.0 → 4.48.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 (31) hide show
  1. package/lib/blocks/lens/FieldData.ts +27 -0
  2. package/lib/blocks/lens/ResourceFetcher.ts +5 -1
  3. package/lib/blocks/lens/Rule.ts +10 -0
  4. package/lib/blocks/lens/components/LensCatalog.vue +826 -39
  5. package/lib/blocks/lens/components/LensSheet.vue +465 -0
  6. package/lib/blocks/lens/components/LensSheetField.vue +210 -0
  7. package/lib/blocks/lens/components/LensTable.vue +79 -5
  8. package/lib/blocks/lens/components/LensTableEditableCell.vue +285 -0
  9. package/lib/blocks/lens/composables/CatalogUrlQuerySync.ts +146 -0
  10. package/lib/blocks/lens/composables/LensEdit.ts +59 -0
  11. package/lib/blocks/lens/composables/LensInlineEdit.ts +36 -0
  12. package/lib/blocks/lens/composables/ResourceFetcher.ts +20 -7
  13. package/lib/blocks/lens/composables/SetupLens.ts +2 -0
  14. package/lib/blocks/lens/fields/AvatarField.ts +43 -0
  15. package/lib/blocks/lens/fields/ContentField.ts +21 -10
  16. package/lib/blocks/lens/fields/Field.ts +26 -1
  17. package/lib/blocks/lens/fields/FileUploadField.ts +8 -0
  18. package/lib/blocks/lens/fields/RelatedManyField.ts +44 -7
  19. package/lib/blocks/lens/fields/RelatedOneField.ts +42 -7
  20. package/lib/blocks/lens/validation/RuleMapper.ts +22 -3
  21. package/lib/blocks/lens/validation/ServerErrors.ts +25 -0
  22. package/lib/components/SInputSwitches.vue +2 -2
  23. package/lib/components/SSheet.vue +104 -0
  24. package/lib/composables/Api.ts +18 -10
  25. package/lib/composables/Url.ts +19 -2
  26. package/lib/styles/variables.css +1 -0
  27. package/lib/validation/rules/index.ts +1 -0
  28. package/lib/validation/rules/slackChannelLink.ts +15 -0
  29. package/lib/validation/validators/index.ts +1 -0
  30. package/lib/validation/validators/slackChannelLink.ts +52 -0
  31. package/package.json +17 -17
@@ -6,8 +6,11 @@ export function useResourceFetcher(): ResourceFetcher {
6
6
  const data = ref<Record<string, any>>({})
7
7
  const pendingList: Record<string, Promise<any> | undefined> = {}
8
8
 
9
- const { execute: resourceFetcher } = useGet<any, [method: ResourceFetchMethod, url: string]>(async (http, method, url) => {
10
- const key = `${method}:${url}`
9
+ const { execute: resourceFetcher } = useGet<
10
+ any,
11
+ [method: ResourceFetchMethod, url: string, body?: Record<string, any> | null]
12
+ >(async (http, method, url, body) => {
13
+ const key = `${method}:${url}:${body ? JSON.stringify(body) : ''}`
11
14
 
12
15
  if (data.value[key]) {
13
16
  return data.value[key]
@@ -17,11 +20,21 @@ export function useResourceFetcher(): ResourceFetcher {
17
20
  return pendingList[key]
18
21
  }
19
22
 
20
- pendingList[key] = http[method](url).then((response) => {
21
- data.value[key] = response
22
- delete pendingList[key]
23
- return response
24
- })
23
+ // `post` carries a request body (e.g. a lens search payload); `get` ignores
24
+ // it. Send no body at all when none is configured — `http.post(url, {})`
25
+ // would serialize and POST an empty JSON object, which endpoints that
26
+ // branch on an absent payload could reject.
27
+ pendingList[key] = (
28
+ method === 'post'
29
+ ? (body != null ? http.post(url, body) : http.post(url))
30
+ : http.get(url)
31
+ ).then(
32
+ (response) => {
33
+ data.value[key] = response
34
+ delete pendingList[key]
35
+ return response
36
+ }
37
+ )
25
38
 
26
39
  return pendingList[key]
27
40
  })
@@ -1,6 +1,7 @@
1
1
  import { type FieldDataType } from '../FieldData'
2
2
  import { type FieldDataFor, type FieldProvider, FieldRegistry } from '../FieldRegistry'
3
3
  import { provideFieldRegistry } from '../composables/FieldRegistry'
4
+ import { AvatarField } from '../fields/AvatarField'
4
5
  import { BooleanField } from '../fields/BooleanField'
5
6
  import { ContentField } from '../fields/ContentField'
6
7
  import { DateField } from '../fields/DateField'
@@ -34,6 +35,7 @@ export function useSetupLens(): SetupLens {
34
35
  registerDefaultFields()
35
36
 
36
37
  function registerDefaultFields(): void {
38
+ fieldRegistry.register('avatar', (ctx, field) => new AvatarField(ctx, field))
37
39
  fieldRegistry.register('boolean', (ctx, field) => new BooleanField(ctx, field))
38
40
  fieldRegistry.register('content', (ctx, field) => new ContentField(ctx, field))
39
41
  fieldRegistry.register('date', (ctx, field) => new DateField(ctx, field))
@@ -0,0 +1,43 @@
1
+ import { h } from 'vue'
2
+ import SAvatar from '../../../components/SAvatar.vue'
3
+ import { type TableCell } from '../../../composables/Table'
4
+ import { type AvatarFieldData } from '../FieldData'
5
+ import { type FilterOperator } from '../FilterOperator'
6
+ import { type FilterInput } from '../filter-inputs/FilterInput'
7
+ import { Field } from './Field'
8
+
9
+ export class AvatarField extends Field<AvatarFieldData> {
10
+ override tableCell(v: any, r: any): TableCell {
11
+ // The display name lives on a sibling key of the record (resolved by the
12
+ // active language), not on the field value, which is the image URL.
13
+ const nameKey = this.ctx.lang === 'ja' ? this.data.nameJa : this.data.nameEn
14
+ return {
15
+ type: 'avatar',
16
+ // The field value is the image URL itself. A missing value renders an
17
+ // empty cell (both `image` and `name` falsy), unless a name companion
18
+ // is configured, in which case the avatar falls back to its initials.
19
+ image: v ?? null,
20
+ // Empty string (not null) when unset: a null name makes the cell
21
+ // renderer fall back to the raw row value, which here is the image URL
22
+ // and would break SAvatar.
23
+ name: nameKey ? (r?.[nameKey] ?? '') : ''
24
+ }
25
+ }
26
+
27
+ override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
28
+ return {}
29
+ }
30
+
31
+ override dataListItemComponent(): any {
32
+ return this.defineDataListItemComponent((value) => {
33
+ // Render nothing for a missing image so SDataListItem shows its empty
34
+ // placeholder ("—"), matching the empty table cell and the other
35
+ // data-list fields, instead of an empty bordered avatar.
36
+ return value ? h(SAvatar, { size: 'small', avatar: value }) : null
37
+ })
38
+ }
39
+
40
+ override formInputComponent(): any {
41
+ throw new Error('Not implemented.')
42
+ }
43
+ }
@@ -1,4 +1,4 @@
1
- import { h } from 'vue'
1
+ import { defineComponent, h } from 'vue'
2
2
  import SContent from '../../../components/SContent.vue'
3
3
  import SMarkdown from '../../../components/SMarkdown.vue'
4
4
  import { type TableCell } from '../../../composables/Table'
@@ -8,6 +8,13 @@ import { type FilterInput } from '../filter-inputs/FilterInput'
8
8
  import { Field } from './Field'
9
9
 
10
10
  export class ContentField extends Field<ContentFieldData> {
11
+ // `content` is a display-only field: `formInputComponent()` renders static
12
+ // Markdown (instructions) rather than an editable input, so it must never be
13
+ // seeded / validated / submitted as a value, nor made inline-editable.
14
+ override isSubmittable(): boolean {
15
+ return false
16
+ }
17
+
11
18
  override tableCell(_v: any, _r: any): TableCell {
12
19
  return { type: 'empty' }
13
20
  }
@@ -17,18 +24,22 @@ export class ContentField extends Field<ContentFieldData> {
17
24
  }
18
25
 
19
26
  override dataListItemComponent(): any {
20
- return null
27
+ // `content` is display-only: render its Markdown body (the same static
28
+ // block as the create form) so a detail-visible content field shows its
29
+ // configured instructions instead of an empty value row.
30
+ return defineComponent(() => () => this.renderBody(), { props: ['value'] })
21
31
  }
22
32
 
23
33
  override formInputComponent(): any {
24
- return this.defineFormInputComponent((_props, _ctx) => {
25
- return () => h(SMarkdown, {
26
- content: this.ctx.lang === 'ja' ? this.data.bodyJa : this.data.bodyEn
27
- }, {
28
- default: (slot: any) => {
29
- return h(SContent, { innerHTML: slot.rendered })
30
- }
31
- })
34
+ return this.defineFormInputComponent((_props, _ctx) => () => this.renderBody())
35
+ }
36
+
37
+ /** Render the field's localized Markdown body. */
38
+ private renderBody(): any {
39
+ return h(SMarkdown, {
40
+ content: this.ctx.lang === 'ja' ? this.data.bodyJa : this.data.bodyEn
41
+ }, {
42
+ default: (slot: any) => h(SContent, { innerHTML: slot.rendered })
32
43
  })
33
44
  }
34
45
  }
@@ -110,7 +110,10 @@ export abstract class Field<T extends FieldData> {
110
110
  * Renders the table filter menu for the field. The filter maybe null when
111
111
  * the field has no available filters.
112
112
  */
113
- async tableFilterMenu(_filters: any[], _onFilterUpdated: (filters: any[]) => void): Promise<DropdownSection | null> {
113
+ async tableFilterMenu(
114
+ _filters: any[],
115
+ _onFilterUpdated: (filters: any[]) => void
116
+ ): Promise<DropdownSection | null> {
114
117
  return null
115
118
  }
116
119
 
@@ -212,6 +215,28 @@ export abstract class Field<T extends FieldData> {
212
215
  })
213
216
  }
214
217
 
218
+ /**
219
+ * Whether this field contributes a value to create / update payloads. Most
220
+ * fields do. Display-only fields (e.g. `content`, which renders static
221
+ * instructions in the form) return false so the sheet renders them but
222
+ * never seeds, validates, submits, or makes them inline-editable.
223
+ */
224
+ isSubmittable(): boolean {
225
+ return true
226
+ }
227
+
228
+ /**
229
+ * Whether this field can be edited in place (inline cell / record sheet) with
230
+ * an optimistic display. False for fields whose input value isn't the
231
+ * displayed value and needs a server round-trip first (e.g. a file upload,
232
+ * which holds raw `File` objects before upload) — patching the row with that
233
+ * raw value would render incorrectly or crash. Such fields remain editable in
234
+ * the create form, which is blocking.
235
+ */
236
+ supportsOptimisticUpdate(): boolean {
237
+ return true
238
+ }
239
+
215
240
  /**
216
241
  * Returns the value should be used when the input is empty.
217
242
  */
@@ -16,6 +16,14 @@ export class FileUploadField extends Field<FileUploadFieldData> {
16
16
  this.downloader = downloader
17
17
  }
18
18
 
19
+ // The form input holds raw `File` objects until they're uploaded, which only
20
+ // happens server-side; an optimistic patch would store those `File`s on the
21
+ // row and crash the persisted-path renderer. So file uploads aren't editable
22
+ // inline / in the sheet (only on create, which is blocking).
23
+ override supportsOptimisticUpdate(): boolean {
24
+ return false
25
+ }
26
+
19
27
  override tableCell(v: any, _r: any): TableCell {
20
28
  return {
21
29
  type: 'text',
@@ -1,4 +1,7 @@
1
1
  import { xor } from 'lodash-es'
2
+ import { h } from 'vue'
3
+ import SDescAvatar from '../../../components/SDescAvatar.vue'
4
+ import SDescPill from '../../../components/SDescPill.vue'
2
5
  import { type DropdownSection } from '../../../composables/Dropdown'
3
6
  import { type TableCell } from '../../../composables/Table'
4
7
  import { type RelatedManyFieldData } from '../FieldData'
@@ -16,7 +19,10 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
16
19
  this.fetcher = fetcher
17
20
  }
18
21
 
19
- override async tableFilterMenu(filters: any[], onFilterUpdated: (filters: any[]) => void): Promise<DropdownSection | null> {
22
+ override async tableFilterMenu(
23
+ filters: any[],
24
+ onFilterUpdated: (filters: any[]) => void
25
+ ): Promise<DropdownSection | null> {
20
26
  const method = this.data.resourceEndpointMethod
21
27
  const url = this.data.resourceEndpointPath
22
28
  const key = this.data.resourceEndpointDataKey
@@ -27,7 +33,7 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
27
33
 
28
34
  const selected = this.inFilterValueFor(this.data.key, filters)
29
35
 
30
- const res = await this.fetcher(method, url)
36
+ const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
31
37
  const data = key ? res[key] : res
32
38
 
33
39
  const isAvatar = this.data.displayAs === 'avatars'
@@ -37,11 +43,11 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
37
43
  type: 'avatar' as const,
38
44
  label: item[this.data.resourceTitle],
39
45
  image: this.data.resourceImage ? item[this.data.resourceImage] : null,
40
- value: item[this.data.filterKey]
46
+ value: this.resolveValue(item[this.data.filterKey])
41
47
  }
42
48
  : {
43
49
  label: item[this.data.resourceTitle],
44
- value: item[this.data.filterKey]
50
+ value: this.resolveValue(item[this.data.filterKey])
45
51
  })
46
52
 
47
53
  return {
@@ -88,10 +94,10 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
88
94
  }
89
95
 
90
96
  const optionsResolver = async () => {
91
- const res = await this.fetcher(method, url)
97
+ const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
92
98
  const data = key ? res[key] : res
93
99
  return data.map((item: any) => ({
94
- value: item[this.data.filterKey],
100
+ value: this.resolveValue(item[this.data.filterKey]),
95
101
  label: item[this.data.resourceTitle]
96
102
  }))
97
103
  }
@@ -106,8 +112,39 @@ export class RelatedManyField extends Field<RelatedManyFieldData> {
106
112
  }
107
113
  }
108
114
 
115
+ // Lens id fields serialize as `{ value, display, path? }`; filters need the
116
+ // raw scalar. Unwrap that shape, passing plain scalars through untouched.
117
+ private resolveValue(raw: any): any {
118
+ return raw !== null && typeof raw === 'object' && 'value' in raw ? raw.value : raw
119
+ }
120
+
109
121
  override dataListItemComponent(): any {
110
- throw new Error('Not implemented.')
122
+ return this.defineDataListItemComponent((value) => {
123
+ // related_many serializes as an array of relation objects (or null).
124
+ // Read the display name / image from the same keys `tableCell()` uses,
125
+ // instead of letting the generic fallback render `[object Object]`.
126
+ const items = (value ?? []) as any[]
127
+
128
+ if (items.length === 0) {
129
+ return null
130
+ }
131
+
132
+ if (this.data.displayAs === 'avatars') {
133
+ return h(SDescAvatar, {
134
+ avatar: items.map((item) => ({
135
+ avatar: this.data.image ? (item[this.data.image] ?? null) : null,
136
+ name: item[this.data.title] ?? null
137
+ }))
138
+ })
139
+ }
140
+
141
+ // 'pills' (and the null default): render one pill per related item.
142
+ return h(SDescPill, {
143
+ pill: items.map((item) => ({
144
+ label: item[this.data.title] ?? ''
145
+ }))
146
+ })
147
+ })
111
148
  }
112
149
 
113
150
  override formInputComponent() {
@@ -1,4 +1,6 @@
1
1
  import { xor } from 'lodash-es'
2
+ import { h } from 'vue'
3
+ import SDescAvatar from '../../../components/SDescAvatar.vue'
2
4
  import { type DropdownSection } from '../../../composables/Dropdown'
3
5
  import { type TableCell } from '../../../composables/Table'
4
6
  import { type RelatedOneFieldData } from '../FieldData'
@@ -16,7 +18,10 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
16
18
  this.fetcher = fetcher
17
19
  }
18
20
 
19
- override async tableFilterMenu(filters: any[], onFilterUpdated: (filters: any[]) => void): Promise<DropdownSection | null> {
21
+ override async tableFilterMenu(
22
+ filters: any[],
23
+ onFilterUpdated: (filters: any[]) => void
24
+ ): Promise<DropdownSection | null> {
20
25
  const method = this.data.resourceEndpointMethod
21
26
  const url = this.data.resourceEndpointPath
22
27
  const key = this.data.resourceEndpointDataKey
@@ -27,7 +32,7 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
27
32
 
28
33
  const selected = this.inFilterValueFor(this.data.key, filters)
29
34
 
30
- const res = await this.fetcher(method, url)
35
+ const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
31
36
  const data = key ? res[key] : res
32
37
 
33
38
  const isAvatar = this.data.displayAs === 'avatar'
@@ -37,11 +42,11 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
37
42
  type: 'avatar' as const,
38
43
  label: item[this.data.resourceTitle],
39
44
  image: this.data.resourceImage ? item[this.data.resourceImage] : null,
40
- value: item[this.data.filterKey]
45
+ value: this.resolveValue(item[this.data.filterKey])
41
46
  }
42
47
  : {
43
48
  label: item[this.data.resourceTitle],
44
- value: item[this.data.filterKey]
49
+ value: this.resolveValue(item[this.data.filterKey])
45
50
  })
46
51
 
47
52
  return {
@@ -91,10 +96,10 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
91
96
  }
92
97
 
93
98
  const optionsResolver = async () => {
94
- const res = await this.fetcher(method, url)
99
+ const res = await this.fetcher(method, url, this.data.resourceEndpointBody)
95
100
  const data = key ? res[key] : res
96
101
  return data.map((item: any) => ({
97
- value: item[this.data.filterKey],
102
+ value: this.resolveValue(item[this.data.filterKey]),
98
103
  label: item[this.data.resourceTitle]
99
104
  }))
100
105
  }
@@ -109,8 +114,38 @@ export class RelatedOneField extends Field<RelatedOneFieldData> {
109
114
  }
110
115
  }
111
116
 
117
+ // Lens id fields serialize as `{ value, display, path? }`; filters need the
118
+ // raw scalar. Unwrap that shape, passing plain scalars through untouched.
119
+ // (Mirrors `RelatedManyField` — relevant when a related-one resource reuses
120
+ // a Lens search endpoint whose `filterKey` is an id field.)
121
+ private resolveValue(raw: any): any {
122
+ return raw !== null && typeof raw === 'object' && 'value' in raw ? raw.value : raw
123
+ }
124
+
112
125
  override dataListItemComponent(): any {
113
- throw new Error('Not implemented.')
126
+ return this.defineDataListItemComponent((value) => {
127
+ // related_one serializes as a single relation object (or null). Read the
128
+ // display name / image from the same keys `tableCell()` uses, instead of
129
+ // letting the generic fallback render the raw object as JSON.
130
+ if (value === null || value === undefined) {
131
+ return null
132
+ }
133
+
134
+ const name = value[this.data.title] ?? null
135
+
136
+ if (this.data.displayAs === 'avatar') {
137
+ return h(SDescAvatar, {
138
+ avatar: {
139
+ avatar: this.data.image ? (value[this.data.image] ?? null) : null,
140
+ name
141
+ }
142
+ })
143
+ }
144
+
145
+ // 'text' (and the null default): render the title as plain text, falling
146
+ // back to the empty placeholder when the title key is missing.
147
+ return name
148
+ })
114
149
  }
115
150
 
116
151
  override formInputComponent() {
@@ -1,6 +1,15 @@
1
1
  import { type ValidationArgs, type ValidationRuleWithParams } from '@vuelidate/core'
2
2
  import { type Day, day } from '../../../support/Day'
3
- import { after, afterOrEqual, before, beforeOrEqual, maxLength, required, slackChannelName } from '../../../validation/rules'
3
+ import {
4
+ after,
5
+ afterOrEqual,
6
+ before,
7
+ beforeOrEqual,
8
+ maxLength,
9
+ required,
10
+ slackChannelLink,
11
+ slackChannelName
12
+ } from '../../../validation/rules'
4
13
  import { type Rule } from '../Rule'
5
14
 
6
15
  /**
@@ -8,17 +17,27 @@ import { type Rule } from '../Rule'
8
17
  */
9
18
  export function map(rules: Rule[]): ValidationArgs {
10
19
  return rules.reduce((carry: ValidationArgs<any>, rule: Rule) => {
11
- carry[rule.type] = mapRule(rule)
20
+ const mapped = mapRule(rule)
21
+ if (mapped) {
22
+ carry[rule.type] = mapped
23
+ }
12
24
  return carry
13
25
  }, {})
14
26
  }
15
27
 
16
- function mapRule(rule: Rule): ValidationRuleWithParams {
28
+ function mapRule(rule: Rule): ValidationRuleWithParams | null {
17
29
  switch (rule.type) {
18
30
  case 'max_length':
19
31
  return maxLength(rule.length)
20
32
  case 'required':
21
33
  return required()
34
+ case 'unique':
35
+ // Uniqueness can only be verified server-side (it queries the
36
+ // database). The backend enforces it and returns a 422 with field
37
+ // errors, so there is no client-side equivalent to apply here.
38
+ return null
39
+ case 'slack_channel_link':
40
+ return slackChannelLink()
22
41
  case 'slack_channel_name':
23
42
  return slackChannelName({ offset: rule.offset })
24
43
  case 'before':
@@ -0,0 +1,25 @@
1
+ import { isFetchError } from '../../../http/Http'
2
+
3
+ /**
4
+ * Extract Laravel-style validation errors from a failed request. The backend
5
+ * enforces rules that can't run client-side (e.g. `unique`) and rejects with a
6
+ * 422 whose body is `{ message, errors: { <fieldKey>: string[] } }`, keyed by
7
+ * the bare field key.
8
+ *
9
+ * Returns the `errors` map so a form can feed it into Vuelidate's
10
+ * `$externalResults` and surface a fixable per-field message, or `null` when
11
+ * the error isn't a usable 422 (so the caller can rethrow it).
12
+ */
13
+ export function extractServerErrors(error: unknown): Record<string, string[]> | null {
14
+ if (!isFetchError(error) || error.status !== 422) {
15
+ return null
16
+ }
17
+
18
+ const errors = (error.data as any)?.errors
19
+
20
+ if (!errors || typeof errors !== 'object') {
21
+ return null
22
+ }
23
+
24
+ return errors
25
+ }
@@ -29,7 +29,7 @@ const props = defineProps<{
29
29
  }>()
30
30
 
31
31
  const emit = defineEmits<{
32
- 'update:modelValue': [value: any[]]
32
+ 'update:model-value': [value: any[]]
33
33
  }>()
34
34
 
35
35
  const classes = computed(() => [
@@ -45,7 +45,7 @@ function onChange(value: any): void {
45
45
  .filter((v) => v !== value)
46
46
  .concat(props.modelValue.includes(value) ? [] : [value])
47
47
 
48
- emit('update:modelValue', difference)
48
+ emit('update:model-value', difference)
49
49
  }
50
50
  </script>
51
51
 
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ import { onKeyStroke } from '@vueuse/core'
3
+ import { ref } from 'vue'
4
+
5
+ export interface Props {
6
+ open: boolean
7
+ closable?: boolean
8
+ position?: 'right' | 'left'
9
+ width?: string
10
+ }
11
+
12
+ const props = withDefaults(defineProps<Props>(), {
13
+ closable: true,
14
+ position: 'right',
15
+ width: '480px'
16
+ })
17
+
18
+ const emit = defineEmits<{
19
+ close: []
20
+ }>()
21
+
22
+ const el = ref<any>(null)
23
+
24
+ function onClick(e: MouseEvent) {
25
+ if (!props.closable) {
26
+ return
27
+ }
28
+
29
+ if (e.button === 0 && e.target === el.value) {
30
+ emit('close')
31
+ }
32
+ }
33
+
34
+ onKeyStroke('Escape', () => {
35
+ if (props.open && props.closable) {
36
+ emit('close')
37
+ }
38
+ })
39
+ </script>
40
+
41
+ <template>
42
+ <Teleport to="#sefirot-modals">
43
+ <Transition name="s-sheet">
44
+ <div
45
+ v-if="open"
46
+ ref="el"
47
+ class="SSheet"
48
+ :class="[`pos-${position}`]"
49
+ @mousedown="onClick"
50
+ >
51
+ <div class="panel" :style="{ width }">
52
+ <slot />
53
+ </div>
54
+ </div>
55
+ </Transition>
56
+ </Teleport>
57
+ </template>
58
+
59
+ <style scoped>
60
+ .SSheet {
61
+ position: fixed;
62
+ top: 0;
63
+ right: 0;
64
+ bottom: 0;
65
+ left: 0;
66
+ z-index: var(--z-index-sheet);
67
+ display: flex;
68
+ padding: 8px;
69
+ transition: opacity 0.25s;
70
+ }
71
+
72
+ .SSheet.pos-right { justify-content: flex-end; }
73
+ .SSheet.pos-left { justify-content: flex-start; }
74
+
75
+ .SSheet.s-sheet-enter-from,
76
+ .SSheet.s-sheet-leave-to {
77
+ opacity: 0;
78
+ }
79
+
80
+ .panel {
81
+ display: flex;
82
+ flex-direction: column;
83
+ max-width: calc(100% - 16px);
84
+ height: 100%;
85
+ border: 1px solid var(--c-divider);
86
+ border-radius: 12px;
87
+ background-color: var(--c-bg-1);
88
+ box-shadow: var(--shadow-depth-3);
89
+ transition: transform 0.25s, opacity 0.25s;
90
+ overflow: hidden;
91
+ }
92
+
93
+ .SSheet.pos-right.s-sheet-enter-from .panel,
94
+ .SSheet.pos-right.s-sheet-leave-to .panel {
95
+ opacity: 0;
96
+ transform: translateX(16px);
97
+ }
98
+
99
+ .SSheet.pos-left.s-sheet-enter-from .panel,
100
+ .SSheet.pos-left.s-sheet-leave-to .panel {
101
+ opacity: 0;
102
+ transform: translateX(-16px);
103
+ }
104
+ </style>
@@ -59,11 +59,15 @@ export function useQuery<Data = any>(
59
59
  async function execute({ assign = true, silent = false } = {}): Promise<Data> {
60
60
  if (!silent) { loading.value = true }
61
61
 
62
- const res: Data = await req(new Http(httpConfig))
63
- if (assign) { data.value = res }
64
-
65
- loading.value = false
66
- return res
62
+ try {
63
+ const res: Data = await req(new Http(httpConfig))
64
+ if (assign) { data.value = res }
65
+ return res
66
+ } finally {
67
+ // Always clear loading, even when the request rejects — otherwise a failed
68
+ // fetch leaves the consumer stuck in its loading/skeleton state.
69
+ loading.value = false
70
+ }
67
71
  }
68
72
 
69
73
  return { loading, data, execute }
@@ -79,11 +83,15 @@ export function useMutation<Data = any, Args extends any[] = any[]>(
79
83
  async function execute(...args: Args): Promise<Data> {
80
84
  loading.value = true
81
85
 
82
- const res: Data = await req(new Http(httpConfig), ...args)
83
- data.value = res
84
-
85
- loading.value = false
86
- return res
86
+ try {
87
+ const res: Data = await req(new Http(httpConfig), ...args)
88
+ data.value = res
89
+ return res
90
+ } finally {
91
+ // Always clear loading, even when the request rejects, so a failed
92
+ // mutation doesn't leave the caller wedged in its loading state.
93
+ loading.value = false
94
+ }
87
95
  }
88
96
 
89
97
  return { loading, data, execute }