frappe-ui 1.0.0-beta.75 → 1.0.0-beta.77

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.
Files changed (51) hide show
  1. package/experimental/CommandPalette/CommandPaletteFooter.vue +1 -1
  2. package/experimental/CommandPalette/CommandPaletteGroup.vue +1 -1
  3. package/experimental/CommandPalette/CommandPaletteInput.vue +1 -1
  4. package/experimental/CommandPalette/CommandPaletteItem.vue +1 -1
  5. package/package.json +1 -1
  6. package/src/components/Badge/Badge.vue +1 -1
  7. package/src/components/Breadcrumbs/Breadcrumbs.vue +6 -4
  8. package/src/components/Button/Button.vue +6 -1
  9. package/src/components/Combobox/Combobox.vue +1 -1
  10. package/src/components/Combobox/ComboboxResults.vue +2 -2
  11. package/src/components/DatePicker/CalendarPanel.vue +2 -2
  12. package/src/components/FormLabel/FormLabel.vue +5 -1
  13. package/src/components/InputLabeling/InputLabel.vue +1 -1
  14. package/src/components/ItemListRow/ItemListRow.vue +1 -1
  15. package/src/components/KeyboardShortcut/KeyboardShortcut.md +5 -5
  16. package/src/components/Menu/MenuItemContent.vue +1 -1
  17. package/src/components/Menu/utils.ts +2 -1
  18. package/src/components/MobileNav/MobileNavItem.vue +2 -2
  19. package/src/components/MultiSelect/MultiSelectResults.vue +1 -1
  20. package/src/components/Select/Select.vue +3 -3
  21. package/src/components/SettingsDialog/SettingsRow.vue +1 -1
  22. package/src/components/Sidebar/Sidebar.api.md +8 -1
  23. package/src/components/Sidebar/Sidebar.md +27 -2
  24. package/src/components/Sidebar/Sidebar.vue +8 -2
  25. package/src/components/Sidebar/SidebarHeader.vue +67 -43
  26. package/src/components/Sidebar/SidebarItem.vue +56 -8
  27. package/src/components/Sidebar/SidebarLabel.vue +1 -1
  28. package/src/components/Sidebar/SidebarSection.vue +13 -6
  29. package/src/components/Sidebar/types.ts +10 -2
  30. package/src/components/SidebarRail/SidebarRail.md +4 -0
  31. package/src/components/SidebarRail/SidebarRailItem.vue +12 -1
  32. package/src/components/Switch/Switch.vue +4 -1
  33. package/src/components/TextInput/TextInput.vue +1 -1
  34. package/src/components/TimePicker/TimePicker.vue +1 -1
  35. package/src/components/Tree/TreeItem.vue +3 -1
  36. package/src/components/shared/tabs/Pill.vue +3 -1
  37. package/src/molecules/editor/extensions/shared/media-node-ops.ts +2 -2
  38. package/src/molecules/editor/extensions/shared/media-upload-engine.ts +3 -3
  39. package/src/molecules/editor/extensions/shared/media-upload-types.ts +3 -3
  40. package/src/molecules/editor/extensions/shared/upload-types.ts +2 -2
  41. package/src/molecules/editor/index.ts +1 -1
  42. package/src/molecules/editor/useEditor.ts +2 -2
  43. package/src/molecules/list/ListGroup.vue +1 -1
  44. package/src/molecules/list/ListHeader.vue +5 -1
  45. package/src/molecules/list/ListHeaderCell.vue +1 -1
  46. package/src/molecules/list/ListHeaderCellSort.vue +2 -2
  47. package/src/utils/useFileUpload.ts +1 -1
  48. package/tailwind/plugin.js +7 -0
  49. package/tailwind/tokens/build.js +11 -2
  50. package/tailwind/tokens/typography.js +10 -10
  51. package/src/components/Sidebar/SidebarItemIcon.vue +0 -25
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div
3
3
  data-slot="command-palette-footer"
4
- class="flex shrink-0 items-center gap-4 border-t border-outline-gray-1 px-4.5 py-2 text-sm text-ink-gray-6 dark:border-outline-gray-2"
4
+ class="flex shrink-0 items-center gap-4 border-t border-outline-gray-1 px-4.5 py-2 text-sm leading-tighter text-ink-gray-6 dark:border-outline-gray-2"
5
5
  >
6
6
  <slot v-bind="slotProps" />
7
7
  </div>
@@ -9,7 +9,7 @@
9
9
  v-if="label"
10
10
  as="div"
11
11
  data-slot="command-palette-group-label"
12
- class="mb-2.5 px-4.5 text-base text-ink-gray-5"
12
+ class="mb-2.5 px-4.5 text-base leading-tighter text-ink-gray-5"
13
13
  >
14
14
  {{ label }}
15
15
  </ListboxGroupLabel>
@@ -12,7 +12,7 @@
12
12
  auto-focus
13
13
  autocomplete="off"
14
14
  :placeholder="placeholder"
15
- class="w-full border-none bg-transparent py-3 px-0 text-base text-ink-gray-8 placeholder-ink-gray-4 focus:ring-0"
15
+ class="w-full border-none bg-transparent py-3 px-0 text-base leading-tighter text-ink-gray-8 placeholder-ink-gray-4 focus:ring-0"
16
16
  v-bind="$attrs"
17
17
  />
18
18
  <slot name="suffix" />
@@ -13,7 +13,7 @@
13
13
  data-slot="command-palette-item"
14
14
  :data-state="active ? 'active' : undefined"
15
15
  :data-disabled="disabled ? '' : undefined"
16
- class="mx-2.5 flex min-w-0 cursor-default items-center rounded-4 px-2 py-2 text-base-medium text-ink-gray-8 outline-none data-[state=active]:bg-surface-gray-2 data-[disabled]:cursor-not-allowed data-[disabled]:text-ink-gray-4"
16
+ class="mx-2.5 flex min-w-0 cursor-default items-center rounded-4 px-2 py-2 text-base-medium leading-tighter text-ink-gray-8 outline-none data-[state=active]:bg-surface-gray-2 data-[disabled]:cursor-not-allowed data-[disabled]:text-ink-gray-4"
17
17
  v-bind="$attrs"
18
18
  >
19
19
  <slot name="prefix" v-bind="slotProps" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frappe-ui",
3
- "version": "1.0.0-beta.75",
3
+ "version": "1.0.0-beta.77",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "engines": {
6
6
  "node": ">=20.19.0"
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <div
3
- class="inline-flex select-none items-center gap-1 overflow-clip rounded-full whitespace-nowrap"
3
+ class="inline-flex select-none items-center gap-1 overflow-clip rounded-full leading-tighter whitespace-nowrap"
4
4
  :class="classes"
5
5
  >
6
6
  <div
@@ -13,13 +13,15 @@
13
13
  </span>
14
14
  </template>
15
15
 
16
- <div class="flex min-w-0 items-center text-ellipsis whitespace-nowrap">
16
+ <div
17
+ class="flex min-w-0 items-center text-ellipsis whitespace-nowrap leading-tighter"
18
+ >
17
19
  <template v-for="(item, i) in crumbs" :key="item.label">
18
20
  <router-link
19
21
  v-if="item.route"
20
22
  :to="item.route"
21
23
  @click="item.onClick?.($event)"
22
- class="flex items-center rounded-4 px-0.5 py-1 text-lg-medium"
24
+ class="flex items-center rounded-4 px-0.5 py-1 text-lg-medium leading-tighter"
23
25
  :class="[
24
26
  i == crumbs.length - 1
25
27
  ? 'min-w-0 text-ink-gray-9'
@@ -39,7 +41,7 @@
39
41
  v-else-if="item.href"
40
42
  :href="item.href"
41
43
  @click="onLinkClick(item, $event)"
42
- class="flex items-center rounded-4 px-0.5 py-1 text-lg-medium"
44
+ class="flex items-center rounded-4 px-0.5 py-1 text-lg-medium leading-tighter"
43
45
  :class="[
44
46
  i == crumbs.length - 1
45
47
  ? 'min-w-0 text-ink-gray-9'
@@ -58,7 +60,7 @@
58
60
  <button
59
61
  v-else
60
62
  @click="item.onClick?.($event)"
61
- class="flex items-center rounded-4 px-0.5 py-1 text-lg-medium"
63
+ class="flex items-center rounded-4 px-0.5 py-1 text-lg-medium leading-tighter"
62
64
  :class="[
63
65
  i == crumbs.length - 1
64
66
  ? 'min-w-0 text-ink-gray-9'
@@ -321,7 +321,12 @@ export default defineComponent({
321
321
  }
322
322
  return h(
323
323
  'span',
324
- { class: ['truncate', { 'sr-only': isIconButton.value }] },
324
+ {
325
+ class: [
326
+ 'truncate leading-tighter',
327
+ { 'sr-only': isIconButton.value },
328
+ ],
329
+ },
325
330
  slots.default?.() ?? props.label,
326
331
  )
327
332
  }
@@ -587,7 +587,7 @@ defineSlots<ComboboxSlots>()
587
587
 
588
588
  <span
589
589
  :class="[
590
- 'min-w-0 flex-1 truncate text-left font-normal',
590
+ 'min-w-0 flex-1 truncate text-left font-normal leading-tighter',
591
591
  !selectedOption && 'text-ink-gray-4',
592
592
  ]"
593
593
  >
@@ -140,7 +140,7 @@ function handleSelect(item: NormalizedItem, event: Event) {
140
140
  <ComboboxLabel
141
141
  v-if="group.group && !group.hideLabel"
142
142
  data-slot="group-label"
143
- class="flex h-7 items-center px-2 text-sm-medium text-ink-gray-4"
143
+ class="flex h-7 items-center px-2 text-sm-medium leading-tighter text-ink-gray-4"
144
144
  >
145
145
  <component
146
146
  :is="ItemSlotRender"
@@ -245,7 +245,7 @@ function handleSelect(item: NormalizedItem, event: Event) {
245
245
  />
246
246
 
247
247
  <div v-else class="min-w-0">
248
- <div class="truncate">{{ item.label }}</div>
248
+ <div class="truncate leading-tighter">{{ item.label }}</div>
249
249
  <div
250
250
  v-if="item.description"
251
251
  class="truncate text-p-sm text-ink-gray-5"
@@ -158,7 +158,7 @@
158
158
  v-for="y in years"
159
159
  type="button"
160
160
  :key="y"
161
- class="w-full text-ink-gray-8 h-7 shrink-0 rounded-4 py-1 text-sm text-center cursor-pointer transition-colors duration-100"
161
+ class="w-full text-ink-gray-8 h-7 shrink-0 rounded-4 py-1 text-sm leading-tighter text-center cursor-pointer transition-colors duration-100"
162
162
  :class="
163
163
  y === currentYear
164
164
  ? 'bg-surface-gray-2 hover:bg-surface-gray-3'
@@ -181,7 +181,7 @@
181
181
  v-for="(m, i) in months"
182
182
  type="button"
183
183
  :key="m"
184
- class="w-full text-ink-gray-8 shrink-0 h-7 rounded-4 py-1 text-sm text-center cursor-pointer transition-colors duration-100"
184
+ class="w-full text-ink-gray-8 shrink-0 h-7 rounded-4 py-1 text-sm leading-tighter text-center cursor-pointer transition-colors duration-100"
185
185
  :class="
186
186
  i === currentMonth
187
187
  ? 'bg-surface-gray-2 hover:bg-surface-gray-3'
@@ -1,5 +1,9 @@
1
1
  <template>
2
- <label class="block text-sm text-ink-gray-6" :for="id" data-slot="label">
2
+ <label
3
+ class="block text-sm leading-tighter text-ink-gray-6"
4
+ :for="id"
5
+ data-slot="label"
6
+ >
3
7
  {{ label }}
4
8
  <RequiredIndicator :required="required" />
5
9
  </label>
@@ -41,7 +41,7 @@ const labelClasses = computed(() => {
41
41
  // Disabled wins over `color` — a dimmed label is a state, not a choice, so
42
42
  // callers shouldn't have to remember to swap the colour themselves.
43
43
  return [
44
- 'block text-sm',
44
+ 'block text-sm leading-tighter',
45
45
  props.disabled
46
46
  ? 'text-ink-gray-4'
47
47
  : props.color === 'gray-7'
@@ -69,7 +69,7 @@ defineSlots<{
69
69
  <slot name="prefix" />
70
70
  </div>
71
71
 
72
- <div data-slot="item-label" class="min-w-0 flex-1">
72
+ <div data-slot="item-label" class="min-w-0 flex-1 leading-tighter">
73
73
  <slot name="label">
74
74
  <slot />
75
75
  </slot>
@@ -37,11 +37,11 @@ and Alt as icons, `bg` mode draws them as text.
37
37
 
38
38
  ## Styling hooks
39
39
 
40
- The root carries `data-slot="keyboard-shortcut"`, and `data-bg="true"` when `bg`
41
- is set. Each key carries `data-slot="key"` with a `data-key-type` of `cmd`,
42
- `ctrl`, `shift`, `alt` or `key`. The `+` separators carry
43
- `data-slot="separator"`, and alternative combos sit in `data-slot="alt-combos"`
44
- inside the root. Style through those instead of a class prop (P10):
40
+ The root carries `data-slot="keyboard-shortcut"`, and `data-bg` when `bg` is
41
+ set. Each key carries `data-slot="key"` with a `data-key-type` of `cmd`, `ctrl`,
42
+ `shift`, `alt` or `key`. The `+` separators carry `data-slot="separator"`, and
43
+ alternative combos sit in `data-slot="alt-combos"` inside the root. Style
44
+ through those instead of a class prop (P10):
45
45
 
46
46
  ```css
47
47
  [data-slot='keyboard-shortcut'][data-bg] [data-slot='key'] {
@@ -167,7 +167,7 @@ function handleSwitchChange(value: boolean) {
167
167
  :content="itemSlotsLabelContent"
168
168
  />
169
169
  <div v-else class="min-w-0">
170
- <div :class="['truncate', getMenuTextColor(item)]">
170
+ <div :class="['truncate leading-tighter', getMenuTextColor(item)]">
171
171
  {{ item.label }}
172
172
  </div>
173
173
  <div v-if="item.description" class="truncate text-p-sm text-ink-gray-5">
@@ -18,7 +18,8 @@ export const menuClasses = {
18
18
  content:
19
19
  'menu-content z-[100] min-w-40 divide-y divide-outline-elevation-2 rounded-6 bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none',
20
20
  group: 'p-1.5',
21
- groupLabel: 'flex h-7 items-center px-2 text-sm font-medium text-ink-gray-4',
21
+ groupLabel:
22
+ 'flex h-7 items-center px-2 text-sm font-medium leading-tighter text-ink-gray-4',
22
23
  itemIcon: 'size-4 shrink-0',
23
24
  itemIconPlaceholder: 'size-4 shrink-0',
24
25
  chevronIcon: 'size-4 shrink-0',
@@ -26,7 +26,7 @@
26
26
  />
27
27
  </slot>
28
28
  <span
29
- class="text-xs-medium"
29
+ class="text-xs-medium leading-tighter"
30
30
  :class="resolvedActive ? 'text-ink-gray-8' : 'text-ink-gray-5'"
31
31
  >
32
32
  {{ label }}
@@ -51,7 +51,7 @@
51
51
  />
52
52
  </slot>
53
53
  <span
54
- class="text-xs-medium"
54
+ class="text-xs-medium leading-tighter"
55
55
  :class="resolvedActive ? 'text-ink-gray-8' : 'text-ink-gray-5'"
56
56
  >
57
57
  {{ label }}
@@ -109,7 +109,7 @@ function getItemTextValue(item: NormalizedOption) {
109
109
  <ComboboxLabel
110
110
  v-if="group.group && !group.hideLabel"
111
111
  data-slot="group-label"
112
- class="flex h-7 items-center px-2 text-sm-medium text-ink-gray-4"
112
+ class="flex h-7 items-center px-2 text-sm-medium leading-tighter text-ink-gray-4"
113
113
  >
114
114
  <component
115
115
  :is="ItemSlotRender"
@@ -362,7 +362,7 @@ defineExpose(exposed)
362
362
  >
363
363
  <SelectValue
364
364
  :placeholder="placeholder"
365
- class="max-w-full truncate opacity-0"
365
+ class="max-w-full truncate leading-tighter opacity-0"
366
366
  :class="{ 'text-ink-gray-4': showPlaceholderForSelected }"
367
367
  >
368
368
  <template v-if="selectedOption">
@@ -400,7 +400,7 @@ defineExpose(exposed)
400
400
  <div class="grid min-w-0 text-left truncate">
401
401
  <SelectValue
402
402
  :placeholder="placeholder"
403
- class="col-start-1 row-start-1 max-w-full truncate"
403
+ class="col-start-1 row-start-1 max-w-full truncate leading-tighter"
404
404
  :class="{ 'text-ink-gray-4': showPlaceholderForSelected }"
405
405
  >
406
406
  <template v-if="selectedOption">
@@ -540,7 +540,7 @@ defineExpose(exposed)
540
540
  name="item-label"
541
541
  v-bind="getItemSlotProps(internalOption.option)"
542
542
  >
543
- <div class="truncate">
543
+ <div class="truncate leading-tighter">
544
544
  {{ internalOption.option.label }}
545
545
  </div>
546
546
  <div
@@ -4,7 +4,7 @@
4
4
  <component
5
5
  :is="resolvedFor ? 'label' : 'div'"
6
6
  :for="resolvedFor"
7
- class="text-base-medium block text-ink-gray-8"
7
+ class="text-base-medium leading-tighter block text-ink-gray-8"
8
8
  :class="{ 'cursor-pointer': resolvedFor }"
9
9
  >
10
10
  {{ title }}
@@ -26,6 +26,13 @@
26
26
  type: 'string',
27
27
  default: '"3rem"'
28
28
  },
29
+ {
30
+ name: 'ariaLabel',
31
+ description: 'Accessible name of the `<nav>` landmark. Default: `\'Main\'`. Give a second\nsidebar on the same page its own name, and translate it in a localised\napp — the library ships no translation layer.',
32
+ required: false,
33
+ type: 'string',
34
+ default: '"Main"'
35
+ },
29
36
  {
30
37
  name: 'collapsed',
31
38
  description: 'v-model. Whether the sidebar is collapsed. Left unset, it collapses automatically below the `sm` breakpoint.',
@@ -66,7 +73,7 @@
66
73
  },
67
74
  {
68
75
  name: 'icon',
69
- description: 'Leading icon: a CSS class (e.g. `lucide-box`), plain text, or a component.\nIgnored when the `#prefix` slot is used.',
76
+ description: 'Leading icon: a `lucide-*` class, an emoji, or a component. Any other\nstring renders nothing and warns in dev — put initials in the `#prefix`\nslot instead. Ignored when the `#prefix` slot is used.',
70
77
  required: false,
71
78
  type: 'string | Component'
72
79
  },
@@ -12,6 +12,18 @@ There are no layout slots and no built-in scrolling in composition mode. Put a
12
12
  header as a direct child, wrap the middle list in your own `overflow-y-auto`
13
13
  container, and push a footer down with `mt-auto`.
14
14
 
15
+ ## Accessibility
16
+
17
+ `Sidebar` renders the `<nav>` landmark, named `Main` through `aria-label`. Set
18
+ `ariaLabel` to rename it — do that when a page holds a second sidebar, and to
19
+ translate the name in a localised app. It is the only landmark the family
20
+ emits: keep your own wrappers inside the slot as plain `div`s, or the page
21
+ reports two navigations where it has one.
22
+
23
+ `SidebarSection` gives its body `role="group"`, named by the section's `<h3>`
24
+ whenever `label` is set, collapsible or not. A collapsible section's trigger
25
+ keeps `aria-expanded` and `aria-controls` on the body.
26
+
15
27
  ## Collapse
16
28
 
17
29
  `Sidebar` owns collapse. Bind `v-model:collapsed` to control it, or leave it
@@ -29,8 +41,10 @@ A single row. It renders a container with a navigable main area and a **sibling*
29
41
  trailing zone, so an options menu in `#suffix` isn't nested inside the link
30
42
  (which anchors and buttons disallow).
31
43
 
32
- - `#prefix` — a leading icon or avatar (falls back to the `icon` prop: a lucide
33
- class, text, or a component).
44
+ - `#prefix` — a leading icon or avatar (falls back to the `icon` prop: a
45
+ `lucide-*` class, an emoji, or a component — the same values `Icon` takes).
46
+ Any other string renders nothing and warns in dev, so put initials or a text
47
+ glyph in `#prefix`.
34
48
  - default slot — the label region (falls back to the `label` prop). Put inline
35
49
  adornments like a lock icon here next to the text.
36
50
  - `#suffix` — the trailing zone: an unread count, an options `…` menu, etc.
@@ -41,6 +55,13 @@ Set `route` to render a router link or `href` for a native same-tab anchor;
41
55
  `route` against the current route. A click invokes `onClick` (bound from
42
56
  `@click`) in every case.
43
57
 
58
+ Attributes you write on `SidebarItem` split in two. `class`, `style` and event
59
+ listeners stay on the row container, so a row background or a drag-and-drop
60
+ ring covers the `#suffix` zone and a `@dragover` / `@drop` there still fires.
61
+ Every other attribute (`target`, `rel`, `id`, `title`, `data-*`, `aria-*`)
62
+ lands on the link or the button inside, the element it describes. Your
63
+ `aria-label` replaces the one derived from the label text.
64
+
44
65
  ## SidebarHeader
45
66
 
46
67
  The app-switcher / workspace-identity row. A fixed 48px region that lines up
@@ -51,6 +72,10 @@ or the title's first letter; `showLogo: false` drops the box entirely for a
51
72
  flush-left title. `menuItems` renders inside the trigger's
52
73
  dropdown — the same structured-options shape `Dropdown` itself takes.
53
74
 
75
+ Without `menuItems` the header is a plain static block: no button, no chevron,
76
+ no hover background, no tab stop. The logo and the title keep the same
77
+ position, so adding a menu later does not move them.
78
+
54
79
  ## SidebarSection
55
80
 
56
81
  A collapsible group. It owns only the label row and the collapse chrome —
@@ -1,7 +1,12 @@
1
1
  <template>
2
- <div
2
+ <!-- A <nav> landmark, so screen-reader users can jump to the app's
3
+ navigation. It is the only one the sidebar family emits: SidebarSection
4
+ bodies are `role="group"`, because nested landmarks add noise instead of
5
+ structure. Keep app-written wrappers inside the slot as plain divs. -->
6
+ <nav
3
7
  data-slot="sidebar"
4
8
  :data-state="shouldCollapse ? 'collapsed' : 'expanded'"
9
+ :aria-label="ariaLabel"
5
10
  class="flex h-full flex-shrink-0 flex-col overflow-x-hidden bg-surface-sidebar transition-[width] duration-300 ease-in-out"
6
11
  :style="{ width: shouldCollapse ? collapsedWidth : width }"
7
12
  >
@@ -9,7 +14,7 @@
9
14
  it from SidebarHeader / SidebarSection / SidebarLabel / SidebarItem
10
15
  and plain markup. -->
11
16
  <slot />
12
- </div>
17
+ </nav>
13
18
  </template>
14
19
 
15
20
  <script setup lang="ts">
@@ -21,6 +26,7 @@ const props = withDefaults(defineProps<SidebarProps>(), {
21
26
  collapsible: true,
22
27
  width: '15rem',
23
28
  collapsedWidth: '3rem',
29
+ ariaLabel: 'Main',
24
30
  })
25
31
 
26
32
  defineSlots<{
@@ -4,7 +4,55 @@
4
4
  the sidebar gutter (px-2) itself — so consumers drop it straight into
5
5
  <Sidebar> without a wrapping padding div. -->
6
6
  <div data-slot="sidebar-header" class="flex h-12 shrink-0 items-center px-1">
7
- <Dropdown :options="props.menuItems" match-trigger-width>
7
+ <!-- The logo + title block. Declared once (this renders nothing) and
8
+ reused by both branches below, so the interactive and the static
9
+ header cannot drift apart. -->
10
+ <DefineIdentity>
11
+ <div
12
+ v-if="showLogo"
13
+ class="size-7 shrink-0 rounded-[6px] overflow-hidden"
14
+ >
15
+ <slot name="prefix">
16
+ <img
17
+ v-if="typeof props.logo === 'string'"
18
+ :src="props.logo"
19
+ class="w-full h-full object-cover"
20
+ alt="Logo"
21
+ />
22
+ <div
23
+ v-else-if="!props.logo"
24
+ class="w-full h-full bg-surface-gray-4 flex items-center justify-center text-ink-gray-7"
25
+ >
26
+ {{ props.title.charAt(0).toUpperCase() }}
27
+ </div>
28
+ <component v-else :is="props.logo" class="w-full h-full" />
29
+ </slot>
30
+ </div>
31
+ <div
32
+ class="flex flex-1 flex-col text-left duration-300 ease-in-out truncate"
33
+ :class="
34
+ isCollapsed
35
+ ? 'ml-0 w-0 overflow-hidden opacity-0'
36
+ : showLogo
37
+ ? 'ml-2 w-auto opacity-100'
38
+ : 'ml-0 w-auto opacity-100'
39
+ "
40
+ >
41
+ <!-- The size sits on the line itself, not on a span inside a
42
+ leading-none div: a line box built from a strut at the
43
+ inherited size and text at another puts the baseline at a
44
+ different offset in an app whose layout sets a smaller base
45
+ size, and the header text sat a px lower there. -->
46
+ <div class="truncate text-base-medium leading-tighter text-ink-gray-8">
47
+ {{ props.title }}
48
+ </div>
49
+ <div class="mt-0.5 truncate text-sm leading-tighter text-ink-gray-6">
50
+ {{ props.subtitle }}
51
+ </div>
52
+ </div>
53
+ </DefineIdentity>
54
+
55
+ <Dropdown v-if="hasMenu" :options="props.menuItems" match-trigger-width>
8
56
  <template v-slot="{ open }">
9
57
  <button
10
58
  class="flex h-10 items-center rounded-4 px-1.5 duration-300 ease-in-out"
@@ -16,48 +64,7 @@
16
64
  : 'w-full hover:bg-surface-gray-3'
17
65
  "
18
66
  >
19
- <div
20
- v-if="showLogo"
21
- class="size-7 shrink-0 rounded-[6px] overflow-hidden"
22
- >
23
- <slot name="prefix">
24
- <img
25
- v-if="typeof props.logo === 'string'"
26
- :src="props.logo"
27
- class="w-full h-full object-cover"
28
- alt="Logo"
29
- />
30
- <div
31
- v-else-if="!props.logo"
32
- class="w-full h-full bg-surface-gray-4 flex items-center justify-center text-ink-gray-7"
33
- >
34
- {{ props.title.charAt(0).toUpperCase() }}
35
- </div>
36
- <component v-else :is="props.logo" class="w-full h-full" />
37
- </slot>
38
- </div>
39
- <div
40
- class="flex flex-1 flex-col text-left duration-300 ease-in-out truncate"
41
- :class="
42
- isCollapsed
43
- ? 'ml-0 w-0 overflow-hidden opacity-0'
44
- : showLogo
45
- ? 'ml-2 w-auto opacity-100'
46
- : 'ml-0 w-auto opacity-100'
47
- "
48
- >
49
- <!-- The size sits on the line itself, not on a span inside a
50
- leading-none div: a line box built from a strut at the
51
- inherited size and text at another puts the baseline at a
52
- different offset in an app whose layout sets a smaller base
53
- size, and the header text sat a px lower there. -->
54
- <div class="truncate text-base-medium text-ink-gray-8">
55
- {{ props.title }}
56
- </div>
57
- <div class="mt-0.5 truncate text-sm text-ink-gray-6">
58
- {{ props.subtitle }}
59
- </div>
60
- </div>
67
+ <ReuseIdentity />
61
68
  <div
62
69
  class="duration-300 ease-in-out"
63
70
  :class="
@@ -71,10 +78,24 @@
71
78
  </button>
72
79
  </template>
73
80
  </Dropdown>
81
+
82
+ <!--
83
+ No menu items, nothing to open: a plain div, with the trigger's box but
84
+ no chevron, no hover background and no tab stop. The identity block keeps
85
+ the same x/y either way, so adding menu items later does not move it.
86
+ -->
87
+ <div
88
+ v-else
89
+ class="flex h-10 items-center rounded-4 px-1.5 duration-300 ease-in-out"
90
+ :class="isCollapsed ? 'w-auto' : 'w-full'"
91
+ >
92
+ <ReuseIdentity />
93
+ </div>
74
94
  </div>
75
95
  </template>
76
96
 
77
97
  <script setup lang="ts">
98
+ import { createReusableTemplate } from '@vueuse/core'
78
99
  import { computed, inject } from 'vue'
79
100
  import Dropdown from '../Dropdown/Dropdown.vue'
80
101
  import { SidebarHeaderProps, sidebarCollapsedKey } from './types'
@@ -91,9 +112,12 @@ defineSlots<{
91
112
  prefix?: () => any
92
113
  }>()
93
114
 
115
+ const [DefineIdentity, ReuseIdentity] = createReusableTemplate()
116
+
94
117
  const isCollapsed = inject(
95
118
  sidebarCollapsedKey,
96
119
  computed(() => false),
97
120
  )
98
121
  const showLogo = computed(() => props.showLogo !== false)
122
+ const hasMenu = computed(() => Boolean(props.menuItems?.length))
99
123
  </script>
@@ -2,6 +2,7 @@
2
2
  <div
3
3
  data-slot="sidebar-item"
4
4
  :data-state="resolvedActive ? 'active' : 'inactive'"
5
+ v-bind="rootAttrs"
5
6
  class="group/sidebar-item flex h-7 items-center rounded-4 transition"
6
7
  :class="
7
8
  resolvedActive
@@ -16,13 +17,18 @@
16
17
  RouterLink component object or the 'a' string is safe; only a raw 'button'
17
18
  string collides with the globally-registered <Button>, so that stays a
18
19
  literal <button v-else>. The inner content is identical in both branches.
20
+
21
+ `controlAttrs` is spread before `linkAttrs` and before the component's own
22
+ attributes, so a caller cannot overwrite `href` / `to`, `aria-current` or
23
+ the classes. `aria-label` is the exception: `ariaLabel` returns the
24
+ caller's value when there is one.
19
25
  -->
20
26
  <component
21
27
  :is="linkComponent"
22
28
  v-if="route || href"
23
- v-bind="linkAttrs"
29
+ v-bind="{ ...controlAttrs, ...linkAttrs }"
24
30
  :accesskey="accessKey"
25
- :aria-label="tooltipText || undefined"
31
+ :aria-label="ariaLabel"
26
32
  :aria-current="resolvedActive ? 'page' : undefined"
27
33
  class="flex h-full min-w-0 flex-1 items-center rounded-4 pl-2 focus-visible:ring-0 focus-visible:focus-ring"
28
34
  @click="handleClick"
@@ -38,7 +44,7 @@
38
44
  square and back (the whole row is already the hit target). -->
39
45
  <span class="grid shrink-0 place-items-center">
40
46
  <slot name="prefix">
41
- <SidebarItemIcon :icon="icon" />
47
+ <Icon :icon="icon" class="size-4 text-ink-gray-6" />
42
48
  </slot>
43
49
  </span>
44
50
  </Tooltip>
@@ -52,7 +58,9 @@
52
58
  >
53
59
  <span ref="labelEl" class="flex min-w-0 items-center">
54
60
  <slot
55
- ><span class="truncate text-sm">{{ label }}</span></slot
61
+ ><span class="truncate text-sm leading-tighter">{{
62
+ label
63
+ }}</span></slot
56
64
  >
57
65
  </span>
58
66
  </span>
@@ -61,8 +69,9 @@
61
69
  <button
62
70
  v-else
63
71
  type="button"
72
+ v-bind="controlAttrs"
64
73
  :accesskey="accessKey"
65
- :aria-label="tooltipText || undefined"
74
+ :aria-label="ariaLabel"
66
75
  class="flex h-full text-left min-w-0 flex-1 items-center rounded-4 pl-2 focus-visible:ring-0 focus-visible:focus-ring"
67
76
  @click="handleClick"
68
77
  >
@@ -77,7 +86,7 @@
77
86
  square and back (the whole row is already the hit target). -->
78
87
  <span class="grid shrink-0 place-items-center">
79
88
  <slot name="prefix">
80
- <SidebarItemIcon :icon="icon" />
89
+ <Icon :icon="icon" class="size-4 text-ink-gray-6" />
81
90
  </slot>
82
91
  </span>
83
92
  </Tooltip>
@@ -91,7 +100,9 @@
91
100
  >
92
101
  <span ref="labelEl" class="flex min-w-0 items-center">
93
102
  <slot
94
- ><span class="truncate text-sm">{{ label }}</span></slot
103
+ ><span class="truncate text-sm leading-tighter">{{
104
+ label
105
+ }}</span></slot
95
106
  >
96
107
  </span>
97
108
  </span>
@@ -123,13 +134,41 @@ import {
123
134
  inject,
124
135
  onMounted,
125
136
  ref,
137
+ useAttrs,
126
138
  useTemplateRef,
127
139
  } from 'vue'
128
140
  import { RouterLink } from 'vue-router'
141
+ import Icon from '../Icon/Icon.vue'
129
142
  import Tooltip from '../Tooltip/Tooltip.vue'
130
- import SidebarItemIcon from './SidebarItemIcon.vue'
131
143
  import { SidebarItemProps, sidebarCollapsedKey } from './types'
132
144
 
145
+ // The root <div> is a wrapper, not the control: the link/button and the
146
+ // #suffix zone are siblings inside it. Without this, every fallthrough
147
+ // attribute stopped at the wrapper, so `target`, `rel`, `data-*` and `aria-*`
148
+ // never reached the element they describe.
149
+ defineOptions({ inheritAttrs: false })
150
+
151
+ const attrs = useAttrs()
152
+
153
+ // The split. `class`, `style` and event listeners belong to the whole row, so
154
+ // they stay on the wrapper: a row background or a drag-and-drop ring has to
155
+ // cover the #suffix zone, and so does a listener. Mail and Drive make a folder
156
+ // row a drop target with `@dragover` / `@drop` while the suffix holds the
157
+ // unread count and the options menu. On the inner control those events would
158
+ // miss that strip. Everything else describes the control and goes there.
159
+ // `onClick` is a declared prop, so it is never in `attrs`; it stays bound to
160
+ // the control through `handleClick`.
161
+ const isRootAttr = (key: string) =>
162
+ key === 'class' || key === 'style' || /^on[A-Z]/.test(key)
163
+
164
+ const rootAttrs = computed(() =>
165
+ Object.fromEntries(Object.entries(attrs).filter(([key]) => isRootAttr(key))),
166
+ )
167
+
168
+ const controlAttrs = computed(() =>
169
+ Object.fromEntries(Object.entries(attrs).filter(([key]) => !isRootAttr(key))),
170
+ )
171
+
133
172
  // `active` must default to `undefined`, not Vue's implicit boolean `false` —
134
173
  // "not passed" and "passed false" are different states here: absence falls
135
174
  // through to route inference.
@@ -162,6 +201,15 @@ onMounted(() => {
162
201
  })
163
202
  const tooltipText = computed(() => props.label || slotLabel.value)
164
203
 
204
+ // A caller's `aria-label` wins over the derived one. It is the more specific
205
+ // name, and it is the only way to name a row whose visible text is not its
206
+ // full name (a folder row that also announces its unread count). Every other
207
+ // attribute the component computes is bound after `controlAttrs`, so a caller
208
+ // cannot overwrite it.
209
+ const ariaLabel = computed(
210
+ () => (attrs['aria-label'] as string) || tooltipText.value || undefined,
211
+ )
212
+
165
213
  // Read the router/route off global properties instead of useRouter()/useRoute()
166
214
  // so this component works — without warnings or crashes — when mounted outside a
167
215
  // vue-router app (docs, tests, embedded use). vue-router installs $router/$route
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div data-slot="sidebar-label" class="relative flex h-7 items-center pl-2">
3
3
  <h3
4
- class="text-base text-ink-gray-5 transition-all duration-300 ease-in-out"
4
+ class="text-base leading-tighter text-ink-gray-5 transition-all duration-300 ease-in-out"
5
5
  :class="
6
6
  isCollapsed ? 'w-0 overflow-hidden opacity-0' : 'w-auto opacity-100'
7
7
  "
@@ -16,7 +16,8 @@
16
16
  <h3> inside it strips the heading from the accessibility tree.
17
17
  -->
18
18
  <h3
19
- class="h-4 text-sm text-ink-gray-5 transition-all duration-300 ease-in-out"
19
+ :id="headingId"
20
+ class="h-4 text-sm leading-tighter text-ink-gray-5 transition-all duration-300 ease-in-out"
20
21
  :class="
21
22
  isSidebarCollapsed
22
23
  ? 'w-0 overflow-hidden opacity-0'
@@ -25,7 +26,6 @@
25
26
  >
26
27
  <button
27
28
  v-if="collapsible"
28
- :id="triggerId"
29
29
  type="button"
30
30
  class="flex items-center gap-1 rounded-4 focus-visible:ring-0 focus-visible:focus-ring"
31
31
  :aria-expanded="!isSectionCollapsed"
@@ -64,15 +64,20 @@
64
64
  resolve to a real element at all times, including the instant
65
65
  aria-expanded flips to "false" — an id that only exists while
66
66
  expanded breaks the reference exactly when it's read.
67
+
68
+ role="group", not a <nav>: Sidebar is already the navigation landmark,
69
+ and a landmark inside a landmark buys nothing. The group takes its
70
+ name from the <h3>, so a non-collapsible section is named too.
67
71
  -->
68
- <nav
72
+ <div
69
73
  v-show="!isSectionCollapsed"
70
74
  :id="bodyId"
71
- :aria-labelledby="collapsible && label ? triggerId : undefined"
75
+ role="group"
76
+ :aria-labelledby="label ? headingId : undefined"
72
77
  class="flex flex-col gap-0.5"
73
78
  >
74
79
  <slot />
75
- </nav>
80
+ </div>
76
81
  </transition>
77
82
  </div>
78
83
  </template>
@@ -109,6 +114,8 @@ defineEmits<{
109
114
  /** v-model. Whether the section is collapsed. Bind it to own the state (start a section collapsed, persist the choice); left unbound the section manages it internally, starting expanded. */
110
115
  const isSectionCollapsed = defineModel<boolean>('collapsed', { default: false })
111
116
 
112
- const triggerId = useId()
117
+ // The heading carries the id, not the trigger button: a non-collapsible
118
+ // section has no button, and the group still has to be named.
119
+ const headingId = useId()
113
120
  const bodyId = useId()
114
121
  </script>
@@ -28,6 +28,13 @@ export type SidebarProps = {
28
28
 
29
29
  /** Collapsed width as a CSS length. */
30
30
  collapsedWidth?: string
31
+
32
+ /**
33
+ * Accessible name of the `<nav>` landmark. Default: `'Main'`. Give a second
34
+ * sidebar on the same page its own name, and translate it in a localised
35
+ * app — the library ships no translation layer.
36
+ */
37
+ ariaLabel?: string
31
38
  }
32
39
 
33
40
  export interface SidebarItemProps {
@@ -38,8 +45,9 @@ export interface SidebarItemProps {
38
45
  accessKey?: string
39
46
 
40
47
  /**
41
- * Leading icon: a CSS class (e.g. `lucide-box`), plain text, or a component.
42
- * Ignored when the `#prefix` slot is used.
48
+ * Leading icon: a `lucide-*` class, an emoji, or a component. Any other
49
+ * string renders nothing and warns in dev — put initials in the `#prefix`
50
+ * slot instead. Ignored when the `#prefix` slot is used.
43
51
  */
44
52
  icon?: string | Component
45
53
 
@@ -36,4 +36,8 @@ The `badge` count shows as a pill (`badgeStyle="count"`) or a dot
36
36
  a dot surfaces the real number in the tooltip. The badge pill teleports to
37
37
  `<body>` so an `overflow-hidden` scroll container can't clip it.
38
38
 
39
+ Attributes you write on `SidebarRailItem` land on the clickable cell — the
40
+ `<button>` or the link — not on the tooltip wrapper, and a `class` adds to the
41
+ cell's own classes.
42
+
39
43
  <!-- @include: ./SidebarRail.api.md -->
@@ -10,11 +10,16 @@
10
10
  literal v-else because a raw 'button' string can resolve to the globally
11
11
  registered <Button> in consumer apps. The inner content is identical in
12
12
  both branches.
13
+
14
+ $attrs is spread before the component's own attributes, so a caller
15
+ cannot overwrite data-slot / data-state, which tests and app CSS select
16
+ on. `class` is exempt: Vue merges class and style whatever the order, so
17
+ a caller's class adds to `cellClasses` instead of replacing it.
13
18
  -->
14
19
  <component
15
20
  :is="linkComponent"
16
21
  v-if="route || href"
17
- v-bind="linkAttrs"
22
+ v-bind="{ ...linkAttrs, ...$attrs }"
18
23
  data-slot="sidebar-rail-item"
19
24
  :data-variant="variant"
20
25
  :data-state="resolvedActive ? 'active' : 'inactive'"
@@ -36,6 +41,7 @@
36
41
  <button
37
42
  v-else
38
43
  type="button"
44
+ v-bind="$attrs"
39
45
  data-slot="sidebar-rail-item"
40
46
  :data-variant="variant"
41
47
  :data-state="resolvedActive ? 'active' : 'inactive'"
@@ -73,6 +79,11 @@ import Tooltip from '../Tooltip/Tooltip.vue'
73
79
  import SidebarRailItemBadge from './SidebarRailItemBadge.vue'
74
80
  import type { SidebarRailItemProps } from './types'
75
81
 
82
+ // The root is <Tooltip>, which drops what it receives. Without this, every
83
+ // fallthrough attribute a caller writes on <SidebarRailItem> is lost; the two
84
+ // branches below re-bind them onto the real clickable cell instead.
85
+ defineOptions({ inheritAttrs: false })
86
+
76
87
  const props = withDefaults(defineProps<SidebarRailItemProps>(), {
77
88
  variant: 'subtle',
78
89
  active: undefined,
@@ -189,7 +189,10 @@ const controlPosition = computed(() => props.controlPosition ?? 'end')
189
189
  const switchGroupClasses = computed(() => {
190
190
  const hasLabel = props.label || slots.label
191
191
  const hasDescription = props.description || slots.description
192
- if (!hasLabel && !hasDescription) return undefined
192
+ // A bare switch still gets `flex`: in a plain block the inline-flex control
193
+ // sits on a text line, and that line takes its height from the parent's
194
+ // line-height. In a 28px menu row that made the row 29px.
195
+ if (!hasLabel && !hasDescription) return 'flex'
193
196
 
194
197
  // `flex-row-reverse` / `justify-*` are inline-axis aware, so this flips
195
198
  // correctly under RTL without any physical left/right values.
@@ -252,7 +252,7 @@ const inputClasses = computed(() => {
252
252
  paddingClasses,
253
253
  variantClasses,
254
254
  textColor.value,
255
- 'transition-colors w-full dark:[color-scheme:dark]',
255
+ 'leading-tighter transition-colors w-full dark:[color-scheme:dark]',
256
256
  ]
257
257
  })
258
258
 
@@ -82,7 +82,7 @@
82
82
  :id="optionId(idx)"
83
83
  type="button"
84
84
  role="option"
85
- class="group flex h-7 w-full items-center rounded-4 px-2 text-left tabular-nums"
85
+ class="group flex h-7 w-full items-center rounded-4 px-2 text-left leading-tighter tabular-nums"
86
86
  :class="rowClass(opt, idx)"
87
87
  :aria-selected="canonicalValue === opt.value || undefined"
88
88
  @click="selectOption(opt.value)"
@@ -66,7 +66,9 @@
66
66
  <slot name="item-prefix" v-bind="itemSlotProps" />
67
67
 
68
68
  <slot name="item-label" v-bind="itemSlotProps">
69
- <span class="truncate text-base text-ink-gray-8">{{ label }}</span>
69
+ <span class="truncate text-base leading-tighter text-ink-gray-8">{{
70
+ label
71
+ }}</span>
70
72
  </slot>
71
73
 
72
74
  <span class="ml-auto flex items-center">
@@ -76,7 +76,9 @@ const rootClasses = computed(() => [
76
76
  'inline-flex box-border shrink-0 select-none items-center justify-center whitespace-nowrap outline-none transition-[background-color,color,box-shadow] duration-150 ease-out motion-reduce:transition-none',
77
77
  // Shipped v1 subtle typography (overrides Figma): 13px regular at both
78
78
  // sizes, never medium.
79
- props.variant === 'subtle' ? 'text-sm leading-[16.1px]' : 'text-base',
79
+ props.variant === 'subtle'
80
+ ? 'text-sm leading-[16.1px]'
81
+ : 'text-base leading-tighter',
80
82
  props.active ? 'text-ink-gray-8' : 'text-ink-gray-5',
81
83
  // Figma: md ghost/browser labels use text/base/medium (500, 0.015em);
82
84
  // md underline and all sm labels stay regular. The selected state
@@ -15,7 +15,7 @@ import {
15
15
  import type { MediaDimensions } from '#molecules/editor/extensions/shared/media-dimensions'
16
16
  import type {
17
17
  InsertMode,
18
- UploadedFile,
18
+ UploadedMedia,
19
19
  } from '#molecules/editor/extensions/shared/media-upload-types'
20
20
 
21
21
  /** Optional intrinsic dimensions (null when the probe failed). */
@@ -120,7 +120,7 @@ export function applyUploadSuccess(
120
120
  view: EditorView,
121
121
  nodeName: string,
122
122
  uploadId: string,
123
- uploaded: UploadedFile,
123
+ uploaded: UploadedMedia,
124
124
  ): void {
125
125
  const pos = findNodeByUploadId(view, nodeName, uploadId)
126
126
  if (pos === null) return
@@ -48,7 +48,7 @@ import type {
48
48
  MediaUploadEngine,
49
49
  MediaUploadOptions,
50
50
  MediaUploadRequestOptions,
51
- UploadedFile,
51
+ UploadedMedia,
52
52
  UploadResult,
53
53
  } from '#molecules/editor/extensions/shared/media-upload-types'
54
54
 
@@ -58,7 +58,7 @@ export type {
58
58
  MediaUploadOptions,
59
59
  MediaUploadRequestOptions,
60
60
  UploadFunction,
61
- UploadedFile,
61
+ UploadedMedia,
62
62
  UploadResult,
63
63
  } from '#molecules/editor/extensions/shared/media-upload-types'
64
64
 
@@ -97,7 +97,7 @@ export async function uploadFile(
97
97
  file: File,
98
98
  options: MediaUploadOptions,
99
99
  requestOptions?: MediaUploadRequestOptions,
100
- ): Promise<UploadedFile> {
100
+ ): Promise<UploadedMedia> {
101
101
  if (!options.uploadFunction) {
102
102
  throw new Error('uploadFunction option is not provided')
103
103
  }
@@ -46,7 +46,7 @@ declare module '@tiptap/core' {
46
46
  * The index signature keeps server fields the editor does not know about, so
47
47
  * an upload handler can return the raw File document.
48
48
  */
49
- export interface UploadedFile extends Partial<FrappeUploadedFile> {
49
+ export interface UploadedMedia extends Partial<FrappeUploadedFile> {
50
50
  file_url: string
51
51
  width?: number | null
52
52
  height?: number | null
@@ -72,7 +72,7 @@ export interface MediaUploadRequestOptions {
72
72
  export type UploadFunction = (
73
73
  file: File,
74
74
  options?: MediaUploadRequestOptions,
75
- ) => Promise<UploadedFile>
75
+ ) => Promise<UploadedMedia>
76
76
 
77
77
  /**
78
78
  * Effective upload options. Mirrors the legacy extension option shape: the only
@@ -88,7 +88,7 @@ export interface MediaUploadOptions {
88
88
  /** Outcome of a no-doc-mutation single-file upload. */
89
89
  export interface UploadResult {
90
90
  success: boolean
91
- file?: UploadedFile
91
+ file?: UploadedMedia
92
92
  error?: Error
93
93
  }
94
94
 
@@ -3,7 +3,7 @@
3
3
  * content-paste path stop each declaring their own `UploadResult` /
4
4
  * `MediaUploadOptions` interfaces.
5
5
  *
6
- * `UploadResult`, `MediaUploadOptions` and `UploadedFile` are OWNED by the image
6
+ * `UploadResult`, `MediaUploadOptions` and `UploadedMedia` are OWNED by the image
7
7
  * cluster's `media-upload-types.ts` (the engine's canonical type surface); they
8
8
  * are re-exported here so consumers have one stable import for the whole upload
9
9
  * value-type vocabulary. The only type added here is {@link ExistingImage},
@@ -13,7 +13,7 @@ export type {
13
13
  MediaUploadOptions,
14
14
  UploadFunction,
15
15
  UploadResult,
16
- UploadedFile,
16
+ UploadedMedia,
17
17
  } from '#molecules/editor/extensions/shared/media-upload-types'
18
18
 
19
19
  /**
@@ -8,7 +8,7 @@ export {
8
8
  useEditor,
9
9
  type Editor as TiptapEditor,
10
10
  type JSONContent,
11
- type UploadedFile,
11
+ type UploadedMedia,
12
12
  type UploadFunction,
13
13
  type MediaUploadProgress,
14
14
  } from './useEditor'
@@ -43,11 +43,11 @@ export type UseEditorOptions = {
43
43
 
44
44
  /**
45
45
  * The upload result contract, re-exported so `frappe-ui/editor` keeps one
46
- * `UploadedFile` name. The canonical declaration lives next to the media
46
+ * `UploadedMedia` name. The canonical declaration lives next to the media
47
47
  * upload engine.
48
48
  */
49
49
  export type {
50
- UploadedFile,
50
+ UploadedMedia,
51
51
  UploadFunction,
52
52
  MediaUploadProgress,
53
53
  MediaUploadRequestOptions,
@@ -7,7 +7,7 @@
7
7
  <div
8
8
  v-if="label || $slots.label"
9
9
  data-slot="list-group-header"
10
- class="flex h-8 items-center text-sm-medium text-ink-gray-5"
10
+ class="flex h-8 items-center text-sm-medium leading-tighter text-ink-gray-5"
11
11
  :class="sticky && 'sticky top-0 z-10 bg-surface-base'"
12
12
  >
13
13
  <slot name="label">{{ label }}</slot>
@@ -1,5 +1,9 @@
1
1
  <template>
2
- <div data-slot="list-header" role="row" class="h-8 text-sm text-ink-gray-5">
2
+ <div
3
+ data-slot="list-header"
4
+ role="row"
5
+ class="h-8 text-sm leading-tighter text-ink-gray-5"
6
+ >
3
7
  <slot />
4
8
  <Transition
5
9
  enter-active-class="transition-transform duration-75 ease-out"
@@ -7,7 +7,7 @@
7
7
  <span v-if="$slots.prefix" class="shrink-0">
8
8
  <slot name="prefix" />
9
9
  </span>
10
- <span class="truncate"><slot /></span>
10
+ <span class="truncate leading-tighter"><slot /></span>
11
11
  <span v-if="$slots.suffix" class="shrink-0">
12
12
  <slot name="suffix" />
13
13
  </span>
@@ -16,7 +16,7 @@
16
16
  <button
17
17
  ref="button"
18
18
  type="button"
19
- class="group inline-flex h-7 min-w-0 items-center gap-1 rounded-4 text-sm-medium text-ink-gray-5 transition-colors"
19
+ class="group inline-flex h-7 min-w-0 items-center gap-1 rounded-4 text-sm-medium leading-tighter text-ink-gray-5 transition-colors"
20
20
  @click="emit('click', $event)"
21
21
  >
22
22
  <!-- End-aligned: the glyph leads so the label stays flush with the
@@ -35,7 +35,7 @@
35
35
  <span v-if="$slots.prefix" class="shrink-0">
36
36
  <slot name="prefix" :direction="direction ?? null" />
37
37
  </span>
38
- <span class="truncate"><slot /></span>
38
+ <span class="truncate leading-tighter"><slot /></span>
39
39
  <!-- A built-in arrow derived from `direction` by default, so the common
40
40
  case needs no glyph wiring; supply #sort-indicator to override. The cell
41
41
  owns the reveal: an inactive column's glyph shows on hover. -->
@@ -83,7 +83,7 @@ export interface UploadState {
83
83
  result: UploadedFile | null
84
84
  }
85
85
 
86
- export interface UploadedFile {
86
+ export type UploadedFile = {
87
87
  file_name: string
88
88
  file_size: number
89
89
  file_url: string
@@ -239,6 +239,13 @@ export default plugin(
239
239
  fontSize: buildFontSize(),
240
240
  screens: screenTokens,
241
241
  extend: {
242
+ // `leading-tighter` pins text to 1.15. It is the opt-out for
243
+ // single-line chrome whose height must not move: buttons, badges,
244
+ // labels, table headers. It sits between Tailwind's `leading-none` (1)
245
+ // and `leading-tight` (1.25). Tailwind's `leading-tight` does not change.
246
+ lineHeight: {
247
+ tighter: '1.15',
248
+ },
242
249
  textColor: {
243
250
  ink: semanticColors.ink,
244
251
  },
@@ -11,7 +11,8 @@
11
11
  * ships nothing, it changed six times in four months, and it does not say
12
12
  * what frappe-ui actually uses. The committed record is the output above, and
13
13
  * this file is where frappe-ui deliberately overrules the export —
14
- * RADIUS_OVERRIDE, FONT_WEIGHT_MAP, DROPPED_SIZES, DROPPED_CUSTOM_ELEVATIONS,
14
+ * RADIUS_OVERRIDE, FONT_WEIGHT_MAP, TEXT_LINE_HEIGHT_OVERRIDE, DROPPED_SIZES,
15
+ * DROPPED_CUSTOM_ELEVATIONS,
15
16
  * the hex→oklch conversion and the shadow layer reversal. Those are code-side
16
17
  * opinions, which is why they live in a tested Node script rather than in a
17
18
  * Figma plugin that no CI can run.
@@ -379,6 +380,14 @@ const pctToRatio = (v) => String(round(parseFloat(v) / 100, 4)) // "115%" -> "1.
379
380
  // letter-spacing % of font size === em. `paragraph/5xl` exports as "0.5px" by an
380
381
  // exporter bug (should be "0.5%"); parseFloat keeps the number and we treat it
381
382
  // as a percent regardless of unit, which yields the intended value either way.
383
+ // The UI text styles, sizes 2xs to 4xl, export at 115%, which cramps
384
+ // any text that wraps. frappe-ui ships them at 135%. Single-line chrome opts
385
+ // back to 1.15 with `leading-tighter` (tailwind/plugin.js). The paragraph and
386
+ // display styles export other values and are not touched. Keep the override
387
+ // until the Figma text styles move to 135%.
388
+ const TEXT_LINE_HEIGHT_OVERRIDE = { '115%': '135%' }
389
+ export const textLineHeight = (v) =>
390
+ pctToRatio(TEXT_LINE_HEIGHT_OVERRIDE[v] ?? v)
382
391
  const lsToEm = (v) => `${round(parseFloat(v) / 100, 5)}em` // "2%" -> "0.02em"
383
392
 
384
393
  function round(n, places) {
@@ -448,7 +457,7 @@ function buildTypography() {
448
457
  fontSize[key] = [
449
458
  v.fontSize,
450
459
  {
451
- lineHeight: pctToRatio(v.lineHeight),
460
+ lineHeight: textLineHeight(v.lineHeight),
452
461
  letterSpacing: lsToEm(v.letterSpacing),
453
462
  fontWeight: FONT_WEIGHT_MAP.regular,
454
463
  },
@@ -16,7 +16,7 @@ export default {
16
16
  "2xs": [
17
17
  "11px",
18
18
  {
19
- "lineHeight": "1.15",
19
+ "lineHeight": "1.35",
20
20
  "letterSpacing": "0.01em",
21
21
  "fontWeight": 420
22
22
  }
@@ -24,7 +24,7 @@ export default {
24
24
  "xs": [
25
25
  "12px",
26
26
  {
27
- "lineHeight": "1.15",
27
+ "lineHeight": "1.35",
28
28
  "letterSpacing": "0.02em",
29
29
  "fontWeight": 420
30
30
  }
@@ -32,7 +32,7 @@ export default {
32
32
  "sm": [
33
33
  "13px",
34
34
  {
35
- "lineHeight": "1.15",
35
+ "lineHeight": "1.35",
36
36
  "letterSpacing": "0.02em",
37
37
  "fontWeight": 420
38
38
  }
@@ -40,7 +40,7 @@ export default {
40
40
  "base": [
41
41
  "14px",
42
42
  {
43
- "lineHeight": "1.15",
43
+ "lineHeight": "1.35",
44
44
  "letterSpacing": "0.02em",
45
45
  "fontWeight": 420
46
46
  }
@@ -48,7 +48,7 @@ export default {
48
48
  "md": [
49
49
  "15px",
50
50
  {
51
- "lineHeight": "1.15",
51
+ "lineHeight": "1.35",
52
52
  "letterSpacing": "0.02em",
53
53
  "fontWeight": 420
54
54
  }
@@ -56,7 +56,7 @@ export default {
56
56
  "lg": [
57
57
  "16px",
58
58
  {
59
- "lineHeight": "1.15",
59
+ "lineHeight": "1.35",
60
60
  "letterSpacing": "0.02em",
61
61
  "fontWeight": 420
62
62
  }
@@ -64,7 +64,7 @@ export default {
64
64
  "xl": [
65
65
  "17px",
66
66
  {
67
- "lineHeight": "1.15",
67
+ "lineHeight": "1.35",
68
68
  "letterSpacing": "0.02em",
69
69
  "fontWeight": 420
70
70
  }
@@ -72,7 +72,7 @@ export default {
72
72
  "2xl": [
73
73
  "18px",
74
74
  {
75
- "lineHeight": "1.15",
75
+ "lineHeight": "1.35",
76
76
  "letterSpacing": "0.01em",
77
77
  "fontWeight": 420
78
78
  }
@@ -80,7 +80,7 @@ export default {
80
80
  "3xl": [
81
81
  "20px",
82
82
  {
83
- "lineHeight": "1.15",
83
+ "lineHeight": "1.35",
84
84
  "letterSpacing": "0.005em",
85
85
  "fontWeight": 420
86
86
  }
@@ -88,7 +88,7 @@ export default {
88
88
  "4xl": [
89
89
  "24px",
90
90
  {
91
- "lineHeight": "1.15",
91
+ "lineHeight": "1.35",
92
92
  "letterSpacing": "0.005em",
93
93
  "fontWeight": 420
94
94
  }
@@ -1,25 +0,0 @@
1
- <template>
2
- <!-- Icon fallback used when no #prefix slot is supplied: a lucide CSS class,
3
- plain text, or a component. -->
4
- <span
5
- v-if="isLucideClass"
6
- class="size-4 text-ink-gray-6"
7
- :class="icon as string"
8
- aria-hidden="true"
9
- />
10
- <span v-else-if="typeof icon === 'string'" class="size-4 text-ink-gray-6">
11
- {{ icon }}
12
- </span>
13
- <component v-else-if="icon" :is="icon" class="size-4 text-ink-gray-6" />
14
- </template>
15
-
16
- <script setup lang="ts">
17
- import { computed } from 'vue'
18
- import type { Component } from 'vue'
19
-
20
- const props = defineProps<{ icon?: string | Component }>()
21
-
22
- const isLucideClass = computed(
23
- () => typeof props.icon === 'string' && props.icon.startsWith('lucide-'),
24
- )
25
- </script>