@globalbrain/sefirot 4.54.0 → 4.56.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,43 @@
1
+ /**
2
+ * A create-form extension contributed by a sheet slot (`#before` / `#after`).
3
+ *
4
+ * The catalog's create form builds its payload from the entity's own Lens
5
+ * fields. A page may need to create related data that isn't a Lens field (e.g. a
6
+ * collaborator's social links, written server-side alongside the record). Such a
7
+ * slot registers an extension via the `registerCreateExtension` slot prop: on
8
+ * submit the sheet runs every registered extension to validate the slot's own
9
+ * inputs and merge its extra keys into the create payload.
10
+ */
11
+ export interface LensCreateContribution {
12
+ /**
13
+ * Whether the slot's own inputs passed validation. When any registered
14
+ * extension reports `false`, the create is aborted (the sheet stays open so
15
+ * each slot can show its own errors).
16
+ */
17
+ valid: boolean
18
+
19
+ /**
20
+ * Extra key/value pairs merged into the create payload (e.g.
21
+ * `{ social_links: [...] }`). Ignored when `valid` is `false`.
22
+ */
23
+ values: Record<string, any>
24
+ }
25
+
26
+ /**
27
+ * A function the sheet calls on create-submit. It validates the slot's inputs
28
+ * and returns its contribution; it may be async (validation usually is). It
29
+ * should report `valid: false` rather than throw — a throw propagates to the
30
+ * sheet's submit handler as an unexpected error.
31
+ */
32
+ export type LensCreateExtension = () =>
33
+ | LensCreateContribution
34
+ | Promise<LensCreateContribution>
35
+
36
+ /**
37
+ * Register a {@link LensCreateExtension} with the sheet's create form. Returns a
38
+ * disposer that unregisters it — call it on unmount (the create slot content
39
+ * unmounts with the sheet, so a component-lifecycle cleanup suffices).
40
+ */
41
+ export type RegisterLensCreateExtension = (
42
+ extension: LensCreateExtension
43
+ ) => () => void
@@ -21,6 +21,7 @@ export type Rule =
21
21
  | AfterRule
22
22
  | AfterOrEqualRule
23
23
  | FileExtensionRule
24
+ | MimeTypesRule
24
25
  | MaxFileSizeRule
25
26
  | EachRule
26
27
 
@@ -122,6 +123,11 @@ export interface FileExtensionRule {
122
123
  extensions: string[]
123
124
  }
124
125
 
126
+ export interface MimeTypesRule {
127
+ type: 'mime_types'
128
+ mimeTypes: string[]
129
+ }
130
+
125
131
  export interface MaxFileSizeRule {
126
132
  type: 'max_file_size'
127
133
  size: string
@@ -11,6 +11,7 @@ import { usePower } from '../../../composables/Power'
11
11
  import { useValidation } from '../../../composables/Validation'
12
12
  import { useSnackbars } from '../../../stores/Snackbars'
13
13
  import { type FieldData } from '../FieldData'
14
+ import { type LensCreateExtension } from '../LensCreateExtension'
14
15
  import { useFieldFactory } from '../composables/FieldFactory'
15
16
  import { useLensEdit } from '../composables/LensEdit'
16
17
  import { extractServerErrors, extractServerMessage } from '../validation/ServerErrors'
@@ -177,6 +178,19 @@ watch(
177
178
  { immediate: true }
178
179
  )
179
180
 
181
+ // Create-form extensions registered by sheet slots (`#before` / `#after`) — a
182
+ // page's custom inputs that aren't Lens fields (e.g. social links written
183
+ // server-side alongside the record). On submit each is run to validate its own
184
+ // inputs and contribute extra keys to the create payload. Registered on mount and
185
+ // disposed on unmount; the create slot content unmounts with the sheet, so a
186
+ // component-lifecycle cleanup keeps this empty between creates.
187
+ const createExtensions = new Set<LensCreateExtension>()
188
+
189
+ function registerCreateExtension(extension: LensCreateExtension): () => void {
190
+ createExtensions.add(extension)
191
+ return () => { createExtensions.delete(extension) }
192
+ }
193
+
180
194
  async function onCreate() {
181
195
  // Guard re-entry: set `saving` before the first await so a fast double-click on
182
196
  // Create can't start a second submission (and create a duplicate record) while
@@ -197,13 +211,32 @@ async function onCreate() {
197
211
  serverErrors.value = {}
198
212
 
199
213
  try {
200
- if (!(await validate())) {
214
+ // Validate the built-in fields and every registered slot extension, running
215
+ // them all even when something already failed, so each section surfaces its
216
+ // own errors on a single submit rather than one at a time. A valid extension
217
+ // contributes extra keys to the payload; any failure aborts the create.
218
+ const fieldsValid = await validate()
219
+ const contributions: Record<string, any>[] = []
220
+ let extensionsValid = true
221
+ for (const extend of createExtensions) {
222
+ const { valid, values } = await extend()
223
+ if (valid) {
224
+ contributions.push(values)
225
+ } else {
226
+ extensionsValid = false
227
+ }
228
+ }
229
+ if (!fieldsValid || !extensionsValid) {
201
230
  return
202
231
  }
203
232
  const values: Record<string, any> = {}
204
233
  for (const { key, field } of createInputViews.value) {
205
234
  values[key] = field.inputToPayload(createModel[key])
206
235
  }
236
+ // Merge each extension's contribution (e.g. `social_links`) into the payload.
237
+ for (const extra of contributions) {
238
+ Object.assign(values, extra)
239
+ }
207
240
  // A picked avatar `File` rides `values`; `edit.create` sends the payload
208
241
  // multipart when one is present, so the avatar persists with the new record.
209
242
  await edit!.create(values)
@@ -266,9 +299,11 @@ function requestClose() {
266
299
  // that hosts the catalog, so it cannot `inject` the edit context (which is
267
300
  // provided inside `LensCatalog`, a child of that page). Expose the pieces a
268
301
  // slot needs as slot props instead: the resolved record id, a record-bound
269
- // partial `save`, and the entity key. This keeps the generic sheet free of
270
- // one-off concerns (avatar upload, social links, linked records) while still
271
- // letting a page implement them.
302
+ // partial `save`, the entity key, the sheet `mode` (so a slot can render
303
+ // different content for create vs view), and `registerCreateExtension` (so a
304
+ // create-mode slot can contribute extra keys to the create payload). This keeps
305
+ // the generic sheet free of one-off concerns (avatar upload, social links,
306
+ // linked records) while still letting a page implement them.
272
307
 
273
308
  const resolvedId = computed(() => (props.record && edit ? edit.resolveId(props.record) : null))
274
309
 
@@ -293,6 +328,9 @@ const slotProps = computed(() => ({
293
328
  record: props.record ?? null,
294
329
  id: resolvedId.value,
295
330
  entity: props.entity,
331
+ // The sheet's current mode, so a slot can branch its content (e.g. show
332
+ // editable inputs only while creating, and a read/edit view otherwise).
333
+ mode: props.mode,
296
334
  // Surfaced so custom editors can disable their save controls until the full
297
335
  // record has loaded; `save` also hard-refuses while loading/error as a guard.
298
336
  loading: props.loading ?? false,
@@ -301,7 +339,11 @@ const slotProps = computed(() => ({
301
339
  // editor can disable its own controls for a rejected row; `save` enforces it
302
340
  // regardless, but otherwise the refusal is only visible as a silent no-op.
303
341
  canEdit: !!props.record && !!edit?.canEdit(props.record),
304
- save: saveRecord
342
+ save: saveRecord,
343
+ // Let a create-mode slot register inputs that contribute extra keys to the
344
+ // create payload (see LensCreateExtension). Stable identity; a no-op in view
345
+ // mode (a slot only registers while the create form is shown).
346
+ registerCreateExtension
305
347
  }))
306
348
  </script>
307
349
 
@@ -1,10 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import IconPencilSimple from '~icons/ph/pencil-simple'
3
- import { computed, ref, watch } from 'vue'
3
+ import { computed, nextTick, ref, watch } from 'vue'
4
4
  import SButton from '../../../components/SButton.vue'
5
5
  import SDataListItem from '../../../components/SDataListItem.vue'
6
6
  import { useTrans } from '../../../composables/Lang'
7
7
  import { useValidation } from '../../../composables/Validation'
8
+ import { dispatchEditorKeydown, focusFirstEditable } from '../../../support/Dom'
8
9
  import { type FieldData } from '../FieldData'
9
10
  import { useLensEdit } from '../composables/LensEdit'
10
11
  import { type Field } from '../fields/Field'
@@ -90,11 +91,17 @@ const { validation, validate, reset } = useValidation(
90
91
  () => ({ input: props.field.generateValidationRules() })
91
92
  )
92
93
 
94
+ const formEl = ref<HTMLElement | null>(null)
95
+
93
96
  function start() {
94
97
  const raw = props.record[props.fieldKey]
95
98
  model.value = props.field.payloadToInput(raw ?? props.field.inputEmptyValue())
96
99
  reset()
97
100
  editing.value = true
101
+ // Focus the input on open (matching the inline table editor): better UX, and
102
+ // it routes the editor's keydowns — notably Escape — through the form handler
103
+ // so Escape cancels the edit rather than closing the sheet.
104
+ nextTick(() => focusFirstEditable(formEl.value))
98
105
  }
99
106
 
100
107
  function cancel() {
@@ -135,6 +142,12 @@ async function apply() {
135
142
  })
136
143
  editing.value = false
137
144
  }
145
+
146
+ function onEditorKeydown(event: KeyboardEvent) {
147
+ // `shield` keeps Escape from reaching the surrounding sheet, which otherwise
148
+ // closes on it (via SSheet's window-level handler).
149
+ dispatchEditorKeydown(event, { cancel, submit: apply, shield: true })
150
+ }
138
151
  </script>
139
152
 
140
153
  <template>
@@ -156,7 +169,7 @@ async function apply() {
156
169
  </button>
157
170
  </div>
158
171
 
159
- <div v-else class="form">
172
+ <div v-else ref="formEl" class="form" @keydown="onEditorKeydown">
160
173
  <component :is="inputComponent" v-model="model" :validation="validation.input" />
161
174
  <div class="actions">
162
175
  <SButton size="mini" :label="t.cancel" @click="cancel" />
@@ -10,6 +10,7 @@ import { useManualDropdownPosition } from '../../../composables/Dropdown'
10
10
  import { useTrans } from '../../../composables/Lang'
11
11
  import { useValidation } from '../../../composables/Validation'
12
12
  import { useSnackbars } from '../../../stores/Snackbars'
13
+ import { dispatchEditorKeydown, focusFirstEditable } from '../../../support/Dom'
13
14
  import { type FieldData } from '../FieldData'
14
15
  import { useLensEdit } from '../composables/LensEdit'
15
16
  import { useLensInlineEdit } from '../composables/LensInlineEdit'
@@ -198,8 +199,7 @@ function startNames() {
198
199
  inline?.start(myKey.value)
199
200
  nextTick(() => {
200
201
  update()
201
- const el = editorEl.value?.querySelector('input, textarea, [contenteditable], [tabindex]') as HTMLElement | null
202
- el?.focus()
202
+ focusFirstEditable(editorEl.value)
203
203
  })
204
204
  }
205
205
 
@@ -246,10 +246,7 @@ async function applyNames() {
246
246
  }
247
247
 
248
248
  function onEditorKeydown(event: KeyboardEvent) {
249
- if (event.key === 'Escape') {
250
- event.preventDefault()
251
- cancelNames()
252
- }
249
+ dispatchEditorKeydown(event, { cancel: cancelNames, submit: applyNames })
253
250
  }
254
251
  </script>
255
252
 
@@ -10,6 +10,7 @@ import { useManualDropdownPosition } from '../../../composables/Dropdown'
10
10
  import { useTrans } from '../../../composables/Lang'
11
11
  import { useValidation } from '../../../composables/Validation'
12
12
  import { day } from '../../../support/Day'
13
+ import { dispatchEditorKeydown, focusFirstEditable } from '../../../support/Dom'
13
14
  import { type FieldData } from '../FieldData'
14
15
  import { useLensEdit } from '../composables/LensEdit'
15
16
  import { useLensInlineEdit } from '../composables/LensInlineEdit'
@@ -159,10 +160,7 @@ function start() {
159
160
  inline?.start(myKey.value)
160
161
  nextTick(() => {
161
162
  update()
162
- const el = editorEl.value?.querySelector(
163
- 'input, textarea, [contenteditable], [tabindex]'
164
- ) as HTMLElement | null
165
- el?.focus()
163
+ focusFirstEditable(editorEl.value)
166
164
  })
167
165
  }
168
166
 
@@ -218,28 +216,7 @@ async function apply() {
218
216
  }
219
217
 
220
218
  function onEditorKeydown(event: KeyboardEvent) {
221
- if (event.key === 'Escape') {
222
- event.preventDefault()
223
- cancel()
224
- return
225
- }
226
- // Enter saves only from a plain text-like input. A textarea inserts a
227
- // newline, and controls that handle Enter themselves (e.g. a dropdown that
228
- // opens its menu on Enter) must keep it — otherwise a keyboard user submits
229
- // the old value instead of choosing an option.
230
- if (event.key === 'Enter' && isTextLikeInput(event.target)) {
231
- event.preventDefault()
232
- apply()
233
- }
234
- }
235
-
236
- function isTextLikeInput(target: EventTarget | null): boolean {
237
- const el = target as HTMLElement | null
238
- if (!el || el.tagName !== 'INPUT') {
239
- return false
240
- }
241
- const type = (el as HTMLInputElement).type
242
- return ['text', 'search', 'url', 'email', 'tel', 'password', 'number'].includes(type)
219
+ dispatchEditorKeydown(event, { cancel, submit: apply })
243
220
  }
244
221
  </script>
245
222
 
@@ -273,6 +273,15 @@ export abstract class Field<T extends FieldData> {
273
273
 
274
274
  /**
275
275
  * Returns the form input component for the field.
276
+ *
277
+ * Several field types currently `throw new Error('Not implemented.')` here —
278
+ * they're shown read-only and have no inline/sheet editor yet. When making one
279
+ * editable, mind the inline editor's keyboard handling: if the input is a
280
+ * composite control with its own nested text input (e.g. a dropdown search
281
+ * filter), that nested input must keep Enter/Escape from bubbling to the
282
+ * editor (see `SDropdownSectionFilter` and `support/Dom`'s
283
+ * `dispatchEditorKeydown`), or typing a value and pressing Enter would
284
+ * submit/cancel the whole editor.
276
285
  */
277
286
  abstract formInputComponent(): any
278
287
 
@@ -13,6 +13,7 @@ import {
13
13
  maxFileSize,
14
14
  maxLength,
15
15
  maxValue,
16
+ mimeTypes,
16
17
  minLength,
17
18
  minValue,
18
19
  negativeInteger,
@@ -130,6 +131,8 @@ function mapRule(rule: Exclude<Rule, EachRule>): ValidationRuleWithParams | null
130
131
  return afterOrEqual(resolveDate(rule.date))
131
132
  case 'file_extension':
132
133
  return fileExtension(rule.extensions)
134
+ case 'mime_types':
135
+ return mimeTypes(rule.mimeTypes)
133
136
  case 'max_file_size':
134
137
  return maxFileSize(rule.size)
135
138
  default: {
@@ -70,7 +70,9 @@ function onClick(option: DropdownSectionFilterOption, value: any) {
70
70
  <template>
71
71
  <div class="SDropdownSectionFilter">
72
72
  <div v-if="search" class="search">
73
- <input ref="input" v-model="query" class="input" :placeholder="t.i_ph">
73
+ <!-- Keep Enter inside the filter: it drives the dropdown, and must not
74
+ bubble to an enclosing editor/form as a submit. -->
75
+ <input ref="input" v-model="query" class="input" :placeholder="t.i_ph" @keydown.enter.stop>
74
76
  </div>
75
77
 
76
78
  <ul v-if="filteredOptions.length" class="list">
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Whether the event target is a plain text-like `<input>` — the controls where
3
+ * pressing Enter should submit an inline editor. Excludes textareas (Enter
4
+ * inserts a newline) and richer controls that handle Enter themselves (e.g. a
5
+ * dropdown that opens its menu on Enter), so a keyboard user isn't forced to
6
+ * submit instead of operating the control.
7
+ */
8
+ export function isTextLikeInput(target: EventTarget | null): boolean {
9
+ const el = target as HTMLElement | null
10
+
11
+ if (!el || el.tagName !== 'INPUT') {
12
+ return false
13
+ }
14
+
15
+ const type = (el as HTMLInputElement).type
16
+
17
+ return ['text', 'search', 'url', 'email', 'tel', 'password', 'number'].includes(type)
18
+ }
19
+
20
+ /**
21
+ * Whether an Enter keydown should submit an inline editor. Ctrl/Cmd+Enter
22
+ * submits from any control — it's the near-universal "submit" gesture (GitHub,
23
+ * Gmail, Slack, …) and never clashes with a textarea's newline or a control that
24
+ * handles bare Enter itself (e.g. a dropdown opening its menu). Bare Enter
25
+ * submits only from a plain text-like input (see {@link isTextLikeInput}).
26
+ *
27
+ * Shift+Enter is left for newlines, and Alt/Option+Enter has no consistent
28
+ * submit meaning (Excel uses it for an in-cell newline), so neither submits.
29
+ * The Enter that commits an IME composition (e.g. CJK input) never submits.
30
+ */
31
+ export function isEditorSubmitKeydown(event: KeyboardEvent): boolean {
32
+ if (event.key !== 'Enter') {
33
+ return false
34
+ }
35
+
36
+ // Ignore the Enter that commits an IME composition (e.g. CJK input): it
37
+ // confirms the candidate rather than submitting.
38
+ if (event.isComposing) {
39
+ return false
40
+ }
41
+
42
+ return event.ctrlKey || event.metaKey || isTextLikeInput(event.target)
43
+ }
44
+
45
+ /**
46
+ * Whether a keydown should cancel an inline editor: Escape, except the Escape
47
+ * that cancels an in-progress IME composition (which reverts the candidate and
48
+ * should leave the editor open).
49
+ */
50
+ export function isEditorCancelKeydown(event: KeyboardEvent): boolean {
51
+ return event.key === 'Escape' && !event.isComposing
52
+ }
53
+
54
+ /**
55
+ * Dispatch a keydown for an inline editor (opened from a cell or sheet field):
56
+ * cancel on Escape, submit on Enter — using the IME- and modifier-aware rules
57
+ * in {@link isEditorCancelKeydown} / {@link isEditorSubmitKeydown}.
58
+ *
59
+ * `shield` is for an editor nested in a surface that itself closes on Escape
60
+ * (e.g. a sheet): Escape is always kept from propagating to that surface — even
61
+ * the Escape that only cancels an IME composition (where the edit stays open) —
62
+ * so the surface can't close underneath the editor.
63
+ *
64
+ * NOTE for future inline/sheet-editable fields: several field types currently
65
+ * throw on `formInputComponent()` (not yet editable). When making one editable,
66
+ * if its input is a composite control with its own nested text input (e.g. a
67
+ * dropdown's search filter), that nested input must keep Enter/Escape from
68
+ * bubbling to this handler — see `SDropdownSectionFilter` — otherwise typing a
69
+ * value and pressing Enter would submit/cancel the whole editor.
70
+ */
71
+ export function dispatchEditorKeydown(
72
+ event: KeyboardEvent,
73
+ handlers: { cancel: () => void; submit: () => void; shield?: boolean }
74
+ ): void {
75
+ if (event.key === 'Escape') {
76
+ if (handlers.shield) {
77
+ event.stopPropagation()
78
+ }
79
+ if (isEditorCancelKeydown(event)) {
80
+ event.preventDefault()
81
+ handlers.cancel()
82
+ }
83
+ return
84
+ }
85
+
86
+ if (isEditorSubmitKeydown(event)) {
87
+ event.preventDefault()
88
+ handlers.submit()
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Focus the first focusable editor control (an input, textarea, contenteditable,
94
+ * or any `[tabindex]`) inside the container — used to focus an inline editor when
95
+ * it opens.
96
+ *
97
+ * Candidates inside `skip` (default `.actions`, the editor's action row) are
98
+ * ignored, so focus never lands on a Cancel/Save button — those are `[tabindex]`
99
+ * elements too — when the field input has no focusable control of its own (e.g.
100
+ * a radio/checkbox group). In that case nothing is focused. Pass `skip: ''` to
101
+ * disable.
102
+ */
103
+ export function focusFirstEditable(container: HTMLElement | null, skip = '.actions'): void {
104
+ const candidates = Array.from(
105
+ container?.querySelectorAll<HTMLElement>('input, textarea, [contenteditable], [tabindex]') ?? []
106
+ )
107
+
108
+ candidates.find((el) => !skip || !el.closest(skip))?.focus()
109
+ }
@@ -12,6 +12,7 @@ export { maxFileSize } from './maxFileSize'
12
12
  export { maxLength } from './maxLength'
13
13
  export { maxTotalFileSize } from './maxTotalFileSize'
14
14
  export { maxValue } from './maxValue'
15
+ export { mimeTypes } from './mimeTypes'
15
16
  export { minLength } from './minLength'
16
17
  export { minValue } from './minValue'
17
18
  export { month } from './month'
@@ -0,0 +1,15 @@
1
+ import { createRule } from '../Rule'
2
+ import { mimeTypes as baseMimeTypes } from '../validators/mimeTypes'
3
+
4
+ export const message = {
5
+ en: 'The file type is invalid.',
6
+ ja: 'ファイル形式が正しくありません。'
7
+ }
8
+
9
+ export function mimeTypes(patterns: string[], msg?: string) {
10
+ return createRule({
11
+ message: ({ lang }) => msg ?? message[lang],
12
+ optional: true,
13
+ validation: (value) => baseMimeTypes(value, patterns)
14
+ })
15
+ }
@@ -12,6 +12,7 @@ export * from './maxFileSize'
12
12
  export * from './maxLength'
13
13
  export * from './maxTotalFileSize'
14
14
  export * from './maxValue'
15
+ export * from './mimeTypes'
15
16
  export * from './minLength'
16
17
  export * from './minValue'
17
18
  export * from './month'
@@ -0,0 +1,19 @@
1
+ export function mimeTypes(value: unknown, patterns: string[]): boolean {
2
+ if (!(value instanceof File)) { return false }
3
+
4
+ // The browser doesn't always report a type (e.g. some drag-and-drop or
5
+ // clipboard sources); defer to the server's content-based check in that case
6
+ // rather than rejecting a possibly-valid file outright.
7
+ if (value.type === '') { return true }
8
+
9
+ const type = value.type.toLowerCase()
10
+
11
+ return patterns.some((pattern) => {
12
+ const p = pattern.toLowerCase()
13
+
14
+ // A `type/*` wildcard (e.g. `image/*`) matches any subtype of that
15
+ // top-level type, mirroring the HTML accept attribute and Laravel's
16
+ // `mimetypes` rule.
17
+ return p.endsWith('/*') ? type.startsWith(p.slice(0, -1)) : type === p
18
+ })
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.54.0",
3
+ "version": "4.56.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",