LLChoices
Manages the choice lists a workbook offers to dropdowns and cascading selectors. A choice is named by its list name and carries a long label, a short label and an order number, and the four columns sit on one worksheet reached through a DataSheet. AddChoice appends a list, RemoveChoice deletes one, ChoiceExists answers whether a list is there, Sort orders the sheet and Translate rewrites the labels. AllChoices, Categories and ConcatenateCategories give the lists back. ManageRows, AddRows, InsertRows, DeleteRows and RemoveRows work on the rows of the sheet, and Import and Export move the sheet between workbooks.
WHAT THE INSTANCE HOLDS
A member that needs the four choice columns reads the header row once as a block and matches the names in memory for the rest of that call. Nothing is held between calls: AddChoice appends rows, RemoveChoice deletes whole rows and Import clears the sheet, so the header row and the end row are read again on every call.
EXCEL STATE BELONGS TO THE CALLER
This class leaves Application.ScreenUpdating, Calculation and EnableEvents where it finds them, and builds no ApplicationState.
Depends on: DataSheet, CustomTable, Checking, BetterArray, TranslationObject
Version: 1.0 (2026-02-09)
Factory
Create #
create
Create a new LLChoices instance
Signature:
Public Function Create(ByVal choiceWorksheet As Worksheet, _
ByVal choiceStartRow As Long, _
ByVal choiceStartColumn As Long, _
Optional ByVal choiceName As String = vbNullString) As _
Wraps the given worksheet in a DataSheet and returns a configured LLChoices instance. The four columns this class works with are "list name", "ordering list", "label" and "short label", and AddHeaders writes them to the sheet in that order.
Parameters:
choiceWorksheet: Worksheet. The worksheet hosting the choices.choiceStartRow: Long. First row of choice data.choiceStartColumn: Long. First column of choice data.choiceName: Optional String. Name for the choice object. Defaults to vbNullString.
Returns: LLChoices. The new instance.
Elements
Choices
Name #
name
Name of the choice object
Signature:
Public Property Get Name() As String
The lazy default is written straight into the backing field, so a Get can change the object. It has to be that way: Seal closes the Let, and the default can only be worked out once the worksheet is known.
Returns: String. The choice object name.
ChoiceExists #
choice-exists
Check whether a choice exists
Signature:
Public Property Get ChoiceExists(ByVal choiceName As String) As Boolean
Answers a quiet False when the list name column is missing or the name is empty. Matching is case-SENSITIVE here, which is not the rule Categories uses -- AutoFilter criteria are case-insensitive and cannot be made otherwise.
Parameters:
choiceName: String. Choice name to search for.
Returns: Boolean. True when the choice is found.
Sort #
sort
Sort choices in the worksheet
Signature:
Public Sub Sort()
Sorts on the list name first, then on the ordering column. The header row is read once and the three ranges are built from the positions it gives; the old shape asked the worksheet for the same header row four times.
AddChoice #
add-choice
Add a new choice with its categories
Signature:
Public Sub AddChoice(ByVal choiceName As String, _
ByVal longLabelData As BetterArray, _
Optional ByVal shortLabelData As Object = Nothing)
Appends a new choice entry with long labels and optional short labels. Silently exits when the choice already exists or the data is empty. The four columns are resolved from one header read, and the list name and the ordering number are written as one block each. They used to be written cell by cell, with both column numbers read back from the worksheet on every pass of the loop. Known and unchanged: on a sheet holding only headers DataEndRow answers one row below the header, so the first append lands one row low and leaves a blank row behind.
Parameters:
choiceName: String. Name of the choice to add.longLabelData: BetterArray. Long label values for the categories.shortLabelData: Optional Object. BetterArray of short label values. Defaults to Nothing.
RemoveChoice #
remove-choice
Remove one or multiple choices
Signature:
Public Sub RemoveChoice(ByVal choiceNames As String, Optional ByVal sep As String = ";")
The sheet is sorted ONCE here, for the whole call. The sort used to run inside the per-name routine, so removing five names paid five full worksheet sorts.
Parameters:
choiceNames: String. Choice names separated by sep.sep: Optional String. Delimiter. Defaults to ";".
Translate #
translate
Translate the choice worksheet labels
Signature:
Public Sub Translate(ByVal trads As TranslationObject)
The BODY of the two label columns only. TranslateRange writes back every cell it is handed, and this used to hand it ranges that started on the header cell, because DataRange defaults includeHeaders to True. A translation table holding a tag called "label" or "short label" therefore replaced the HEADER with its translation, and from that moment on every member of this class that needs the four headers left in silence and Categories came back empty.
Parameters:
trads: TranslationObject. Translation provider.
ManageRows #
manage-rows
Add or remove rows via a single toggle
Signature:
Public Sub ManageRows(Optional ByVal del As Boolean = False)
Works on the FIRST ListObject of the host sheet, whatever it is, and leaves in silence when there is none. The two branches are not symmetric: adding uses CustomTable's own default of 5 rows, removing takes 1.
Parameters:
del: Optional Boolean. When True, removes rows. Defaults to False.
AddRows #
add-rows
Add rows to the choice table
Signature:
Public Sub AddRows()
InsertRows #
insert-rows
Insert rows at the selected position
Signature:
Public Sub InsertRows(ByVal targetCell As Range, _
Optional ByVal insertShift As Boolean = False)
Mirrors the selection height inside the choices ListObject. When the selection is invalid the attempt is logged and ignored.
Parameters:
targetCell: Range. Selection anchoring the insertion point.insertShift: Optional Boolean. When True, inserts worksheet rows. Defaults to False.
DeleteRows #
delete-rows
Delete rows intersecting the selection
Signature:
Public Sub DeleteRows(ByVal targetCell As Range, _
Optional ByVal includeIds As Boolean = True, _
Optional ByVal forceShift As Boolean = False)
Parameters:
targetCell: Range. Selection identifying rows to delete.includeIds: Optional Boolean. When True, renumbers the ID column. Defaults to True.forceShift: Optional Boolean. When True, forces worksheet row deletion. Defaults to False.
RemoveRows #
remove-rows
Remove empty rows from the choice table
Signature:
Public Sub RemoveRows()
AllChoices #
all-choices
All distinct choice names
Signature:
Public Property Get AllChoices() As BetterArray
Worksheet order is kept: the first time a name is seen is where it lands. The column is read as ONE block and the names are de-duplicated in memory. It used to read every cell TWICE from the worksheet, once to ask whether the value was already held and once to push it, and the "already held" test copied the whole internal array on every call. On a 2,000-row choices sheet that was about 8,000 worksheet crossings.
Returns: BetterArray. The distinct choice names. Never Nothing.
Categories
Categories #
categories
Categories of a choice
Signature:
Public Property Get Categories(ByVal choiceName As String, _
Optional ByVal useShortlabels As Boolean = False) _
Returns the long label categories for the specified choice. When useShortlabels is True, short labels replace long labels where available. Falls back to long labels for missing short entries. Matching on the choice name is case-INSENSITIVE, because AutoFilter criteria are and cannot be made otherwise. There used to be a full Range.Find in front of the filter, only to decide whether to bother filtering; the filter already answers "no rows matched" with a zero-length array. Dropping it means a sheet holding both list_a and List_A now answers as if they were one name. That is a data fault on the sheet, and it is also one of the ways the filter itself used to go wrong. A missing header is logged and answered with an empty array, the way every other member of this class answers. It used to raise out of the class.
Parameters:
choiceName: String. The choice to retrieve categories from.useShortlabels: Optional Boolean. When True, uses short labels. Defaults to False.
Returns: BetterArray. The category values. Never Nothing. Safe to change in place.
ConcatenateCategories #
concatenate-categories
Concatenate all categories of a choice
Signature:
Public Property Get ConcatenateCategories( _
ByVal choiceName As String, _
Optional ByVal sep As String = " | ", _
Optional ByVal trads As Object = Nothing) As String
Joins the categories with a separator. Optionally translates each category via the supplied TranslationObject.
Parameters:
choiceName: String. The choice whose categories to concatenate.sep: Optional String. Separator. Defaults to " | ".trads: Optional Object. A TranslationObject. Defaults to Nothing.
Returns: String. The concatenated categories.
DataExchange
AddHeaders #
add-headers
Write the default headers to the choice worksheet
Signature:
Public Sub AddHeaders()
Writes the four standard column headers starting at the configured origin cell, in this order: list name, ordering list, label, short label. The label header is always the DEFAULT one. A sheet that carried a translated label column loses it here.
Export #
export
Export the current choice object to a workbook
Signature:
Public Sub Export(ByVal Wkb As Workbook, Optional ByVal Hide As Long = xlSheetHidden)
Delegates to DataSheet.Export, including hidden names.
Parameters:
Wkb: Workbook. Destination workbook.Hide: Long. Worksheet visibility constant. Defaults to xlSheetHidden.
Import #
import
Import choices from a worksheet
Signature:
Public Sub Import(ByVal fromWksh As Worksheet, _
ByVal fromStartRow As Long, _
ByVal fromStartcol As Long, _
Optional ByVal clearSheet As Boolean = False)
Optionally clears the sheet and rewrites headers before importing. Creates a temporary DataSheet from the source range and delegates to DataSheet.Import. Two things to know before passing clearSheet:=True. Cells.Clear takes the ListObject of the sheet with it, and ManageRows, AddRows, InsertRows and DeleteRows all need that table, so they do nothing until something builds it again. And the import itself matches source columns to target columns case-SENSITIVELY, unlike every lookup this class makes on its own.
Parameters:
fromWksh: Worksheet. Source worksheet containing choices.fromStartRow: Long. First data row in the source.fromStartcol: Long. First data column in the source.clearSheet: Boolean. When True, clears the sheet before import. Defaults to False.
Checkings
HarvestCollaboratorCheckings #
harvest-collaborator-checkings
Whether the choice object has logged diagnostic messages
Signature:
Private Sub HarvestCollaboratorCheckings(ByVal collaboratorChecks As Checking)
DataSheet and CustomTable each keep a store of their own, and nobody
read either of them before this line.
Parameters:
collaboratorChecks: Checking. What the collaborator filed.
Returns: Boolean. True when diagnostic entries exist.
CheckingValues #
checking-values
Retrieve the diagnostic log entries
Signature:
Public Property Get CheckingValues() As Object
Returns the internal Checking object containing all logged messages. Returns Nothing when no entries have been recorded.
Returns: Object. A Checking instance, or Nothing.
Internal members (not exported)
Elements
ChoiceData #
choice-data-set
Assign the backing DataSheet
Signature:
Public Property Set ChoiceData(ByVal customDataSheet As DataSheet)
Parameters:
customDataSheet: DataSheet. The DataSheet to assign.
StartRow #
start-row
First row of choice data
Signature:
Public Property Get StartRow() As Long
Returns: Long. The start row.
StartColumn #
start-column
First column of choice data
Signature:
Public Property Get StartColumn() As Long
Returns: Long. The start column.
Header lookup
HeaderValues #
header-values
Read the header row once as a 1-based list of values
Signature:
Private Function HeaderValues(ByRef firstColumn As Long) As Variant
Range.Value hands back a plain value when the Range is one cell and a two-dimensional array otherwise, so both shapes are flattened here and the callers do not repeat the test. The block is deliberately NOT kept on the instance. Callers write to these sheets without going through this object, so it is read again on the next call.
Parameters:
firstColumn: Long. Out. The worksheet column the header row starts at.
Returns: Variant. A 1-based one-dimensional array of header values.
ColumnAt #
column-at
Find one column in a header row already read
Signature:
Private Function ColumnAt(ByRef headers As Variant, ByVal colName As String, _
ByVal firstColumn As Long) As Long
Whole-string and case-insensitive, which is the rule this class has always used: every lookup it made went through DataRange, and DataRange forces matchCase:=False. The leftmost match wins, the same way DataSheet answers.
Parameters:
headers: Variant. The header values from HeaderValues.colName: String. Column header to look for.firstColumn: Long. The worksheet column the header row starts at.
Returns: Long. The worksheet column of the match, or 0 when there is none.
CellText #
cell-text
Read one cell value as text without raising
Signature:
Private Function CellText(ByVal cellValue As Variant) As String
A cell holding an error value (#N/A and the like) makes CStr raise a type mismatch. Such a cell can never match a column name or a choice name, so it reads as empty text instead of stopping the caller.
Parameters:
cellValue: Variant. The raw cell value.
Returns: String. The value as text, or empty text.
ChoiceLabelIn #
choice-label-in
Resolve the label column name from a header row already read
Signature:
Private Function ChoiceLabelIn(ByRef headers As Variant, ByVal firstColumn As Long) As String
The translated label column wins when the sheet carries one, otherwise the default label column is used.
Parameters:
headers: Variant. The header values from HeaderValues.firstColumn: Long. The worksheet column the header row starts at.
Returns: String. The resolved column name.
BodyBlock #
body-block
Build a Range over the body rows of one or more columns
Signature:
Private Function BodyBlock(ByVal dataObject As DataSheet, _
ByVal firstColumn As Long, _
ByVal lastColumn As Long) As Range
This is what DataSheet.DataRange(name, includeHeaders:=False) builds, but from a column position that is already known instead of from a second search for the header text.
Parameters:
dataObject: DataSheet. The backing DataSheet.firstColumn: Long. Left worksheet column.lastColumn: Long. Right worksheet column.
Returns: Range. The body block.
ColumnValues #
column-values
Read one worksheet column as a 1-based list of values
Signature:
Private Function ColumnValues(ByVal blockRng As Range) As Variant
Parameters:
blockRng: Range. A single-column block.
Returns: Variant. A 1-based one-dimensional array of cell values.
Validation
HasAllChoiceColumns #
has-all-choice-columns
Check a header row already read for the four choice columns
Signature:
Private Function HasAllChoiceColumns(ByRef headers As Variant, ByVal firstColumn As Long) As Boolean
The four ColumnExists calls this replaces cost about 40 worksheet round trips, and VBA does not short-circuit And, so all four always ran.
Parameters:
headers: Variant. The header values from HeaderValues.firstColumn: Long. The worksheet column the header row starts at.
Returns: Boolean. True when all four required columns are found.
Choices
Name #
name-set
Assign the choice object name
Signature:
Public Property Let Name(ByVal choiceName As String)
Parameters:
choiceName: String. The name to assign.
ListNameRange #
list-name-range
Body range of the list name column, or Nothing when it is missing
Signature:
Private Function ListNameRange() As Range
ChoiceExists used to resolve this column twice: once through ColumnExists, only to be able to answer with a quiet False when the column is missing, and once more inside DataRange, which raises instead. The header row is read once here and the range is built from the position it gives.
Returns: Range. The list name column body, or Nothing.
FindChoiceCell #
find-choice-cell
Find the first cell of the list name column holding a choice name
Signature:
Private Function FindChoiceCell(ByVal choiceName As String, ByVal listRng As Range) As Range
The four search arguments are kept exactly as they have always been. Three things follow from them and they are known, not accidents: Range.Find reuses whatever LookIn, SearchOrder and SearchDirection the last search anywhere in Excel used, including the user's own Ctrl+F; it always reads *, ? and ~ as wildcards even under xlWhole; and it skips hidden rows.
Parameters:
choiceName: String. Choice name to search for.listRng: Range. The list name column body.
Returns: Range. The matching cell, or Nothing.
RemoveOneChoice #
remove-one-choice
Remove every row of a single choice
Signature:
Private Sub RemoveOneChoice(ByVal choiceName As String, ByVal listCol As Long)
The caller has already sorted the sheet, so every row of one choice sits in a single unbroken run. The run is found by reading the list name column once and matching in memory, and then taken in ONE worksheet delete. It used to delete a row at a time, which is one of the most expensive things Excel does, and the loop was only correct because Offset(1) and the upward shift of the rows below cancelled each other exactly. EntireRow.Delete takes the WHOLE worksheet row, on a sheet this class does not own on its own. That is unchanged.
Parameters:
choiceName: String. The choice to remove.listColumn: Long. Worksheet column holding the list names.
ResolveRowTarget #
resolve-row-target
Validate a selection and resolve its ListObject
Signature:
Private Function ResolveRowTarget(ByVal targetCell As Range, ByVal actionLabel As String) As ListObject
The "the worksheet could not be resolved" branch looks unreachable and is not: targetCell can point at a sheet that has since been deleted, and then reading its Worksheet raises. The trap above it is what keeps selectionSheet Nothing, and the next test would read .Name off Nothing, so both guards stay. This resolves the table from the SELECTION, while ManageRows takes the first table of the sheet. On a choices sheet carrying more than one table those are two different answers.
Parameters:
targetCell: Range. The user-selected cell to validate.actionLabel: String. Human-readable action name used in warning messages.
Returns: ListObject. The ListObject, or Nothing when invalid.
Checkings
LogInfo #
log-info
Append a diagnostic entry to the internal checking object
Signature:
Private Sub LogInfo(ByVal label As String, _
Optional ByVal scope As Byte = checkingNote)
Lazily creates the Checking instance on first call. Increments the internal counter used as the entry key.
Parameters:
label: String. The message to record.scope: Byte. Severity level. Defaults to checkingNote.
LogAndTrace #
log-and-trace
Log a message and print the debug trace
Signature:
Private Sub LogAndTrace(ByVal label As String, _
Optional ByVal scope As Byte = checkingNote)
The "log it, show the trace, then leave" tail was written out four times, on the two failure paths of the choice removal and on the header guards of AllChoices and the removal itself.
Parameters:
label: String. The message to record.scope: Optional Byte. Severity level. Defaults to checkingNote.
LogMissingHeaders #
log-missing-headers
Log that a member left because a required header is missing
Signature:
Private Sub LogMissingHeaders(ByVal actionLabel As String)
Six members need the choice columns and they used to write this tail three different ways: Sort logged nothing at all, Translate logged at checkingNote, and the rest at checkingWarning. There is one shape now, at checkingWarning, because a missing header always means the caller's work was dropped. Sort is the one that changes: it used to leave without a word, which gave the user a button that did nothing and said nothing.
Parameters:
actionLabel: String. What was not done, in plain words.
ShowDebug #
show-debug
Print diagnostic summary to the Immediate window
Signature:
Private Sub ShowDebug()
MergeCollaboratorCheckings #
merge-collaborator-checkings
Take the data sheet's entries before the report is read
Signature:
Private Sub MergeCollaboratorCheckings()
The data sheet lives as long as this object does, so its entries are
folded in when the report is asked for. The flag keeps the fold to once,
because the caller reads HasCheckings and then CheckingValues.
Instantiation Guard
ThrowError #
throw-error
Raise a ProjectError-based exception
Signature:
Private Sub ThrowError(ByVal errNumb As Long, ByVal errorMessage As String)
Parameters:
errNumb: Long. ProjectError code.errorMessage: String. Descriptive message.
Throws:
- ProjectError.
Always raises the specified error.
Seal #
seal
Seal the instance so setup setters can no longer be written
Signature:
Public Sub Seal()
GuardNotSealed #
guard-not-sealed
Guard a setup setter against writes after sealing
Signature:
Private Sub GuardNotSealed(ByVal propName As String)
Parameters:
propName: String. Name of the property being guarded.
Used in (29 file(s))
- ChoiceKeys.cls
- LLExporter.cls
- LLImporter.cls
- CustomTable.cls
- Linelist.cls
- LinelistSpecs.cls
- EventMasterSetup.cls
- MasterSetupVariables.cls
- EventSetup.cls
- SetupErrors.cls
- SetupImport.cls
- InitTransfer.bas
- MasterSetupHelpers.bas
- EventsGlobal.bas
- TestAnalysisOutput.bas
- TestChoiceKeys.bas
- TestLLImporter.bas
- TestLLChoices.bas
- ChoicesTestFixture.bas
- LinelistDataStub.cls
- SetupImportTestFixture.bas
- TestEventLinelistSheets.bas
- TestLinelistSpecs.bas
- TestLLDataEntry.bas
- TestEventMasterSetup.bas
- TestMasterSetupVariables.bas
- TestSectionBuilder.bas
- TestVarWriter.bas
- TestSetupErrors.bas