@stacksjs/defaults 0.75.242 → 0.75.244

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.
@@ -12,6 +12,36 @@ import { formatCurrency } from '@stacksjs/commerce/money'
12
12
  * meant a table whose parent ignored the event had sort arrows that did
13
13
  * nothing. It now sorts what it was given. A parent that sorts server-side
14
14
  * sets `manual-sort` and keeps the event.
15
+ *
16
+ * ## The slots, and what to use instead
17
+ *
18
+ * Three upstream stx limitations, and together they are why this component
19
+ * had no callers at all while 69 dashboard components hand-rolled a `<table>`:
20
+ *
21
+ * - `{{ … }}` in slot content is evaluated at slot-expansion time against an
22
+ * empty context and written out as an empty string, so the expression is
23
+ * gone before the client loop runs. Attribute bindings (`x-text`, `:attr`,
24
+ * `@click`) survive intact, so half of a slot works and the text silently
25
+ * vanishes. stacksjs/stx#2095.
26
+ * - `stx typecheck` does not bind scoped slot props, so every reference to
27
+ * `row` in `<template #cell="{ row }">` is a TS2304, in every binding form.
28
+ * It is a required CI step, so correct usage cannot land. stacksjs/stx#2096.
29
+ * - `$slots.name` evaluates falsy inside a compound condition, so every
30
+ * `$slots` reference below stands alone and the branches are split rather
31
+ * than combined. stacksjs/stx#2098.
32
+ *
33
+ * So prefer the props over the slots until those are fixed:
34
+ *
35
+ * - action buttons: `rowActions` + the `row-action` event, with `when` for
36
+ * per-row availability and `busy-key`/`busy-value` to disable a row that is
37
+ * being written
38
+ * - a second line under a value: the column's `subtitleKey`
39
+ * - a pill that only marks some rows: a `badge` column with `emptyText: ''`
40
+ * - page-level filters that belong beside the table: `#toolbar`, which sits
41
+ * outside the row loop and so is unaffected by #2095
42
+ *
43
+ * `Library/DependenciesDashboard` and the Commerce `*Table.stx` migrations are
44
+ * the worked examples. See stacksjs/stacks#2924 and stacksjs/stacks#2925.
15
45
  */
16
46
  interface DashboardTableColumn {
17
47
  key: string
@@ -29,7 +59,7 @@ interface DashboardTableColumn {
29
59
  * formatter function because props cross an attribute boundary, where a
30
60
  * function does not survive.
31
61
  */
32
- type?: 'text' | 'number' | 'currency' | 'date' | 'boolean' | 'badge'
62
+ type?: 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'boolean' | 'badge'
33
63
  /**
34
64
  * For a `badge` column: the classes each value wears. A value with no
35
65
  * entry gets a neutral pill, so a new status never renders unstyled.
@@ -48,7 +78,20 @@ interface DashboardTableColumn {
48
78
  */
49
79
  badgeLabels?: Record<string, string>
50
80
  /**
51
- * What an empty cell reads as. Defaults to `-`.
81
+ * Row field rendered as a second, quieter line under the value.
82
+ *
83
+ * A name with its slug, abbreviation or description underneath is by far the
84
+ * most common custom cell in the dashboard - 38 of the 67 hand-rolled tables
85
+ * have one - and it is the only reason most of them needed a slot at all.
86
+ * Doing it here keeps them off the `cell` slot, which cannot carry text
87
+ * (stacksjs/stx#2095). A row with no value for the key renders one line.
88
+ */
89
+ subtitleKey?: string
90
+
91
+ /**
92
+ * What an empty cell reads as. Defaults to `-`. On a `badge` column, `''`
93
+ * means no pill at all for rows without a value, which is what a flag
94
+ * column wants.
52
95
  *
53
96
  * Per column because the right word is specific: a shipping table wants
54
97
  * "No minimum" for an absent free-shipping threshold, which is a statement
@@ -70,6 +113,50 @@ interface DashboardTableColumn {
70
113
  class?: string
71
114
  /** Hidden until switched on in the column menu. */
72
115
  hidden?: boolean
116
+ /**
117
+ * Route this column through the `cell` slot and leave every other column on
118
+ * the built-in rendering.
119
+ *
120
+ * Without this, a caller that needs custom markup in one column has to
121
+ * re-implement all of them, because the slot replaces the default content
122
+ * wholesale. Per-column slots would be the obvious answer and are not
123
+ * reachable: stx matches slot names literally, so `:name="'cell-' + key"`
124
+ * never resolves and its content is dropped silently.
125
+ *
126
+ * A table where no column sets this keeps the old behaviour and sends every
127
+ * cell through the slot, so existing callers are unaffected.
128
+ * See stacksjs/stacks#2924.
129
+ */
130
+ slotted?: boolean
131
+ }
132
+
133
+ /**
134
+ * An icon button in the actions column, declared rather than slotted.
135
+ *
136
+ * The `actions` slot needs `row`, and `stx typecheck` rejects every reference
137
+ * to a scoped slot prop (stacksjs/stx#2096), so a table with action buttons
138
+ * could not be migrated at all. Props and events cross the attribute boundary
139
+ * cleanly, which this does: Table renders the buttons and emits `row-action`
140
+ * with the action name and the row.
141
+ */
142
+ interface DashboardTableRowAction {
143
+ /** Identifies this action in the `row-action` payload. */
144
+ action: string
145
+ /** Read out as "<label> <row[actionLabelKey]>", e.g. "Edit Kilogram". */
146
+ label: string
147
+ /** Iconify class, e.g. `i-hugeicons-edit-01`. */
148
+ icon: string
149
+ /** Colour class for the icon. Defaults to the usual blue. */
150
+ class?: string
151
+ /**
152
+ * Show this action only on rows where `row[key]` equals `equals`.
153
+ *
154
+ * Approving, seating or cancelling is usually only offered on the rows it
155
+ * applies to - a restaurant party can be seated while it is still waiting,
156
+ * and not afterwards. Without this the whole actions column had to be a
157
+ * slot, which is the thing that could not be migrated.
158
+ */
159
+ when?: { key: string, equals: unknown }
73
160
  }
74
161
 
75
162
  type DashboardTableRow = Record<string, any>
@@ -100,6 +187,17 @@ const columnToggle = useReactiveProp('columnToggle', false)
100
187
  const exportable = useReactiveProp('exportable', false)
101
188
  const exportFilename = useReactiveProp('exportFilename', 'table')
102
189
  const stickyFirstColumn = useReactiveProp('stickyFirstColumn', false)
190
+ const rowActions = useReactiveProp<DashboardTableRowAction[]>('rowActions', [])
191
+ const actionLabelKey = useReactiveProp('actionLabelKey', '')
192
+ /**
193
+ * Disable every action on the row currently being written.
194
+ *
195
+ * `busyValue` is whatever the parent is tracking - usually the id of the row
196
+ * whose request is in flight - and `busyKey` is the field it compares against,
197
+ * defaulting to the row key. Empty means nothing is busy.
198
+ */
199
+ const busyKey = useReactiveProp('busyKey', '')
200
+ const busyValue = useReactiveProp<unknown>('busyValue', '')
103
201
 
104
202
  const selectedKeys = state<TableKey[]>([])
105
203
  const sortKey = state('')
@@ -167,9 +265,18 @@ function displayValue(row: DashboardTableRow, column: DashboardTableColumn): str
167
265
  return Number.isFinite(amount) ? amount.toLocaleString() : String(value)
168
266
  }
169
267
  if (column.type === 'date') {
170
- const at = new Date(value)
268
+ const at = toDate(value)
171
269
  return Number.isNaN(at.getTime()) ? String(value) : at.toLocaleDateString()
172
270
  }
271
+ // An event or log row is ordered by when it happened, and to the minute, so
272
+ // the date alone is not enough to read it. Separate from `date` rather than
273
+ // a flag on it because the two render differently everywhere they appear.
274
+ if (column.type === 'datetime') {
275
+ const at = toDate(value)
276
+ return Number.isNaN(at.getTime())
277
+ ? String(value)
278
+ : at.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
279
+ }
173
280
  if (column.type === 'boolean')
174
281
  return value ? 'Yes' : 'No'
175
282
 
@@ -217,6 +324,31 @@ function badgeClass(row: DashboardTableRow, column: DashboardTableColumn): strin
217
324
  return `${BADGE_BASE} ${column.badgeClasses?.[key] ?? BADGE_NEUTRAL}`
218
325
  }
219
326
 
327
+ /**
328
+ * Whether this row gets a pill at all.
329
+ *
330
+ * A badge column with `emptyText: ''` is a flag: it marks the rows that have
331
+ * the value and leaves the rest blank, rather than drawing an empty pill
332
+ * around a dash on every other row.
333
+ */
334
+ function hasBadge(row: DashboardTableRow, column: DashboardTableColumn): boolean {
335
+ return badgeLabel(row, column) !== ''
336
+ }
337
+
338
+ /**
339
+ * Parse a stored timestamp.
340
+ *
341
+ * SQLite hands back `2026-01-01 12:00:00`, with a space and no zone. `new
342
+ * Date()` reads that as local time where it reads it at all, so every row
343
+ * shifted by the viewer's offset - a date near midnight landing on the wrong
344
+ * day. The hand-rolled tables each normalised it themselves; this is the same
345
+ * knowledge in the one place that needs it.
346
+ */
347
+ function toDate(value: unknown): Date {
348
+ const text = String(value)
349
+ return new Date(/^\d{4}-\d{2}-\d{2} \d/.test(text) ? `${text.replace(' ', 'T')}Z` : text)
350
+ }
351
+
220
352
  /** The pill's text: the mapped label, else the value with its underscores read as spaces. */
221
353
  function badgeLabel(row: DashboardTableRow, column: DashboardTableColumn): string {
222
354
  const value = rawValue(row, column.key)
@@ -244,8 +376,11 @@ function compare(a: any, b: any, type: DashboardTableColumn['type']): number {
244
376
 
245
377
  if (type === 'number' || type === 'currency')
246
378
  return Number(a) - Number(b)
247
- if (type === 'date')
248
- return new Date(a).getTime() - new Date(b).getTime()
379
+ // `datetime` sorts as a date too. Left out, it fell through to the string
380
+ // comparison below, which is only accidentally right for ISO timestamps and
381
+ // wrong for every other format.
382
+ if (type === 'date' || type === 'datetime')
383
+ return toDate(a).getTime() - toDate(b).getTime()
249
384
  if (type === 'boolean')
250
385
  return (a ? 1 : 0) - (b ? 1 : 0)
251
386
 
@@ -456,6 +591,55 @@ function rowClass(index: number): string {
456
591
  ].filter(Boolean).join(' ')
457
592
  }
458
593
 
594
+ /**
595
+ * Whether any column opted into the `cell` slot.
596
+ *
597
+ * Once one does, only those columns route through the slot and the rest keep
598
+ * the built-in rendering. A table that marks none of them behaves as it always
599
+ * did: the slot, when given, takes every cell. `$slots` is template-only, so
600
+ * the presence of the slot is checked alongside this in the markup.
601
+ */
602
+ function hasSlottedColumns(): boolean {
603
+ return columns().some(column => column.slotted)
604
+ }
605
+
606
+ /** Whether the actions column is shown at all. */
607
+ function hasRowActions(): boolean {
608
+ return rowActions().length > 0
609
+ }
610
+
611
+ /** The quieter second line, when the column asked for one and the row has it. */
612
+ function subtitleValue(row: DashboardTableRow, column: DashboardTableColumn): string {
613
+ if (!column.subtitleKey)
614
+ return ''
615
+ const value = rawValue(row, column.subtitleKey)
616
+ return value === null || value === undefined ? '' : String(value)
617
+ }
618
+
619
+ /** "Edit Kilogram", built from the action's label and the row's own name. */
620
+ function rowActionLabel(row: DashboardTableRow, action: DashboardTableRowAction): string {
621
+ const key = actionLabelKey() || rowKey()
622
+ const name = rawValue(row, key)
623
+ return name === null || name === undefined || name === '' ? action.label : `${action.label} ${String(name)}`
624
+ }
625
+
626
+ /** Whether this action applies to this row. */
627
+ function showsRowAction(row: DashboardTableRow, action: DashboardTableRowAction): boolean {
628
+ return !action.when || rawValue(row, action.when.key) === action.when.equals
629
+ }
630
+
631
+ /** Whether this row has a write in flight, so its actions are inert. */
632
+ function rowIsBusy(row: DashboardTableRow): boolean {
633
+ const value = busyValue()
634
+ if (value === '' || value === null || value === undefined)
635
+ return false
636
+ return rawValue(row, busyKey() || rowKey()) === value
637
+ }
638
+
639
+ function rowActionIconClass(action: DashboardTableRowAction): string {
640
+ return `block h-4 w-4 ${action.class || 'text-blue-500'} ${action.icon}`
641
+ }
642
+
459
643
  function cellClass(column: DashboardTableColumn, index: number): string {
460
644
  return [
461
645
  index === 0 && !selectable() ? 'pl-6' : 'pl-4',
@@ -476,8 +660,13 @@ function cellClass(column: DashboardTableColumn, index: number): string {
476
660
  </div>
477
661
 
478
662
  <!-- Toolbar: only rendered when something asked for it. -->
663
+ {{-- `$slots.toolbar` used to be the last arm of this `||`, which only ever
664
+ worked because one of the calls before it was usually true: `$slots`
665
+ does not resolve inside a compound expression, so a table whose ONLY
666
+ toolbar content came from the slot rendered no toolbar at all. The
667
+ slot-only case gets its own branch below. See stacksjs/stx#2098. --}}
479
668
  <div
480
- :if="searchable() || columnToggle() || exportable() || $slots.toolbar"
669
+ :if="searchable() || columnToggle() || exportable()"
481
670
  class="flex flex-wrap gap-3 items-center justify-between px-4 py-3 border-b border-stone-200/80 dark:border-neutral-700"
482
671
  >
483
672
  <div class="flex flex-wrap gap-2 items-center">
@@ -552,6 +741,17 @@ function cellClass(column: DashboardTableColumn, index: number): string {
552
741
  </div>
553
742
  </div>
554
743
 
744
+ {{-- A table that only contributes toolbar content through the slot, with
745
+ none of the built-in controls switched on. --}}
746
+ <div
747
+ :else-if="$slots.toolbar"
748
+ class="flex flex-wrap gap-3 items-center justify-between px-4 py-3 border-b border-stone-200/80 dark:border-neutral-700"
749
+ >
750
+ <div class="flex flex-wrap gap-2 items-center">
751
+ <slot name="toolbar" />
752
+ </div>
753
+ </div>
754
+
555
755
  <div class="overflow-x-auto">
556
756
  <table class="w-full">
557
757
  <thead :class="'bg-stone-50/80 dark:bg-neutral-800/50 ' + (stickyHeader() ? 'sticky top-0 z-20' : '')">
@@ -591,7 +791,12 @@ function cellClass(column: DashboardTableColumn, index: number): string {
591
791
  </th>
592
792
  </template>
593
793
 
794
+ {{-- `$slots` only resolves as a standalone reference: inside a
795
+ compound expression like `$slots.actions || hasRowActions()` the
796
+ whole condition comes out falsy, so the column silently vanished.
797
+ Hence the pair. See stacksjs/stx#2098. --}}
594
798
  <th :if="$slots.actions" scope="col" class="px-4 py-3 w-12"><span class="sr-only">Actions</span></th>
799
+ <th :else-if="hasRowActions()" scope="col" class="px-4 py-3 w-12"><span class="sr-only">Actions</span></th>
595
800
  </tr>
596
801
 
597
802
  <!-- Per-column filter row. -->
@@ -623,6 +828,7 @@ function cellClass(column: DashboardTableColumn, index: number): string {
623
828
  </td>
624
829
  </template>
625
830
  <td :if="$slots.actions" class="px-4 py-2"></td>
831
+ <td :else-if="hasRowActions()" class="px-4 py-2"></td>
626
832
  </tr>
627
833
  </thead>
628
834
 
@@ -641,16 +847,45 @@ function cellClass(column: DashboardTableColumn, index: number): string {
641
847
 
642
848
  <template :for="(column, columnIndex) in visibleColumns()">
643
849
  <td :class="cellClass(column, columnIndex)">
644
- <slot name="cell" :row="row" :column="column" :value="row[column.key]">
645
- <span :if="column.type === 'badge'" :class="badgeClass(row, column)">{{ badgeLabel(row, column) }}</span>
850
+ {{-- No `$slots` here: it only resolves standalone, so the
851
+ compound condition this used to carry made every slotted
852
+ cell render nothing. The slot's own fallback covers the
853
+ "caller gave no slot" case instead. See stacksjs/stx#2098. --}}
854
+ <template :if="column.slotted || !hasSlottedColumns()">
855
+ <slot name="cell" :row="row" :column="column" :value="row[column.key]">
856
+ <span :if="column.type === 'badge' && hasBadge(row, column)" :class="badgeClass(row, column)">{{ badgeLabel(row, column) }}</span>
857
+ <span :if="column.type !== 'badge'">{{ displayValue(row, column) }}</span>
858
+ <p :if="subtitleValue(row, column)" class="mt-1 text-neutral-500 text-xs dark:text-neutral-400">{{ subtitleValue(row, column) }}</p>
859
+ </slot>
860
+ </template>
861
+ <template :else>
862
+ <span :if="column.type === 'badge' && hasBadge(row, column)" :class="badgeClass(row, column)">{{ badgeLabel(row, column) }}</span>
646
863
  <span :if="column.type !== 'badge'">{{ displayValue(row, column) }}</span>
647
- </slot>
864
+ <p :if="subtitleValue(row, column)" class="mt-1 text-neutral-500 text-xs dark:text-neutral-400">{{ subtitleValue(row, column) }}</p>
865
+ </template>
648
866
  </td>
649
867
  </template>
650
868
 
651
869
  <td :if="$slots.actions" class="px-4 w-12" :class="compact() ? 'py-2' : 'py-3'">
652
870
  <slot name="actions" :row="row" />
653
871
  </td>
872
+ <td :else-if="hasRowActions()" class="px-4 w-12" :class="compact() ? 'py-2' : 'py-3'">
873
+ <div class="flex gap-1 justify-end">
874
+ <template :for="action in rowActions()">
875
+ <Button
876
+ :if="showsRowAction(row, action)"
877
+ variant="ghost"
878
+ size="sm"
879
+ iconOnly
880
+ :disabled="rowIsBusy(row)"
881
+ :ariaLabel="rowActionLabel(row, action)"
882
+ @click="emit('row-action', { action: action.action, row })"
883
+ >
884
+ <span aria-hidden="true" :class="rowActionIconClass(action)"></span>
885
+ </Button>
886
+ </template>
887
+ </div>
888
+ </td>
654
889
  </tr>
655
890
  </template>
656
891
  </tbody>
@@ -45,12 +45,46 @@ export interface ResolvedDashboardToggles {
45
45
  data: DashboardDataRowToggles
46
46
  }
47
47
 
48
- export function defaultDashboardToggles(): ResolvedDashboardToggles {
48
+ /**
49
+ * Dashboard sections that mirror an installable feature.
50
+ *
51
+ * A section whose feature is off used to stay in the sidebar anyway, and every
52
+ * page under it failed: the models those pages query are never loaded, so the
53
+ * first call came back `Manufacturer.orderByDesc is not a function` and the
54
+ * endpoint 503'd. Switching commerce off took two edits in two files -
55
+ * `config/commerce.ts` and `config/dashboard.ts` - and missing the second left
56
+ * a Commerce section that could not load. See stacksjs/stacks#2928.
57
+ *
58
+ * `library`, `analytics`, `management` and `utilities` are deliberately absent:
59
+ * they have no feature of their own, so they stay on.
60
+ */
61
+ const FEATURE_BACKED_SECTIONS = {
62
+ commerce: 'commerce',
63
+ content: 'cms',
64
+ marketing: 'marketing',
65
+ } as const
66
+
67
+ /**
68
+ * Reads whether a feature is on. Supplied by the caller rather than imported,
69
+ * because this module is loaded on the dashboard's synchronous path and stays
70
+ * free of `@stacksjs/*` imports - its sibling `sidebar.ts` says why.
71
+ *
72
+ * Omitted, every section defaults on exactly as it did before.
73
+ */
74
+ export type FeatureResolver = (name: string) => boolean
75
+
76
+ function sectionFallback(section: keyof typeof FEATURE_BACKED_SECTIONS, isFeatureEnabled?: FeatureResolver): boolean {
77
+ if (!isFeatureEnabled)
78
+ return true
79
+ return isFeatureEnabled(FEATURE_BACKED_SECTIONS[section])
80
+ }
81
+
82
+ export function defaultDashboardToggles(isFeatureEnabled?: FeatureResolver): ResolvedDashboardToggles {
49
83
  return {
50
84
  library: true,
51
- content: true,
52
- commerce: true,
53
- marketing: true,
85
+ content: sectionFallback('content', isFeatureEnabled),
86
+ commerce: sectionFallback('commerce', isFeatureEnabled),
87
+ marketing: sectionFallback('marketing', isFeatureEnabled),
54
88
  analytics: true,
55
89
  management: true,
56
90
  utilities: true,
@@ -66,7 +100,7 @@ export function defaultDashboardToggles(): ResolvedDashboardToggles {
66
100
  }
67
101
  }
68
102
 
69
- export function resolveDashboardToggles(value: unknown): ResolvedDashboardToggles {
103
+ export function resolveDashboardToggles(value: unknown, isFeatureEnabled?: FeatureResolver): ResolvedDashboardToggles {
70
104
  const config = objectValue(value, 'dashboard config')
71
105
  const sections = config.sections === undefined
72
106
  ? {}
@@ -77,9 +111,11 @@ export function resolveDashboardToggles(value: unknown): ResolvedDashboardToggle
77
111
 
78
112
  return {
79
113
  library: sectionEnabled(sections.library, 'library', true),
80
- content: sectionEnabled(sections.content, 'content', true),
81
- commerce: sectionEnabled(sections.commerce, 'commerce', true),
82
- marketing: sectionEnabled(sections.marketing, 'marketing', true),
114
+ // An explicit `enabled` in config/dashboard.ts still wins, both ways: an
115
+ // app can show a section whose feature is off, or hide one that is on.
116
+ content: sectionEnabled(sections.content, 'content', sectionFallback('content', isFeatureEnabled)),
117
+ commerce: sectionEnabled(sections.commerce, 'commerce', sectionFallback('commerce', isFeatureEnabled)),
118
+ marketing: sectionEnabled(sections.marketing, 'marketing', sectionFallback('marketing', isFeatureEnabled)),
83
119
  analytics: sectionEnabled(sections.analytics, 'analytics', true),
84
120
  management: sectionEnabled(sections.management, 'management', true),
85
121
  utilities: sectionEnabled(sections.utilities, 'utilities', true),
@@ -157,16 +193,16 @@ export interface LoadedDashboardConfig {
157
193
  * One import rather than two: the config module is cache-busted on mtime, so a
158
194
  * second load of the same file would be a second evaluation of user code.
159
195
  */
160
- export async function loadDashboardConfig(configPath: string): Promise<LoadedDashboardConfig> {
196
+ export async function loadDashboardConfig(configPath: string, isFeatureEnabled?: FeatureResolver): Promise<LoadedDashboardConfig> {
161
197
  if (!existsSync(configPath))
162
- return { toggles: defaultDashboardToggles(), nav: [] }
198
+ return { toggles: defaultDashboardToggles(isFeatureEnabled), nav: [] }
163
199
 
164
200
  try {
165
201
  const moduleUrl = pathToFileURL(configPath)
166
202
  moduleUrl.searchParams.set('dashboard-config-mtime', String(statSync(configPath).mtimeMs))
167
203
  const configModule = await import(moduleUrl.href) as { default?: unknown }
168
204
  return {
169
- toggles: resolveDashboardToggles(configModule.default),
205
+ toggles: resolveDashboardToggles(configModule.default, isFeatureEnabled),
170
206
  nav: resolveDashboardNav(configModule.default),
171
207
  }
172
208
  }
@@ -58,9 +58,16 @@ export default {
58
58
  * defines `<Button />`, and cannot quietly replace the framework's.
59
59
  */
60
60
  components: [
61
- library,
62
61
  // Relative to this file, which is what stx resolves plugin entries against.
63
62
  'resources/components',
63
+ // `library` is a package root too, and it used to sit above the line
64
+ // before this one - so for 16 names present in both trees the package
65
+ // answered, which is the exact thing the paragraph above says cannot
66
+ // happen. `<EmptyState>` was the visible one: the framework's takes an
67
+ // Iconify class, the package's takes raw SVG and prints it through
68
+ // `{!! icon !!}`, so ten dashboard components rendered the class name as
69
+ // text inside a grey circle. See stacksjs/stacks#2927.
70
+ library,
64
71
  ...packageDirs(),
65
72
  ],
66
73
  }