@izumisy-tailor/tailor-data-viewer 0.1.49 → 0.1.51

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.
@@ -128,7 +128,11 @@ const DataViewer = createDataViewer({
128
128
 
129
129
  // Table-specific renderers
130
130
  "task:status": StatusBadge({
131
- colorMap: { todo: "gray", "in-progress": "blue", done: "green" },
131
+ valuesMap: {
132
+ todo: { color: "gray" },
133
+ "in-progress": { color: "blue" },
134
+ done: { color: "green" },
135
+ },
132
136
  }),
133
137
  },
134
138
  byFieldType: {
@@ -181,7 +185,11 @@ export const DataViewer = createDataViewer({
181
185
  "*:*Email": EmailLink,
182
186
  "*:id": TruncatedId,
183
187
  "orders:status": StatusBadge({
184
- colorMap: { pending: "yellow", approved: "green", rejected: "red" },
188
+ valuesMap: {
189
+ pending: { color: "yellow" },
190
+ approved: { color: "green" },
191
+ rejected: { color: "red" },
192
+ },
185
193
  }),
186
194
  },
187
195
  byFieldType: {
@@ -207,17 +215,17 @@ const { EmailLink, StatusBadge, BooleanIcon, TruncatedId } = builtInRenderers;
207
215
  byFieldName: {
208
216
  // Table-specific renderers
209
217
  "orders:status": StatusBadge({
210
- colorMap: {
211
- "pending": "yellow",
212
- "approved": "green",
213
- "rejected": "red",
218
+ valuesMap: {
219
+ "pending": { color: "yellow" },
220
+ "approved": { color: "green" },
221
+ "rejected": { color: "red" },
214
222
  },
215
223
  }),
216
224
  "invoices:status": StatusBadge({
217
- colorMap: {
218
- "draft": "gray",
219
- "sent": "blue",
220
- "paid": "green",
225
+ valuesMap: {
226
+ "draft": { color: "gray" },
227
+ "sent": { color: "blue" },
228
+ "paid": { color: "green" },
221
229
  },
222
230
  }),
223
231
 
@@ -296,22 +304,28 @@ byFieldType: {
296
304
 
297
305
  ### StatusBadge
298
306
 
299
- Factory function that creates a colored badge renderer based on value-to-color mapping.
307
+ Factory function that creates a colored badge renderer with custom display labels.
300
308
 
301
309
  ```tsx
302
310
  StatusBadge({
303
- colorMap: {
304
- "pending": "yellow", // Preset color
305
- "approved": "green",
306
- "rejected": "red",
307
- "pending*": "yellow", // Prefix match: pending, pending_review
308
- "*approved": "green", // Suffix match: manager_approved
309
- "*error*": "red", // Contains match: validation_error
311
+ valuesMap: {
312
+ "pending": { color: "yellow", label: "保留中" },
313
+ "approved": { color: "green", label: "承認済み" },
314
+ "rejected": { color: "red", label: "却下" },
315
+ "pending*": { color: "yellow" }, // Prefix match: pending, pending_review
316
+ "*approved": { color: "green" }, // Suffix match: manager_approved
317
+ "*error*": { color: "red" }, // Contains match: validation_error
310
318
  },
311
- defaultColor: "gray", // Default for unmatched values
319
+ defaultColor: "gray", // Default for unmatched values
312
320
  })
313
321
  ```
314
322
 
323
+ **Options:**
324
+ - `valuesMap?: Record<string, StatusBadgeValue>` — Mapping from value to color and label (supports wildcard patterns)
325
+ - `color?: BadgeColor` — Badge color (preset name or custom colors)
326
+ - `label?: string` — Display label (if not specified, the raw value is displayed)
327
+ - `defaultColor?: BadgeColor` — Default color for unmatched values (default: "gray")
328
+
315
329
  **Preset Colors:**
316
330
  - `gray`: #F3F4F6 bg, #374151 text
317
331
  - `red`: #FEE2E2 bg, #991B1B text
@@ -322,13 +336,13 @@ StatusBadge({
322
336
  **Custom Colors:**
323
337
  ```tsx
324
338
  StatusBadge({
325
- colorMap: {
326
- "custom_status": { bg: "#E0F2FE", text: "#0369A1" },
339
+ valuesMap: {
340
+ "custom_status": { color: { bg: "#E0F2FE", text: "#0369A1" } },
327
341
  },
328
342
  })
329
343
  ```
330
344
 
331
- **Pattern Priority:**
345
+ **Pattern Priority (for valuesMap keys):**
332
346
  1. Exact match (`"approved"`)
333
347
  2. Prefix match (`"approved*"`)
334
348
  3. Suffix match (`"*approved"`)
package/docs/labels.md CHANGED
@@ -20,6 +20,8 @@ type Labels = Record<string, string>;
20
20
  |------------|-------------|---------|
21
21
  | `tableName:fieldName` | Field label for specific table | `"orders:status"` → "Order Status" |
22
22
  | `*:fieldName` | Field label across all tables (wildcard) | `"*:createdAt"` → "Created" |
23
+ | `tableName:fieldName=value` | Value label for specific field | `"task:status=pending"` → "保留中" |
24
+ | `*:fieldName=value` | Value label across all tables (wildcard) | `"*:status=active"` → "有効" |
23
25
  | `tableName` | Table name label | `"orders"` → "Orders" |
24
26
  | `$:uiKey` | Built-in UI text override | `"$:refresh"` → "Refresh" |
25
27
 
@@ -37,6 +39,11 @@ For nested relation fields (`getLabel("task:assignee.name")`):
37
39
  4. `labels["*:name"]` — target field wildcard
38
40
  5. `"name"` — fallback to target field name
39
41
 
42
+ For value labels (`getValueLabel("task", "status", "pending")`):
43
+ 1. `labels["task:status=pending"]` — exact match (highest)
44
+ 2. `labels["*:status=pending"]` — wildcard match
45
+ 3. `"pending"` — fallback to string representation
46
+
40
47
  For UI labels (`getLabel("$:refresh")`):
41
48
  1. `labels["$:refresh"]` — custom override (highest)
42
49
  2. `DEFAULT_UI_LABELS["$:refresh"]` — built-in default
@@ -148,34 +155,51 @@ Access labels within components using the `useLabels` hook:
148
155
  import { useLabels } from "@izumisy-tailor/tailor-data-viewer/component";
149
156
 
150
157
  function MyComponent() {
151
- const { getLabel } = useLabels();
158
+ const { getLabel, getValueLabel } = useLabels();
152
159
 
153
160
  return (
154
161
  <div>
155
162
  <h2>{getLabel("orders")}</h2> {/* Table label */}
156
163
  <span>{getLabel("orders:status")}</span> {/* Field label */}
157
164
  <button>{getLabel("$:refresh")}</button> {/* UI label */}
165
+ <span>{getValueLabel("task", "status", "pending")}</span> {/* Value label */}
158
166
  </div>
159
167
  );
160
168
  }
161
169
  ```
162
170
 
163
- ## Using resolveLabel Function
171
+ ## Using resolveLabel and resolveValueLabel Functions
164
172
 
165
173
  For use outside of React components or context:
166
174
 
167
175
  ```tsx
168
- import { resolveLabel } from "@izumisy-tailor/tailor-data-viewer/component";
176
+ import { resolveLabel, resolveValueLabel } from "@izumisy-tailor/tailor-data-viewer/component";
169
177
 
170
- const labels = { "orders:status": "Order Status" };
178
+ const labels = {
179
+ "orders:status": "Order Status",
180
+ "task:status=pending": "保留中",
181
+ "*:status=active": "有効",
182
+ };
171
183
 
172
- // Resolve a label
184
+ // Resolve a field label
173
185
  const label = resolveLabel(labels, "orders:status");
174
186
  // → "Order Status"
175
187
 
176
188
  // Fallback to field name
177
189
  const fallback = resolveLabel(labels, "orders:unknown");
178
190
  // → "unknown"
191
+
192
+ // Resolve a value label
193
+ const valueLabel = resolveValueLabel(labels, "task", "status", "pending");
194
+ // → "保留中"
195
+
196
+ // Wildcard value label
197
+ const wildcardValueLabel = resolveValueLabel(labels, "order", "status", "active");
198
+ // → "有効"
199
+
200
+ // Value parameter is optional (returns empty string when omitted)
201
+ const emptyLabel = resolveValueLabel(labels, "task", "status");
202
+ // → ""
179
203
  ```
180
204
 
181
205
  ## Built-in UI Labels (DEFAULT_UI_LABELS)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.1.49",
4
+ "version": "0.1.51",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -230,3 +230,181 @@ describe("FileImageThumbnail", () => {
230
230
  });
231
231
  });
232
232
  });
233
+
234
+ describe("StatusBadge", () => {
235
+ const { StatusBadge } = builtInRenderers;
236
+
237
+ it("renders '-' for null value", () => {
238
+ const renderer = StatusBadge();
239
+ const props = createMockProps({ value: null });
240
+ render(<>{renderer(props)}</>);
241
+ expect(screen.getByText("-")).toBeInTheDocument();
242
+ });
243
+
244
+ it("renders '-' for empty string value", () => {
245
+ const renderer = StatusBadge();
246
+ const props = createMockProps({ value: "" });
247
+ render(<>{renderer(props)}</>);
248
+ expect(screen.getByText("-")).toBeInTheDocument();
249
+ });
250
+
251
+ it("renders value as badge", () => {
252
+ const renderer = StatusBadge();
253
+ const props = createMockProps({ value: "active" });
254
+ render(<>{renderer(props)}</>);
255
+ expect(screen.getByText("active")).toBeInTheDocument();
256
+ });
257
+
258
+ it("applies color from valuesMap", () => {
259
+ const renderer = StatusBadge({
260
+ valuesMap: { active: { color: "green" } },
261
+ });
262
+ const props = createMockProps({ value: "active" });
263
+ render(<>{renderer(props)}</>);
264
+
265
+ const badge = screen.getByText("active");
266
+ expect(badge).toHaveStyle({ backgroundColor: "#D1FAE5", color: "#065F46" });
267
+ });
268
+
269
+ it("applies custom color from valuesMap", () => {
270
+ const renderer = StatusBadge({
271
+ valuesMap: { active: { color: { bg: "#ff0000", text: "#ffffff" } } },
272
+ });
273
+ const props = createMockProps({ value: "active" });
274
+ render(<>{renderer(props)}</>);
275
+
276
+ const badge = screen.getByText("active");
277
+ expect(badge).toHaveStyle({ backgroundColor: "#ff0000", color: "#ffffff" });
278
+ });
279
+
280
+ it("applies defaultColor when value not in valuesMap", () => {
281
+ const renderer = StatusBadge({
282
+ valuesMap: { active: { color: "green" } },
283
+ defaultColor: "red",
284
+ });
285
+ const props = createMockProps({ value: "unknown" });
286
+ render(<>{renderer(props)}</>);
287
+
288
+ const badge = screen.getByText("unknown");
289
+ expect(badge).toHaveStyle({ backgroundColor: "#FEE2E2", color: "#991B1B" });
290
+ });
291
+
292
+ describe("valuesMap with label", () => {
293
+ it("displays label from valuesMap instead of raw value", () => {
294
+ const renderer = StatusBadge({
295
+ valuesMap: {
296
+ pending: { label: "保留中" },
297
+ active: { label: "有効" },
298
+ },
299
+ });
300
+ const props = createMockProps({ value: "pending" });
301
+ render(<>{renderer(props)}</>);
302
+ expect(screen.getByText("保留中")).toBeInTheDocument();
303
+ expect(screen.queryByText("pending")).not.toBeInTheDocument();
304
+ });
305
+
306
+ it("displays raw value when valuesMap does not contain the value", () => {
307
+ const renderer = StatusBadge({
308
+ valuesMap: {
309
+ pending: { label: "保留中" },
310
+ },
311
+ });
312
+ const props = createMockProps({ value: "unknown" });
313
+ render(<>{renderer(props)}</>);
314
+ expect(screen.getByText("unknown")).toBeInTheDocument();
315
+ });
316
+
317
+ it("combines label and color in valuesMap", () => {
318
+ const renderer = StatusBadge({
319
+ valuesMap: {
320
+ active: { color: "green", label: "有効" },
321
+ },
322
+ });
323
+ const props = createMockProps({ value: "active" });
324
+ render(<>{renderer(props)}</>);
325
+
326
+ const badge = screen.getByText("有効");
327
+ expect(badge).toBeInTheDocument();
328
+ expect(badge).toHaveStyle({
329
+ backgroundColor: "#D1FAE5",
330
+ color: "#065F46",
331
+ });
332
+ });
333
+
334
+ it("uses defaultColor when only label is specified", () => {
335
+ const renderer = StatusBadge({
336
+ valuesMap: {
337
+ pending: { label: "保留中" },
338
+ },
339
+ defaultColor: "yellow",
340
+ });
341
+ const props = createMockProps({ value: "pending" });
342
+ render(<>{renderer(props)}</>);
343
+
344
+ const badge = screen.getByText("保留中");
345
+ expect(badge).toHaveStyle({
346
+ backgroundColor: "#FEF3C7",
347
+ color: "#92400E",
348
+ });
349
+ });
350
+ });
351
+
352
+ describe("wildcard valuesMap patterns", () => {
353
+ it("matches prefix pattern", () => {
354
+ const renderer = StatusBadge({
355
+ valuesMap: { "active*": { color: "green" } },
356
+ });
357
+ const props = createMockProps({ value: "active_state" });
358
+ render(<>{renderer(props)}</>);
359
+
360
+ const badge = screen.getByText("active_state");
361
+ expect(badge).toHaveStyle({
362
+ backgroundColor: "#D1FAE5",
363
+ color: "#065F46",
364
+ });
365
+ });
366
+
367
+ it("matches suffix pattern", () => {
368
+ const renderer = StatusBadge({
369
+ valuesMap: { "*_active": { color: "green" } },
370
+ });
371
+ const props = createMockProps({ value: "is_active" });
372
+ render(<>{renderer(props)}</>);
373
+
374
+ const badge = screen.getByText("is_active");
375
+ expect(badge).toHaveStyle({
376
+ backgroundColor: "#D1FAE5",
377
+ color: "#065F46",
378
+ });
379
+ });
380
+
381
+ it("matches contains pattern", () => {
382
+ const renderer = StatusBadge({
383
+ valuesMap: { "*active*": { color: "green" } },
384
+ });
385
+ const props = createMockProps({ value: "is_active_now" });
386
+ render(<>{renderer(props)}</>);
387
+
388
+ const badge = screen.getByText("is_active_now");
389
+ expect(badge).toHaveStyle({
390
+ backgroundColor: "#D1FAE5",
391
+ color: "#065F46",
392
+ });
393
+ });
394
+
395
+ it("applies label from wildcard match", () => {
396
+ const renderer = StatusBadge({
397
+ valuesMap: { "error*": { color: "red", label: "エラー" } },
398
+ });
399
+ const props = createMockProps({ value: "error_500" });
400
+ render(<>{renderer(props)}</>);
401
+
402
+ // wildcard match でもlabelが設定されていれば表示される
403
+ const badge = screen.getByText("エラー");
404
+ expect(badge).toHaveStyle({
405
+ backgroundColor: "#FEE2E2",
406
+ color: "#991B1B",
407
+ });
408
+ });
409
+ });
410
+ });
@@ -28,12 +28,22 @@ export interface CustomColor {
28
28
  */
29
29
  export type BadgeColor = PresetColor | CustomColor;
30
30
 
31
+ /**
32
+ * Value configuration for StatusBadge
33
+ */
34
+ export interface StatusBadgeValue {
35
+ /** Badge color (preset name or custom colors) */
36
+ color?: BadgeColor;
37
+ /** Display label (if not specified, the raw value is displayed) */
38
+ label?: string;
39
+ }
40
+
31
41
  /**
32
42
  * Options for StatusBadge renderer
33
43
  */
34
44
  export interface StatusBadgeOptions {
35
45
  /**
36
- * Mapping from value to color
46
+ * Mapping from value to color and label configuration
37
47
  *
38
48
  * Key format (wildcard support):
39
49
  * - "pending" → exact match
@@ -42,10 +52,12 @@ export interface StatusBadgeOptions {
42
52
  * - "*pending*" → contains match
43
53
  *
44
54
  * Value format:
45
- * - "green" preset color
46
- * - { bg: "#xxx", text: "#xxx" } custom color
55
+ * - { color: "green", label: "有効" } both color and label
56
+ * - { color: "green" } color only
57
+ * - { label: "保留中" } → label only
58
+ * - { color: { bg: "#xxx", text: "#xxx" } } → custom color
47
59
  */
48
- colorMap?: Record<string, BadgeColor>;
60
+ valuesMap?: Record<string, StatusBadgeValue>;
49
61
  defaultColor?: BadgeColor;
50
62
  }
51
63
 
@@ -115,38 +127,62 @@ function matchPattern(value: string, pattern: string): boolean {
115
127
  }
116
128
  }
117
129
 
118
- function resolveStatusColor(
119
- value: unknown,
120
- options: StatusBadgeOptions,
121
- ): CustomColor {
122
- const { colorMap = {}, defaultColor = "gray" } = options;
123
- const strValue = String(value ?? "");
124
-
125
- // Priority: exact prefix → suffix → contains
130
+ /**
131
+ * Find matching key from valuesMap with wildcard support
132
+ * Priority: exact → prefix → suffix → contains
133
+ */
134
+ function findMatchingKey(
135
+ valuesMap: Record<string, StatusBadgeValue>,
136
+ strValue: string,
137
+ ): string | undefined {
126
138
  // 1. Exact match
127
- if (colorMap[strValue]) {
128
- return resolveColor(colorMap[strValue]);
139
+ if (valuesMap[strValue]) {
140
+ return strValue;
129
141
  }
130
142
 
131
143
  // 2. Prefix match (pattern*)
132
- const prefixKey = Object.keys(colorMap).find(
144
+ const prefixKey = Object.keys(valuesMap).find(
133
145
  (k) => k.endsWith("*") && !k.startsWith("*") && matchPattern(strValue, k),
134
146
  );
135
- if (prefixKey) return resolveColor(colorMap[prefixKey]);
147
+ if (prefixKey) return prefixKey;
136
148
 
137
149
  // 3. Suffix match (*pattern)
138
- const suffixKey = Object.keys(colorMap).find(
150
+ const suffixKey = Object.keys(valuesMap).find(
139
151
  (k) => k.startsWith("*") && !k.endsWith("*") && matchPattern(strValue, k),
140
152
  );
141
- if (suffixKey) return resolveColor(colorMap[suffixKey]);
153
+ if (suffixKey) return suffixKey;
142
154
 
143
155
  // 4. Contains match (*pattern*)
144
- const containsKey = Object.keys(colorMap).find(
156
+ const containsKey = Object.keys(valuesMap).find(
145
157
  (k) => k.startsWith("*") && k.endsWith("*") && matchPattern(strValue, k),
146
158
  );
147
- if (containsKey) return resolveColor(colorMap[containsKey]);
159
+ if (containsKey) return containsKey;
148
160
 
149
- return resolveColor(defaultColor);
161
+ return undefined;
162
+ }
163
+
164
+ function resolveStatusConfig(
165
+ value: unknown,
166
+ options: StatusBadgeOptions,
167
+ ): { color: CustomColor; label: string } {
168
+ const { valuesMap = {}, defaultColor = "gray" } = options;
169
+ const strValue = String(value ?? "");
170
+
171
+ const matchedKey = findMatchingKey(valuesMap, strValue);
172
+ if (matchedKey) {
173
+ const config = valuesMap[matchedKey];
174
+ return {
175
+ color: config.color
176
+ ? resolveColor(config.color)
177
+ : resolveColor(defaultColor),
178
+ label: config.label ?? strValue,
179
+ };
180
+ }
181
+
182
+ return {
183
+ color: resolveColor(defaultColor),
184
+ label: strValue,
185
+ };
150
186
  }
151
187
 
152
188
  // =============================================================================
@@ -266,7 +302,7 @@ const ArrayBadges: CellRenderer = ({ value }) => {
266
302
 
267
303
  /**
268
304
  * Factory function to create a StatusBadge renderer with custom options
269
- * Renders values as colored badges based on colorMap
305
+ * Renders values as colored badges based on valuesMap configuration
270
306
  */
271
307
  function StatusBadge(options: StatusBadgeOptions = {}): CellRenderer {
272
308
  return ({ value }) => {
@@ -274,15 +310,14 @@ function StatusBadge(options: StatusBadgeOptions = {}): CellRenderer {
274
310
  return <span className="text-muted-foreground">-</span>;
275
311
  }
276
312
 
277
- const strValue = String(value);
278
- const color = resolveStatusColor(strValue, options);
313
+ const { color, label } = resolveStatusConfig(value, options);
279
314
 
280
315
  return (
281
316
  <span
282
317
  className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
283
318
  style={{ backgroundColor: color.bg, color: color.text }}
284
319
  >
285
- {strValue}
320
+ {label}
286
321
  </span>
287
322
  );
288
323
  };
@@ -272,7 +272,7 @@ export function DataTable(props: DataTableProps = {}) {
272
272
 
273
273
  return (
274
274
  <>
275
- <div className="rounded-md border">
275
+ <div className="rounded-md border bg-background">
276
276
  <Table>
277
277
  <TableHeader>
278
278
  <TableRow>
@@ -1,69 +1,18 @@
1
1
  import { useCallback } from "react";
2
2
  import { useDataViewer } from "../contexts";
3
- import type { Labels } from "../types";
4
- import { DEFAULT_UI_LABELS } from "../ui-labels";
3
+ import { resolveLabel, resolveValueLabel } from "../label-resolver";
5
4
 
6
- /**
7
- * Resolve a label from a key
8
- *
9
- * @param labels Labels configuration
10
- * @param key Label key (e.g., "orders:status", "*:createdAt", "orders", "$:refresh")
11
- * @returns Resolved label with wildcard fallback for "table:field" format
12
- *
13
- * Resolution order for UI labels (`$:*` format):
14
- * 1. labels["$:key"]
15
- * 2. DEFAULT_UI_LABELS["$:key"]
16
- *
17
- * Resolution order for "tableName:fieldName" format:
18
- * 1. labels["tableName:fieldName"]
19
- * 2. labels["*:fieldName"] (wildcard fallback)
20
- * 3. "fieldName" (default)
21
- *
22
- * Resolution order for other formats (e.g., "tableName", "fieldName"):
23
- * 1. labels[key]
24
- * 2. key itself (default, no wildcard fallback)
25
- *
26
- * @example
27
- * ```tsx
28
- * resolveLabel(labels, "$:refresh") // labels["$:refresh"] ?? DEFAULT_UI_LABELS["$:refresh"]
29
- * resolveLabel(labels, "orders:status") // labels["orders:status"] ?? labels["*:status"] ?? "status"
30
- * resolveLabel(labels, "orders") // labels["orders"] ?? "orders"
31
- * ```
32
- */
33
- export function resolveLabel(labels: Labels | undefined, key: string): string {
34
- // If key exists in labels, return it
35
- if (labels?.[key]) {
36
- return labels[key];
37
- }
38
-
39
- // Handle UI labels ($:* format) - fallback to DEFAULT_UI_LABELS
40
- if (key.startsWith("$:")) {
41
- return DEFAULT_UI_LABELS[key] ?? key;
42
- }
43
-
44
- const colonIndex = key.indexOf(":");
45
- if (colonIndex >= 0) {
46
- const fieldName = key.slice(colonIndex + 1);
47
- // For "table:field" format, try wildcard fallback
48
- const wildcardKey = `*:${fieldName}`;
49
- if (labels?.[wildcardKey]) {
50
- return labels[wildcardKey];
51
- }
52
- return fieldName;
53
- }
54
-
55
- // For keys without colon, return key itself (no wildcard fallback)
56
- return key;
57
- }
5
+ // Re-export for backward compatibility
6
+ export { resolveLabel, resolveValueLabel };
58
7
 
59
8
  /**
60
9
  * Hook to get labels from DataViewer context
61
10
  *
62
- * @returns Object with getLabel function
11
+ * @returns Object with getLabel and getValueLabel functions
63
12
  *
64
13
  * @example
65
14
  * ```tsx
66
- * const { getLabel } = useLabels();
15
+ * const { getLabel, getValueLabel } = useLabels();
67
16
  *
68
17
  * // Field label
69
18
  * <span>{getLabel(`${tableName}:${field.name}`)}</span>
@@ -73,6 +22,9 @@ export function resolveLabel(labels: Labels | undefined, key: string): string {
73
22
  *
74
23
  * // Cross-table field label
75
24
  * <span>{getLabel(`*:${field.name}`)}</span>
25
+ *
26
+ * // Value label (e.g., enum display name)
27
+ * <span>{getValueLabel(tableName, fieldName, value)}</span>
76
28
  * ```
77
29
  */
78
30
  export function useLabels() {
@@ -85,5 +37,12 @@ export function useLabels() {
85
37
  [labels],
86
38
  );
87
39
 
88
- return { getLabel, labels };
40
+ const getValueLabel = useCallback(
41
+ (tableName: string, fieldName: string, value?: unknown): string => {
42
+ return resolveValueLabel(labels, tableName, fieldName, value);
43
+ },
44
+ [labels],
45
+ );
46
+
47
+ return { getLabel, getValueLabel, labels };
89
48
  }
@@ -55,6 +55,7 @@ export type {
55
55
  PresetColor,
56
56
  CustomColor,
57
57
  BadgeColor,
58
+ StatusBadgeValue,
58
59
  StatusBadgeOptions,
59
60
  FileImageThumbnailOptions,
60
61
  BuiltInRenderers,
@@ -103,7 +104,7 @@ export type {
103
104
  export { isFileValue, OPERATOR_LABELS, OPERATORS_BY_FIELD_TYPE } from "./types";
104
105
 
105
106
  // Label utilities
106
- export { resolveLabel } from "./label-resolver";
107
+ export { resolveLabel, resolveValueLabel } from "./label-resolver";
107
108
  export { useLabels } from "./hooks/use-labels";
108
109
  export { DEFAULT_UI_LABELS } from "./ui-labels";
109
110
  export type { UILabelKey } from "./ui-labels";
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { resolveLabel } from "./label-resolver";
2
+ import { resolveLabel, resolveValueLabel } from "./label-resolver";
3
3
  import type { Labels } from "./types";
4
4
 
5
5
  describe("resolveLabel", () => {
@@ -141,3 +141,89 @@ describe("resolveLabel", () => {
141
141
  });
142
142
  });
143
143
  });
144
+
145
+ describe("resolveValueLabel", () => {
146
+ it("returns value label when exact match exists", () => {
147
+ const labels: Labels = {
148
+ "task:status=pending": "保留中",
149
+ "task:status=active": "有効",
150
+ };
151
+ expect(resolveValueLabel(labels, "task", "status", "pending")).toBe(
152
+ "保留中",
153
+ );
154
+ expect(resolveValueLabel(labels, "task", "status", "active")).toBe("有効");
155
+ });
156
+
157
+ it("falls back to wildcard match", () => {
158
+ const labels: Labels = {
159
+ "*:status=completed": "完了",
160
+ };
161
+ expect(resolveValueLabel(labels, "task", "status", "completed")).toBe(
162
+ "完了",
163
+ );
164
+ expect(resolveValueLabel(labels, "order", "status", "completed")).toBe(
165
+ "完了",
166
+ );
167
+ });
168
+
169
+ it("falls back to string representation when no label found", () => {
170
+ const labels: Labels = {};
171
+ expect(resolveValueLabel(labels, "task", "status", "unknown")).toBe(
172
+ "unknown",
173
+ );
174
+ });
175
+
176
+ it("handles undefined labels", () => {
177
+ expect(resolveValueLabel(undefined, "task", "status", "pending")).toBe(
178
+ "pending",
179
+ );
180
+ });
181
+
182
+ it("handles null and undefined values", () => {
183
+ const labels: Labels = {};
184
+ expect(resolveValueLabel(labels, "task", "status", null)).toBe("");
185
+ expect(resolveValueLabel(labels, "task", "status", undefined)).toBe("");
186
+ });
187
+
188
+ it("handles omitted value parameter", () => {
189
+ const labels: Labels = {};
190
+ expect(resolveValueLabel(labels, "task", "status")).toBe("");
191
+ });
192
+
193
+ it("prefers exact match over wildcard", () => {
194
+ const labels: Labels = {
195
+ "task:status=pending": "タスク保留中",
196
+ "*:status=pending": "保留中(共通)",
197
+ };
198
+ expect(resolveValueLabel(labels, "task", "status", "pending")).toBe(
199
+ "タスク保留中",
200
+ );
201
+ expect(resolveValueLabel(labels, "order", "status", "pending")).toBe(
202
+ "保留中(共通)",
203
+ );
204
+ });
205
+
206
+ it("handles numeric values", () => {
207
+ const labels: Labels = {
208
+ "task:priority=1": "低",
209
+ "task:priority=2": "中",
210
+ "task:priority=3": "高",
211
+ };
212
+ expect(resolveValueLabel(labels, "task", "priority", 1)).toBe("低");
213
+ expect(resolveValueLabel(labels, "task", "priority", 2)).toBe("中");
214
+ expect(resolveValueLabel(labels, "task", "priority", 3)).toBe("高");
215
+ });
216
+
217
+ it("handles boolean values", () => {
218
+ const labels: Labels = {
219
+ "task:isActive=true": "アクティブ",
220
+ "task:isActive=false": "非アクティブ",
221
+ };
222
+ expect(resolveValueLabel(labels, "task", "isActive", true)).toBe(
223
+ "アクティブ",
224
+ );
225
+ expect(resolveValueLabel(labels, "task", "isActive", false)).toBe(
226
+ "非アクティブ",
227
+ );
228
+ });
229
+ });
@@ -86,3 +86,56 @@ export function resolveLabel(labels: Labels | undefined, key: string): string {
86
86
  // For keys without colon, return key itself (no wildcard fallback)
87
87
  return key;
88
88
  }
89
+
90
+ /**
91
+ * Resolve a value label (e.g., enum value display name)
92
+ *
93
+ * @param labels Labels configuration
94
+ * @param tableName Table name
95
+ * @param fieldName Field name
96
+ * @param value The actual value to resolve a label for (optional)
97
+ * @returns Resolved label or the original value as string
98
+ *
99
+ * Resolution order:
100
+ * 1. labels["tableName:fieldName=value"] - exact match
101
+ * 2. labels["*:fieldName=value"] - wildcard match
102
+ * 3. String(value) - fallback to string representation
103
+ *
104
+ * @example
105
+ * ```tsx
106
+ * const labels = {
107
+ * "task:status=pending": "保留中",
108
+ * "task:status=active": "有効",
109
+ * "*:status=completed": "完了",
110
+ * };
111
+ *
112
+ * resolveValueLabel(labels, "task", "status", "pending") // → "保留中"
113
+ * resolveValueLabel(labels, "task", "status", "active") // → "有効"
114
+ * resolveValueLabel(labels, "order", "status", "completed") // → "完了" (wildcard)
115
+ * resolveValueLabel(labels, "task", "status", "unknown") // → "unknown"
116
+ * resolveValueLabel(labels, "task", "status") // → ""
117
+ * ```
118
+ */
119
+ export function resolveValueLabel(
120
+ labels: Labels | undefined,
121
+ tableName: string,
122
+ fieldName: string,
123
+ value?: unknown,
124
+ ): string {
125
+ const strValue = String(value ?? "");
126
+
127
+ // 1. Exact match: tableName:fieldName=value
128
+ const exactKey = `${tableName}:${fieldName}=${strValue}`;
129
+ if (labels?.[exactKey]) {
130
+ return labels[exactKey];
131
+ }
132
+
133
+ // 2. Wildcard match: *:fieldName=value
134
+ const wildcardKey = `*:${fieldName}=${strValue}`;
135
+ if (labels?.[wildcardKey]) {
136
+ return labels[wildcardKey];
137
+ }
138
+
139
+ // 3. Fallback to string representation
140
+ return strValue;
141
+ }
@@ -4,7 +4,12 @@ import * as React from "react";
4
4
 
5
5
  import { cn } from "../lib/utils";
6
6
 
7
- function Table({ className, ...props }: React.ComponentProps<"table">) {
7
+ interface TableProps extends React.ComponentProps<"table"> {
8
+ /** Table layout algorithm. Defaults to "fixed" to respect explicit column widths. */
9
+ tableLayout?: "fixed" | "auto";
10
+ }
11
+
12
+ function Table({ className, tableLayout = "fixed", ...props }: TableProps) {
8
13
  return (
9
14
  <div
10
15
  data-slot="table-container"
@@ -12,7 +17,11 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
12
17
  >
13
18
  <table
14
19
  data-slot="table"
15
- className={cn("w-full caption-bottom text-sm", className)}
20
+ className={cn(
21
+ "w-full caption-bottom text-sm",
22
+ tableLayout === "fixed" ? "table-fixed" : "table-auto",
23
+ className,
24
+ )}
16
25
  {...props}
17
26
  />
18
27
  </div>