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
@@ -2,6 +2,10 @@
2
2
  <Navigation ref="navigation">
3
3
  <Header username = "Guest" @toggleSidebar="toggleSidebar" @logoClick="goToIndex" @userClick="goToUserDetails" title = "PicoCrank" :logoUrl="logoUrl" :sidebarEnabled="sidebarEnabled" :navigation="navigation" :topBarEnabled="topBarEnabled" :showBranding="brandingEnabled" :breadcrumbs="breadcrumbsEnabled" :themeToggleEnabled="themeToggleEnabled">
4
4
  <template #toolbar>
5
+ <ExampleHeaderThemeSwitcher
6
+ v-if="themeSwitcherEnabled"
7
+ :include-supplemental-themes="includeSupplementalThemes"
8
+ />
5
9
  <QuickSearch
6
10
  v-if="quickSearchEnabled"
7
11
  ref="quickSearchRef"
@@ -37,6 +41,7 @@
37
41
  import { Pin02Icon, UserIcon } from '@hugeicons/core-free-icons'
38
42
 
39
43
  import QuickSearch from './../components/QuickSearch.vue'
44
+ import ExampleHeaderThemeSwitcher from './ExampleHeaderThemeSwitcher.vue'
40
45
  import Navigation from './../components/Navigation.vue'
41
46
  import NotificationPopups from './../components/NotificationPopups.vue'
42
47
  import '../../styles.css'
@@ -54,10 +59,12 @@
54
59
  const quickSearchEnabled = ref(true);
55
60
  const dummySearchFetchEnabled = ref(false);
56
61
  const themeToggleEnabled = ref(true);
62
+ const themeSwitcherEnabled = ref(true);
63
+ const includeSupplementalThemes = ref(true);
57
64
  const topBarEnabled = ref(false);
58
65
  const breadcrumbsEnabled = ref(false);
59
66
 
60
- const { discoverThemes } = initCustomTheme();
67
+ const { discoverThemes } = initCustomTheme({ includeSupplementalThemes: true });
61
68
 
62
69
  function toggleSidebar() {
63
70
  if (sidebar.value) {
@@ -93,6 +100,10 @@
93
100
  themeToggleEnabled.value = !themeToggleEnabled.value;
94
101
  }
95
102
 
103
+ function toggleThemeSwitcherEnabled() {
104
+ themeSwitcherEnabled.value = !themeSwitcherEnabled.value;
105
+ }
106
+
96
107
  function toggleTopBarEnabled() {
97
108
  topBarEnabled.value = !topBarEnabled.value;
98
109
  }
@@ -161,6 +172,8 @@
161
172
  provide('toggleDummySearchFetchEnabled', toggleDummySearchFetchEnabled);
162
173
  provide('themeToggleEnabled', themeToggleEnabled);
163
174
  provide('toggleThemeToggleEnabled', toggleThemeToggleEnabled);
175
+ provide('themeSwitcherEnabled', themeSwitcherEnabled);
176
+ provide('toggleThemeSwitcherEnabled', toggleThemeSwitcherEnabled);
164
177
  provide('topBarEnabled', topBarEnabled);
165
178
  provide('toggleTopBarEnabled', toggleTopBarEnabled);
166
179
  provide('breadcrumbsEnabled', breadcrumbsEnabled);
@@ -0,0 +1,78 @@
1
+ <template>
2
+ <ThemeSwitcher
3
+ v-if="enabled"
4
+ compact
5
+ label=""
6
+ :select-id="HEADER_THEME_SWITCHER_SELECT_ID"
7
+ default-option-label="Default (Femtocrank only)"
8
+ :include-supplemental-themes="includeSupplementalThemes"
9
+ />
10
+ </template>
11
+
12
+ <script setup>
13
+ import ThemeSwitcher, { HEADER_THEME_SWITCHER_SELECT_ID } from '../components/ThemeSwitcher.vue'
14
+
15
+ defineProps({
16
+ enabled: {
17
+ type: Boolean,
18
+ default: true,
19
+ },
20
+ includeSupplementalThemes: {
21
+ type: Boolean,
22
+ default: true,
23
+ },
24
+ })
25
+ </script>
26
+
27
+ <!-- Examples app only: match Header toolbar chrome (see Header search-trigger styles). -->
28
+ <style>
29
+ /*
30
+ * Header renders theme toggle before the toolbar slot; use flex order to swap
31
+ * the drop-in theme selector ahead of the light/dark button without changing Header.
32
+ */
33
+ header .header-actions .theme-switcher--compact {
34
+ order: 1;
35
+ align-self: stretch;
36
+ display: inline-flex;
37
+ align-items: center;
38
+ margin: 0;
39
+ }
40
+
41
+ header .header-actions .theme-toggle {
42
+ order: 2;
43
+ }
44
+
45
+ header .header-actions .quick-search {
46
+ order: 3;
47
+ }
48
+
49
+ header .header-actions .user-info {
50
+ order: 4;
51
+ }
52
+
53
+ header .header-actions .theme-switcher--compact select {
54
+ align-self: stretch;
55
+ height: 100%;
56
+ border: 0;
57
+ border-radius: 0;
58
+ background-color: transparent;
59
+ color: var(--header-fg-color, var(--header-text-color, #fff));
60
+ padding-top: 0;
61
+ padding-bottom: 0;
62
+ padding-left: 0.6em;
63
+ padding-right: 0.6em;
64
+ font-size: small;
65
+ font-family: inherit;
66
+ cursor: pointer;
67
+ }
68
+
69
+ header .header-actions .theme-switcher--compact select:hover {
70
+ background-color: var(--header-hover-background-color);
71
+ color: var(--header-hover-text-color);
72
+ }
73
+
74
+ header .header-actions .theme-switcher--compact select:focus-visible {
75
+ outline: 2px solid var(--header-hover-text-color, #fff);
76
+ outline-offset: -2px;
77
+ }
78
+ </style>
@@ -233,12 +233,10 @@ function handleSSOLogin(provider) {
233
233
 
234
234
  .magic-link-form input {
235
235
  padding: 0.75rem;
236
- border: 1px solid var(--border-color, #ccc);
237
236
  border-radius: 4px;
238
237
  font-size: 1em;
239
- background-color: var(--input-bg, #fff);
240
- color: var(--text-color, #000);
241
238
  width: 100%;
239
+ box-sizing: border-box;
242
240
  }
243
241
 
244
242
  .magic-link-form input:focus {
@@ -62,6 +62,17 @@
62
62
  />
63
63
  </FormField>
64
64
 
65
+ <FormField label="Theme switcher" fake>
66
+ <RadioGroup
67
+ name="ps-theme-switcher"
68
+ variant="boolean"
69
+ aria-label="Theme switcher"
70
+ :model-value="themeSwitcherEnabled"
71
+ :options="showHideOptions"
72
+ @update:model-value="setThemeSwitcherEnabled"
73
+ />
74
+ </FormField>
75
+
65
76
  <FormField label="Top bar" fake>
66
77
  <div>
67
78
  <RadioGroup
@@ -170,6 +181,8 @@ const dummySearchFetchEnabled = inject('dummySearchFetchEnabled')
170
181
  const toggleDummySearchFetchEnabled = inject('toggleDummySearchFetchEnabled')
171
182
  const themeToggleEnabled = inject('themeToggleEnabled')
172
183
  const toggleThemeToggleEnabled = inject('toggleThemeToggleEnabled')
184
+ const themeSwitcherEnabled = inject('themeSwitcherEnabled')
185
+ const toggleThemeSwitcherEnabled = inject('toggleThemeSwitcherEnabled')
173
186
  const topBarEnabled = inject('topBarEnabled')
174
187
  const toggleTopBarEnabled = inject('toggleTopBarEnabled')
175
188
  const breadcrumbsEnabled = inject('breadcrumbsEnabled')
@@ -200,6 +213,10 @@ function setThemeToggleEnabled(want) {
200
213
  setFlag(themeToggleEnabled, toggleThemeToggleEnabled, want)
201
214
  }
202
215
 
216
+ function setThemeSwitcherEnabled(want) {
217
+ setFlag(themeSwitcherEnabled, toggleThemeSwitcherEnabled, want)
218
+ }
219
+
203
220
  function setTopBarEnabled(want) {
204
221
  setFlag(topBarEnabled, toggleTopBarEnabled, want)
205
222
  }
@@ -1,17 +1,24 @@
1
1
  <template>
2
2
  <Section
3
3
  title="Table Example"
4
+ :icon="TableIcon"
4
5
  subtitle="Sortable columns, pagination, and column filters. Right-click or long-press a column header to filter."
5
6
  :padding="false"
6
7
  >
7
- <template #toolbar>
8
- <button>Add Item</button>
9
- </template>
10
-
11
8
  <Table
12
- :data="data"
9
+ table-id="table-example"
10
+ row-key="name"
11
+ :data="tableData"
13
12
  :headers="headers"
14
- :show-pagination="true"
13
+ :show-pagination="enabledFeatures.includes('pagination')"
14
+ :filterable="enabledFeatures.includes('filters')"
15
+ :column-options="enabledFeatures.includes('columnOptions')"
16
+ :loading="enabledFeatures.includes('loading')"
17
+ :horizontal-scroll="layout === 'scroll'"
18
+ :sticky-cols="stickyCols"
19
+ v-model:column-visibility="columnVisibility"
20
+ :default-column-visibility="defaultColumnVisibility"
21
+ v-bind="tableListeners"
15
22
  >
16
23
  <template #cell-name="{ row, value }">
17
24
  <router-link :to="{ name: 'ViewItem', params: { id: row.name } }">
@@ -24,23 +31,334 @@
24
31
  {{ value }}
25
32
  </span>
26
33
  </template>
34
+
35
+ <template #cell-status="{ value }">
36
+ <span class="tag fg-good">{{ value }}</span>
37
+ </template>
27
38
  </Table>
28
39
  </Section>
40
+
41
+ <Section
42
+ title="Explore table options"
43
+ :icon="Settings01Icon"
44
+ subtitle="Adjust layout and behaviour for the table above."
45
+ >
46
+ <template #toolbar>
47
+ <button type="button" @click="resetControls">Reset options</button>
48
+ </template>
49
+
50
+ <FormLayout>
51
+ <FormField label="Data" fake description="Connect or disconnect row data for the table above. Saved column layouts persist in localStorage for this table.">
52
+ <div class="table-data-actions">
53
+ <button type="button" :disabled="dataConnected" @click="dataConnected = true">
54
+ Connect data
55
+ </button>
56
+ <button type="button" class="neutral" :disabled="!dataConnected" @click="dataConnected = false">
57
+ Disconnect data
58
+ </button>
59
+ </div>
60
+ </FormField>
61
+
62
+ <FormField label="Features" fake description="Toggle core table behaviour.">
63
+ <CheckGroup
64
+ v-model="enabledFeatures"
65
+ name="table-features"
66
+ :options="featureOptions"
67
+ />
68
+ </FormField>
69
+
70
+ <FormField
71
+ label="Responsive layout"
72
+ fake
73
+ description="Column priorities hide lower-priority columns as the table narrows. Horizontal scroll keeps every column visible with optional sticky leading columns."
74
+ >
75
+ <RadioGroup
76
+ v-model="layout"
77
+ name="table-layout"
78
+ :options="layoutOptions"
79
+ />
80
+ </FormField>
81
+
82
+ <FormField
83
+ v-if="layout === 'scroll'"
84
+ label="Sticky columns"
85
+ fake
86
+ description="Pin the first columns while scrolling horizontally."
87
+ >
88
+ <RadioGroup
89
+ v-model="stickyColsOption"
90
+ name="table-sticky-cols"
91
+ :options="stickyColOptions"
92
+ />
93
+ </FormField>
94
+
95
+ <FormField
96
+ v-if="layout === 'scroll'"
97
+ label="Wide table"
98
+ fake
99
+ description="Adds extra columns so horizontal scroll is easier to see."
100
+ >
101
+ <label>
102
+ <input v-model="wideTable" type="checkbox" />
103
+ Show extended columns
104
+ </label>
105
+ </FormField>
106
+ </FormLayout>
107
+
108
+ <div class="table-events">
109
+ <h4>Events</h4>
110
+ <p v-if="lastRowClick" class="event-line">
111
+ <strong>Row click:</strong>
112
+ index {{ lastRowClick.index }}, name “{{ lastRowClick.row.name }}”
113
+ </p>
114
+ <p v-else class="event-line subtle">Row click: enable “Row click” above, then click a row to open the detail dialog.</p>
115
+
116
+ <p v-if="lastQuery" class="event-line">
117
+ <strong>Query:</strong>
118
+ page {{ lastQuery.page }},
119
+ sort {{ lastQuery.sortBy ?? 'none' }} {{ lastQuery.sortDir }},
120
+ {{ activeFilterCount }} active filter(s)
121
+ </p>
122
+ <p v-else class="event-line subtle">Query: waiting for the first table update…</p>
123
+ </div>
124
+ </Section>
125
+
126
+ <dialog
127
+ ref="rowDialogRef"
128
+ class="row-detail-dialog"
129
+ @click="onRowDialogBackdropClick"
130
+ >
131
+ <div v-if="lastRowClick" class="dialog-panel" @click.stop>
132
+ <h3>{{ lastRowClick.row.name }}</h3>
133
+ <p class="subtle">Row index {{ lastRowClick.index }} in the current filtered and sorted result set.</p>
134
+
135
+ <dl>
136
+ <dt>Index</dt>
137
+ <dd>{{ lastRowClick.index }}</dd>
138
+
139
+ <template v-for="header in headers" :key="header.key">
140
+ <dt>{{ header.label || header.key }}</dt>
141
+ <dd>
142
+ <span v-if="header.key === 'status'" class="tag fg-good">
143
+ {{ lastRowClick.row[header.key] }}
144
+ </span>
145
+ <template v-else>
146
+ {{ lastRowClick.row[header.key] }}
147
+ </template>
148
+ </dd>
149
+ </template>
150
+ </dl>
151
+
152
+ <form method="dialog">
153
+ <button type="submit" class="neutral">Close</button>
154
+ </form>
155
+ </div>
156
+ </dialog>
29
157
  </template>
30
158
 
31
159
  <script setup>
32
- import { ref, onMounted } from 'vue'
160
+ import { ref, computed, onMounted } from 'vue'
161
+ import { TableIcon, Settings01Icon } from '@hugeicons/core-free-icons'
33
162
  import { getExampleTableRows } from '../data/examplePeople.js'
163
+ import { countActiveFilters } from '../composables/tableFilters.js'
164
+
165
+ const defaultFeatures = ['pagination', 'filters', 'columnOptions', 'rowClick']
34
166
 
35
- const headers = ref([
36
- { key: 'name', label: 'Name', sortable: true, filterable: true, filterType: 'text', hidden: false, width: '20%' },
167
+ const defaultColumnVisibility = {
168
+ birthYear: false,
169
+ }
170
+
171
+ const enabledFeatures = ref([...defaultFeatures])
172
+ const columnVisibility = ref({ ...defaultColumnVisibility })
173
+ const layout = ref('default')
174
+ const stickyColsOption = ref('2')
175
+ const wideTable = ref(true)
176
+ const dataConnected = ref(true)
177
+ const data = ref([])
178
+ const lastRowClick = ref(null)
179
+ const lastQuery = ref(null)
180
+ const rowDialogRef = ref(null)
181
+
182
+ const featureOptions = [
183
+ { value: 'pagination', label: 'Pagination' },
184
+ { value: 'filters', label: 'Column filters' },
185
+ { value: 'columnOptions', label: 'Column options' },
186
+ { value: 'rowClick', label: 'Row click' },
187
+ { value: 'loading', label: 'Loading state' },
188
+ ]
189
+
190
+ const layoutOptions = [
191
+ { value: 'default', label: 'Default' },
192
+ { value: 'priorities', label: 'Column priorities' },
193
+ { value: 'scroll', label: 'Horizontal scroll' },
194
+ ]
195
+
196
+ const stickyColOptions = [
197
+ { value: 'none', label: 'None' },
198
+ { value: '1', label: '1 column' },
199
+ { value: '2', label: '2 columns' },
200
+ { value: '3', label: '3 columns' },
201
+ ]
202
+
203
+ const baseHeaders = [
204
+ { key: 'name', label: 'Name', sortable: true, filterable: true, filterType: 'text', width: '20%' },
37
205
  { key: 'age', label: 'Age', sortable: true, filterable: true, filterType: 'number' },
206
+ { key: 'birthYear', label: 'Birth year', sortable: true, filterable: true, filterType: 'number' },
38
207
  { key: 'city', label: 'City', sortable: true, filterable: true, filterType: 'select', width: '200px' },
39
- ])
208
+ ]
40
209
 
41
- const data = ref([])
210
+ const extendedHeaders = [
211
+ { key: 'email', label: 'Email', sortable: true, filterable: true, filterType: 'text' },
212
+ { key: 'department', label: 'Department', sortable: true, filterable: true, filterType: 'select' },
213
+ { key: 'role', label: 'Role', sortable: true, filterable: true, filterType: 'text' },
214
+ { key: 'status', label: 'Status', sortable: true, filterable: true, filterType: 'select' },
215
+ ]
216
+
217
+ const priorityByKey = {
218
+ age: 2,
219
+ birthYear: 3,
220
+ city: 1,
221
+ department: 2,
222
+ role: 1,
223
+ status: 1,
224
+ }
225
+
226
+ const stickyCols = computed(() => {
227
+ if (layout.value !== 'scroll' || stickyColsOption.value === 'none') {
228
+ return null
229
+ }
230
+ return Number(stickyColsOption.value)
231
+ })
232
+
233
+ const useExtendedColumns = computed(() => layout.value === 'scroll' && wideTable.value)
234
+
235
+ const headers = computed(() => {
236
+ const columns = useExtendedColumns.value
237
+ ? [...baseHeaders, ...extendedHeaders]
238
+ : [...baseHeaders]
239
+
240
+ if (layout.value !== 'priorities') {
241
+ return columns
242
+ }
243
+
244
+ return columns.map((header) => {
245
+ const colPriority = priorityByKey[header.key]
246
+ if (!colPriority) {
247
+ return { ...header }
248
+ }
249
+ return { ...header, colPriority }
250
+ })
251
+ })
252
+
253
+ const tableData = computed(() => {
254
+ if (!dataConnected.value) {
255
+ return []
256
+ }
257
+
258
+ const birthYear = new Date().getFullYear()
259
+
260
+ return data.value.map((row) => {
261
+ const withBirthYear = {
262
+ ...row,
263
+ birthYear: birthYear - row.age,
264
+ }
265
+
266
+ if (!useExtendedColumns.value) {
267
+ return withBirthYear
268
+ }
269
+
270
+ return {
271
+ ...withBirthYear,
272
+ email: `${String(row.name).toLowerCase()}@example.com`,
273
+ department: row.age >= 30 ? 'Operations' : 'Engineering',
274
+ role: row.age >= 35 ? 'Manager' : 'Member',
275
+ status: row.age % 2 === 0 ? 'Active' : 'Pending',
276
+ }
277
+ })
278
+ })
279
+
280
+ const activeFilterCount = computed(() => {
281
+ if (!lastQuery.value?.filters) {
282
+ return 0
283
+ }
284
+ return countActiveFilters(lastQuery.value.filters, headers.value, tableData.value)
285
+ })
286
+
287
+ const tableListeners = computed(() => {
288
+ const listeners = {
289
+ onQueryChange: handleQueryChange,
290
+ }
291
+ if (enabledFeatures.value.includes('rowClick')) {
292
+ listeners.onRowClick = handleRowClick
293
+ }
294
+ return listeners
295
+ })
296
+
297
+ function handleRowClick(payload) {
298
+ lastRowClick.value = payload
299
+ rowDialogRef.value?.showModal()
300
+ }
301
+
302
+ function onRowDialogBackdropClick(event) {
303
+ if (event.target === rowDialogRef.value) {
304
+ rowDialogRef.value.close()
305
+ }
306
+ }
307
+
308
+ function handleQueryChange(query) {
309
+ lastQuery.value = query
310
+ }
311
+
312
+ function resetControls() {
313
+ enabledFeatures.value = [...defaultFeatures]
314
+ columnVisibility.value = { ...defaultColumnVisibility }
315
+ layout.value = 'default'
316
+ stickyColsOption.value = '2'
317
+ wideTable.value = true
318
+ dataConnected.value = true
319
+ lastRowClick.value = null
320
+ if (rowDialogRef.value?.open) {
321
+ rowDialogRef.value.close()
322
+ }
323
+ }
42
324
 
43
325
  onMounted(() => {
44
326
  data.value = getExampleTableRows()
45
327
  })
46
328
  </script>
329
+
330
+ <style scoped>
331
+ .table-events {
332
+ margin-top: 1.5rem;
333
+ }
334
+
335
+ .table-data-actions {
336
+ display: flex;
337
+ flex-wrap: wrap;
338
+ gap: 0.5rem;
339
+ }
340
+
341
+ .table-events h4 {
342
+ margin-top: 0;
343
+ }
344
+
345
+ .event-line {
346
+ margin: 0.35rem 0;
347
+ }
348
+
349
+ .row-detail-dialog .dialog-panel h3 {
350
+ margin: 0 0 0.5rem;
351
+ }
352
+
353
+ .row-detail-dialog .dialog-panel p {
354
+ margin: 0 0 1rem;
355
+ }
356
+
357
+ .row-detail-dialog dl {
358
+ margin: 0 0 1rem;
359
+ }
360
+
361
+ .row-detail-dialog form {
362
+ margin: 0;
363
+ }
364
+ </style>