@globalbrain/sefirot 4.43.5 → 4.44.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.
@@ -20,6 +20,7 @@ export interface FieldDataRegistry {
20
20
  content: ContentFieldData
21
21
  date: DateFieldData
22
22
  datetime: DatetimeFieldData
23
+ decimal: DecimalFieldData
23
24
  file_upload: FileUploadFieldData
24
25
  id: IdFieldData
25
26
  link: LinkFieldData
@@ -88,6 +89,24 @@ export interface LinkFieldData extends FieldDataBase {
88
89
 
89
90
  export interface NumberFieldData extends FieldDataBase {
90
91
  type: 'number'
92
+ align: 'left' | 'center' | 'right' | null
93
+ separator: boolean | null
94
+ abbr: 'en' | 'ja' | null
95
+ fractionDigits: number | null
96
+ }
97
+
98
+ /**
99
+ * A `decimal` field is rendered identically to a `number` field on the
100
+ * client. The distinction exists so backends can preserve arbitrary
101
+ * precision (e.g. sending the value as a string instead of a JS number)
102
+ * without losing the type-level signal at the spec layer.
103
+ */
104
+ export interface DecimalFieldData extends FieldDataBase {
105
+ type: 'decimal'
106
+ align: 'left' | 'center' | 'right' | null
107
+ separator: boolean | null
108
+ abbr: 'en' | 'ja' | null
109
+ fractionDigits: number | null
91
110
  }
92
111
 
93
112
  export interface SelectFieldData extends FieldDataBase {
@@ -152,6 +152,12 @@ async function onSave() {
152
152
  <SInputCheckbox v-model="freeze" />
153
153
  </div>
154
154
  </div>
155
+ <!--
156
+ Default slot — field-specific override forms (e.g. number /
157
+ decimal) inject their own `.item` rows here so they line up
158
+ visually with the base inputs.
159
+ -->
160
+ <slot />
155
161
  </div>
156
162
  </SDoc>
157
163
  </SCardBlock>
@@ -0,0 +1,166 @@
1
+ <script setup lang="ts" generic="T extends DecimalFieldData | NumberFieldData">
2
+ import { computed, reactive } from 'vue'
3
+ import SInputCheckbox from '../../../components/SInputCheckbox.vue'
4
+ import SInputNumber from '../../../components/SInputNumber.vue'
5
+ import SInputSelect from '../../../components/SInputSelect.vue'
6
+ import { useTrans } from '../../../composables/Lang'
7
+ import { type DecimalFieldData, type NumberFieldData } from '../FieldData'
8
+ import LensFormOverrideBase from './LensFormOverrideBase.vue'
9
+
10
+ const props = defineProps<{
11
+ name: string
12
+ field: T
13
+ override: T
14
+ }>()
15
+
16
+ const emit = defineEmits<{
17
+ cancel: []
18
+ saved: [field: Partial<T>]
19
+ }>()
20
+
21
+ const { t } = useTrans({
22
+ en: {
23
+ i_align_label: 'Alignment',
24
+ i_separator_text: 'Separate by comma',
25
+ i_abbr_label: 'Abbreviation',
26
+ i_fraction_digits: 'Fraction digits'
27
+ },
28
+ ja: {
29
+ i_align_label: '配置',
30
+ i_separator_text: 'カンマで区切る',
31
+ i_abbr_label: '省略表記',
32
+ i_fraction_digits: '小数点以下の桁数'
33
+ }
34
+ })
35
+
36
+ // Local state for the number-specific inputs. Base inputs
37
+ // (label, width, freeze) keep their own state inside
38
+ // `LensFormOverrideBase`; we receive their changes via the `saved`
39
+ // event and merge in our four extras here.
40
+ //
41
+ // `separator` keeps its original `boolean | null` value so a no-op
42
+ // submit (user opens the modal and clicks Finish without touching
43
+ // the checkbox) doesn't promote `null` to `false`. The
44
+ // `separatorModel` computed below adapts `null` to `false` only for
45
+ // the `SInputCheckbox` v-model.
46
+ const data = reactive({
47
+ align: props.override.align,
48
+ separator: props.override.separator,
49
+ abbr: props.override.abbr,
50
+ fractionDigits: props.override.fractionDigits
51
+ })
52
+
53
+ // `SInputCheckbox` accepts `boolean | 'indeterminate' | undefined`
54
+ // (not `null`), so adapt at the binding boundary. The model writes
55
+ // the user's `true`/`false` straight into `data.separator`, while
56
+ // `null` from the field-level default stays `null` until the user
57
+ // actually flips the checkbox.
58
+ const separatorModel = computed({
59
+ get: () => data.separator ?? false,
60
+ set: (v) => { data.separator = v }
61
+ })
62
+
63
+ const { t: alignOptions } = useTrans({
64
+ en: [
65
+ { label: 'Left', value: 'left' },
66
+ { label: 'Center', value: 'center' },
67
+ { label: 'Right', value: 'right' }
68
+ ],
69
+ ja: [
70
+ { label: '左寄せ', value: 'left' },
71
+ { label: '中央寄せ', value: 'center' },
72
+ { label: '右寄せ', value: 'right' }
73
+ ]
74
+ })
75
+
76
+ // `null` represents "no abbreviation". We expose it as a real option
77
+ // so users can switch back to plain rendering after picking a locale.
78
+ // `SInputSelect` accepts `null` values when the option carries
79
+ // `value: null`.
80
+ const { t: abbrOptions } = useTrans({
81
+ en: [
82
+ { label: 'None', value: null },
83
+ { label: 'English (1K, 1M, 1B)', value: 'en' },
84
+ { label: 'Japanese (1万, 1億, 1兆)', value: 'ja' }
85
+ ],
86
+ ja: [
87
+ { label: 'なし', value: null },
88
+ { label: '英語スタイル (1K, 1M, 1B)', value: 'en' },
89
+ { label: '日本語スタイル (1万, 1億, 1兆)', value: 'ja' }
90
+ ]
91
+ })
92
+
93
+ function onSaved(override: Partial<T>): void {
94
+ // Only include keys whose value diverged from the underlying field
95
+ // definition — matches what `LensFormOverrideBase` does for the
96
+ // base inputs. We mutate the payload from the base in place rather
97
+ // than re-allocating so any keys it already populated (label,
98
+ // width, freeze) carry through unchanged.
99
+ //
100
+ // Plain `!==` (not the base's `isNotNullOrSame`) is intentional: the
101
+ // four formatting options are tri-state, so clearing a previous
102
+ // `align: 'right'` back to `null` must actually persist `align: null`
103
+ // rather than silently inherit the field-level default.
104
+ const o = override as Record<string, any>
105
+ if (data.align !== props.field.align) {
106
+ o.align = data.align
107
+ }
108
+ if (data.separator !== props.field.separator) {
109
+ o.separator = data.separator
110
+ }
111
+ if (data.abbr !== props.field.abbr) {
112
+ o.abbr = data.abbr
113
+ }
114
+ if (data.fractionDigits !== props.field.fractionDigits) {
115
+ o.fractionDigits = data.fractionDigits
116
+ }
117
+ emit('saved', o as Partial<T>)
118
+ }
119
+ </script>
120
+
121
+ <template>
122
+ <LensFormOverrideBase
123
+ class="LensFormOverrideNumber"
124
+ :name
125
+ :field
126
+ :label-en="override.labelEn"
127
+ :label-ja="override.labelJa"
128
+ :width="override.width"
129
+ :freeze="override.freeze"
130
+ @cancel="$emit('cancel')"
131
+ @saved="onSaved"
132
+ >
133
+ <div class="item">
134
+ <div class="key">{{ t.i_align_label }}</div>
135
+ <div class="value">
136
+ <SInputSelect
137
+ v-model="data.align"
138
+ size="mini"
139
+ :options="alignOptions"
140
+ />
141
+ </div>
142
+ </div>
143
+ <div class="item">
144
+ <div class="key">{{ t.i_separator_text }}</div>
145
+ <div class="value">
146
+ <SInputCheckbox v-model="separatorModel" />
147
+ </div>
148
+ </div>
149
+ <div class="item">
150
+ <div class="key">{{ t.i_abbr_label }}</div>
151
+ <div class="value">
152
+ <SInputSelect
153
+ v-model="data.abbr"
154
+ size="mini"
155
+ :options="abbrOptions"
156
+ />
157
+ </div>
158
+ </div>
159
+ <div class="item">
160
+ <div class="key">{{ t.i_fraction_digits }}</div>
161
+ <div class="value">
162
+ <SInputNumber v-model="data.fractionDigits" size="mini" />
163
+ </div>
164
+ </div>
165
+ </LensFormOverrideBase>
166
+ </template>
@@ -4,6 +4,7 @@ import { provideFieldRegistry } from '../composables/FieldRegistry'
4
4
  import { ContentField } from '../fields/ContentField'
5
5
  import { DateField } from '../fields/DateField'
6
6
  import { DatetimeField } from '../fields/DatetimeField'
7
+ import { DecimalField } from '../fields/DecimalField'
7
8
  import { FileUploadField } from '../fields/FileUploadField'
8
9
  import { IdField } from '../fields/IdField'
9
10
  import { LinkField } from '../fields/LinkField'
@@ -34,6 +35,7 @@ export function useSetupLens(): SetupLens {
34
35
  fieldRegistry.register('content', (ctx, field) => new ContentField(ctx, field))
35
36
  fieldRegistry.register('date', (ctx, field) => new DateField(ctx, field))
36
37
  fieldRegistry.register('datetime', (ctx, field) => new DatetimeField(ctx, field))
38
+ fieldRegistry.register('decimal', (ctx, field) => new DecimalField(ctx, field))
37
39
  fieldRegistry.register('file_upload', (ctx, field) => new FileUploadField(ctx, field, fileDownloader))
38
40
  fieldRegistry.register('link', (ctx, field) => new LinkField(ctx, field))
39
41
  fieldRegistry.register('number', (ctx, field) => new NumberField(ctx, field))
@@ -0,0 +1,60 @@
1
+ import { defineComponent, h } from 'vue'
2
+ import { type TableCell } from '../../../composables/Table'
3
+ import { type DecimalFieldData } from '../FieldData'
4
+ import { type FilterOperator } from '../FilterOperator'
5
+ import LensFormOverrideNumber from '../components/LensFormOverrideNumber.vue'
6
+ import { type FilterInput } from '../filter-inputs/FilterInput'
7
+ import { NumberFilterInput } from '../filter-inputs/NumberFilterInput'
8
+ import { Field } from './Field'
9
+ import { renderNumberLikeTableCell } from './support/Renderers'
10
+
11
+ /**
12
+ * A decimal field is rendered identically to a number field on the
13
+ * client — `renderNumberLikeTableCell` covers both. The type exists so
14
+ * backends can preserve arbitrary precision (e.g. send the value as a
15
+ * string instead of a JS number) without losing the type-level signal
16
+ * at the spec layer.
17
+ */
18
+ export class DecimalField extends Field<DecimalFieldData> {
19
+ override tableCell(v: any, _r: any): TableCell {
20
+ return renderNumberLikeTableCell(this.data, v)
21
+ }
22
+
23
+ override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
24
+ const number = new NumberFilterInput()
25
+
26
+ return {
27
+ '=': number,
28
+ '!=': number
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Renders the override form with extra inputs for `align`,
34
+ * `separator`, `abbr` and `fractionDigits` on top of the base
35
+ * label / width / freeze controls.
36
+ */
37
+ override overrideForm(): any {
38
+ return defineComponent((props) => {
39
+ return () => h(LensFormOverrideNumber, {
40
+ name: props.name,
41
+ field: props.field,
42
+ override: props.override
43
+ })
44
+ }, {
45
+ props: [
46
+ 'name',
47
+ 'field',
48
+ 'override'
49
+ ]
50
+ })
51
+ }
52
+
53
+ override dataListItemComponent(): any {
54
+ throw new Error('Not implemented.')
55
+ }
56
+
57
+ override formInputComponent() {
58
+ throw new Error('Not implemented.')
59
+ }
60
+ }
@@ -1,16 +1,16 @@
1
+ import { defineComponent, h } from 'vue'
1
2
  import { type TableCell } from '../../../composables/Table'
2
3
  import { type NumberFieldData } from '../FieldData'
3
4
  import { type FilterOperator } from '../FilterOperator'
5
+ import LensFormOverrideNumber from '../components/LensFormOverrideNumber.vue'
4
6
  import { type FilterInput } from '../filter-inputs/FilterInput'
5
7
  import { NumberFilterInput } from '../filter-inputs/NumberFilterInput'
6
8
  import { Field } from './Field'
9
+ import { renderNumberLikeTableCell } from './support/Renderers'
7
10
 
8
11
  export class NumberField extends Field<NumberFieldData> {
9
12
  override tableCell(v: any, _r: any): TableCell {
10
- return {
11
- type: 'number',
12
- value: v
13
- }
13
+ return renderNumberLikeTableCell(this.data, v)
14
14
  }
15
15
 
16
16
  override availableFilters(): Partial<Record<FilterOperator, FilterInput>> {
@@ -22,6 +22,27 @@ export class NumberField extends Field<NumberFieldData> {
22
22
  }
23
23
  }
24
24
 
25
+ /**
26
+ * Renders the override form with extra inputs for `align`,
27
+ * `separator`, `abbr` and `fractionDigits` on top of the base
28
+ * label / width / freeze controls.
29
+ */
30
+ override overrideForm(): any {
31
+ return defineComponent((props) => {
32
+ return () => h(LensFormOverrideNumber, {
33
+ name: props.name,
34
+ field: props.field,
35
+ override: props.override
36
+ })
37
+ }, {
38
+ props: [
39
+ 'name',
40
+ 'field',
41
+ 'override'
42
+ ]
43
+ })
44
+ }
45
+
25
46
  override dataListItemComponent(): any {
26
47
  throw new Error('Not implemented.')
27
48
  }
@@ -0,0 +1,78 @@
1
+ import { type TableCell } from '../../../../composables/Table'
2
+ import { abbreviate } from '../../../../support/Num'
3
+ import { type DecimalFieldData, type NumberFieldData } from '../../FieldData'
4
+
5
+ /**
6
+ * `Intl.NumberFormat`'s `maximumFractionDigits` accepts integers in
7
+ * the range `0..20` (inclusive) and throws `RangeError` outside it.
8
+ * The override UI can in principle accept any number — including
9
+ * negative values via `SInputNumber` — so we clamp defensively on
10
+ * the rendering path. `null` / `undefined` stays untouched so the
11
+ * cell component can route through the "no formatting" path.
12
+ */
13
+ function clampFractionDigits(digits: number | null | undefined): number | null {
14
+ if (digits == null) {
15
+ return null
16
+ }
17
+ return Math.max(0, Math.min(20, Math.trunc(digits)))
18
+ }
19
+
20
+ /**
21
+ * Coerces an incoming cell value to a finite number, or `null` when
22
+ * the value is missing / blank / non-numeric. We intentionally treat
23
+ * empty and whitespace-only strings the same as `null` — otherwise
24
+ * `Number('') === 0` would render blank numeric cells as a literal
25
+ * `0`, which is a regression from the pre-formatting renderer that
26
+ * passed values through untouched. `NaN` (from `Number('abc')` and
27
+ * friends) collapses to `null` too so the cell renders blank instead
28
+ * of the string `"NaN"`.
29
+ */
30
+ function toNumberOrNull(v: any): number | null {
31
+ if (v == null) {
32
+ return null
33
+ }
34
+ if (typeof v === 'string' && v.trim() === '') {
35
+ return null
36
+ }
37
+ const num = Number(v)
38
+ return Number.isFinite(num) ? num : null
39
+ }
40
+
41
+ /**
42
+ * Renders a `number` or `decimal` field as a table cell. The two share
43
+ * the same rendering rules so this helper covers both.
44
+ *
45
+ * - When `abbr` is `null`, we emit a `number` cell. The cell component
46
+ * applies `separator` and `maximumFractionDigits` via
47
+ * `toLocaleString`. The raw incoming value is coerced with `Number()`
48
+ * so decimal fields delivered as strings still render.
49
+ * - When `abbr` is `'en'` or `'ja'`, we emit a `text` cell with the
50
+ * compact-notation string baked in. We fall back to a 1-digit
51
+ * precision when `fractionDigits` is `null` — matches what the
52
+ * default user-facing column-format UI would set.
53
+ */
54
+ export function renderNumberLikeTableCell(
55
+ data: NumberFieldData | DecimalFieldData,
56
+ v: any
57
+ ): TableCell {
58
+ const num = toNumberOrNull(v)
59
+ const cap = clampFractionDigits(data.fractionDigits)
60
+
61
+ if (data.abbr != null) {
62
+ return {
63
+ type: 'text',
64
+ align: data.align ?? 'left',
65
+ value: num != null
66
+ ? abbreviate(num, cap ?? 1, data.abbr)
67
+ : null
68
+ }
69
+ }
70
+
71
+ return {
72
+ type: 'number',
73
+ align: data.align ?? 'left',
74
+ value: num,
75
+ separator: data.separator ?? false,
76
+ maximumFractionDigits: cap
77
+ }
78
+ }
@@ -54,6 +54,7 @@ const valueIsImagePath = computed(() => {
54
54
  :icon="computedCell.icon"
55
55
  :number="computedCell.value ?? value"
56
56
  :separator="computedCell.separator"
57
+ :maximum-fraction-digits="computedCell.maximumFractionDigits"
57
58
  :link="computedCell.link"
58
59
  :color="computedCell.color"
59
60
  :icon-color="computedCell.iconColor"
@@ -1,7 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { type Component, computed } from 'vue'
3
3
  import { type TableCellValueColor } from '../composables/Table'
4
- import { format } from '../support/Num'
5
4
  import SLink from './SLink.vue'
6
5
 
7
6
  const props = defineProps<{
@@ -11,6 +10,7 @@ const props = defineProps<{
11
10
  icon?: Component
12
11
  number?: number | null
13
12
  separator?: boolean
13
+ maximumFractionDigits?: number | null
14
14
  color?: TableCellValueColor
15
15
  iconColor?: TableCellValueColor
16
16
  link?: string | null
@@ -25,6 +25,29 @@ const classes = computed(() => [
25
25
  _color,
26
26
  { link: !!(props.link || props.onClick) }
27
27
  ])
28
+
29
+ // We format the value inline (rather than via `Num.format`) so we can
30
+ // thread `useGrouping` and `maximumFractionDigits` through the same
31
+ // `toLocaleString` call. `Num.format` is kept for callers that just
32
+ // want the default separator-on / unbounded-digits behavior.
33
+ //
34
+ // When neither `separator` nor `maximumFractionDigits` is requested we
35
+ // return the raw number and let Vue's template interpolation stringify
36
+ // it. `toLocaleString` would otherwise round very small numbers to
37
+ // `"0"` (e.g. `1e-25` → `"0"` at 20 digits), losing information that
38
+ // the plain `String(number)` form preserves via scientific notation.
39
+ const formatted = computed(() => {
40
+ if (props.number == null) {
41
+ return ''
42
+ }
43
+ if (!props.separator && props.maximumFractionDigits == null) {
44
+ return props.number
45
+ }
46
+ return props.number.toLocaleString('en-US', {
47
+ useGrouping: props.separator === true,
48
+ maximumFractionDigits: props.maximumFractionDigits ?? 20
49
+ })
50
+ })
28
51
  </script>
29
52
 
30
53
  <template>
@@ -40,7 +63,7 @@ const classes = computed(() => [
40
63
  <component :is="icon" class="svg" />
41
64
  </div>
42
65
  <div class="value" :class="_color">
43
- {{ separator ? format(number) : number }}
66
+ {{ formatted }}
44
67
  </div>
45
68
  </SLink>
46
69
  </div>
@@ -115,6 +115,12 @@ export interface TableCellNumber<V = any, R = any> extends TableCellBase {
115
115
  icon?: Component
116
116
  value?: number | null
117
117
  separator?: boolean
118
+ /**
119
+ * Caps the displayed fractional digits using
120
+ * `Intl.NumberFormat`-style "maximum fractional digits" semantics.
121
+ * `null` / `undefined` shows the value as-is (no rounding).
122
+ */
123
+ maximumFractionDigits?: number | null
118
124
  link?: string | null
119
125
  color?: TableCellValueColor
120
126
  iconColor?: TableCellValueColor
@@ -2,8 +2,9 @@ export function format(value: number): string {
2
2
  return value.toLocaleString('en-US', { maximumFractionDigits: 20 })
3
3
  }
4
4
 
5
- export function abbreviate(value: number, precision = 0): string {
6
- return value.toLocaleString('en-US', {
5
+ export function abbreviate(value: number, precision = 0, lang: 'en' | 'ja' = 'en'): string {
6
+ const locale = lang === 'ja' ? 'ja-JP' : 'en-US'
7
+ return value.toLocaleString(locale, {
7
8
  notation: 'compact',
8
9
  maximumFractionDigits: precision
9
10
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.43.5",
3
+ "version": "4.44.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",