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.
- package/experimental/CommandPalette/CommandPaletteFooter.vue +1 -1
- package/experimental/CommandPalette/CommandPaletteGroup.vue +1 -1
- package/experimental/CommandPalette/CommandPaletteInput.vue +1 -1
- package/experimental/CommandPalette/CommandPaletteItem.vue +1 -1
- package/package.json +1 -1
- package/src/components/Badge/Badge.vue +1 -1
- package/src/components/Breadcrumbs/Breadcrumbs.vue +6 -4
- package/src/components/Button/Button.vue +6 -1
- package/src/components/Combobox/Combobox.vue +1 -1
- package/src/components/Combobox/ComboboxResults.vue +2 -2
- package/src/components/DatePicker/CalendarPanel.vue +2 -2
- package/src/components/FormLabel/FormLabel.vue +5 -1
- package/src/components/InputLabeling/InputLabel.vue +1 -1
- package/src/components/ItemListRow/ItemListRow.vue +1 -1
- package/src/components/KeyboardShortcut/KeyboardShortcut.md +5 -5
- package/src/components/Menu/MenuItemContent.vue +1 -1
- package/src/components/Menu/utils.ts +2 -1
- package/src/components/MobileNav/MobileNavItem.vue +2 -2
- package/src/components/MultiSelect/MultiSelectResults.vue +1 -1
- package/src/components/Select/Select.vue +3 -3
- package/src/components/SettingsDialog/SettingsRow.vue +1 -1
- package/src/components/Sidebar/Sidebar.api.md +8 -1
- package/src/components/Sidebar/Sidebar.md +27 -2
- package/src/components/Sidebar/Sidebar.vue +8 -2
- package/src/components/Sidebar/SidebarHeader.vue +67 -43
- package/src/components/Sidebar/SidebarItem.vue +56 -8
- package/src/components/Sidebar/SidebarLabel.vue +1 -1
- package/src/components/Sidebar/SidebarSection.vue +13 -6
- package/src/components/Sidebar/types.ts +10 -2
- package/src/components/SidebarRail/SidebarRail.md +4 -0
- package/src/components/SidebarRail/SidebarRailItem.vue +12 -1
- package/src/components/Switch/Switch.vue +4 -1
- package/src/components/TextInput/TextInput.vue +1 -1
- package/src/components/TimePicker/TimePicker.vue +1 -1
- package/src/components/Tree/TreeItem.vue +3 -1
- package/src/components/shared/tabs/Pill.vue +3 -1
- package/src/molecules/editor/extensions/shared/media-node-ops.ts +2 -2
- package/src/molecules/editor/extensions/shared/media-upload-engine.ts +3 -3
- package/src/molecules/editor/extensions/shared/media-upload-types.ts +3 -3
- package/src/molecules/editor/extensions/shared/upload-types.ts +2 -2
- package/src/molecules/editor/index.ts +1 -1
- package/src/molecules/editor/useEditor.ts +2 -2
- package/src/molecules/list/ListGroup.vue +1 -1
- package/src/molecules/list/ListHeader.vue +5 -1
- package/src/molecules/list/ListHeaderCell.vue +1 -1
- package/src/molecules/list/ListHeaderCellSort.vue +2 -2
- package/src/utils/useFileUpload.ts +1 -1
- package/tailwind/plugin.js +7 -0
- package/tailwind/tokens/build.js +11 -2
- package/tailwind/tokens/typography.js +10 -10
- 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>
|
|
@@ -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
|
@@ -13,13 +13,15 @@
|
|
|
13
13
|
</span>
|
|
14
14
|
</template>
|
|
15
15
|
|
|
16
|
-
<div
|
|
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
|
-
{
|
|
324
|
+
{
|
|
325
|
+
class: [
|
|
326
|
+
'truncate leading-tighter',
|
|
327
|
+
{ 'sr-only': isIconButton.value },
|
|
328
|
+
],
|
|
329
|
+
},
|
|
325
330
|
slots.default?.() ?? props.label,
|
|
326
331
|
)
|
|
327
332
|
}
|
|
@@ -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'
|
|
@@ -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'
|
|
@@ -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
|
|
41
|
-
|
|
42
|
-
`
|
|
43
|
-
|
|
44
|
-
|
|
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:
|
|
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
|
|
@@ -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
|
|
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
|
|
33
|
-
class,
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
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
|
-
<
|
|
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="
|
|
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
|
-
<
|
|
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">{{
|
|
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="
|
|
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
|
-
<
|
|
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">{{
|
|
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
|
-
|
|
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
|
-
<
|
|
72
|
+
<div
|
|
69
73
|
v-show="!isSectionCollapsed"
|
|
70
74
|
:id="bodyId"
|
|
71
|
-
|
|
75
|
+
role="group"
|
|
76
|
+
:aria-labelledby="label ? headingId : undefined"
|
|
72
77
|
class="flex flex-col gap-0.5"
|
|
73
78
|
>
|
|
74
79
|
<slot />
|
|
75
|
-
</
|
|
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
|
-
|
|
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
|
|
42
|
-
*
|
|
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
|
-
|
|
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.
|
|
@@ -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">{{
|
|
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'
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
|
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<
|
|
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?:
|
|
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 `
|
|
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
|
-
|
|
16
|
+
UploadedMedia,
|
|
17
17
|
} from '#molecules/editor/extensions/shared/media-upload-types'
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -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
|
-
* `
|
|
46
|
+
* `UploadedMedia` name. The canonical declaration lives next to the media
|
|
47
47
|
* upload engine.
|
|
48
48
|
*/
|
|
49
49
|
export type {
|
|
50
|
-
|
|
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
|
|
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. -->
|
package/tailwind/plugin.js
CHANGED
|
@@ -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
|
},
|
package/tailwind/tokens/build.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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>
|