@kernhq/module-quire 0.10.6 → 0.10.8
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/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
session,
|
|
11
11
|
} from '@kernhq/ui'
|
|
12
12
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
13
|
+
import { untrack } from 'svelte'
|
|
13
14
|
import { getQuireApi } from '../api-instance.js'
|
|
14
15
|
import { t } from '../i18n.js'
|
|
15
16
|
import { buildPageTree, type PageTreeNode } from '../index.js'
|
|
@@ -76,18 +77,31 @@ const matches = $derived(
|
|
|
76
77
|
query ? nodes.filter((n) => (n.title || t('untitled')).toLowerCase().includes(query)) : [],
|
|
77
78
|
)
|
|
78
79
|
|
|
79
|
-
/**
|
|
80
|
+
/**
|
|
81
|
+
* A page opened from a link may be nested; its ancestors have to be open for it to be visible.
|
|
82
|
+
*
|
|
83
|
+
* `expanded` is read through `untrack` because this effect also writes it. Tracked, the write makes
|
|
84
|
+
* the effect its own trigger: collapsing an ancestor of the open page removed it from the set, the
|
|
85
|
+
* effect re-ran and put it straight back, and the disclosure was inert — click it, press Enter on
|
|
86
|
+
* it, nothing moves. That is the "effect that reads a flag it also clears" trap already written
|
|
87
|
+
* down in shell's CLAUDE.md, and it was invisible until `navigation.params.page` started arriving:
|
|
88
|
+
* with `activePageId` permanently null the loop never ran at all.
|
|
89
|
+
*
|
|
90
|
+
* What genuinely selects this effect is which page is open and what the tree contains. Whether a
|
|
91
|
+
* disclosure is open is the *result*, and a result must not be an input.
|
|
92
|
+
*/
|
|
80
93
|
$effect(() => {
|
|
81
94
|
if (!activePageId || nodes.length === 0) return
|
|
82
95
|
const byId = new Map(nodes.map((n) => [n.id, n]))
|
|
83
|
-
const
|
|
96
|
+
const current = untrack(() => expanded)
|
|
97
|
+
const next = new Set(current)
|
|
84
98
|
let cursor = byId.get(activePageId)?.parentId ?? null
|
|
85
99
|
let guard = 0
|
|
86
100
|
while (cursor && guard++ < 100) {
|
|
87
101
|
next.add(cursor)
|
|
88
102
|
cursor = byId.get(cursor)?.parentId ?? null
|
|
89
103
|
}
|
|
90
|
-
if (next.size !==
|
|
104
|
+
if (next.size !== current.size) expanded = next
|
|
91
105
|
})
|
|
92
106
|
|
|
93
107
|
function toggle(id: string) {
|
|
@@ -7,9 +7,15 @@ import { t } from '../../i18n.js'
|
|
|
7
7
|
* A date, stored as an ISO string.
|
|
8
8
|
*
|
|
9
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.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* reachable by a screen reader without any of that being written here. But a native
|
|
11
|
+
* `<input type="date">` draws its *value* in the **browser's** locale, not the page's, and nothing
|
|
12
|
+
* can restyle that — so a table left permanently in edit mode showed `08/21/2026` in Latin digits
|
|
13
|
+
* on a Persian screen, in a column beside one already counting `۱ ۲ ۳`. The one untranslated thing
|
|
14
|
+
* on the page, which is the shape this project treats as a defect rather than as polish.
|
|
15
|
+
*
|
|
16
|
+
* So the cell reads through `formatDate` whether or not it is editable, and the input appears when
|
|
17
|
+
* somebody goes to change it. Focus moves to it on the way in and the formatted text comes back on
|
|
18
|
+
* the way out, so a keyboard never lands on something it cannot see.
|
|
13
19
|
*/
|
|
14
20
|
interface Props {
|
|
15
21
|
value: unknown
|
|
@@ -36,6 +42,22 @@ const fieldValue = $derived.by(() => {
|
|
|
36
42
|
|
|
37
43
|
const shown = $derived(iso ? (withTime ? formatDateTime(iso) : formatDate(iso)) : '')
|
|
38
44
|
|
|
45
|
+
/** Swapped for the native input only while somebody is actually changing the date. */
|
|
46
|
+
let editing = $state(false)
|
|
47
|
+
let field = $state<HTMLInputElement>()
|
|
48
|
+
|
|
49
|
+
/*
|
|
50
|
+
* Taking focus is not optional here. Replacing the button with the input would otherwise leave
|
|
51
|
+
* focus on a removed element, which the browser resolves by dropping it to `<body>` — a keyboard
|
|
52
|
+
* user tabs to a date, presses Enter and is returned to the top of the page.
|
|
53
|
+
*/
|
|
54
|
+
$effect(() => {
|
|
55
|
+
if (editing && field) {
|
|
56
|
+
field.focus()
|
|
57
|
+
field.showPicker?.()
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
39
61
|
const commit = (next: string) => {
|
|
40
62
|
if (next === '') {
|
|
41
63
|
if (iso) onchange(null)
|
|
@@ -47,14 +69,25 @@ const commit = (next: string) => {
|
|
|
47
69
|
}
|
|
48
70
|
</script>
|
|
49
71
|
|
|
50
|
-
{#if editable}
|
|
72
|
+
{#if editable && editing}
|
|
51
73
|
<input
|
|
74
|
+
bind:this={field}
|
|
52
75
|
class="cell-input"
|
|
53
76
|
type={withTime ? 'datetime-local' : 'date'}
|
|
54
77
|
value={fieldValue}
|
|
55
78
|
aria-label={name}
|
|
56
79
|
onchange={(e) => commit(e.currentTarget.value)}
|
|
80
|
+
onblur={() => {
|
|
81
|
+
editing = false
|
|
82
|
+
}}
|
|
83
|
+
onkeydown={(e) => {
|
|
84
|
+
if (e.key === 'Escape' || e.key === 'Enter') editing = false
|
|
85
|
+
}}
|
|
57
86
|
/>
|
|
87
|
+
{:else if editable}
|
|
88
|
+
<button type="button" class="cell-open" aria-label={name} onclick={() => (editing = true)}>
|
|
89
|
+
{#if shown}{shown}{:else}<span class="muted">{t('db_cell_empty')}</span>{/if}
|
|
90
|
+
</button>
|
|
58
91
|
{:else}
|
|
59
92
|
<span class="cell-static" title={reason}>
|
|
60
93
|
{#if shown}{shown}{:else}<span class="muted">{t('db_cell_empty')}</span>{/if}
|
|
@@ -83,6 +116,36 @@ const commit = (next: string) => {
|
|
|
83
116
|
background: var(--kern-surface-raised);
|
|
84
117
|
outline: none;
|
|
85
118
|
}
|
|
119
|
+
/* Mirrors `.cell-input` so swapping one for the other does not move the row by a pixel. */
|
|
120
|
+
.cell-open {
|
|
121
|
+
display: block;
|
|
122
|
+
width: 100%;
|
|
123
|
+
min-width: 0;
|
|
124
|
+
min-height: 26px;
|
|
125
|
+
padding: 2px 6px;
|
|
126
|
+
margin-inline-start: -6px;
|
|
127
|
+
border: 1px solid transparent;
|
|
128
|
+
border-radius: var(--kern-r-sm);
|
|
129
|
+
background: none;
|
|
130
|
+
color: inherit;
|
|
131
|
+
font: inherit;
|
|
132
|
+
font-size: 13px;
|
|
133
|
+
text-align: start;
|
|
134
|
+
overflow: hidden;
|
|
135
|
+
text-overflow: ellipsis;
|
|
136
|
+
white-space: nowrap;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
}
|
|
139
|
+
.cell-open:hover {
|
|
140
|
+
background: var(--kern-surface-active);
|
|
141
|
+
}
|
|
142
|
+
/*
|
|
143
|
+
* The ring itself comes from the global `:focus-visible` rule in tokens.css — a box-shadow, not an
|
|
144
|
+
* outline. Only the ground is set here, so the two do not fight.
|
|
145
|
+
*/
|
|
146
|
+
.cell-open:focus-visible {
|
|
147
|
+
background: var(--kern-surface-raised);
|
|
148
|
+
}
|
|
86
149
|
.cell-static {
|
|
87
150
|
min-width: 0;
|
|
88
151
|
overflow: hidden;
|