@dryui/ui 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -74,6 +74,7 @@ export { DatePicker } from './date-picker/index.js';
74
74
  export { Calendar } from './calendar/index.js';
75
75
  export { DateRangePicker } from './date-range-picker/index.js';
76
76
  export { Listbox } from './listbox/index.js';
77
+ export { LogoMark } from './logo-mark/index.js';
77
78
  export { PinInput } from './pin-input/index.js';
78
79
  export { RangeCalendar } from './range-calendar/index.js';
79
80
  export { Rating } from './rating/index.js';
@@ -0,0 +1,10 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ export interface LogoMarkProps extends HTMLAttributes<HTMLSpanElement> {
3
+ src?: string;
4
+ alt?: string;
5
+ fallback?: string;
6
+ size?: 'sm' | 'md' | 'lg';
7
+ shape?: 'square' | 'rounded' | 'circle';
8
+ color?: 'brand' | 'neutral' | 'error' | 'warning' | 'success' | 'info';
9
+ }
10
+ export { default as LogoMark } from './logo-mark.svelte';
@@ -0,0 +1 @@
1
+ export { default as LogoMark } from './logo-mark.svelte';
@@ -0,0 +1,154 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+
4
+ interface Props extends HTMLAttributes<HTMLSpanElement> {
5
+ src?: string;
6
+ alt?: string;
7
+ fallback?: string;
8
+ size?: 'sm' | 'md' | 'lg';
9
+ shape?: 'square' | 'rounded' | 'circle';
10
+ color?: 'brand' | 'neutral' | 'error' | 'warning' | 'success' | 'info';
11
+ }
12
+
13
+ let {
14
+ src,
15
+ alt = '',
16
+ fallback,
17
+ size = 'md',
18
+ shape = 'rounded',
19
+ color = 'neutral',
20
+ class: className,
21
+ ...rest
22
+ }: Props = $props();
23
+
24
+ let failedSrc = $state<string | null>(null);
25
+ const showImage = $derived(Boolean(src) && src !== failedSrc);
26
+
27
+ function handleError() {
28
+ failedSrc = src ?? null;
29
+ }
30
+
31
+ function getFallbackText(): string {
32
+ const source = (fallback ?? alt).trim();
33
+ if (!source) return '';
34
+
35
+ const compact = source.replace(/[^\p{L}\p{N}]+/gu, '');
36
+ const characters = Array.from(compact || source);
37
+ return characters.slice(0, 2).join('').toUpperCase();
38
+ }
39
+ </script>
40
+
41
+ <span
42
+ role="img"
43
+ aria-label={alt}
44
+ data-logo-mark
45
+ data-size={size}
46
+ data-shape={shape}
47
+ data-color={color}
48
+ class={className}
49
+ {...rest}
50
+ >
51
+ {#if showImage}
52
+ <img {src} {alt} onerror={handleError} />
53
+ {:else}
54
+ <span aria-hidden="true">{getFallbackText()}</span>
55
+ {/if}
56
+ </span>
57
+
58
+ <style>
59
+ [data-logo-mark] {
60
+ --dry-logo-mark-size: 32px;
61
+ --dry-logo-mark-radius: var(--dry-radius-md);
62
+ --dry-logo-mark-bg: var(--dry-color-fill);
63
+ --dry-logo-mark-color: var(--dry-color-text-weak);
64
+
65
+ display: inline-grid;
66
+ place-items: center;
67
+ aspect-ratio: 1;
68
+ height: var(--dry-logo-mark-size);
69
+ border-radius: var(--dry-logo-mark-radius);
70
+ background: var(--dry-logo-mark-bg);
71
+ color: var(--dry-logo-mark-color);
72
+ font-family: var(--dry-font-sans);
73
+ font-size: var(--dry-logo-mark-font-size, var(--dry-type-small-size, var(--dry-text-sm-size)));
74
+ font-weight: 600;
75
+ line-height: 1;
76
+ overflow: hidden;
77
+ user-select: none;
78
+ }
79
+
80
+ [data-logo-mark] img {
81
+ width: 100%;
82
+ height: 100%;
83
+ object-fit: cover;
84
+ border-radius: inherit;
85
+ }
86
+
87
+ [data-logo-mark] > span {
88
+ display: grid;
89
+ place-items: center;
90
+ }
91
+
92
+ /* -- Shapes ------------------------------------------------------------- */
93
+
94
+ [data-shape='square'] {
95
+ --dry-logo-mark-radius: var(--dry-radius-sm);
96
+ }
97
+
98
+ [data-shape='rounded'] {
99
+ --dry-logo-mark-radius: var(--dry-radius-md);
100
+ }
101
+
102
+ [data-shape='circle'] {
103
+ --dry-logo-mark-radius: var(--dry-radius-full);
104
+ }
105
+
106
+ /* -- Sizes -------------------------------------------------------------- */
107
+
108
+ [data-size='sm'] {
109
+ --dry-logo-mark-size: 24px;
110
+ --dry-logo-mark-font-size: var(--dry-type-tiny-size, var(--dry-text-xs-size));
111
+ }
112
+
113
+ [data-size='md'] {
114
+ --dry-logo-mark-size: 32px;
115
+ --dry-logo-mark-font-size: var(--dry-type-small-size, var(--dry-text-sm-size));
116
+ }
117
+
118
+ [data-size='lg'] {
119
+ --dry-logo-mark-size: 40px;
120
+ --dry-logo-mark-font-size: var(--dry-type-small-size, var(--dry-text-base-size));
121
+ }
122
+
123
+ /* -- Colors ------------------------------------------------------------- */
124
+
125
+ [data-color='neutral'] {
126
+ --dry-logo-mark-bg: var(--dry-color-fill);
127
+ --dry-logo-mark-color: var(--dry-color-text-weak);
128
+ }
129
+
130
+ [data-color='brand'] {
131
+ --dry-logo-mark-bg: var(--dry-color-fill-brand-weak);
132
+ --dry-logo-mark-color: var(--dry-color-text-brand);
133
+ }
134
+
135
+ [data-color='error'] {
136
+ --dry-logo-mark-bg: var(--dry-color-fill-error-weak);
137
+ --dry-logo-mark-color: var(--dry-color-text-error);
138
+ }
139
+
140
+ [data-color='warning'] {
141
+ --dry-logo-mark-bg: var(--dry-color-fill-warning-weak);
142
+ --dry-logo-mark-color: var(--dry-color-text-warning);
143
+ }
144
+
145
+ [data-color='success'] {
146
+ --dry-logo-mark-bg: var(--dry-color-fill-success-weak);
147
+ --dry-logo-mark-color: var(--dry-color-text-success);
148
+ }
149
+
150
+ [data-color='info'] {
151
+ --dry-logo-mark-bg: var(--dry-color-fill-info-weak);
152
+ --dry-logo-mark-color: var(--dry-color-text-info);
153
+ }
154
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ interface Props extends HTMLAttributes<HTMLSpanElement> {
3
+ src?: string;
4
+ alt?: string;
5
+ fallback?: string;
6
+ size?: 'sm' | 'md' | 'lg';
7
+ shape?: 'square' | 'rounded' | 'circle';
8
+ color?: 'brand' | 'neutral' | 'error' | 'warning' | 'success' | 'info';
9
+ }
10
+ declare const LogoMark: import("svelte").Component<Props, {}, "">;
11
+ type LogoMark = ReturnType<typeof LogoMark>;
12
+ export default LogoMark;
@@ -47,11 +47,10 @@
47
47
  const isDisabled = $derived(disabled || formCtx?.disabled || false);
48
48
  const hasError = $derived(formCtx?.hasError || false);
49
49
 
50
- let inputEl: HTMLInputElement | undefined = $state();
50
+ let inputEl: HTMLInputElement | undefined;
51
51
  let isFocused = $state(false);
52
52
  let mirrorSelectionStart = $state<number | null>(null);
53
53
  let mirrorSelectionEnd = $state<number | null>(null);
54
- let completeFired = $state(false);
55
54
 
56
55
  const validationRegex = $derived(pattern ?? (type === 'numeric' ? /^\d+$/ : /^[a-zA-Z0-9]+$/));
57
56
 
@@ -98,24 +97,28 @@
98
97
  mirrorSelectionEnd = inputEl.selectionEnd;
99
98
  }
100
99
 
101
- $effect(() => {
102
- const handler = () => {
103
- if (document.activeElement === inputEl) {
104
- syncSelection();
100
+ function captureInput(node: HTMLInputElement) {
101
+ inputEl = node;
102
+ return () => {
103
+ if (inputEl === node) {
104
+ inputEl = undefined;
105
105
  }
106
106
  };
107
- document.addEventListener('selectionchange', handler);
108
- return () => document.removeEventListener('selectionchange', handler);
109
- });
107
+ }
110
108
 
111
- $effect(() => {
112
- if (value.length < length) {
113
- completeFired = false;
109
+ function maybeFireComplete(previousValue: string, nextValue: string) {
110
+ if (nextValue.length !== length) return;
111
+ if (previousValue.length === length && previousValue === nextValue) return;
112
+
113
+ oncomplete?.(nextValue);
114
+ if (blurOnComplete) {
115
+ inputEl?.blur();
114
116
  }
115
- });
117
+ }
116
118
 
117
119
  function handleInput(e: Event) {
118
120
  const input = e.target as HTMLInputElement;
121
+ const previousValue = value;
119
122
  let newValue = input.value;
120
123
 
121
124
  newValue = newValue
@@ -126,18 +129,12 @@
126
129
  newValue = newValue.slice(0, length);
127
130
  value = newValue;
128
131
  syncSelection();
129
-
130
- if (newValue.length === length && !completeFired) {
131
- completeFired = true;
132
- oncomplete?.(newValue);
133
- if (blurOnComplete) {
134
- inputEl?.blur();
135
- }
136
- }
132
+ maybeFireComplete(previousValue, newValue);
137
133
  }
138
134
 
139
135
  function handlePaste(e: ClipboardEvent) {
140
136
  e.preventDefault();
137
+ const previousValue = value;
141
138
  let pasted = e.clipboardData?.getData('text') ?? '';
142
139
  if (pasteTransformer) {
143
140
  pasted = pasteTransformer(pasted);
@@ -166,13 +163,7 @@
166
163
  syncSelection();
167
164
  });
168
165
 
169
- if (newValue.length === length && !completeFired) {
170
- completeFired = true;
171
- oncomplete?.(newValue);
172
- if (blurOnComplete) {
173
- inputEl?.blur();
174
- }
175
- }
166
+ maybeFireComplete(previousValue, newValue);
176
167
  }
177
168
 
178
169
  function handleFocus() {
@@ -212,13 +203,10 @@
212
203
  const sizeAttr = $derived(`data-pin-input-${size}`);
213
204
  </script>
214
205
 
215
- <!-- svelte-ignore a11y_role_supports_aria_props -->
216
206
  <div
217
207
  role="group"
218
208
  aria-label="PIN input"
219
209
  aria-describedby={formCtx?.describedBy}
220
- aria-invalid={hasError || undefined}
221
- aria-errormessage={formCtx?.errorMessageId}
222
210
  data-pin-input-root
223
211
  data-disabled={isDisabled || undefined}
224
212
  data-error={hasError || undefined}
@@ -230,7 +218,7 @@
230
218
  {...rest}
231
219
  >
232
220
  <input
233
- bind:this={inputEl}
221
+ {@attach captureInput}
234
222
  type="text"
235
223
  inputmode={type === 'numeric' ? 'numeric' : 'text'}
236
224
  autocomplete="one-time-code"
@@ -238,6 +226,9 @@
238
226
  {value}
239
227
  id={formCtx?.id}
240
228
  aria-label="PIN input"
229
+ aria-describedby={formCtx?.describedBy}
230
+ aria-invalid={hasError || undefined}
231
+ aria-errormessage={formCtx?.errorMessageId}
241
232
  aria-required={formCtx?.required || undefined}
242
233
  disabled={isDisabled}
243
234
  spellcheck={false}
@@ -247,6 +238,8 @@
247
238
  onfocus={handleFocus}
248
239
  onblur={handleBlur}
249
240
  onkeydown={handleKeydown}
241
+ onkeyup={syncSelection}
242
+ onselect={syncSelection}
250
243
  />
251
244
 
252
245
  {#if userChildren}
@@ -2,6 +2,12 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import { generateFormId } from '@dryui/primitives';
4
4
  import { setSelectCtx } from './context.svelte.js';
5
+ import SelectTrigger from './select-trigger.svelte';
6
+ import SelectValue from './select-value.svelte';
7
+ import SelectContent from './select-content.svelte';
8
+ import SelectItem from './select-item.svelte';
9
+
10
+ type SelectOption = { value: string; label: string };
5
11
 
6
12
  interface Props {
7
13
  open?: boolean;
@@ -9,7 +15,9 @@
9
15
  disabled?: boolean;
10
16
  name?: string;
11
17
  class?: string;
12
- children: Snippet;
18
+ options?: Array<string | SelectOption>;
19
+ placeholder?: string;
20
+ children?: Snippet;
13
21
  }
14
22
 
15
23
  let {
@@ -18,9 +26,15 @@
18
26
  disabled = false,
19
27
  name,
20
28
  class: className,
29
+ options,
30
+ placeholder,
21
31
  children
22
32
  }: Props = $props();
23
33
 
34
+ const normalizedOptions = $derived(
35
+ options?.map((opt) => (typeof opt === 'string' ? { value: opt, label: opt } : opt))
36
+ );
37
+
24
38
  const triggerId = generateFormId('select-trigger');
25
39
  const contentId = generateFormId('select-content');
26
40
 
@@ -59,7 +73,18 @@
59
73
  </script>
60
74
 
61
75
  <div data-select-wrapper class={className}>
62
- {@render children()}
76
+ {#if normalizedOptions && !children}
77
+ <SelectTrigger>
78
+ <SelectValue {placeholder} />
79
+ </SelectTrigger>
80
+ <SelectContent>
81
+ {#each normalizedOptions as opt (opt.value)}
82
+ <SelectItem value={opt.value}>{opt.label}</SelectItem>
83
+ {/each}
84
+ </SelectContent>
85
+ {:else if children}
86
+ {@render children()}
87
+ {/if}
63
88
 
64
89
  {#if name}
65
90
  <input type="hidden" {name} {value} disabled={disabled || undefined} />
@@ -5,7 +5,9 @@ interface Props {
5
5
  disabled?: boolean;
6
6
  name?: string;
7
7
  class?: string;
8
- children: Snippet;
8
+ options?: Array<string | { value: string; label: string }>;
9
+ placeholder?: string;
10
+ children?: Snippet;
9
11
  }
10
12
  declare const SelectRoot: import('svelte').Component<Props, {}, 'value' | 'open'>;
11
13
  type SelectRoot = ReturnType<typeof SelectRoot>;
@@ -1,8 +1,12 @@
1
1
  <script lang="ts">
2
- import { untrack } from 'svelte';
3
2
  import { getFormControlCtx } from '@dryui/primitives';
4
3
  import { Select } from '../select/index.js';
5
4
 
5
+ type TimeParts = {
6
+ hour: string;
7
+ minute: string;
8
+ };
9
+
6
10
  interface Props {
7
11
  value?: string;
8
12
  disabled?: boolean;
@@ -23,43 +27,46 @@
23
27
 
24
28
  const ctx = getFormControlCtx();
25
29
  const isDisabled = $derived(disabled || ctx?.disabled || false);
30
+ const hasError = $derived(ctx?.hasError || false);
31
+ const describedBy = $derived(ctx?.describedBy);
32
+ const errorMessageId = $derived(ctx?.hasError ? ctx?.errorMessageId : undefined);
26
33
 
27
- let hourStr = $state('');
28
- let minuteStr = $state('');
29
- let updating = false;
30
-
31
- // Parse incoming value into hour/minute (only reacts to value changes)
32
- $effect(() => {
33
- if (updating) return;
34
- const parts = value.split(':');
35
- const h = parts[0] ?? '';
36
- const m = parts[1] ?? '';
37
- untrack(() => {
38
- if (h !== hourStr) hourStr = h;
39
- if (m !== minuteStr) minuteStr = m;
40
- });
41
- });
34
+ function parseTime(nextValue: string): TimeParts {
35
+ const [hour = '', minute = ''] = nextValue.split(':');
36
+ return { hour, minute };
37
+ }
38
+
39
+ let draftHour = $state('');
40
+ let draftMinute = $state('');
42
41
 
43
- // Reconstruct value when selects change (only reacts to hourStr/minuteStr)
44
- $effect(() => {
45
- const h = hourStr;
46
- const m = minuteStr;
47
- if (h && m) {
48
- const newVal = `${h}:${m}`;
49
- untrack(() => {
50
- if (newVal !== value) {
51
- updating = true;
52
- value = newVal;
53
- updating = false;
54
- }
55
- });
42
+ const parsedValue = $derived.by(() => parseTime(value));
43
+ const hourStr = $derived(value ? parsedValue.hour : draftHour);
44
+ const minuteStr = $derived(value ? parsedValue.minute : draftMinute);
45
+
46
+ function setTime(nextHour: string, nextMinute: string) {
47
+ draftHour = nextHour;
48
+ draftMinute = nextMinute;
49
+
50
+ if (nextHour && nextMinute) {
51
+ value = `${nextHour}:${nextMinute}`;
52
+ draftHour = '';
53
+ draftMinute = '';
54
+ return;
56
55
  }
57
- });
58
56
 
59
- // Generate hour options 00-23
57
+ value = '';
58
+ }
59
+
60
+ function setHour(nextHour: string) {
61
+ setTime(nextHour, minuteStr);
62
+ }
63
+
64
+ function setMinute(nextMinute: string) {
65
+ setTime(hourStr, nextMinute);
66
+ }
67
+
60
68
  const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
61
69
 
62
- // Generate minute options based on step
63
70
  const minutes = $derived.by(() => {
64
71
  const stepMinutes = step && step >= 60 ? Math.floor(step / 60) : 1;
65
72
  const count = Math.floor(60 / stepMinutes);
@@ -75,12 +82,16 @@
75
82
  data-time-input-wrapper
76
83
  data-disabled={isDisabled || undefined}
77
84
  id={ctx?.id}
78
- aria-describedby={[ctx?.describedBy, ctx?.hasError ? ctx?.errorMessageId : undefined].filter(Boolean).join(' ') || undefined}
79
- aria-invalid={ctx?.hasError || undefined}
80
85
  class={className}
81
86
  >
82
- <Select.Root bind:value={hourStr} disabled={isDisabled}>
83
- <Select.Trigger {size} aria-label="Hour">
87
+ <Select.Root bind:value={() => hourStr, setHour} disabled={isDisabled}>
88
+ <Select.Trigger
89
+ {size}
90
+ aria-label="Hour"
91
+ aria-describedby={describedBy}
92
+ aria-invalid={hasError || undefined}
93
+ aria-errormessage={errorMessageId}
94
+ >
84
95
  <span data-time-display data-placeholder={!hourStr ? '' : undefined}>
85
96
  {hourStr || 'HH'}
86
97
  </span>
@@ -94,8 +105,14 @@
94
105
 
95
106
  <span data-time-separator>:</span>
96
107
 
97
- <Select.Root bind:value={minuteStr} disabled={isDisabled}>
98
- <Select.Trigger {size} aria-label="Minute">
108
+ <Select.Root bind:value={() => minuteStr, setMinute} disabled={isDisabled}>
109
+ <Select.Trigger
110
+ {size}
111
+ aria-label="Minute"
112
+ aria-describedby={describedBy}
113
+ aria-invalid={hasError || undefined}
114
+ aria-errormessage={errorMessageId}
115
+ >
99
116
  <span data-time-display data-placeholder={!minuteStr ? '' : undefined}>
100
117
  {minuteStr || 'MM'}
101
118
  </span>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dryui/ui",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "author": "Rob Balfre",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -401,6 +401,11 @@
401
401
  "svelte": "./dist/listbox/index.js",
402
402
  "default": "./dist/listbox/index.js"
403
403
  },
404
+ "./logo-mark": {
405
+ "types": "./dist/logo-mark/index.d.ts",
406
+ "svelte": "./dist/logo-mark/index.js",
407
+ "default": "./dist/logo-mark/index.js"
408
+ },
404
409
  "./map": {
405
410
  "types": "./dist/map/index.d.ts",
406
411
  "svelte": "./dist/map/index.js",
@@ -1141,6 +1146,11 @@
1141
1146
  "svelte": "./dist/listbox/index.js",
1142
1147
  "default": "./dist/listbox/index.js"
1143
1148
  },
1149
+ "./logo-mark": {
1150
+ "types": "./dist/logo-mark/index.d.ts",
1151
+ "svelte": "./dist/logo-mark/index.js",
1152
+ "default": "./dist/logo-mark/index.js"
1153
+ },
1144
1154
  "./map": {
1145
1155
  "types": "./dist/map/index.d.ts",
1146
1156
  "svelte": "./dist/map/index.js",
@@ -1504,7 +1514,7 @@
1504
1514
  "thumbnail:create": "bun scripts/create-thumbnail.ts"
1505
1515
  },
1506
1516
  "dependencies": {
1507
- "@dryui/primitives": "^0.1.13"
1517
+ "@dryui/primitives": "^0.2.0"
1508
1518
  },
1509
1519
  "peerDependencies": {
1510
1520
  "svelte": "^5.55.1"
@@ -31,6 +31,8 @@ Most DryUI components are compound — they require `<Card.Root>`, not `<Card>`.
31
31
  <!-- Right --> <Card.Root>content</Card.Root>
32
32
  ```
33
33
 
34
+ Compound components include Accordion, Alert, AlertDialog, Breadcrumb, Calendar, Card, Carousel, Chart, ChipGroup, Collapsible, ColorPicker, Combobox, CommandPalette, ContextMenu, DataGrid, DateField, DatePicker, DateRangePicker, DescriptionList, Dialog, DragAndDrop, Drawer, DropdownMenu, Field, Fieldset, FileSelect, FileUpload, FlipCard, FloatButton, HoverCard, InputGroup, LinkPreview, List, Listbox, Map, MegaMenu, Menubar, MultiSelectCombobox, NavigationMenu, NotificationCenter, OptionSwatchGroup, Pagination, PinInput, Popover, RadioGroup, RangeCalendar, RichTextEditor, SegmentedControl, Select, Sidebar, Splitter, StarRating, Stepper, Table, TableOfContents, Tabs, TagsInput, Timeline, Toast, ToggleGroup, Toolbar, Tooltip, Tour, Transfer, Tree and Typography.
35
+
34
36
  The test: every compound component in your markup uses `.Root`, and its parts are wrapped inside it. See `rules/compound-components.md` for the full list and parts reference.
35
37
 
36
38
  ## 3. Let the Theme Do Its Job
@@ -153,6 +155,13 @@ Always verify with `info`, but these are the most common mistakes:
153
155
 
154
156
  Use these to look up APIs, discover components, plan setup, and validate code.
155
157
 
158
+ ### Recommended workflow
159
+
160
+ 1. `compose` or `info` before writing components so you confirm kind, required parts, bindables, and canonical usage.
161
+ 2. Build the route or component with raw CSS grid, `Container` for constrained width, and `@container` for responsive layout.
162
+ 3. `review` or `doctor` after implementation to catch composition drift, layout violations, and accessibility regressions.
163
+ 4. Never guess component shape from memory. DryUI is intentionally strict, and the lookup cost is lower than rework.
164
+
156
165
  ### MCP tools (preferred)
157
166
 
158
167
  | Workflow | Tools |
@@ -402,13 +402,62 @@ Call `compose` with any recipe name to get a full working snippet.
402
402
  | `data-table-with-actions` | Table with header actions | Table, Badge, Avatar, Button |
403
403
  | `checkout-flow` | Multi-step checkout | Stepper, Card, Field, RadioGroup |
404
404
  | `hotel-listing-card` | Product/listing card | Card, Image, Badge, Button, Text |
405
- | `stat-card-grid` | KPI dashboard cards | Grid, StatCard, Chart, Sparkline |
405
+ | `stat-card-grid` | KPI dashboard cards | StatCard, Chart, Sparkline, Container |
406
406
  | `settings-page` | Settings with tabs | Tabs, Card, Field, Input, Select |
407
407
  | `form-with-validation` | Form with error handling | Card, Field, Label, Input, Field.Error |
408
- | `sidebar-layout` | Page with sidebar nav | Grid, Sidebar, PageHeader |
409
- | `dashboard-page` | Full dashboard layout | Grid, Sidebar, StatCard, Chart, Table |
408
+ | `sidebar-layout` | Page with sidebar nav | Sidebar, PageHeader, Container |
409
+ | `dashboard-page` | Full dashboard layout | Sidebar, StatCard, Chart, Table |
410
410
  | `user-profile-card` | User info card | Card, Avatar, Text, Badge, Button |
411
411
  | `notification-list` | Notification feed | Card, Avatar, Text, Badge |
412
412
  | `command-bar` | Command palette trigger | CommandPalette, Hotkey |
413
413
  | `file-upload-form` | File upload with progress | Card, FileUpload, Progress, Button |
414
414
  | `pricing-table` | Pricing comparison | Card, Text, Button, Badge |
415
+
416
+ ## State-heavy form flows
417
+
418
+ DryUI is a presentation and accessibility system, not a workflow engine. For dependent-field planners, approvals, and booking-style state machines:
419
+
420
+ - Normalize route/session state in script before rendering DryUI inputs.
421
+ - Reset dependent `Select.Root` values when their parent choice changes; do not rely on stale child state surviving domain changes.
422
+ - Use raw CSS grid to lay out planner sections, and keep orchestration logic in route-level stores or derived state.
423
+ - Run `compose` or `info` before introducing a new field shape, then run `review` or `doctor` after the flow is wired.
424
+
425
+ ```svelte
426
+ <script lang="ts">
427
+ let country = $state('');
428
+ let airport = $state('');
429
+
430
+ const airportOptions = $derived(getAirports(country));
431
+
432
+ $effect(() => {
433
+ if (!airportOptions.some((option) => option.value === airport)) {
434
+ airport = '';
435
+ }
436
+ });
437
+ </script>
438
+
439
+ <div class="planner">
440
+ <Field.Root>
441
+ <Label>Country</Label>
442
+ <Select.Root bind:value={country}>
443
+ <Select.Trigger><Select.Value placeholder="Choose country" /></Select.Trigger>
444
+ <Select.Content>{/* items */}</Select.Content>
445
+ </Select.Root>
446
+ </Field.Root>
447
+
448
+ <Field.Root>
449
+ <Label>Airport</Label>
450
+ <Select.Root bind:value={airport} disabled={!country}>
451
+ <Select.Trigger><Select.Value placeholder="Choose airport" /></Select.Trigger>
452
+ <Select.Content>{/* filtered items */}</Select.Content>
453
+ </Select.Root>
454
+ </Field.Root>
455
+ </div>
456
+
457
+ <style>
458
+ .planner {
459
+ display: grid;
460
+ gap: var(--dry-space-4);
461
+ }
462
+ </style>
463
+ ```