lutaml-jsonschema 0.1.9 → 0.1.10

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d756394e76dba6d56e58308f5b5bf05dee2426f3ed1f06741db6d42e2b9b0722
4
- data.tar.gz: 1dda2ab2e8d89fc28c62df3a278d4bba0ef3e0bcb21a38176fc3816c4922927d
3
+ metadata.gz: c2817082f5751466a6e7bae6f77310304d3b58127a392c1e60341d561b65706a
4
+ data.tar.gz: 2c6f4b882f26600890a7a21a241e3a3709fa155df21097ca3c484513cef5e4ba
5
5
  SHA512:
6
- metadata.gz: 24cc7e89987cd5d49cf5bda35a6a43af05f6de286755d2f324128eca05d643972095eb56d9375406f714ebf11f2b5731563d02f2c8a20f6972fc056ba0727b94
7
- data.tar.gz: 9d65c1d9e86e2321d561f5853ac2d1617231b267f0ff0d32dbeab6e28f6b4067ecf1fc5fa8458df7db49fbe6dc4fc0e6cf8664abaf5f4a714b7a48cb3ca2442c
6
+ metadata.gz: 4f1ca6699188d725ac053c230043316a96dbdb1fda2cf1c880a48e848ab31c6d4270e19f37f392c10d3205eafb27f529f5443f00fa39d20ec5f7f06dc32daa64
7
+ data.tar.gz: 905835a47ca2348e8bd2cd5b5f2bc526a115782fbd3264c61b212e7c61192e87d6dbd1e9193f0d9598c9f4bf28916f410cf8fcee0919f1b747cf8f6119ac0516
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.10] - 2026-05-09
4
+
5
+ ### Redoc-style UX improvements
6
+
7
+ - JSON viewer collapsed objects show key names (e.g. `{a, b, c}`), arrays show item count
8
+ - Sidebar auto-scrolls to active schema node on selection
9
+ - Search results sorted by relevance: exact name > starts with > contains > description
10
+ - Copy button shows positioned tooltip overlay instead of text swap
11
+ - Mobile sidebar backdrop overlay (click-to-close)
12
+ - DetailPanel overview shows definition-level metadata: required fields, composition badges, property range, additionalProperties
13
+ - Nullable badge for union types (e.g. `string,null`) in SchemaBuilder
14
+ - Focus trap in DetailPanel: focus moves to panel on open, Tab/Shift+Tab trapped, Escape closes
15
+
3
16
  ## [0.1.0] - 2026-05-05
4
17
 
5
18
  - Initial release
data/frontend/src/App.vue CHANGED
@@ -1,6 +1,7 @@
1
1
  <template>
2
2
  <div class="app-layout">
3
3
  <AppSidebar />
4
+ <div v-if="!uiStore.sidebarCollapsed" class="sidebar-overlay" @click="uiStore.toggleSidebar"></div>
4
5
  <div class="main-content">
5
6
  <AppHeader />
6
7
  <div class="content-area">
@@ -100,4 +101,19 @@ function isInputFocused(): boolean {
100
101
  padding: var(--space-6);
101
102
  background: var(--bg-primary);
102
103
  }
104
+
105
+ /* Mobile sidebar overlay — hidden on desktop, shown on mobile when sidebar open */
106
+ .sidebar-overlay {
107
+ display: none;
108
+ }
109
+
110
+ @media (max-width: 768px) {
111
+ .sidebar-overlay {
112
+ display: block;
113
+ position: fixed;
114
+ inset: 0;
115
+ background: rgba(0, 0, 0, 0.4);
116
+ z-index: 39;
117
+ }
118
+ }
103
119
  </style>
@@ -69,7 +69,7 @@
69
69
  <div v-else-if="debouncedQuery && !searchResults.length && searchQuery" class="search-no-results text-muted">
70
70
  No results found
71
71
  </div>
72
- <div v-else class="schema-tree">
72
+ <div v-else class="schema-tree" ref="sidebarTreeRef">
73
73
  <div
74
74
  v-for="schema in schemaStore.schemas"
75
75
  :key="schema.name"
@@ -150,13 +150,14 @@
150
150
  </template>
151
151
 
152
152
  <script setup lang="ts">
153
- import { ref, computed, watch } from 'vue'
153
+ import { ref, computed, watch, nextTick } from 'vue'
154
154
  import { useSchemaStore } from '../stores/schemaStore'
155
155
  import { useUiStore } from '../stores/uiStore'
156
156
  import type { SpaSearchEntry } from '../types'
157
157
 
158
158
  const schemaStore = useSchemaStore()
159
159
  const uiStore = useUiStore()
160
+ const sidebarTreeRef = ref<HTMLElement | null>(null)
160
161
 
161
162
  const searchQuery = ref('')
162
163
  const debouncedQuery = ref('')
@@ -177,16 +178,41 @@ watch(searchQuery, (q) => {
177
178
  const searchResults = computed(() => {
178
179
  const q = debouncedQuery.value.trim().toLowerCase()
179
180
  if (!q) return []
180
- return schemaStore.searchIndex.filter(entry =>
181
- entry.name.toLowerCase().includes(q) ||
182
- (entry.title && entry.title.toLowerCase().includes(q)) ||
183
- entry.schemaName.toLowerCase().includes(q) ||
184
- (entry.description && entry.description.toLowerCase().includes(q)),
185
- ).slice(0, 15)
181
+ const scored = schemaStore.searchIndex
182
+ .map(entry => {
183
+ let score = 0
184
+ const nameLower = entry.name.toLowerCase()
185
+ const titleLower = (entry.title || '').toLowerCase()
186
+ const descLower = (entry.description || '').toLowerCase()
187
+ const schemaLower = entry.schemaName.toLowerCase()
188
+ if (nameLower === q) score += 100
189
+ else if (nameLower.startsWith(q)) score += 50
190
+ else if (nameLower.includes(q)) score += 30
191
+ if (titleLower === q) score += 80
192
+ else if (titleLower.startsWith(q)) score += 40
193
+ else if (titleLower.includes(q)) score += 20
194
+ if (descLower.includes(q)) score += 10
195
+ if (schemaLower.includes(q)) score += 5
196
+ return { entry, score }
197
+ })
198
+ .filter(r => r.score > 0)
199
+ .sort((a, b) => b.score - a.score)
200
+ .slice(0, 15)
201
+ .map(r => r.entry)
202
+ return scored
186
203
  })
187
204
 
188
205
  watch(searchResults, () => { activeResultIdx.value = -1 })
189
206
 
207
+ watch(() => schemaStore.selectedSchemaName, () => {
208
+ nextTick(() => {
209
+ const container = sidebarTreeRef.value
210
+ if (!container) return
211
+ const active = container.querySelector('.schema-node-header.active') as HTMLElement | null
212
+ if (active) active.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
213
+ })
214
+ })
215
+
190
216
  function handleSearchKey(event: KeyboardEvent) {
191
217
  if (event.key === 'ArrowDown') {
192
218
  activeResultIdx.value = Math.min(activeResultIdx.value + 1, searchResults.value.length - 1)
@@ -1,11 +1,11 @@
1
1
  <template>
2
2
  <div class="detail-panel-overlay" @click.self="uiStore.closeDetailPanel">
3
- <aside class="detail-panel">
3
+ <aside class="detail-panel" ref="panelRef">
4
4
  <div class="panel-header">
5
5
  <div class="panel-title">
6
6
  <h2 v-if="item">{{ itemTitle }}</h2>
7
7
  </div>
8
- <button class="btn btn-ghost" @click="uiStore.closeDetailPanel">
8
+ <button ref="closeBtnRef" class="btn btn-ghost" @click="uiStore.closeDetailPanel" aria-label="Close panel">
9
9
  <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
10
10
  <path d="M5 5l8 8M13 5l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
11
11
  </svg>
@@ -98,6 +98,32 @@
98
98
  <span class="meta-label">Additional</span>
99
99
  <span class="badge badge-locked-detail">Denied</span>
100
100
  </div>
101
+ <div v-if="definitionItem && definitionItem.required?.length" class="meta-row">
102
+ <span class="meta-label">Required</span>
103
+ <div class="meta-tags">
104
+ <span v-for="r in definitionItem.required" :key="r" class="badge badge-required-sm">{{ r }}</span>
105
+ </div>
106
+ </div>
107
+ <div v-if="definitionItem && (definitionItem.hasAllOf || definitionItem.hasAnyOf || definitionItem.hasOneOf)" class="meta-row">
108
+ <span class="meta-label">Composition</span>
109
+ <div class="meta-tags">
110
+ <span v-if="definitionItem.hasAllOf" class="badge badge-composition-detail">allOf</span>
111
+ <span v-if="definitionItem.hasAnyOf" class="badge badge-composition-detail">anyOf</span>
112
+ <span v-if="definitionItem.hasOneOf" class="badge badge-composition-detail">oneOf</span>
113
+ </div>
114
+ </div>
115
+ <div v-if="definitionItem && (definitionItem.minProperties != null || definitionItem.maxProperties != null)" class="meta-row">
116
+ <span class="meta-label">Properties</span>
117
+ <span class="text-secondary">
118
+ <template v-if="definitionItem.minProperties != null && definitionItem.maxProperties != null">{{ definitionItem.minProperties }}..{{ definitionItem.maxProperties }}</template>
119
+ <template v-else-if="definitionItem.minProperties != null">&ge; {{ definitionItem.minProperties }}</template>
120
+ <template v-else>&le; {{ definitionItem.maxProperties }}</template>
121
+ </span>
122
+ </div>
123
+ <div v-if="definitionItem && definitionItem.additionalProperties === false" class="meta-row">
124
+ <span class="meta-label">Additional</span>
125
+ <span class="badge badge-locked-detail">Denied</span>
126
+ </div>
101
127
  </div>
102
128
  </div>
103
129
 
@@ -241,7 +267,7 @@
241
267
  </template>
242
268
 
243
269
  <script setup lang="ts">
244
- import { computed } from 'vue'
270
+ import { computed, ref, onMounted, onUnmounted } from 'vue'
245
271
  import { useSchemaStore, type SelectedItem } from '../stores/schemaStore'
246
272
  import { useUiStore } from '../stores/uiStore'
247
273
  import type { SpaProperty } from '../types'
@@ -249,6 +275,33 @@ import type { SpaProperty } from '../types'
249
275
  const schemaStore = useSchemaStore()
250
276
  const uiStore = useUiStore()
251
277
 
278
+ const panelRef = ref<HTMLElement | null>(null)
279
+ const closeBtnRef = ref<HTMLButtonElement | null>(null)
280
+
281
+ onMounted(() => {
282
+ closeBtnRef.value?.focus()
283
+ document.addEventListener('keydown', trapFocus)
284
+ })
285
+
286
+ onUnmounted(() => {
287
+ document.removeEventListener('keydown', trapFocus)
288
+ })
289
+
290
+ function trapFocus(e: KeyboardEvent) {
291
+ if (e.key !== 'Tab' || !panelRef.value) return
292
+ const focusable = panelRef.value.querySelectorAll<HTMLElement>(
293
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
294
+ )
295
+ if (!focusable.length) return
296
+ const first = focusable[0]
297
+ const last = focusable[focusable.length - 1]
298
+ if (e.shiftKey) {
299
+ if (document.activeElement === first) { e.preventDefault(); last.focus() }
300
+ } else {
301
+ if (document.activeElement === last) { e.preventDefault(); first.focus() }
302
+ }
303
+ }
304
+
252
305
  const item = computed<SelectedItem | null>(() => schemaStore.selectedItem)
253
306
  const schema = computed(() => schemaStore.selectedSchema)
254
307
 
@@ -379,6 +432,11 @@ const propertyItem = computed<SpaProperty | null>(() => {
379
432
  if (item.value?.kind !== 'property') return null
380
433
  return item.value.property
381
434
  })
435
+
436
+ const definitionItem = computed(() => {
437
+ if (item.value?.kind !== 'definition') return null
438
+ return item.value.definition
439
+ })
382
440
  </script>
383
441
 
384
442
  <style scoped>
@@ -25,6 +25,7 @@
25
25
  <span v-if="field.prop.deprecated" class="deprecated-badge">deprecated</span>
26
26
  <span v-if="field.prop.readOnly" class="readonly-badge">read-only</span>
27
27
  <span v-if="field.prop.writeOnly" class="writeonly-badge">write-only</span>
28
+ <span v-if="isNullableType(field.prop.type)" class="nullable-badge">nullable</span>
28
29
  <span v-if="field.prop.compositionSource" class="composition-badge">{{ field.prop.compositionSource }}</span>
29
30
 
30
31
  <div class="field-control">
@@ -209,8 +210,9 @@
209
210
  <div class="toolbar-actions">
210
211
  <button class="btn btn-ghost btn-sm" @click="expandAllJson">Expand all</button>
211
212
  <button class="btn btn-ghost btn-sm" @click="collapseAllJson">Collapse all</button>
212
- <button class="btn btn-ghost btn-sm" @click="copyJson">
213
- {{ copied ? 'Copied!' : 'Copy' }}
213
+ <button class="btn btn-ghost btn-sm copy-btn-wrap" @click="copyJson">
214
+ Copy
215
+ <span v-if="copied" class="copy-tooltip">Copied!</span>
214
216
  </button>
215
217
  </div>
216
218
  </div>
@@ -405,6 +407,10 @@ function typeBadgeClass(prop: SpaProperty): string {
405
407
  }
406
408
  }
407
409
 
410
+ function isNullableType(type?: string): boolean {
411
+ return (type || '').split(',').map(s => s.trim()).includes('null')
412
+ }
413
+
408
414
  function truncatedPattern(pattern: string): { text: string; truncated: boolean } {
409
415
  if (pattern.length <= MAX_PATTERN_LEN) return { text: pattern, truncated: false }
410
416
  if (expandedPatterns.value.has(pattern)) return { text: pattern, truncated: true }
@@ -583,6 +589,18 @@ async function copyJson() {
583
589
  background: var(--color-orange-alpha);
584
590
  }
585
591
 
592
+ .nullable-badge {
593
+ font-size: 10px;
594
+ font-weight: 600;
595
+ text-transform: uppercase;
596
+ color: var(--text-muted);
597
+ background: var(--bg-secondary);
598
+ border: 1px dashed var(--border-medium);
599
+ padding: 1px 5px;
600
+ border-radius: 2px;
601
+ flex-shrink: 0;
602
+ }
603
+
586
604
  .composition-badge {
587
605
  font-size: 10px;
588
606
  font-weight: 600;
@@ -1048,13 +1066,10 @@ async function copyJson() {
1048
1066
  display: none;
1049
1067
  color: var(--text-muted);
1050
1068
  font-size: var(--text-xs);
1069
+ font-style: italic;
1051
1070
  margin-left: 4px;
1052
1071
  }
1053
1072
 
1054
- .json-block :deep(.jv-ellipsis::after) {
1055
- content: ' … ';
1056
- }
1057
-
1058
1073
  .json-block :deep(.jv-children.jv-collapsed) {
1059
1074
  display: none;
1060
1075
  }
@@ -1106,6 +1121,33 @@ async function copyJson() {
1106
1121
  gap: var(--space-1);
1107
1122
  }
1108
1123
 
1124
+ /* Copy button tooltip */
1125
+ .copy-btn-wrap {
1126
+ position: relative;
1127
+ }
1128
+
1129
+ .copy-tooltip {
1130
+ position: absolute;
1131
+ bottom: calc(100% + 4px);
1132
+ left: 50%;
1133
+ transform: translateX(-50%);
1134
+ background: var(--bg-elevated);
1135
+ color: var(--text-primary);
1136
+ font-size: var(--text-xs);
1137
+ padding: 3px 8px;
1138
+ border-radius: var(--radius-sm);
1139
+ border: 1px solid var(--border-light);
1140
+ box-shadow: var(--shadow-md);
1141
+ white-space: nowrap;
1142
+ pointer-events: none;
1143
+ animation: tooltipFade var(--transition-slow);
1144
+ }
1145
+
1146
+ @keyframes tooltipFade {
1147
+ from { opacity: 0; transform: translateX(-50%) translateY(2px); }
1148
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
1149
+ }
1150
+
1109
1151
  .empty-hint {
1110
1152
  padding: var(--space-8);
1111
1153
  text-align: center;
@@ -56,7 +56,7 @@ function renderArray(arr: unknown[], maxExpand: number, level: number): string {
56
56
  html += '</div></li>'
57
57
  })
58
58
  html += '</ul>'
59
- html += `<span class="jv-ellipsis"></span>`
59
+ html += `<span class="jv-ellipsis">${arr.length} items</span>`
60
60
  html += punct(']')
61
61
  return html
62
62
  }
@@ -76,7 +76,8 @@ function renderObject(obj: Record<string, unknown>, maxExpand: number, level: nu
76
76
  html += '</div></li>'
77
77
  })
78
78
  html += '</ul>'
79
- html += `<span class="jv-ellipsis"></span>`
79
+ const previewKeys = keys.length <= 5 ? keys : [...keys.slice(0, 5), `+${keys.length - 5}`]
80
+ html += `<span class="jv-ellipsis">${previewKeys.map(k => escHtml(k)).join(', ')}</span>`
80
81
  html += punct('}')
81
82
  return html
82
83
  }
@@ -136,8 +136,9 @@
136
136
  <div v-if="schemaStore.selectedSchema.sourceJson" class="source-viewer">
137
137
  <div class="source-toolbar">
138
138
  <span class="text-muted">{{ sourceLineCount }} lines</span>
139
- <button class="btn btn-ghost btn-sm" @click="copySource">
140
- {{ sourceCopied ? 'Copied!' : 'Copy' }}
139
+ <button class="btn btn-ghost btn-sm copy-btn-wrap" @click="copySource">
140
+ Copy
141
+ <span v-if="sourceCopied" class="copy-tooltip">Copied!</span>
141
142
  </button>
142
143
  </div>
143
144
  <div class="source-code-wrapper">
@@ -592,6 +593,33 @@ watch(() => schemaStore.selectedItemKey, (key) => {
592
593
  font-size: var(--text-xs);
593
594
  }
594
595
 
596
+ /* Copy button tooltip */
597
+ .copy-btn-wrap {
598
+ position: relative;
599
+ }
600
+
601
+ .copy-tooltip {
602
+ position: absolute;
603
+ bottom: calc(100% + 4px);
604
+ left: 50%;
605
+ transform: translateX(-50%);
606
+ background: var(--bg-elevated);
607
+ color: var(--text-primary);
608
+ font-size: var(--text-xs);
609
+ padding: 3px 8px;
610
+ border-radius: var(--radius-sm);
611
+ border: 1px solid var(--border-light);
612
+ box-shadow: var(--shadow-md);
613
+ white-space: nowrap;
614
+ pointer-events: none;
615
+ animation: tooltipFade var(--transition-slow);
616
+ }
617
+
618
+ @keyframes tooltipFade {
619
+ from { opacity: 0; transform: translateX(-50%) translateY(2px); }
620
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
621
+ }
622
+
595
623
  .source-code-wrapper {
596
624
  display: flex;
597
625
  max-height: 70vh;
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Jsonschema
5
- VERSION = "0.1.9"
5
+ VERSION = "0.1.10"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-jsonschema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-08 00:00:00.000000000 Z
11
+ date: 2026-05-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json