picocrank 1.24.0 → 1.25.1

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 (41) hide show
  1. package/AGENTS.md +2 -0
  2. package/docs/filter-query-v1.md +180 -0
  3. package/logo.svg +7 -7
  4. package/package.json +7 -5
  5. package/public/themes/README.md +34 -2
  6. package/public/themes/index.json +1 -5
  7. package/styles/dark-theme.css +42 -186
  8. package/styles/picocrank-extensions.css +15 -0
  9. package/styles.css +1 -0
  10. package/supplemental-themes/README.md +9 -0
  11. package/supplemental-themes/catppuccin-latte-frappe/theme.css +211 -0
  12. package/supplemental-themes/dracula-alucard/theme.css +193 -0
  13. package/supplemental-themes/gruvbox-dark-light/theme.css +199 -0
  14. package/supplemental-themes/index.json +17 -0
  15. package/{public/themes → supplemental-themes}/waffles/theme.css +69 -46
  16. package/vite.config.js +91 -0
  17. package/vue/components/Header.vue +3 -3
  18. package/vue/components/Login.vue +1 -3
  19. package/vue/components/Section.vue +45 -14
  20. package/vue/components/Sidebar.vue +6 -11
  21. package/vue/components/SidebarNavLink.vue +6 -9
  22. package/vue/components/Table.vue +1115 -75
  23. package/vue/components/TableColumnFilterPopover.vue +263 -44
  24. package/vue/components/TableColumnOptionsPopover.vue +1116 -0
  25. package/vue/components/Tabs.vue +4 -25
  26. package/vue/components/ThemeSwitcher.vue +158 -0
  27. package/vue/components/TopBar.vue +2 -2
  28. package/vue/composables/tableColumnPresets.js +296 -0
  29. package/vue/composables/tableFilterQuery.js +202 -0
  30. package/vue/composables/tableFilters.js +167 -3
  31. package/vue/composables/useCustomTheme.js +167 -24
  32. package/vue/data/examplePeople.js +2 -6
  33. package/vue/examples/App.vue +14 -1
  34. package/vue/examples/ExampleHeaderThemeSwitcher.vue +78 -0
  35. package/vue/examples/LoginExample.vue +1 -3
  36. package/vue/examples/PageStructure.vue +17 -0
  37. package/vue/examples/TableExample.vue +329 -11
  38. package/vue/examples/TableRemoteExample.vue +112 -4
  39. package/vue/examples/ThemePreview.vue +45 -38
  40. package/public/themes/catppuccin/theme.css +0 -195
  41. package/public/themes/dracula/theme.css +0 -181
@@ -146,7 +146,7 @@ defineExpose({
146
146
  border-radius: 0;
147
147
  cursor: pointer;
148
148
  font-size: 1em;
149
- color: var(--text-muted, #666);
149
+ color: var(--tab-fg);
150
150
  transition: all 0.2s ease;
151
151
  white-space: nowrap;
152
152
  position: relative;
@@ -158,13 +158,13 @@ defineExpose({
158
158
  }
159
159
 
160
160
  .tab-button.active {
161
- color: var(--primary-color, #007bff);
162
- border-bottom-color: var(--primary-color, #007bff);
161
+ color: var(--tab-active-fg);
162
+ border-bottom-color: var(--tab-active-fg);
163
163
  font-weight: bold;
164
164
  }
165
165
 
166
166
  .tab-button:focus {
167
- outline: 2px solid var(--primary-color, #007bff);
167
+ outline: 2px solid var(--tab-focus-outline-color);
168
168
  outline-offset: -2px;
169
169
  border-radius: 0;
170
170
  }
@@ -196,27 +196,6 @@ defineExpose({
196
196
  }
197
197
  }
198
198
 
199
- /* Dark theme support */
200
- html[data-theme="dark"] {
201
- .tabs-header {
202
- border-bottom-color: var(--border-color, #444);
203
- }
204
-
205
- .tab-button {
206
- color: var(--text-muted, #999);
207
- }
208
-
209
- .tab-button:hover {
210
- color: var(--text-color, #fff);
211
- background-color: var(--hover-background-color, #333);
212
- }
213
-
214
- .tab-button.active {
215
- color: var(--primary-color, #60a5fa);
216
- border-bottom-color: var(--primary-color, #60a5fa);
217
- }
218
- }
219
-
220
199
  /* Responsive */
221
200
  @media (max-width: 768px) {
222
201
  .tabs-header {
@@ -0,0 +1,158 @@
1
+ <template>
2
+ <label :class="['theme-switcher', { 'theme-switcher--compact': compact }]">
3
+ <span v-if="label" class="theme-switcher-label">{{ label }}</span>
4
+ <select
5
+ ref="selectRef"
6
+ :id="selectId || undefined"
7
+ :value="themePreference"
8
+ :aria-label="compact ? (label || 'Active theme') : undefined"
9
+ @change="onThemeChange"
10
+ >
11
+ <option value="">{{ defaultOptionLabel }}</option>
12
+ <option
13
+ v-for="name in availableThemes"
14
+ :key="name"
15
+ :value="name"
16
+ >
17
+ {{ themeLabels[name] || name }}
18
+ </option>
19
+ </select>
20
+ </label>
21
+ <p v-if="showEmptyHint && !compact && availableThemes.length === 0" class="subtle theme-switcher-hint">
22
+ <slot name="empty">
23
+ No drop-in themes found. Add <code>public/themes/&lt;name&gt;/theme.css</code>
24
+ or set <code>include-supplemental-themes</code> to list bundled supplemental themes.
25
+ </slot>
26
+ </p>
27
+ </template>
28
+
29
+ <script>
30
+ export const HEADER_THEME_SWITCHER_SELECT_ID = 'picocrank-header-theme-switcher'
31
+ </script>
32
+
33
+ <script setup>
34
+ import { onMounted, ref, watch } from 'vue'
35
+ import { useCustomTheme } from '../composables/useCustomTheme.js'
36
+
37
+ const props = defineProps({
38
+ label: {
39
+ type: String,
40
+ default: 'Active theme',
41
+ },
42
+ defaultOptionLabel: {
43
+ type: String,
44
+ default: 'Default (Femtocrank only)',
45
+ },
46
+ includeSupplementalThemes: {
47
+ type: Boolean,
48
+ default: false,
49
+ },
50
+ themesBasePath: {
51
+ type: String,
52
+ default: undefined,
53
+ },
54
+ supplementalThemesBasePath: {
55
+ type: String,
56
+ default: undefined,
57
+ },
58
+ storageKey: {
59
+ type: String,
60
+ default: undefined,
61
+ },
62
+ showEmptyHint: {
63
+ type: Boolean,
64
+ default: false,
65
+ },
66
+ compact: {
67
+ type: Boolean,
68
+ default: false,
69
+ },
70
+ selectId: {
71
+ type: String,
72
+ default: undefined,
73
+ },
74
+ })
75
+
76
+ const emit = defineEmits(['change'])
77
+
78
+ const {
79
+ availableThemes,
80
+ themeLabels,
81
+ themePreference,
82
+ setTheme,
83
+ discoverThemes,
84
+ configure,
85
+ } = useCustomTheme()
86
+
87
+ const selectRef = ref(null)
88
+
89
+ function focusSelect() {
90
+ selectRef.value?.focus()
91
+ selectRef.value?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
92
+ }
93
+
94
+ defineExpose({
95
+ focus: focusSelect,
96
+ })
97
+
98
+ function syncOptions() {
99
+ configure({
100
+ includeSupplementalThemes: props.includeSupplementalThemes,
101
+ themesBasePath: props.themesBasePath,
102
+ supplementalThemesBasePath: props.supplementalThemesBasePath,
103
+ storageKey: props.storageKey,
104
+ })
105
+ }
106
+
107
+ function onThemeChange(event) {
108
+ const value = event.target.value
109
+ setTheme(value)
110
+ emit('change', value)
111
+ }
112
+
113
+ watch(
114
+ () => [
115
+ props.includeSupplementalThemes,
116
+ props.themesBasePath,
117
+ props.supplementalThemesBasePath,
118
+ props.storageKey,
119
+ ],
120
+ () => {
121
+ syncOptions()
122
+ void discoverThemes()
123
+ },
124
+ )
125
+
126
+ onMounted(() => {
127
+ syncOptions()
128
+ void discoverThemes()
129
+ })
130
+ </script>
131
+
132
+ <style scoped>
133
+ .theme-switcher {
134
+ display: flex;
135
+ flex-wrap: wrap;
136
+ align-items: center;
137
+ gap: 0.75rem;
138
+ margin: 0;
139
+ }
140
+
141
+ .theme-switcher select {
142
+ min-width: 14rem;
143
+ }
144
+
145
+ .theme-switcher--compact {
146
+ flex-wrap: nowrap;
147
+ gap: 0;
148
+ }
149
+
150
+ .theme-switcher--compact select {
151
+ min-width: 10rem;
152
+ max-width: 13.5rem;
153
+ }
154
+
155
+ .theme-switcher-hint {
156
+ margin: 0.75rem 0 0;
157
+ }
158
+ </style>
@@ -97,7 +97,7 @@ function handleLinkClick(callback = null) {
97
97
  padding: 0.5em 0.75em;
98
98
  border-radius: 0.25em;
99
99
  text-decoration: none;
100
- color: inherit;
100
+ color: var(--header-fg-color, var(--header-text-color, inherit));
101
101
  transition: background-color 0.15s ease;
102
102
  white-space: nowrap;
103
103
  border: 0;
@@ -124,7 +124,7 @@ function handleLinkClick(callback = null) {
124
124
 
125
125
  html[data-theme="dark"] {
126
126
  .topbar-link {
127
- color: #f8f9fa;
127
+ color: var(--header-fg-color, var(--header-text-color, #f8f9fa));
128
128
  }
129
129
 
130
130
  .separator {
@@ -0,0 +1,296 @@
1
+ import {
2
+ normalizeColumnFilterEntries,
3
+ normalizeStoredFilterEntry,
4
+ } from './tableFilters.js'
5
+
6
+ export const PRESET_STORE_VERSION = 1
7
+ export const MAX_PRESETS_PER_TABLE = 20
8
+ export const ALLOWED_PAGE_SIZES = [10, 25, 50, 100]
9
+
10
+ let presetIdCounter = 0
11
+
12
+ export function createPresetId() {
13
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
14
+ return `preset-${crypto.randomUUID()}`
15
+ }
16
+
17
+ presetIdCounter += 1
18
+ return `preset-${presetIdCounter}-${Date.now()}`
19
+ }
20
+
21
+ export function getPresetStorageKey(tableId) {
22
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'ssr'
23
+ return `picocrank-table-layouts:v${PRESET_STORE_VERSION}:${origin}:${tableId}`
24
+ }
25
+
26
+ function createEmptyStore() {
27
+ return {
28
+ version: PRESET_STORE_VERSION,
29
+ defaultPresetId: null,
30
+ presets: [],
31
+ }
32
+ }
33
+
34
+ function normalizeStore(raw) {
35
+ if (!raw || typeof raw !== 'object') {
36
+ return createEmptyStore()
37
+ }
38
+
39
+ const presets = Array.isArray(raw.presets)
40
+ ? raw.presets.filter((preset) => preset?.id && preset?.name && preset?.state)
41
+ : []
42
+
43
+ const defaultPresetId = typeof raw.defaultPresetId === 'string'
44
+ && presets.some((preset) => preset.id === raw.defaultPresetId)
45
+ ? raw.defaultPresetId
46
+ : null
47
+
48
+ return {
49
+ version: PRESET_STORE_VERSION,
50
+ defaultPresetId,
51
+ presets,
52
+ }
53
+ }
54
+
55
+ export function readPresetStore(tableId) {
56
+ if (!tableId || typeof localStorage === 'undefined') {
57
+ return createEmptyStore()
58
+ }
59
+
60
+ try {
61
+ const raw = localStorage.getItem(getPresetStorageKey(tableId))
62
+ if (!raw) {
63
+ return createEmptyStore()
64
+ }
65
+ return normalizeStore(JSON.parse(raw))
66
+ } catch {
67
+ return createEmptyStore()
68
+ }
69
+ }
70
+
71
+ export function writePresetStore(tableId, store) {
72
+ if (!tableId || typeof localStorage === 'undefined') {
73
+ return false
74
+ }
75
+
76
+ localStorage.setItem(getPresetStorageKey(tableId), JSON.stringify(normalizeStore(store)))
77
+ return true
78
+ }
79
+
80
+ export function listPresets(tableId) {
81
+ const store = readPresetStore(tableId)
82
+ return {
83
+ presets: store.presets.map((preset) => ({ ...preset })),
84
+ defaultPresetId: store.defaultPresetId,
85
+ }
86
+ }
87
+
88
+ export function getDefaultPreset(tableId) {
89
+ const store = readPresetStore(tableId)
90
+ if (!store.defaultPresetId) {
91
+ return null
92
+ }
93
+ return store.presets.find((preset) => preset.id === store.defaultPresetId) ?? null
94
+ }
95
+
96
+ function isValidColPriority(priority) {
97
+ return Number.isInteger(priority) && priority >= 1 && priority <= 5
98
+ }
99
+
100
+ function headerMaps(headers = []) {
101
+ const headerByKey = new Map()
102
+ const manageableKeys = []
103
+ const configurableKeys = []
104
+
105
+ for (const header of headers) {
106
+ if (!header?.key || header.hidden) {
107
+ continue
108
+ }
109
+ manageableKeys.push(header.key)
110
+ headerByKey.set(header.key, header)
111
+ if (header.hideable !== false) {
112
+ configurableKeys.push(header.key)
113
+ }
114
+ }
115
+
116
+ return { headerByKey, manageableKeys, configurableKeys }
117
+ }
118
+
119
+ export function sanitizePresetState(state, headers = [], rows = []) {
120
+ if (!state || typeof state !== 'object') {
121
+ return null
122
+ }
123
+
124
+ const { headerByKey, manageableKeys, configurableKeys } = headerMaps(headers)
125
+ const sanitized = {}
126
+
127
+ if (Array.isArray(state.columnOrder)) {
128
+ const fromOrder = state.columnOrder.filter((key) => manageableKeys.includes(key))
129
+ const missing = manageableKeys.filter((key) => !fromOrder.includes(key))
130
+ sanitized.columnOrder = [...fromOrder, ...missing]
131
+ }
132
+
133
+ if (state.columnVisibility && typeof state.columnVisibility === 'object') {
134
+ sanitized.columnVisibility = Object.fromEntries(
135
+ configurableKeys.map((key) => [
136
+ key,
137
+ state.columnVisibility[key] !== false,
138
+ ]),
139
+ )
140
+ }
141
+
142
+ if (state.columnPriorities && typeof state.columnPriorities === 'object') {
143
+ const priorities = {}
144
+ for (const [key, value] of Object.entries(state.columnPriorities)) {
145
+ if (!manageableKeys.includes(key)) {
146
+ continue
147
+ }
148
+ if (value === null || value === '') {
149
+ priorities[key] = null
150
+ continue
151
+ }
152
+ if (isValidColPriority(value)) {
153
+ priorities[key] = value
154
+ }
155
+ }
156
+ sanitized.columnPriorities = priorities
157
+ }
158
+
159
+ if (state.filters && typeof state.filters === 'object') {
160
+ const filters = {}
161
+ for (const [key, columnFilters] of Object.entries(state.filters)) {
162
+ const header = headerByKey.get(key)
163
+ if (!header || header.filterable === false) {
164
+ continue
165
+ }
166
+ const entries = normalizeColumnFilterEntries(columnFilters, header, rows)
167
+ .map((entry) => normalizeStoredFilterEntry(header, entry, rows))
168
+ .filter(Boolean)
169
+ if (entries.length > 0) {
170
+ filters[key] = entries
171
+ }
172
+ }
173
+ sanitized.filters = filters
174
+ }
175
+
176
+ if (state.sortBy) {
177
+ const header = headerByKey.get(state.sortBy)
178
+ if (header?.sortable) {
179
+ sanitized.sortBy = state.sortBy
180
+ sanitized.sortDir = state.sortDir === 'desc' ? 'desc' : 'asc'
181
+ }
182
+ }
183
+
184
+ const rawPageSize = Number(state.pageSize)
185
+ if (Number.isFinite(rawPageSize)) {
186
+ const normalizedPageSize = Math.floor(rawPageSize)
187
+ if (ALLOWED_PAGE_SIZES.includes(normalizedPageSize)) {
188
+ sanitized.pageSize = normalizedPageSize
189
+ }
190
+ }
191
+
192
+ return sanitized
193
+ }
194
+
195
+ export function savePreset(tableId, { name, state, setAsDefault = false }) {
196
+ const trimmedName = String(name ?? '').trim()
197
+ if (!tableId) {
198
+ return { ok: false, error: 'Table identity is required.' }
199
+ }
200
+ if (!trimmedName) {
201
+ return { ok: false, error: 'Preset name is required.' }
202
+ }
203
+ if (!state || typeof state !== 'object') {
204
+ return { ok: false, error: 'Nothing to save.' }
205
+ }
206
+
207
+ const store = readPresetStore(tableId)
208
+ if (store.presets.length >= MAX_PRESETS_PER_TABLE) {
209
+ return { ok: false, error: `Maximum of ${MAX_PRESETS_PER_TABLE} saved layouts reached.` }
210
+ }
211
+
212
+ const preset = {
213
+ id: createPresetId(),
214
+ name: trimmedName,
215
+ savedAt: new Date().toISOString(),
216
+ state: JSON.parse(JSON.stringify(state)),
217
+ }
218
+
219
+ store.presets.push(preset)
220
+ if (setAsDefault) {
221
+ store.defaultPresetId = preset.id
222
+ }
223
+
224
+ writePresetStore(tableId, store)
225
+ return { ok: true, preset, defaultPresetId: store.defaultPresetId }
226
+ }
227
+
228
+ export function deletePreset(tableId, presetId) {
229
+ if (!tableId || !presetId) {
230
+ return { ok: false, error: 'Preset not found.' }
231
+ }
232
+
233
+ const store = readPresetStore(tableId)
234
+ const nextPresets = store.presets.filter((preset) => preset.id !== presetId)
235
+ if (nextPresets.length === store.presets.length) {
236
+ return { ok: false, error: 'Preset not found.' }
237
+ }
238
+
239
+ store.presets = nextPresets
240
+ if (store.defaultPresetId === presetId) {
241
+ store.defaultPresetId = null
242
+ }
243
+
244
+ writePresetStore(tableId, store)
245
+ return { ok: true, defaultPresetId: store.defaultPresetId }
246
+ }
247
+
248
+ export function setDefaultPreset(tableId, presetId) {
249
+ if (!tableId) {
250
+ return { ok: false, error: 'Table identity is required.' }
251
+ }
252
+
253
+ const store = readPresetStore(tableId)
254
+ if (presetId === null) {
255
+ store.defaultPresetId = null
256
+ writePresetStore(tableId, store)
257
+ return { ok: true, defaultPresetId: null }
258
+ }
259
+
260
+ const preset = store.presets.find((item) => item.id === presetId)
261
+ if (!preset) {
262
+ return { ok: false, error: 'Preset not found.' }
263
+ }
264
+
265
+ store.defaultPresetId = presetId
266
+ writePresetStore(tableId, store)
267
+ return { ok: true, defaultPresetId: presetId }
268
+ }
269
+
270
+ export function getPresetById(tableId, presetId) {
271
+ const store = readPresetStore(tableId)
272
+ return store.presets.find((preset) => preset.id === presetId) ?? null
273
+ }
274
+
275
+ export function cloneLayoutState(state) {
276
+ return JSON.parse(JSON.stringify(state ?? {}))
277
+ }
278
+
279
+ export function normalizeLayoutStateForCompare(state, headers = [], rows = []) {
280
+ const sanitized = sanitizePresetState(state, headers, rows) ?? {}
281
+
282
+ return {
283
+ columnOrder: sanitized.columnOrder ?? [],
284
+ columnVisibility: sanitized.columnVisibility ?? {},
285
+ columnPriorities: sanitized.columnPriorities ?? {},
286
+ filters: sanitized.filters ?? {},
287
+ sortBy: sanitized.sortBy ?? null,
288
+ sortDir: sanitized.sortDir === 'desc' ? 'desc' : 'asc',
289
+ pageSize: sanitized.pageSize ?? ALLOWED_PAGE_SIZES[0],
290
+ }
291
+ }
292
+
293
+ export function layoutStatesEqual(left, right, headers = [], rows = []) {
294
+ return JSON.stringify(normalizeLayoutStateForCompare(left, headers, rows))
295
+ === JSON.stringify(normalizeLayoutStateForCompare(right, headers, rows))
296
+ }