@rokkit/ui 1.0.0-next.144 → 1.0.0-next.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/ui",
3
- "version": "1.0.0-next.144",
3
+ "version": "1.0.0-next.146",
4
4
  "description": "Data driven UI components for Rokkit applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,10 +43,10 @@
43
43
  "dropdown"
44
44
  ],
45
45
  "dependencies": {
46
- "@rokkit/core": "workspace:*",
47
- "@rokkit/data": "workspace:*",
48
- "@rokkit/states": "workspace:*",
49
- "@rokkit/actions": "workspace:*"
46
+ "@rokkit/core": "latest",
47
+ "@rokkit/data": "latest",
48
+ "@rokkit/states": "latest",
49
+ "@rokkit/actions": "latest"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "shiki": "^3.23.0",
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ import { SvelteSet } from 'svelte/reactivity'
3
+ import { alerts } from '@rokkit/states'
4
+ import Message from './Message.svelte'
5
+
6
+ interface AlertListProps {
7
+ /** Screen position for the toast stack */
8
+ position?:
9
+ | 'top-right'
10
+ | 'top-center'
11
+ | 'top-left'
12
+ | 'bottom-right'
13
+ | 'bottom-center'
14
+ | 'bottom-left'
15
+ /** Additional CSS class */
16
+ class?: string
17
+ }
18
+
19
+ const { position = 'top-right', class: className = '' }: AlertListProps = $props()
20
+
21
+ let el: HTMLElement | undefined = $state()
22
+ const dismissing = new SvelteSet<string>()
23
+
24
+ // Portal to document.body so position:fixed is relative to the viewport,
25
+ // not clipped by any overflow:auto ancestor (e.g. the docs main column).
26
+ $effect(() => {
27
+ if (!el) return
28
+
29
+ document.body.appendChild(el)
30
+ return () => el?.remove()
31
+ })
32
+
33
+ function startDismiss(id: string) {
34
+ dismissing.add(id)
35
+ }
36
+
37
+ function onTransitionEnd(id: string, e: TransitionEvent) {
38
+ if (e.propertyName === 'max-height' && dismissing.has(id)) {
39
+ dismissing.delete(id)
40
+ alerts.dismiss(id)
41
+ }
42
+ }
43
+ </script>
44
+
45
+ <div bind:this={el} data-alert-list data-position={position} class={className || undefined}>
46
+ {#each alerts.current as alert (alert.id)}
47
+ <div
48
+ data-dismissing={dismissing.has(alert.id) || undefined}
49
+ ontransitionend={(e) => onTransitionEnd(alert.id, e)}
50
+ >
51
+ <Message
52
+ type={alert.type as 'error' | 'info' | 'success' | 'warning'}
53
+ text={alert.text}
54
+ dismissible={alert.dismissible}
55
+ actions={alert.actions as any}
56
+ ondismiss={() => startDismiss(alert.id)}
57
+ />
58
+ </div>
59
+ {/each}
60
+ </div>
@@ -6,6 +6,8 @@
6
6
  href?: string
7
7
  /** Click handler (only applies when no href) */
8
8
  onclick?: () => void
9
+ /** Visual variant */
10
+ variant?: 'default' | 'primary' | 'secondary' | 'tertiary'
9
11
  /** Additional CSS class */
10
12
  class?: string
11
13
  /** Card header snippet */
@@ -16,7 +18,7 @@
16
18
  children?: Snippet
17
19
  }
18
20
 
19
- const { href, onclick, class: className = '', header, footer, children }: CardProps = $props()
21
+ const { href, onclick, variant = 'default', class: className = '', header, footer, children }: CardProps = $props()
20
22
  </script>
21
23
 
22
24
  {#snippet cardContent()}
@@ -40,15 +42,15 @@
40
42
  {/snippet}
41
43
 
42
44
  {#if href}
43
- <a {href} data-card class={className || undefined}>
45
+ <a {href} data-card data-variant={variant} class={className || undefined}>
44
46
  {@render cardContent()}
45
47
  </a>
46
48
  {:else if onclick}
47
- <button type="button" data-card data-card-interactive class={className || undefined} {onclick}>
49
+ <button type="button" data-card data-card-interactive data-variant={variant} class={className || undefined} {onclick}>
48
50
  {@render cardContent()}
49
51
  </button>
50
52
  {:else}
51
- <div data-card class={className || undefined}>
53
+ <div data-card data-variant={variant} class={className || undefined}>
52
54
  {@render cardContent()}
53
55
  </div>
54
56
  {/if}
@@ -0,0 +1,202 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Dropdown — Trigger that shows the selected value + dropdown panel with options.
4
+ *
5
+ * Similar to Menu but the trigger label reflects the currently selected value
6
+ * instead of a static label prop.
7
+ *
8
+ * Data attributes:
9
+ * data-dropdown — root container
10
+ * data-dropdown-trigger — trigger button
11
+ * data-dropdown-icon — optional icon in trigger
12
+ * data-dropdown-label — selected value text in trigger
13
+ * data-dropdown-arrow — expand/collapse chevron
14
+ * data-dropdown-panel — dropdown options panel
15
+ * data-dropdown-option — each option item
16
+ * data-dropdown-separator — separator between options
17
+ * data-active — marks the currently selected option
18
+ * data-open — present on root when panel is open
19
+ * data-size — size variant (sm | md | lg)
20
+ * data-align — panel alignment (start | end)
21
+ * data-direction — panel direction (down | up)
22
+ */
23
+ // @ts-nocheck
24
+ import { ProxyTree, Wrapper } from '@rokkit/states'
25
+ import { Navigator, Trigger } from '@rokkit/actions'
26
+ import { DEFAULT_STATE_ICONS } from '@rokkit/core'
27
+
28
+ interface DropdownIcons {
29
+ opened?: string
30
+ closed?: string
31
+ }
32
+
33
+ let {
34
+ items = [],
35
+ fields = {},
36
+ value = $bindable(),
37
+ placeholder = 'Select...',
38
+ icon,
39
+ size = 'md',
40
+ disabled = false,
41
+ showArrow = true,
42
+ align = 'start',
43
+ direction = 'down',
44
+ icons: userIcons = {} as DropdownIcons,
45
+ onchange,
46
+ class: className = ''
47
+ }: {
48
+ items?: unknown[]
49
+ fields?: Record<string, string>
50
+ value?: unknown
51
+ placeholder?: string
52
+ icon?: string
53
+ size?: string
54
+ disabled?: boolean
55
+ showArrow?: boolean
56
+ align?: 'start' | 'end'
57
+ direction?: 'up' | 'down'
58
+ icons?: DropdownIcons
59
+ onchange?: (value: unknown, item: unknown) => void
60
+ class?: string
61
+ } = $props()
62
+
63
+ const icons = $derived({ ...DEFAULT_STATE_ICONS.selector, ...userIcons })
64
+
65
+ // ─── State ────────────────────────────────────────────────────────────────
66
+
67
+ let isOpen = $state(false)
68
+ let rootRef = $state<HTMLElement | null>(null)
69
+ let triggerRef = $state<HTMLElement | null>(null)
70
+ let panelRef = $state<HTMLElement | null>(null)
71
+
72
+ // ─── Wrapper ──────────────────────────────────────────────────────────────
73
+
74
+ function handleSelect(v: unknown, proxy: unknown) {
75
+ if ((proxy as { disabled?: boolean }).disabled) return
76
+ value = v
77
+ onchange?.(v, (proxy as { original: unknown }).original)
78
+ isOpen = false
79
+ triggerRef?.focus()
80
+ }
81
+
82
+ const proxyTree = $derived(new ProxyTree(items, fields))
83
+ const wrapper = $derived(new Wrapper(proxyTree, { onselect: handleSelect }))
84
+
85
+ $effect(() => {
86
+ const w = wrapper
87
+ w.cancel = () => {
88
+ isOpen = false
89
+ triggerRef?.focus()
90
+ }
91
+ w.blur = () => {
92
+ isOpen = false
93
+ }
94
+ })
95
+
96
+ $effect(() => {
97
+ const _w = wrapper
98
+ if (isOpen) _w.first(null)
99
+ })
100
+
101
+ // ─── Trigger action ───────────────────────────────────────────────────────
102
+
103
+ $effect(() => {
104
+ if (!triggerRef || !rootRef || disabled) return
105
+ const t = new Trigger(triggerRef, rootRef, {
106
+ isOpen: () => isOpen,
107
+ onopen: () => {
108
+ isOpen = true
109
+ requestAnimationFrame(() => wrapper.first(null))
110
+ },
111
+ onclose: () => {
112
+ isOpen = false
113
+ },
114
+ onlast: () => requestAnimationFrame(() => wrapper.last(null))
115
+ })
116
+ return () => t.destroy()
117
+ })
118
+
119
+ // ─── Navigator on panel ───────────────────────────────────────────────────
120
+
121
+ $effect(() => {
122
+ if (!isOpen || !panelRef) return
123
+ const nav = new Navigator(panelRef, wrapper, {})
124
+ return () => nav.destroy()
125
+ })
126
+
127
+ // Focus sync — move DOM focus to focusedKey in panel
128
+ $effect(() => {
129
+ const key = wrapper.focusedKey
130
+ if (!isOpen || !panelRef || !key) return
131
+ requestAnimationFrame(() => {
132
+ const target = panelRef?.querySelector(`[data-path="${key}"]`) as HTMLElement | null
133
+ if (target && target !== document.activeElement) target.focus()
134
+ })
135
+ })
136
+
137
+ // ─── Selected label ───────────────────────────────────────────────────────
138
+
139
+ const selectedLabel = $derived.by(() => {
140
+ if (value === undefined || value === null) return null
141
+ for (const node of wrapper.flatView) {
142
+ if (node.proxy.value === value) return node.proxy.label
143
+ }
144
+ return String(value)
145
+ })
146
+ </script>
147
+
148
+ <div
149
+ bind:this={rootRef}
150
+ data-dropdown
151
+ data-open={isOpen || undefined}
152
+ data-size={size}
153
+ data-disabled={disabled || undefined}
154
+ data-align={align}
155
+ data-direction={direction}
156
+ class={className || undefined}
157
+ >
158
+ <button
159
+ bind:this={triggerRef}
160
+ type="button"
161
+ data-dropdown-trigger
162
+ {disabled}
163
+ aria-haspopup="listbox"
164
+ aria-expanded={isOpen}
165
+ >
166
+ {#if icon}
167
+ <span data-dropdown-icon class={icon} aria-hidden="true"></span>
168
+ {/if}
169
+ <span data-dropdown-label>{selectedLabel ?? placeholder}</span>
170
+ {#if showArrow}
171
+ <span data-dropdown-arrow class={isOpen ? icons.opened : icons.closed} aria-hidden="true"
172
+ ></span>
173
+ {/if}
174
+ </button>
175
+
176
+ {#if isOpen}
177
+ <div bind:this={panelRef} data-dropdown-panel role="listbox">
178
+ {#each wrapper.flatView as node (node.key)}
179
+ {@const proxy = node.proxy}
180
+ {@const isActive = proxy.value === value}
181
+
182
+ {#if node.type === 'separator'}
183
+ <hr data-dropdown-separator />
184
+ {:else}
185
+ <button
186
+ type="button"
187
+ data-dropdown-option
188
+ data-path={node.key}
189
+ data-active={isActive || undefined}
190
+ data-disabled={proxy.disabled || undefined}
191
+ disabled={proxy.disabled || disabled}
192
+ role="option"
193
+ aria-selected={isActive}
194
+ tabindex="-1"
195
+ >
196
+ <span data-dropdown-option-label>{proxy.label}</span>
197
+ </button>
198
+ {/if}
199
+ {/each}
200
+ </div>
201
+ {/if}
202
+ </div>
@@ -141,7 +141,10 @@
141
141
  focusDomItem(index)
142
142
  }
143
143
 
144
- function matchesEntryId(item: { proxy: ProxyItem; original: Record<string, unknown> }, id: string): boolean {
144
+ function matchesEntryId(
145
+ item: { proxy: ProxyItem; original: Record<string, unknown> },
146
+ id: string
147
+ ): boolean {
145
148
  return resolveTargetId(item) === id
146
149
  }
147
150
 
@@ -82,6 +82,15 @@
82
82
  return () => nav.destroy()
83
83
  })
84
84
 
85
+ // Expand all groups on mount and when items change.
86
+ // collapsible=false: groups are fixed section headers — must always show children.
87
+ // collapsible=true: groups start expanded; user can collapse individual groups.
88
+ $effect(() => {
89
+ for (const [, proxy] of wrapper.lookup) {
90
+ if (proxy.hasChildren) proxy.expanded = true
91
+ }
92
+ })
93
+
85
94
  // ─── Sync external value → focused key ────────────────────────────────────
86
95
 
87
96
  $effect(() => {
@@ -0,0 +1,77 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import { DEFAULT_STATE_ICONS } from '@rokkit/core'
4
+
5
+ interface MessageProps {
6
+ /** Alert type — controls color and icon */
7
+ type?: 'error' | 'info' | 'success' | 'warning'
8
+ /** Icon class overrides per type */
9
+ icons?: { error?: string; info?: string; success?: string; warning?: string }
10
+ /** Text content (shorthand; children takes precedence) */
11
+ text?: string
12
+ /** Show dismiss button */
13
+ dismissible?: boolean
14
+ /** Auto-dismiss after N ms. Defaults to 4000 unless dismissible (persistent). Pass 0 to disable. */
15
+ timeout?: number
16
+ /** Optional action buttons snippet */
17
+ actions?: Snippet
18
+ /** Rich content (takes precedence over text) */
19
+ children?: Snippet
20
+ /** Called when dismissed (button click or timeout) */
21
+ ondismiss?: () => void
22
+ /** Additional CSS class */
23
+ class?: string
24
+ }
25
+
26
+ const {
27
+ type = 'error',
28
+ icons = DEFAULT_STATE_ICONS.state as Record<string, string>,
29
+ text = undefined,
30
+ dismissible = false,
31
+ timeout = dismissible ? 0 : 4000,
32
+ actions,
33
+ children,
34
+ ondismiss,
35
+ class: className = ''
36
+ }: MessageProps = $props()
37
+
38
+ const role = $derived(type === 'error' || type === 'warning' ? 'alert' : 'status')
39
+ const icon = $derived(icons[type] ?? '')
40
+
41
+ $effect(() => {
42
+ if (timeout > 0) {
43
+ const timer = setTimeout(() => ondismiss?.(), timeout)
44
+ return () => clearTimeout(timer)
45
+ }
46
+ })
47
+ </script>
48
+
49
+ <div
50
+ data-message-root
51
+ data-type={type}
52
+ data-dismissible={dismissible}
53
+ {role}
54
+ class={className || undefined}
55
+ >
56
+ <span data-message-icon class={icon} aria-hidden="true"></span>
57
+
58
+ <span data-message-text>
59
+ {#if children}
60
+ {@render children()}
61
+ {:else}
62
+ {text}
63
+ {/if}
64
+ </span>
65
+
66
+ {#if actions}
67
+ <div data-message-actions>
68
+ {@render actions()}
69
+ </div>
70
+ {/if}
71
+
72
+ {#if dismissible}
73
+ <button type="button" data-message-dismiss aria-label="Dismiss" onclick={ondismiss}
74
+ >×</button
75
+ >
76
+ {/if}
77
+ </div>
@@ -127,10 +127,22 @@
127
127
  }
128
128
 
129
129
  function applyUpperKey(key: string): boolean {
130
- if (isIncreaseKey(key)) { nudgeUpper(1); return true }
131
- if (isDecreaseKey(key)) { nudgeUpper(-1); return true }
132
- if (key === 'Home') { jumpUpper(false); return true }
133
- if (key === 'End') { jumpUpper(true); return true }
130
+ if (isIncreaseKey(key)) {
131
+ nudgeUpper(1)
132
+ return true
133
+ }
134
+ if (isDecreaseKey(key)) {
135
+ nudgeUpper(-1)
136
+ return true
137
+ }
138
+ if (key === 'Home') {
139
+ jumpUpper(false)
140
+ return true
141
+ }
142
+ if (key === 'End') {
143
+ jumpUpper(true)
144
+ return true
145
+ }
134
146
  return false
135
147
  }
136
148
 
@@ -173,10 +185,22 @@
173
185
  }
174
186
 
175
187
  function applyLowerKey(key: string): boolean {
176
- if (isIncreaseKey(key)) { nudgeLower(1); return true }
177
- if (isDecreaseKey(key)) { nudgeLower(-1); return true }
178
- if (key === 'Home') { lower = min; return true }
179
- if (key === 'End') { lower = upper; return true }
188
+ if (isIncreaseKey(key)) {
189
+ nudgeLower(1)
190
+ return true
191
+ }
192
+ if (isDecreaseKey(key)) {
193
+ nudgeLower(-1)
194
+ return true
195
+ }
196
+ if (key === 'Home') {
197
+ lower = min
198
+ return true
199
+ }
200
+ if (key === 'End') {
201
+ lower = upper
202
+ return true
203
+ }
180
204
  return false
181
205
  }
182
206
 
@@ -107,13 +107,12 @@
107
107
  const childrenField = $derived(fields?.children || 'children')
108
108
 
109
109
  function childMatchesQuery(child: unknown, query: string): boolean {
110
- return String((child as Record<string, unknown>)[textField] ?? '').toLowerCase().includes(query)
110
+ return String((child as Record<string, unknown>)[textField] ?? '')
111
+ .toLowerCase()
112
+ .includes(query)
111
113
  }
112
114
 
113
- function filterGroupChildren(
114
- asRecord: Record<string, unknown>,
115
- query: string
116
- ): unknown | null {
115
+ function filterGroupChildren(asRecord: Record<string, unknown>, query: string): unknown | null {
117
116
  const children = asRecord[childrenField] as unknown[]
118
117
  const matching = children.filter((child: unknown) => childMatchesQuery(child, query))
119
118
  return matching.length > 0 ? { ...asRecord, [childrenField]: matching } : null
@@ -0,0 +1,18 @@
1
+ <script>
2
+ import { DEFAULT_STATE_ICONS } from '@rokkit/core'
3
+
4
+ const DEFAULT_ICONS = DEFAULT_STATE_ICONS.badge
5
+
6
+ let { class: className = '', items, icons = {} } = $props()
7
+
8
+ const resolvedIcons = $derived({ ...DEFAULT_ICONS, ...icons })
9
+ </script>
10
+
11
+ <div data-status-list class={className} role="status">
12
+ {#each items as { text, status }, index (index)}
13
+ <div data-status-item data-status={status}>
14
+ <span class={resolvedIcons[status]} aria-hidden="true"></span>
15
+ <p>{text}</p>
16
+ </div>
17
+ {/each}
18
+ </div>
@@ -17,6 +17,7 @@
17
17
  fields: userFields,
18
18
  position = 'top',
19
19
  size = 'md',
20
+ width = 'full',
20
21
  sticky = false,
21
22
  compact = false,
22
23
  showDividers = true,
@@ -252,6 +253,7 @@
252
253
  data-toolbar
253
254
  data-toolbar-position={position}
254
255
  data-toolbar-size={size}
256
+ data-toolbar-width={width === 'fit' ? 'fit' : undefined}
255
257
  data-toolbar-sticky={sticky || undefined}
256
258
  data-toolbar-compact={compact || undefined}
257
259
  data-toolbar-disabled={disabled || undefined}
@@ -3,6 +3,7 @@ export { default as Button } from './Button.svelte'
3
3
  export { default as ButtonGroup } from './ButtonGroup.svelte'
4
4
  export { default as Code } from './Code.svelte'
5
5
  export { default as Menu } from './Menu.svelte'
6
+ export { default as Dropdown } from './Dropdown.svelte'
6
7
  export { default as Select } from './Select.svelte'
7
8
  export { default as MultiSelect } from './MultiSelect.svelte'
8
9
  export { default as Toolbar } from './Toolbar.svelte'
@@ -36,3 +37,6 @@ export { default as UploadTarget } from './UploadTarget.svelte'
36
37
  export { default as UploadFileStatus } from './UploadFileStatus.svelte'
37
38
  export { default as UploadProgress } from './UploadProgress.svelte'
38
39
  export { default as FloatingNavigation } from './FloatingNavigation.svelte'
40
+ export { default as StatusList } from './StatusList.svelte'
41
+ export { default as Message } from './Message.svelte'
42
+ export { default as AlertList } from './AlertList.svelte'
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export {
4
4
  ButtonGroup,
5
5
  Code,
6
6
  Menu,
7
+ Dropdown,
7
8
  Select,
8
9
  MultiSelect,
9
10
  Toolbar,
@@ -35,7 +36,10 @@ export {
35
36
  Grid,
36
37
  UploadTarget,
37
38
  UploadFileStatus,
38
- UploadProgress
39
+ UploadProgress,
40
+ StatusList,
41
+ Message,
42
+ AlertList
39
43
  } from './components/index.js'
40
44
 
41
45
  // Utilities
@@ -112,6 +112,9 @@ export interface ToolbarProps {
112
112
  /** Size variant */
113
113
  size?: 'sm' | 'md' | 'lg'
114
114
 
115
+ /** Width behaviour: full stretches to container, fit sizes to content */
116
+ width?: 'full' | 'fit'
117
+
115
118
  /** Whether toolbar should stick to its position */
116
119
  sticky?: boolean
117
120
 
@@ -83,7 +83,11 @@ export function rgbToHex(rgb: RGB): string {
83
83
  /**
84
84
  * Convert RGB to HSL
85
85
  */
86
- interface NormalizedRGB { r: number; g: number; b: number }
86
+ interface NormalizedRGB {
87
+ r: number
88
+ g: number
89
+ b: number
90
+ }
87
91
 
88
92
  function computeHue(norm: NormalizedRGB, max: number, d: number): number {
89
93
  const { r, g, b } = norm
@@ -545,11 +549,28 @@ export function applyPalette(
545
549
  }
546
550
 
547
551
  const DEFAULT_PALETTE_ROLES: ColorRole[] = [
548
- 'primary', 'secondary', 'accent', 'surface', 'success', 'warning', 'danger', 'info'
552
+ 'primary',
553
+ 'secondary',
554
+ 'accent',
555
+ 'surface',
556
+ 'success',
557
+ 'warning',
558
+ 'danger',
559
+ 'info'
549
560
  ]
550
561
 
551
562
  const ALL_SHADE_KEYS: ShadeKey[] = [
552
- '50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'
563
+ '50',
564
+ '100',
565
+ '200',
566
+ '300',
567
+ '400',
568
+ '500',
569
+ '600',
570
+ '700',
571
+ '800',
572
+ '900',
573
+ '950'
553
574
  ]
554
575
 
555
576
  /**