@egose/shadcn-theme-ng-tw 0.6.1 → 0.6.3

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.
@@ -156,7 +156,7 @@ Key `EgDataTable` inputs: `columns` (required), `gridColumns`, `data` (array, or
156
156
 
157
157
  Outputs: `selectionChange` (also fires once on init, mirroring React's mount effect), `pageChange`, `pageSizeChange`, `rowClick`, `sortingChange`, `columnFiltersChange`, `filterChange` (toolbar input string), `columnVisibilityChange`.
158
158
 
159
- The toolbar (`showToolbar`) always renders the filter input: with `filterColumnId` it filters that column client-side (and `filterChange` mirrors the string); without it, keystrokes only emit `filterChange` so the caller can filter server-side. The column-visibility dropdown is opt-in via `showColumnToggle` (default `false`). `toolbarActions` takes an `ng-template` rendered on the right side of the toolbar row (built-ins stay left) — it renders the row even when `showToolbar` is false.
159
+ The toolbar (`showToolbar`) always renders the filter input: with `filterColumnId` it filters that column client-side (and `filterChange` mirrors the string); without it, keystrokes only emit `filterChange` so the caller can filter server-side. The column-visibility dropdown is opt-in via `showColumnToggle` (default `false`, requires `showToolbar`). `showColumnToggle` also enables hiding: the `Hide` item in `EgDataTableColumnHeader` only renders when it is true (TanStack `column.getCanHide()`), so a hidden column always has a way back via View; per-column `enableHiding: false` disables individual columns even then. `toolbarActions` takes an `ng-template` rendered on the right side of the toolbar row (built-ins stay left) — it renders the row even when `showToolbar` is false.
160
160
 
161
161
  `size` scales spatial density one step down/up (`sm`: `h-8` headers, `p-1` cells, `h-7` buttons; `lg`: `h-12` headers, `p-3` cells, `h-9` buttons; filter input, page-size trigger, and pager meta texts follow). `EgDataTableColumnHeader` (consumer-instantiated via `flexRenderComponent`) takes the same `size` — for reactive updates put every input in `bindings` (`inputBinding('size', ...)`, plus `column`/`title`, since `bindings` cannot mix with `inputs`).
162
162
 
@@ -368,7 +368,7 @@ class EgDataTableViewOptions {
368
368
  type="button"
369
369
  [hlmDropdownMenuTrigger]="_menu"
370
370
  >
371
- <ng-icon hlm name="lucideSettings2" />
371
+ <ng-icon hlm size="sm" name="lucideSettings2" />
372
372
  View
373
373
  </button>
374
374
  <ng-template #_menu>
@@ -406,7 +406,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
406
406
  type="button"
407
407
  [hlmDropdownMenuTrigger]="_menu"
408
408
  >
409
- <ng-icon hlm name="lucideSettings2" />
409
+ <ng-icon hlm size="sm" name="lucideSettings2" />
410
410
  View
411
411
  </button>
412
412
  <ng-template #_menu>
@@ -560,7 +560,7 @@ class EgDataTable {
560
560
  ...(ngDevMode ? [{ debugName: "filterColumnId" }] : /* istanbul ignore next */ []));
561
561
  filterPlaceholder = input('Filter...', /* @ts-ignore */
562
562
  ...(ngDevMode ? [{ debugName: "filterPlaceholder" }] : /* istanbul ignore next */ []));
563
- /** Show the column-visibility dropdown inside the toolbar. Off by default; opt in per table. */
563
+ /** Show the column-visibility dropdown inside the toolbar (requires `showToolbar`). Off by default; opt in per table. Also enables hiding — the `Hide` item in `EgDataTableColumnHeader` only renders when this is true, so hidden columns always have a way back. Per-column `enableHiding: false` further disables individual columns. */
564
564
  showColumnToggle = input(false, /* @ts-ignore */
565
565
  ...(ngDevMode ? [{ debugName: "showColumnToggle" }] : /* istanbul ignore next */ []));
566
566
  /** Custom right-side toolbar content (e.g. action buttons) as a template. Renders the toolbar row even when `showToolbar` is false; built-ins stay on the left. */
@@ -722,6 +722,10 @@ class EgDataTable {
722
722
  columns: this._finalColumns(),
723
723
  data: this._tableItems(),
724
724
  getRowId: this.getRowId(),
725
+ // Hiding is only allowed when the View menu can bring columns back.
726
+ // This drives `column.getCanHide()` for both `EgDataTableColumnHeader`
727
+ // (Hide item) and `EgDataTableViewOptions` (toggle list).
728
+ enableHiding: this.showColumnToggle(),
725
729
  manualPagination: server,
726
730
  manualSorting: server,
727
731
  manualFiltering: server,
@@ -1324,7 +1328,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
1324
1328
  *
1325
1329
  * Render via `flexRenderComponent(EgDataTableColumnHeader, { inputs: { column, title } })`
1326
1330
  * so the extra `title` input flows alongside the flex-render `column` context.
1327
- * Non-sortable columns render a plain label.
1331
+ * Non-sortable columns render a plain label. The `Hide` menu item only renders
1332
+ * when `column.getCanHide()` is true — `EgDataTable` ties table-level
1333
+ * `enableHiding` to its `showColumnToggle` input so there is always a way back
1334
+ * via the View menu, and per-column `enableHiding: false` further disables it.
1328
1335
  */
1329
1336
  class EgDataTableColumnHeader {
1330
1337
  column = input.required(/* @ts-ignore */
@@ -1336,6 +1343,9 @@ class EgDataTableColumnHeader {
1336
1343
  ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
1337
1344
  _sorted = computed(() => this.column().getIsSorted(), /* @ts-ignore */
1338
1345
  ...(ngDevMode ? [{ debugName: "_sorted" }] : /* istanbul ignore next */ []));
1346
+ /** Hideable only when TanStack allows it (table + column `enableHiding`). Absent `getCanHide` (legacy mocks) means hideable. */
1347
+ _canHide = computed(() => this.column().getCanHide?.() ?? true, /* @ts-ignore */
1348
+ ...(ngDevMode ? [{ debugName: "_canHide" }] : /* istanbul ignore next */ []));
1339
1349
  /** Height per density. Merged by `hlmButton`. */
1340
1350
  _buttonClass = computed(() => hlm('tw:-ml-3', this.size() === 'sm' ? 'tw:h-7' : this.size() === 'lg' ? 'tw:h-9' : 'tw:h-8'), /* @ts-ignore */
1341
1351
  ...(ngDevMode ? [{ debugName: "_buttonClass" }] : /* istanbul ignore next */ []));
@@ -1374,11 +1384,13 @@ class EgDataTableColumnHeader {
1374
1384
  <ng-icon hlm size="sm" name="lucideArrowDown" />
1375
1385
  Desc
1376
1386
  </button>
1377
- <div hlmDropdownMenuSeparator></div>
1378
- <button hlmDropdownMenuItem type="button" (click)="column().toggleVisibility(false)">
1379
- <ng-icon hlm size="sm" name="lucideEyeOff" />
1380
- Hide
1381
- </button>
1387
+ @if (_canHide()) {
1388
+ <div hlmDropdownMenuSeparator></div>
1389
+ <button hlmDropdownMenuItem type="button" (click)="column().toggleVisibility(false)">
1390
+ <ng-icon hlm size="sm" name="lucideEyeOff" />
1391
+ Hide
1392
+ </button>
1393
+ }
1382
1394
  </div>
1383
1395
  </ng-template>
1384
1396
  </div>
@@ -1427,11 +1439,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
1427
1439
  <ng-icon hlm size="sm" name="lucideArrowDown" />
1428
1440
  Desc
1429
1441
  </button>
1430
- <div hlmDropdownMenuSeparator></div>
1431
- <button hlmDropdownMenuItem type="button" (click)="column().toggleVisibility(false)">
1432
- <ng-icon hlm size="sm" name="lucideEyeOff" />
1433
- Hide
1434
- </button>
1442
+ @if (_canHide()) {
1443
+ <div hlmDropdownMenuSeparator></div>
1444
+ <button hlmDropdownMenuItem type="button" (click)="column().toggleVisibility(false)">
1445
+ <ng-icon hlm size="sm" name="lucideEyeOff" />
1446
+ Hide
1447
+ </button>
1448
+ }
1435
1449
  </div>
1436
1450
  </ng-template>
1437
1451
  </div>
@@ -237,7 +237,7 @@ declare class EgDataTable<TData extends RowData> implements OnInit {
237
237
  /** Column id the toolbar filter input writes to (e.g. `'email'`). Empty means client-side filtering is skipped and keystrokes only emit `filterChange` (server-side pattern). */
238
238
  readonly filterColumnId: _angular_core.InputSignal<string>;
239
239
  readonly filterPlaceholder: _angular_core.InputSignal<string>;
240
- /** Show the column-visibility dropdown inside the toolbar. Off by default; opt in per table. */
240
+ /** Show the column-visibility dropdown inside the toolbar (requires `showToolbar`). Off by default; opt in per table. Also enables hiding — the `Hide` item in `EgDataTableColumnHeader` only renders when this is true, so hidden columns always have a way back. Per-column `enableHiding: false` further disables individual columns. */
241
241
  readonly showColumnToggle: _angular_core.InputSignal<boolean>;
242
242
  /** Custom right-side toolbar content (e.g. action buttons) as a template. Renders the toolbar row even when `showToolbar` is false; built-ins stay on the left. */
243
243
  readonly toolbarActions: _angular_core.InputSignal<TemplateRef<unknown> | undefined>;
@@ -381,13 +381,18 @@ interface EgSortableColumn {
381
381
  getIsSorted(): false | SortDirection;
382
382
  toggleSorting(desc?: boolean): void;
383
383
  toggleVisibility(value?: boolean): void;
384
+ /** Whether the column can be hidden (`columnDef.enableHiding` and table `enableHiding`). Optional for back-compat mocks — absent means hideable. */
385
+ getCanHide?(): boolean;
384
386
  }
385
387
  /**
386
388
  * Sortable + hideable column header.
387
389
  *
388
390
  * Render via `flexRenderComponent(EgDataTableColumnHeader, { inputs: { column, title } })`
389
391
  * so the extra `title` input flows alongside the flex-render `column` context.
390
- * Non-sortable columns render a plain label.
392
+ * Non-sortable columns render a plain label. The `Hide` menu item only renders
393
+ * when `column.getCanHide()` is true — `EgDataTable` ties table-level
394
+ * `enableHiding` to its `showColumnToggle` input so there is always a way back
395
+ * via the View menu, and per-column `enableHiding: false` further disables it.
391
396
  */
392
397
  declare class EgDataTableColumnHeader {
393
398
  readonly column: _angular_core.InputSignal<EgSortableColumn>;
@@ -395,6 +400,8 @@ declare class EgDataTableColumnHeader {
395
400
  /** Density of the trigger button. */
396
401
  readonly size: _angular_core.InputSignal<HlmTableSize>;
397
402
  protected readonly _sorted: _angular_core.Signal<false | SortDirection>;
403
+ /** Hideable only when TanStack allows it (table + column `enableHiding`). Absent `getCanHide` (legacy mocks) means hideable. */
404
+ protected readonly _canHide: _angular_core.Signal<boolean>;
398
405
  /** Height per density. Merged by `hlmButton`. */
399
406
  protected readonly _buttonClass: _angular_core.Signal<string>;
400
407
  static ɵfac: _angular_core.ɵɵFactoryDeclaration<EgDataTableColumnHeader, never>;
@@ -44,8 +44,9 @@ export class MyForm {}
44
44
  Requirements:
45
45
 
46
46
  - Must sit inside a `<form [formGroup]>` (it injects `FormGroupDirective` and provides `ControlContainer`).
47
- - `controlName` is required — forwarded as `[formControlName]` to the inner `brn-select` / `brn-select-multiple`.
47
+ - `controlName` is required — forwarded as `[formControlName]` to the inner `hlm-select` / `hlm-select-multiple`.
48
48
  - Single mode binds `string`; multi mode (`multiple`) binds `string[]`.
49
+ - The closed trigger always shows the option `label` (single: via `itemToString`; multi: comma-joined labels). The form value stays the raw `value` string(s).
49
50
 
50
51
  ## Anatomy / Structure
51
52
 
@@ -54,7 +55,7 @@ Requirements:
54
55
  <!-- rendered internally (single mode) -->
55
56
  <hlm-form-field>
56
57
  <label hlmLabel for="<effectiveId>">Country <span>*</span></label>
57
- <brn-select hlmSelect formControlName="country">
58
+ <hlm-select formControlName="country" itemToString="…">
58
59
  <hlm-select-trigger buttonId="<effectiveId>" ariaDescribedby="…">
59
60
  <hlm-select-value placeholder="Select…" />
60
61
  </hlm-select-trigger>
@@ -62,14 +63,14 @@ Requirements:
62
63
  <hlm-select-label>Fruits</hlm-select-label>
63
64
  <hlm-select-item value="apple">Apple</hlm-select-item>
64
65
  </hlm-select-content>
65
- </brn-select>
66
+ </hlm-select>
66
67
  <hlm-error id="<effectiveId>-error">…</hlm-error>
67
68
  <hlm-hint id="<effectiveId>-hint">…</hlm-hint>
68
69
  </hlm-form-field>
69
70
  </eg-form-select>
70
71
  ```
71
72
 
72
- In multi mode the inner `brn-select` becomes `brn-select-multiple`; everything else is identical. Real selectors: `eg-form-select`, `hlm-form-field`, `label[hlmLabel]`, `brn-select[hlmSelect]` / `brn-select-multiple[hlmSelect]`, `hlm-select-trigger`, `hlm-select-value`, `hlm-select-content`, `hlm-select-label`, `hlm-select-item`, `hlm-error`, `hlm-hint`.
73
+ In multi mode the inner `hlm-select` becomes `hlm-select-multiple` and the trigger renders `<hlm-select-placeholder>` plus an `hlmSelectValues` template with comma-joined labels inside `<hlm-select-values-content>`; everything else is identical. Real selectors: `eg-form-select`, `hlm-form-field`, `label[hlmLabel]`, `hlm-select` / `hlm-select-multiple`, `hlm-select-trigger`, `hlm-select-value` (single) / `hlm-select-placeholder` + `hlm-select-values-content` (multi), `hlm-select-content`, `hlm-select-label`, `hlm-select-item`, `hlm-error`, `hlm-hint`.
73
74
 
74
75
  ## API reference
75
76
 
@@ -84,11 +85,11 @@ In multi mode the inner `brn-select` becomes `brn-select-multiple`; everything e
84
85
  | `error` | `string \| undefined` | `undefined` | Error text rendered in `<hlm-error>`. |
85
86
  | `autoError` | `boolean` | `true` | Auto-resolve the message from the control's `ValidationErrors` when `error` is unset. Explicit `error` always wins; `error=""` counts as unset. Global wording via `provideEgFormErrorMessages`. |
86
87
  | `hint` | `string \| undefined` | `undefined` | Hint text rendered in `<hlm-hint>`. |
87
- | `placeholder` | `string` | `''` | Placeholder forwarded to `<hlm-select-value>`. |
88
+ | `placeholder` | `string` | `''` | Placeholder forwarded to `<hlm-select-value>` (single) / `<hlm-select-placeholder>` (multi). |
88
89
  | `disabled` | `boolean` | `false` | Forwarded as `wrapperDisabled` to `<hlm-select-trigger>`. |
89
90
  | `required` | `boolean` | `false` | Renders a red `*` next to the label (pair with `Validators.required`). |
90
- | `multiple` | `boolean` | `false` | When `true`, renders `brn-select-multiple` (value is `string[]`). |
91
- | `options` | `SelectOption[]` | `[]` | `{ value: string; label: string }[]` rendered as `<hlm-select-item>` rows. Local interface, not exported. |
91
+ | `multiple` | `boolean` | `false` | When `true`, renders `hlm-select-multiple` (value is `string[]`, trigger shows comma-joined labels). |
92
+ | `options` | `SelectOption[]` | `[]` | `{ value: string; label: string }[]` rendered as `<hlm-select-item>` rows. `label` is shown in the list and the closed trigger; `value` is the form value. Local interface, not exported. |
92
93
  | `optionsLabel` | `string \| undefined` | `undefined` | Optional group heading rendered once as `<hlm-select-label>`. |
93
94
  | `class` (`userClass`) | `ClassValue` | `''` | Extra host classes (merged over `tw:flex tw:flex-col`). |
94
95
  | `labelClass` | `string` | `''` | Extra label classes (merged over `tw:mb-1 tw:gap-0`). |
@@ -7,7 +7,7 @@ import { HlmLabel } from '@egose/shadcn-theme-ng-tw/label';
7
7
  import { hlm } from '@egose/shadcn-theme-ng-tw/utils';
8
8
  import { BrnSelectImports } from '@spartan-ng/brain/select';
9
9
  import * as i2 from '@egose/shadcn-theme-ng-tw/select';
10
- import { HlmSelectImports, HlmSelect, HlmSelectTrigger, HlmSelectValue, HlmSelectContent, HlmSelectItem, HlmSelectLabel } from '@egose/shadcn-theme-ng-tw/select';
10
+ import { HlmSelectImports, HlmSelect, HlmSelectMultiple, HlmSelectTrigger, HlmSelectValue, HlmSelectPlaceholder, HlmSelectValues, HlmSelectValuesContent, HlmSelectContent, HlmSelectItem, HlmSelectLabel } from '@egose/shadcn-theme-ng-tw/select';
11
11
 
12
12
  const EgFormSelectConfigToken = new InjectionToken('EgFormSelectConfig');
13
13
  function provideEgFormSelectConfig(config) {
@@ -91,6 +91,32 @@ class EgFormSelect {
91
91
  ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
92
92
  optionsLabel = input(undefined, /* @ts-ignore */
93
93
  ...(ngDevMode ? [{ debugName: "optionsLabel" }] : /* istanbul ignore next */ [])); // optional group label
94
+ /**
95
+ * Maps a stored `value` to its human-readable `label` for the closed trigger
96
+ * (and listbox type-ahead). Spartan's `BrnSelectValue` renders
97
+ * `stringifyAsLabel(value, itemToString)` — without this it would show the raw
98
+ * value (e.g. `jahn` instead of `J. Hahn`). Unknown IDs fall back to the raw
99
+ * value so async/programmatic values stay visible. Plain arrow-function
100
+ * property (stable identity); reads `options()` at call time so Spartan's
101
+ * display computed re-evaluates when options arrive.
102
+ */
103
+ itemToString = (value) => {
104
+ if (value === null || value === undefined)
105
+ return '';
106
+ return this.options().find((option) => option.value === value)?.label ?? String(value);
107
+ };
108
+ /**
109
+ * Comma-joined labels for multi-select trigger content. Plain method (not a
110
+ * computed): `values` comes from the `hlmSelectValues` template context, while
111
+ * `options()` is read fresh so late-arriving options refresh labels without
112
+ * touching selection — mirroring the React `FormMultiSelect` fallback.
113
+ */
114
+ formatMultipleLabels(values) {
115
+ if (!values?.length)
116
+ return '';
117
+ const labels = new Map(this.options().map((option) => [option.value, option.label]));
118
+ return values.map((value) => labels.get(value) ?? String(value)).join(', ');
119
+ }
94
120
  // Styling classes
95
121
  userClass = input('', { ...(ngDevMode ? { debugName: "userClass" } : /* istanbul ignore next */ {}), alias: 'class' });
96
122
  labelClass = input('', /* @ts-ignore */
@@ -130,14 +156,19 @@ class EgFormSelect {
130
156
  }
131
157
 
132
158
  @if (multiple()) {
133
- <brn-select-multiple hlmSelect [formControlName]="cnm">
159
+ <hlm-select-multiple [formControlName]="cnm" [itemToString]="itemToString">
134
160
  <hlm-select-trigger
135
161
  [buttonId]="effectiveId()"
136
162
  [ariaDescribedby]="describedBy()"
137
163
  [wrapperDisabled]="disabled()"
138
164
  [class]="$selectClass()"
139
165
  >
140
- <hlm-select-value [placeholder]="placeholder()" />
166
+ <hlm-select-placeholder>{{ placeholder() }}</hlm-select-placeholder>
167
+ <ng-template hlmSelectValues let-values>
168
+ <hlm-select-values-content>
169
+ {{ formatMultipleLabels(values) }}
170
+ </hlm-select-values-content>
171
+ </ng-template>
141
172
  </hlm-select-trigger>
142
173
 
143
174
  <hlm-select-content *hlmSelectPortal>
@@ -150,9 +181,9 @@ class EgFormSelect {
150
181
  }
151
182
  }
152
183
  </hlm-select-content>
153
- </brn-select-multiple>
184
+ </hlm-select-multiple>
154
185
  } @else {
155
- <brn-select hlmSelect [formControlName]="cnm">
186
+ <hlm-select [formControlName]="cnm" [itemToString]="itemToString">
156
187
  <hlm-select-trigger
157
188
  [buttonId]="effectiveId()"
158
189
  [ariaDescribedby]="describedBy()"
@@ -172,7 +203,7 @@ class EgFormSelect {
172
203
  }
173
204
  }
174
205
  </hlm-select-content>
175
- </brn-select>
206
+ </hlm-select>
176
207
  }
177
208
 
178
209
  @if (showError()) {
@@ -187,7 +218,7 @@ class EgFormSelect {
187
218
  </hlm-hint>
188
219
  }
189
220
  </hlm-form-field>
190
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: HlmFormField, selector: "hlm-form-field", inputs: ["class"] }, { kind: "directive", type: HlmError, selector: "hlm-error", inputs: ["class"] }, { kind: "directive", type: HlmHint, selector: "hlm-hint", inputs: ["class"] }, { kind: "directive", type: HlmLabel, selector: "[hlmLabel]", inputs: ["class"] }, { kind: "directive", type: i2.HlmSelect, selector: "[hlmSelect],hlm-select" }, { kind: "component", type: i2.HlmSelectContent, selector: "hlm-select-content", inputs: ["showScroll"] }, { kind: "component", type: i2.HlmSelectItem, selector: "hlm-select-item" }, { kind: "directive", type: i2.HlmSelectLabel, selector: "[hlmSelectLabel],hlm-select-label" }, { kind: "directive", type: i2.HlmSelectPortal, selector: "[hlmSelectPortal]" }, { kind: "component", type: i2.HlmSelectTrigger, selector: "hlm-select-trigger", inputs: ["class", "buttonId", "ariaDescribedby", "wrapperDisabled", "size", "forceInvalid"] }, { kind: "directive", type: i2.HlmSelectValue, selector: "[hlmSelectValue],hlm-select-value" }] });
221
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "component", type: HlmFormField, selector: "hlm-form-field", inputs: ["class"] }, { kind: "directive", type: HlmError, selector: "hlm-error", inputs: ["class"] }, { kind: "directive", type: HlmHint, selector: "hlm-hint", inputs: ["class"] }, { kind: "directive", type: HlmLabel, selector: "[hlmLabel]", inputs: ["class"] }, { kind: "directive", type: i2.HlmSelect, selector: "[hlmSelect],hlm-select" }, { kind: "component", type: i2.HlmSelectContent, selector: "hlm-select-content", inputs: ["showScroll"] }, { kind: "component", type: i2.HlmSelectItem, selector: "hlm-select-item" }, { kind: "directive", type: i2.HlmSelectLabel, selector: "[hlmSelectLabel],hlm-select-label" }, { kind: "directive", type: i2.HlmSelectMultiple, selector: "[hlmSelectMultiple],hlm-select-multiple" }, { kind: "directive", type: i2.HlmSelectPlaceholder, selector: "[hlmSelectPlaceholder],hlm-select-placeholder" }, { kind: "directive", type: i2.HlmSelectPortal, selector: "[hlmSelectPortal]" }, { kind: "component", type: i2.HlmSelectTrigger, selector: "hlm-select-trigger", inputs: ["class", "buttonId", "ariaDescribedby", "wrapperDisabled", "size", "forceInvalid"] }, { kind: "directive", type: i2.HlmSelectValue, selector: "[hlmSelectValue],hlm-select-value" }, { kind: "directive", type: i2.HlmSelectValues, selector: "[hlmSelectValues]" }, { kind: "directive", type: i2.HlmSelectValuesContent, selector: "[hlmSelectValuesContent],hlm-select-values-content" }] });
191
222
  }
192
223
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: EgFormSelect, decorators: [{
193
224
  type: Component,
@@ -206,8 +237,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
206
237
  BrnSelectImports,
207
238
  HlmSelectImports,
208
239
  HlmSelect,
240
+ HlmSelectMultiple,
209
241
  HlmSelectTrigger,
210
242
  HlmSelectValue,
243
+ HlmSelectPlaceholder,
244
+ HlmSelectValues,
245
+ HlmSelectValuesContent,
211
246
  HlmSelectContent,
212
247
  HlmSelectItem,
213
248
  HlmSelectLabel,
@@ -230,14 +265,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
230
265
  }
231
266
 
232
267
  @if (multiple()) {
233
- <brn-select-multiple hlmSelect [formControlName]="cnm">
268
+ <hlm-select-multiple [formControlName]="cnm" [itemToString]="itemToString">
234
269
  <hlm-select-trigger
235
270
  [buttonId]="effectiveId()"
236
271
  [ariaDescribedby]="describedBy()"
237
272
  [wrapperDisabled]="disabled()"
238
273
  [class]="$selectClass()"
239
274
  >
240
- <hlm-select-value [placeholder]="placeholder()" />
275
+ <hlm-select-placeholder>{{ placeholder() }}</hlm-select-placeholder>
276
+ <ng-template hlmSelectValues let-values>
277
+ <hlm-select-values-content>
278
+ {{ formatMultipleLabels(values) }}
279
+ </hlm-select-values-content>
280
+ </ng-template>
241
281
  </hlm-select-trigger>
242
282
 
243
283
  <hlm-select-content *hlmSelectPortal>
@@ -250,9 +290,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
250
290
  }
251
291
  }
252
292
  </hlm-select-content>
253
- </brn-select-multiple>
293
+ </hlm-select-multiple>
254
294
  } @else {
255
- <brn-select hlmSelect [formControlName]="cnm">
295
+ <hlm-select [formControlName]="cnm" [itemToString]="itemToString">
256
296
  <hlm-select-trigger
257
297
  [buttonId]="effectiveId()"
258
298
  [ariaDescribedby]="describedBy()"
@@ -272,7 +312,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
272
312
  }
273
313
  }
274
314
  </hlm-select-content>
275
- </brn-select>
315
+ </hlm-select>
276
316
  }
277
317
 
278
318
  @if (showError()) {
@@ -47,6 +47,23 @@ declare class EgFormSelect {
47
47
  multiple: _angular_core.InputSignal<boolean>;
48
48
  options: _angular_core.InputSignal<SelectOption[]>;
49
49
  optionsLabel: _angular_core.InputSignal<string | undefined>;
50
+ /**
51
+ * Maps a stored `value` to its human-readable `label` for the closed trigger
52
+ * (and listbox type-ahead). Spartan's `BrnSelectValue` renders
53
+ * `stringifyAsLabel(value, itemToString)` — without this it would show the raw
54
+ * value (e.g. `jahn` instead of `J. Hahn`). Unknown IDs fall back to the raw
55
+ * value so async/programmatic values stay visible. Plain arrow-function
56
+ * property (stable identity); reads `options()` at call time so Spartan's
57
+ * display computed re-evaluates when options arrive.
58
+ */
59
+ readonly itemToString: (value: string | null | undefined) => string;
60
+ /**
61
+ * Comma-joined labels for multi-select trigger content. Plain method (not a
62
+ * computed): `values` comes from the `hlmSelectValues` template context, while
63
+ * `options()` is read fresh so late-arriving options refresh labels without
64
+ * touching selection — mirroring the React `FormMultiSelect` fallback.
65
+ */
66
+ protected formatMultipleLabels(values: string[] | null | undefined): string;
50
67
  userClass: _angular_core.InputSignal<ClassValue>;
51
68
  labelClass: _angular_core.InputSignal<string>;
52
69
  selectClass: _angular_core.InputSignal<string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@egose/shadcn-theme-ng-tw",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Angular shadcn UI theme",
5
5
  "author": "Junmin Ahn",
6
6
  "bundles": [
package/select/README.md CHANGED
@@ -100,16 +100,15 @@ Single select:
100
100
  </div>
101
101
  ```
102
102
 
103
- Multi select (same skeleton, `hlmSelectMultiple` + `hlmSelectValues`):
103
+ Multi select (same skeleton, `hlmSelectMultiple` + `hlmSelectValues` template):
104
104
 
105
105
  ```html
106
106
  <div hlmSelectMultiple [(value)]="toppings">
107
107
  <hlm-select-trigger>
108
- <span hlmSelectValues>
109
- <span hlmSelectValuesContent>
110
- <span hlmSelectValue placeholder="Pick toppings" />
111
- </span>
112
- </span>
108
+ <hlm-select-placeholder>Pick toppings</hlm-select-placeholder>
109
+ <ng-template hlmSelectValues let-values>
110
+ <hlm-select-values-content> {{ formatToppings(values) }} </hlm-select-values-content>
111
+ </ng-template>
113
112
  </hlm-select-trigger>
114
113
 
115
114
  <hlm-select-content *hlmSelectPortal>
@@ -119,6 +118,8 @@ Multi select (same skeleton, `hlmSelectMultiple` + `hlmSelectValues`):
119
118
  </div>
120
119
  ```
121
120
 
121
+ > Display rule: the closed trigger does **not** read the item content — single mode renders `itemToString(value)` (raw `value` when unset), so whenever the label differs from the value (e.g. `jahn` → `J. Hahn`) pass `[itemToString]` that maps values to labels. Multi mode renders whatever the `hlmSelectValues` template outputs, so map each id to its label there (see examples 1 and 4).
122
+
122
123
  Selector/slot summary:
123
124
 
124
125
  | Selector | `data-slot` | Notes |
@@ -145,13 +146,13 @@ Selector/slot summary:
145
146
 
146
147
  Thin wrappers. Forwarded `BrnSelect` / `BrnSelectMultiple` bindings:
147
148
 
148
- | Binding | Kind | Description |
149
- | -------------------- | ------ | ------------------------------------------------------------ |
150
- | `disabled` | input | Disables the select |
151
- | `value` | input | Selected value (single) / values (multiple); use `[(value)]` |
152
- | `isItemEqualToValue` | input | Custom equality fn for object values |
153
- | `itemToString` | input | Custom label fn for object values |
154
- | `valueChange` | output | Emits on selection change |
149
+ | Binding | Kind | Description |
150
+ | -------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------- |
151
+ | `disabled` | input | Disables the select |
152
+ | `value` | input | Selected value (single) / values (multiple); use `[(value)]` |
153
+ | `isItemEqualToValue` | input | Custom equality fn for object values |
154
+ | `itemToString` | input | Maps a value to its trigger/type-ahead label. Required whenever the label differs from the value — without it the raw value shows. |
155
+ | `valueChange` | output | Emits on selection change |
155
156
 
156
157
  Forwarded `BrnPopover` bindings (positioning of the floating panel):
157
158
 
@@ -194,18 +195,18 @@ Forwarded `BrnSelectItem` inputs: `id`, `disabled`, `value`. Shows a `lucideChec
194
195
 
195
196
  ### Remaining pieces
196
197
 
197
- | Class | Forwarded inputs | Notes |
198
- | ------------------------ | -------------------------------------------- | ----------------------------------------------- |
199
- | `HlmSelectGroup` | — (plain `BrnSelectGroup`) | Padding wrapper |
200
- | `HlmSelectLabel` | `id` (via `BrnSelectLabel`) | Muted small-caps-ish heading |
201
- | `HlmSelectValue` | `placeholder` (via `BrnSelectValue`) | Hides itself (`data-hidden`) when a value shows |
202
- | `HlmSelectValues` | — (plain `BrnSelectValues`) | Multi-value host |
203
- | `HlmSelectValuesContent` | — | Flex row with gap |
204
- | `HlmSelectPlaceholder` | — (plain `BrnSelectPlaceholder`) | Hides when a value shows (`data-hidden`) |
205
- | `HlmSelectSeparator` | `orientation` (via `BrnSelectSeparator`) | 1px divider |
206
- | `HlmSelectScrollUp/Down` | — | Sticky chevron affordances |
207
- | `HlmSelectPortal` | `context`, `class` (via `BrnPopoverContent`) | Structural use: `*hlmSelectPortal="let ctx"` |
208
- | `HlmSelectValueTemplate` | — (plain `BrnSelectValueTemplate`) | Marker for custom value rendering |
198
+ | Class | Forwarded inputs | Notes |
199
+ | ------------------------ | -------------------------------------------- | --------------------------------------------------------------------------------------- |
200
+ | `HlmSelectGroup` | — (plain `BrnSelectGroup`) | Padding wrapper |
201
+ | `HlmSelectLabel` | `id` (via `BrnSelectLabel`) | Muted small-caps-ish heading |
202
+ | `HlmSelectValue` | `placeholder` (via `BrnSelectValue`) | Renders `itemToString(value)` or the placeholder; hidden when empty with no placeholder |
203
+ | `HlmSelectValues` | — (plain `BrnSelectValues`) | Multi-value template host (`<ng-template hlmSelectValues let-values>`) |
204
+ | `HlmSelectValuesContent` | — | Flex row with gap |
205
+ | `HlmSelectPlaceholder` | — (plain `BrnSelectPlaceholder`) | Hides when a value shows (`data-hidden`) |
206
+ | `HlmSelectSeparator` | `orientation` (via `BrnSelectSeparator`) | 1px divider |
207
+ | `HlmSelectScrollUp/Down` | — | Sticky chevron affordances |
208
+ | `HlmSelectPortal` | `context`, `class` (via `BrnPopoverContent`) | Structural use: `*hlmSelectPortal="let ctx"` |
209
+ | `HlmSelectValueTemplate` | — (plain `BrnSelectValueTemplate`) | Marker for custom value rendering |
209
210
 
210
211
  ## Examples
211
212
 
@@ -220,14 +221,14 @@ import { HlmSelectImports } from '@egose/shadcn-theme-ng/select';
220
221
  standalone: true,
221
222
  imports: [HlmSelectImports],
222
223
  template: `
223
- <div hlmSelect [(value)]="fruit">
224
+ <div hlmSelect [(value)]="fruit" [itemToString]="fruitToString">
224
225
  <hlm-select-trigger>
225
226
  <hlm-select-value placeholder="Pick a fruit" />
226
227
  </hlm-select-trigger>
227
228
  <hlm-select-content *hlmSelectPortal>
228
- <hlm-select-item value="apple">Apple</hlm-select-item>
229
- <hlm-select-item value="banana">Banana</hlm-select-item>
230
- <hlm-select-item value="orange">Orange</hlm-select-item>
229
+ @for (option of fruits; track option.value) {
230
+ <hlm-select-item [value]="option.value">{{ option.label }}</hlm-select-item>
231
+ }
231
232
  </hlm-select-content>
232
233
  </div>
233
234
  <p>Selected: {{ fruit() ?? 'none' }}</p>
@@ -235,13 +236,22 @@ import { HlmSelectImports } from '@egose/shadcn-theme-ng/select';
235
236
  })
236
237
  export class BasicSelectComponent {
237
238
  readonly fruit = signal<string | null>(null);
239
+ readonly fruits = [
240
+ { value: 'apple', label: 'Apple' },
241
+ { value: 'banana', label: 'Banana' },
242
+ { value: 'orange', label: 'Orange' },
243
+ ];
244
+
245
+ // Without this the trigger would show `apple` instead of `Apple`.
246
+ readonly fruitToString = (value: string | null) =>
247
+ this.fruits.find((option) => option.value === value)?.label ?? value ?? '';
238
248
  }
239
249
  ```
240
250
 
241
251
  ### 2. Grouped options with labels, separator, disabled item, scroll buttons
242
252
 
243
253
  ```html
244
- <div hlmSelect [(value)]="city">
254
+ <div hlmSelect [(value)]="city" [itemToString]="cityToString">
245
255
  <hlm-select-trigger>
246
256
  <hlm-select-value placeholder="Pick a city" />
247
257
  </hlm-select-trigger>
@@ -264,6 +274,19 @@ export class BasicSelectComponent {
264
274
  </div>
265
275
  ```
266
276
 
277
+ ```ts
278
+ // Component companion: map every grouped value to its label so the trigger
279
+ // shows `Berlin`, not `berlin`.
280
+ readonly cities = [
281
+ { value: 'berlin', label: 'Berlin' },
282
+ { value: 'munich', label: 'Munich' },
283
+ { value: 'paris', label: 'Paris' },
284
+ { value: 'lyon', label: 'Lyon (unavailable)' },
285
+ ];
286
+ readonly cityToString = (value: string | null) =>
287
+ this.cities.find((option) => option.value === value)?.label ?? value ?? '';
288
+ ```
289
+
267
290
  ### 3. Reactive forms + small trigger + forced invalid demo
268
291
 
269
292
  `BrnSelect` is a `ControlValueAccessor`: bind `formControlName` on the `hlmSelect` host.
@@ -279,14 +302,14 @@ import { HlmSelectImports } from '@egose/shadcn-theme-ng/select';
279
302
  imports: [HlmSelectImports, ReactiveFormsModule],
280
303
  template: `
281
304
  <form [formGroup]="form" (ngSubmit)="submit()">
282
- <div hlmSelect formControlName="country">
305
+ <div hlmSelect formControlName="country" [itemToString]="countryToString">
283
306
  <hlm-select-trigger size="sm" ariaDescribedby="country-hint">
284
307
  <hlm-select-value placeholder="Country" />
285
308
  </hlm-select-trigger>
286
309
  <hlm-select-content *hlmSelectPortal>
287
- <hlm-select-item value="de">Germany</hlm-select-item>
288
- <hlm-select-item value="fr">France</hlm-select-item>
289
- <hlm-select-item value="es">Spain</hlm-select-item>
310
+ @for (option of countries; track option.value) {
311
+ <hlm-select-item [value]="option.value">{{ option.label }}</hlm-select-item>
312
+ }
290
313
  </hlm-select-content>
291
314
  </div>
292
315
  <p id="country-hint" class="tw:text-muted-foreground tw:text-xs">Used for shipping.</p>
@@ -298,6 +321,13 @@ export class FormSelectComponent {
298
321
  readonly form = new FormGroup({
299
322
  country: new FormControl<string | null>(null, Validators.required),
300
323
  });
324
+ readonly countries = [
325
+ { value: 'de', label: 'Germany' },
326
+ { value: 'fr', label: 'France' },
327
+ { value: 'es', label: 'Spain' },
328
+ ];
329
+ readonly countryToString = (value: string | null) =>
330
+ this.countries.find((option) => option.value === value)?.label ?? value ?? '';
301
331
 
302
332
  submit(): void {
303
333
  this.form.markAllAsTouched();
@@ -318,16 +348,17 @@ import { HlmSelectImports } from '@egose/shadcn-theme-ng/select';
318
348
  template: `
319
349
  <div hlmSelectMultiple [(value)]="toppings">
320
350
  <hlm-select-trigger>
321
- <span hlmSelectValues>
322
- <span hlmSelectValuesContent>
323
- <span hlmSelectValue placeholder="Pick toppings" />
324
- </span>
325
- </span>
351
+ <hlm-select-placeholder>Pick toppings</hlm-select-placeholder>
352
+ <ng-template hlmSelectValues let-values>
353
+ <hlm-select-values-content>
354
+ {{ formatToppings(values) }}
355
+ </hlm-select-values-content>
356
+ </ng-template>
326
357
  </hlm-select-trigger>
327
358
  <hlm-select-content *hlmSelectPortal>
328
- <hlm-select-item value="cheese">Extra cheese</hlm-select-item>
329
- <hlm-select-item value="bacon">Bacon</hlm-select-item>
330
- <hlm-select-item value="mushrooms">Mushrooms</hlm-select-item>
359
+ @for (option of options; track option.value) {
360
+ <hlm-select-item [value]="option.value">{{ option.label }}</hlm-select-item>
361
+ }
331
362
  </hlm-select-content>
332
363
  </div>
333
364
  <p>{{ toppings().length }} selected</p>
@@ -335,6 +366,17 @@ import { HlmSelectImports } from '@egose/shadcn-theme-ng/select';
335
366
  })
336
367
  export class MultiSelectComponent {
337
368
  readonly toppings = signal<string[]>([]);
369
+ readonly options = [
370
+ { value: 'cheese', label: 'Extra cheese' },
371
+ { value: 'bacon', label: 'Bacon' },
372
+ { value: 'mushrooms', label: 'Mushrooms' },
373
+ ];
374
+
375
+ // The values template renders raw ids — map them to labels explicitly.
376
+ formatToppings(values: string[]): string {
377
+ const labels = new Map(this.options.map((option) => [option.value, option.label]));
378
+ return values.map((value) => labels.get(value) ?? value).join(', ');
379
+ }
338
380
  }
339
381
  ```
340
382
 
@@ -400,11 +442,12 @@ import { HlmSelectImports } from '@egose/shadcn-theme-ng/select';
400
442
  (closed)="onClosed()"
401
443
  >
402
444
  <hlm-select-trigger>
403
- <span hlmSelectValueTemplate>⭐ {{ plan() ?? 'Pick a plan' }}</span>
445
+ <span hlmSelectValueTemplate>⭐ {{ planLabel() }}</span>
404
446
  </hlm-select-trigger>
405
447
  <hlm-select-content *hlmSelectPortal="let ctx">
406
- <hlm-select-item value="hobby">Hobby</hlm-select-item>
407
- <hlm-select-item value="pro">Pro</hlm-select-item>
448
+ @for (option of plans; track option.value) {
449
+ <hlm-select-item [value]="option.value">{{ option.label }}</hlm-select-item>
450
+ }
408
451
  </hlm-select-content>
409
452
  </div>
410
453
  `,
@@ -412,6 +455,15 @@ import { HlmSelectImports } from '@egose/shadcn-theme-ng/select';
412
455
  export class AdvancedSelectComponent {
413
456
  readonly plan = signal<string | null>(null);
414
457
  readonly open = signal(false);
458
+ readonly plans = [
459
+ { value: 'hobby', label: 'Hobby' },
460
+ { value: 'pro', label: 'Pro' },
461
+ ];
462
+
463
+ // Custom templates bypass itemToString — resolve the label explicitly.
464
+ planLabel(): string {
465
+ return this.plans.find((option) => option.value === this.plan())?.label ?? 'Pick a plan';
466
+ }
415
467
 
416
468
  onClosed(): void {
417
469
  console.log('panel closed');