@rokkit/app 1.14.1 → 1.15.1
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/dist/components/ScreenInfo.svelte +85 -0
- package/dist/components/ScreenInfo.svelte.d.ts +10 -0
- package/dist/components/ScreenShell.svelte +324 -0
- package/dist/components/ScreenShell.svelte.d.ts +4 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/screen-shell.d.ts +165 -0
- package/dist/types/screen-shell.js +1 -0
- package/dist/utils/screen-shell.d.ts +62 -0
- package/dist/utils/screen-shell.js +166 -0
- package/package.json +6 -5
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The info band: up to three columns on a wash of one role — a framing column (eyebrow,
|
|
4
|
+
* large mark, display title, lead), a how-to column (steps, each mark in a paper circle) and
|
|
5
|
+
* a stat column after a hairline (a value, its total and a meter). Each column is data, or a
|
|
6
|
+
* snippet drawn in the column's frame. Text stays ink; only the wash and the eyebrows take
|
|
7
|
+
* the role.
|
|
8
|
+
*/
|
|
9
|
+
import type { Snippet } from 'svelte'
|
|
10
|
+
import type { GlyphMode, InfoColumn, InfoRole, InfoSlot } from '../types/screen-shell.js'
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
columns: InfoSlot[]
|
|
14
|
+
role?: InfoRole
|
|
15
|
+
glyphs?: GlyphMode
|
|
16
|
+
class?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { columns, role = 'accent', glyphs = 'text', class: className = '' }: Props = $props()
|
|
20
|
+
|
|
21
|
+
const isSnippet = (slot: InfoSlot): slot is Snippet => typeof slot === 'function'
|
|
22
|
+
const percent = (progress: number) => `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
{#snippet mark(kanji: string | undefined, icon: string | undefined, part: string)}
|
|
26
|
+
{#if glyphs === 'icon' && icon}
|
|
27
|
+
<span {...{ [`data-screen-info-${part}`]: '' }} class={icon} aria-hidden="true"></span>
|
|
28
|
+
{:else if kanji}
|
|
29
|
+
<span {...{ [`data-screen-info-${part}`]: '' }} aria-hidden="true">{kanji}</span>
|
|
30
|
+
{/if}
|
|
31
|
+
{/snippet}
|
|
32
|
+
|
|
33
|
+
{#snippet column(info: InfoColumn)}
|
|
34
|
+
<span data-screen-info-label>{info.label}</span>
|
|
35
|
+
{#if info.kanji || info.icon || info.title}
|
|
36
|
+
<div data-screen-info-heading>
|
|
37
|
+
{@render mark(info.kanji, info.icon, 'mark')}
|
|
38
|
+
{#if info.title}<p data-screen-info-title>{info.title}</p>{/if}
|
|
39
|
+
</div>
|
|
40
|
+
{/if}
|
|
41
|
+
{#if info.value !== undefined}
|
|
42
|
+
<div data-screen-info-figure>
|
|
43
|
+
<span data-screen-info-value>{info.value}</span>
|
|
44
|
+
{#if info.total !== undefined}<span data-screen-info-total>/ {info.total}</span>{/if}
|
|
45
|
+
</div>
|
|
46
|
+
{/if}
|
|
47
|
+
{#if info.progress !== undefined}
|
|
48
|
+
<div
|
|
49
|
+
data-screen-info-meter
|
|
50
|
+
role="meter"
|
|
51
|
+
aria-label={info.label}
|
|
52
|
+
aria-valuemin={0}
|
|
53
|
+
aria-valuemax={1}
|
|
54
|
+
aria-valuenow={info.progress}
|
|
55
|
+
>
|
|
56
|
+
<span data-screen-info-meter-fill style:inline-size={percent(info.progress)}></span>
|
|
57
|
+
</div>
|
|
58
|
+
{/if}
|
|
59
|
+
{#if info.steps?.length}
|
|
60
|
+
<ol data-screen-info-steps>
|
|
61
|
+
{#each info.steps as step, index (index)}
|
|
62
|
+
<li data-screen-info-step>
|
|
63
|
+
<span data-screen-info-step-seal>{@render mark(step.kanji, step.icon, 'step-mark')}</span>
|
|
64
|
+
<div data-screen-info-step-body>
|
|
65
|
+
<span data-screen-info-step-title>{step.title}</span>
|
|
66
|
+
{#if step.text}<span data-screen-info-step-text>{step.text}</span>{/if}
|
|
67
|
+
</div>
|
|
68
|
+
</li>
|
|
69
|
+
{/each}
|
|
70
|
+
</ol>
|
|
71
|
+
{/if}
|
|
72
|
+
{#if info.text}<p data-screen-info-text>{info.text}</p>{/if}
|
|
73
|
+
{/snippet}
|
|
74
|
+
|
|
75
|
+
<div data-screen-info data-role={role} class={className || undefined}>
|
|
76
|
+
{#each columns.slice(0, 3) as slot, index (index)}
|
|
77
|
+
<div data-screen-info-column>
|
|
78
|
+
{#if isSnippet(slot)}
|
|
79
|
+
{@render slot()}
|
|
80
|
+
{:else}
|
|
81
|
+
{@render column(slot)}
|
|
82
|
+
{/if}
|
|
83
|
+
</div>
|
|
84
|
+
{/each}
|
|
85
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GlyphMode, InfoRole, InfoSlot } from '../types/screen-shell.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
columns: InfoSlot[];
|
|
4
|
+
role?: InfoRole;
|
|
5
|
+
glyphs?: GlyphMode;
|
|
6
|
+
class?: string;
|
|
7
|
+
}
|
|
8
|
+
declare const ScreenInfo: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type ScreenInfo = ReturnType<typeof ScreenInfo>;
|
|
10
|
+
export default ScreenInfo;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One screen, laid out from data: a title bar (mark, title, context, then the screen's own
|
|
4
|
+
* buttons and the band toggles), a description, the info / stats / filters bands (search is
|
|
5
|
+
* the filters band's first control), a body and a sidebar that each scroll on their own, and
|
|
6
|
+
* a footer. The shell fills
|
|
7
|
+
* its container; nothing but the body and the sidebar scrolls.
|
|
8
|
+
*
|
|
9
|
+
* Layout only, and it persists nothing: `panels` and `filter` are bindable (and emitted
|
|
10
|
+
* through `onpanels` / `onfilter`); the host stores them by `viewId`.
|
|
11
|
+
*/
|
|
12
|
+
import { untrack, type Snippet } from 'svelte'
|
|
13
|
+
import { StatCard, Toolbar, type ToolbarItem } from '@rokkit/ui'
|
|
14
|
+
import { FormRenderer } from '@rokkit/forms'
|
|
15
|
+
import { messages } from '@rokkit/states'
|
|
16
|
+
import { DEFAULT_STATE_ICONS } from '@rokkit/core'
|
|
17
|
+
import ScreenInfo from './ScreenInfo.svelte'
|
|
18
|
+
import type {
|
|
19
|
+
FilterApi,
|
|
20
|
+
FilterSchema,
|
|
21
|
+
FilterState,
|
|
22
|
+
FooterParts,
|
|
23
|
+
PanelId,
|
|
24
|
+
PanelLabels,
|
|
25
|
+
ScreenShellProps,
|
|
26
|
+
ShellStat,
|
|
27
|
+
StatMemory
|
|
28
|
+
} from '../types/screen-shell.js'
|
|
29
|
+
import {
|
|
30
|
+
PANEL_IDS,
|
|
31
|
+
applyStat,
|
|
32
|
+
changedKeys,
|
|
33
|
+
clearStat,
|
|
34
|
+
isOpen,
|
|
35
|
+
presentPanels,
|
|
36
|
+
resolveToolbar,
|
|
37
|
+
setFilterValue,
|
|
38
|
+
statSpan,
|
|
39
|
+
togglePanel
|
|
40
|
+
} from '../utils/screen-shell.js'
|
|
41
|
+
|
|
42
|
+
const DEFAULT_ICONS: PanelLabels = {
|
|
43
|
+
info: DEFAULT_STATE_ICONS.state.info,
|
|
44
|
+
stats: DEFAULT_STATE_ICONS.view.chart,
|
|
45
|
+
filters: 'i-glyph:search-filter',
|
|
46
|
+
side: 'i-glyph:sidebar'
|
|
47
|
+
}
|
|
48
|
+
const SEARCH_DELAY = 250
|
|
49
|
+
|
|
50
|
+
let {
|
|
51
|
+
viewId,
|
|
52
|
+
mark,
|
|
53
|
+
title,
|
|
54
|
+
context,
|
|
55
|
+
description,
|
|
56
|
+
search = {},
|
|
57
|
+
toolbar,
|
|
58
|
+
info,
|
|
59
|
+
infoRole = 'accent',
|
|
60
|
+
stats,
|
|
61
|
+
filters,
|
|
62
|
+
sidebar,
|
|
63
|
+
sidebarSide = 'right',
|
|
64
|
+
footer,
|
|
65
|
+
children,
|
|
66
|
+
panels = $bindable({}),
|
|
67
|
+
onpanels,
|
|
68
|
+
filter = $bindable({ values: {}, stat: null }),
|
|
69
|
+
onfilter,
|
|
70
|
+
glyphs = 'text',
|
|
71
|
+
icons,
|
|
72
|
+
labels,
|
|
73
|
+
class: className = ''
|
|
74
|
+
}: ScreenShellProps = $props()
|
|
75
|
+
|
|
76
|
+
const text = $derived({ ...messages.screen, ...labels })
|
|
77
|
+
const values = $derived(filter?.values ?? {})
|
|
78
|
+
const statList = $derived(stats ?? [])
|
|
79
|
+
|
|
80
|
+
// ─── Panels ────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const has = $derived<Record<PanelId, boolean>>({
|
|
83
|
+
info: Boolean(info?.length),
|
|
84
|
+
stats: stats !== undefined,
|
|
85
|
+
// Search lives in the filters band, so a screen with search has the band even when it
|
|
86
|
+
// passes no filters of its own.
|
|
87
|
+
filters: Boolean(filters) || search !== false,
|
|
88
|
+
side: Boolean(sidebar)
|
|
89
|
+
})
|
|
90
|
+
const present = $derived(presentPanels(has))
|
|
91
|
+
const shown = (id: PanelId) => has[id] && isOpen(panels, id)
|
|
92
|
+
|
|
93
|
+
function flip(id: PanelId) {
|
|
94
|
+
const next = togglePanel(panels, id, present)
|
|
95
|
+
panels = next
|
|
96
|
+
onpanels?.(next)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Toolbar ───────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
const items = $derived(
|
|
102
|
+
resolveToolbar(toolbar, {
|
|
103
|
+
present,
|
|
104
|
+
panels,
|
|
105
|
+
labels: text,
|
|
106
|
+
icons: { ...DEFAULT_ICONS, ...icons },
|
|
107
|
+
busy: text.busy
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
function runItem(value: unknown, item: ToolbarItem) {
|
|
112
|
+
if (item.type === 'toggle' && PANEL_IDS.includes(value as PanelId))
|
|
113
|
+
return flip(value as PanelId)
|
|
114
|
+
;(item.onclick as (() => void) | undefined)?.()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Filter ────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
let memory: StatMemory | null = null
|
|
120
|
+
|
|
121
|
+
function commit(step: { filter: FilterState; memory: StatMemory | null }) {
|
|
122
|
+
memory = step.memory
|
|
123
|
+
filter = step.filter
|
|
124
|
+
onfilter?.(step.filter)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const current = (): FilterState => ({ values, stat: filter?.stat ?? null })
|
|
128
|
+
const setValue = (key: string, value: unknown) =>
|
|
129
|
+
commit(setFilterValue(current(), key, value, { stats: statList, memory }))
|
|
130
|
+
const clearValues = () => commit({ filter: { values: {}, stat: null }, memory: null })
|
|
131
|
+
const clearActive = () => commit(clearStat(current(), statList, memory))
|
|
132
|
+
const pickStat = (stat: ShellStat) => commit(applyStat(current(), stat, memory, statList))
|
|
133
|
+
|
|
134
|
+
const api: FilterApi = {
|
|
135
|
+
get values() {
|
|
136
|
+
return values
|
|
137
|
+
},
|
|
138
|
+
set: setValue,
|
|
139
|
+
clear: clearValues
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A FormRenderer update: every key that changed goes through the same merge rule. */
|
|
143
|
+
function updateFromForm(next: Record<string, unknown>) {
|
|
144
|
+
for (const key of changedKeys(values, next)) setValue(key, next[key])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const formData = $derived({ ...values })
|
|
148
|
+
const activeStat = $derived(statList.find((s) => s.id === filter?.stat && s.filter))
|
|
149
|
+
const filterSchema = $derived(
|
|
150
|
+
filters && typeof filters !== 'function' ? (filters as FilterSchema) : null
|
|
151
|
+
)
|
|
152
|
+
const filterSnippet = $derived(
|
|
153
|
+
typeof filters === 'function' ? (filters as Snippet<[FilterApi]>) : null
|
|
154
|
+
)
|
|
155
|
+
const filterActions = $derived(
|
|
156
|
+
resolveToolbar(
|
|
157
|
+
{ add: filterSchema?.actions ?? [] },
|
|
158
|
+
{
|
|
159
|
+
present: [],
|
|
160
|
+
panels,
|
|
161
|
+
labels: text,
|
|
162
|
+
icons: { ...DEFAULT_ICONS, ...icons },
|
|
163
|
+
busy: text.busy
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// ─── Search ────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
const searchOn = $derived(search !== false)
|
|
171
|
+
const placeholder = $derived((search && search.placeholder) || text.search)
|
|
172
|
+
const delay = $derived(search ? (search.delay ?? SEARCH_DELAY) : SEARCH_DELAY)
|
|
173
|
+
let query = $state(untrack(() => String(filter?.values?.q ?? '')))
|
|
174
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
175
|
+
|
|
176
|
+
// A q set from outside (a restored filter, a reset) shows in the field — but not while the
|
|
177
|
+
// user's own text is still waiting out the debounce.
|
|
178
|
+
$effect(() => {
|
|
179
|
+
const q = String(values.q ?? '')
|
|
180
|
+
untrack(() => {
|
|
181
|
+
if (timer === undefined && q !== query) query = q
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
$effect(() => () => clearTimeout(timer))
|
|
186
|
+
|
|
187
|
+
function onsearch() {
|
|
188
|
+
clearTimeout(timer)
|
|
189
|
+
timer = setTimeout(() => {
|
|
190
|
+
timer = undefined
|
|
191
|
+
setValue('q', query)
|
|
192
|
+
}, delay)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Stats & footer ────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
const reserve = $derived(statList.some((s) => Boolean(s.description)))
|
|
198
|
+
const footerParts = $derived(
|
|
199
|
+
footer && typeof footer !== 'function' ? (footer as FooterParts) : null
|
|
200
|
+
)
|
|
201
|
+
const footerSnippet = $derived(typeof footer === 'function' ? (footer as Snippet) : null)
|
|
202
|
+
const FOOTER_PARTS = ['left', 'mid', 'right'] as const
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
{#snippet glyph(kanji: string, icon: string | undefined)}
|
|
206
|
+
{#if glyphs === 'icon' && icon}
|
|
207
|
+
<span data-screen-mark class={icon} aria-hidden="true"></span>
|
|
208
|
+
{:else}
|
|
209
|
+
<span data-screen-mark aria-hidden="true">{kanji}</span>
|
|
210
|
+
{/if}
|
|
211
|
+
{/snippet}
|
|
212
|
+
|
|
213
|
+
{#snippet side()}
|
|
214
|
+
{#if sidebar && shown('side')}
|
|
215
|
+
<aside data-screen-sidebar data-side={sidebarSide} aria-label={text.side}>
|
|
216
|
+
{@render sidebar()}
|
|
217
|
+
</aside>
|
|
218
|
+
{/if}
|
|
219
|
+
{/snippet}
|
|
220
|
+
|
|
221
|
+
<div data-screen-shell data-view={viewId} class={className || undefined}>
|
|
222
|
+
<header data-screen-header>
|
|
223
|
+
<div data-screen-titlebar>
|
|
224
|
+
{@render glyph(mark.kanji, mark.icon)}
|
|
225
|
+
<h1 data-screen-title>{title}</h1>
|
|
226
|
+
{#if context}<span data-screen-context>{context}</span>{/if}
|
|
227
|
+
{#if items.length}
|
|
228
|
+
<div data-screen-tools>
|
|
229
|
+
<Toolbar {items} label={title} width="fit" showDividers={false} onclick={runItem} />
|
|
230
|
+
</div>
|
|
231
|
+
{/if}
|
|
232
|
+
</div>
|
|
233
|
+
{#if description}<p data-screen-description>{description}</p>{/if}
|
|
234
|
+
|
|
235
|
+
{#if info?.length && shown('info')}
|
|
236
|
+
<ScreenInfo columns={info} role={infoRole} {glyphs} />
|
|
237
|
+
{/if}
|
|
238
|
+
|
|
239
|
+
{#if stats !== undefined && shown('stats')}
|
|
240
|
+
<div data-screen-stats aria-label={text.stats} role="group">
|
|
241
|
+
{#if stats === null}
|
|
242
|
+
<p data-screen-stats-reading>{text.reading}</p>
|
|
243
|
+
{:else}
|
|
244
|
+
{#each stats as stat (stat.id)}
|
|
245
|
+
<StatCard
|
|
246
|
+
label={stat.title}
|
|
247
|
+
value={stat.value}
|
|
248
|
+
tone={stat.tone}
|
|
249
|
+
description={stat.description}
|
|
250
|
+
reserveDescription={reserve}
|
|
251
|
+
span={statSpan(stat)}
|
|
252
|
+
href={stat.filter ? undefined : stat.href}
|
|
253
|
+
icon={glyphs === 'icon' ? stat.icon : undefined}
|
|
254
|
+
mark={stat.kanji}
|
|
255
|
+
onclick={stat.filter ? () => pickStat(stat) : undefined}
|
|
256
|
+
pressed={filter?.stat === stat.id}
|
|
257
|
+
data-stat={stat.id}
|
|
258
|
+
/>
|
|
259
|
+
{/each}
|
|
260
|
+
{/if}
|
|
261
|
+
</div>
|
|
262
|
+
{/if}
|
|
263
|
+
|
|
264
|
+
{#if shown('filters')}
|
|
265
|
+
<div data-screen-filters role="group" aria-label={text.filters}>
|
|
266
|
+
{#if searchOn}
|
|
267
|
+
<label data-screen-search>
|
|
268
|
+
<span
|
|
269
|
+
data-screen-search-icon
|
|
270
|
+
class={DEFAULT_STATE_ICONS.action.search}
|
|
271
|
+
aria-hidden="true"
|
|
272
|
+
></span>
|
|
273
|
+
<input
|
|
274
|
+
type="search"
|
|
275
|
+
bind:value={query}
|
|
276
|
+
oninput={onsearch}
|
|
277
|
+
{placeholder}
|
|
278
|
+
aria-label={placeholder}
|
|
279
|
+
/>
|
|
280
|
+
</label>
|
|
281
|
+
{/if}
|
|
282
|
+
{#if filterSnippet}
|
|
283
|
+
{@render filterSnippet(api)}
|
|
284
|
+
{:else if filterSchema}
|
|
285
|
+
<FormRenderer
|
|
286
|
+
data={formData}
|
|
287
|
+
schema={filterSchema.schema}
|
|
288
|
+
layout={filterSchema.layout}
|
|
289
|
+
onupdate={updateFromForm}
|
|
290
|
+
/>
|
|
291
|
+
{#if filterActions.length}
|
|
292
|
+
<div data-screen-filter-actions>
|
|
293
|
+
<Toolbar items={filterActions} label={text.filters} width="fit" onclick={runItem} />
|
|
294
|
+
</div>
|
|
295
|
+
{/if}
|
|
296
|
+
{/if}
|
|
297
|
+
{#if activeStat}
|
|
298
|
+
<span data-screen-filter-chip>
|
|
299
|
+
{text.fromStat}: <strong>{activeStat.title}</strong>
|
|
300
|
+
<button type="button" onclick={clearActive}>{text.clear}</button>
|
|
301
|
+
</span>
|
|
302
|
+
{/if}
|
|
303
|
+
</div>
|
|
304
|
+
{/if}
|
|
305
|
+
</header>
|
|
306
|
+
|
|
307
|
+
<div data-screen-main>
|
|
308
|
+
{#if sidebarSide === 'left'}{@render side()}{/if}
|
|
309
|
+
<div data-screen-body role="region" aria-label={title}>
|
|
310
|
+
{@render children()}
|
|
311
|
+
</div>
|
|
312
|
+
{#if sidebarSide !== 'left'}{@render side()}{/if}
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
{#if footerSnippet}
|
|
316
|
+
<footer data-screen-footer>{@render footerSnippet()}</footer>
|
|
317
|
+
{:else if footerParts}
|
|
318
|
+
<footer data-screen-footer>
|
|
319
|
+
{#each FOOTER_PARTS as part (part)}
|
|
320
|
+
{#if footerParts[part]}<span data-part={part}>{footerParts[part]}</span>{/if}
|
|
321
|
+
{/each}
|
|
322
|
+
</footer>
|
|
323
|
+
{/if}
|
|
324
|
+
</div>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { default as ThemeSwitcherToggle } from './ThemeSwitcherToggle.svelte';
|
|
2
2
|
export { default as TableOfContents } from './TableOfContents.svelte';
|
|
3
3
|
export { default as SkinSwitcherToggle } from './SkinSwitcherToggle.svelte';
|
|
4
|
+
export { default as ScreenShell } from './ScreenShell.svelte';
|
|
5
|
+
export { default as ScreenInfo } from './ScreenInfo.svelte';
|
package/dist/components/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { default as ThemeSwitcherToggle } from './ThemeSwitcherToggle.svelte';
|
|
2
2
|
export { default as TableOfContents } from './TableOfContents.svelte';
|
|
3
3
|
export { default as SkinSwitcherToggle } from './SkinSwitcherToggle.svelte';
|
|
4
|
+
export { default as ScreenShell } from './ScreenShell.svelte';
|
|
5
|
+
export { default as ScreenInfo } from './ScreenInfo.svelte';
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { ThemeSwitcherToggle, SkinSwitcherToggle, TableOfContents } from './components/index.js';
|
|
1
|
+
export { ThemeSwitcherToggle, SkinSwitcherToggle, TableOfContents, ScreenShell, ScreenInfo } from './components/index.js';
|
|
2
2
|
export * from './types/index.js';
|
|
3
3
|
export { ColorModeManager, resolveMode, type ColorMode, type ResolvedMode } from './utils/color-mode.svelte.js';
|
|
4
|
+
export { PANEL_IDS, isOpen, presentPanels, togglePanel, resolveToolbar, statSpan, applyStat, clearStat, setFilterValue, changedKeys } from './utils/screen-shell.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Components
|
|
2
|
-
export { ThemeSwitcherToggle, SkinSwitcherToggle, TableOfContents } from './components/index.js';
|
|
2
|
+
export { ThemeSwitcherToggle, SkinSwitcherToggle, TableOfContents, ScreenShell, ScreenInfo } from './components/index.js';
|
|
3
3
|
// Types
|
|
4
4
|
export * from './types/index.js';
|
|
5
5
|
// Utilities
|
|
6
6
|
export { ColorModeManager, resolveMode } from './utils/color-mode.svelte.js';
|
|
7
|
+
export { PANEL_IDS, isOpen, presentPanels, togglePanel, resolveToolbar, statSpan, applyStat, clearStat, setFilterValue, changedKeys } from './utils/screen-shell.js';
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScreenShell Types
|
|
3
|
+
*
|
|
4
|
+
* The data a screen hands its shell: a title bar (mark, title, context, search, toolbar), an
|
|
5
|
+
* optional description, info / stats / filters bands, a body and a sidebar that each scroll on
|
|
6
|
+
* their own, and a footer. The shell is layout only; it persists nothing — `panels` and
|
|
7
|
+
* `filter` are bindable and the host stores them.
|
|
8
|
+
*/
|
|
9
|
+
import type { Snippet } from 'svelte';
|
|
10
|
+
import type { ToolbarItem } from '@rokkit/ui';
|
|
11
|
+
/** The bands a screen can open and close from its toolbar, in toolbar order. */
|
|
12
|
+
export type PanelId = 'info' | 'stats' | 'filters' | 'side';
|
|
13
|
+
/** Which bands are open. A missing key is open, so a first visit opens everything. */
|
|
14
|
+
export type PanelState = Partial<Record<PanelId, boolean>>;
|
|
15
|
+
/** Per-panel strings (toggle labels) or icon classes. */
|
|
16
|
+
export type PanelLabels = Record<PanelId, string>;
|
|
17
|
+
/** A screen's mark: the kanji, or the icon when the shell is in icon mode and one is given. */
|
|
18
|
+
export interface ShellMark {
|
|
19
|
+
kanji: string;
|
|
20
|
+
icon?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Which glyph a mark draws: the text (kanji) or, when it has one, the icon. */
|
|
23
|
+
export type GlyphMode = 'text' | 'icon';
|
|
24
|
+
/**
|
|
25
|
+
* A screen's own button: an icon, its label as the tooltip and the accessible name. A
|
|
26
|
+
* `disabled` string is the reason, shown with the label. A rokkit Toolbar `button` item.
|
|
27
|
+
*/
|
|
28
|
+
export interface ShellAction {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
icon: string;
|
|
32
|
+
onclick?: () => void;
|
|
33
|
+
href?: string;
|
|
34
|
+
busy?: boolean;
|
|
35
|
+
disabled?: string | boolean;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The toolbar: the default (one toggle per band the view has) extended with `add` (left of the
|
|
40
|
+
* toggles) and `remove` (drops a default by id); or a full list the screen owns.
|
|
41
|
+
*/
|
|
42
|
+
export type ToolbarSpec = {
|
|
43
|
+
add?: Array<ShellAction | ToolbarItem>;
|
|
44
|
+
remove?: string[];
|
|
45
|
+
} | ToolbarItem[];
|
|
46
|
+
/** One step in an info column: a mark in a paper circle, a title and a line. */
|
|
47
|
+
export interface InfoStep {
|
|
48
|
+
kanji: string;
|
|
49
|
+
icon?: string;
|
|
50
|
+
title: string;
|
|
51
|
+
text?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* One info column. `label` is the eyebrow and `text` the lead — the contract's minimum. A
|
|
55
|
+
* column may also carry a large mark and a display title (the framing column), steps (the
|
|
56
|
+
* how-to column) or a value with an optional total and progress (the stat column).
|
|
57
|
+
*/
|
|
58
|
+
export interface InfoColumn {
|
|
59
|
+
label: string;
|
|
60
|
+
text?: string;
|
|
61
|
+
kanji?: string;
|
|
62
|
+
icon?: string;
|
|
63
|
+
title?: string;
|
|
64
|
+
steps?: InfoStep[];
|
|
65
|
+
value?: string | number;
|
|
66
|
+
total?: string | number;
|
|
67
|
+
/** 0–1: how far along, drawn as a meter. */
|
|
68
|
+
progress?: number;
|
|
69
|
+
}
|
|
70
|
+
/** A column is data, or a snippet that draws it inside the band's column frame. */
|
|
71
|
+
export type InfoSlot = InfoColumn | Snippet;
|
|
72
|
+
/** The role whose wash tints the info band. */
|
|
73
|
+
export type InfoRole = 'accent' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'danger';
|
|
74
|
+
export type ShellStatTone = 'ink' | 'danger' | 'warning' | 'success' | 'muted';
|
|
75
|
+
/** One stat card. With `filter` it is a toggle that sets the filter; with `href` a link. */
|
|
76
|
+
export interface ShellStat {
|
|
77
|
+
id: string;
|
|
78
|
+
/** Shown capitalised by the theme, not typed in capitals. */
|
|
79
|
+
title: string;
|
|
80
|
+
kanji?: string;
|
|
81
|
+
icon?: string;
|
|
82
|
+
value: string | number;
|
|
83
|
+
tone?: ShellStatTone;
|
|
84
|
+
description?: string;
|
|
85
|
+
/** Columns of 12 the card takes; computed from its content when absent. */
|
|
86
|
+
span?: number;
|
|
87
|
+
filter?: {
|
|
88
|
+
key: string;
|
|
89
|
+
value: unknown;
|
|
90
|
+
};
|
|
91
|
+
href?: string;
|
|
92
|
+
}
|
|
93
|
+
/** The filter values, the one source of truth, and the active stat's id. */
|
|
94
|
+
export interface FilterState {
|
|
95
|
+
values: Record<string, unknown>;
|
|
96
|
+
stat?: string | null;
|
|
97
|
+
}
|
|
98
|
+
/** What a filter band snippet is handed. `clear()` empties every value. */
|
|
99
|
+
export interface FilterApi {
|
|
100
|
+
values: Record<string, unknown>;
|
|
101
|
+
set(key: string, value: unknown): void;
|
|
102
|
+
clear(): void;
|
|
103
|
+
}
|
|
104
|
+
/** A filter band drawn by `@rokkit/forms` FormRenderer; `actions` are its toolbar. */
|
|
105
|
+
export interface FilterSchema {
|
|
106
|
+
schema: Record<string, unknown>;
|
|
107
|
+
layout: Record<string, unknown>;
|
|
108
|
+
actions?: ShellAction[];
|
|
109
|
+
}
|
|
110
|
+
/** What the shell remembers about an applied stat, so clearing it restores the key. */
|
|
111
|
+
export interface StatMemory {
|
|
112
|
+
/** The stat it was taken for; it applies only while that stat is the active one. */
|
|
113
|
+
stat: string;
|
|
114
|
+
key: string;
|
|
115
|
+
had: boolean;
|
|
116
|
+
value: unknown;
|
|
117
|
+
}
|
|
118
|
+
export interface FooterParts {
|
|
119
|
+
left?: string;
|
|
120
|
+
mid?: string;
|
|
121
|
+
right?: string;
|
|
122
|
+
}
|
|
123
|
+
export interface ScreenShellLabels extends PanelLabels {
|
|
124
|
+
search: string;
|
|
125
|
+
fromStat: string;
|
|
126
|
+
clear: string;
|
|
127
|
+
reading: string;
|
|
128
|
+
busy: string;
|
|
129
|
+
}
|
|
130
|
+
export interface ScreenShellProps {
|
|
131
|
+
/** `<surface>.<area>[.<view>]` — the host keys its stored panels by it. */
|
|
132
|
+
viewId?: string;
|
|
133
|
+
mark: ShellMark;
|
|
134
|
+
title: string;
|
|
135
|
+
context?: string;
|
|
136
|
+
description?: string;
|
|
137
|
+
/** On by default; `false` only for a view with nothing to search. Writes `values.q`. */
|
|
138
|
+
search?: {
|
|
139
|
+
placeholder?: string;
|
|
140
|
+
delay?: number;
|
|
141
|
+
} | false;
|
|
142
|
+
toolbar?: ToolbarSpec;
|
|
143
|
+
info?: InfoSlot[];
|
|
144
|
+
/** The info band's wash: the role it tints with (default `accent`). */
|
|
145
|
+
infoRole?: InfoRole;
|
|
146
|
+
/** `null` = still reading (the band says so). */
|
|
147
|
+
stats?: ShellStat[] | null;
|
|
148
|
+
filters?: Snippet<[FilterApi]> | FilterSchema;
|
|
149
|
+
sidebar?: Snippet;
|
|
150
|
+
/** Which side the sidebar sits on (default `right`). */
|
|
151
|
+
sidebarSide?: 'left' | 'right';
|
|
152
|
+
footer?: Snippet | FooterParts;
|
|
153
|
+
children: Snippet;
|
|
154
|
+
panels?: PanelState;
|
|
155
|
+
onpanels?: (next: PanelState) => void;
|
|
156
|
+
filter?: FilterState;
|
|
157
|
+
onfilter?: (next: FilterState) => void;
|
|
158
|
+
/** Draw marks as kanji (`text`, default) or as their icons (`icon`). */
|
|
159
|
+
glyphs?: GlyphMode;
|
|
160
|
+
/** Toggle icon classes per band. */
|
|
161
|
+
icons?: Partial<PanelLabels>;
|
|
162
|
+
/** String overrides; defaults come from `messages.screen`. */
|
|
163
|
+
labels?: Partial<ScreenShellLabels>;
|
|
164
|
+
class?: string;
|
|
165
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScreenShell — the pure rules behind the shell: which bands are open, what the toolbar holds,
|
|
3
|
+
* how wide a stat card is, and how a stat's filter merges into the filter values.
|
|
4
|
+
*/
|
|
5
|
+
import type { ToolbarItem } from '@rokkit/ui';
|
|
6
|
+
import type { PanelId, PanelState, PanelLabels, ToolbarSpec, ShellStat, FilterState, StatMemory } from '../types/screen-shell.js';
|
|
7
|
+
/** The bands a screen can toggle, in toolbar order. */
|
|
8
|
+
export declare const PANEL_IDS: readonly PanelId[];
|
|
9
|
+
/** Whether a band is open: a missing key is open (first visit: everything open). */
|
|
10
|
+
export declare function isOpen(panels: PanelState | undefined, id: PanelId): boolean;
|
|
11
|
+
/** The bands the view has, in toolbar order. */
|
|
12
|
+
export declare function presentPanels(has: Partial<Record<PanelId, boolean>>): PanelId[];
|
|
13
|
+
/**
|
|
14
|
+
* The whole panel state after one band flips, for the bands the view has. A band the view
|
|
15
|
+
* does not have is never stored, open or closed.
|
|
16
|
+
*/
|
|
17
|
+
export declare function togglePanel(panels: PanelState | undefined, id: PanelId, present: PanelId[]): PanelState;
|
|
18
|
+
interface ToolbarContext {
|
|
19
|
+
present: PanelId[];
|
|
20
|
+
panels: PanelState | undefined;
|
|
21
|
+
labels: PanelLabels;
|
|
22
|
+
icons: PanelLabels;
|
|
23
|
+
/** Appended to a busy action's label. */
|
|
24
|
+
busy?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The toolbar's items: the screen's own buttons, a separator, then one toggle per band the
|
|
28
|
+
* view has (`info · stats · filters · side`), or the full list a screen passes.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveToolbar(spec: ToolbarSpec | undefined, ctx: ToolbarContext): ToolbarItem[];
|
|
31
|
+
/**
|
|
32
|
+
* The columns (of 12) a stat card takes: a short title and no description → 1; a longer
|
|
33
|
+
* title, a long value or a short description → 2; a description over 32 characters → 3. A
|
|
34
|
+
* stat that names its span keeps it (1–12).
|
|
35
|
+
*/
|
|
36
|
+
export declare function statSpan(stat: Pick<ShellStat, 'title' | 'value' | 'description' | 'span'>): number;
|
|
37
|
+
interface FilterStep {
|
|
38
|
+
filter: FilterState;
|
|
39
|
+
memory: StatMemory | null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Clear the active stat: its key goes back to the value it had before the stat was applied
|
|
43
|
+
* (from `memory`), or is removed when the shell holds no memory for it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function clearStat(filter: FilterState, stats: ShellStat[], memory: StatMemory | null): FilterStep;
|
|
46
|
+
/**
|
|
47
|
+
* Click a stat: apply its filter (set its key, mark it active), or — when it is already the
|
|
48
|
+
* active one — clear it. A stat with no filter changes nothing. `stats` (the band) lets a
|
|
49
|
+
* previously active stat the shell holds no memory for be cleared by its key.
|
|
50
|
+
*/
|
|
51
|
+
export declare function applyStat(filter: FilterState, stat: ShellStat, memory: StatMemory | null, stats?: ShellStat[]): FilterStep;
|
|
52
|
+
/**
|
|
53
|
+
* Set one filter value by hand. Changing the active stat's key clears the stat (the new value
|
|
54
|
+
* stays); changing any other key leaves it active.
|
|
55
|
+
*/
|
|
56
|
+
export declare function setFilterValue(filter: FilterState, key: string, value: unknown, { stats, memory }: {
|
|
57
|
+
stats: ShellStat[];
|
|
58
|
+
memory: StatMemory | null;
|
|
59
|
+
}): FilterStep;
|
|
60
|
+
/** The keys whose value differs between two value maps (added and removed keys included). */
|
|
61
|
+
export declare function changedKeys(before: Record<string, unknown>, after: Record<string, unknown>): string[];
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/** The bands a screen can toggle, in toolbar order. */
|
|
2
|
+
export const PANEL_IDS = ['info', 'stats', 'filters', 'side'];
|
|
3
|
+
const isPanelId = (value) => PANEL_IDS.includes(value);
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// Panels
|
|
6
|
+
// =============================================================================
|
|
7
|
+
/** Whether a band is open: a missing key is open (first visit: everything open). */
|
|
8
|
+
export function isOpen(panels, id) {
|
|
9
|
+
return panels?.[id] !== false;
|
|
10
|
+
}
|
|
11
|
+
/** The bands the view has, in toolbar order. */
|
|
12
|
+
export function presentPanels(has) {
|
|
13
|
+
return PANEL_IDS.filter((id) => has[id]);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The whole panel state after one band flips, for the bands the view has. A band the view
|
|
17
|
+
* does not have is never stored, open or closed.
|
|
18
|
+
*/
|
|
19
|
+
export function togglePanel(panels, id, present) {
|
|
20
|
+
return Object.fromEntries(present.map((p) => [p, p === id ? !isOpen(panels, p) : isOpen(panels, p)]));
|
|
21
|
+
}
|
|
22
|
+
const SEPARATOR = { type: 'separator' };
|
|
23
|
+
/** Why an action cannot run now: its disabled reason, else the busy note while it runs. */
|
|
24
|
+
function unavailableNote(action, busy) {
|
|
25
|
+
if (typeof action.disabled === 'string' && action.disabled)
|
|
26
|
+
return action.disabled;
|
|
27
|
+
return action.busy ? busy : '';
|
|
28
|
+
}
|
|
29
|
+
/** A screen action (or any toolbar item) as a Toolbar item: value = id, a button by default. */
|
|
30
|
+
function toToolbarItem(item, busy = '') {
|
|
31
|
+
const action = item;
|
|
32
|
+
const note = unavailableNote(action, busy);
|
|
33
|
+
return {
|
|
34
|
+
...item,
|
|
35
|
+
value: item.value ?? item.id,
|
|
36
|
+
type: item.type ?? 'button',
|
|
37
|
+
label: note ? `${action.label} — ${note}` : action.label,
|
|
38
|
+
disabled: Boolean(action.disabled || action.busy)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** One toggle per band, pressed while the band is open. */
|
|
42
|
+
function panelToggle(id, ctx) {
|
|
43
|
+
return {
|
|
44
|
+
id,
|
|
45
|
+
value: id,
|
|
46
|
+
type: 'toggle',
|
|
47
|
+
icon: ctx.icons[id],
|
|
48
|
+
label: ctx.labels[id],
|
|
49
|
+
active: isOpen(ctx.panels, id)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** A full list: kept as is, except a band toggle follows the band (dropped when it is absent). */
|
|
53
|
+
function resolveFullList(spec, ctx) {
|
|
54
|
+
return spec
|
|
55
|
+
.filter((item) => item.type !== 'toggle' || !isPanelId(item.value) || ctx.present.includes(item.value))
|
|
56
|
+
.map((item) => item.type === 'toggle' && isPanelId(item.value)
|
|
57
|
+
? { ...item, active: isOpen(ctx.panels, item.value) }
|
|
58
|
+
: item.type === 'separator' || item.type === 'spacer'
|
|
59
|
+
? item
|
|
60
|
+
: toToolbarItem(item, ctx.busy));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The toolbar's items: the screen's own buttons, a separator, then one toggle per band the
|
|
64
|
+
* view has (`info · stats · filters · side`), or the full list a screen passes.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveToolbar(spec, ctx) {
|
|
67
|
+
if (Array.isArray(spec))
|
|
68
|
+
return resolveFullList(spec, ctx);
|
|
69
|
+
const { add = [], remove = [] } = spec ?? {};
|
|
70
|
+
const removed = new Set(remove);
|
|
71
|
+
const toggles = ctx.present.filter((id) => !removed.has(id)).map((id) => panelToggle(id, ctx));
|
|
72
|
+
const added = add
|
|
73
|
+
.filter((item) => !removed.has(item.id))
|
|
74
|
+
.map((item) => toToolbarItem(item, ctx.busy));
|
|
75
|
+
const separator = added.length && toggles.length ? [SEPARATOR] : [];
|
|
76
|
+
return [...added, ...separator, ...toggles];
|
|
77
|
+
}
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// Stats
|
|
80
|
+
// =============================================================================
|
|
81
|
+
/** Card width thresholds (characters) — the contract's stand-in for measuring the text. */
|
|
82
|
+
const SHORT_TITLE = 11;
|
|
83
|
+
const SHORT_DESCRIPTION = 32;
|
|
84
|
+
const SHORT_VALUE = 6;
|
|
85
|
+
const MAX_SPAN = 12;
|
|
86
|
+
/**
|
|
87
|
+
* The columns (of 12) a stat card takes: a short title and no description → 1; a longer
|
|
88
|
+
* title, a long value or a short description → 2; a description over 32 characters → 3. A
|
|
89
|
+
* stat that names its span keeps it (1–12).
|
|
90
|
+
*/
|
|
91
|
+
export function statSpan(stat) {
|
|
92
|
+
if (stat.span)
|
|
93
|
+
return Math.max(1, Math.min(MAX_SPAN, Math.round(stat.span)));
|
|
94
|
+
const description = stat.description ?? '';
|
|
95
|
+
if (description.length > SHORT_DESCRIPTION)
|
|
96
|
+
return 3;
|
|
97
|
+
if (description || stat.title.length > SHORT_TITLE || String(stat.value).length > SHORT_VALUE)
|
|
98
|
+
return 2;
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
/** A copy of `values` with `key` set, or removed when it had none. */
|
|
102
|
+
function restore(values, memory) {
|
|
103
|
+
const next = { ...values };
|
|
104
|
+
if (memory.had)
|
|
105
|
+
next[memory.key] = memory.value;
|
|
106
|
+
else
|
|
107
|
+
delete next[memory.key];
|
|
108
|
+
return next;
|
|
109
|
+
}
|
|
110
|
+
/** The memory, when it is the active stat's; a memory taken for another stat is stale. */
|
|
111
|
+
function liveMemory(filter, memory) {
|
|
112
|
+
return memory && memory.stat === filter.stat ? memory : null;
|
|
113
|
+
}
|
|
114
|
+
/** The filter key the active stat set: from the shell's memory, else from the stat itself. */
|
|
115
|
+
function activeKey(filter, stats, memory) {
|
|
116
|
+
const live = liveMemory(filter, memory);
|
|
117
|
+
if (live)
|
|
118
|
+
return live.key;
|
|
119
|
+
return stats.find((s) => s.id === filter.stat)?.filter?.key;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Clear the active stat: its key goes back to the value it had before the stat was applied
|
|
123
|
+
* (from `memory`), or is removed when the shell holds no memory for it.
|
|
124
|
+
*/
|
|
125
|
+
export function clearStat(filter, stats, memory) {
|
|
126
|
+
if (!filter.stat)
|
|
127
|
+
return { filter, memory: null };
|
|
128
|
+
const key = activeKey(filter, stats, memory);
|
|
129
|
+
const used = liveMemory(filter, memory) ??
|
|
130
|
+
(key ? { stat: filter.stat, key, had: false, value: undefined } : null);
|
|
131
|
+
const values = used ? restore(filter.values, used) : { ...filter.values };
|
|
132
|
+
return { filter: { values, stat: null }, memory: null };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Click a stat: apply its filter (set its key, mark it active), or — when it is already the
|
|
136
|
+
* active one — clear it. A stat with no filter changes nothing. `stats` (the band) lets a
|
|
137
|
+
* previously active stat the shell holds no memory for be cleared by its key.
|
|
138
|
+
*/
|
|
139
|
+
export function applyStat(filter, stat, memory, stats = [stat]) {
|
|
140
|
+
if (!stat.filter)
|
|
141
|
+
return { filter, memory };
|
|
142
|
+
if (filter.stat === stat.id)
|
|
143
|
+
return clearStat(filter, stats, memory);
|
|
144
|
+
const base = filter.stat ? clearStat(filter, stats, memory).filter : filter;
|
|
145
|
+
const { key, value } = stat.filter;
|
|
146
|
+
return {
|
|
147
|
+
filter: { values: { ...base.values, [key]: value }, stat: stat.id },
|
|
148
|
+
memory: { stat: stat.id, key, had: key in base.values, value: base.values[key] }
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Set one filter value by hand. Changing the active stat's key clears the stat (the new value
|
|
153
|
+
* stays); changing any other key leaves it active.
|
|
154
|
+
*/
|
|
155
|
+
export function setFilterValue(filter, key, value, { stats, memory }) {
|
|
156
|
+
const values = { ...filter.values, [key]: value };
|
|
157
|
+
const stat = filter.stat ?? null;
|
|
158
|
+
if (stat && activeKey(filter, stats, memory) === key)
|
|
159
|
+
return { filter: { values, stat: null }, memory: null };
|
|
160
|
+
return { filter: { values, stat }, memory };
|
|
161
|
+
}
|
|
162
|
+
/** The keys whose value differs between two value maps (added and removed keys included). */
|
|
163
|
+
export function changedKeys(before, after) {
|
|
164
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
165
|
+
return [...keys].filter((key) => !Object.is(before[key], after[key]));
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.15.1",
|
|
4
4
|
"description": "App-level controls for Rokkit applications - theme management and UI chrome",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,10 +38,11 @@
|
|
|
38
38
|
"build": "svelte-package -i src -o dist"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@rokkit/actions": "1.
|
|
42
|
-
"@rokkit/core": "1.
|
|
43
|
-
"@rokkit/
|
|
44
|
-
"@rokkit/
|
|
41
|
+
"@rokkit/actions": "1.15.1",
|
|
42
|
+
"@rokkit/core": "1.15.1",
|
|
43
|
+
"@rokkit/forms": "1.15.1",
|
|
44
|
+
"@rokkit/states": "1.15.1",
|
|
45
|
+
"@rokkit/ui": "1.15.1"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
48
|
"svelte": "^5.0.0"
|