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
@@ -0,0 +1,202 @@
1
+ import {
2
+ applyColumnFilter,
3
+ filterEntrySpec,
4
+ isFilterActive,
5
+ isFilterEntryActive,
6
+ normalizeColumnFilterEntries,
7
+ normalizeFilter,
8
+ } from './tableFilters.js'
9
+
10
+ export const FILTER_QUERY_VERSION = 1
11
+
12
+ /** @see docs/filter-query-v1.md */
13
+
14
+ export function createEmptyFilterQuery() {
15
+ return {
16
+ version: FILTER_QUERY_VERSION,
17
+ match: 'all',
18
+ columns: [],
19
+ }
20
+ }
21
+
22
+ function normalizeFilterSpec(entry, header, rows) {
23
+ if (!entry || typeof entry !== 'object') {
24
+ return null
25
+ }
26
+
27
+ const { id, name, enabled, ...partial } = entry
28
+ const filter = normalizeFilter(header, partial, rows)
29
+ if (!filter) {
30
+ return null
31
+ }
32
+
33
+ return {
34
+ id: typeof id === 'string' ? id : undefined,
35
+ name: typeof name === 'string' ? name : '',
36
+ enabled: enabled !== false,
37
+ ...filter,
38
+ }
39
+ }
40
+
41
+ export function normalizeFilterQuery(filterQuery, headers = [], rows = []) {
42
+ if (!filterQuery || typeof filterQuery !== 'object') {
43
+ return createEmptyFilterQuery()
44
+ }
45
+
46
+ const headerByKey = new Map(
47
+ headers
48
+ .filter((header) => header?.key)
49
+ .map((header) => [header.key, header]),
50
+ )
51
+
52
+ const columns = Array.isArray(filterQuery.columns)
53
+ ? filterQuery.columns
54
+ .map((column) => {
55
+ if (!column?.key || !headerByKey.has(column.key)) {
56
+ return null
57
+ }
58
+
59
+ const header = headerByKey.get(column.key)
60
+ if (header.filterable === false) {
61
+ return null
62
+ }
63
+
64
+ const filters = Array.isArray(column.filters)
65
+ ? column.filters
66
+ .map((entry) => normalizeFilterSpec(entry, header, rows))
67
+ .filter(Boolean)
68
+ : []
69
+
70
+ if (filters.length === 0) {
71
+ return null
72
+ }
73
+
74
+ return {
75
+ key: column.key,
76
+ match: column.match === 'any' ? 'any' : 'all',
77
+ filters,
78
+ }
79
+ })
80
+ .filter(Boolean)
81
+ : []
82
+
83
+ return {
84
+ version: FILTER_QUERY_VERSION,
85
+ match: filterQuery.match === 'any' ? 'any' : 'all',
86
+ columns,
87
+ }
88
+ }
89
+
90
+ export function buildFilterQuery(filters = {}, headers = [], rows = [], { includeInactive = true } = {}) {
91
+ const headerByKey = new Map(
92
+ headers
93
+ .filter((header) => header?.key)
94
+ .map((header) => [header.key, header]),
95
+ )
96
+
97
+ const columns = []
98
+
99
+ for (const [key, columnFilters] of Object.entries(filters ?? {})) {
100
+ const header = headerByKey.get(key)
101
+ if (!header || header.filterable === false) {
102
+ continue
103
+ }
104
+
105
+ const entries = normalizeColumnFilterEntries(columnFilters, header, rows)
106
+ .map((entry) => normalizeFilterSpec(entry, header, rows))
107
+ .filter(Boolean)
108
+ .filter((entry) => includeInactive || isFilterEntryActive(entry))
109
+
110
+ if (entries.length === 0) {
111
+ continue
112
+ }
113
+
114
+ columns.push({
115
+ key,
116
+ match: 'all',
117
+ filters: entries,
118
+ })
119
+ }
120
+
121
+ return {
122
+ version: FILTER_QUERY_VERSION,
123
+ match: 'all',
124
+ columns,
125
+ }
126
+ }
127
+
128
+ export function filterQueryToFilters(filterQuery, headers = [], rows = []) {
129
+ const normalized = normalizeFilterQuery(filterQuery, headers, rows)
130
+ const filters = {}
131
+
132
+ for (const column of normalized.columns) {
133
+ filters[column.key] = column.filters.map((entry) => ({ ...entry }))
134
+ }
135
+
136
+ return filters
137
+ }
138
+
139
+ function applyColumnFilterQuery(row, header, columnQuery) {
140
+ const activeFilters = (columnQuery.filters ?? [])
141
+ .filter((entry) => entry.enabled !== false)
142
+ .map((entry) => filterEntrySpec(entry))
143
+ .filter((filter) => isFilterActive(filter))
144
+
145
+ if (activeFilters.length === 0) {
146
+ return true
147
+ }
148
+
149
+ if (columnQuery.match === 'any') {
150
+ return activeFilters.some((filter) => applyColumnFilter(row, header, filter))
151
+ }
152
+
153
+ return activeFilters.every((filter) => applyColumnFilter(row, header, filter))
154
+ }
155
+
156
+ export function applyFilterQuery(rows, headers = [], filterQuery) {
157
+ const normalized = normalizeFilterQuery(filterQuery, headers, rows)
158
+ if (normalized.columns.length === 0) {
159
+ return rows
160
+ }
161
+
162
+ const headerByKey = new Map(
163
+ headers
164
+ .filter((header) => header?.key)
165
+ .map((header) => [header.key, header]),
166
+ )
167
+
168
+ return rows.filter((row) => {
169
+ const columnResults = normalized.columns.map((column) => {
170
+ const header = headerByKey.get(column.key)
171
+ if (!header) {
172
+ return true
173
+ }
174
+ return applyColumnFilterQuery(row, header, column)
175
+ })
176
+
177
+ if (normalized.match === 'any') {
178
+ return columnResults.some(Boolean)
179
+ }
180
+
181
+ return columnResults.every(Boolean)
182
+ })
183
+ }
184
+
185
+ export function countActiveFilterQuery(filterQuery, headers = [], rows = []) {
186
+ const normalized = normalizeFilterQuery(filterQuery, headers, rows)
187
+ let total = 0
188
+
189
+ for (const column of normalized.columns) {
190
+ total += column.filters.filter((entry) => isFilterEntryActive(entry)).length
191
+ }
192
+
193
+ return total
194
+ }
195
+
196
+ export function isFilterQueryActive(filterQuery, headers = [], rows = []) {
197
+ return countActiveFilterQuery(filterQuery, headers, rows) > 0
198
+ }
199
+
200
+ export function cloneFilterQuery(filterQuery) {
201
+ return JSON.parse(JSON.stringify(filterQuery ?? createEmptyFilterQuery()))
202
+ }
@@ -1,7 +1,37 @@
1
1
  const TEXT_OPERATORS = ['contains', 'equals', 'startsWith', 'empty']
2
2
  const NUMBER_OPERATORS = ['eq', 'gt', 'gte', 'lt', 'lte']
3
3
 
4
+ const TEXT_OPERATOR_LABELS = {
5
+ contains: 'Contains',
6
+ equals: 'Equals',
7
+ startsWith: 'Starts with',
8
+ empty: 'Is empty',
9
+ }
10
+
11
+ const NUMBER_OPERATOR_LABELS = {
12
+ eq: 'Equals',
13
+ gt: 'Greater than',
14
+ gte: 'Greater or equal',
15
+ lt: 'Less than',
16
+ lte: 'Less or equal',
17
+ }
18
+
19
+ let filterIdCounter = 0
20
+
21
+ export function createFilterId() {
22
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
23
+ return `filter-${crypto.randomUUID()}`
24
+ }
25
+
26
+ filterIdCounter += 1
27
+ return `filter-${filterIdCounter}-${Date.now()}`
28
+ }
29
+
4
30
  export function inferFilterType(header, rows = []) {
31
+ if (!header) {
32
+ return 'text'
33
+ }
34
+
5
35
  if (header.filterType) {
6
36
  return header.filterType
7
37
  }
@@ -85,6 +115,101 @@ export function normalizeFilter(header, partial, rows = []) {
85
115
  }
86
116
  }
87
117
 
118
+ export function filterEntrySpec(entry) {
119
+ if (!entry || typeof entry !== 'object') {
120
+ return null
121
+ }
122
+
123
+ const { id, name, enabled, ...filter } = entry
124
+ return filter
125
+ }
126
+
127
+ export function normalizeStoredFilterEntry(header, entry, rows = []) {
128
+ if (!entry || typeof entry !== 'object') {
129
+ return null
130
+ }
131
+
132
+ const { id, name, enabled, ...partial } = entry
133
+ const filter = normalizeFilter(header, partial, rows)
134
+ if (!filter) {
135
+ return null
136
+ }
137
+
138
+ return {
139
+ id: id || createFilterId(),
140
+ name: typeof name === 'string' ? name : '',
141
+ enabled: enabled !== false,
142
+ ...filter,
143
+ }
144
+ }
145
+
146
+ export function normalizeColumnFilterEntries(value, header = null, rows = []) {
147
+ if (!value) {
148
+ return []
149
+ }
150
+
151
+ if (Array.isArray(value)) {
152
+ return value
153
+ .map((entry) => normalizeStoredFilterEntry(header, entry, rows))
154
+ .filter(Boolean)
155
+ }
156
+
157
+ if (typeof value === 'object' && value.type) {
158
+ const filter = header ? normalizeFilter(header, value, rows) : value
159
+ if (!filter || !isFilterActive(filter)) {
160
+ return []
161
+ }
162
+
163
+ return [{
164
+ id: value.id || createFilterId(),
165
+ name: typeof value.name === 'string' ? value.name : '',
166
+ enabled: value.enabled !== false,
167
+ ...filter,
168
+ }]
169
+ }
170
+
171
+ return []
172
+ }
173
+
174
+ export function createFilterEntry(header, partial, rows = [], { name = '' } = {}) {
175
+ const filter = normalizeFilter(header, partial, rows)
176
+ if (!filter) {
177
+ return null
178
+ }
179
+
180
+ return {
181
+ id: createFilterId(),
182
+ name: typeof name === 'string' ? name.trim() : '',
183
+ enabled: true,
184
+ ...filter,
185
+ }
186
+ }
187
+
188
+ export function describeFilter(header, entry) {
189
+ const filter = filterEntrySpec(entry)
190
+ if (!filter) {
191
+ return 'Filter'
192
+ }
193
+
194
+ const label = header?.label || header?.key || 'Column'
195
+
196
+ switch (filter.type) {
197
+ case 'text':
198
+ if (filter.operator === 'empty') {
199
+ return `${label} is empty`
200
+ }
201
+ return `${TEXT_OPERATOR_LABELS[filter.operator] || 'Contains'} “${filter.value}”`
202
+ case 'number':
203
+ return `${NUMBER_OPERATOR_LABELS[filter.operator] || 'Equals'} ${filter.value}`
204
+ case 'boolean':
205
+ return filter.value ? 'Yes' : 'No'
206
+ case 'select':
207
+ return `${filter.values.length} selected`
208
+ default:
209
+ return 'Filter'
210
+ }
211
+ }
212
+
88
213
  export function isFilterActive(filter) {
89
214
  if (!filter) {
90
215
  return false
@@ -104,6 +229,29 @@ export function isFilterActive(filter) {
104
229
  }
105
230
  }
106
231
 
232
+ export function isFilterEntryActive(entry) {
233
+ return entry?.enabled !== false && isFilterActive(filterEntrySpec(entry))
234
+ }
235
+
236
+ export function isColumnFiltered(columnValue, header = null, rows = []) {
237
+ return normalizeColumnFilterEntries(columnValue, header, rows)
238
+ .some((entry) => isFilterEntryActive(entry))
239
+ }
240
+
241
+ export function countActiveFilters(filters = {}, headers = [], rows = []) {
242
+ const headerByKey = new Map(headers.map((header) => [header.key, header]))
243
+ let total = 0
244
+
245
+ for (const [key, value] of Object.entries(filters ?? {})) {
246
+ const header = headerByKey.get(key) ?? { key }
247
+ total += normalizeColumnFilterEntries(value, header, rows)
248
+ .filter((entry) => isFilterEntryActive(entry))
249
+ .length
250
+ }
251
+
252
+ return total
253
+ }
254
+
107
255
  function compareText(value, filter) {
108
256
  const text = value == null ? '' : String(value)
109
257
  const needle = String(filter.value ?? '')
@@ -166,6 +314,18 @@ export function applyColumnFilter(row, header, filter) {
166
314
  }
167
315
  }
168
316
 
317
+ export function applyColumnFilterEntries(row, header, entries) {
318
+ const activeFilters = normalizeColumnFilterEntries(entries, header)
319
+ .filter((entry) => isFilterEntryActive(entry))
320
+ .map((entry) => filterEntrySpec(entry))
321
+
322
+ if (activeFilters.length === 0) {
323
+ return true
324
+ }
325
+
326
+ return activeFilters.every((filter) => applyColumnFilter(row, header, filter))
327
+ }
328
+
169
329
  export function applyFilters(rows, headers, filters) {
170
330
  if (!filters || Object.keys(filters).length === 0) {
171
331
  return rows
@@ -174,12 +334,12 @@ export function applyFilters(rows, headers, filters) {
174
334
  const headerByKey = new Map(headers.map((header) => [header.key, header]))
175
335
 
176
336
  return rows.filter((row) => {
177
- for (const [key, filter] of Object.entries(filters)) {
337
+ for (const [key, columnFilters] of Object.entries(filters)) {
178
338
  const header = headerByKey.get(key)
179
- if (!header || !isFilterActive(filter)) {
339
+ if (!header) {
180
340
  continue
181
341
  }
182
- if (!applyColumnFilter(row, header, filter)) {
342
+ if (!applyColumnFilterEntries(row, header, columnFilters)) {
183
343
  return false
184
344
  }
185
345
  }
@@ -223,6 +383,10 @@ export function cloneFilters(filters = {}) {
223
383
  return JSON.parse(JSON.stringify(filters ?? {}))
224
384
  }
225
385
 
386
+ export function cloneColumnFilterEntries(value, header = null, rows = []) {
387
+ return normalizeColumnFilterEntries(value, header, rows).map((entry) => ({ ...entry }))
388
+ }
389
+
226
390
  export function sortRows(rows, sortBy, sortDir) {
227
391
  if (!sortBy) {
228
392
  return [...rows]
@@ -3,30 +3,92 @@ import { ref, watch } from 'vue'
3
3
  const STYLE_ELEMENT_ID = 'theme-style'
4
4
  const DEFAULT_STORAGE_KEY = 'picocrank-custom-theme'
5
5
 
6
- function resolveDefaultBasePath() {
6
+ export const SUPPLEMENTAL_THEME_NAMES = [
7
+ 'catppuccin-latte-frappe',
8
+ 'dracula-alucard',
9
+ 'gruvbox-dark-light',
10
+ 'waffles',
11
+ ]
12
+
13
+ const LEGACY_THEME_ALIASES = {
14
+ catppuccin: 'catppuccin-latte-frappe',
15
+ dracula: 'dracula-alucard',
16
+ }
17
+
18
+ function resolveDefaultBasePath(segment) {
7
19
  try {
8
20
  const base = import.meta.env?.BASE_URL || '/'
9
- const joined = base.endsWith('/') ? `${base}themes` : `${base}/themes`
10
- return joined.replace(/\/$/, '') || '/themes'
21
+ const joined = base.endsWith('/') ? `${base}${segment}` : `${base}/${segment}`
22
+ return joined.replace(/\/$/, '') || `/${segment}`
11
23
  } catch {
12
- return '/themes'
24
+ return `/${segment}`
13
25
  }
14
26
  }
15
27
 
16
- const DEFAULT_BASE_PATH = resolveDefaultBasePath()
28
+ const DEFAULT_THEMES_BASE_PATH = resolveDefaultBasePath('themes')
29
+ const DEFAULT_SUPPLEMENTAL_THEMES_BASE_PATH = resolveDefaultBasePath('supplemental-themes')
17
30
 
18
31
  const availableThemes = ref([])
32
+ const themeLabels = ref({})
19
33
  const themePreference = ref('')
20
- const themesBasePath = ref(DEFAULT_BASE_PATH)
34
+ const themesBasePath = ref(DEFAULT_THEMES_BASE_PATH)
35
+ const supplementalThemesBasePath = ref(DEFAULT_SUPPLEMENTAL_THEMES_BASE_PATH)
21
36
  const storageKey = ref(DEFAULT_STORAGE_KEY)
37
+ const includeSupplementalThemes = ref(false)
38
+ const supplementalThemeNames = ref([...SUPPLEMENTAL_THEME_NAMES])
22
39
  let initialized = false
23
40
 
24
- function normalizeBasePath(basePath) {
41
+ function normalizeBasePath(basePath, fallback) {
25
42
  if (!basePath || typeof basePath !== 'string') {
26
- return DEFAULT_BASE_PATH
43
+ return fallback
27
44
  }
28
45
  const trimmed = basePath.replace(/\/$/, '')
29
- return trimmed || DEFAULT_BASE_PATH
46
+ return trimmed || fallback
47
+ }
48
+
49
+ function normalizeStoredThemeName(name) {
50
+ if (!name) {
51
+ return ''
52
+ }
53
+ return LEGACY_THEME_ALIASES[name] || name
54
+ }
55
+
56
+ function normalizeThemeEntry(entry) {
57
+ if (typeof entry === 'string' && entry.length > 0) {
58
+ return { id: entry, label: entry }
59
+ }
60
+ if (entry && typeof entry.id === 'string' && entry.id.length > 0) {
61
+ return {
62
+ id: entry.id,
63
+ label: typeof entry.label === 'string' && entry.label.length > 0 ? entry.label : entry.id,
64
+ }
65
+ }
66
+ return null
67
+ }
68
+
69
+ function mergeThemeEntries(entries) {
70
+ const ids = []
71
+ const labels = {}
72
+
73
+ for (const entry of entries) {
74
+ const normalized = normalizeThemeEntry(entry)
75
+ if (!normalized) {
76
+ continue
77
+ }
78
+ ids.push(normalized.id)
79
+ if (normalized.label !== normalized.id) {
80
+ labels[normalized.id] = normalized.label
81
+ }
82
+ }
83
+
84
+ return {
85
+ ids: [...new Set(ids)].sort((a, b) => a.localeCompare(b)),
86
+ labels,
87
+ }
88
+ }
89
+
90
+ function isSupplementalThemeName(name) {
91
+ return supplementalThemeNames.value.includes(name)
30
92
  }
31
93
 
32
94
  function readStoredTheme(key) {
@@ -36,6 +98,10 @@ function readStoredTheme(key) {
36
98
  return localStorage.getItem(key) || ''
37
99
  }
38
100
 
101
+ function readStoredThemePreference(key) {
102
+ return normalizeStoredThemeName(readStoredTheme(key))
103
+ }
104
+
39
105
  function persistTheme(key, value) {
40
106
  if (typeof localStorage === 'undefined') {
41
107
  return
@@ -61,6 +127,18 @@ function ensureStyleElement() {
61
127
  return themeStyle
62
128
  }
63
129
 
130
+ function isSupplementalTheme(name) {
131
+ return includeSupplementalThemes.value
132
+ && isSupplementalThemeName(name)
133
+ }
134
+
135
+ function themeStylesheetHref(name) {
136
+ const basePath = isSupplementalThemeName(name)
137
+ ? supplementalThemesBasePath.value
138
+ : themesBasePath.value
139
+ return `${basePath}/${encodeURIComponent(name)}/theme.css`
140
+ }
141
+
64
142
  function applyTheme() {
65
143
  const themeStyle = ensureStyleElement()
66
144
  if (!themeStyle) {
@@ -73,38 +151,74 @@ function applyTheme() {
73
151
  return
74
152
  }
75
153
 
76
- const href = `${themesBasePath.value}/${encodeURIComponent(name)}/theme.css`
154
+ if (isSupplementalThemeName(name) && !includeSupplementalThemes.value) {
155
+ themeStyle.textContent = ''
156
+ return
157
+ }
158
+
159
+ const href = themeStylesheetHref(name)
77
160
  themeStyle.textContent = `@import url('${href}') layer(theme);`
78
161
  }
79
162
 
80
163
  function setTheme(name) {
81
- themePreference.value = name || ''
164
+ const nextName = normalizeStoredThemeName(name || '')
165
+ if (nextName && isSupplementalThemeName(nextName) && !includeSupplementalThemes.value) {
166
+ themePreference.value = ''
167
+ return
168
+ }
169
+ themePreference.value = nextName
82
170
  }
83
171
 
84
172
  function clearTheme() {
85
173
  setTheme('')
86
174
  }
87
175
 
88
- async function discoverThemes() {
176
+ function clearStoredSupplementalThemeIfDisabled() {
177
+ if (
178
+ themePreference.value
179
+ && isSupplementalThemeName(themePreference.value)
180
+ && !includeSupplementalThemes.value
181
+ ) {
182
+ themePreference.value = ''
183
+ }
184
+ }
185
+
186
+ async function fetchThemeIndex(basePath) {
89
187
  if (typeof fetch === 'undefined') {
90
- availableThemes.value = []
91
- return availableThemes.value
188
+ return []
92
189
  }
93
190
 
94
- const indexUrl = `${themesBasePath.value}/index.json`
191
+ const indexUrl = `${basePath}/index.json`
95
192
  try {
96
193
  const response = await fetch(indexUrl, { cache: 'no-store' })
97
194
  if (!response.ok) {
98
- availableThemes.value = []
99
- return availableThemes.value
195
+ return []
100
196
  }
101
197
  const data = await response.json()
102
- const themes = Array.isArray(data?.themes)
103
- ? data.themes.filter((theme) => typeof theme === 'string' && theme.length > 0)
104
- : []
105
- availableThemes.value = themes.sort((a, b) => a.localeCompare(b))
198
+ return Array.isArray(data?.themes) ? data.themes : []
106
199
  } catch {
107
- availableThemes.value = []
200
+ return []
201
+ }
202
+ }
203
+
204
+ async function discoverThemes() {
205
+ const userThemes = mergeThemeEntries(await fetchThemeIndex(themesBasePath.value))
206
+ let ids = [...userThemes.ids]
207
+ let labels = { ...userThemes.labels }
208
+
209
+ if (includeSupplementalThemes.value) {
210
+ const supplementalThemes = mergeThemeEntries(await fetchThemeIndex(supplementalThemesBasePath.value))
211
+ ids = [...ids, ...supplementalThemes.ids]
212
+ labels = { ...labels, ...supplementalThemes.labels }
213
+ }
214
+
215
+ availableThemes.value = [...new Set(ids)].sort((a, b) => a.localeCompare(b))
216
+ themeLabels.value = labels
217
+
218
+ clearStoredSupplementalThemeIfDisabled()
219
+
220
+ if (themePreference.value && !availableThemes.value.includes(themePreference.value)) {
221
+ themePreference.value = ''
108
222
  }
109
223
 
110
224
  return availableThemes.value
@@ -112,11 +226,24 @@ async function discoverThemes() {
112
226
 
113
227
  function configure(options = {}) {
114
228
  if (options.themesBasePath !== undefined) {
115
- themesBasePath.value = normalizeBasePath(options.themesBasePath)
229
+ themesBasePath.value = normalizeBasePath(options.themesBasePath, DEFAULT_THEMES_BASE_PATH)
230
+ }
231
+ if (options.supplementalThemesBasePath !== undefined) {
232
+ supplementalThemesBasePath.value = normalizeBasePath(
233
+ options.supplementalThemesBasePath,
234
+ DEFAULT_SUPPLEMENTAL_THEMES_BASE_PATH,
235
+ )
116
236
  }
117
237
  if (options.storageKey !== undefined && typeof options.storageKey === 'string' && options.storageKey) {
118
238
  storageKey.value = options.storageKey
119
239
  }
240
+ if (options.includeSupplementalThemes !== undefined) {
241
+ includeSupplementalThemes.value = Boolean(options.includeSupplementalThemes)
242
+ }
243
+ if (Array.isArray(options.supplementalThemeNames)) {
244
+ supplementalThemeNames.value = options.supplementalThemeNames
245
+ .filter((theme) => typeof theme === 'string' && theme.length > 0)
246
+ }
120
247
  if (Array.isArray(options.availableThemes)) {
121
248
  availableThemes.value = options.availableThemes
122
249
  .filter((theme) => typeof theme === 'string' && theme.length > 0)
@@ -128,11 +255,21 @@ function initCustomTheme(options = {}) {
128
255
  configure(options)
129
256
 
130
257
  if (!initialized) {
131
- themePreference.value = readStoredTheme(storageKey.value)
258
+ const storedTheme = readStoredThemePreference(storageKey.value)
259
+ if (storedTheme !== readStoredTheme(storageKey.value)) {
260
+ persistTheme(storageKey.value, storedTheme)
261
+ }
262
+ themePreference.value = storedTheme
263
+ clearStoredSupplementalThemeIfDisabled()
132
264
  watch(themePreference, (value) => {
133
265
  persistTheme(storageKey.value, value)
134
266
  applyTheme()
135
267
  })
268
+ watch(includeSupplementalThemes, () => {
269
+ void discoverThemes()
270
+ clearStoredSupplementalThemeIfDisabled()
271
+ applyTheme()
272
+ })
136
273
  initialized = true
137
274
  }
138
275
 
@@ -147,15 +284,21 @@ export function useCustomTheme(options) {
147
284
 
148
285
  return {
149
286
  availableThemes,
287
+ themeLabels,
150
288
  themePreference,
151
289
  themesBasePath,
290
+ supplementalThemesBasePath,
152
291
  storageKey,
292
+ includeSupplementalThemes,
293
+ supplementalThemeNames,
153
294
  applyTheme,
154
295
  setTheme,
155
296
  clearTheme,
156
297
  discoverThemes,
157
298
  configure,
158
299
  initCustomTheme,
300
+ isSupplementalTheme,
301
+ isSupplementalThemeName,
159
302
  }
160
303
  }
161
304
 
@@ -35,11 +35,7 @@ export const examplePeople = [
35
35
  { name: 'George', age: 29, city: 'Miami' },
36
36
  ]
37
37
 
38
- /** Longer list matching the previous table example volume (with repeats for pagination). */
38
+ /** Example table rows — the full unique people list. */
39
39
  export function getExampleTableRows() {
40
- return [
41
- ...examplePeople,
42
- ...examplePeople.slice(7),
43
- ...examplePeople.slice(7, 22),
44
- ]
40
+ return [...examplePeople]
45
41
  }