@kernhq/module-quire 0.8.0 → 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.
- package/README.md +93 -55
- package/dist/{server → client}/formula.d.ts +6 -0
- package/dist/client/formula.d.ts.map +1 -0
- package/dist/{server → client}/formula.js +6 -0
- package/dist/client/formula.js.map +1 -0
- package/dist/contract/properties.d.ts +26 -0
- package/dist/contract/properties.d.ts.map +1 -1
- package/dist/contract/properties.js +24 -0
- package/dist/contract/properties.js.map +1 -1
- package/dist/contract/router.d.ts +334 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +43 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +341 -0
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +79 -10
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/services/databases.d.ts +73 -1
- package/dist/server/services/databases.d.ts.map +1 -1
- package/dist/server/services/databases.js +167 -10
- package/dist/server/services/databases.js.map +1 -1
- package/dist/server/services/pages.d.ts.map +1 -1
- package/dist/server/services/pages.js +11 -1
- package/dist/server/services/pages.js.map +1 -1
- package/dist/server/services/query.d.ts.map +1 -1
- package/dist/server/services/query.js +93 -37
- package/dist/server/services/query.js.map +1 -1
- package/migrations/0006_rank_collation.sql +14 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -2
- package/src/client/components/PageTreeRow.svelte +1 -1
- package/src/client/core-api.ts +38 -0
- package/src/client/database/BoardView.svelte +354 -0
- package/src/client/database/CalendarView.svelte +346 -0
- package/src/client/database/DatabaseView.svelte +785 -0
- package/src/client/database/FilterMenu.svelte +281 -0
- package/src/client/database/FilterValue.svelte +176 -0
- package/src/client/database/GalleryView.svelte +177 -0
- package/src/client/database/ListView.svelte +108 -0
- package/src/client/database/OptionChip.svelte +38 -0
- package/src/client/database/PropertyDialog.svelte +460 -0
- package/src/client/database/PropertyMenu.svelte +162 -0
- package/src/client/database/RowPanel.svelte +157 -0
- package/src/client/database/SortMenu.svelte +199 -0
- package/src/client/database/TableView.svelte +388 -0
- package/src/client/database/ViewDialog.svelte +229 -0
- package/src/client/database/cells/Cell.svelte +103 -0
- package/src/client/database/cells/CheckboxCell.svelte +33 -0
- package/src/client/database/cells/ComputedCell.svelte +100 -0
- package/src/client/database/cells/DateCell.svelte +97 -0
- package/src/client/database/cells/LinkCell.svelte +143 -0
- package/src/client/database/cells/NumberCell.svelte +131 -0
- package/src/client/database/cells/PersonCell.svelte +116 -0
- package/src/client/database/cells/RelationCell.svelte +222 -0
- package/src/client/database/cells/SelectCell.svelte +133 -0
- package/src/client/database/cells/TextCell.svelte +85 -0
- package/src/client/database/colours.ts +27 -0
- package/src/client/database/property-types.test.ts +64 -0
- package/src/client/database/property-types.ts +294 -0
- package/src/client/database/view-config.test.ts +148 -0
- package/src/client/database/view-config.ts +102 -0
- package/src/client/formula.test.ts +139 -0
- package/src/client/formula.ts +430 -0
- package/src/client/i18n.ts +1176 -0
- package/src/client/index.ts +31 -0
- package/src/client/mock.ts +511 -1
- package/src/client/pages/PageView.svelte +47 -17
- package/src/client/pages/SpacePage.svelte +8 -2
- package/src/client/query.ts +17 -4
- package/src/contract/properties.ts +28 -0
- package/src/contract/router.ts +46 -0
- package/dist/server/formula.d.ts.map +0 -1
- 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>
|