@omnitend/dashboard-for-laravel 0.44.0 → 0.46.0
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.
- package/dist/assets/IBMPlexSans-Bold-Latin1-914f1400.woff2 +0 -0
- package/dist/assets/IBMPlexSans-Medium-Latin1-b5610af0.woff2 +0 -0
- package/dist/assets/IBMPlexSans-Regular-Latin1-b5ad7bd3.woff2 +0 -0
- package/dist/assets/IBMPlexSans-SemiBold-Latin1-fff0ab3a.woff2 +0 -0
- package/dist/components/base/DFormFile.vue.d.ts +17 -0
- package/dist/components/base/DFormValidFeedback.vue.d.ts +12 -0
- package/dist/components/extended/DXDashboard.vue.d.ts +95 -111
- package/dist/components/extended/DXDashboardNavbar.vue.d.ts +2 -36
- package/dist/components/extended/DXDashboardSidebar.vue.d.ts +60 -79
- package/dist/components/extended/DXLoading.vue.d.ts +3 -0
- package/dist/components/extended/DXTable.vue.d.ts +10 -1
- package/dist/dashboard-for-laravel.js +9783 -9564
- package/dist/dashboard-for-laravel.js.map +1 -1
- package/dist/dashboard-for-laravel.umd.cjs +7 -7
- package/dist/dashboard-for-laravel.umd.cjs.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/style.css +2 -2
- package/dist/types/navigation.d.ts +153 -3
- package/dist/utils/statCardFit.d.ts +19 -0
- package/docs/public/api-reference.json +50 -8
- package/docs/public/docs-map.md +4 -2
- package/docs/public/llms.txt +4 -2
- package/package.json +3 -2
- package/resources/css/theme.scss +384 -4
- package/resources/fonts/ibm-plex-sans/IBMPlexSans-Bold-Latin1.woff2 +0 -0
- package/resources/fonts/ibm-plex-sans/IBMPlexSans-Medium-Latin1.woff2 +0 -0
- package/resources/fonts/ibm-plex-sans/IBMPlexSans-Regular-Latin1.woff2 +0 -0
- package/resources/fonts/ibm-plex-sans/IBMPlexSans-SemiBold-Latin1.woff2 +0 -0
- package/resources/fonts/ibm-plex-sans/OFL.txt +93 -0
- package/resources/fonts/ibm-plex-sans/SOURCE.txt +18 -0
- package/resources/js/components/base/DFormFile.vue +103 -0
- package/resources/js/components/base/DFormValidFeedback.vue +16 -0
- package/resources/js/components/extended/DXDashboard.vue +41 -18
- package/resources/js/components/extended/DXDashboardNavbar.vue +9 -1
- package/resources/js/components/extended/DXDashboardSidebar.vue +25 -3
- package/resources/js/components/extended/DXField.vue +36 -8
- package/resources/js/components/extended/DXLoading.vue +18 -11
- package/resources/js/components/extended/DXStatCard.vue +154 -11
- package/resources/js/components/extended/DXTable.vue +60 -10
- package/resources/js/index.ts +9 -0
- package/resources/js/types/navigation.ts +175 -3
- package/resources/js/utils/statCardFit.ts +210 -0
package/resources/js/index.ts
CHANGED
|
@@ -62,9 +62,11 @@ export { default as DDropdownItem } from "./components/base/DDropdownItem.vue";
|
|
|
62
62
|
export { default as DForm } from "./components/base/DForm.vue";
|
|
63
63
|
export { default as DFormCheckbox } from "./components/base/DFormCheckbox.vue";
|
|
64
64
|
export { default as DFormCheckboxGroup } from "./components/base/DFormCheckboxGroup.vue";
|
|
65
|
+
export { default as DFormFile } from "./components/base/DFormFile.vue";
|
|
65
66
|
export { default as DFormGroup } from "./components/base/DFormGroup.vue";
|
|
66
67
|
export { default as DFormInput } from "./components/base/DFormInput.vue";
|
|
67
68
|
export { default as DFormInvalidFeedback } from "./components/base/DFormInvalidFeedback.vue";
|
|
69
|
+
export { default as DFormValidFeedback } from "./components/base/DFormValidFeedback.vue";
|
|
68
70
|
export { default as DFormOtp } from "./components/base/DFormOtp.vue";
|
|
69
71
|
export { default as DFormRadio } from "./components/base/DFormRadio.vue";
|
|
70
72
|
export { default as DFormRadioGroup } from "./components/base/DFormRadioGroup.vue";
|
|
@@ -216,7 +218,14 @@ export type {
|
|
|
216
218
|
NavigationItem,
|
|
217
219
|
NavigationGroup,
|
|
218
220
|
Navigation,
|
|
221
|
+
NavigationItemOf,
|
|
219
222
|
NavbarSearchAlign,
|
|
220
223
|
NavbarActionsOnMobile,
|
|
221
224
|
SidebarVariant,
|
|
225
|
+
DashboardUser,
|
|
226
|
+
DashboardNavbarSlots,
|
|
227
|
+
DashboardSidebarSlots,
|
|
228
|
+
DashboardSlotExtras,
|
|
229
|
+
DashboardSlots,
|
|
230
|
+
PrefixedDashboardSlots,
|
|
222
231
|
} from "./types/navigation";
|
|
@@ -8,7 +8,13 @@ export interface NavigationItem {
|
|
|
8
8
|
visible?: boolean;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* A group of sidebar links. `TItem` is the app's own item type, for menus whose
|
|
13
|
+
* items carry extra keys (a permission, a route name): DXDashboard and
|
|
14
|
+
* DXDashboardSidebar infer it from the `navigation` prop, so the `link` slot's
|
|
15
|
+
* `item` has those keys too. Defaults to `NavigationItem`.
|
|
16
|
+
*/
|
|
17
|
+
export interface NavigationGroup<TItem extends NavigationItem = NavigationItem> {
|
|
12
18
|
label?: string;
|
|
13
19
|
/**
|
|
14
20
|
* Stable identifier for the group's collapsible open/closed state. Defaults
|
|
@@ -17,7 +23,7 @@ export interface NavigationGroup {
|
|
|
17
23
|
* state attached to the right group.
|
|
18
24
|
*/
|
|
19
25
|
key?: string;
|
|
20
|
-
items:
|
|
26
|
+
items: TItem[];
|
|
21
27
|
/**
|
|
22
28
|
* Per-group override for the sidebar's `collapsibleGroups` behaviour.
|
|
23
29
|
* Set to `false` to keep a group permanently expanded (no toggle header)
|
|
@@ -29,7 +35,26 @@ export interface NavigationGroup {
|
|
|
29
35
|
visible?: boolean;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
/** The sidebar's navigation: its groups, in order. See `NavigationGroup` for `TItem`. */
|
|
39
|
+
export type Navigation<TItem extends NavigationItem = NavigationItem> = NavigationGroup<TItem>[];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The item type of a navigation, as the `link` slot's `item` receives it:
|
|
43
|
+
* `NavigationItemOf<Navigation<AppItem>>` is `AppItem`. An `any` navigation
|
|
44
|
+
* (untyped page props) gives `any`, and an empty `[]` gives `NavigationItem`.
|
|
45
|
+
*
|
|
46
|
+
* DXDashboard and DXDashboardSidebar are generic over the whole navigation
|
|
47
|
+
* (`TNavigation`) and derive the item from it, rather than generic over the
|
|
48
|
+
* item: TypeScript infers `any` for a type parameter that IS the prop's type,
|
|
49
|
+
* but not for one nested inside it (`{ items: TItem[] }[]`), where an `any`
|
|
50
|
+
* navigation would have fallen back to `NavigationItem` and rejected the app's
|
|
51
|
+
* extra keys.
|
|
52
|
+
*/
|
|
53
|
+
export type NavigationItemOf<TNavigation extends Navigation> = [
|
|
54
|
+
TNavigation[number]["items"][number],
|
|
55
|
+
] extends [never]
|
|
56
|
+
? NavigationItem
|
|
57
|
+
: TNavigation[number]["items"][number];
|
|
33
58
|
|
|
34
59
|
/**
|
|
35
60
|
* Horizontal alignment of the navbar search slot content within its region:
|
|
@@ -54,3 +79,150 @@ export type SidebarVariant = "light" | "dark";
|
|
|
54
79
|
* Shared by DXDashboard (which forwards it) and DXDashboardNavbar.
|
|
55
80
|
*/
|
|
56
81
|
export type NavbarActionsOnMobile = "wrap" | "hide";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The signed-in user DXDashboardNavbar (and DXDashboard, which forwards it)
|
|
85
|
+
* shows in the avatar dropdown. Extra keys are allowed and reach the
|
|
86
|
+
* user-menu slots.
|
|
87
|
+
*/
|
|
88
|
+
export interface DashboardUser {
|
|
89
|
+
name: string;
|
|
90
|
+
email: string;
|
|
91
|
+
[key: string]: any;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A slot that receives no props. */
|
|
95
|
+
type NoSlotProps = Record<never, never>;
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* The slot interfaces below are what `defineSlots` uses and what an editor
|
|
99
|
+
* shows on hover. The API docs (`docs:generate:ai`) still read each slot from
|
|
100
|
+
* the `@slot`/`@binding` comments in the component's template: vue-docgen-api
|
|
101
|
+
* only reads `defineSlots` members whose keys are bare identifiers, and it
|
|
102
|
+
* drops the binding types when it does, so every key here is QUOTED on
|
|
103
|
+
* purpose. Keep the text in step with the template comments.
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/** Slots of DXDashboardNavbar, with their slot props. All are optional. */
|
|
107
|
+
export interface DashboardNavbarSlots {
|
|
108
|
+
/** Custom hamburger/menu icon. Defaults to a three-line SVG. */
|
|
109
|
+
"menu-icon"?(props: NoSlotProps): any;
|
|
110
|
+
/**
|
|
111
|
+
* Search input or component. Fills the middle of the bar at every width,
|
|
112
|
+
* aligned per `searchAlign`. The region is a size container named
|
|
113
|
+
* `dx-navbar-search`; a `<kbd>` hint inside it is hidden when it is
|
|
114
|
+
* narrower than 16rem.
|
|
115
|
+
*/
|
|
116
|
+
"search"?(props: NoSlotProps): any;
|
|
117
|
+
/**
|
|
118
|
+
* Page-level primary actions (e.g. a Create button). Right-aligned next to
|
|
119
|
+
* the user menu from `md` up; below, wraps to its own full-width row or is
|
|
120
|
+
* hidden entirely per `actionsOnMobile`.
|
|
121
|
+
*/
|
|
122
|
+
"actions"?(props: {
|
|
123
|
+
/** The current page title, for context. */
|
|
124
|
+
pageTitle: string;
|
|
125
|
+
}): any;
|
|
126
|
+
/** Replaces the entire user menu (the default avatar dropdown). */
|
|
127
|
+
"user-menu"?(props: {
|
|
128
|
+
/** The signed-in user; `null` for a guest. */
|
|
129
|
+
user: DashboardUser | null;
|
|
130
|
+
}): any;
|
|
131
|
+
/**
|
|
132
|
+
* Custom user avatar/icon in the dropdown trigger. To decorate the default
|
|
133
|
+
* avatar (e.g. add a notification dot) rather than replace it, render
|
|
134
|
+
* `DXUserAvatar`. Only rendered for a signed-in user.
|
|
135
|
+
*/
|
|
136
|
+
"user-icon"?(props: {
|
|
137
|
+
/** The first letter of the user's name. */
|
|
138
|
+
initial: string;
|
|
139
|
+
/** The signed-in user. */
|
|
140
|
+
user: DashboardUser;
|
|
141
|
+
}): any;
|
|
142
|
+
/** Items for the default user dropdown menu. Only rendered for a signed-in user. */
|
|
143
|
+
"user-menu-items"?(props: {
|
|
144
|
+
/** The signed-in user. */
|
|
145
|
+
user: DashboardUser;
|
|
146
|
+
}): any;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Slots of DXDashboardSidebar, with their slot props. All are optional.
|
|
151
|
+
* `TItem` is the navigation's item type (see `NavigationGroup`).
|
|
152
|
+
*/
|
|
153
|
+
export interface DashboardSidebarSlots<TItem extends NavigationItem = NavigationItem> {
|
|
154
|
+
/**
|
|
155
|
+
* Brand/logo area in the sidebar header. Defaults to the title's initial
|
|
156
|
+
* plus the full title (hidden when collapsed).
|
|
157
|
+
*/
|
|
158
|
+
"brand"?(props: {
|
|
159
|
+
/** Whether the sidebar rail is collapsed to icons only. */
|
|
160
|
+
collapsed: boolean;
|
|
161
|
+
/** The sidebar title text. */
|
|
162
|
+
title: string;
|
|
163
|
+
}): any;
|
|
164
|
+
/**
|
|
165
|
+
* Renders a single navigation item link. Defaults to an anchor with
|
|
166
|
+
* optional icon, label, and badge.
|
|
167
|
+
*/
|
|
168
|
+
"link"?(props: {
|
|
169
|
+
/**
|
|
170
|
+
* The navigation item (label, url, icon, badge, badgeColor), typed as the
|
|
171
|
+
* app's own item type when its items carry extra keys.
|
|
172
|
+
*/
|
|
173
|
+
item: TItem;
|
|
174
|
+
/** Whether this item matches the current route. */
|
|
175
|
+
isActive: boolean;
|
|
176
|
+
/** Whether the sidebar rail is collapsed to icons only. */
|
|
177
|
+
collapsed: boolean;
|
|
178
|
+
/** Whether the item's group is currently expanded. */
|
|
179
|
+
isExpanded: boolean;
|
|
180
|
+
}): any;
|
|
181
|
+
/**
|
|
182
|
+
* Utility/secondary links pinned to the bottom of the sidebar (help,
|
|
183
|
+
* changelog, sign-out, …), below the nav groups.
|
|
184
|
+
*/
|
|
185
|
+
"footer"?(props: {
|
|
186
|
+
/** Whether the sidebar rail is collapsed to icons only. */
|
|
187
|
+
collapsed: boolean;
|
|
188
|
+
}): any;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The two bindings DXDashboard adds to every forwarded `sidebar-*` and
|
|
193
|
+
* `navbar-*` slot. They are bound after the child's own slot props, so they
|
|
194
|
+
* win a name clash.
|
|
195
|
+
*/
|
|
196
|
+
export interface DashboardSlotExtras {
|
|
197
|
+
/** Shows/hides the sidebar. */
|
|
198
|
+
toggleSidebar: () => void;
|
|
199
|
+
/** Whether the sidebar is currently hidden. */
|
|
200
|
+
sidebarHidden: boolean;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The props a (possibly optional) slot function receives. */
|
|
204
|
+
type SlotPropsOf<Slot> = NonNullable<Slot> extends (props: infer Props) => any ? Props : never;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A child component's slots renamed with `Prefix` and given the dashboard's
|
|
208
|
+
* extra bindings: `{ brand(props: P) }` becomes
|
|
209
|
+
* `{ "sidebar-brand"(props: P & DashboardSlotExtras) }`. Derived from the
|
|
210
|
+
* child's slot types, so a slot added to the child is typed here for free.
|
|
211
|
+
*/
|
|
212
|
+
export type PrefixedDashboardSlots<Slots, Prefix extends string> = {
|
|
213
|
+
[Name in keyof Slots as `${Prefix}${Name & string}`]?: (
|
|
214
|
+
props: Omit<SlotPropsOf<Slots[Name]>, keyof DashboardSlotExtras> & DashboardSlotExtras,
|
|
215
|
+
) => any;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Slots of DXDashboard: the page content (`default`), every
|
|
220
|
+
* DXDashboardSidebar slot as `sidebar-<name>` and every DXDashboardNavbar
|
|
221
|
+
* slot as `navbar-<name>`. An unprefixed name (`#search`) is not forwarded.
|
|
222
|
+
* `TItem` is the navigation's item type (see `NavigationGroup`).
|
|
223
|
+
*/
|
|
224
|
+
export type DashboardSlots<TItem extends NavigationItem = NavigationItem> = {
|
|
225
|
+
/** The main page content. */
|
|
226
|
+
default?(props: NoSlotProps): any;
|
|
227
|
+
} & PrefixedDashboardSlots<DashboardSidebarSlots<TItem>, "sidebar-"> &
|
|
228
|
+
PrefixedDashboardSlots<DashboardNavbarSlots, "navbar-">;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* DXStatCard's value fit (internal, not exported from the package).
|
|
3
|
+
*
|
|
4
|
+
* Fit to content (#213): a numeric value is 1.75rem unless it would overflow
|
|
5
|
+
* its box, and then the largest size (to 0.25px) at which it fits, down to
|
|
6
|
+
* the 1.125rem floor.
|
|
7
|
+
*
|
|
8
|
+
* Measure and check: every candidate size is applied and the value's real
|
|
9
|
+
* rendered width compared with the box. Nothing assumes the width is
|
|
10
|
+
* proportional to the font size, because it is not: Linux Chromium rounds
|
|
11
|
+
* glyph advances per size, so the width rises in steps of several pixels,
|
|
12
|
+
* and slot content can have fixed-size parts (px padding, a fixed-width
|
|
13
|
+
* marker). The search tries 1.75rem, then the proportional estimate, then
|
|
14
|
+
* halves the remaining interval, always keeping the largest size that fitted
|
|
15
|
+
* and the smallest that did not, so the result fits by construction.
|
|
16
|
+
*
|
|
17
|
+
* Every card due a fit joins one queue, flushed in rounds: write a candidate
|
|
18
|
+
* size on every unfinished card, then read them all (one forced layout per
|
|
19
|
+
* round, not one per card). A short value is done after round one; a long
|
|
20
|
+
* one takes at most MAX_ROUNDS.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One card's fit: its elements, and the box width it was last fitted to. */
|
|
24
|
+
export interface FitTarget {
|
|
25
|
+
box: HTMLElement;
|
|
26
|
+
value: HTMLElement;
|
|
27
|
+
/** Only numeric values are fitted; text wraps instead. */
|
|
28
|
+
isNumeric: () => boolean;
|
|
29
|
+
/** -1 when the next resize entry must refit whatever its width. */
|
|
30
|
+
fittedBoxWidth: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FIT_SIZE_PROPERTY = "--dx-stat-card-fit-size";
|
|
34
|
+
const MAX_SIZE_REM = 1.75;
|
|
35
|
+
const FLOOR_SIZE_REM = 1.125;
|
|
36
|
+
/** The fit's precision: sizes are tried on a 0.25px grid. */
|
|
37
|
+
const SIZE_STEP_PX = 0.25;
|
|
38
|
+
/** Layout rounding (1/64px) never counts as overflow. */
|
|
39
|
+
const FIT_EPSILON_PX = 0.02;
|
|
40
|
+
/** 1.75rem, the estimate, then halving 10px down to 0.25px (6 halvings). */
|
|
41
|
+
const MAX_ROUNDS = 8;
|
|
42
|
+
|
|
43
|
+
interface Search {
|
|
44
|
+
target: FitTarget;
|
|
45
|
+
boxWidth: number;
|
|
46
|
+
/** Largest size known to fit (or the floor, which is used regardless). */
|
|
47
|
+
fitting: number;
|
|
48
|
+
/** Smallest size known to overflow. */
|
|
49
|
+
overflowing: number;
|
|
50
|
+
/** The size written this round. */
|
|
51
|
+
candidate: number;
|
|
52
|
+
/** The size the value had before this fit, kept if it cannot be measured. */
|
|
53
|
+
previousSize: string;
|
|
54
|
+
measurable: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const queuedTargets = new Set<FitTarget>();
|
|
58
|
+
const registeredTargets = new Set<FitTarget>();
|
|
59
|
+
let microtaskQueued = false;
|
|
60
|
+
let frameQueued = false;
|
|
61
|
+
|
|
62
|
+
const snapDown = (size: number): number => Math.floor(size / SIZE_STEP_PX) * SIZE_STEP_PX;
|
|
63
|
+
|
|
64
|
+
/** The next size to try, strictly between the bounds, or null when settled. */
|
|
65
|
+
function nextCandidate(search: Search, estimate: number | null): number | null {
|
|
66
|
+
const low = search.fitting + SIZE_STEP_PX;
|
|
67
|
+
const high = search.overflowing - SIZE_STEP_PX;
|
|
68
|
+
if (low > high + 0.001) return null;
|
|
69
|
+
const ideal = estimate ?? (search.fitting + search.overflowing) / 2;
|
|
70
|
+
return Math.min(high, Math.max(low, snapDown(ideal)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function flushFits(): void {
|
|
74
|
+
microtaskQueued = false;
|
|
75
|
+
frameQueued = false;
|
|
76
|
+
const targets: FitTarget[] = [];
|
|
77
|
+
for (const target of queuedTargets) {
|
|
78
|
+
if (target.value.isConnected) {
|
|
79
|
+
targets.push(target);
|
|
80
|
+
} else {
|
|
81
|
+
// Detached (a deactivated <KeepAlive>): it comes back with a box of
|
|
82
|
+
// the width it left with, which a resize entry would skip, so it
|
|
83
|
+
// forgets that width and refits on return.
|
|
84
|
+
target.fittedBoxWidth = -1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
queuedTargets.clear();
|
|
88
|
+
if (targets.length === 0) return;
|
|
89
|
+
|
|
90
|
+
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
91
|
+
const maxSize = MAX_SIZE_REM * rootFontSize;
|
|
92
|
+
const floorSize = FLOOR_SIZE_REM * rootFontSize;
|
|
93
|
+
|
|
94
|
+
// Round one: every numeric value at 1.75rem, laid out at its natural
|
|
95
|
+
// width (the box is inline-size contained, so this never changes the
|
|
96
|
+
// box's own width).
|
|
97
|
+
let searches: Search[] = [];
|
|
98
|
+
for (const target of targets) {
|
|
99
|
+
if (!target.isNumeric()) {
|
|
100
|
+
target.value.style.removeProperty(FIT_SIZE_PROPERTY);
|
|
101
|
+
target.fittedBoxWidth = -1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const previousSize = target.value.style.getPropertyValue(FIT_SIZE_PROPERTY);
|
|
105
|
+
target.value.style.width = "max-content";
|
|
106
|
+
target.value.style.setProperty(FIT_SIZE_PROPERTY, `${maxSize}px`);
|
|
107
|
+
searches.push({
|
|
108
|
+
target,
|
|
109
|
+
boxWidth: 0,
|
|
110
|
+
fitting: floorSize,
|
|
111
|
+
overflowing: maxSize,
|
|
112
|
+
candidate: maxSize,
|
|
113
|
+
previousSize,
|
|
114
|
+
measurable: true,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const allSearches = searches;
|
|
118
|
+
|
|
119
|
+
for (let round = 0; round < MAX_ROUNDS && searches.length > 0; round += 1) {
|
|
120
|
+
// Read every card (one layout; the boxes too in round one) ...
|
|
121
|
+
const widths = searches.map((search) => {
|
|
122
|
+
if (round === 0) search.boxWidth = search.target.box.getBoundingClientRect().width;
|
|
123
|
+
return search.target.value.getBoundingClientRect().width;
|
|
124
|
+
});
|
|
125
|
+
const unfinished: Search[] = [];
|
|
126
|
+
searches.forEach((search, index) => {
|
|
127
|
+
const naturalWidth = widths[index]!;
|
|
128
|
+
if (round === 0) {
|
|
129
|
+
// In layout px, as the ResizeObserver reports it: `boxWidth` is in
|
|
130
|
+
// transformed px, which differ under a `transform: scale()` ancestor
|
|
131
|
+
// and made every fit's own height change read as a new width. The
|
|
132
|
+
// box has no padding or border, so its computed width is its content
|
|
133
|
+
// width.
|
|
134
|
+
search.target.fittedBoxWidth = parseFloat(getComputedStyle(search.target.box).width);
|
|
135
|
+
// Hidden (display: none) or empty: nothing to fit until it shows.
|
|
136
|
+
if (search.boxWidth <= 0 || naturalWidth <= 0) {
|
|
137
|
+
search.measurable = false;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (naturalWidth <= search.boxWidth + FIT_EPSILON_PX) search.fitting = search.candidate;
|
|
142
|
+
else search.overflowing = search.candidate;
|
|
143
|
+
if (search.fitting >= maxSize) return;
|
|
144
|
+
// ... then write the next candidate on each one still searching.
|
|
145
|
+
// The proportional estimate first: exact where the width scales
|
|
146
|
+
// with the size (macOS), a good first split where it does not.
|
|
147
|
+
const estimate = round === 0 ? (maxSize * search.boxWidth) / naturalWidth : null;
|
|
148
|
+
const candidate = nextCandidate(search, estimate);
|
|
149
|
+
if (candidate === null) return;
|
|
150
|
+
search.candidate = candidate;
|
|
151
|
+
search.target.value.style.setProperty(FIT_SIZE_PROPERTY, `${candidate}px`);
|
|
152
|
+
unfinished.push(search);
|
|
153
|
+
});
|
|
154
|
+
searches = unfinished;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const search of allSearches) {
|
|
158
|
+
const { value } = search.target;
|
|
159
|
+
const size = search.measurable ? `${search.fitting}px` : search.previousSize;
|
|
160
|
+
if (size) value.style.setProperty(FIT_SIZE_PROPERTY, size);
|
|
161
|
+
else value.style.removeProperty(FIT_SIZE_PROPERTY);
|
|
162
|
+
value.style.removeProperty("width");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Queue a fit. "microtask" runs before the next paint, for mounts, content
|
|
168
|
+
* changes, reactivation and font loads. "frame" is for ResizeObserver
|
|
169
|
+
* callbacks: a fit inside the observer's delivery (microtasks run there
|
|
170
|
+
* too) changes the box's HEIGHT, which the browser reports as a
|
|
171
|
+
* "ResizeObserver loop" error, so a resize refits in the next frame.
|
|
172
|
+
*/
|
|
173
|
+
export function queueFit(target: FitTarget, timing: "microtask" | "frame"): void {
|
|
174
|
+
queuedTargets.add(target);
|
|
175
|
+
if (timing === "microtask") {
|
|
176
|
+
if (microtaskQueued) return;
|
|
177
|
+
microtaskQueued = true;
|
|
178
|
+
queueMicrotask(() => {
|
|
179
|
+
if (microtaskQueued) flushFits();
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (frameQueued || microtaskQueued) return;
|
|
184
|
+
frameQueued = true;
|
|
185
|
+
requestAnimationFrame(() => {
|
|
186
|
+
if (frameQueued) flushFits();
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Web fonts (the figures face, the body face) swap in after first paint and
|
|
191
|
+
// change the value's width without changing the box's, so each finished
|
|
192
|
+
// batch of font loads refits every mounted card. One listener serves all
|
|
193
|
+
// cards; it is attached at the first registration, so a load already under
|
|
194
|
+
// way then still ends in a `loadingdone` it hears.
|
|
195
|
+
function refitAll(): void {
|
|
196
|
+
for (const target of registeredTargets) queueFit(target, "microtask");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function registerTarget(target: FitTarget): void {
|
|
200
|
+
registeredTargets.add(target);
|
|
201
|
+
if (registeredTargets.size > 1 || typeof document === "undefined" || !document.fonts) return;
|
|
202
|
+
document.fonts.addEventListener("loadingdone", refitAll);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function unregisterTarget(target: FitTarget): void {
|
|
206
|
+
registeredTargets.delete(target);
|
|
207
|
+
queuedTargets.delete(target);
|
|
208
|
+
if (registeredTargets.size > 0 || typeof document === "undefined" || !document.fonts) return;
|
|
209
|
+
document.fonts.removeEventListener("loadingdone", refitAll);
|
|
210
|
+
}
|