@rokkit/ui 1.0.0-next.147 → 1.0.0-next.150

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.147",
3
+ "version": "1.0.0-next.150",
4
4
  "description": "Data driven UI components for Rokkit applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,7 +46,9 @@
46
46
  "@rokkit/core": "latest",
47
47
  "@rokkit/data": "latest",
48
48
  "@rokkit/states": "latest",
49
- "@rokkit/actions": "latest"
49
+ "@rokkit/actions": "latest",
50
+ "marked": "^15.0.0",
51
+ "isomorphic-dompurify": "^2.0.0"
50
52
  },
51
53
  "peerDependencies": {
52
54
  "shiki": "^3.23.0",
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ import { marked } from 'marked'
3
+ import type { Token, TokensList } from 'marked'
4
+ import DOMPurify from 'isomorphic-dompurify'
5
+ import type { MarkdownPlugin } from './markdown-plugin.js'
6
+
7
+ interface Props {
8
+ markdown: string
9
+ plugins?: MarkdownPlugin[]
10
+ }
11
+
12
+ let { markdown, plugins = [] }: Props = $props()
13
+
14
+ const pluginMap = $derived(
15
+ Object.fromEntries(plugins.map((p) => [p.language.toLowerCase(), p.component]))
16
+ )
17
+
18
+ const tokens = $derived(marked.lexer(markdown))
19
+
20
+ function tokenToSafeHtml(token: Token): string {
21
+ const tokenList = Object.assign([token], { links: (tokens as TokensList).links ?? {} }) as TokensList
22
+ const raw = marked.parser(tokenList)
23
+ return DOMPurify.sanitize(raw)
24
+ }
25
+ </script>
26
+
27
+ <div class="markdown-renderer" data-markdown>
28
+ {#each tokens as token, i (i)}
29
+ {#if token.type === 'code'}
30
+ {@const lang = (token.lang ?? '').toLowerCase()}
31
+ {@const Plugin = pluginMap[lang]}
32
+ {#if Plugin}
33
+ <Plugin code={token.text} />
34
+ {:else}
35
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
36
+ {@html tokenToSafeHtml(token)}
37
+ {/if}
38
+ {:else}
39
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
40
+ {@html tokenToSafeHtml(token)}
41
+ {/if}
42
+ {/each}
43
+ </div>
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from 'svelte'
2
3
  import { SvelteSet } from 'svelte/reactivity'
3
4
  import { alerts } from '@rokkit/states'
4
5
  import Message from './Message.svelte'
@@ -18,17 +19,15 @@
18
19
 
19
20
  const { position = 'top-right', class: className = '' }: AlertListProps = $props()
20
21
 
21
- let el: HTMLElement | undefined = $state()
22
22
  const dismissing = new SvelteSet<string>()
23
23
 
24
24
  // Portal to document.body so position:fixed is relative to the viewport,
25
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
- })
26
+
27
+ function mountPortal(node: HTMLElement) {
28
+ document.body.appendChild(node)
29
+ return { destroy: () => node.remove() }
30
+ }
32
31
 
33
32
  function startDismiss(id: string) {
34
33
  dismissing.add(id)
@@ -42,7 +41,7 @@
42
41
  }
43
42
  </script>
44
43
 
45
- <div bind:this={el} data-alert-list data-position={position} class={className || undefined}>
44
+ <div use:mountPortal data-alert-list data-position={position} class={className || undefined}>
46
45
  {#each alerts.current as alert (alert.id)}
47
46
  <div
48
47
  data-dismissing={dismissing.has(alert.id) || undefined}
@@ -52,7 +51,7 @@
52
51
  type={alert.type as 'error' | 'info' | 'success' | 'warning'}
53
52
  text={alert.text}
54
53
  dismissible={alert.dismissible}
55
- actions={alert.actions as any}
54
+ actions={alert.actions as Snippet}
56
55
  ondismiss={() => startDismiss(alert.id)}
57
56
  />
58
57
  </div>
@@ -82,14 +82,38 @@
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(() => {
85
+ // Expand groups based on collapsible mode and active value.
86
+ // collapsible=false: groups are fixed section headers — always expanded.
87
+ // collapsible=true: only expand the ancestor group of the active value;
88
+ // all other groups start collapsed (user can toggle them).
89
+ function expandAllGroups() {
89
90
  for (const [, proxy] of wrapper.lookup) {
90
91
  if (proxy.hasChildren) proxy.expanded = true
91
92
  }
92
- })
93
+ }
94
+
95
+ function findActiveKey(): string | null {
96
+ for (const [key, proxy] of wrapper.lookup) {
97
+ if (proxy.value === value) return key
98
+ }
99
+ return null
100
+ }
101
+
102
+ function expandAncestorGroups(activeKey: string | null) {
103
+ for (const [key, proxy] of wrapper.lookup) {
104
+ if (!proxy.hasChildren) continue
105
+ proxy.expanded =
106
+ activeKey !== null &&
107
+ (activeKey === key || activeKey.startsWith(`${key }-`))
108
+ }
109
+ }
110
+
111
+ function syncExpandedGroups() {
112
+ if (!collapsible) { expandAllGroups(); return }
113
+ expandAncestorGroups(findActiveKey())
114
+ }
115
+
116
+ $effect(syncExpandedGroups)
93
117
 
94
118
  // ─── Sync external value → focused key ────────────────────────────────────
95
119
 
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ // @ts-nocheck
3
+ import { Wrapper, ProxyTree, messages } from '@rokkit/states'
4
+ import { Navigator } from '@rokkit/actions'
5
+
6
+ let {
7
+ options = [],
8
+ fields: userFields = {},
9
+ value = $bindable(),
10
+ multiple = false,
11
+ shape = 'square',
12
+ size = 'md',
13
+ disabled = false,
14
+ label = messages.current.swatch?.label ?? 'Select',
15
+ class: className = '',
16
+ onchange,
17
+ item: itemSnippet,
18
+ ..._rest
19
+ } = $props()
20
+
21
+ const proxyTree = $derived(new ProxyTree(options, userFields))
22
+ const wrapper = $derived(new Wrapper(proxyTree, { onselect: handleSelect }))
23
+
24
+ let containerRef = $state<HTMLElement | null>(null)
25
+
26
+ $effect(() => {
27
+ if (!containerRef) return
28
+ const nav = new Navigator(containerRef, wrapper, { orientation: 'horizontal' })
29
+ return () => nav.destroy()
30
+ })
31
+
32
+ $effect(() => {
33
+ wrapper.moveToValue(value)
34
+ })
35
+
36
+ function isSelected(proxy) {
37
+ if (multiple && Array.isArray(value)) return value.includes(proxy.value)
38
+ return proxy.value === value
39
+ }
40
+
41
+ function toggleMultiValue(extracted, original) {
42
+ const arr = Array.isArray(value) ? [...value] : []
43
+ const idx = arr.indexOf(extracted)
44
+ if (idx >= 0) arr.splice(idx, 1)
45
+ else arr.push(extracted)
46
+ value = arr
47
+ onchange?.(value, original)
48
+ }
49
+
50
+ function selectSingleValue(extracted, original) {
51
+ if (extracted === value) return
52
+ value = extracted
53
+ onchange?.(extracted, original)
54
+ }
55
+
56
+ function handleSelect(extracted, proxy) {
57
+ if (proxy.disabled || disabled) return
58
+ if (multiple) toggleMultiValue(extracted, proxy.original)
59
+ else selectSingleValue(extracted, proxy.original)
60
+ }
61
+ </script>
62
+
63
+ <div
64
+ bind:this={containerRef}
65
+ data-swatch
66
+ data-swatch-size={size}
67
+ data-swatch-shape={shape}
68
+ data-swatch-disabled={disabled || undefined}
69
+ data-swatch-multiple={multiple || undefined}
70
+ role={multiple ? 'group' : 'radiogroup'}
71
+ aria-label={label}
72
+ aria-disabled={disabled || undefined}
73
+ class={className || undefined}
74
+ >
75
+ {#each wrapper.flatView as node (node.key)}
76
+ {@const proxy = node.proxy}
77
+ {@const sel = isSelected(proxy)}
78
+ {@const fill = proxy.get(userFields.fill ?? 'fill')}
79
+ {@const stroke = proxy.get(userFields.stroke ?? 'stroke')}
80
+ <button
81
+ type="button"
82
+ data-swatch-item
83
+ data-path={node.key}
84
+ data-selected={sel || undefined}
85
+ data-disabled={proxy.disabled || undefined}
86
+ role={multiple ? 'checkbox' : 'radio'}
87
+ aria-checked={sel}
88
+ aria-label={proxy.label}
89
+ title={proxy.label}
90
+ disabled={proxy.disabled || disabled}
91
+ style={fill ? `--swatch-fill:${fill};--swatch-stroke:${stroke ?? fill}` : undefined}
92
+ >
93
+ {#if itemSnippet}
94
+ {@render itemSnippet(proxy, sel)}
95
+ {:else if shape === 'circle'}
96
+ <span data-swatch-circle></span>
97
+ {:else}
98
+ <span data-swatch-square></span>
99
+ {/if}
100
+ </button>
101
+ {/each}
102
+ </div>
@@ -10,6 +10,7 @@ export { default as Toolbar } from './Toolbar.svelte'
10
10
  export { default as ToolbarGroup } from './ToolbarGroup.svelte'
11
11
  export { default as Tabs } from './Tabs.svelte'
12
12
  export { default as Toggle } from './Toggle.svelte'
13
+ export { default as Swatch } from './Swatch.svelte'
13
14
  export { default as List } from './List.svelte'
14
15
  export { default as Tree } from './Tree.svelte'
15
16
  export { default as LazyTree } from './LazyTree.svelte'
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  ToolbarGroup,
12
12
  Tabs,
13
13
  Toggle,
14
+ Swatch,
14
15
  List,
15
16
  Tree,
16
17
  LazyTree,
@@ -42,6 +43,9 @@ export {
42
43
  AlertList
43
44
  } from './components/index.js'
44
45
 
46
+ export { default as MarkdownRenderer } from './MarkdownRenderer.svelte'
47
+ export type { MarkdownPlugin } from './markdown-plugin.js'
48
+
45
49
  // Utilities
46
50
  export { highlightCode, preloadHighlighter, getSupportedLanguages } from './utils/shiki.js'
47
51
  export * from './utils/palette.js'
@@ -0,0 +1,12 @@
1
+ import type { Component } from 'svelte'
2
+
3
+ /**
4
+ * A plugin that renders a fenced code block as a Svelte component.
5
+ * The component receives { code: string } as props.
6
+ */
7
+ export interface MarkdownPlugin {
8
+ /** Fenced code block language to match (e.g. 'plot', 'table', 'sparkline') */
9
+ language: string
10
+ /** Svelte component to render the block. Receives { code: string } */
11
+ component: Component<{ code: string }>
12
+ }
@@ -12,7 +12,7 @@ import type { Snippet } from 'svelte'
12
12
  // =============================================================================
13
13
 
14
14
  /** Semantic color variant */
15
- export type ButtonVariant = 'default' | 'primary' | 'secondary' | 'danger'
15
+ export type ButtonVariant = 'default' | 'primary' | 'secondary' | 'accent' | 'danger'
16
16
 
17
17
  /** Visual style treatment */
18
18
  export type ButtonStyle = 'default' | 'outline' | 'ghost' | 'gradient' | 'link'