@kernhq/module-quire 0.7.4 → 0.9.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 (88) hide show
  1. package/README.md +93 -55
  2. package/dist/{server → client}/formula.d.ts +6 -0
  3. package/dist/client/formula.d.ts.map +1 -0
  4. package/dist/{server → client}/formula.js +6 -0
  5. package/dist/client/formula.js.map +1 -0
  6. package/dist/contract/properties.d.ts +26 -0
  7. package/dist/contract/properties.d.ts.map +1 -1
  8. package/dist/contract/properties.js +24 -0
  9. package/dist/contract/properties.js.map +1 -1
  10. package/dist/contract/router.d.ts +334 -0
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +43 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +341 -0
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +79 -10
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/document.d.ts +13 -0
  19. package/dist/server/document.d.ts.map +1 -0
  20. package/dist/server/document.js +58 -0
  21. package/dist/server/document.js.map +1 -0
  22. package/dist/server/index.d.ts.map +1 -1
  23. package/dist/server/index.js +26 -1
  24. package/dist/server/index.js.map +1 -1
  25. package/dist/server/render.d.ts +53 -0
  26. package/dist/server/render.d.ts.map +1 -0
  27. package/dist/server/render.js +358 -0
  28. package/dist/server/render.js.map +1 -0
  29. package/dist/server/services/databases.d.ts +73 -1
  30. package/dist/server/services/databases.d.ts.map +1 -1
  31. package/dist/server/services/databases.js +167 -10
  32. package/dist/server/services/databases.js.map +1 -1
  33. package/dist/server/services/pages.d.ts.map +1 -1
  34. package/dist/server/services/pages.js +11 -1
  35. package/dist/server/services/pages.js.map +1 -1
  36. package/dist/server/services/query.d.ts.map +1 -1
  37. package/dist/server/services/query.js +93 -37
  38. package/dist/server/services/query.js.map +1 -1
  39. package/dist/server/services/versions.d.ts.map +1 -1
  40. package/dist/server/services/versions.js +13 -1
  41. package/dist/server/services/versions.js.map +1 -1
  42. package/migrations/0006_rank_collation.sql +14 -0
  43. package/migrations/meta/_journal.json +7 -0
  44. package/package.json +12 -9
  45. package/src/client/components/PageEditor.svelte +7 -0
  46. package/src/client/components/PageTreeRow.svelte +1 -1
  47. package/src/client/core-api.ts +38 -0
  48. package/src/client/database/BoardView.svelte +354 -0
  49. package/src/client/database/CalendarView.svelte +346 -0
  50. package/src/client/database/DatabaseView.svelte +785 -0
  51. package/src/client/database/FilterMenu.svelte +281 -0
  52. package/src/client/database/FilterValue.svelte +176 -0
  53. package/src/client/database/GalleryView.svelte +177 -0
  54. package/src/client/database/ListView.svelte +108 -0
  55. package/src/client/database/OptionChip.svelte +38 -0
  56. package/src/client/database/PropertyDialog.svelte +460 -0
  57. package/src/client/database/PropertyMenu.svelte +162 -0
  58. package/src/client/database/RowPanel.svelte +157 -0
  59. package/src/client/database/SortMenu.svelte +199 -0
  60. package/src/client/database/TableView.svelte +388 -0
  61. package/src/client/database/ViewDialog.svelte +229 -0
  62. package/src/client/database/cells/Cell.svelte +103 -0
  63. package/src/client/database/cells/CheckboxCell.svelte +33 -0
  64. package/src/client/database/cells/ComputedCell.svelte +100 -0
  65. package/src/client/database/cells/DateCell.svelte +97 -0
  66. package/src/client/database/cells/LinkCell.svelte +143 -0
  67. package/src/client/database/cells/NumberCell.svelte +131 -0
  68. package/src/client/database/cells/PersonCell.svelte +116 -0
  69. package/src/client/database/cells/RelationCell.svelte +222 -0
  70. package/src/client/database/cells/SelectCell.svelte +133 -0
  71. package/src/client/database/cells/TextCell.svelte +85 -0
  72. package/src/client/database/colours.ts +27 -0
  73. package/src/client/database/property-types.test.ts +64 -0
  74. package/src/client/database/property-types.ts +294 -0
  75. package/src/client/database/view-config.test.ts +148 -0
  76. package/src/client/database/view-config.ts +102 -0
  77. package/src/client/formula.test.ts +139 -0
  78. package/src/client/formula.ts +430 -0
  79. package/src/client/i18n.ts +1176 -0
  80. package/src/client/index.ts +31 -0
  81. package/src/client/mock.ts +511 -1
  82. package/src/client/pages/PageView.svelte +47 -17
  83. package/src/client/pages/SpacePage.svelte +8 -2
  84. package/src/client/query.ts +17 -4
  85. package/src/contract/properties.ts +28 -0
  86. package/src/contract/router.ts +46 -0
  87. package/dist/server/formula.d.ts.map +0 -1
  88. package/dist/server/formula.js.map +0 -1
@@ -0,0 +1,103 @@
1
+ <script lang="ts">
2
+ import type { Property, Row } from '../../../contract/index.js'
3
+ import type { Person } from '../../core-api.js'
4
+ import { t } from '../../i18n.js'
5
+ import { descriptorFor } from '../property-types.js'
6
+ import CheckboxCell from './CheckboxCell.svelte'
7
+ import ComputedCell from './ComputedCell.svelte'
8
+ import DateCell from './DateCell.svelte'
9
+ import LinkCell from './LinkCell.svelte'
10
+ import NumberCell from './NumberCell.svelte'
11
+ import PersonCell from './PersonCell.svelte'
12
+ import RelationCell from './RelationCell.svelte'
13
+ import SelectCell from './SelectCell.svelte'
14
+ import TextCell from './TextCell.svelte'
15
+
16
+ /**
17
+ * One cell: the property's type in, the right editor out.
18
+ *
19
+ * Every branch reads `property-types.ts` rather than switching on the type again, so a type added to
20
+ * the contract lands in exactly one place. A cell reads `computed` for the six server-written types
21
+ * and `props` for everything else — the same split the server's `valueExpr` makes, and getting it
22
+ * wrong here shows an empty column rather than an error.
23
+ *
24
+ * `onchange` fires once per **committed** edit, never per keystroke.
25
+ */
26
+ interface Props {
27
+ property: Property
28
+ row: Row
29
+ people: Person[]
30
+ workspaceId: string
31
+ /** false when the viewer may not edit this database at all */
32
+ canEdit: boolean
33
+ onchange: (value: unknown) => void
34
+ }
35
+ const { property, row, people, workspaceId, canEdit, onchange }: Props = $props()
36
+
37
+ const descriptor = $derived(descriptorFor(property.type))
38
+ const value = $derived(descriptor.readOnly ? row.computed[property.key] : row.props[property.key])
39
+ const editable = $derived(canEdit && !descriptor.readOnly)
40
+
41
+ /**
42
+ * Why this cell cannot be typed into. A disabled control with no explanation is a bug, and the two
43
+ * reasons are different: the column is worked out, or you may not edit anything here.
44
+ */
45
+ const reason = $derived(descriptor.readOnly ? t('db_cell_readonly') : t('db_cell_no_permission'))
46
+ </script>
47
+
48
+ {#if descriptor.editor === 'computed'}
49
+ <ComputedCell {value} name={property.name} type={property.type} {people} />
50
+ {:else if descriptor.editor === 'unsupported'}
51
+ <span class="unsupported" title={t('db_files_unsupported')}>{t('db_files_unsupported')}</span>
52
+ {:else if descriptor.editor === 'number'}
53
+ <NumberCell {value} name={property.name} config={property.config} {editable} {reason} {onchange} />
54
+ {:else if descriptor.editor === 'select'}
55
+ <SelectCell
56
+ {value}
57
+ name={property.name}
58
+ type={property.type}
59
+ config={property.config}
60
+ {editable}
61
+ {reason}
62
+ {onchange}
63
+ />
64
+ {:else if descriptor.editor === 'date'}
65
+ <DateCell {value} name={property.name} config={property.config} {editable} {reason} {onchange} />
66
+ {:else if descriptor.editor === 'person'}
67
+ <PersonCell
68
+ {value}
69
+ name={property.name}
70
+ config={property.config}
71
+ {people}
72
+ {editable}
73
+ {reason}
74
+ {onchange}
75
+ />
76
+ {:else if descriptor.editor === 'checkbox'}
77
+ <CheckboxCell {value} name={property.name} {editable} {reason} {onchange} />
78
+ {:else if descriptor.editor === 'link'}
79
+ <LinkCell {value} name={property.name} type={property.type} {editable} {reason} {onchange} />
80
+ {:else if descriptor.editor === 'relation'}
81
+ <RelationCell
82
+ {value}
83
+ name={property.name}
84
+ config={property.config}
85
+ {workspaceId}
86
+ {editable}
87
+ {reason}
88
+ {onchange}
89
+ />
90
+ {:else}
91
+ <TextCell {value} name={property.name} {editable} {reason} {onchange} />
92
+ {/if}
93
+
94
+ <style>
95
+ .unsupported {
96
+ font-size: 12.5px;
97
+ color: var(--kern-ink-450);
98
+ overflow: hidden;
99
+ text-overflow: ellipsis;
100
+ white-space: nowrap;
101
+ cursor: default;
102
+ }
103
+ </style>
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import { Checkbox } from '@kernhq/ui'
3
+
4
+ /**
5
+ * A checkbox with no visible label, so the column's name is its accessible name — without it a
6
+ * screen reader announces a table of twelve boxes all called "checkbox".
7
+ *
8
+ * An untouched cell is false rather than absent, which is what the server's filter also assumes:
9
+ * "not done" has to include the rows nobody has touched, and that is most of them.
10
+ */
11
+ interface Props {
12
+ value: unknown
13
+ name: string
14
+ editable: boolean
15
+ reason?: string
16
+ onchange: (value: unknown) => void
17
+ }
18
+ const { value, name, editable, reason, onchange }: Props = $props()
19
+
20
+ const checked = $derived(value === true || value === 'true')
21
+ </script>
22
+
23
+ <span class="wrap" title={editable ? undefined : reason}>
24
+ <Checkbox {checked} disabled={!editable} ariaLabel={name} onCheckedChange={(on) => onchange(on)} />
25
+ </span>
26
+
27
+ <style>
28
+ .wrap {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ min-height: 26px;
32
+ }
33
+ </style>
@@ -0,0 +1,100 @@
1
+ <script lang="ts">
2
+ import { Avatar, formatDateTime, Icon, messageLocale } from '@kernhq/ui'
3
+ import type { PropertyType } from '../../../contract/index.js'
4
+ import type { Person } from '../../core-api.js'
5
+ import { t } from '../../i18n.js'
6
+
7
+ /**
8
+ * formula, rollup and the four audit stamps — everything the server works out.
9
+ *
10
+ * Read-only, and it says so on hover rather than rendering a disabled input with no explanation.
11
+ *
12
+ * A broken formula is stored as `{ error: "…" }`, and rendering that with `String()` puts
13
+ * `[object Object]` in the cell — which reads as a bug in the table rather than as a mistake in the
14
+ * expression. The message is shown instead, in the danger tone, which is where the fix actually is.
15
+ */
16
+ interface Props {
17
+ value: unknown
18
+ name: string
19
+ type: PropertyType
20
+ people: Person[]
21
+ reason?: string
22
+ }
23
+ const { value, name, type, people, reason }: Props = $props()
24
+
25
+ const error = $derived(
26
+ value !== null && typeof value === 'object' && !Array.isArray(value) && 'error' in value
27
+ ? String((value as { error: unknown }).error)
28
+ : null,
29
+ )
30
+
31
+ const personFor = (id: string) => people.find((p) => p.id === id) ?? null
32
+
33
+ const shown = $derived.by(() => {
34
+ if (error) return ''
35
+ if (value === null || value === undefined || value === '') return ''
36
+ if (type === 'created_time' || type === 'edited_time') return formatDateTime(String(value))
37
+ if (typeof value === 'boolean') return value ? '✓' : '—'
38
+ if (typeof value === 'number') return new Intl.NumberFormat(messageLocale()).format(value)
39
+ if (Array.isArray(value)) return value.map(String).join(', ')
40
+ return String(value)
41
+ })
42
+
43
+ const isPerson = $derived(type === 'created_by' || type === 'edited_by')
44
+ const personId = $derived(isPerson && typeof value === 'string' && value !== '' ? value : null)
45
+ </script>
46
+
47
+ {#if error}
48
+ <!-- The message, not the chip, is what tells somebody where the mistake is. -->
49
+ <span class="err" title={error} aria-label={`${name}: ${error}`}>
50
+ <Icon name="triangle-alert" size={13} strokeWidth={1.7} />
51
+ <span class="nm">{t('db_cell_formula_error')}</span>
52
+ </span>
53
+ {:else if isPerson}
54
+ <span class="cell-static" title={reason ?? t('db_cell_readonly')} aria-label={name}>
55
+ {#if personId}
56
+ <Avatar id={personId} name={personFor(personId)?.name ?? null} src={personFor(personId)?.avatarUrl ?? null} size={20} />
57
+ <span class="nm">{personFor(personId)?.name ?? personId}</span>
58
+ {:else}
59
+ <span class="muted">{t('db_cell_empty')}</span>
60
+ {/if}
61
+ </span>
62
+ {:else}
63
+ <span class="cell-static" title={reason ?? t('db_cell_readonly')} aria-label={name}>
64
+ {#if shown}{shown}{:else}<span class="muted">{t('db_cell_empty')}</span>{/if}
65
+ </span>
66
+ {/if}
67
+
68
+ <style>
69
+ .cell-static {
70
+ display: inline-flex;
71
+ align-items: center;
72
+ gap: 5px;
73
+ min-width: 0;
74
+ min-height: 26px;
75
+ overflow: hidden;
76
+ cursor: default;
77
+ font-size: 13px;
78
+ color: var(--kern-ink-550);
79
+ }
80
+ .nm {
81
+ overflow: hidden;
82
+ text-overflow: ellipsis;
83
+ white-space: nowrap;
84
+ }
85
+ .err {
86
+ display: inline-flex;
87
+ align-items: center;
88
+ gap: 5px;
89
+ min-width: 0;
90
+ padding: 2px 7px;
91
+ border-radius: var(--kern-r-md);
92
+ background: var(--kern-danger-tint);
93
+ color: var(--kern-danger);
94
+ font-size: 12px;
95
+ cursor: help;
96
+ }
97
+ .muted {
98
+ color: var(--kern-ink-450);
99
+ }
100
+ </style>
@@ -0,0 +1,97 @@
1
+ <script lang="ts">
2
+ import { formatDate, formatDateTime } from '@kernhq/ui'
3
+ import type { PropertyConfig } from '../../../contract/index.js'
4
+ import { t } from '../../i18n.js'
5
+
6
+ /**
7
+ * A date, stored as an ISO string.
8
+ *
9
+ * The native picker rather than a hand-rolled calendar: it is localised, keyboard-operable and
10
+ * reachable by a screen reader without any of that being written here. It is drawn through
11
+ * `formatDate` when it is not being edited, so the table reads in the interface language rather
12
+ * than in the browser's.
13
+ */
14
+ interface Props {
15
+ value: unknown
16
+ name: string
17
+ config: PropertyConfig
18
+ editable: boolean
19
+ reason?: string
20
+ onchange: (value: unknown) => void
21
+ }
22
+ const { value, name, config, editable, reason, onchange }: Props = $props()
23
+
24
+ const withTime = $derived(config.includeTime === true)
25
+ const iso = $derived(typeof value === 'string' && value !== '' ? value : null)
26
+
27
+ /** `datetime-local` wants `YYYY-MM-DDTHH:mm` in local time; `date` wants `YYYY-MM-DD`. */
28
+ const fieldValue = $derived.by(() => {
29
+ if (!iso) return ''
30
+ const at = new Date(iso)
31
+ if (Number.isNaN(at.getTime())) return ''
32
+ const pad = (n: number) => String(n).padStart(2, '0')
33
+ const day = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
34
+ return withTime ? `${day}T${pad(at.getHours())}:${pad(at.getMinutes())}` : day
35
+ })
36
+
37
+ const shown = $derived(iso ? (withTime ? formatDateTime(iso) : formatDate(iso)) : '')
38
+
39
+ const commit = (next: string) => {
40
+ if (next === '') {
41
+ if (iso) onchange(null)
42
+ return
43
+ }
44
+ const at = new Date(next)
45
+ if (Number.isNaN(at.getTime())) return
46
+ onchange(at.toISOString())
47
+ }
48
+ </script>
49
+
50
+ {#if editable}
51
+ <input
52
+ class="cell-input"
53
+ type={withTime ? 'datetime-local' : 'date'}
54
+ value={fieldValue}
55
+ aria-label={name}
56
+ onchange={(e) => commit(e.currentTarget.value)}
57
+ />
58
+ {:else}
59
+ <span class="cell-static" title={reason}>
60
+ {#if shown}{shown}{:else}<span class="muted">{t('db_cell_empty')}</span>{/if}
61
+ </span>
62
+ {/if}
63
+
64
+ <style>
65
+ .cell-input {
66
+ width: 100%;
67
+ min-width: 0;
68
+ min-height: 26px;
69
+ padding: 2px 6px;
70
+ margin-inline-start: -6px;
71
+ border: 1px solid transparent;
72
+ border-radius: var(--kern-r-sm);
73
+ background: none;
74
+ color: inherit;
75
+ font: inherit;
76
+ font-size: 13px;
77
+ }
78
+ .cell-input:hover {
79
+ background: var(--kern-surface-active);
80
+ }
81
+ .cell-input:focus {
82
+ border-color: var(--kern-border);
83
+ background: var(--kern-surface-raised);
84
+ outline: none;
85
+ }
86
+ .cell-static {
87
+ min-width: 0;
88
+ overflow: hidden;
89
+ text-overflow: ellipsis;
90
+ white-space: nowrap;
91
+ cursor: default;
92
+ font-size: 13px;
93
+ }
94
+ .muted {
95
+ color: var(--kern-ink-450);
96
+ }
97
+ </style>
@@ -0,0 +1,143 @@
1
+ <script lang="ts">
2
+ import { Icon } from '@kernhq/ui'
3
+ import type { PropertyType } from '../../../contract/index.js'
4
+ import { t } from '../../i18n.js'
5
+
6
+ /**
7
+ * url, email and phone.
8
+ *
9
+ * The trailing action is **hidden** when the cell is empty rather than disabled: a control that
10
+ * cannot do anything and does not say why is worse than one that is not there, and "open this
11
+ * link" has no meaning without a link.
12
+ */
13
+ interface Props {
14
+ value: unknown
15
+ name: string
16
+ type: PropertyType
17
+ editable: boolean
18
+ reason?: string
19
+ onchange: (value: unknown) => void
20
+ }
21
+ const { value, name, type, editable, reason, onchange }: Props = $props()
22
+
23
+ const text = $derived(value == null ? '' : String(value))
24
+
25
+ const href = $derived.by(() => {
26
+ const raw = text.trim()
27
+ if (!raw) return null
28
+ if (type === 'email') return `mailto:${raw}`
29
+ if (type === 'phone') return `tel:${raw.replace(/\s+/g, '')}`
30
+ return /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`
31
+ })
32
+
33
+ const icon = $derived(type === 'email' ? 'mail' : type === 'phone' ? 'smartphone' : 'external-link')
34
+ const actionLabel = $derived(
35
+ type === 'email' ? t('db_send_email') : type === 'phone' ? t('db_call') : t('db_open_link'),
36
+ )
37
+ const placeholder = $derived(type === 'url' ? 'https://' : t('db_cell_empty'))
38
+
39
+ const commit = (next: string) => {
40
+ if (next === text) return
41
+ onchange(next === '' ? null : next)
42
+ }
43
+ </script>
44
+
45
+ <span class="link-cell">
46
+ {#if editable}
47
+ <input
48
+ class="cell-input"
49
+ type="text"
50
+ inputmode={type === 'email' ? 'email' : type === 'phone' ? 'tel' : 'url'}
51
+ value={text}
52
+ aria-label={name}
53
+ {placeholder}
54
+ onblur={(e) => commit(e.currentTarget.value)}
55
+ onkeydown={(e) => {
56
+ if (e.key === 'Enter') e.currentTarget.blur()
57
+ if (e.key === 'Escape') {
58
+ e.currentTarget.value = text
59
+ e.currentTarget.blur()
60
+ }
61
+ }}
62
+ />
63
+ {:else}
64
+ <span class="cell-static" title={reason}>
65
+ {#if text}{text}{:else}<span class="muted">{t('db_cell_empty')}</span>{/if}
66
+ </span>
67
+ {/if}
68
+ {#if href}
69
+ <a
70
+ class="go"
71
+ {href}
72
+ target={type === 'url' ? '_blank' : undefined}
73
+ rel={type === 'url' ? 'noreferrer noopener' : undefined}
74
+ aria-label={actionLabel}
75
+ title={actionLabel}
76
+ >
77
+ <Icon name={icon} size={13} strokeWidth={1.7} />
78
+ </a>
79
+ {/if}
80
+ </span>
81
+
82
+ <style>
83
+ .link-cell {
84
+ display: inline-flex;
85
+ align-items: center;
86
+ gap: 2px;
87
+ min-width: 0;
88
+ width: 100%;
89
+ }
90
+ .cell-input {
91
+ flex: 1;
92
+ min-width: 0;
93
+ min-height: 26px;
94
+ padding: 2px 6px;
95
+ margin-inline-start: -6px;
96
+ border: 1px solid transparent;
97
+ border-radius: var(--kern-r-sm);
98
+ background: none;
99
+ color: inherit;
100
+ font: inherit;
101
+ font-size: 13px;
102
+ }
103
+ .cell-input:hover {
104
+ background: var(--kern-surface-active);
105
+ }
106
+ .cell-input:focus {
107
+ border-color: var(--kern-border);
108
+ background: var(--kern-surface-raised);
109
+ outline: none;
110
+ }
111
+ .cell-input::placeholder {
112
+ color: var(--kern-ink-350);
113
+ }
114
+ .cell-static {
115
+ flex: 1;
116
+ min-width: 0;
117
+ overflow: hidden;
118
+ text-overflow: ellipsis;
119
+ white-space: nowrap;
120
+ cursor: default;
121
+ font-size: 13px;
122
+ }
123
+ /*
124
+ * 26px square with the icon centred: under 24 it fails the target-size rule the ux sweep checks,
125
+ * and it sits next to an input, so the spacing exception does not apply.
126
+ */
127
+ .go {
128
+ flex: none;
129
+ display: inline-grid;
130
+ place-items: center;
131
+ width: 26px;
132
+ height: 26px;
133
+ border-radius: var(--kern-r-sm);
134
+ color: var(--kern-ink-450);
135
+ }
136
+ .go:hover {
137
+ background: var(--kern-surface-active);
138
+ color: var(--kern-ink-900);
139
+ }
140
+ .muted {
141
+ color: var(--kern-ink-450);
142
+ }
143
+ </style>
@@ -0,0 +1,131 @@
1
+ <script lang="ts">
2
+ import { messageLocale } from '@kernhq/ui'
3
+ import type { PropertyConfig } from '../../../contract/index.js'
4
+ import { t } from '../../i18n.js'
5
+
6
+ /**
7
+ * A number, drawn formatted and edited raw.
8
+ *
9
+ * Formatting while somebody is typing fights them — a thousands separator inserted mid-number moves
10
+ * the caret — so the field carries the plain value whenever it has focus and the formatted one the
11
+ * rest of the time. `Intl.NumberFormat` for the interface locale, because a percentage rendered as
12
+ * "12%" beside Persian digits is the one untranslated thing on the screen.
13
+ */
14
+ interface Props {
15
+ value: unknown
16
+ name: string
17
+ config: PropertyConfig
18
+ editable: boolean
19
+ reason?: string
20
+ onchange: (value: unknown) => void
21
+ }
22
+ const { value, name, config, editable, reason, onchange }: Props = $props()
23
+
24
+ let focused = $state(false)
25
+
26
+ const raw = $derived(typeof value === 'number' ? value : value == null || value === '' ? null : Number(value))
27
+ const plain = $derived(raw === null || Number.isNaN(raw) ? '' : String(raw))
28
+
29
+ const formatted = $derived.by(() => {
30
+ if (raw === null || Number.isNaN(raw)) return ''
31
+ const options: Intl.NumberFormatOptions = {}
32
+ if (config.precision !== undefined) {
33
+ options.minimumFractionDigits = config.precision
34
+ options.maximumFractionDigits = config.precision
35
+ }
36
+ if (config.format === 'percent') options.style = 'percent'
37
+ if (config.format === 'currency') {
38
+ options.style = 'currency'
39
+ options.currency = 'USD'
40
+ }
41
+ try {
42
+ // `percent` multiplies by 100, so a column storing 0.2 reads "20%" — which is what a percentage
43
+ // column means. A column storing 20 and formatted as a percentage is the author's own choice.
44
+ return new Intl.NumberFormat(messageLocale(), options).format(raw)
45
+ } catch {
46
+ return String(raw)
47
+ }
48
+ })
49
+
50
+ const commit = (next: string) => {
51
+ const trimmed = next.trim()
52
+ if (trimmed === '') {
53
+ if (raw !== null) onchange(null)
54
+ return
55
+ }
56
+ const parsed = Number(trimmed)
57
+ if (Number.isNaN(parsed) || parsed === raw) return
58
+ onchange(parsed)
59
+ }
60
+ </script>
61
+
62
+ {#if editable}
63
+ <input
64
+ class="cell-input num"
65
+ inputmode="decimal"
66
+ value={focused ? plain : formatted}
67
+ aria-label={name}
68
+ placeholder={t('db_cell_empty')}
69
+ onfocus={(e) => {
70
+ focused = true
71
+ e.currentTarget.value = plain
72
+ }}
73
+ onblur={(e) => {
74
+ focused = false
75
+ commit(e.currentTarget.value)
76
+ }}
77
+ onkeydown={(e) => {
78
+ if (e.key === 'Enter') e.currentTarget.blur()
79
+ if (e.key === 'Escape') {
80
+ e.currentTarget.value = plain
81
+ e.currentTarget.blur()
82
+ }
83
+ }}
84
+ />
85
+ {:else}
86
+ <span class="cell-static num" title={reason}>
87
+ {#if formatted}{formatted}{:else}<span class="muted">{t('db_cell_empty')}</span>{/if}
88
+ </span>
89
+ {/if}
90
+
91
+ <style>
92
+ .cell-input {
93
+ width: 100%;
94
+ min-width: 0;
95
+ min-height: 26px;
96
+ padding: 2px 6px;
97
+ margin-inline-start: -6px;
98
+ border: 1px solid transparent;
99
+ border-radius: var(--kern-r-sm);
100
+ background: none;
101
+ color: inherit;
102
+ font: inherit;
103
+ font-size: 13px;
104
+ }
105
+ .cell-input:hover {
106
+ background: var(--kern-surface-active);
107
+ }
108
+ .cell-input:focus {
109
+ border-color: var(--kern-border);
110
+ background: var(--kern-surface-raised);
111
+ outline: none;
112
+ }
113
+ .cell-input::placeholder {
114
+ color: var(--kern-ink-350);
115
+ }
116
+ /* A number is read by comparing it with the one above; a tabular figure is what makes that work. */
117
+ .num {
118
+ font-variant-numeric: tabular-nums;
119
+ }
120
+ .cell-static {
121
+ min-width: 0;
122
+ overflow: hidden;
123
+ text-overflow: ellipsis;
124
+ white-space: nowrap;
125
+ cursor: default;
126
+ font-size: 13px;
127
+ }
128
+ .muted {
129
+ color: var(--kern-ink-450);
130
+ }
131
+ </style>