| 1 |
import type { Field } from '@/types/field' |
| 2 |
|
| 3 |
/** |
| 4 |
* Composable for calculating field widths in grid layout |
| 5 |
* |
| 6 |
* Supports: |
| 7 |
* - Auto width based on fields per row (1=100%, 2=50%, 3=33%, 4=25%, 5=20%) |
| 8 |
* - Custom widths from manual resize |
| 9 |
* - Maximum 5 fields per row |
| 10 |
*/ |
| 11 |
export function useFieldWidth() { |
| 12 |
/** |
| 13 |
* Calculate width percentage based on number of fields in row |
| 14 |
* Returns values with one decimal place for precision |
| 15 |
* Maximum fields per row is 5 (minimum 20% each) |
| 16 |
* CSS will handle the gap spacing separately |
| 17 |
*/ |
| 18 |
const getAutoWidth = (fieldsInRow: number): number => { |
| 19 |
if (fieldsInRow === 1) return 100 |
| 20 |
if (fieldsInRow >= 5) return 20 // Max 5 fields = 20% each |
| 21 |
|
| 22 |
// Divide 100% evenly and round to 1 decimal place |
| 23 |
return Math.round((100 / fieldsInRow) * 10) / 10 |
| 24 |
} |
| 25 |
|
| 26 |
/** |
| 27 |
* Group fields by row index |
| 28 |
*/ |
| 29 |
const groupFieldsByRow = (fields: Field[]): Map<number, Field[]> => { |
| 30 |
const rows = new Map<number, Field[]>() |
| 31 |
|
| 32 |
fields.forEach((field) => { |
| 33 |
const rowIndex = field.rowIndex ?? 0 |
| 34 |
if (!rows.has(rowIndex)) { |
| 35 |
rows.set(rowIndex, []) |
| 36 |
} |
| 37 |
rows.get(rowIndex)!.push(field) |
| 38 |
}) |
| 39 |
|
| 40 |
// Sort fields within each row by columnIndex |
| 41 |
rows.forEach((rowFields) => { |
| 42 |
rowFields.sort((a, b) => (a.columnIndex ?? 0) - (b.columnIndex ?? 0)) |
| 43 |
}) |
| 44 |
|
| 45 |
return rows |
| 46 |
} |
| 47 |
|
| 48 |
/** |
| 49 |
* Calculate width for a field |
| 50 |
* Returns custom width if set, otherwise calculates based on fields in row |
| 51 |
*/ |
| 52 |
const getFieldWidth = (field: Field, fieldsInRow: Field[]): number => { |
| 53 |
// If custom width is set, use it |
| 54 |
if (field.width && field.width >= 20 && field.width <= 100) { |
| 55 |
return field.width |
| 56 |
} |
| 57 |
|
| 58 |
// Otherwise calculate based on number of fields in row |
| 59 |
return getAutoWidth(fieldsInRow.length) |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* Get CSS width style for a field |
| 64 |
* Uses calc() to account for gap between fields. Gap can be any valid CSS |
| 65 |
* length value (px/rem/em/etc.) so spacing controls keep their units. |
| 66 |
* |
| 67 |
* @param field - current field |
| 68 |
* @param fieldsInRow - all fields present in the same row |
| 69 |
* @param gap - horizontal gap CSS value (defaults to 12px) |
| 70 |
*/ |
| 71 |
const getFieldWidthStyle = (field: Field, fieldsInRow: Field[], gap: string = '12px'): string => { |
| 72 |
const width = getFieldWidth(field, fieldsInRow) |
| 73 |
const numFields = fieldsInRow.length |
| 74 |
|
| 75 |
// For single field, use full width |
| 76 |
if (numFields === 1) { |
| 77 |
return '100%' |
| 78 |
} |
| 79 |
|
| 80 |
// Calculate width accounting for gaps with preserved CSS units. |
| 81 |
// Formula: calc(width% - (gap * ((numFields - 1) / numFields))). |
| 82 |
const gapFactor = (numFields - 1) / numFields |
| 83 |
return `calc(${width}% - (${gap} * ${gapFactor}))` |
| 84 |
} |
| 85 |
|
| 86 |
/** |
| 87 |
* Check if row can accept more fields (max 5 per row) |
| 88 |
*/ |
| 89 |
const canAddToRow = (fieldsInRow: Field[]): boolean => { |
| 90 |
return fieldsInRow.length < 5 |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* Get next available column index in row |
| 95 |
*/ |
| 96 |
const getNextColumnIndex = (fieldsInRow: Field[]): number => { |
| 97 |
if (fieldsInRow.length === 0) return 0 |
| 98 |
|
| 99 |
// Find the maximum columnIndex and add 1 |
| 100 |
const maxColumn = Math.max(...fieldsInRow.map((f) => f.columnIndex ?? 0)) |
| 101 |
return maxColumn + 1 |
| 102 |
} |
| 103 |
|
| 104 |
/** |
| 105 |
* Distribute widths intelligently when adding a new field to a row |
| 106 |
* - New field gets equal share (1/n where n is total fields after adding) |
| 107 |
* - Existing fields are proportionally scaled to fit remaining space |
| 108 |
* |
| 109 |
* Example: Row has 2 fields at 40% and 60%, adding 3rd field: |
| 110 |
* - New field gets 33.33% (1/3) |
| 111 |
* - Remaining space: 100% - 33.33% = 66.67% |
| 112 |
* - Existing fields scaled proportionally: |
| 113 |
* - Field 1: 40/(40+60) * 66.67% = 26.67% |
| 114 |
* - Field 2: 60/(40+60) * 66.67% = 40% |
| 115 |
*/ |
| 116 |
const distributeWidthsEvenly = (fields: Field[]): Field[] => { |
| 117 |
if (fields.length === 0) return [] |
| 118 |
if (fields.length === 1) return fields.map((f) => ({ ...f, width: 100 })) |
| 119 |
|
| 120 |
// For rows with gaps (12px each), we need to account for gap space |
| 121 |
// Each field should take up width that, when combined with gaps, totals 100% |
| 122 |
// Formula: width% = 100 / fieldCount |
| 123 |
// The calc() in CSS will handle subtracting the gap portion |
| 124 |
const width = Math.round((100 / fields.length) * 10) / 10 |
| 125 |
|
| 126 |
return fields.map((field) => ({ |
| 127 |
...field, |
| 128 |
width: width, |
| 129 |
})) |
| 130 |
} |
| 131 |
|
| 132 |
/** |
| 133 |
* Distribute widths proportionally when adding a new field to existing resized fields |
| 134 |
* Maintains the relative proportions of existing fields |
| 135 |
*/ |
| 136 |
const distributeWidthsProportionally = ( |
| 137 |
existingFields: Field[], |
| 138 |
newFieldCount: number = 1, |
| 139 |
): Field[] => { |
| 140 |
if (existingFields.length === 0) return [] |
| 141 |
|
| 142 |
const totalFieldsAfter = existingFields.length + newFieldCount |
| 143 |
const newFieldWidth = getAutoWidth(totalFieldsAfter) |
| 144 |
const remainingSpace = 100 - newFieldWidth * newFieldCount |
| 145 |
|
| 146 |
// Calculate total width of existing fields |
| 147 |
const existingTotalWidth = existingFields.reduce((sum, f) => sum + (f.width ?? 100), 0) |
| 148 |
|
| 149 |
// Scale existing fields proportionally to fit remaining space |
| 150 |
return existingFields.map((field) => { |
| 151 |
const currentWidth = field.width ?? 100 |
| 152 |
const proportion = |
| 153 |
existingTotalWidth > 0 ? currentWidth / existingTotalWidth : 1 / existingFields.length |
| 154 |
const newWidth = remainingSpace * proportion |
| 155 |
|
| 156 |
return { |
| 157 |
...field, |
| 158 |
width: Math.max(10, Math.min(100, newWidth)), // Clamp between 10% and 100% |
| 159 |
} |
| 160 |
}) |
| 161 |
} |
| 162 |
|
| 163 |
/** |
| 164 |
* Validate and normalize widths in a row |
| 165 |
* Ensures total doesn't exceed 100% and all fields have valid widths |
| 166 |
*/ |
| 167 |
const normalizeRowWidths = (fields: Field[]): Field[] => { |
| 168 |
const totalWidth = fields.reduce((sum, f) => sum + (f.width ?? 100), 0) |
| 169 |
|
| 170 |
// If total exceeds 100%, distribute evenly |
| 171 |
if (totalWidth > 100) { |
| 172 |
return distributeWidthsEvenly(fields) |
| 173 |
} |
| 174 |
|
| 175 |
// Ensure all fields have width set |
| 176 |
return fields.map((field) => ({ |
| 177 |
...field, |
| 178 |
width: field.width ?? getAutoWidth(fields.length), |
| 179 |
})) |
| 180 |
} |
| 181 |
|
| 182 |
return { |
| 183 |
getAutoWidth, |
| 184 |
groupFieldsByRow, |
| 185 |
getFieldWidth, |
| 186 |
getFieldWidthStyle, |
| 187 |
canAddToRow, |
| 188 |
getNextColumnIndex, |
| 189 |
distributeWidthsEvenly, |
| 190 |
distributeWidthsProportionally, |
| 191 |
normalizeRowWidths, |
| 192 |
} |
| 193 |
} |
| 194 |
|