@flood-md/agent 0.2.10 → 0.2.12

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 (140) hide show
  1. package/DESIGN.md +8 -5
  2. package/README.md +1 -1
  3. package/SKILL.md +11 -0
  4. package/dist/catalog.json +1914 -278
  5. package/dist/materials/component-app-shell.md +49 -0
  6. package/dist/materials/component-app-top-bar.md +36 -0
  7. package/dist/materials/component-autocomplete.md +54 -0
  8. package/dist/materials/component-bulk-action-bar.md +52 -0
  9. package/dist/materials/component-button.md +1 -0
  10. package/dist/materials/component-card.md +2 -2
  11. package/dist/materials/component-collapsible.md +1 -0
  12. package/dist/materials/component-collection-toolbar.md +69 -0
  13. package/dist/materials/component-collection-view-switcher.md +49 -0
  14. package/dist/materials/component-combobox.md +10 -0
  15. package/dist/materials/component-command-palette.md +53 -0
  16. package/dist/materials/component-copy-button.md +1 -0
  17. package/dist/materials/component-data-table.md +34 -4
  18. package/dist/materials/component-date-picker.md +1 -0
  19. package/dist/materials/component-date-range-picker.md +1 -0
  20. package/dist/materials/component-date-time-picker.md +2 -0
  21. package/dist/materials/component-date-time-range-picker.md +4 -0
  22. package/dist/materials/component-drawer.md +1 -0
  23. package/dist/materials/component-editable.md +4 -0
  24. package/dist/materials/component-figure.md +1 -1
  25. package/dist/materials/component-filter-condition.md +53 -0
  26. package/dist/materials/component-filter-group.md +55 -0
  27. package/dist/materials/component-heading.md +1 -1
  28. package/dist/materials/component-icon-button.md +2 -0
  29. package/dist/materials/component-menubar.md +41 -0
  30. package/dist/materials/component-multi-select.md +10 -0
  31. package/dist/materials/component-navigation-group.md +51 -0
  32. package/dist/materials/component-navigation-item.md +59 -0
  33. package/dist/materials/component-navigation-tree.md +8 -0
  34. package/dist/materials/component-overflow-actions.md +47 -0
  35. package/dist/materials/component-page-header.md +40 -0
  36. package/dist/materials/component-popover.md +2 -1
  37. package/dist/materials/component-radio-card.md +2 -2
  38. package/dist/materials/component-section.md +1 -1
  39. package/dist/materials/component-segmented-control.md +6 -4
  40. package/dist/materials/component-sheet.md +49 -0
  41. package/dist/materials/component-sidebar.md +64 -0
  42. package/dist/materials/component-sparkline.md +41 -0
  43. package/dist/materials/component-table.md +2 -0
  44. package/dist/materials/component-tabs.md +2 -0
  45. package/dist/materials/component-text.md +2 -2
  46. package/dist/materials/component-toggle.md +1 -0
  47. package/dist/materials/component-toolbar.md +5 -0
  48. package/dist/materials/component-tooltip.md +1 -1
  49. package/dist/materials/guide-agent-knowledge.md +13 -6
  50. package/dist/materials/guide-agents.md +8 -1
  51. package/dist/materials/guide-foundations.md +45 -2
  52. package/dist/materials/guide-installation.md +6 -6
  53. package/dist/materials/guide-interface-content.md +1 -1
  54. package/dist/materials/guide-introduction.md +4 -2
  55. package/dist/materials/guide-layout.md +39 -10
  56. package/dist/materials/guide-motion.md +66 -0
  57. package/dist/materials/guide-skills.md +9 -0
  58. package/dist/materials/recipe-collection-views.md +670 -0
  59. package/dist/materials/recipe-command-palette.md +31 -52
  60. package/dist/materials/recipe-filter-bar.md +12 -3
  61. package/dist/materials/recipe-grouped-form.md +5 -0
  62. package/dist/materials/recipe-navigation-workspace.md +222 -0
  63. package/dist/materials/recipe-record-management.md +310 -0
  64. package/dist/materials/recipe-record-workspace.md +207 -0
  65. package/dist/materials/recipe-settings-workspace.md +232 -0
  66. package/dist/materials/token-sys.color.context.base.action-disabled.json +41 -0
  67. package/dist/materials/token-sys.color.context.base.action-hover.json +41 -0
  68. package/dist/materials/token-sys.color.context.base.action-pressed.json +41 -0
  69. package/dist/materials/token-sys.color.context.base.action-rest.json +41 -0
  70. package/dist/materials/token-sys.color.context.base.avatar-background.json +41 -0
  71. package/dist/materials/token-sys.color.context.base.border-decorative.json +41 -0
  72. package/dist/materials/token-sys.color.context.base.divider-decorative.json +41 -0
  73. package/dist/materials/token-sys.color.context.base.field-background.json +41 -0
  74. package/dist/materials/token-sys.color.context.base.field-border.json +41 -0
  75. package/dist/materials/token-sys.color.context.base.field-disabled.json +41 -0
  76. package/dist/materials/token-sys.color.context.base.field-readonly.json +41 -0
  77. package/dist/materials/token-sys.color.context.base.indicator-track.json +41 -0
  78. package/dist/materials/token-sys.color.context.base.loading-placeholder.json +41 -0
  79. package/dist/materials/token-sys.color.context.base.muted-background.json +41 -0
  80. package/dist/materials/token-sys.color.context.base.on-surface-action-disabled.json +41 -0
  81. package/dist/materials/token-sys.color.context.base.on-surface-action-hover.json +41 -0
  82. package/dist/materials/token-sys.color.context.base.on-surface-action-pressed.json +41 -0
  83. package/dist/materials/token-sys.color.context.base.on-surface-action-rest.json +41 -0
  84. package/dist/materials/token-sys.color.context.base.selection-background.json +41 -0
  85. package/dist/materials/token-sys.color.context.base.supporting-background.json +41 -0
  86. package/dist/materials/token-sys.color.context.filled.action-disabled.json +41 -0
  87. package/dist/materials/token-sys.color.context.filled.action-hover.json +41 -0
  88. package/dist/materials/token-sys.color.context.filled.action-pressed.json +41 -0
  89. package/dist/materials/token-sys.color.context.filled.action-rest.json +41 -0
  90. package/dist/materials/token-sys.color.context.filled.avatar-background.json +41 -0
  91. package/dist/materials/token-sys.color.context.filled.border-decorative.json +41 -0
  92. package/dist/materials/token-sys.color.context.filled.divider-decorative.json +41 -0
  93. package/dist/materials/token-sys.color.context.filled.field-background.json +41 -0
  94. package/dist/materials/token-sys.color.context.filled.field-border.json +41 -0
  95. package/dist/materials/token-sys.color.context.filled.field-disabled.json +41 -0
  96. package/dist/materials/token-sys.color.context.filled.field-readonly.json +41 -0
  97. package/dist/materials/token-sys.color.context.filled.indicator-track.json +41 -0
  98. package/dist/materials/token-sys.color.context.filled.loading-placeholder.json +41 -0
  99. package/dist/materials/token-sys.color.context.filled.muted-background.json +41 -0
  100. package/dist/materials/token-sys.color.context.filled.selection-background.json +41 -0
  101. package/dist/materials/token-sys.color.context.filled.supporting-background.json +41 -0
  102. package/dist/materials/token-sys.color.field.readonly.json +41 -0
  103. package/dist/materials/token-sys.color.surface.avatar.json +41 -0
  104. package/dist/materials/token-sys.color.surface.current.json +41 -0
  105. package/dist/materials/token-sys.color.surface.muted.json +41 -0
  106. package/dist/materials/token-sys.color.surface.supporting.json +41 -0
  107. package/dist/materials/token-sys.motion.distance-panel.json +36 -0
  108. package/dist/materials/token-sys.motion.distance-small.json +36 -0
  109. package/dist/materials/token-sys.motion.duration-enter.json +36 -0
  110. package/dist/materials/token-sys.motion.duration-exit.json +36 -0
  111. package/dist/materials/token-sys.motion.duration-layer.json +36 -0
  112. package/dist/materials/token-sys.motion.duration-loop.json +36 -0
  113. package/dist/materials/token-sys.motion.duration-press.json +36 -0
  114. package/dist/materials/token-sys.motion.easing-enter.json +38 -0
  115. package/dist/materials/token-sys.motion.easing-exit.json +38 -0
  116. package/knowledge.json +115 -2
  117. package/lib/inspect.mjs +11 -0
  118. package/manifest.json +11 -1
  119. package/package.json +9 -3
  120. package/references/component-families.md +150 -0
  121. package/references/composition.md +16 -4
  122. package/references/customization.md +14 -0
  123. package/references/flood-composition/SKILL.md +4 -0
  124. package/references/flood-data-display/SKILL.md +146 -0
  125. package/references/flood-feedback/SKILL.md +37 -0
  126. package/references/flood-forms/SKILL.md +31 -0
  127. package/references/flood-foundations/SKILL.md +36 -0
  128. package/references/flood-hierarchy/SKILL.md +27 -7
  129. package/references/flood-interface-content/SKILL.md +33 -5
  130. package/references/flood-motion/SKILL.md +47 -0
  131. package/references/flood-navigation/SKILL.md +195 -0
  132. package/references/flood-overlays/SKILL.md +41 -0
  133. package/references/flood-records/SKILL.md +65 -2
  134. package/references/flood-spacing/SKILL.md +45 -0
  135. package/references/flood-verification/SKILL.md +20 -0
  136. package/references/interface-content-examples.md +4 -0
  137. package/references/rendered-review.md +5 -0
  138. package/references/spacing.md +30 -0
  139. package/rules.json +98 -8
  140. package/workflows.json +28 -3
@@ -0,0 +1,670 @@
1
+ # Collection filters and saved views
2
+
3
+ [Open Projects and Files](/playground/collections).
4
+
5
+ One set of controls serves a DataTable and a FileList. It demonstrates search,
6
+ quick filters, staged conditions, applied tags, saved definitions and sorting from existing
7
+ Flood primitives. CollectionViewControls is project-owned recipe code, not an
8
+ export from @flood-md/react. The installed catalog contains these same typed
9
+ sources. Keep model and controls together; adapt datasets and shell to the host.
10
+
11
+ ## Query ownership
12
+
13
+ The wide layout has two control rows. View selection and its actions share the
14
+ first row. Search and filter/reset actions share the second row. At most two
15
+ secondary filter properties appear inline. With three or more properties, one
16
+ Filters panel contains the choices, including currently inactive properties.
17
+ Applied conditions remain visible with property names and remove actions.
18
+ Groups wrap as space becomes constrained. Test both empty filters and longer
19
+ selected values before adopting the host layout.
20
+
21
+ Filters anchors to the inline end of the control row. A reserved Clear filters
22
+ slot precedes it, so applying or clearing conditions does not shift the trigger.
23
+ The inactive reset is visually hidden, disabled and absent from accessibility
24
+ navigation. Applied chips stay in the search area and can wrap independently.
25
+
26
+ A query contains search, a flat all/any group, enum conditions and sort. Search
27
+ combines with the group using AND. Values within one condition mean any matching
28
+ value; is-not excludes all of them. Missing record values do not match is and do
29
+ match is-not. The host defines properties, values and record meaning.
30
+
31
+ For inline simple filters, a named Menu applies one value per property immediately.
32
+ The adjacent Tag shows the selected value and gets its property context from
33
+ the menu beside it. Choose All or remove the Tag to clear that property. The Tag returns
34
+ focus to its menu when removed. Projects and Files use the same controls with
35
+ their own properties. Search and sort retain their current values.
36
+
37
+ The collapsed panel stages its labelled choices until Apply filters. Cancel
38
+ discards the changes. Advanced conditions switches the same panel to the full
39
+ condition editor when exclusions, multiple values or Any logic are needed.
40
+
41
+ Use the Filters editor for exclusion, multiple values per property or Any across
42
+ properties. It has a separate draft. Apply validates and commits, while Cancel or
43
+ outside dismissal discards it. Advanced conditions are summarized by removable
44
+ tags with their All/Any logic visible when relevant. Clear filters removes search
45
+ and conditions, retaining sort. Unknown properties, values, operators, sort
46
+ columns and schema versions fail explicitly rather than silently broadening a view.
47
+
48
+ An applied query change outside the editor closes its obsolete draft. Applying
49
+ conditions never restores an old search/sort value from when the editor opened.
50
+
51
+ DataTable receives the full locally filtered collection. The example resets page
52
+ and selection on query/preset/sort changes. Saving or renaming a definition does
53
+ not change the query or selection. FileList has its own host sort. Remote requests,
54
+ permissions and server-wide selection remain host responsibilities.
55
+
56
+ Table selection serves real status and archive commands for the chosen projects.
57
+ Hosts without a selection operation should omit selectable rather than display
58
+ checkboxes with no resulting action.
59
+
60
+ ## Selected records
61
+
62
+ Selecting projects reveals one shared action bar floating at the viewport bottom. It shows the
63
+ selected count, changes status, confirms archiving and clears the
64
+ selection. DataTable uses `showSelection={false}` to keep its result range and
65
+ pagination while announcing the selected count without displaying it again.
66
+ Selection spans the filtered client collection across its pages.
67
+ Changing the query resets it. Archiving captures IDs and names before opening
68
+ the confirmation. Cancel keeps the selection and records intact.
69
+
70
+ The host owns the update and accepted records. This example changes its in-memory
71
+ records after the optional onUpdateStatus callback accepts the whole operation.
72
+ Reload returns to the declared fixtures. A rejection keeps accepted records and
73
+ selection intact. Retry uses the failed operation's captured IDs and status.
74
+ Dismiss or a deliberate query/selection change abandons its feedback.
75
+ Report results in a floating ToastRegion so the table stays in place.
76
+ Use the default bottom-end placement. Success expires automatically. A failed
77
+ update offers a persistent Retry action. While the selection rail is visible,
78
+ raise the toast above it without changing the table's scroll clearance.
79
+
80
+ Only one update can run at a time. Pending actions are disabled. If the user
81
+ changes the query or selection while an update is pending, its result still
82
+ belongs to the captured IDs. Success removes those IDs from current selection,
83
+ preserving newly selected records, and returns pagination to the first page.
84
+ The host callback must accept/reject atomically. A service allowing partial
85
+ success needs a per-record result and reconciliation contract.
86
+
87
+ Clear selection returns focus to the collection. A successful update moves focus
88
+ there if the disappearing action bar held it. Confirmation renders outside the
89
+ roving Toolbar so the dialog retains its own focus model.
90
+
91
+ ```tsx
92
+ import { useState } from 'react';
93
+ import { AlertDialog, BulkActionBar, VisuallyHidden } from '@flood-md/react';
94
+
95
+ export type ProjectStatus = 'active' | 'paused' | 'archived';
96
+ export interface SelectedProject { id: string; name: string }
97
+ export interface ProjectStatusUpdate { ids: string[]; status: ProjectStatus }
98
+
99
+ export function ProjectSelectionActions({ projects, pending, onUpdate, onClear }: {
100
+ projects: readonly SelectedProject[];
101
+ pending: boolean;
102
+ onUpdate: (update: ProjectStatusUpdate) => void;
103
+ onClear: () => void;
104
+ }) {
105
+ const [archive, setArchive] = useState<SelectedProject[] | null>(null);
106
+
107
+ return <>
108
+ <BulkActionBar label="Selected projects" selectionCount={projects.length} pending={pending}
109
+ items={[
110
+ { id: 'archive', label: 'Archive', priority: 10 },
111
+ { id: 'paused', label: 'Pause', priority: 5 },
112
+ { id: 'active', label: 'Set active' },
113
+ ]}
114
+ onAction={action => {
115
+ if (action === 'archive') setArchive(projects.map(project => ({ ...project })));
116
+ else onUpdate({ ids: projects.map(project => project.id), status: action as ProjectStatus });
117
+ }}
118
+ onClear={onClear} />
119
+ <VisuallyHidden role="status">{pending ? 'Updating…' : ''}</VisuallyHidden>
120
+ {archive && <AlertDialog
121
+ title={`Archive ${archive.length} ${archive.length === 1 ? 'project' : 'projects'}?`}
122
+ description={`${archive.map(project => project.name).join(', ')}\n${archive.length === 1 ? 'It' : 'They'} will move to Archived.`}
123
+ confirmLabel="Archive projects"
124
+ cancelLabel="Keep projects"
125
+ open
126
+ onOpenChange={open => { if (!open) setArchive(null); }}
127
+ onConfirm={() => {
128
+ onUpdate({ ids: archive.map(project => project.id), status: 'archived' });
129
+ }}
130
+ trigger={() => null}
131
+ />}
132
+ </>;
133
+ }
134
+ ```
135
+
136
+ The panel uses the existing floating-surface tokens and a viewport-safe inset.
137
+ It leaves normal document flow and stays centered when the document scrolls.
138
+ Check the table header position and scroll dimensions with zero, one and
139
+ several selected records. A collection that fits should keep fitting on selection.
140
+ BulkActionBar keeps a single pill-shaped row with at most two visible commands
141
+ by default. Lower-priority commands move into More as space shrinks; the host
142
+ keeps the commands on one row. Budget bottom clearance in the base layout
143
+ using the control height, the panel chrome and the viewport inset. At the end
144
+ of a scroll, the final rows and pagination should be reachable above the panel.
145
+ Keep the scroll area inside the bordered Surface and clip the frame at its
146
+ rounded corners so the table footer cannot cover the outline.
147
+
148
+ ```css
149
+ .collection-examples { padding-block:var(--flood-sys-space-8); min-inline-size:0; }
150
+ .collection-view-controls { min-inline-size:0; }
151
+ .flood-root .collection-filter-editor { inline-size:min(42rem,calc(100vw - 2rem)); }
152
+
153
+ .collection-projects {
154
+ --collection-selection-inset: max(var(--flood-sys-space-4), env(safe-area-inset-bottom));
155
+ --collection-selection-control-height: var(--flood-sys-control-sm-height);
156
+ --collection-selection-height: calc(var(--collection-selection-control-height) + 2 * (var(--flood-sys-space-1) + var(--flood-sys-stroke-width)));
157
+ padding-block-end: calc(var(--collection-selection-inset) + var(--collection-selection-height) + var(--flood-sys-space-2));
158
+ }
159
+
160
+ @media (pointer: coarse) {
161
+ .collection-projects {
162
+ --collection-selection-control-height: max(var(--flood-sys-control-sm-height), var(--flood-sys-size-touch));
163
+ }
164
+ }
165
+
166
+ .collection-table-frame {
167
+ border: var(--flood-sys-stroke-width) solid var(--flood-sys-color-border-decorative);
168
+ overflow: clip;
169
+ }
170
+
171
+ .collection-selection-panel {
172
+ position: fixed;
173
+ inset-inline: 0;
174
+ inset-block-end: var(--collection-selection-inset);
175
+ margin-inline: auto;
176
+ inline-size: min(26rem, calc(100% - 2 * var(--flood-sys-space-4)));
177
+ max-inline-size: calc(100% - 2 * var(--flood-sys-space-4));
178
+ z-index: var(--flood-sys-layer-sticky);
179
+ }
180
+
181
+ .flood-root .collection-selection-panel + .flood-toast-region {
182
+ inset-block-end: calc(var(--collection-selection-inset) + var(--collection-selection-height) + var(--flood-sys-space-2));
183
+ max-block-size: calc(100dvh - var(--collection-selection-inset) - var(--collection-selection-height) - var(--flood-sys-space-6));
184
+ }
185
+ ```
186
+
187
+ ## Working state and saved definitions
188
+
189
+ Built-in presets are read-only. A saved view stores the query and supported sort,
190
+ not record IDs or cached results. Dirty views offer Update and Reset. Save as new
191
+ captures the current query; Rename changes only its name, even while filters are
192
+ dirty. Deleting a definition leaves collection records unchanged.
193
+
194
+ Working changes stay transient until saved. Reload starts at the default preset;
195
+ saved definitions remain selectable. Choosing another view replaces the working
196
+ query. URL sharing, remembered active views, nested groups, date/number operators,
197
+ collaboration and cross-tab synchronization are not implemented here.
198
+
199
+ Names are trimmed, at most 80 UTF-16 code units and unique after NFC/case
200
+ normalization, including preset names. Equality ignores condition UI IDs, condition
201
+ order and selected-value order; it is not general boolean equivalence.
202
+
203
+ ## Persistence and focus
204
+
205
+ Two keys keep project/file definitions separate. Read occurs after mount to retain
206
+ consistent initial server/client markup. Mount a separate keyed instance when
207
+ changing collection schema. Browser storage is an example adapter, not production
208
+ durability. A missing key means no custom definitions; malformed/refused reads
209
+ preserve the available raw snapshot and block writes to that store. Independently
210
+ loaded records remain usable. Retry/export/explicit replacement address the actual
211
+ preferences failure; exported bytes do not guarantee automatic restoration.
212
+
213
+ Refused writes retain accepted definitions and entered names. Cancel ends a name
214
+ draft's error; Reset ends a failed update of the discarded query. Read failures
215
+ have a separate lifetime. A real remote adapter needs pending/result ownership,
216
+ reconciliation and migrations rather than this synchronous example.
217
+
218
+ Existing overlay owners retain nested behaviour. Naming/deletion actions focus
219
+ the persistent view selector before opening a modal so it remains a useful return
220
+ target when an action disappears. Escape closes the value picker before the outer
221
+ condition editor; closing the editor returns to Filters.
222
+
223
+ ## Reused owners
224
+
225
+ FilterGroup composes FilterCondition rows for the flat advanced draft. It owns
226
+ row addition/removal, duplicate-property choice policy, the all/any switch and
227
+ accepted focus transitions. FilterCondition keeps equal control sizing and
228
+ container-based reflow. Changing a property clears incompatible values only in
229
+ the proposed draft. The collection form retains Apply/Cancel and query validation,
230
+ so creating an incomplete row never changes accepted results.
231
+
232
+ CollectionToolbar owns the shared query layout: search, accepted summaries,
233
+ reserved Clear filters and end-aligned Filters. Its fieldset uses normal Tab
234
+ navigation because it includes editable text. Keep saved-view controls outside
235
+ this row. Supply the staged editor through filterContent; opening it copies the
236
+ accepted query, Apply accepts it and Cancel abandons it. Reset and removal return
237
+ focus to the persistent Filters trigger (search when no panel exists). The host
238
+ defines reset scope and supplies canClear for any state held by quickFilters.
239
+ At most two task-important quick properties remain inline; additional properties
240
+ belong inside Filters. Summaries outside the panel name the property and preserve
241
+ any-match meaning. Container width, rather than viewport size, governs reflow.
242
+ The Projects table places its Columns control in CollectionToolbar's `actions`
243
+ slot via DataTable's `toolbar` render function. It shares the tool row with
244
+ search and filters, and the commands wrap as a group in a narrow container.
245
+
246
+ CollectionViewSwitcher owns the separate accepted-view choice and adaptive
247
+ commands. It reuses Field/Select and Button/OverflowActions with the same size.
248
+ The recipe supplies applicable actions and retains all persistence, name-only
249
+ rename, dirty query reset, confirmation and failure recovery. selectRef preserves
250
+ the existing naming/deletion dialog return target; saving a definition does not
251
+ reset result selection.
252
+
253
+ SearchInput, Select, Menu, MultiSelect and SegmentedControl own input/choice behaviour;
254
+ Popover contains the condition draft and Tag removes applied conditions. Button
255
+ and IconButton perform actions. Several view commands use OverflowActions; a
256
+ single command uses Button directly. Field/Fieldset, Grid/Stack/Inline, Dialog,
257
+ AlertDialog and ToastRegion retain their own roles. DataTable/StatusIndicator and
258
+ FileList/FileItem/FileMeta demonstrate two result structures. No owner is inserted
259
+ merely to increase its usage count, and no new package atom is needed for layout.
260
+
261
+ For a few mutually exclusive views of the same result table, use SegmentedControl
262
+ with a controlled value. An option's `count` can show workload, such as pending
263
+ items. Leave it unset when it repeats a total already visible beside the results.
264
+ Use Tabs when each choice opens its own associated content panel.
265
+
266
+ ## Model
267
+
268
+ ```ts
269
+ import type {ComboboxOption,DataTableSort} from '@flood-md/react';
270
+
271
+ export interface FilterProperty {id:string;label:string;options:readonly ComboboxOption[];}
272
+ export interface CollectionSchema {properties:readonly FilterProperty[];sortColumns:readonly string[];}
273
+ export interface FilterCondition {id:string;property:string;operator:'is'|'is-not';values:string[];}
274
+ export interface CollectionQuery {search:string;match:'all'|'any';conditions:FilterCondition[];sort:DataTableSort|null;}
275
+ export interface CollectionView {id:string;name:string;query:CollectionQuery;}
276
+ export type ViewStorage=Pick<Storage,'getItem'|'setItem'>;
277
+ export type ViewRead={state:'ready';views:CollectionView[]}|{state:'error';views:[];raw:string|null};
278
+ const object=(value:unknown):value is Record<string,unknown>=>Boolean(value)&&typeof value==='object'&&!Array.isArray(value);
279
+ export const emptyCollectionQuery=():CollectionQuery=>({search:'',match:'all',conditions:[],sort:null});
280
+ export const copyCollectionQuery=(query:CollectionQuery):CollectionQuery=>({...query,conditions:query.conditions.map(condition=>({...condition,values:[...condition.values]})),sort:query.sort?{...query.sort}:null});
281
+
282
+ /** Validate persisted definitions instead of silently dropping unknown predicates. */
283
+ export function parseCollectionQuery(value:unknown,schema:CollectionSchema):CollectionQuery {
284
+ if(!object(value)||typeof value.search!=='string'||(value.match!=='all'&&value.match!=='any')||!Array.isArray(value.conditions))throw Error('Unsupported view query');
285
+ const ids=new Set<string>(),properties=new Set<string>();
286
+ const conditions=value.conditions.map((condition:unknown):FilterCondition=>{
287
+ if(!object(condition)||typeof condition.id!=='string'||!condition.id.trim()||ids.has(condition.id)||typeof condition.property!=='string'||properties.has(condition.property)||(condition.operator!=='is'&&condition.operator!=='is-not')||!Array.isArray(condition.values)||!condition.values.length)throw Error('Unsupported view condition');
288
+ const property=schema.properties.find(property=>property.id===condition.property);
289
+ if(!property||new Set(condition.values).size!==condition.values.length||condition.values.some(value=>typeof value!=='string'||!property.options.some(option=>option.value===value)))throw Error('Unknown view values');
290
+ ids.add(condition.id);properties.add(condition.property);
291
+ return {id:condition.id,property:condition.property,operator:condition.operator as FilterCondition['operator'],values:[...condition.values] as string[]};
292
+ });
293
+ let sort:DataTableSort|null=null;
294
+ if(value.sort!==null){if(!object(value.sort)||typeof value.sort.column!=='string'||!schema.sortColumns.includes(value.sort.column)||(value.sort.direction!=='ascending'&&value.sort.direction!=='descending'))throw Error('Unsupported view sort');sort={column:value.sort.column,direction:value.sort.direction};}
295
+ return {search:value.search,match:value.match as CollectionQuery['match'],conditions,sort};
296
+ }
297
+
298
+ /** Row IDs and condition UI IDs do not define a saved query's meaning. */
299
+ export function collectionQueryKey(query:CollectionQuery){return JSON.stringify({search:query.search.trim(),match:query.conditions.length>1?query.match:'all',conditions:query.conditions.map(({property,operator,values})=>({property,operator,values:[...values].sort()})).sort((a,b)=>a.property.localeCompare(b.property)),sort:query.sort});}
300
+
301
+ export function matchesCollectionQuery(query:CollectionQuery,searchText:string,read:(property:string)=>string|readonly string[]|null){
302
+ if(!searchText.toLocaleLowerCase().includes(query.search.trim().toLocaleLowerCase()))return false;
303
+ const tests=query.conditions.map(condition=>{const raw=read(condition.property),values=raw===null?[]:typeof raw==='string'?[raw]:raw;const includes=condition.values.some(value=>values.includes(value));return condition.operator==='is'?includes:!includes;});
304
+ return !tests.length||(query.match==='all'?tests.every(Boolean):tests.some(Boolean));
305
+ }
306
+
307
+ export function readCollectionViews(key:string,schema:CollectionSchema,storage:ViewStorage,reservedNames:readonly string[]=[]):ViewRead {
308
+ let raw:string|null=null;
309
+ try{
310
+ raw=storage.getItem(key);if(raw===null)return {state:'ready',views:[]};
311
+ const value:unknown=JSON.parse(raw);
312
+ if(!object(value)||value.version!==1||!Array.isArray(value.views))throw Error('Unsupported saved views');
313
+ const ids=new Set<string>(),names=new Set(reservedNames.map(name=>name.normalize('NFC').toLowerCase()));
314
+ const views=value.views.map((view:unknown):CollectionView=>{
315
+ if(!object(view)||typeof view.id!=='string'||!view.id.startsWith('saved-')||ids.has(view.id)||typeof view.name!=='string'||!view.name.trim()||view.name.length>80||names.has(view.name.trim().normalize('NFC').toLowerCase()))throw Error('Unsupported saved view');
316
+ ids.add(view.id);names.add(view.name.trim().normalize('NFC').toLowerCase());
317
+ return {id:view.id,name:view.name.trim(),query:parseCollectionQuery(view.query,schema)};
318
+ });
319
+ return {state:'ready',views};
320
+ }catch{return {state:'error',views:[],raw};}
321
+ }
322
+ ```
323
+
324
+ ## Shared controls
325
+
326
+ ```tsx
327
+ "use client";
328
+ import {useEffect,useRef,useState,type FormEvent,type ReactNode} from 'react';
329
+ import {Alert,AlertDialog,Button,CollectionToolbar,CollectionViewSwitcher,Dialog,Field,FilterGroup,IconSlot,Inline,Input,Menu,Select,Stack,Tag,type OverflowAction} from '@flood-md/react';
330
+ import {collectionQueryKey,copyCollectionQuery,parseCollectionQuery,readCollectionViews,type CollectionQuery,type CollectionSchema,type CollectionView,type FilterProperty,type ViewRead,type ViewStorage} from './collection-views-model';
331
+
332
+ export function AppliedFilterControl({ property, value, onChange }: {
333
+ property: FilterProperty;
334
+ value: string;
335
+ onChange: (value: string) => void;
336
+ }) {
337
+ const trigger = useRef<HTMLButtonElement | null>(null);
338
+ const { id, label, options } = property;
339
+ const selected = options.find(option => option.value === value);
340
+
341
+ return (
342
+ <Inline gap="2" align="center">
343
+ <Menu
344
+ label={label}
345
+ trigger={({ ref, ...props }) => (
346
+ <Button {...props} ref={node => { trigger.current = node; ref(node); }} variant="subtle" size="sm">
347
+ {label} <IconSlot size="xs"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg></IconSlot>
348
+ </Button>
349
+ )}
350
+ items={[{ value: '', label: 'All', disabled: false }, ...options].map(option => ({
351
+ id: option.value ? `option-${option.value}` : 'all',
352
+ label: option.label,
353
+ type: 'radio',
354
+ group: id,
355
+ disabled: option.disabled,
356
+ checked: value === option.value,
357
+ closeOnSelect: true,
358
+ onCheckedChange: () => onChange(option.value),
359
+ }))}
360
+ />
361
+ {selected && (
362
+ <Tag size="md" removeLabel={`Remove ${label.toLowerCase()} filter: ${selected.label}`} onRemove={() => {
363
+ trigger.current?.focus({ preventScroll: true });
364
+ onChange('');
365
+ }}>
366
+ {selected.label}
367
+ </Tag>
368
+ )}
369
+ </Inline>
370
+ );
371
+ }
372
+
373
+ interface CollectionViewControlsProps {
374
+ label:string;schema:CollectionSchema;presets:readonly CollectionView[];
375
+ value:CollectionQuery;onValueChange:(query:CollectionQuery)=>void;storageKey:string;storage?:ViewStorage;toolbarActions?:ReactNode;
376
+ }
377
+ type NameDraft={kind:'create'|'rename';id?:string;name:string;snapshot:CollectionQuery;error?:string;invalid?:string};
378
+ const inlineFilterLimit = 2;
379
+
380
+ /** Host recipe: shared behaviour, not a second Flood component implementation. */
381
+ export function CollectionViewControls({label,schema,presets,value,onValueChange,storageKey,storage,toolbarActions}:CollectionViewControlsProps){
382
+ const [saved,setSaved]=useState<ViewRead|{state:'loading';views:[]}>({state:'loading',views:[]}),[selectedId,setSelectedId]=useState(presets[0].id);
383
+ const [editing,setEditing]=useState(false),[draft,setDraft]=useState(()=>copyCollectionQuery(value)),[draftError,setDraftError]=useState('');
384
+ const [advancedEditor, setAdvancedEditor] = useState(false);
385
+ const editorPanel = useRef<HTMLDivElement>(null);
386
+ const [nameDraft,setNameDraft]=useState<NameDraft|null>(null),[confirmation,setConfirmation]=useState<{kind:'delete';id:string;name:string}|{kind:'replace'}|null>(null);
387
+ const [operationError,setOperationError]=useState<{kind:'update'|'delete'|'replace'|'export';message:string}|null>(null),[readAttempt,setReadAttempt]=useState(0);
388
+ const nameInput=useRef<HTMLInputElement>(null),viewInput=useRef<HTMLSelectElement>(null);
389
+ const schemaKey=JSON.stringify({properties:schema.properties.map(property=>[property.id,property.options.map(option=>option.value)]),sort:schema.sortColumns});
390
+ const appliedKey=collectionQueryKey(value);
391
+ const compactConditions = (value.match === 'all' || value.conditions.length < 2) &&
392
+ value.conditions.every(condition => condition.operator === 'is' && condition.values.length === 1);
393
+ const collapseFilters = schema.properties.length > inlineFilterLimit;
394
+ const canClearFilters = value.conditions.length > 0 || Boolean(value.search);
395
+ const simpleEditor = collapseFilters && compactConditions && !advancedEditor;
396
+
397
+ useEffect(() => {
398
+ if (advancedEditor) editorPanel.current?.focus({ preventScroll: true });
399
+ }, [advancedEditor]);
400
+
401
+ function changeSimpleDraft(property: string, option: string) {
402
+ setDraft(current => {
403
+ const existing = current.conditions.find(condition => condition.property === property);
404
+ const conditions = current.conditions.filter(condition => condition.property !== property);
405
+ if (option) conditions.push({ id: existing?.id ?? crypto.randomUUID(), property, operator: 'is', values: [option] });
406
+ return { ...current, match: 'all', conditions };
407
+ });
408
+ }
409
+
410
+ function changeQuickFilter(property: string, option: string) {
411
+ const existing = value.conditions.find(condition => condition.property === property);
412
+ const conditions = value.conditions.filter(condition => condition.property !== property);
413
+ if (option) conditions.push({ id: existing?.id ?? crypto.randomUUID(), property, operator: 'is', values: [option] });
414
+ onValueChange({ ...value, match: 'all', conditions });
415
+ }
416
+ useEffect(()=>setEditing(false),[appliedKey]);
417
+ useEffect(()=>{try{setSaved(readCollectionViews(storageKey,schema,storage??window.localStorage,presets.map(preset=>preset.name)));}catch{setSaved({state:'error',views:[],raw:null});}},[storageKey,storage,schemaKey,readAttempt]);
418
+ const views=[...presets,...saved.views],selected=views.find(view=>view.id===selectedId)??presets[0];
419
+ const dirty=collectionQueryKey(value)!==collectionQueryKey(selected.query),custom=saved.views.find(view=>view.id===selected.id);
420
+ function persist(views:CollectionView[]){
421
+ if(saved.state!=='ready')return false;
422
+ try{(storage??window.localStorage).setItem(storageKey,JSON.stringify({version:1,views}));setSaved({state:'ready',views});setOperationError(null);return true;}catch{return false;}
423
+ }
424
+ const requestEditor=(next:boolean)=>{if(next){setDraft(copyCollectionQuery(value));setDraftError('');setAdvancedEditor(false);}setEditing(next);};
425
+ function apply(event:FormEvent<HTMLFormElement>){event.preventDefault();try{onValueChange(parseCollectionQuery({...value,match:draft.match,conditions:draft.conditions},schema));setEditing(false);}catch{setDraftError('Choose a value for each condition.');}}
426
+ function saveName(event:FormEvent<HTMLFormElement>){
427
+ event.preventDefault();if(!nameDraft)return;const name=nameDraft.name.trim();
428
+ if(!name){setNameDraft({...nameDraft,invalid:'Enter a view name.'});return;}
429
+ if(views.some(view=>view.id!==nameDraft.id&&view.name.normalize('NFC').toLowerCase()===name.normalize('NFC').toLowerCase())){setNameDraft({...nameDraft,invalid:'A view with this name already exists.'});return;}
430
+ const id=nameDraft.kind==='create'?`saved-${crypto.randomUUID()}`:nameDraft.id!;
431
+ const next=nameDraft.kind==='create'?[...saved.views,{id,name,query:copyCollectionQuery(nameDraft.snapshot)}]:saved.views.map(view=>view.id===id?{...view,name}:view);
432
+ if(persist(next)){setNameDraft(null);setSelectedId(id);}else setNameDraft({...nameDraft,error:'The view could not be saved. Try again.'});
433
+ }
434
+ function action(id:string){
435
+ if(id==='reset'){onValueChange(copyCollectionQuery(selected.query));setOperationError(error=>error?.kind==='update'?null:error);return;}
436
+ if(id==='create'||id==='rename'||id==='delete')viewInput.current?.focus({preventScroll:true});
437
+ if(id==='create')setNameDraft({kind:'create',name:'',snapshot:copyCollectionQuery(value)});
438
+ else if(id==='rename'&&custom)setNameDraft({kind:'rename',id:custom.id,name:custom.name,snapshot:copyCollectionQuery(custom.query)});
439
+ else if(id==='update'&&custom){if(!persist(saved.views.map(view=>view.id===custom.id?{...view,query:copyCollectionQuery(value)}:view)))setOperationError({kind:'update',message:'The view could not be updated. Try again.'});}
440
+ else if(id==='delete'&&custom)setConfirmation({kind:'delete',id:custom.id,name:custom.name});
441
+ }
442
+ function confirm(){
443
+ const pending=confirmation;setConfirmation(null);if(!pending)return;
444
+ if(pending.kind==='delete'){
445
+ if(persist(saved.views.filter(view=>view.id!==pending.id))){setSelectedId(presets[0].id);onValueChange(copyCollectionQuery(presets[0].query));}
446
+ else setOperationError({kind:'delete',message:'The view could not be deleted. Try again.'});
447
+ }else{
448
+ try{(storage??window.localStorage).setItem(storageKey,JSON.stringify({version:1,views:[]}));setSaved({state:'ready',views:[]});setSelectedId(presets[0].id);setOperationError(null);}
449
+ catch{setOperationError({kind:'replace',message:'Saved views could not be replaced.'});}
450
+ }
451
+ }
452
+ function exportSnapshot(){
453
+ if(saved.state!=='error'||saved.raw===null)return;
454
+ try{const url=URL.createObjectURL(new Blob([saved.raw],{type:'application/json'})),link=document.createElement('a');link.href=url;link.download='saved-views-recovery.json';link.click();setTimeout(()=>URL.revokeObjectURL(url),0);}catch{setOperationError({kind:'export',message:'The snapshot could not be exported.'});}
455
+ }
456
+ const actions:OverflowAction[]=[
457
+ ...(custom&&dirty?[{id:'update',label:'Update view',priority:20,disabled:saved.state!=='ready'}]:[]),
458
+ {id:'create',label:'Save as new view',priority:10,disabled:saved.state!=='ready'},
459
+ ...(dirty?[{id:'reset',label:'Reset view'}]:[]),
460
+ ...(custom?[{id:'rename',label:'Rename',disabled:saved.state!=='ready'},{id:'delete',label:'Delete view',tone:'danger' as const,disabled:saved.state!=='ready'}]:[]),
461
+ ];
462
+ return <Stack gap="3" className="collection-view-controls" aria-label={`${label} collection controls`}>
463
+ <CollectionViewSwitcher label={`${label} view`} selectRef={viewInput} controlSize="sm"
464
+ views={views.map(view=>({id:view.id,name:view.name}))} value={selected.id}
465
+ onValueChange={id=>{const next=views.find(view=>view.id===id);if(next){setSelectedId(next.id);setOperationError(null);onValueChange(copyCollectionQuery(next.query));}}}
466
+ actions={actions} actionsLabel={`${label} view actions`} moreLabel="View actions" onAction={action}/>
467
+ {saved.state==='error'&&<Stack gap="3"><Alert tone="error">Saved views could not be read.</Alert><Inline gap="3"><Button size="sm" variant="subtle" onClick={()=>setReadAttempt(attempt=>attempt+1)}>Retry saved views</Button>{saved.raw!==null&&<Button size="sm" variant="subtle" onClick={exportSnapshot}>Export snapshot</Button>}<Button size="sm" variant="subtle" tone="danger" onClick={()=>{viewInput.current?.focus({preventScroll:true});setConfirmation({kind:'replace'});}}>Replace saved views</Button></Inline></Stack>}
468
+ {operationError&&<Alert tone="error" announcement="polite"><Inline gap="3">{operationError.message}<Button size="sm" variant="ghost" onClick={()=>setOperationError(null)}>Dismiss</Button></Inline></Alert>}
469
+ <CollectionToolbar label={label+' collection toolbar'} searchValue={value.search}
470
+ actions={toolbarActions}
471
+ onSearchValueChange={search=>onValueChange({...value,search})}
472
+ searchLabel={'Search '+label.toLowerCase()} searchPlaceholder={'Search '+label.toLowerCase()+'…'} clearSearchLabel={'Clear '+label.toLowerCase()+' search'}
473
+ appliedFiltersLabel={label+' applied filters'}
474
+ quickFilters={compactConditions&&!collapseFilters?schema.properties.map(property=><AppliedFilterControl key={property.id}
475
+ property={property} value={value.conditions.find(condition=>condition.property===property.id)?.values[0]??''}
476
+ onChange={option=>changeQuickFilter(property.id,option)} />):undefined}
477
+ appliedFilters={compactConditions&&!collapseFilters?[]:value.conditions.map(condition=>{
478
+ const property=schema.properties.find(property=>property.id===condition.property)!;
479
+ const labels=condition.values.map(id=>property.options.find(option=>option.value===id)!.label);
480
+ return {id:condition.id,label:property.label+(compactConditions?': ':condition.operator==='is'?' is ':' excludes ')+labels.join(condition.operator==='is'?' or ':', '),
481
+ removeLabel:'Remove '+property.label.toLowerCase()+' filter'+(compactConditions?': '+labels[0]:'')};
482
+ })}
483
+ summaryPrefix={!compactConditions&&value.conditions.length>1?(value.match==='all'?'All:':'Any:'):undefined}
484
+ onRemoveFilter={id=>onValueChange({...value,conditions:value.conditions.filter(condition=>condition.id!==id)})}
485
+ onClear={()=>onValueChange({...value,search:'',conditions:[]})} canClear={canClearFilters}
486
+ filterTitle={label+' filters'} filtersOpen={editing} onFiltersOpenChange={requestEditor}
487
+ filterPanelRef={editorPanel} filterPanelClassName={simpleEditor?undefined:'collection-filter-editor'}
488
+ filterContent={
489
+ <form onSubmit={apply}><Stack gap="4">
490
+ {simpleEditor ? <>
491
+ {schema.properties.map(property => (
492
+ <Field key={property.id} label={property.label}>{props => (
493
+ <Select {...props} controlSize="sm"
494
+ value={draft.conditions.find(condition => condition.property === property.id)?.values[0] ?? ''}
495
+ onChange={event => changeSimpleDraft(property.id, event.currentTarget.value)}>
496
+ <option value="">All</option>
497
+ {property.options.map(option => <option key={option.value} value={option.value} disabled={option.disabled}>{option.label}</option>)}
498
+ </Select>
499
+ )}</Field>
500
+ ))}
501
+ <Button variant="subtle" size="sm" onClick={() => setAdvancedEditor(true)}>Advanced conditions</Button>
502
+ </> : <>
503
+ <FilterGroup label={`${label} conditions`} properties={schema.properties}
504
+ value={{match:draft.match,conditions:draft.conditions}}
505
+ onValueChange={next=>{setDraft(current=>({...current,...next}));setDraftError('');}} />
506
+ </>}
507
+ {draftError&&<Alert tone="error" announcement="polite">{draftError}</Alert>}
508
+ <Inline gap="3"><Button type="submit">Apply filters</Button><Button variant="subtle" onClick={()=>setEditing(false)}>Cancel</Button></Inline>
509
+ </Stack></form>
510
+ } />
511
+ {nameDraft&&<Dialog title={nameDraft.kind==='create'?'Save view':'Rename view'} open onOpenChange={next=>{if(!next)setNameDraft(null);}} trigger={()=>null} initialFocusRef={nameInput}>
512
+ <form onSubmit={saveName}><Stack gap="6"><Field label="View name" required error={nameDraft.invalid}>{props=><Input {...props} ref={nameInput} maxLength={80} value={nameDraft.name} onChange={event=>setNameDraft({...nameDraft,name:event.target.value,invalid:undefined})}/>}</Field>{nameDraft.error&&<Alert tone="error" announcement="polite">{nameDraft.error}</Alert>}<Inline gap="3"><Button type="submit">{nameDraft.kind==='create'?'Save view':'Rename view'}</Button><Button variant="subtle" onClick={()=>setNameDraft(null)}>Cancel</Button></Inline></Stack></form>
513
+ </Dialog>}
514
+ {confirmation&&<AlertDialog open onOpenChange={next=>{if(!next)setConfirmation(null);}} trigger={()=>null} title={confirmation.kind==='delete'?`Delete “${confirmation.name}”?`:'Replace saved views?'} description={confirmation.kind==='delete'?'The saved view will be removed. Collection records are unchanged.':'Unreadable saved views will be replaced with an empty set. Export the snapshot first if you need it.'} confirmLabel={confirmation.kind==='delete'?'Delete view':'Replace saved views'} cancelLabel="Keep views" onConfirm={confirm}/>}
515
+ </Stack>;
516
+ }
517
+ ```
518
+
519
+ ## Two consumers
520
+
521
+ Projects keeps DataTable mounted when an accepted filter finds no rows. Its
522
+ emptyContent composes EmptyState and Reset filters; the host clears search and
523
+ conditions, retains sort, resets page/selection and returns focus to search.
524
+ This is distinct from a read failure or loading state, which remain DataTable's
525
+ Alert/retry and Spinner owners. Files uses EmptyState outside its native FileList.
526
+
527
+ The last export is the website-only shell. Hosts need the model, controls and their
528
+ chosen consumer with actual data and their own chrome.
529
+
530
+ ```tsx
531
+ "use client";
532
+ import {useRef,useState} from 'react';
533
+ import {Button,DataTable,EmptyState,Field,FileItem,FileList,FloodRoot,Heading,Select,Stack,StatusIndicator,Surface,Tabs,ToastRegion,type ToastItem} from '@flood-md/react';
534
+ import {SiteHeader} from './site-header';
535
+ import {SiteFooter} from './site-footer';
536
+ import {CollectionViewControls} from './recipes/collection-views';
537
+ import {emptyCollectionQuery,matchesCollectionQuery,type CollectionQuery,type CollectionSchema,type CollectionView,type ViewStorage} from './recipes/collection-views-model';
538
+ import './collections-playground.css';
539
+ import {ProjectSelectionActions,type ProjectStatusUpdate} from './recipes/project-selection-actions';
540
+
541
+ const owners=[{value:'maya',label:'Maya'},{value:'noah',label:'Noah'},{value:'sam',label:'Sam'}];
542
+ const projectSchema:CollectionSchema={properties:[
543
+ {id:'status',label:'Status',options:[{value:'active',label:'Active'},{value:'paused',label:'Paused'},{value:'archived',label:'Archived'}]},
544
+ {id:'owner',label:'Owner',options:owners},
545
+ {id:'priority',label:'Priority',options:[{value:'urgent',label:'Urgent'},{value:'normal',label:'Normal'}]},
546
+ ],sortColumns:['name','owner','status','priority']};
547
+ const projects=[
548
+ {id:'design',name:'Design system',status:'active',owner:'maya',priority:'urgent'},
549
+ {id:'checkout',name:'Checkout',status:'active',owner:'noah',priority:'urgent'},
550
+ {id:'help',name:'Help center',status:'paused',owner:'sam',priority:'normal'},
551
+ {id:'portal',name:'Customer portal',status:'active',owner:'maya',priority:'normal'},
552
+ {id:'analytics',name:'Analytics',status:'archived',owner:'noah',priority:'normal'},
553
+ {id:'onboarding',name:'Onboarding',status:'paused',owner:'maya',priority:'urgent'},
554
+ {id:'docs',name:'Documentation',status:'active',owner:'sam',priority:'normal'},
555
+ {id:'billing',name:'Billing',status:'archived',owner:'sam',priority:'normal'},
556
+ ];
557
+ const projectPresets:CollectionView[]=[{id:'all',name:'All projects',query:emptyCollectionQuery()},{id:'active',name:'Active projects',query:{...emptyCollectionQuery(),conditions:[{id:'preset-active',property:'status',operator:'is',values:['active']}]}}];
558
+
559
+ export function ProjectCollection({storage,onUpdateStatus}: {
560
+ storage?:ViewStorage;
561
+ onUpdateStatus?:(update:ProjectStatusUpdate)=>void|Promise<void>;
562
+ }){
563
+ const [records, setRecords] = useState(projects);
564
+ const [query, setQuery] = useState(emptyCollectionQuery);
565
+ const [page, setPage] = useState(1);
566
+ const [selection, setSelection] = useState<readonly string[]>([]);
567
+ const [pending, setPending] = useState(false);
568
+ const [feedback, setFeedback] = useState<ToastItem | null>(null);
569
+ const feedbackSequence = useRef(0);
570
+ const inFlight = useRef(false);
571
+ const collection = useRef<HTMLElement>(null);
572
+ const selectionPanel = useRef<HTMLDivElement>(null);
573
+ const change = (query: CollectionQuery) => {
574
+ setQuery(query);
575
+ setPage(1);
576
+ setSelection([]);
577
+ setFeedback(null);
578
+ };
579
+ const rows=records.filter(project=>matchesCollectionQuery(query,`${project.name} ${owners.find(owner=>owner.value===project.owner)!.label}`,property=>project[property as 'status'|'owner'|'priority']));
580
+ const selectedProjects=rows.filter(project=>selection.includes(project.id));
581
+ const hasSelection = selectedProjects.length > 0;
582
+
583
+ async function updateStatus(proposal: ProjectStatusUpdate) {
584
+ if (inFlight.current) return;
585
+ const update = { ids: [...new Set(proposal.ids)], status: proposal.status };
586
+ if (!update.ids.length) return;
587
+ inFlight.current = true;
588
+ setPending(true);
589
+ setFeedback(null);
590
+ try {
591
+ await onUpdateStatus?.({ ids: [...update.ids], status: update.status });
592
+ const ids = new Set(update.ids);
593
+ setRecords(current => current.map(project => ids.has(project.id) ? { ...project, status: update.status } : project));
594
+ if (selectionPanel.current?.contains(collection.current?.ownerDocument.activeElement ?? null)) {
595
+ collection.current?.focus({ preventScroll: true });
596
+ }
597
+ setSelection(current => current.filter(id => !ids.has(id)));
598
+ setPage(1);
599
+ const result = update.status === 'active' ? 'set active' : update.status;
600
+ setFeedback({ id: String(++feedbackSequence.current), status: 'success', title: `${ids.size} ${ids.size === 1 ? 'project' : 'projects'} ${result}.` });
601
+ } catch {
602
+ setFeedback({
603
+ id: String(++feedbackSequence.current),
604
+ status: 'error',
605
+ title: `Couldn’t update ${update.ids.length} ${update.ids.length === 1 ? 'project' : 'projects'}.`,
606
+ action: { label: 'Retry update', onClick: () => {
607
+ collection.current?.focus({ preventScroll: true });
608
+ void updateStatus(update);
609
+ }},
610
+ });
611
+ } finally {
612
+ inFlight.current = false;
613
+ setPending(false);
614
+ }
615
+ }
616
+
617
+ return <section ref={collection} tabIndex={-1} className="collection-projects" aria-label="Projects collection"><Stack gap="6">
618
+ {hasSelection&&<div ref={selectionPanel} className="collection-selection-panel"><ProjectSelectionActions projects={selectedProjects} pending={pending} onUpdate={update=>{void updateStatus(update);}} onClear={()=>{
619
+ collection.current?.focus({preventScroll:true});setSelection([]);setFeedback(null);
620
+ }}/></div>}
621
+ <ToastRegion label="Project updates" items={feedback ? [feedback] : []}
622
+ onDismiss={id=>setFeedback(current=>current?.id===id?null:current)}/>
623
+ <Surface padding="0" className="collection-table-frame">
624
+ <DataTable caption="Projects" hideCaption minWidth="36rem" selectable showSelection={false} pageSize={5} page={page} onPageChange={setPage} selectedIds={selection} onSelectionChange={ids=>{setSelection(ids);setFeedback(null);}} sort={query.sort} onSortChange={sort=>change({...query,sort})}
625
+ inset="roomy" showColumnVisibility
626
+ toolbar={({columns}) => <CollectionViewControls label="Projects" schema={projectSchema} presets={projectPresets}
627
+ value={query} onValueChange={change} storageKey="flood-project-views-v1" storage={storage} toolbarActions={columns}/>}
628
+ emptyContent={<EmptyState title="No matching projects" actions={<Button variant="subtle" onClick={()=>{change({...query,search:'',conditions:[]});collection.current?.querySelector<HTMLInputElement>('.flood-collection-toolbar input')?.focus({preventScroll:true});}}>Reset filters</Button>}/>}
629
+ columns={[{id:'name',label:'Project',sortable:true,hideable:false},{id:'owner',label:'Owner',sortable:true},{id:'status',label:'Status',sortable:true},{id:'priority',label:'Priority',sortable:true}]}
630
+ rows={rows.map(project=>({id:project.id,label:project.name,cells:{name:project.name,owner:owners.find(owner=>owner.value===project.owner)!.label,status:<StatusIndicator label={projectSchema.properties[0].options.find(option=>option.value===project.status)!.label} tone={project.status==='active'?'success':project.status==='paused'?'warning':'neutral'}/>,priority:project.priority==='urgent'?'Urgent':'Normal'},sortValues:{name:project.name,owner:project.owner,status:project.status,priority:project.priority==='urgent'?1:0}}))}/>
631
+ </Surface>
632
+ </Stack></section>;
633
+ }
634
+
635
+ const teams=[{value:'design',label:'Design team'},{value:'product',label:'Product team'},{value:'finance',label:'Finance team'}];
636
+ const fileSchema:CollectionSchema={properties:[
637
+ {id:'kind',label:'Format',options:[{value:'pdf',label:'PDF'},{value:'fig',label:'Figma'},{value:'png',label:'PNG'},{value:'csv',label:'CSV'}]},
638
+ {id:'team',label:'Team',options:teams},
639
+ ],sortColumns:['name','size']};
640
+ const files=[
641
+ {id:'guidelines',name:'Brand guidelines.pdf',kind:'pdf',team:'design',size:2400000},
642
+ {id:'library',name:'Component library.fig',kind:'fig',team:'design',size:18400000},
643
+ {id:'research',name:'Research summary.pdf',kind:'pdf',team:'product',size:780000},
644
+ {id:'forecast',name:'Revenue forecast.csv',kind:'csv',team:'finance',size:28000},
645
+ {id:'cover',name:'Launch cover.png',kind:'png',team:'design',size:4100000},
646
+ {id:'feedback',name:'Customer feedback.csv',kind:'csv',team:'product',size:92000},
647
+ ];
648
+ const fileDefault=():CollectionQuery=>({...emptyCollectionQuery(),sort:{column:'name',direction:'ascending'}});
649
+ const filePresets:CollectionView[]=[{id:'all',name:'All files',query:fileDefault()},{id:'design',name:'Design files',query:{...fileDefault(),conditions:[{id:'preset-design',property:'team',operator:'is',values:['design']}]}}];
650
+ export function FileCollection({storage}: {storage?:ViewStorage}){
651
+ const [query,setQuery]=useState(fileDefault);
652
+ const sort=query.sort??{column:'name',direction:'ascending'};
653
+ const rows=files.filter(file=>matchesCollectionQuery(query,file.name,property=>file[property as 'kind'|'team'])).sort((a,b)=>(sort.column==='size'?a.size-b.size:a.name.localeCompare(b.name))*(sort.direction==='ascending'?1:-1));
654
+ return <section aria-label="Files collection"><Stack gap="6">
655
+ <CollectionViewControls label="Files" schema={fileSchema} presets={filePresets} value={query} onValueChange={setQuery} storageKey="flood-file-views-v1" storage={storage}/>
656
+ <div style={{maxWidth:'16rem'}}><Field label="Sort files" hideLabel>{props=><Select {...props} value={`${sort.column}:${sort.direction}`} onChange={event=>{const[column,direction]=event.target.value.split(':');setQuery({...query,sort:{column,direction:direction as 'ascending'|'descending'}});}}><option value="name:ascending">Name A–Z</option><option value="name:descending">Name Z–A</option><option value="size:ascending">Size small–large</option><option value="size:descending">Size large–small</option></Select>}</Field></div>
657
+ {rows.length?<FileList label="Files">{rows.map(file=><FileItem key={file.id} name={file.name} size={file.size} format={file.kind.toUpperCase()} description={teams.find(team=>team.value===file.team)!.label}/>)}</FileList>:<EmptyState title="No matching files" actions={<Button variant="subtle" onClick={()=>setQuery({...query,search:'',conditions:[]})}>Reset filters</Button>}/>}
658
+ </Stack></section>;
659
+ }
660
+
661
+ export default function CollectionsPlayground(){return <FloodRoot className="site-root"><div className="site-shell"><SiteHeader path="/playground/collections"/><main id="main-content" className="collection-examples"><Stack gap="4"><Heading level={1} size="page">Collections</Heading><Tabs label="Collection examples" variant="line" items={[{value:'projects',label:'Projects',content:<ProjectCollection/>},{value:'files',label:'Files',content:<FileCollection/>}]}/></Stack></main><SiteFooter/></div></FloodRoot>;}
662
+ ```
663
+
664
+ ## Verification
665
+
666
+ Exercise all/any, exclusion, incomplete drafts, Cancel/Escape, removal and no-results.
667
+ Verify page/selection changes; save, rename while dirty, update, reset, delete and
668
+ reload. Refuse reads/writes and preserve bytes/names. Check both consumers, nested
669
+ focus, narrow/enlarged geometry and final scroll actions. These checks do not
670
+ establish full UX, performance or assistive-technology certification.