@rokkit/app 1.15.0 → 1.15.2

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.
@@ -7,16 +7,24 @@
7
7
  * the role.
8
8
  */
9
9
  import type { Snippet } from 'svelte'
10
+ import type { HTMLAttributes } from 'svelte/elements'
10
11
  import type { GlyphMode, InfoColumn, InfoRole, InfoSlot } from '../types/screen-shell.js'
11
12
 
12
- interface Props {
13
+ // `role` is the band's wash, not the ARIA role, so it stays out of the passthrough.
14
+ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'role'> {
13
15
  columns: InfoSlot[]
14
16
  role?: InfoRole
15
17
  glyphs?: GlyphMode
16
18
  class?: string
17
19
  }
18
20
 
19
- const { columns, role = 'accent', glyphs = 'text', class: className = '' }: Props = $props()
21
+ const {
22
+ columns,
23
+ role = 'accent',
24
+ glyphs = 'text',
25
+ class: className = '',
26
+ ...rest
27
+ }: Props = $props()
20
28
 
21
29
  const isSnippet = (slot: InfoSlot): slot is Snippet => typeof slot === 'function'
22
30
  const percent = (progress: number) => `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`
@@ -72,7 +80,7 @@
72
80
  {#if info.text}<p data-screen-info-text>{info.text}</p>{/if}
73
81
  {/snippet}
74
82
 
75
- <div data-screen-info data-role={role} class={className || undefined}>
83
+ <div {...rest} data-screen-info data-role={role} class={className || undefined}>
76
84
  {#each columns.slice(0, 3) as slot, index (index)}
77
85
  <div data-screen-info-column>
78
86
  {#if isSnippet(slot)}
@@ -1,5 +1,6 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
1
2
  import type { GlyphMode, InfoRole, InfoSlot } from '../types/screen-shell.js';
2
- interface Props {
3
+ interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'role'> {
3
4
  columns: InfoSlot[];
4
5
  role?: InfoRole;
5
6
  glyphs?: GlyphMode;
@@ -1,8 +1,9 @@
1
1
  <script lang="ts">
2
2
  /**
3
- * One screen, laid out from data: a title bar (mark, title, context, then search, the
4
- * screen's own buttons and the band toggles), a description, the info / stats / filters
5
- * bands, a body and a sidebar that each scroll on their own, and a footer. The shell fills
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
6
7
  * its container; nothing but the body and the sidebar scrolls.
7
8
  *
8
9
  * Layout only, and it persists nothing: `panels` and `filter` are bindable (and emitted
@@ -69,7 +70,10 @@
69
70
  glyphs = 'text',
70
71
  icons,
71
72
  labels,
72
- class: className = ''
73
+ class: className = '',
74
+ toggleAttrs,
75
+ bandAttrs,
76
+ ...rest
73
77
  }: ScreenShellProps = $props()
74
78
 
75
79
  const text = $derived({ ...messages.screen, ...labels })
@@ -81,7 +85,9 @@
81
85
  const has = $derived<Record<PanelId, boolean>>({
82
86
  info: Boolean(info?.length),
83
87
  stats: stats !== undefined,
84
- filters: Boolean(filters),
88
+ // Search lives in the filters band, so a screen with search has the band even when it
89
+ // passes no filters of its own.
90
+ filters: Boolean(filters) || search !== false,
85
91
  side: Boolean(sidebar)
86
92
  })
87
93
  const present = $derived(presentPanels(has))
@@ -101,7 +107,8 @@
101
107
  panels,
102
108
  labels: text,
103
109
  icons: { ...DEFAULT_ICONS, ...icons },
104
- busy: text.busy
110
+ busy: text.busy,
111
+ toggleAttrs
105
112
  })
106
113
  )
107
114
 
@@ -209,55 +216,38 @@
209
216
 
210
217
  {#snippet side()}
211
218
  {#if sidebar && shown('side')}
212
- <aside data-screen-sidebar data-side={sidebarSide} aria-label={text.side}>
219
+ <aside {...bandAttrs?.side} data-screen-sidebar data-side={sidebarSide} aria-label={text.side}>
213
220
  {@render sidebar()}
214
221
  </aside>
215
222
  {/if}
216
223
  {/snippet}
217
224
 
218
- <div data-screen-shell data-view={viewId} class={className || undefined}>
225
+ <div {...rest} data-screen-shell data-view={viewId} class={className || undefined}>
219
226
  <header data-screen-header>
220
227
  <div data-screen-titlebar>
221
228
  {@render glyph(mark.kanji, mark.icon)}
222
229
  <h1 data-screen-title>{title}</h1>
223
230
  {#if context}<span data-screen-context>{context}</span>{/if}
224
- {#if searchOn || items.length}
231
+ {#if items.length}
225
232
  <div data-screen-tools>
226
- {#if searchOn}
227
- <label data-screen-search>
228
- <span
229
- data-screen-search-icon
230
- class={DEFAULT_STATE_ICONS.action.search}
231
- aria-hidden="true"
232
- ></span>
233
- <input
234
- type="search"
235
- bind:value={query}
236
- oninput={onsearch}
237
- {placeholder}
238
- aria-label={placeholder}
239
- />
240
- </label>
241
- {/if}
242
- {#if items.length}
243
- <Toolbar {items} label={title} width="fit" showDividers={false} onclick={runItem} />
244
- {/if}
233
+ <Toolbar {items} label={title} width="fit" showDividers={false} onclick={runItem} />
245
234
  </div>
246
235
  {/if}
247
236
  </div>
248
237
  {#if description}<p data-screen-description>{description}</p>{/if}
249
238
 
250
239
  {#if info?.length && shown('info')}
251
- <ScreenInfo columns={info} role={infoRole} {glyphs} />
240
+ <ScreenInfo columns={info} role={infoRole} {glyphs} {...bandAttrs?.info} />
252
241
  {/if}
253
242
 
254
243
  {#if stats !== undefined && shown('stats')}
255
- <div data-screen-stats aria-label={text.stats} role="group">
244
+ <div {...bandAttrs?.stats} data-screen-stats aria-label={text.stats} role="group">
256
245
  {#if stats === null}
257
246
  <p data-screen-stats-reading>{text.reading}</p>
258
247
  {:else}
259
248
  {#each stats as stat (stat.id)}
260
249
  <StatCard
250
+ {...stat.attrs}
261
251
  label={stat.title}
262
252
  value={stat.value}
263
253
  tone={stat.tone}
@@ -276,8 +266,24 @@
276
266
  </div>
277
267
  {/if}
278
268
 
279
- {#if filters && shown('filters')}
280
- <div data-screen-filters role="group" aria-label={text.filters}>
269
+ {#if shown('filters')}
270
+ <div {...bandAttrs?.filters} data-screen-filters role="group" aria-label={text.filters}>
271
+ {#if searchOn}
272
+ <label data-screen-search>
273
+ <span
274
+ data-screen-search-icon
275
+ class={DEFAULT_STATE_ICONS.action.search}
276
+ aria-hidden="true"
277
+ ></span>
278
+ <input
279
+ type="search"
280
+ bind:value={query}
281
+ oninput={onsearch}
282
+ {placeholder}
283
+ aria-label={placeholder}
284
+ />
285
+ </label>
286
+ {/if}
281
287
  {#if filterSnippet}
282
288
  {@render filterSnippet(api)}
283
289
  {:else if filterSchema}
@@ -312,9 +318,9 @@
312
318
  </div>
313
319
 
314
320
  {#if footerSnippet}
315
- <footer data-screen-footer>{@render footerSnippet()}</footer>
321
+ <footer {...bandAttrs?.footer} data-screen-footer>{@render footerSnippet()}</footer>
316
322
  {:else if footerParts}
317
- <footer data-screen-footer>
323
+ <footer {...bandAttrs?.footer} data-screen-footer>
318
324
  {#each FOOTER_PARTS as part (part)}
319
325
  {#if footerParts[part]}<span data-part={part}>{footerParts[part]}</span>{/if}
320
326
  {/each}
@@ -7,7 +7,14 @@
7
7
  * `filter` are bindable and the host stores them.
8
8
  */
9
9
  import type { Snippet } from 'svelte';
10
+ import type { HTMLAttributes } from 'svelte/elements';
10
11
  import type { ToolbarItem } from '@rokkit/ui';
12
+ /**
13
+ * Attributes a host hangs on a shell part for its own hooks (`data-testid`) or extra
14
+ * semantics (`aria-*`): components take them as rest props, data (actions, stats, toolbar
15
+ * items) carries them in an `attrs` field, rendered on the element.
16
+ */
17
+ export type ShellAttrs = Record<string, string>;
11
18
  /** The bands a screen can open and close from its toolbar, in toolbar order. */
12
19
  export type PanelId = 'info' | 'stats' | 'filters' | 'side';
13
20
  /** Which bands are open. A missing key is open, so a first visit opens everything. */
@@ -33,6 +40,8 @@ export interface ShellAction {
33
40
  href?: string;
34
41
  busy?: boolean;
35
42
  disabled?: string | boolean;
43
+ /** Attributes rendered on the action's button or link (e.g. `data-testid`). */
44
+ attrs?: ShellAttrs;
36
45
  [key: string]: unknown;
37
46
  }
38
47
  /**
@@ -89,6 +98,8 @@ export interface ShellStat {
89
98
  value: unknown;
90
99
  };
91
100
  href?: string;
101
+ /** Attributes rendered on the stat's card (e.g. `data-testid`). */
102
+ attrs?: ShellAttrs;
92
103
  }
93
104
  /** The filter values, the one source of truth, and the active stat's id. */
94
105
  export interface FilterState {
@@ -127,7 +138,7 @@ export interface ScreenShellLabels extends PanelLabels {
127
138
  reading: string;
128
139
  busy: string;
129
140
  }
130
- export interface ScreenShellProps {
141
+ export interface ScreenShellProps extends HTMLAttributes<HTMLDivElement> {
131
142
  /** `<surface>.<area>[.<view>]` — the host keys its stored panels by it. */
132
143
  viewId?: string;
133
144
  mark: ShellMark;
@@ -162,4 +173,8 @@ export interface ScreenShellProps {
162
173
  /** String overrides; defaults come from `messages.screen`. */
163
174
  labels?: Partial<ScreenShellLabels>;
164
175
  class?: string;
176
+ /** Attributes per band toggle, rendered on its toolbar button (e.g. `data-testid`). */
177
+ toggleAttrs?: Partial<Record<PanelId, ShellAttrs>>;
178
+ /** Attributes per band, rendered on the band's element (e.g. `data-testid`). */
179
+ bandAttrs?: Partial<Record<PanelId | 'footer', ShellAttrs>>;
165
180
  }
@@ -3,7 +3,7 @@
3
3
  * how wide a stat card is, and how a stat's filter merges into the filter values.
4
4
  */
5
5
  import type { ToolbarItem } from '@rokkit/ui';
6
- import type { PanelId, PanelState, PanelLabels, ToolbarSpec, ShellStat, FilterState, StatMemory } from '../types/screen-shell.js';
6
+ import type { PanelId, PanelState, PanelLabels, ToolbarSpec, ShellAttrs, ShellStat, FilterState, StatMemory } from '../types/screen-shell.js';
7
7
  /** The bands a screen can toggle, in toolbar order. */
8
8
  export declare const PANEL_IDS: readonly PanelId[];
9
9
  /** Whether a band is open: a missing key is open (first visit: everything open). */
@@ -22,6 +22,8 @@ interface ToolbarContext {
22
22
  icons: PanelLabels;
23
23
  /** Appended to a busy action's label. */
24
24
  busy?: string;
25
+ /** Attributes per band toggle, rendered on its toolbar button. */
26
+ toggleAttrs?: Partial<Record<PanelId, ShellAttrs>>;
25
27
  }
26
28
  /**
27
29
  * The toolbar's items: the screen's own buttons, a separator, then one toggle per band the
@@ -38,23 +38,32 @@ function toToolbarItem(item, busy = '') {
38
38
  disabled: Boolean(action.disabled || action.busy)
39
39
  };
40
40
  }
41
+ /** A panel toggle with the band's attrs under its own (its own keys win). */
42
+ function withToggleAttrs(item, band) {
43
+ if (!band && item.attrs === undefined)
44
+ return item;
45
+ return { ...item, attrs: { ...band, ...(item.attrs ?? {}) } };
46
+ }
41
47
  /** One toggle per band, pressed while the band is open. */
42
48
  function panelToggle(id, ctx) {
43
- return {
49
+ return withToggleAttrs({
44
50
  id,
45
51
  value: id,
46
52
  type: 'toggle',
47
53
  icon: ctx.icons[id],
48
54
  label: ctx.labels[id],
49
55
  active: isOpen(ctx.panels, id)
50
- };
56
+ }, ctx.toggleAttrs?.[id]);
51
57
  }
52
58
  /** A full list: kept as is, except a band toggle follows the band (dropped when it is absent). */
53
59
  function resolveFullList(spec, ctx) {
54
60
  return spec
55
61
  .filter((item) => item.type !== 'toggle' || !isPanelId(item.value) || ctx.present.includes(item.value))
56
62
  .map((item) => item.type === 'toggle' && isPanelId(item.value)
57
- ? { ...item, active: isOpen(ctx.panels, item.value) }
63
+ ? {
64
+ ...withToggleAttrs(item, ctx.toggleAttrs?.[item.value]),
65
+ active: isOpen(ctx.panels, item.value)
66
+ }
58
67
  : item.type === 'separator' || item.type === 'spacer'
59
68
  ? item
60
69
  : toToolbarItem(item, ctx.busy));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/app",
3
- "version": "1.15.0",
3
+ "version": "1.15.2",
4
4
  "description": "App-level controls for Rokkit applications - theme management and UI chrome",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,11 +38,11 @@
38
38
  "build": "svelte-package -i src -o dist"
39
39
  },
40
40
  "dependencies": {
41
- "@rokkit/actions": "1.15.0",
42
- "@rokkit/core": "1.15.0",
43
- "@rokkit/forms": "1.15.0",
44
- "@rokkit/states": "1.15.0",
45
- "@rokkit/ui": "1.15.0"
41
+ "@rokkit/actions": "1.15.2",
42
+ "@rokkit/core": "1.15.2",
43
+ "@rokkit/forms": "1.15.2",
44
+ "@rokkit/states": "1.15.2",
45
+ "@rokkit/ui": "1.15.2"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "svelte": "^5.0.0"